Extend dialogue highlighting (#1908)

This commit is contained in:
Veronica Berglyd Olsen
2024-06-11 00:16:32 +02:00
committed by GitHub
23 changed files with 810 additions and 154 deletions
+4 -4
View File
@@ -61,10 +61,10 @@ class nwConst:
class nwRegEx:
FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_ST = r"(?<![\w\\])([~]{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:i|b|s|u|m|sup|sub)\])"
FMT_SV = r"(?<!\\)(\[(?i)(?:footnote):)(.+?)(?<!\\)(\])"
FMT_EB = r"(?<![\w\\])(\*{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_ST = r"(?<![\w\\])(~{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:b|i|s|u|m|sup|sub)\])"
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote):)(.+?)(?<!\\)(\])"
class nwShortcode:
+2
View File
@@ -82,6 +82,7 @@ SETTINGS_TEMPLATE = {
"format.stripUnicode": (bool, False),
"format.replaceTabs": (bool, False),
"format.keepBreaks": (bool, True),
"format.showDialogue": (bool, False),
"format.firstLineIndent": (bool, False),
"format.firstIndentWidth": (float, 1.4),
"format.indentFirstPar": (bool, False),
@@ -131,6 +132,7 @@ SETTINGS_LABELS = {
"format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"),
"format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"),
"format.keepBreaks": QT_TRANSLATE_NOOP("Builds", "Preserve Hard Line Breaks"),
"format.showDialogue": QT_TRANSLATE_NOOP("Builds", "Apply Dialogue Highlighting"),
"format.grpParIndent": QT_TRANSLATE_NOOP("Builds", "First Line Indent"),
"format.firstLineIndent": QT_TRANSLATE_NOOP("Builds", "Enable Indent"),
+1
View File
@@ -338,6 +338,7 @@ class NWBuildDocument:
bldObj.setJustify(self._build.getBool("format.justifyText"))
bldObj.setLineHeight(self._build.getFloat("format.lineHeight"))
bldObj.setKeepLineBreaks(self._build.getBool("format.keepBreaks"))
bldObj.setDialogueHighlight(self._build.getBool("format.showDialogue"))
bldObj.setFirstLineIndent(
self._build.getBool("format.firstLineIndent"),
self._build.getFloat("format.firstIndentWidth"),
+7 -1
View File
@@ -52,6 +52,10 @@ HTML5_TAGS = {
Tokenizer.FMT_SUP_E: "</sup>",
Tokenizer.FMT_SUB_B: "<sub>",
Tokenizer.FMT_SUB_E: "</sub>",
Tokenizer.FMT_DL_B: "<span class='dialog'>",
Tokenizer.FMT_DL_E: "</span>",
Tokenizer.FMT_ADL_B: "<span class='altdialog'>",
Tokenizer.FMT_ADL_E: "</span>",
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 = "<sup>ERR</sup>"
else:
html = HTML5_TAGS.get(fmt, "ERR")
html = HTML5_TAGS.get(fmt, "")
temp = f"{temp[:pos]}{html}{temp[pos:]}"
temp = temp.replace("\n", "<br>")
return stripEscape(temp)
+50 -11
View File
@@ -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 = []
+45 -14
View File
@@ -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] == "#":
+19 -9
View File
@@ -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)
+3
View File
@@ -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
+30 -39
View File
@@ -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:
+6 -1
View File
@@ -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")
+113
View File
@@ -0,0 +1,113 @@
"""
novelWriter Text Pattern Functions
====================================
File History:
Created: 2024-06-01 [2.5ec1]
This file is a part of novelWriter
Copyright 20182024, 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 <https://www.gnu.org/licenses/>.
"""
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()
+2
View File
@@ -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):
+4
View File
@@ -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())
@@ -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);}
</style>
<body>
<article>
@@ -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": [
[
+23
View File
@@ -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):
"<a class='tag' href='#tag_Europe'>Europe</a></p>\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 == (
"<h1 style='page-break-before: always;'>Chapter</h1>\n"
"<p>This text <span class='dialog'>\u201chas dialogue\u201d</span> in it.</p>\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 == (
"<h1 style='page-break-before: always;'>Chapter</h1>\n"
"<p>This text <span class='altdialog'>:: has alt dialogue ::</span> in it.</p>\n"
)
# Footnotes
# =========
+125 -66
View File
@@ -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
+49 -2
View File
@@ -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) == (
'<office:text>'
'<text:p text:style-name="Standard">Text with '
'<text:span text:style-name="T1">\'dialogue in it.\'</text:span></text:p>'
'</office:text>'
)
# 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) == (
'<office:text>'
'<text:p text:style-name="Standard">Text with '
'<text:span text:style-name="T2">::dialogue in it.::</text:span></text:p>'
'</office:text>'
)
@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")
+31 -1
View File
@@ -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 |<alternative dialog>| 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 |<alternative dialog>| 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):
+5
View File
@@ -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"
-3
View File
@@ -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)
+272
View File
@@ -0,0 +1,272 @@
"""
novelWriter Patterns Module Tester
====================================
This file is a part of novelWriter
Copyright 20182024, 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 <https://www.gnu.org/licenses/>.
"""
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)]
]
@@ -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