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 @@ - + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel @@ -46,7 +46,7 @@ New Chapter - + New Scene diff --git a/tests/test_formats/test_fmt_tohtml.py b/tests/test_formats/test_fmt_tohtml.py index d9257738b..03ad126e5 100644 --- a/tests/test_formats/test_fmt_tohtml.py +++ b/tests/test_formats/test_fmt_tohtml.py @@ -143,7 +143,6 @@ def testFmtToHtml_ConvertParagraphs(mockGUI): project = NWProject() html = ToHtml(project) html.initDocument() - html._isNovel = True html._isFirst = True @@ -279,20 +278,31 @@ def testFmtToHtml_ConvertParagraphs(mockGUI): "Europe

\n" ) - # Dialogue - html.setDialogueHighlight(True) + +@pytest.mark.core +def testFmtToHtml_Dialog(mockGUI): + """Test paragraph formats in the ToHtml class.""" + CONFIG.altDialogOpen = "::" + CONFIG.altDialogClose = "::" + + project = NWProject() + html = ToHtml(project) + html.initDocument() + html.setDialogHighlight(True) + html._isNovel = True + html._isFirst = True + + # Dialog + html.setDialogHighlight(True) html._text = "## Chapter\n\nThis text \u201chas dialogue\u201d in it.\n\n" html.tokenizeText() html.doConvert() assert html._pages[-1] == ( - "

Chapter

\n" + "

Chapter

\n" "

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), + ]