diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 14b4ed166..7161a7946 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -272,7 +272,7 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
bldObj.setJustify(self._build.getBool("format.justifyText"))
bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
- bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
+ bldObj.setDialogHighlight(self._build.getBool("format.showDialogue"))
bldObj.setFirstLineIndent(
self._build.getBool("format.firstLineIndent"),
self._build.getFloat("format.firstIndentWidth"),
diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py
index 8d23728a2..6723cd4aa 100644
--- a/novelwriter/dialogs/preferences.py
+++ b/novelwriter/dialogs/preferences.py
@@ -34,7 +34,7 @@
)
from novelwriter import CONFIG, SHARED
-from novelwriter.common import describeFont, uniqueCompact
+from novelwriter.common import compact, describeFont, uniqueCompact
from novelwriter.constants import nwUnicode
from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm
@@ -133,7 +133,7 @@ def buildForm(self) -> None:
"""Build the settings form."""
section = 0
iSz = SHARED.theme.baseIconSize
- boxFixed = 5*SHARED.theme.textNWidth
+ boxFixed = 6*SHARED.theme.textNWidth
minWidth = CONFIG.pxInt(200)
fontWidth = CONFIG.pxInt(162)
@@ -568,13 +568,13 @@ def buildForm(self) -> None:
)
self.dialogLine = QLineEdit(self)
- self.dialogLine.setMaxLength(1)
+ self.dialogLine.setMaxLength(4)
self.dialogLine.setFixedWidth(boxFixed)
self.dialogLine.setAlignment(QtAlignCenter)
self.dialogLine.setText(CONFIG.dialogLine)
self.mainForm.addRow(
- self.tr("Dialogue line symbol"), self.dialogLine,
- self.tr("Lines starting with this symbol are dialogue.")
+ self.tr("Dialogue line symbols"), self.dialogLine,
+ self.tr("Lines starting with these symbols are always dialogue.")
)
self.narratorBreak = QLineEdit(self)
@@ -583,8 +583,8 @@ def buildForm(self) -> None:
self.narratorBreak.setAlignment(QtAlignCenter)
self.narratorBreak.setText(CONFIG.narratorBreak)
self.mainForm.addRow(
- self.tr("Dialogue narrator break symbol"), self.narratorBreak,
- self.tr("Symbol to indicate injected narrator break.")
+ self.tr("Alternating dialogue/narration symbol"), self.narratorBreak,
+ self.tr("Alternates dialogue highlighting within a paragraph.")
)
self.highlightEmph = NSwitch(self)
@@ -953,9 +953,9 @@ def _doSave(self) -> None:
dialogueStyle = self.dialogStyle.currentData()
allowOpenDial = self.allowOpenDial.isChecked()
narratorBreak = self.narratorBreak.text().strip()
- dialogueLine = self.dialogLine.text().strip()
- altDialogOpen = self.altDialogOpen.text()
- altDialogClose = self.altDialogClose.text()
+ dialogueLine = uniqueCompact(self.dialogLine.text())
+ altDialogOpen = compact(self.altDialogOpen.text())
+ altDialogClose = compact(self.altDialogClose.text())
highlightEmph = self.highlightEmph.isChecked()
showMultiSpaces = self.showMultiSpaces.isChecked()
diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py
index 759185c78..e63fb1500 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -46,7 +46,7 @@
from novelwriter.formats.shared import (
BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt
)
-from novelwriter.text.patterns import REGEX_PATTERNS
+from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__)
@@ -199,9 +199,10 @@ def __init__(self, project: NWProject) -> None:
}
# Dialogue
- self._rxDialogue: list[tuple[re.Pattern, tuple[int, str], tuple[int, str]]] = []
- self._dialogLine = ""
- self._narratorBreak = ""
+ self._hlightDialog = False
+ self._rxAltDialog = REGEX_PATTERNS.altDialogStyle
+ self._dialogParser = DialogParser()
+ self._dialogParser.initParser()
return
@@ -335,22 +336,9 @@ def setJustify(self, state: bool) -> None:
self._doJustify = state
return
- def setDialogueHighlight(self, state: bool) -> None:
+ def setDialogHighlight(self, state: bool) -> None:
"""Enable or disable dialogue highlighting."""
- self._rxDialogue = []
- if state:
- if CONFIG.dialogStyle > 0:
- self._rxDialogue.append((
- REGEX_PATTERNS.dialogStyle,
- (TextFmt.COL_B, "dialog"), (TextFmt.COL_E, ""),
- ))
- if CONFIG.altDialogOpen and CONFIG.altDialogClose:
- self._rxDialogue.append((
- REGEX_PATTERNS.altDialogStyle,
- (TextFmt.COL_B, "altdialog"), (TextFmt.COL_E, ""),
- ))
- self._dialogLine = CONFIG.dialogLine.strip()[:1]
- self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
+ self._hlightDialog = state
return
def setTitleMargins(self, upper: float, lower: float) -> None:
@@ -1132,10 +1120,8 @@ def _extractFormats(
# Match URLs
for res in REGEX_PATTERNS.url.finditer(text):
- s = res.start(0)
- e = res.end(0)
- temp.append((s, s, TextFmt.HRF_B, res.group(0)))
- temp.append((e, e, TextFmt.HRF_E, ""))
+ temp.append((res.start(0), 0, TextFmt.HRF_B, res.group(0)))
+ temp.append((res.end(0), 0, TextFmt.HRF_E, ""))
# Match Shortcodes
for res in REGEX_PATTERNS.shortcodePlain.finditer(text):
@@ -1156,24 +1142,15 @@ def _extractFormats(
))
# Match Dialogue
- if self._rxDialogue and hDialog:
- for regEx, (fmtB, clsB), (fmtE, clsE) in self._rxDialogue:
- for res in regEx.finditer(text):
- temp.append((res.start(0), 0, fmtB, clsB))
- temp.append((res.end(0), 0, fmtE, clsE))
-
- if self._dialogLine and text.startswith(self._dialogLine):
- if self._narratorBreak:
- pos = 0
- for num, bit in enumerate(text[1:].split(self._narratorBreak), 1):
- length = len(bit) + 1
- if num%2:
- temp.append((pos, 0, TextFmt.COL_B, "dialog"))
- temp.append((pos + length, 0, TextFmt.COL_E, ""))
- pos += length
- else:
- temp.append((0, 0, TextFmt.COL_B, "dialog"))
- temp.append((len(text), 0, TextFmt.COL_E, ""))
+ if self._hlightDialog and hDialog:
+ if self._dialogParser.enabled:
+ for pos, end in self._dialogParser(text):
+ temp.append((pos, 0, TextFmt.COL_B, "dialog"))
+ temp.append((end, 0, TextFmt.COL_E, ""))
+ if self._rxAltDialog:
+ for res in self._rxAltDialog.finditer(text):
+ temp.append((res.start(0), 0, TextFmt.COL_B, "altdialog"))
+ temp.append((res.end(0), 0, TextFmt.COL_E, ""))
# Post-process text and format
result = text
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index a4f76529b..feaa912ce 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -36,6 +36,7 @@
from enum import Enum
from time import time
+from typing import NamedTuple
from PyQt5.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
@@ -83,6 +84,21 @@ class _SelectAction(Enum):
MOVE_AFTER = 3
+class AutoReplaceConfig(NamedTuple):
+
+ typPadChar: str
+ typSQuoteO: str
+ typSQuoteC: str
+ typDQuoteO: str
+ typDQuoteC: str
+ typRepDQuote: bool
+ typRepSQuote: bool
+ typRepDash: bool
+ typRepDots: bool
+ typPadBefore: str
+ typPadAfter: str
+
+
class GuiDocEditor(QPlainTextEdit):
"""Gui Widget: Main Document Editor"""
@@ -128,17 +144,19 @@ def __init__(self, parent: QWidget) -> None:
self._doReplace = False # Switch to temporarily disable auto-replace
# Typography Cache
- self._typPadChar = " "
- self._typDQuoteO = '"'
- self._typDQuoteC = '"'
- self._typSQuoteO = "'"
- self._typSQuoteC = "'"
- self._typRepDQuote = False
- self._typRepSQuote = False
- self._typRepDash = False
- self._typRepDots = False
- self._typPadBefore = ""
- self._typPadAfter = ""
+ self._typConf = AutoReplaceConfig(
+ typPadChar=" ",
+ typSQuoteO="'",
+ typSQuoteC="'",
+ typDQuoteO='"',
+ typDQuoteC='"',
+ typRepSQuote=False,
+ typRepDQuote=False,
+ typRepDash=False,
+ typRepDots=False,
+ typPadBefore="",
+ typPadAfter="",
+ )
# Completer
self._completer = MetaCompleter(self)
@@ -310,21 +328,19 @@ def initEditor(self) -> None:
created, and when the user changes the main editor preferences.
"""
# Typography
- if CONFIG.fmtPadThin:
- self._typPadChar = nwUnicode.U_THNBSP
- else:
- self._typPadChar = nwUnicode.U_NBSP
-
- self._typSQuoteO = CONFIG.fmtSQuoteOpen
- self._typSQuoteC = CONFIG.fmtSQuoteClose
- self._typDQuoteO = CONFIG.fmtDQuoteOpen
- self._typDQuoteC = CONFIG.fmtDQuoteClose
- self._typRepDQuote = CONFIG.doReplaceDQuote
- self._typRepSQuote = CONFIG.doReplaceSQuote
- self._typRepDash = CONFIG.doReplaceDash
- self._typRepDots = CONFIG.doReplaceDots
- self._typPadBefore = CONFIG.fmtPadBefore
- self._typPadAfter = CONFIG.fmtPadAfter
+ self._typConf = AutoReplaceConfig(
+ typPadChar=nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP,
+ typSQuoteO=CONFIG.fmtSQuoteOpen,
+ typSQuoteC=CONFIG.fmtSQuoteClose,
+ typDQuoteO=CONFIG.fmtDQuoteOpen,
+ typDQuoteC=CONFIG.fmtDQuoteClose,
+ typRepSQuote=CONFIG.doReplaceSQuote,
+ typRepDQuote=CONFIG.doReplaceDQuote,
+ typRepDash=CONFIG.doReplaceDash,
+ typRepDots=CONFIG.doReplaceDots,
+ typPadBefore=CONFIG.fmtPadBefore,
+ typPadAfter=CONFIG.fmtPadAfter,
+ )
# Reload spell check and dictionaries
SHARED.updateSpellCheckLanguage()
@@ -737,6 +753,7 @@ def docAction(self, action: nwDocAction) -> bool:
logger.debug("Requesting action: %s", action.name)
+ tConf = self._typConf
self._allowAutoReplace(False)
if action == nwDocAction.UNDO:
self.undo()
@@ -755,9 +772,9 @@ def docAction(self, action: nwDocAction) -> bool:
elif action == nwDocAction.MD_STRIKE:
self._toggleFormat(2, "~")
elif action == nwDocAction.S_QUOTE:
- self._wrapSelection(self._typSQuoteO, self._typSQuoteC)
+ self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.D_QUOTE:
- self._wrapSelection(self._typDQuoteO, self._typDQuoteC)
+ self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.SEL_ALL:
self._makeSelection(QTextCursor.SelectionType.Document)
elif action == nwDocAction.SEL_PARA:
@@ -783,9 +800,9 @@ def docAction(self, action: nwDocAction) -> bool:
elif action == nwDocAction.BLOCK_HSC:
self._formatBlock(nwDocAction.BLOCK_HSC)
elif action == nwDocAction.REPL_SNG:
- self._replaceQuotes("'", self._typSQuoteO, self._typSQuoteC)
+ self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC)
elif action == nwDocAction.REPL_DBL:
- self._replaceQuotes("\"", self._typDQuoteO, self._typDQuoteC)
+ self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC)
elif action == nwDocAction.RM_BREAKS:
self._removeInParLineBreaks()
elif action == nwDocAction.ALIGN_L:
@@ -857,13 +874,13 @@ def insertText(self, insert: str | nwDocInsert) -> None:
text = insert
elif isinstance(insert, nwDocInsert):
if insert == nwDocInsert.QUOTE_LS:
- text = self._typSQuoteO
+ text = self._typConf.typSQuoteO
elif insert == nwDocInsert.QUOTE_RS:
- text = self._typSQuoteC
+ text = self._typConf.typSQuoteC
elif insert == nwDocInsert.QUOTE_LD:
- text = self._typDQuoteO
+ text = self._typConf.typDQuoteO
elif insert == nwDocInsert.QUOTE_RD:
- text = self._typDQuoteC
+ text = self._typConf.typDQuoteC
elif insert == nwDocInsert.SYNOPSIS:
text = "%Synopsis: "
block = True
@@ -1978,88 +1995,98 @@ def _docAutoReplace(self, text: str) -> None:
if tLen < 1 or tPos-1 > tLen:
return
- tOne = text[tPos-1:tPos]
- tTwo = text[tPos-2:tPos]
- tThree = text[tPos-3:tPos]
+ t1 = text[tPos-1:tPos]
+ t2 = text[tPos-2:tPos]
+ t3 = text[tPos-3:tPos]
+ t4 = text[tPos-4:tPos]
- if not tOne:
+ if not t1:
return
- nDelete = 0
- tInsert = tOne
+ delete = 0
+ insert = t1
+ tConf = self._typConf
- if self._typRepDQuote and tTwo[:1].isspace() and tTwo.endswith('"'):
- nDelete = 1
- tInsert = self._typDQuoteO
+ if tConf.typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
+ delete = 1
+ insert = tConf.typDQuoteO
- elif self._typRepDQuote and tOne == '"':
- nDelete = 1
+ elif tConf.typRepDQuote and t1 == '"':
+ delete = 1
if tPos == 1:
- tInsert = self._typDQuoteO
- elif tPos == 2 and tTwo == '>"':
- tInsert = self._typDQuoteO
- elif tPos == 3 and tThree == '>>"':
- tInsert = self._typDQuoteO
+ insert = tConf.typDQuoteO
+ elif tPos == 2 and t2 == '>"':
+ insert = tConf.typDQuoteO
+ elif tPos == 3 and t3 == '>>"':
+ insert = tConf.typDQuoteO
else:
- tInsert = self._typDQuoteC
+ insert = tConf.typDQuoteC
- elif self._typRepSQuote and tTwo[:1].isspace() and tTwo.endswith("'"):
- nDelete = 1
- tInsert = self._typSQuoteO
+ elif tConf.typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
+ delete = 1
+ insert = tConf.typSQuoteO
- elif self._typRepSQuote and tOne == "'":
- nDelete = 1
+ elif tConf.typRepSQuote and t1 == "'":
+ delete = 1
if tPos == 1:
- tInsert = self._typSQuoteO
- elif tPos == 2 and tTwo == ">'":
- tInsert = self._typSQuoteO
- elif tPos == 3 and tThree == ">>'":
- tInsert = self._typSQuoteO
+ insert = tConf.typSQuoteO
+ elif tPos == 2 and t2 == ">'":
+ insert = tConf.typSQuoteO
+ elif tPos == 3 and t3 == ">>'":
+ insert = tConf.typSQuoteO
else:
- tInsert = self._typSQuoteC
+ insert = tConf.typSQuoteC
+
+ elif tConf.typRepDash and t4 == "----":
+ delete = 4
+ insert = nwUnicode.U_HBAR
+
+ elif tConf.typRepDash and t3 == "---":
+ delete = 3
+ insert = nwUnicode.U_EMDASH
- elif self._typRepDash and tThree == "---":
- nDelete = 3
- tInsert = nwUnicode.U_EMDASH
+ elif tConf.typRepDash and t2 == "--":
+ delete = 2
+ insert = nwUnicode.U_ENDASH
- elif self._typRepDash and tTwo == "--":
- nDelete = 2
- tInsert = nwUnicode.U_ENDASH
+ elif tConf.typRepDash and t2 == nwUnicode.U_ENDASH + "-":
+ delete = 2
+ insert = nwUnicode.U_EMDASH
- elif self._typRepDash and tTwo == nwUnicode.U_ENDASH + "-":
- nDelete = 2
- tInsert = nwUnicode.U_EMDASH
+ elif tConf.typRepDash and t2 == nwUnicode.U_EMDASH + "-":
+ delete = 2
+ insert = nwUnicode.U_HBAR
- elif self._typRepDots and tThree == "...":
- nDelete = 3
- tInsert = nwUnicode.U_HELLIP
+ elif tConf.typRepDots and t3 == "...":
+ delete = 3
+ insert = nwUnicode.U_HELLIP
- elif tOne == nwUnicode.U_LSEP:
+ elif t1 == nwUnicode.U_LSEP:
# This resolves issue #1150
- nDelete = 1
- tInsert = nwUnicode.U_PSEP
-
- tCheck = tInsert
- if self._typPadBefore and tCheck in self._typPadBefore:
- if self._allowSpaceBeforeColon(text, tCheck):
- nDelete = max(nDelete, 1)
- chkPos = tPos - nDelete - 1
+ delete = 1
+ insert = nwUnicode.U_PSEP
+
+ check = insert
+ if tConf.typPadBefore and check in tConf.typPadBefore:
+ if self._allowSpaceBeforeColon(text, check):
+ delete = max(delete, 1)
+ chkPos = tPos - delete - 1
if chkPos >= 0 and text[chkPos].isspace():
# Strip existing space before inserting a new (#1061)
- nDelete += 1
- tInsert = self._typPadChar + tInsert
+ delete += 1
+ insert = tConf.typPadChar + insert
- if self._typPadAfter and tCheck in self._typPadAfter:
- if self._allowSpaceBeforeColon(text, tCheck):
- nDelete = max(nDelete, 1)
- tInsert = tInsert + self._typPadChar
+ if tConf.typPadAfter and check in tConf.typPadAfter:
+ if self._allowSpaceBeforeColon(text, check):
+ delete = max(delete, 1)
+ insert = insert + tConf.typPadChar
- if nDelete > 0:
- cursor.movePosition(QtMoveLeft, QtKeepAnchor, nDelete)
- cursor.insertText(tInsert)
+ if delete > 0:
+ cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
+ cursor.insertText(insert)
- # Re-highlight, since the auto-replace sometimes interferes with it
- self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
+ # Re-highlight, since the auto-replace sometimes interferes with it
+ self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index 894365797..ac5bf2e83 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -40,7 +40,7 @@
from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.core.index import processComment
from novelwriter.enum import nwComment
-from novelwriter.text.patterns import REGEX_PATTERNS
+from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__)
@@ -59,8 +59,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = (
"_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr",
- "_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogLine",
- "_narratorBreak",
+ "_hStyles", "_minRules", "_txtRules", "_cmnRules", "_dialogParser",
)
def __init__(self, document: QTextDocument) -> None:
@@ -79,8 +78,7 @@ def __init__(self, document: QTextDocument) -> None:
self._txtRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
self._cmnRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
- self._dialogLine = ""
- self._narratorBreak = ""
+ self._dialogParser = DialogParser()
self.initHighlighter()
@@ -136,8 +134,7 @@ def initHighlighter(self) -> None:
self._txtRules.clear()
self._cmnRules.clear()
- self._dialogLine = CONFIG.dialogLine.strip()[:1]
- self._narratorBreak = CONFIG.narratorBreak.strip()[:1]
+ self._dialogParser.initParser()
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
@@ -158,16 +155,8 @@ def initHighlighter(self) -> None:
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
- # Dialogue
- if CONFIG.dialogStyle > 0:
- rxRule = REGEX_PATTERNS.dialogStyle
- hlRule = {
- 0: self._hStyles["dialog"],
- }
- self._txtRules.append((rxRule, hlRule))
-
- if CONFIG.altDialogOpen and CONFIG.altDialogClose:
- rxRule = REGEX_PATTERNS.altDialogStyle
+ # Alt Dialogue
+ if rxRule := REGEX_PATTERNS.altDialogStyle:
hlRule = {
0: self._hStyles["altdialog"],
}
@@ -403,17 +392,10 @@ def highlightBlock(self, text: str) -> None:
else: # Text Paragraph
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules
-
- if self._dialogLine and text.startswith(self._dialogLine):
- if self._narratorBreak:
- tPos = 0
- for tNum, tBit in enumerate(text[1:].split(self._narratorBreak), 1):
- tLen = len(tBit) + 1
- if tNum%2:
- self.setFormat(tPos, tLen, self._hStyles["dialog"])
- tPos += tLen
- else:
- self.setFormat(0, len(text), self._hStyles["dialog"])
+ if self._dialogParser.enabled:
+ for pos, end in self._dialogParser(text):
+ length = end - pos
+ self.setFormat(pos, length, self._hStyles["dialog"])
if hRules:
for rX, hRule in hRules:
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index aa6248e23..9cf0e4185 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -214,7 +214,7 @@ def loadText(self, tHandle: str, updateHistory: bool = True) -> bool:
sPos = self.verticalScrollBar().value()
qDoc = ToQTextDocument(SHARED.project)
qDoc.setJustify(CONFIG.doJustify)
- qDoc.setDialogueHighlight(True)
+ qDoc.setDialogHighlight(True)
qDoc.setFont(CONFIG.textFont)
qDoc.setTheme(self._docTheme)
qDoc.initDocument()
diff --git a/novelwriter/text/patterns.py b/novelwriter/text/patterns.py
index 64d9cc33e..350ab69da 100644
--- a/novelwriter/text/patterns.py
+++ b/novelwriter/text/patterns.py
@@ -26,6 +26,7 @@
import re
from novelwriter import CONFIG
+from novelwriter.common import compact, uniqueCompact
from novelwriter.constants import nwRegEx
@@ -82,26 +83,103 @@ def shortcodeValue(self) -> re.Pattern:
return self._rxSCValue
@property
- def dialogStyle(self) -> re.Pattern:
+ def dialogStyle(self) -> re.Pattern | None:
"""Dialogue detection rule based on user settings."""
- symO = ""
- symC = ""
- if CONFIG.dialogStyle in (1, 3):
- symO += CONFIG.fmtSQuoteOpen
- symC += CONFIG.fmtSQuoteClose
- if CONFIG.dialogStyle in (2, 3):
- symO += CONFIG.fmtDQuoteOpen
- symC += CONFIG.fmtDQuoteClose
-
- rxEnd = "|$" if CONFIG.allowOpenDial else ""
- return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)
+ if CONFIG.dialogStyle > 0:
+ symO = ""
+ symC = ""
+ if CONFIG.dialogStyle in (1, 3):
+ symO += CONFIG.fmtSQuoteOpen.strip()[:1]
+ symC += CONFIG.fmtSQuoteClose.strip()[:1]
+ if CONFIG.dialogStyle in (2, 3):
+ symO += CONFIG.fmtDQuoteOpen.strip()[:1]
+ symC += CONFIG.fmtDQuoteClose.strip()[:1]
+
+ rxEnd = "|$" if CONFIG.allowOpenDial else ""
+ return re.compile(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})", re.UNICODE)
+ return None
@property
- def altDialogStyle(self) -> re.Pattern:
+ def altDialogStyle(self) -> re.Pattern | None:
"""Dialogue alternative rule based on user settings."""
- symO = re.escape(CONFIG.altDialogOpen)
- symC = re.escape(CONFIG.altDialogClose)
- return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE)
+ if CONFIG.altDialogOpen and CONFIG.altDialogClose:
+ symO = re.escape(compact(CONFIG.altDialogOpen))
+ symC = re.escape(compact(CONFIG.altDialogClose))
+ return re.compile(f"\\B{symO}.*?{symC}\\B", re.UNICODE)
+ return None
REGEX_PATTERNS = RegExPatterns()
+
+
+class DialogParser:
+
+ __slots__ = ("_quotes", "_dialog", "_narrator", "_break", "_enabled")
+
+ def __init__(self) -> None:
+ self._quotes = None
+ self._dialog = ""
+ self._narrator = ""
+ self._break = re.compile("")
+ self._enabled = False
+ return
+
+ @property
+ def enabled(self) -> bool:
+ """Return True if there are any settings to parse."""
+ return self._enabled
+
+ def initParser(self) -> None:
+ """Init parser settings. Must be called when config changes."""
+ punct = re.escape("!?.,:;")
+ self._quotes = REGEX_PATTERNS.dialogStyle
+ self._dialog = uniqueCompact(CONFIG.dialogLine)
+ self._narrator = CONFIG.narratorBreak.strip()[:1]
+ self._break = re.compile(
+ f"({self._narrator}\\s?.*?\\s?(?:{self._narrator}[{punct}]?|$))", re.UNICODE
+ )
+ self._enabled = bool(self._quotes or self._dialog or self._narrator)
+ return
+
+ def __call__(self, text: str) -> list[tuple[int, int]]:
+ """Caller wrapper for dialogue processing."""
+ temp: list[int] = []
+ if text:
+ plain = True
+ if self._dialog and text[0] in self._dialog:
+ plain = False
+ temp.append(0)
+ temp.append(len(text))
+ if self._narrator:
+ for res in self._break.finditer(text, 1):
+ temp.append(res.start(0))
+ temp.append(res.end(0))
+ elif self._quotes:
+ for res in self._quotes.finditer(text):
+ plain = False
+ temp.append(res.start(0))
+ temp.append(res.end(0))
+ if self._narrator:
+ for sub in self._break.finditer(text, res.start(0), res.end(0)):
+ temp.append(sub.start(0))
+ temp.append(sub.end(0))
+
+ if plain and self._narrator:
+ pos = 0
+ for num, bit in enumerate(text.split(self._narrator)):
+ length = len(bit) + int(num > 0)
+ if num%2:
+ temp.append(pos)
+ temp.append(pos + length)
+ pos += length
+
+ start = None
+ result = []
+ for pos in sorted(set(temp)):
+ if start is None:
+ start = pos
+ else:
+ result.append((start, pos))
+ start = None
+
+ return result
diff --git a/tests/reference/guiEditor_Main_Final_000000000000f.nwd b/tests/reference/guiEditor_Main_Final_000000000000f.nwd
index fe96f10a4..e59f324e1 100644
--- a/tests/reference/guiEditor_Main_Final_000000000000f.nwd
+++ b/tests/reference/guiEditor_Main_Final_000000000000f.nwd
@@ -1,8 +1,8 @@
%%~name: New Scene
%%~path: 000000000000d/000000000000f
%%~kind: NOVEL/DOCUMENT
-%%~hash: 89b54ddaec2fddfd220e91dd438c84fb3aef9fc2
-%%~date: 2024-10-30 00:07:21/2024-10-30 00:07:26
+%%~hash: e4148ea77e78c90c334d5dc46c38a2b7904ac117
+%%~date: 2024-11-01 21:15:57/2024-11-01 21:16:01
# Novel
## Chapter
@@ -26,7 +26,7 @@ This is a paragraph of nonsense text.
This is another paragraph
with a line separator in it.
-This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! We can also try replacing “quotes”, even single ‘quotes’ are replaced. Isn’t that nice? We can hyphen-ate, make dashes – and even longer dashes — if we want. Ellipsis? Not a problem either … How about three hyphens — for long dash? It works too.
+This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! We can also try replacing “quotes”, even single ‘quotes’ are replaced. Isn’t that nice? We can hyphen-ate, make dashes – and even longer dashes — if we want. We can even go on to a ― hotizontal bar. Ellipsis? Not a problem either … How about three hyphens — for long dash? It works too. Even four hyphens ― for a horizontal works!
“Full line double quoted text.”
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index 66c1939d6..4a06631ab 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -1,5 +1,5 @@
-
This text “has dialogue” in it.
\n" ) - # Alt. Dialogue - CONFIG.altDialogOpen = "::" - CONFIG.altDialogClose = "::" - html.setDialogueHighlight(True) + # Alt Dialog html._text = "## Chapter\n\nThis text ::has alt dialogue:: in it.\n\n" html.tokenizeText() html.doConvert() @@ -301,8 +311,15 @@ def testFmtToHtml_ConvertParagraphs(mockGUI): "This text ::has alt dialogue:: in it.
\n" ) - # Footnotes - # ========= + +@pytest.mark.core +def testFmtToHtml_Footnotes(mockGUI): + """Test paragraph formats in the ToHtml class.""" + project = NWProject() + html = ToHtml(project) + html.initDocument() + html._isNovel = True + html._isFirst = True html._text = ( "Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n" diff --git a/tests/test_formats/test_fmt_tokenizer.py b/tests/test_formats/test_fmt_tokenizer.py index f4dfdb7be..1daf7eeb0 100644 --- a/tests/test_formats/test_fmt_tokenizer.py +++ b/tests/test_formats/test_fmt_tokenizer.py @@ -1286,7 +1286,7 @@ def testFmtToken_Dialogue(mockGUI): project = NWProject() tokens = BareTokenizer(project) - tokens.setDialogueHighlight(True) + tokens.setDialogHighlight(True) tokens._handle = TMH tokens._isNovel = True @@ -1337,7 +1337,10 @@ def testFmtToken_Dialogue(mockGUI): # Dialogue line CONFIG.dialogLine = "\u2013" - tokens.setDialogueHighlight(True) + tokens = BareTokenizer(project) + tokens.setDialogHighlight(True) + tokens._handle = TMH + tokens._isNovel = True tokens._text = "\u2013 Dialogue line without narrator break.\n" tokens.tokenizeText() assert tokens._blocks == [( @@ -1352,16 +1355,19 @@ def testFmtToken_Dialogue(mockGUI): # Dialogue line with narrator break CONFIG.narratorBreak = "\u2013" - tokens.setDialogueHighlight(True) - tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?\n" + tokens = BareTokenizer(project) + tokens.setDialogHighlight(True) + tokens._handle = TMH + tokens._isNovel = True + tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said\u2013, see?\n" tokens.tokenizeText() assert tokens._blocks == [( BlockTyp.TEXT, "", - "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?", + "\u2013 Dialogue with a narrator break, \u2013he said\u2013, see?", [ (0, TextFmt.COL_B, "dialog"), (34, TextFmt.COL_E, ""), - (43, TextFmt.COL_B, "dialog"), + (44, TextFmt.COL_B, "dialog"), (49, TextFmt.COL_E, ""), ], BlockFmt.NONE diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py index 18d128d7b..3c9aa8f70 100644 --- a/tests/test_formats/test_fmt_toodt.py +++ b/tests/test_formats/test_fmt_toodt.py @@ -263,7 +263,7 @@ def testFmtToOdt_DialogueFormatting(mockGUI): """Test formatting of dialogue.""" project = NWProject() odt = ToOdt(project, isFlat=True) - odt.setDialogueHighlight(True) + odt.setDialogHighlight(True) odt.initDocument() oStyle = ODTParagraphStyle("test") diff --git a/tests/test_formats/test_fmt_toqdoc.py b/tests/test_formats/test_fmt_toqdoc.py index 46ff0b855..743876532 100644 --- a/tests/test_formats/test_fmt_toqdoc.py +++ b/tests/test_formats/test_fmt_toqdoc.py @@ -442,7 +442,7 @@ def testFmtToQTextDocument_TextCharFormats(mockGUI): # Convert before init doc._text = "Blabla" - doc.setDialogueHighlight(True) + doc.setDialogHighlight(True) doc.doConvert() doc.tokenizeText() assert doc.document.toPlainText() == "" diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 62c608590..0f1fc5c23 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -80,7 +80,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert qDoc.defaultTextOption().alignment() == QtAlignLeft assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded - assert docEditor._typPadChar == nwUnicode.U_NBSP + assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP assert docEditor.docHeader.itemTitle.text() == ( "Novel \u203a New Chapter \u203a New Scene" ) @@ -105,7 +105,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert qDoc.defaultTextOption().flags() & QTextOption.ShowLineAndParagraphSeparators assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff - assert docEditor._typPadChar == nwUnicode.U_THNBSP + assert docEditor._typConf.typPadChar == nwUnicode.U_THNBSP assert docEditor.docHeader.itemTitle.text() == "New Scene" # Header diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 622d81b6f..6d151b811 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -421,6 +421,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) + for c in "We can even go on to a ---- hotizontal bar. ": + qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "Ellipsis? Not a problem either ... ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "How about three hyphens - -": @@ -428,7 +430,17 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY) - for c in "- for long dash? It works too.": + for c in "- for long dash? It works too. ": + qtbot.keyClick(docEditor, c, delay=KEY_DELAY) + for c in "Even four hyphens - - -": + qtbot.keyClick(docEditor, c, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Left, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Backspace, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Right, delay=KEY_DELAY) + for c in "- for a horizontal works!": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) @@ -447,16 +459,18 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) # Insert spaces before and after quotes - docEditor._typPadBefore = "\u201d" - docEditor._typPadAfter = "\u201c" + CONFIG.fmtPadBefore = "\u201d" + CONFIG.fmtPadAfter = "\u201c" + docEditor.initEditor() for c in "Some \"double quoted text with spaces padded\".": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) - docEditor._typPadBefore = "" - docEditor._typPadAfter = "" + CONFIG.fmtPadBefore = "" + CONFIG.fmtPadAfter = "" + docEditor.initEditor() # Dialogue Line for c in "-- Hi, I am a character speaking.": @@ -478,7 +492,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): # ================== # Insert spaces before colon, but ignore tags - docEditor._typPadBefore = ":" + CONFIG.fmtPadBefore = ":" + docEditor.initEditor() for c in "@object: NoSpaceAdded": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) @@ -505,7 +520,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) - docEditor._typPadBefore = "" + CONFIG.fmtPadBefore = "" + docEditor.initEditor() # Indent and Align # ================ diff --git a/tests/test_text/test_text_patterns.py b/tests/test_text/test_text_patterns.py index c26fc55d7..4c68f144f 100644 --- a/tests/test_text/test_text_patterns.py +++ b/tests/test_text/test_text_patterns.py @@ -26,7 +26,7 @@ from novelwriter import CONFIG from novelwriter.constants import nwUnicode -from novelwriter.text.patterns import REGEX_PATTERNS +from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser def allMatches(regEx: re.Pattern, text: str) -> list[list[str]]: @@ -267,6 +267,10 @@ def testTextPatterns_ShortcodesValue(): @pytest.mark.core def testTextPatterns_DialogueStyle(): """Test the dialogue style pattern regexes.""" + # Before set, the regex is None + CONFIG.dialogStyle = 0 + assert REGEX_PATTERNS.dialogStyle is None + # Set the config CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO @@ -280,6 +284,7 @@ def testTextPatterns_DialogueStyle(): CONFIG.allowOpenDial = False regEx = REGEX_PATTERNS.dialogStyle + assert regEx is not None # Defined single quotes are recognised assert allMatches(regEx, "one \u2018two\u2019 three") == [ @@ -305,6 +310,7 @@ def testTextPatterns_DialogueStyle(): CONFIG.allowOpenDial = True regEx = REGEX_PATTERNS.dialogStyle + assert regEx is not None # Defined single quotes are recognised also when open assert allMatches(regEx, "one \u2018two three") == [ @@ -320,19 +326,19 @@ def testTextPatterns_DialogueStyle(): @pytest.mark.core def testTextPatterns_DialogueSpecial(): """Test the special dialogue style pattern regexes.""" - # Set the config - CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO - CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO - CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO - CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO + # Before set, the regex is None + CONFIG.altDialogOpen = "" + CONFIG.altDialogClose = "" + assert REGEX_PATTERNS.altDialogStyle is None - CONFIG.dialogStyle = 3 + # Set the config CONFIG.altDialogOpen = "::" CONFIG.altDialogClose = "::" # Alternative Dialogue # ==================== regEx = REGEX_PATTERNS.altDialogStyle + assert regEx is not None # With no padding assert allMatches(regEx, "one ::two:: three") == [ @@ -343,3 +349,106 @@ def testTextPatterns_DialogueSpecial(): assert allMatches(regEx, "one :: two :: three") == [ [(":: two ::", 4, 13)] ] + + +@pytest.mark.core +def testTextPatterns_DialogParserEnglish(): + """Test the dialog parser with English settings.""" + # Set the config + CONFIG.dialogStyle = 3 + CONFIG.fmtSQuoteOpen = nwUnicode.U_LSQUO + CONFIG.fmtSQuoteClose = nwUnicode.U_RSQUO + CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO + + parser = DialogParser() + parser.initParser() + + # Positions: 0 18 + assert parser("“Simple dialogue.”") == [ + (0, 18), + ] + + # Positions: 0 18 + assert parser("“Simple dialogue,” argued John.") == [ + (0, 18), + ] + + # Positions: 0 18 32 56 + assert parser("“Simple dialogue,” argued John, “is not always so easy.”") == [ + (0, 18), (32, 56), + ] + + # With Narrator breaks + CONFIG.dialogLine = "" + CONFIG.narratorBreak = nwUnicode.U_EMDASH + parser.initParser() + + # Positions: 0 18 34 58 + assert parser("“Simple dialogue, — argued John, — is not always so easy.”") == [ + (0, 18), (34, 58), + ] + + +@pytest.mark.core +def testTextPatterns_DialogParserSpanish(): + """Test the dialog parser with Spanish settings.""" + # Set the config + CONFIG.dialogStyle = 3 + CONFIG.fmtSQuoteOpen = nwUnicode.U_LSAQUO + CONFIG.fmtSQuoteClose = nwUnicode.U_RSAQUO + CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO + CONFIG.dialogLine = nwUnicode.U_EMDASH + nwUnicode.U_RAQUO + CONFIG.narratorBreak = nwUnicode.U_EMDASH + + parser = DialogParser() + parser.initParser() + + # Positions: 0 18 54 70 + assert parser("—No te preocupes. —Cerró la puerta y salió corriendo—. Volveré pronto.") == [ + (0, 18), (54, 70), + ] + + # Positions: 0 14 + assert parser("«Tengo hambre», pensó Pedro.") == [ + (0, 14), + ] + + # Positions: 0 16 + assert parser("—Puedes hacerlo —le dije y pensé «pero te costará mucho trabajo».") == [ + (0, 16), + ] + + +@pytest.mark.core +def testTextPatterns_DialogParserAlternating(): + """Test the dialog parser with alternating dialogue/narration like + for Portuguese and Polish. + """ + # Set the config + CONFIG.dialogStyle = 0 + CONFIG.fmtSQuoteOpen = nwUnicode.U_LSAQUO + CONFIG.fmtSQuoteClose = nwUnicode.U_RSAQUO + CONFIG.fmtDQuoteOpen = nwUnicode.U_LAQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RAQUO + CONFIG.dialogLine = "" + CONFIG.narratorBreak = nwUnicode.U_EMDASH + + parser = DialogParser() + parser.initParser() + + # Positions: 0 21 + assert parser("— Está ficando tarde.") == [ + (0, 21), + ] + + # Positions: 0 12 + assert parser("— Ainda não — ela responde.") == [ + (0, 12), + ] + + # Positions: 0 12 28 49 + assert parser("— Tudo bem? — ele pergunta. — Você falou com ele?") == [ + (0, 12), (28, 49), + ]