diff --git a/novelwriter/config.py b/novelwriter/config.py index 9e010c55..45dc01d9 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -161,8 +161,9 @@ class Config: self.dialogStyle = 2 # Quote type to use for dialogue self.allowOpenDial = True # Allow open-ended dialogue quotes - self.narratorBreak = "" # Symbol to use for narrator break self.dialogLine = "" # Symbol to use for dialogue line + self.narratorBreak = "" # Symbol to use for narrator break + self.narratorDialog = "" # Symbol for alternating between dialogue and narrator self.altDialogOpen = "" # Alternative dialog symbol, open self.altDialogClose = "" # Alternative dialog symbol, close self.highlightEmph = True # Add colour to text emphasis @@ -657,10 +658,11 @@ class Config: self.showFullPath = conf.rdBool(sec, "showfullpath", self.showFullPath) self.dialogStyle = conf.rdInt(sec, "dialogstyle", self.dialogStyle) self.allowOpenDial = conf.rdBool(sec, "allowopendial", self.allowOpenDial) + self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine) self.narratorBreak = conf.rdStr(sec, "narratorbreak", self.narratorBreak) + self.narratorDialog = conf.rdStr(sec, "narratordialog", self.narratorDialog) self.altDialogOpen = conf.rdStr(sec, "altdialogopen", self.altDialogOpen) self.altDialogClose = conf.rdStr(sec, "altdialogclose", self.altDialogClose) - self.dialogLine = conf.rdStr(sec, "dialogline", self.dialogLine) self.highlightEmph = conf.rdBool(sec, "highlightemph", self.highlightEmph) self.stopWhenIdle = conf.rdBool(sec, "stopwhenidle", self.stopWhenIdle) self.userIdleTime = conf.rdInt(sec, "useridletime", self.userIdleTime) @@ -766,10 +768,11 @@ class Config: "showfullpath": str(self.showFullPath), "dialogstyle": str(self.dialogStyle), "allowopendial": str(self.allowOpenDial), + "dialogline": str(self.dialogLine), "narratorbreak": str(self.narratorBreak), + "narratordialog": str(self.narratorDialog), "altdialogopen": str(self.altDialogOpen), "altdialogclose": str(self.altDialogClose), - "dialogline": str(self.dialogLine), "highlightemph": str(self.highlightEmph), "stopwhenidle": str(self.stopWhenIdle), "useridletime": str(self.userIdleTime), diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 6723cd4a..444729ad 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -583,8 +583,18 @@ class GuiPreferences(NDialog): self.narratorBreak.setAlignment(QtAlignCenter) self.narratorBreak.setText(CONFIG.narratorBreak) self.mainForm.addRow( - self.tr("Alternating dialogue/narration symbol"), self.narratorBreak, - self.tr("Alternates dialogue highlighting within a paragraph.") + self.tr("Dialogue narrator break symbol"), self.narratorBreak, + self.tr("Symbol to indicate injected narrator break in dialogue") + ) + + self.narratorDialog = QLineEdit(self) + self.narratorDialog.setMaxLength(1) + self.narratorDialog.setFixedWidth(boxFixed) + self.narratorDialog.setAlignment(QtAlignCenter) + self.narratorDialog.setText(CONFIG.narratorDialog) + self.mainForm.addRow( + self.tr("Alternating dialogue/narration symbol"), self.narratorDialog, + self.tr("Alternates dialogue highlighting within any paragraph.") ) self.highlightEmph = NSwitch(self) @@ -952,8 +962,9 @@ class GuiPreferences(NDialog): # Text Highlighting dialogueStyle = self.dialogStyle.currentData() allowOpenDial = self.allowOpenDial.isChecked() - narratorBreak = self.narratorBreak.text().strip() dialogueLine = uniqueCompact(self.dialogLine.text()) + narratorBreak = self.narratorBreak.text().strip() + narratorDialog = self.narratorDialog.text().strip() altDialogOpen = compact(self.altDialogOpen.text()) altDialogClose = compact(self.altDialogClose.text()) highlightEmph = self.highlightEmph.isChecked() @@ -961,8 +972,9 @@ class GuiPreferences(NDialog): updateSyntax |= CONFIG.dialogStyle != dialogueStyle updateSyntax |= CONFIG.allowOpenDial != allowOpenDial - updateSyntax |= CONFIG.narratorBreak != narratorBreak updateSyntax |= CONFIG.dialogLine != dialogueLine + updateSyntax |= CONFIG.narratorBreak != narratorBreak + updateSyntax |= CONFIG.narratorDialog != narratorDialog updateSyntax |= CONFIG.altDialogOpen != altDialogOpen updateSyntax |= CONFIG.altDialogClose != altDialogClose updateSyntax |= CONFIG.highlightEmph != highlightEmph @@ -970,8 +982,9 @@ class GuiPreferences(NDialog): CONFIG.dialogStyle = dialogueStyle CONFIG.allowOpenDial = allowOpenDial - CONFIG.narratorBreak = narratorBreak CONFIG.dialogLine = dialogueLine + CONFIG.narratorBreak = narratorBreak + CONFIG.narratorDialog = narratorDialog CONFIG.altDialogOpen = altDialogOpen CONFIG.altDialogClose = altDialogClose CONFIG.highlightEmph = highlightEmph diff --git a/novelwriter/text/patterns.py b/novelwriter/text/patterns.py index 350ab69d..e2137a4d 100644 --- a/novelwriter/text/patterns.py +++ b/novelwriter/text/patterns.py @@ -86,26 +86,26 @@ class RegExPatterns: def dialogStyle(self) -> re.Pattern | None: """Dialogue detection rule based on user settings.""" if CONFIG.dialogStyle > 0: - symO = "" - symC = "" + end = "|$" if CONFIG.allowOpenDial else "" + rx = [] if CONFIG.dialogStyle in (1, 3): - symO += CONFIG.fmtSQuoteOpen.strip()[:1] - symC += CONFIG.fmtSQuoteClose.strip()[:1] + qO = CONFIG.fmtSQuoteOpen.strip()[:1] + qC = CONFIG.fmtSQuoteClose.strip()[:1] + rx.append(f"(?:\\B{qO}.*?(?:{qC}\\B{end}))") 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) + qO = CONFIG.fmtDQuoteOpen.strip()[:1] + qC = CONFIG.fmtDQuoteClose.strip()[:1] + rx.append(f"(?:\\B{qO}.*?(?:{qC}\\B{end}))") + return re.compile("|".join(rx), re.UNICODE) return None @property def altDialogStyle(self) -> re.Pattern | None: """Dialogue alternative rule based on user settings.""" 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) + qO = re.escape(compact(CONFIG.altDialogOpen)) + qC = re.escape(compact(CONFIG.altDialogClose)) + return re.compile(f"\\B{qO}.*?{qC}\\B", re.UNICODE) return None @@ -114,12 +114,13 @@ REGEX_PATTERNS = RegExPatterns() class DialogParser: - __slots__ = ("_quotes", "_dialog", "_narrator", "_break", "_enabled") + __slots__ = ("_quotes", "_dialog", "_narrator", "_alternate", "_break", "_enabled") def __init__(self) -> None: self._quotes = None self._dialog = "" self._narrator = "" + self._alternate = "" self._break = re.compile("") self._enabled = False return @@ -135,8 +136,9 @@ class DialogParser: self._quotes = REGEX_PATTERNS.dialogStyle self._dialog = uniqueCompact(CONFIG.dialogLine) self._narrator = CONFIG.narratorBreak.strip()[:1] + self._alternate = CONFIG.narratorDialog.strip()[:1] self._break = re.compile( - f"({self._narrator}\\s?.*?\\s?(?:{self._narrator}[{punct}]?|$))", re.UNICODE + f"({self._narrator}\\s?.*?)(\\s?(?:{self._narrator}[{punct}]?|$))", re.UNICODE ) self._enabled = bool(self._quotes or self._dialog or self._narrator) return @@ -147,26 +149,35 @@ class DialogParser: if text: plain = True if self._dialog and text[0] in self._dialog: + # The whole line is dialogue plain = False temp.append(0) temp.append(len(text)) if self._narrator: + # Process narrator breaks in the dialogue for res in self._break.finditer(text, 1): temp.append(res.start(0)) - temp.append(res.end(0)) + if (two := res.group(2)) and two[0].isspace(): + temp.append(res.start(2)) + else: + temp.append(res.end(0)) elif self._quotes: + # The line contains quoted dialogue 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)) + for res in self._break.finditer(text, 1): + temp.append(res.start(0)) + if (two := res.group(2)) and two[0].isspace(): + temp.append(res.start(2)) + else: + temp.append(res.end(0)) - if plain and self._narrator: + if plain and self._alternate: pos = 0 - for num, bit in enumerate(text.split(self._narrator)): + for num, bit in enumerate(text.split(self._alternate)): length = len(bit) + int(num > 0) if num%2: temp.append(pos) diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index b9d485a2..046ac8c8 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2024-06-16 00:36:27 +timestamp = 2024-11-03 21:45:08 [Main] font = @@ -59,10 +59,11 @@ incnoteswcount = True showfullpath = True dialogstyle = 2 allowopendial = True +dialogline = narratorbreak = +narratordialog = altdialogopen = altdialogclose = -dialogline = highlightemph = True stopwhenidle = True useridletime = 300 diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index 036621df..4285da9f 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -247,8 +247,9 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, tstPaths): # Text Highlighting prefs.dialogStyle.setCurrentData(3, 0) prefs.allowOpenDial.setChecked(False) - prefs.narratorBreak.setText("–") prefs.dialogLine.setText("–") + prefs.narratorBreak.setText("–") + prefs.narratorDialog.setText("–") prefs.altDialogOpen.setText("<") prefs.altDialogClose.setText(">") prefs.highlightEmph.setChecked(False) @@ -256,8 +257,9 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, tstPaths): assert CONFIG.dialogStyle == 2 assert CONFIG.allowOpenDial is True - assert CONFIG.narratorBreak == "" assert CONFIG.dialogLine == "" + assert CONFIG.narratorBreak == "" + assert CONFIG.narratorDialog == "" assert CONFIG.altDialogOpen == "" assert CONFIG.altDialogClose == "" assert CONFIG.highlightEmph is True @@ -366,8 +368,9 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, tstPaths): # Text Highlighting assert CONFIG.dialogStyle == 3 assert CONFIG.allowOpenDial is False - assert CONFIG.narratorBreak == "–" assert CONFIG.dialogLine == "–" + assert CONFIG.narratorBreak == "–" + assert CONFIG.narratorDialog == "–" assert CONFIG.altDialogOpen == "<" assert CONFIG.altDialogClose == ">" assert CONFIG.highlightEmph is False diff --git a/tests/test_text/test_text_patterns.py b/tests/test_text/test_text_patterns.py index 4c68f144..2ace18bf 100644 --- a/tests/test_text/test_text_patterns.py +++ b/tests/test_text/test_text_patterns.py @@ -296,6 +296,16 @@ def testTextPatterns_DialogueStyle(): [("\u201ctwo\u201d", 4, 9)] ] + # Both single and double quotes are recognised + assert allMatches(regEx, "one \u2018two\u2019 three \u201cfour\u201d five") == [ + [("\u2018two\u2019", 4, 9)], [("\u201cfour\u201d", 16, 22)] + ] + + # But not mixed + assert allMatches(regEx, "one \u2018two\u201d three \u201cfour\u2019 five") == [ + [("\u2018two\u201d three \u201cfour\u2019", 4, 22)] + ] + # Straight single quotes are ignored assert allMatches(regEx, "one 'two' three") == [] @@ -363,6 +373,7 @@ def testTextPatterns_DialogParserEnglish(): parser = DialogParser() parser.initParser() + assert parser.enabled is True # Positions: 0 18 assert parser("“Simple dialogue.”") == [ @@ -384,9 +395,14 @@ def testTextPatterns_DialogParserEnglish(): CONFIG.narratorBreak = nwUnicode.U_EMDASH parser.initParser() - # Positions: 0 18 34 58 + # Positions: 0 18 32 58 assert parser("“Simple dialogue, — argued John, — is not always so easy.”") == [ - (0, 18), (34, 58), + (0, 18), (32, 58), + ] + + # Positions: 0 18 32 56 + assert parser("“Simple dialogue, —argued John—, is not always so easy.”") == [ + (0, 18), (32, 56), ] @@ -422,17 +438,15 @@ def testTextPatterns_DialogParserSpanish(): @pytest.mark.core -def testTextPatterns_DialogParserAlternating(): - """Test the dialog parser with alternating dialogue/narration like - for Portuguese and Polish. - """ +def testTextPatterns_DialogParserPortuguese(): + """Test the dialog parser with Portuguese settings.""" # 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.dialogLine = nwUnicode.U_EMDASH CONFIG.narratorBreak = nwUnicode.U_EMDASH parser = DialogParser() @@ -448,7 +462,53 @@ def testTextPatterns_DialogParserAlternating(): (0, 12), ] - # Positions: 0 12 28 49 + # Positions: 0 12 27 49 assert parser("— Tudo bem? — ele pergunta. — Você falou com ele?") == [ - (0, 12), (28, 49), + (0, 12), (27, 49), ] + + +@pytest.mark.core +def testTextPatterns_DialogParserPolish(): + """Test the dialog parser with alternating Polish settings.""" + # Set the config + CONFIG.dialogStyle = 0 + CONFIG.fmtSQuoteOpen = "'" + CONFIG.fmtSQuoteClose = "'" + CONFIG.fmtDQuoteOpen = '"' + CONFIG.fmtDQuoteClose = '"' + CONFIG.dialogLine = "" + CONFIG.narratorBreak = "" + CONFIG.narratorDialog = nwUnicode.U_ENDASH + + parser = DialogParser() + parser.initParser() + + # This is what an example dialogue might look like using Polish punctuation rules + # See discussion #1976 + + assert parser( + "– Example statement – someone said. And he added: – Another example statement." + ) == [ + (0, 20), (50, 78), + ] + + assert parser( + "– Oh my! – It would be nice if only the statements were highlighted, without " + "any narration. In a paragraph where there is only a short statement and then " + "a lot happens, this would be especially justified." + ) == [ + (0, 9), + ] + + assert parser( + "There are also sometimes paragraphs that start with a narrative, and only then " + "someone shouts out the words: – Oooh! Look!" + ) == [ + (109, 122), + ] + + assert parser( + "And so on and so forth. However, \"text in quotation marks\" should not be " + "highlighted at all, and if so, it should be highlighted differently." + ) == []