diff --git a/novelwriter/constants.py b/novelwriter/constants.py index ea625465..bf62caeb 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -61,10 +61,10 @@ class nwConst: class nwRegEx: FMT_EI = r"(?", Tokenizer.FMT_SUB_B: "", Tokenizer.FMT_SUB_E: "", + Tokenizer.FMT_DL_B: "", + Tokenizer.FMT_DL_E: "", + Tokenizer.FMT_ADL_B: "", + Tokenizer.FMT_ADL_E: "", Tokenizer.FMT_STRIP: "", } @@ -431,6 +435,8 @@ class ToHtml(Tokenizer): styles.append(".break {text-align: left;}") styles.append(".synopsis {font-style: italic;}") styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}") + styles.append(".dialog {color: rgb(66, 113, 174);}") + styles.append(".altdialog {color: rgb(129, 55, 9);}") return styles @@ -451,7 +457,7 @@ class ToHtml(Tokenizer): else: html = "ERR" else: - html = HTML5_TAGS.get(fmt, "ERR") + html = HTML5_TAGS.get(fmt, "") temp = f"{temp[:pos]}{html}{temp[pos:]}" temp = temp.replace("\n", "
") return stripEscape(temp) diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 93b967f5..a2238b73 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -36,13 +36,13 @@ from time import time from PyQt5.QtCore import QCoreApplication, QRegularExpression from PyQt5.QtGui import QFont +from novelwriter import CONFIG from novelwriter.common import checkInt, formatTimeStamp, numberToRoman -from novelwriter.constants import ( - nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst -) +from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwShortcode, nwUnicode, trConst from novelwriter.core.index import processComment from novelwriter.core.project import NWProject from novelwriter.enum import nwComment, nwItemLayout +from novelwriter.text.patterns import REGEX_PATTERNS logger = logging.getLogger(__name__) @@ -85,8 +85,12 @@ class Tokenizer(ABC): FMT_SUP_E = 12 # End superscript FMT_SUB_B = 13 # Begin subscript FMT_SUB_E = 14 # End subscript - FMT_FNOTE = 15 # Footnote marker - FMT_STRIP = 16 # Strip the format code + FMT_DL_B = 15 # Begin dialogue + FMT_DL_E = 16 # End dialogue + FMT_ADL_B = 17 # Begin alt dialogue + FMT_ADL_E = 18 # End alt dialogue + FMT_FNOTE = 19 # Footnote marker + FMT_STRIP = 20 # Strip the format code # Block Type T_EMPTY = 1 # Empty line (new paragraph) @@ -193,7 +197,8 @@ class Tokenizer(ABC): # Instance Variables self._hFormatter = HeadingFormatter(self._project) - self._noSep = True # Flag to indicate that we don't want a scene separator + self._noSep = True # Flag to indicate that we don't want a scene separator + self._showDialog = False # Flag for dialogue highlighting # This File self._isNovel = False # Document is a novel document @@ -208,12 +213,12 @@ class Tokenizer(ABC): # Format RegEx self._rxMarkdown = [ - (QRegularExpression(nwRegEx.FMT_EI), [0, self.FMT_I_B, 0, self.FMT_I_E]), - (QRegularExpression(nwRegEx.FMT_EB), [0, self.FMT_B_B, 0, self.FMT_B_E]), - (QRegularExpression(nwRegEx.FMT_ST), [0, self.FMT_D_B, 0, self.FMT_D_E]), + (REGEX_PATTERNS.markdownItalic, [0, self.FMT_I_B, 0, self.FMT_I_E]), + (REGEX_PATTERNS.markdownBold, [0, self.FMT_B_B, 0, self.FMT_B_E]), + (REGEX_PATTERNS.markdownStrike, [0, self.FMT_D_B, 0, self.FMT_D_E]), ] - self._rxShortCodes = QRegularExpression(nwRegEx.FMT_SC) - self._rxShortCodeVals = QRegularExpression(nwRegEx.FMT_SV) + self._rxShortCodes = REGEX_PATTERNS.shortcodePlain + self._rxShortCodeVals = REGEX_PATTERNS.shortcodeValue self._shortCodeFmt = { nwShortcode.ITALIC_O: self.FMT_I_B, nwShortcode.ITALIC_C: self.FMT_I_E, @@ -228,6 +233,8 @@ class Tokenizer(ABC): nwShortcode.FOOTNOTE_B: self.FMT_FNOTE, } + self._rxDialogue: list[tuple[QRegularExpression, int, int]] = [] + return ## @@ -349,6 +356,29 @@ class Tokenizer(ABC): self._doJustify = state return + def setDialogueHighlight(self, state: bool) -> None: + """Enable or disable dialogue highlighting.""" + self._rxDialogue = [] + self._showDialog = state + if state: + if CONFIG.dialogStyle > 0: + self._rxDialogue.append(( + REGEX_PATTERNS.dialogStyle, self.FMT_DL_B, self.FMT_DL_E + )) + if CONFIG.dialogLine: + self._rxDialogue.append(( + REGEX_PATTERNS.dialogLine, self.FMT_DL_B, self.FMT_DL_E + )) + if CONFIG.narratorBreak: + self._rxDialogue.append(( + REGEX_PATTERNS.narratorBreak, self.FMT_DL_E, self.FMT_DL_B + )) + if CONFIG.altDialogOpen and CONFIG.altDialogClose: + self._rxDialogue.append(( + REGEX_PATTERNS.altDialogStyle, self.FMT_ADL_B, self.FMT_ADL_E + )) + return + def setTitleMargins(self, upper: float, lower: float) -> None: """Set the upper and lower title margin.""" self._marginTitle = (float(upper), float(lower)) @@ -1106,6 +1136,15 @@ class Tokenizer(ABC): f"{tHandle}:{rxMatch.captured(2)}", )) + # Match Dialogue + if self._rxDialogue: + for regEx, fmtB, fmtE in self._rxDialogue: + rxItt = regEx.globalMatch(text, 0) + while rxItt.hasNext(): + rxMatch = rxItt.next() + temp.append((rxMatch.capturedStart(0), 0, fmtB, "")) + temp.append((rxMatch.capturedEnd(0), 0, fmtE, "")) + # Post-process text and format result = text formats = [] diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index c901db86..efb4f8d1 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -82,13 +82,15 @@ TAG_SPAN = _mkTag("text", "span") TAG_STNM = _mkTag("text", "style-name") # Formatting Codes -X_BLD = 0x01 # Bold format -X_ITA = 0x02 # Italic format -X_DEL = 0x04 # Strikethrough format -X_UND = 0x08 # Underline format -X_MRK = 0x10 # Marked format -X_SUP = 0x20 # Superscript -X_SUB = 0x40 # Subscript +X_BLD = 0x001 # Bold format +X_ITA = 0x002 # Italic format +X_DEL = 0x004 # Strikethrough format +X_UND = 0x008 # Underline format +X_MRK = 0x010 # Marked format +X_SUP = 0x020 # Superscript +X_SUB = 0x040 # Subscript +X_DLG = 0x080 # Dialogue +X_DLA = 0x100 # Alt. Dialogue # Formatting Masks M_BLD = ~X_BLD @@ -98,6 +100,8 @@ M_UND = ~X_UND M_MRK = ~X_MRK M_SUP = ~X_SUP M_SUB = ~X_SUB +M_DLG = ~X_DLG +M_DLA = ~X_DLA # ODT Styles S_TITLE = "Title" @@ -216,13 +220,15 @@ class ToOdt(Tokenizer): self._mDocRight = "2.000cm" # Colour - self._colHead12 = None - self._opaHead12 = None - self._colHead34 = None - self._opaHead34 = None - self._colMetaTx = None - self._opaMetaTx = None - self._markText = "#ffffa6" + self._colHead12 = None + self._opaHead12 = None + self._colHead34 = None + self._opaHead34 = None + self._colMetaTx = None + self._opaMetaTx = None + self._colDialogM = None + self._colDialogA = None + self._markText = "#ffffa6" return @@ -324,6 +330,10 @@ class ToOdt(Tokenizer): self._colMetaTx = "#813709" self._opaMetaTx = "100%" + if self._showDialog: + self._colDialogM = "#2a6099" + self._colDialogA = "#813709" + self._fLineHeight = f"{round(100 * self._lineHeight):d}%" self._fBlockIndent = self._emToCm(self._blockIndent) self._fTextIndent = self._emToCm(self._firstWidth) @@ -684,6 +694,14 @@ class ToOdt(Tokenizer): xFmt |= X_SUB elif fFmt == self.FMT_SUB_E: xFmt &= M_SUB + elif fFmt == self.FMT_DL_B: + xFmt |= X_DLG + elif fFmt == self.FMT_DL_E: + xFmt &= M_DLG + elif fFmt == self.FMT_ADL_B: + xFmt |= X_DLA + elif fFmt == self.FMT_ADL_E: + xFmt &= M_DLA elif fFmt == self.FMT_FNOTE: xNode = self._generateFootnote(fData) elif fFmt == self.FMT_STRIP: @@ -757,6 +775,10 @@ class ToOdt(Tokenizer): style.setTextPosition("super") if hFmt & X_SUB: style.setTextPosition("sub") + if hFmt & X_DLG: + style.setColour(self._colDialogM) + if hFmt & X_DLA: + style.setColour(self._colDialogA) self._autoText[hFmt] = style return style.name @@ -1357,6 +1379,7 @@ class ODTTextStyle: self._tAttr = { "font-weight": ["fo", None], "font-style": ["fo", None], + "color": ["fo", None], "background-color": ["fo", None], "text-position": ["style", None], "text-line-through-style": ["style", None], @@ -1391,6 +1414,14 @@ class ODTTextStyle: self._tAttr["font-style"][1] = None return + def setColour(self, value: str | None) -> None: + """Set text colour.""" + if value and len(value) == 7 and value[0] == "#": + self._tAttr["color"][1] = value + else: + self._tAttr["color"][1] = None + return + def setBackgroundColour(self, value: str | None) -> None: """Set text background colour.""" if value and len(value) == 7 and value[0] == "#": diff --git a/novelwriter/core/toqdoc.py b/novelwriter/core/toqdoc.py index d40c4345..fc7d3b46 100644 --- a/novelwriter/core/toqdoc.py +++ b/novelwriter/core/toqdoc.py @@ -45,16 +45,18 @@ T_TextStyle = tuple[QTextBlockFormat, QTextCharFormat] class TextDocumentTheme: - text: QColor = QtBlack + text: QColor = QtBlack highlight: QColor = QtTransparent - head: QColor = QtBlack - comment: QColor = QtBlack - note: QColor = QtBlack - code: QColor = QtBlack - modifier: QColor = QtBlack - keyword: QColor = QtBlack - tag: QColor = QtBlack - optional: QColor = QtBlack + head: QColor = QtBlack + comment: QColor = QtBlack + note: QColor = QtBlack + code: QColor = QtBlack + modifier: QColor = QtBlack + keyword: QColor = QtBlack + tag: QColor = QtBlack + optional: QColor = QtBlack + dialog: QColor = QtBlack + altdialog: QColor = QtBlack def newBlock(cursor: QTextCursor, bFmt: QTextBlockFormat) -> None: @@ -340,6 +342,14 @@ class ToQTextDocument(Tokenizer): cFmt.setVerticalAlignment(QtVAlignSub) elif fmt == self.FMT_SUB_E: cFmt.setVerticalAlignment(QtVAlignNormal) + elif fmt == self.FMT_DL_B: + cFmt.setForeground(self._theme.dialog) + elif fmt == self.FMT_DL_E: + cFmt.setForeground(self._theme.text) + elif fmt == self.FMT_ADL_B: + cFmt.setForeground(self._theme.altdialog) + elif fmt == self.FMT_ADL_E: + cFmt.setForeground(self._theme.text) elif fmt == self.FMT_FNOTE: xFmt = QTextCharFormat(self._cCode) xFmt.setVerticalAlignment(QtVAlignSuper) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index a1055802..6581d601 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2030,6 +2030,9 @@ class GuiDocEditor(QPlainTextEdit): cursor.movePosition(QtMoveLeft, QtKeepAnchor, nDelete) cursor.insertText(tInsert) + # Re-highlight, since the auto-replace sometimes interferes with it + self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block()) + return @staticmethod diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index c00f7b44..bb2cfc49 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -39,6 +39,7 @@ from novelwriter.common import checkInt from novelwriter.constants import nwHeaders, nwRegEx, nwUnicode from novelwriter.core.index import processComment from novelwriter.enum import nwComment +from novelwriter.text.patterns import REGEX_PATTERNS from novelwriter.types import QRegExUnicode logger = logging.getLogger(__name__) @@ -59,8 +60,8 @@ BLOCK_TITLE = 4 class GuiDocHighlighter(QSyntaxHighlighter): __slots__ = ( - "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hStyles", - "_txtRules", "_cmnRules", + "_tHandle", "_isNovel", "_isInactive", "_spellCheck", "_spellErr", + "_hStyles", "_minRules", "_txtRules", "_cmnRules", ) def __init__(self, document: QTextDocument) -> None: @@ -69,11 +70,13 @@ class GuiDocHighlighter(QSyntaxHighlighter): logger.debug("Create: GuiDocHighlighter") self._tHandle = None + self._isNovel = False self._isInactive = False self._spellCheck = False self._spellErr = QTextCharFormat() self._hStyles: dict[str, QTextCharFormat] = {} + self._minRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = [] self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = [] self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = [] @@ -137,6 +140,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): hlRule = { 0: self._hStyles["mspaces"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) @@ -146,106 +150,89 @@ class GuiDocHighlighter(QSyntaxHighlighter): hlRule = { 0: self._hStyles["nobreak"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Dialogue 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 "" - rxRule = QRegularExpression(f"\\B[{symO}].*?[{symC}]\\B{rxEnd}") - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.dialogStyle hlRule = { 0: self._hStyles["dialog"], } self._txtRules.append((rxRule, hlRule)) if CONFIG.dialogLine: - sym = QRegularExpression.escape(CONFIG.dialogLine) - rxRule = QRegularExpression(f"^{sym}.*?$") - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.dialogLine hlRule = { 0: self._hStyles["dialog"], } self._txtRules.append((rxRule, hlRule)) if CONFIG.narratorBreak: - sym = QRegularExpression.escape(CONFIG.narratorBreak) - rxRule = QRegularExpression(f"({sym}\\b)(.*?)(\\b{sym})") - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.narratorBreak hlRule = { 0: self._hStyles["text"], } self._txtRules.append((rxRule, hlRule)) if CONFIG.altDialogOpen and CONFIG.altDialogClose: - symO = QRegularExpression.escape(CONFIG.altDialogOpen) - symC = QRegularExpression.escape(CONFIG.altDialogClose) - rxRule = QRegularExpression(f"\\B{symO}.*?{symC}\\B") - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.altDialogStyle hlRule = { 0: self._hStyles["altdialog"], } self._txtRules.append((rxRule, hlRule)) # Markdown Italic - rxRule = QRegularExpression(nwRegEx.FMT_EI) - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.markdownItalic hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["italic"], 3: self._hStyles["markup"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Markdown Bold - rxRule = QRegularExpression(nwRegEx.FMT_EB) - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.markdownBold hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["bold"], 3: self._hStyles["markup"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Markdown Strikethrough - rxRule = QRegularExpression(nwRegEx.FMT_ST) - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.markdownStrike hlRule = { 1: self._hStyles["markup"], 2: self._hStyles["strike"], 3: self._hStyles["markup"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Shortcodes - rxRule = QRegularExpression(nwRegEx.FMT_SC) - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.shortcodePlain hlRule = { 1: self._hStyles["code"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) # Shortcodes w/Value - rxRule = QRegularExpression(nwRegEx.FMT_SV) - rxRule.setPatternOptions(QRegExUnicode) + rxRule = REGEX_PATTERNS.shortcodeValue hlRule = { 1: self._hStyles["code"], 2: self._hStyles["value"], 3: self._hStyles["code"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) @@ -255,6 +242,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): hlRule = { 1: self._hStyles["markup"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) # Auto-Replace Tags @@ -263,6 +251,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): hlRule = { 0: self._hStyles["replace"], } + self._minRules.append((rxRule, hlRule)) self._txtRules.append((rxRule, hlRule)) self._cmnRules.append((rxRule, hlRule)) @@ -280,9 +269,11 @@ class GuiDocHighlighter(QSyntaxHighlighter): def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle - self._isInactive = ( - item.isInactiveClass() if (item := SHARED.project.tree[tHandle]) else False - ) + self._isNovel = False + self._isInactive = False + if item := SHARED.project.tree[tHandle]: + self._isNovel = item.isDocumentLayout() + self._isInactive = item.isInactiveClass() logger.debug("Syntax highlighter enabled for item '%s'", tHandle) return @@ -397,7 +388,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): elif text.startswith("["): # Special Command self.setCurrentBlockState(BLOCK_TEXT) - hRules = self._txtRules + hRules = self._txtRules if self._isNovel else self._minRules sText = text.rstrip().lower() if sText in ("[newpage]", "[new page]", "[vspace]"): @@ -414,7 +405,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): else: # Text Paragraph self.setCurrentBlockState(BLOCK_TEXT) - hRules = self._txtRules + hRules = self._txtRules if self._isNovel else self._minRules if hRules: for rX, hRule in hRules: diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index d73f0abb..20e1888b 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -165,6 +165,8 @@ class GuiDocViewer(QTextBrowser): self._docTheme.keyword = SHARED.theme.colKey self._docTheme.tag = SHARED.theme.colTag self._docTheme.optional = SHARED.theme.colOpt + self._docTheme.dialog = SHARED.theme.colDialN + self._docTheme.altdialog = SHARED.theme.colDialA # Set default text margins self.document().setDocumentMargin(0) @@ -201,6 +203,7 @@ class GuiDocViewer(QTextBrowser): sPos = self.verticalScrollBar().value() qDoc = ToQTextDocument(SHARED.project) qDoc.setJustify(CONFIG.doJustify) + qDoc.setDialogueHighlight(True) qDoc.initDocument(CONFIG.textFont, self._docTheme) qDoc.setKeywords(True) qDoc.setComments(CONFIG.viewComments) @@ -361,8 +364,10 @@ class GuiDocViewer(QTextBrowser): """Process a clicked link in the document.""" if link := url.url(): logger.debug("Clicked link: '%s'", link) - if (bits := link.partition("_")) and bits[2]: + if (bits := link.partition("_")) and bits[0] == "#tag" and bits[2]: self.loadDocumentTagRequest.emit(bits[2], nwDocMode.VIEW) + else: + self.navigateTo(link) return @pyqtSlot("QPoint") diff --git a/novelwriter/text/patterns.py b/novelwriter/text/patterns.py new file mode 100644 index 00000000..4d60222e --- /dev/null +++ b/novelwriter/text/patterns.py @@ -0,0 +1,113 @@ +""" +novelWriter – Text Pattern Functions +==================================== + +File History: +Created: 2024-06-01 [2.5ec1] + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +from PyQt5.QtCore import QRegularExpression + +from novelwriter import CONFIG +from novelwriter.constants import nwRegEx +from novelwriter.types import QRegExUnicode + + +class RegExPatterns: + + @property + def markdownItalic(self) -> QRegularExpression: + """Markdown italic style.""" + rxRule = QRegularExpression(nwRegEx.FMT_EI) + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def markdownBold(self) -> QRegularExpression: + """Markdown bold style.""" + rxRule = QRegularExpression(nwRegEx.FMT_EB) + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def markdownStrike(self) -> QRegularExpression: + """Markdown strikethrough style.""" + rxRule = QRegularExpression(nwRegEx.FMT_ST) + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def shortcodePlain(self) -> QRegularExpression: + """Plain shortcode style.""" + rxRule = QRegularExpression(nwRegEx.FMT_SC) + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def shortcodeValue(self) -> QRegularExpression: + """Plain shortcode style.""" + rxRule = QRegularExpression(nwRegEx.FMT_SV) + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def dialogStyle(self) -> QRegularExpression: + """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 "" + rxRule = QRegularExpression(f"\\B[{symO}].*?(?:[{symC}]\\B{rxEnd})") + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def dialogLine(self) -> QRegularExpression: + """Dialogue line rule based on user settings.""" + sym = QRegularExpression.escape(CONFIG.dialogLine) + rxRule = QRegularExpression(f"^{sym}.*?$") + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def narratorBreak(self) -> QRegularExpression: + """Dialogue narrator break rule based on user settings.""" + sym = QRegularExpression.escape(CONFIG.narratorBreak) + rxRule = QRegularExpression(f"\\B{sym}\\S.*?\\S{sym}\\B") + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + @property + def altDialogStyle(self) -> QRegularExpression: + """Dialogue alternative rule based on user settings.""" + symO = QRegularExpression.escape(CONFIG.altDialogOpen) + symC = QRegularExpression.escape(CONFIG.altDialogClose) + rxRule = QRegularExpression(f"\\B{symO}.*?{symC}\\B") + rxRule.setPatternOptions(QRegExUnicode) + return rxRule + + +REGEX_PATTERNS = RegExPatterns() diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 85105b86..f43eab64 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -341,6 +341,8 @@ class GuiManuscript(NToolDialog): theme.keyword = QColor(245, 135, 31) theme.tag = QColor(66, 113, 174) theme.optional = QColor(66, 113, 174) + theme.dialog = QColor(66, 113, 174) + theme.altdialog = QColor(129, 55, 9) self.docPreview.beginNewBuild(len(docBuild)) for step, _ in docBuild.iterBuildPreview(theme): diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 23b2a946..f060a7b0 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -1093,11 +1093,13 @@ class _FormatTab(NScrollableForm): self.stripUnicode = NSwitch(self, height=iPx) self.replaceTabs = NSwitch(self, height=iPx) self.keepBreaks = NSwitch(self, height=iPx) + self.showDialogue = NSwitch(self, height=iPx) self.addRow(self._build.getLabel("format.justifyText"), self.justifyText) self.addRow(self._build.getLabel("format.stripUnicode"), self.stripUnicode) self.addRow(self._build.getLabel("format.replaceTabs"), self.replaceTabs) self.addRow(self._build.getLabel("format.keepBreaks"), self.keepBreaks) + self.addRow(self._build.getLabel("format.showDialogue"), self.showDialogue) # First Line Indent # ================= @@ -1180,6 +1182,7 @@ class _FormatTab(NScrollableForm): self.stripUnicode.setChecked(self._build.getBool("format.stripUnicode")) self.replaceTabs.setChecked(self._build.getBool("format.replaceTabs")) self.keepBreaks.setChecked(self._build.getBool("format.keepBreaks")) + self.showDialogue.setChecked(self._build.getBool("format.showDialogue")) self.firstIndent.setChecked(self._build.getBool("format.firstLineIndent")) self.indentWidth.setValue(self._build.getFloat("format.firstIndentWidth")) @@ -1219,6 +1222,7 @@ class _FormatTab(NScrollableForm): self._build.setValue("format.stripUnicode", self.stripUnicode.isChecked()) self._build.setValue("format.replaceTabs", self.replaceTabs.isChecked()) self._build.setValue("format.keepBreaks", self.keepBreaks.isChecked()) + self._build.setValue("format.showDialogue", self.showDialogue.isChecked()) self._build.setValue("format.firstLineIndent", self.firstIndent.isChecked()) self._build.setValue("format.firstIndentWidth", self.indentWidth.value()) diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm index abcaa5f4..d5348e24 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm @@ -19,6 +19,8 @@ mark {background: rgb(255, 255, 166);} .break {text-align: left;} .synopsis {font-style: italic;} .comment {font-style: italic; color: rgb(100, 100, 100);} +.dialog {color: rgb(66, 113, 174);} +.altdialog {color: rgb(129, 55, 9);}
diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json index 8eac4a6b..ab8b2d8f 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.json @@ -2,8 +2,8 @@ "meta": { "projectName": "Lorem Ipsum", "novelAuthor": "lipsum.com", - "buildTime": 1716803284, - "buildTimeStr": "2024-05-27 11:48:04" + "buildTime": 1718057434, + "buildTimeStr": "2024-06-11 00:10:34" }, "text": { "css": [ @@ -20,7 +20,9 @@ ".keyword {color: rgb(245, 135, 31); font-weight: bold;}", ".break {text-align: left;}", ".synopsis {font-style: italic;}", - ".comment {font-style: italic; color: rgb(100, 100, 100);}" + ".comment {font-style: italic; color: rgb(100, 100, 100);}", + ".dialog {color: rgb(66, 113, 174);}", + ".altdialog {color: rgb(129, 55, 9);}" ], "html": [ [ diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index 8a4a54ab..9e150295 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -24,6 +24,7 @@ import json import pytest +from novelwriter import CONFIG from novelwriter.core.project import NWProject from novelwriter.core.tohtml import ToHtml @@ -260,6 +261,28 @@ def testCoreToHtml_ConvertParagraphs(mockGUI): "Europe

\n" ) + # Dialogue + html.setDialogueHighlight(True) + html._text = "## Chapter\n\nThis text \u201chas dialogue\u201d in it.\n\n" + html.tokenizeText() + html.doConvert() + assert html.result == ( + "

Chapter

\n" + "

This text \u201chas dialogue\u201d in it.

\n" + ) + + # Alt. Dialogue + CONFIG.altDialogOpen = "::" + CONFIG.altDialogClose = "::" + html.setDialogueHighlight(True) + html._text = "## Chapter\n\nThis text :: has alt dialogue :: in it.\n\n" + html.tokenizeText() + html.doConvert() + assert html.result == ( + "

Chapter

\n" + "

This text :: has alt dialogue :: in it.

\n" + ) + # Footnotes # ========= diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 814a27be..aa23ad1d 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -26,6 +26,7 @@ import pytest from PyQt5.QtGui import QFont +from novelwriter import CONFIG from novelwriter.constants import nwHeadFmt from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import HeadingFormatter, Tokenizer, stripEscape @@ -1016,88 +1017,151 @@ def testCoreToken_TextFormat(mockGUI): # Text Emphasis tokens._text = "Some **bolded text** on this lines\n" tokens.tokenizeText() - assert tokens._tokens == [ - ( - Tokenizer.T_TEXT, 0, - "Some bolded text on this lines", - [ - (5, Tokenizer.FMT_B_B, ""), - (16, Tokenizer.FMT_B_E, ""), - ], - Tokenizer.A_NONE - ), - ] + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, "Some bolded text on this lines", + [ + (5, Tokenizer.FMT_B_B, ""), + (16, Tokenizer.FMT_B_E, ""), + ], + Tokenizer.A_NONE + )] assert tokens.allMarkdown[-1] == "Some **bolded text** on this lines\n\n" tokens._text = "Some _italic text_ on this lines\n" tokens.tokenizeText() - assert tokens._tokens == [ - ( - Tokenizer.T_TEXT, 0, - "Some italic text on this lines", - [ - (5, Tokenizer.FMT_I_B, ""), - (16, Tokenizer.FMT_I_E, ""), - ], - Tokenizer.A_NONE - ), - ] + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, "Some italic text on this lines", + [ + (5, Tokenizer.FMT_I_B, ""), + (16, Tokenizer.FMT_I_E, ""), + ], + Tokenizer.A_NONE + )] assert tokens.allMarkdown[-1] == "Some _italic text_ on this lines\n\n" tokens._text = "Some **_bold italic text_** on this lines\n" tokens.tokenizeText() - assert tokens._tokens == [ - ( - Tokenizer.T_TEXT, 0, - "Some bold italic text on this lines", - [ - (5, Tokenizer.FMT_B_B, ""), - (5, Tokenizer.FMT_I_B, ""), - (21, Tokenizer.FMT_I_E, ""), - (21, Tokenizer.FMT_B_E, ""), - ], - Tokenizer.A_NONE - ), - ] + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, "Some bold italic text on this lines", + [ + (5, Tokenizer.FMT_B_B, ""), + (5, Tokenizer.FMT_I_B, ""), + (21, Tokenizer.FMT_I_E, ""), + (21, Tokenizer.FMT_B_E, ""), + ], + Tokenizer.A_NONE + )] assert tokens.allMarkdown[-1] == "Some **_bold italic text_** on this lines\n\n" tokens._text = "Some ~~strikethrough text~~ on this lines\n" tokens.tokenizeText() - assert tokens._tokens == [ - ( - Tokenizer.T_TEXT, 0, - "Some strikethrough text on this lines", - [ - (5, Tokenizer.FMT_D_B, ""), - (23, Tokenizer.FMT_D_E, ""), - ], - Tokenizer.A_NONE - ), - ] + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, "Some strikethrough text on this lines", + [ + (5, Tokenizer.FMT_D_B, ""), + (23, Tokenizer.FMT_D_E, ""), + ], + Tokenizer.A_NONE + )] assert tokens.allMarkdown[-1] == "Some ~~strikethrough text~~ on this lines\n\n" tokens._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" tokens.tokenizeText() - assert tokens._tokens == [ - ( - Tokenizer.T_TEXT, 0, - "Some nested bold and italic and strikethrough text here", - [ - (5, Tokenizer.FMT_B_B, ""), - (21, Tokenizer.FMT_I_B, ""), - (27, Tokenizer.FMT_I_E, ""), - (32, Tokenizer.FMT_D_B, ""), - (45, Tokenizer.FMT_D_E, ""), - (50, Tokenizer.FMT_B_E, ""), - ], - Tokenizer.A_NONE - ), - ] + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, "Some nested bold and italic and strikethrough text here", + [ + (5, Tokenizer.FMT_B_B, ""), + (21, Tokenizer.FMT_I_B, ""), + (27, Tokenizer.FMT_I_E, ""), + (32, Tokenizer.FMT_D_B, ""), + (45, Tokenizer.FMT_D_E, ""), + (50, Tokenizer.FMT_B_E, ""), + ], + Tokenizer.A_NONE + )] assert tokens.allMarkdown[-1] == ( "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" ) +@pytest.mark.core +def testCoreToken_Dialogue(mockGUI): + """Test the tokenization of dialogue in the Tokenizer class.""" + CONFIG.fmtDQuoteOpen = "\u201c" + CONFIG.fmtDQuoteClose = "\u201d" + CONFIG.fmtSQuoteOpen = "\u2018" + CONFIG.fmtSQuoteClose = "\u2019" + CONFIG.dialogStyle = 3 + CONFIG.altDialogOpen = "::" + CONFIG.altDialogClose = "::" + CONFIG.dialogLine = "\u2013" + CONFIG.narratorBreak = "\u2013" + + project = NWProject() + tokens = BareTokenizer(project) + tokens.setDialogueHighlight(True) + + # Single quotes + tokens._text = "Text with \u2018dialogue one,\u2019 and \u2018dialogue two.\u2019\n" + tokens.tokenizeText() + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, + "Text with \u2018dialogue one,\u2019 and \u2018dialogue two.\u2019", + [ + (10, Tokenizer.FMT_DL_B, ""), + (25, Tokenizer.FMT_DL_E, ""), + (30, Tokenizer.FMT_DL_B, ""), + (45, Tokenizer.FMT_DL_E, ""), + ], + Tokenizer.A_NONE + )] + + # Double quotes + tokens._text = "Text with \u201cdialogue one,\u201d and \u201cdialogue two.\u201d\n" + tokens.tokenizeText() + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, + "Text with \u201cdialogue one,\u201d and \u201cdialogue two.\u201d", + [ + (10, Tokenizer.FMT_DL_B, ""), + (25, Tokenizer.FMT_DL_E, ""), + (30, Tokenizer.FMT_DL_B, ""), + (45, Tokenizer.FMT_DL_E, ""), + ], + Tokenizer.A_NONE + )] + + # Alt quotes + tokens._text = "Text with ::dialogue one,:: and ::dialogue two.::\n" + tokens.tokenizeText() + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, + "Text with ::dialogue one,:: and ::dialogue two.::", + [ + (10, Tokenizer.FMT_ADL_B, ""), + (27, Tokenizer.FMT_ADL_E, ""), + (32, Tokenizer.FMT_ADL_B, ""), + (49, Tokenizer.FMT_ADL_E, ""), + ], + Tokenizer.A_NONE + )] + + # Dialogue line with narrator break + tokens._text = "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?\n" + tokens.tokenizeText() + assert tokens._tokens == [( + Tokenizer.T_TEXT, 0, + "\u2013 Dialogue with a narrator break, \u2013he said,\u2013 see?", + [ + (0, Tokenizer.FMT_DL_B, ""), + (34, Tokenizer.FMT_DL_E, ""), + (44, Tokenizer.FMT_DL_B, ""), + (49, Tokenizer.FMT_DL_E, ""), + ], + Tokenizer.A_NONE + )] + + @pytest.mark.core def testCoreToken_SpecialFormat(mockGUI): """Test the tokenization of special formats in the Tokenizer class.""" @@ -1267,11 +1331,6 @@ def testCoreToken_ProcessHeaders(mockGUI): project.data.setLanguage("en") project._loadProjectLocalisation() tokens = BareTokenizer(project) - - ## - # Story Files - ## - tokens._isNovel = True # Titles diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index 4a341165..77f17a0d 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -234,6 +234,42 @@ def testCoreToOdt_TextFormatting(mockGUI): ) +@pytest.mark.core +def testCoreToOdt_DialogueFormatting(mockGUI): + """Test formatting of dialogue.""" + project = NWProject() + odt = ToOdt(project, isFlat=True) + odt.setDialogueHighlight(True) + odt.initDocument() + oStyle = ODTParagraphStyle("test") + + # Regular dialogue + text = "Text with 'dialogue in it.'" + fmt = [(10, odt.FMT_DL_B, ""), (27, odt.FMT_DL_E, "")] + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) + assert odt.errData == [] + assert xmlToText(xTest) == ( + '' + 'Text with ' + '\'dialogue in it.\'' + '' + ) + + # Alternative dialogue + text = "Text with ::dialogue in it.::" + fmt = [(10, odt.FMT_ADL_B, ""), (29, odt.FMT_ADL_E, "")] + xTest = ET.Element(_mkTag("office", "text")) + odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt) + assert odt.errData == [] + assert xmlToText(xTest) == ( + '' + 'Text with ' + '::dialogue in it.::' + '' + ) + + @pytest.mark.core def testCoreToOdt_ConvertHeaders(mockGUI): """Test the converter of the ToOdt class.""" @@ -854,8 +890,8 @@ def testCoreToOdt_SaveFull(mockGUI, fncPath, tstPaths): @pytest.mark.core -def testCoreToOdt_Format(mockGUI): - """Test the formatters for the ToOdt class.""" +def testCoreToOdt_SpecialFormats(mockGUI): + """Test the special formatters for the ToOdt class.""" project = NWProject() odt = ToOdt(project, isFlat=True) @@ -1168,6 +1204,17 @@ def testCoreToOdt_ODTTextStyle(): txtStyle.setFontStyle("stuff") assert txtStyle._tAttr["font-style"] == ["fo", None] + # Text Color + assert txtStyle._tAttr["color"] == ["fo", None] + txtStyle.setColour("stuff") + assert txtStyle._tAttr["color"] == ["fo", None] + txtStyle.setColour("012345") + assert txtStyle._tAttr["color"] == ["fo", None] + txtStyle.setColour("#012345") + assert txtStyle._tAttr["color"] == ["fo", "#012345"] + txtStyle.setColour("stuff") + assert txtStyle._tAttr["color"] == ["fo", None] + # Background Color assert txtStyle._tAttr["background-color"] == ["fo", None] txtStyle.setBackgroundColour("stuff") diff --git a/tests/test_core/test_core_toqdoc.py b/tests/test_core/test_core_toqdoc.py index bc83b1af..b83badbe 100644 --- a/tests/test_core/test_core_toqdoc.py +++ b/tests/test_core/test_core_toqdoc.py @@ -44,6 +44,8 @@ THEME.modifier = QColor(129, 55, 9) THEME.keyword = QColor(245, 135, 31) THEME.tag = QColor(66, 113, 174) THEME.optional = QColor(66, 113, 174) +THEME.dialog = QColor(113, 140, 0) +THEME.altdialog = QColor(234, 183, 0) def charFmtInBlock(block: QTextBlock, pos: int) -> QTextCharFormat: @@ -441,11 +443,17 @@ def testCoreToQTextDocument_TextBlockFormats(mockGUI): @pytest.mark.core def testCoreToQTextDocument_TextCharFormats(mockGUI): """Test text char formats in the ToQTextDocument class.""" + CONFIG.fmtDQuoteOpen = nwUnicode.U_LDQUO + CONFIG.fmtDQuoteClose = nwUnicode.U_RDQUO + CONFIG.altDialogOpen = "|<" + CONFIG.altDialogClose = ">|" + project = NWProject() qdoc = ToQTextDocument(project) # Convert before init qdoc._text = "Blabla" + qdoc.setDialogueHighlight(True) qdoc.doConvert() qdoc.tokenizeText() assert qdoc.document.toPlainText() == "" @@ -465,10 +473,12 @@ def testCoreToQTextDocument_TextCharFormats(mockGUI): "With [m]highlighted[/m] text\n\n" "With super[sup]script[/sup] text\n\n" "With sub[sub]script[/sub] text\n\n" + "With \u201cdialog\u201d text\n\n" + "With || text\n\n" ) qdoc.tokenizeText() qdoc.doConvert() - assert qdoc.document.blockCount() == 8 + assert qdoc.document.blockCount() == 10 # 0: Scene block = qdoc.document.findBlockByNumber(0) @@ -544,6 +554,26 @@ def testCoreToQTextDocument_TextCharFormats(mockGUI): cFmt = charFmtInBlock(block, 15) assert cFmt.verticalAlignment() == QtVAlignNormal + # 8: Dialogue + block = qdoc.document.findBlockByNumber(8) + assert block.text() == "With \u201cdialog\u201d text" + cFmt = charFmtInBlock(block, 1) + assert cFmt.foreground() == THEME.text + cFmt = charFmtInBlock(block, 6) + assert cFmt.foreground() == THEME.dialog + cFmt = charFmtInBlock(block, 14) + assert cFmt.foreground() == THEME.text + + # 9: Alt. Dialogue + block = qdoc.document.findBlockByNumber(9) + assert block.text() == "With || text" + cFmt = charFmtInBlock(block, 1) + assert cFmt.foreground() == THEME.text + cFmt = charFmtInBlock(block, 6) + assert cFmt.foreground() == THEME.altdialog + cFmt = charFmtInBlock(block, 28) + assert cFmt.foreground() == THEME.text + @pytest.mark.core def testCoreToQTextDocument_Footnotes(mockGUI): diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index c82e8332..efbccb38 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -160,6 +160,11 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum): docViewer._linkClicked(QUrl("#tag_bod")) assert docViewer.docHandle == "4c4f28287af27" + # Other links should just trigger a navigate call + with qtbot.waitSignal(docViewer.sourceChanged, timeout=1000) as signal: + docViewer._linkClicked(QUrl("#somewhere_else")) + assert signal.args[0].url() == "#somewhere_else" + # Click mouse nav buttons qtbot.mouseClick(docViewer.viewport(), Qt.BackButton, pos=rect.center(), delay=100) assert docViewer.docHandle == "88243afbe5ed8" diff --git a/tests/test_gui/test_gui_i18n.py b/tests/test_gui/test_gui_i18n.py index 93f333a7..83638f13 100644 --- a/tests/test_gui/test_gui_i18n.py +++ b/tests/test_gui/test_gui_i18n.py @@ -64,9 +64,6 @@ def testGuiI18n_Localisation(qtbot, monkeypatch, language, nwGUI, projPath): qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(dType) is not None, timeout=1000) dialog = SHARED.findTopLevelWidget(dType) assert isinstance(dialog, dType) - with qtbot.waitExposed(dialog): - dialog.show() - dialog.close() showDialog(nwGUI.showWelcomeDialog, GuiWelcome) showDialog(nwGUI.showPreferencesDialog, GuiPreferences) diff --git a/tests/test_text/test_text_patterns.py b/tests/test_text/test_text_patterns.py new file mode 100644 index 00000000..1b4623fd --- /dev/null +++ b/tests/test_text/test_text_patterns.py @@ -0,0 +1,272 @@ +""" +novelWriter – Patterns Module Tester +==================================== + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import pytest + +from PyQt5.QtCore import QRegularExpression + +from novelwriter import CONFIG +from novelwriter.constants import nwUnicode +from novelwriter.text.patterns import REGEX_PATTERNS + + +def allMatches(regEx: QRegularExpression, text: str) -> list[list[str]]: + """Get all matches for a regex.""" + result = [] + itt = regEx.globalMatch(text, 0) + while itt.hasNext(): + match = itt.next() + result.append([ + (match.captured(n), match.capturedStart(n), match.capturedEnd(n)) + for n in range(match.lastCapturedIndex() + 1) + ]) + return result + + +@pytest.mark.core +def testTextPatterns_Markdown(): + """Test the markdown pattern regexes.""" + # Bold + regEx = REGEX_PATTERNS.markdownBold + assert allMatches(regEx, "one **two** three") == [ + [("**two**", 4, 11), ("**", 4, 6), ("two", 6, 9), ("**", 9, 11)] + ] + assert allMatches(regEx, "one **two* three") == [] + assert allMatches(regEx, "one *two** three") == [] + assert allMatches(regEx, "one**two**three") == [] + + # Italic + regEx = REGEX_PATTERNS.markdownItalic + assert allMatches(regEx, "one _two_ three") == [ + [("_two_", 4, 9), ("_", 4, 5), ("two", 5, 8), ("_", 8, 9)] + ] + assert allMatches(regEx, "one __two_ three") == [] + assert allMatches(regEx, "one _two__ three") == [ + [("_two__", 4, 10), ("_", 4, 5), ("two_", 5, 9), ("_", 9, 10)] + ] + assert allMatches(regEx, "one_two_three") == [] + + # Strike + regEx = REGEX_PATTERNS.markdownStrike + assert allMatches(regEx, "one ~~two~~ three") == [ + [("~~two~~", 4, 11), ("~~", 4, 6), ("two", 6, 9), ("~~", 9, 11)] + ] + assert allMatches(regEx, "one ~~two~ three") == [] + assert allMatches(regEx, "one ~two~~ three") == [] + assert allMatches(regEx, "one~~two~~three") == [] + + +@pytest.mark.core +def testTextPatterns_ShortcodesPlain(): + """Test the shortcode pattern regexes.""" + regEx = REGEX_PATTERNS.shortcodePlain + + # Test Usage + # ========== + + # General, normal usage + assert allMatches(regEx, "one [b]two[/b] three") == [ + [("[b]", 4, 7), ("[b]", 4, 7)], + [("[/b]", 10, 14), ("[/b]", 10, 14)], + ] + + # General, no spaces + assert allMatches(regEx, "one[b]two[/b]three") == [ + [("[b]", 3, 6), ("[b]", 3, 6)], + [("[/b]", 9, 13), ("[/b]", 9, 13)], + ] + + # General, with padding + assert allMatches(regEx, "one [b] two [/b] three") == [ + [("[b]", 4, 7), ("[b]", 4, 7)], + [("[/b]", 12, 16), ("[/b]", 12, 16)], + ] + + # General, with escapes + assert allMatches(regEx, "one \\[b]two[/b\\] three") == [] + + # Test Formats + # ============ + + # Bold + assert allMatches(regEx, "one [b]two[/b] three") == [ + [("[b]", 4, 7), ("[b]", 4, 7)], + [("[/b]", 10, 14), ("[/b]", 10, 14)], + ] + + # Italic + assert allMatches(regEx, "one [i]two[/i] three") == [ + [("[i]", 4, 7), ("[i]", 4, 7)], + [("[/i]", 10, 14), ("[/i]", 10, 14)], + ] + + # Strike + assert allMatches(regEx, "one [s]two[/s] three") == [ + [("[s]", 4, 7), ("[s]", 4, 7)], + [("[/s]", 10, 14), ("[/s]", 10, 14)], + ] + + # Underline + assert allMatches(regEx, "one [u]two[/u] three") == [ + [("[u]", 4, 7), ("[u]", 4, 7)], + [("[/u]", 10, 14), ("[/u]", 10, 14)], + ] + + # Mark + assert allMatches(regEx, "one [m]two[/m] three") == [ + [("[m]", 4, 7), ("[m]", 4, 7)], + [("[/m]", 10, 14), ("[/m]", 10, 14)], + ] + + # Superscript + assert allMatches(regEx, "one [sup]two[/sup] three") == [ + [("[sup]", 4, 9), ("[sup]", 4, 9)], + [("[/sup]", 12, 18), ("[/sup]", 12, 18)], + ] + + # Subscript + assert allMatches(regEx, "one [sub]two[/sub] three") == [ + [("[sub]", 4, 9), ("[sub]", 4, 9)], + [("[/sub]", 12, 18), ("[/sub]", 12, 18)], + ] + + # Test Invalid + # ============ + + assert allMatches(regEx, "one [x]two[/x] three") == [] + + +@pytest.mark.core +def testTextPatterns_ShortcodesValue(): + """Test the shortcode with value pattern regexes.""" + regEx = REGEX_PATTERNS.shortcodeValue + + assert allMatches(regEx, "one [footnote:two] three") == [ + [("[footnote:two]", 4, 18), ("[footnote:", 4, 14), ("two", 14, 17), ("]", 17, 18)] + ] + + +@pytest.mark.core +def testTextPatterns_DialogueStyle(): + """Test the 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 + + CONFIG.dialogStyle = 3 + + # Closed + # ====== + + CONFIG.allowOpenDial = False + regEx = REGEX_PATTERNS.dialogStyle + + # Defined single quotes are recognised + assert allMatches(regEx, "one \u2018two\u2019 three") == [ + [("\u2018two\u2019", 4, 9)] + ] + + # Defined double quotes are recognised + assert allMatches(regEx, "one \u201ctwo\u201d three") == [ + [("\u201ctwo\u201d", 4, 9)] + ] + + # Straight single quotes are ignored + assert allMatches(regEx, "one 'two' three") == [] + + # Straight double quotes are ignored + assert allMatches(regEx, "one \"two\" three") == [] + + # Skipping whitespace is not allowed + assert allMatches(regEx, "one\u2018two\u2019three") == [] + + # Open + # ==== + + CONFIG.allowOpenDial = True + regEx = REGEX_PATTERNS.dialogStyle + + # Defined single quotes are recognised also when open + assert allMatches(regEx, "one \u2018two three") == [ + [("\u2018two three", 4, 14)] + ] + + # Defined double quotes are recognised also when open + assert allMatches(regEx, "one \u201ctwo three") == [ + [("\u201ctwo three", 4, 14)] + ] + + +@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 + + CONFIG.dialogStyle = 3 + CONFIG.dialogLine = nwUnicode.U_ENDASH + CONFIG.narratorBreak = nwUnicode.U_ENDASH + CONFIG.altDialogOpen = "::" + CONFIG.altDialogClose = "::" + + # Dialogue Line + # ============= + regEx = REGEX_PATTERNS.dialogLine + + # Check dialogue line in first position + assert allMatches(regEx, "\u2013 one two three") == [ + [("\u2013 one two three", 0, 15)] + ] + + # Check dialogue line in second position + assert allMatches(regEx, " \u2013 one two three") == [] + + # Narrator Break + # ============== + regEx = REGEX_PATTERNS.narratorBreak + + # Narrator break with no padding + assert allMatches(regEx, "one \u2013two\u2013 three") == [ + [("\u2013two\u2013", 4, 9)] + ] + + # Narrator break with padding + assert allMatches(regEx, "one \u2013 two \u2013 three") == [] + + # Alternative Dialogue + # ==================== + regEx = REGEX_PATTERNS.altDialogStyle + + # With no padding + assert allMatches(regEx, "one ::two:: three") == [ + [("::two::", 4, 11)] + ] + + # With padding + assert allMatches(regEx, "one :: two :: three") == [ + [(":: two ::", 4, 13)] + ] diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py index 76a41a48..cc39745e 100644 --- a/tests/test_tools/test_tools_manussettings.py +++ b/tests/test_tools/test_tools_manussettings.py @@ -555,6 +555,12 @@ def testToolBuildSettings_Format(monkeypatch, qtbot, nwGUI): build.setValue("format.justifyText", False) build.setValue("format.stripUnicode", False) build.setValue("format.replaceTabs", False) + build.setValue("format.keepBreaks", True) + build.setValue("format.showDialogue", False) + + build.setValue("format.firstLineIndent", False) + build.setValue("format.firstIndentWidth", 1.4) + build.setValue("format.indentFirstPar", False) build.setValue("format.pageUnit", "mm") build.setValue("format.pageSize", "Custom") @@ -580,6 +586,8 @@ def testToolBuildSettings_Format(monkeypatch, qtbot, nwGUI): assert fmtTab.justifyText.isChecked() is False assert fmtTab.stripUnicode.isChecked() is False assert fmtTab.replaceTabs.isChecked() is False + assert fmtTab.keepBreaks.isChecked() is True + assert fmtTab.showDialogue.isChecked() is False assert fmtTab.firstIndent.isChecked() is False assert fmtTab.indentWidth.value() == 1.4 @@ -603,6 +611,8 @@ def testToolBuildSettings_Format(monkeypatch, qtbot, nwGUI): fmtTab.justifyText.setChecked(True) fmtTab.stripUnicode.setChecked(True) fmtTab.replaceTabs.setChecked(True) + fmtTab.keepBreaks.setChecked(False) + fmtTab.showDialogue.setChecked(True) fmtTab.firstIndent.setChecked(True) fmtTab.indentWidth.setValue(2.0) @@ -620,6 +630,8 @@ def testToolBuildSettings_Format(monkeypatch, qtbot, nwGUI): assert build.getBool("format.justifyText") is True assert build.getBool("format.stripUnicode") is True assert build.getBool("format.replaceTabs") is True + assert build.getBool("format.keepBreaks") is False + assert build.getBool("format.showDialogue") is True assert build.getBool("format.firstLineIndent") is True assert build.getFloat("format.firstIndentWidth") == 2.0