diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 14b4ed16..7161a794 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -272,7 +272,7 @@ class NWBuildDocument: 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 8d23728a..9dfdc05f 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -583,8 +583,8 @@ class GuiPreferences(NDialog): 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) diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py index 759185c7..e63fb150 100644 --- a/novelwriter/formats/tokenizer.py +++ b/novelwriter/formats/tokenizer.py @@ -46,7 +46,7 @@ from novelwriter.enum import nwComment, nwItemLayout 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 @@ class Tokenizer(ABC): } # 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 @@ class Tokenizer(ABC): 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 @@ class Tokenizer(ABC): # 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 @@ class Tokenizer(ABC): )) # 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/dochighlight.py b/novelwriter/gui/dochighlight.py index 89436579..ac5bf2e8 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -40,7 +40,7 @@ from novelwriter.common import checkInt 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 @@ class GuiDocHighlighter(QSyntaxHighlighter): 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 @@ class GuiDocHighlighter(QSyntaxHighlighter): 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 @@ class GuiDocHighlighter(QSyntaxHighlighter): 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 @@ class GuiDocHighlighter(QSyntaxHighlighter): 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 aa6248e2..9cf0e418 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -214,7 +214,7 @@ class GuiDocViewer(QTextBrowser): 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 64d9cc33..e09bd883 100644 --- a/novelwriter/text/patterns.py +++ b/novelwriter/text/patterns.py @@ -82,26 +82,108 @@ class RegExPatterns: 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 + if CONFIG.dialogStyle > 0: + 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) + 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(CONFIG.altDialogOpen) + symC = re.escape(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 = 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 + + # print("-"*80) + # print(f"'{text}'") + # print(temp) + # print(result) + + return result