Rewrite the auto-replace feature (#3128)

This commit is contained in:
Veronica Berglyd Olsen
2026-11-01 20:04:16 +01:00
committed by GitHub
41 changed files with 811 additions and 99 deletions

View File

@@ -102,6 +102,7 @@ class nwRegEx:
FMT_LN = r"(?<!\\)(\[)([^\[\]]+)(?<!\\)(\]\()([^\s()]+)(?<!\\)(\))"
FMT_SC = r"(?i)(?<!\\)(\[(?:b|/b|i|/i|s|/s|u|/u|m|/m|sup|/sup|sub|/sub|br)\])"
FMT_SV = r"(?i)(?<!\\)(\[(?:footnote|field):)(.+?)(?<!\\)(\])"
AUTO_REP = r"<([^\s<>]+)>"
class nwShortcode:
@@ -328,6 +329,12 @@ class nwStats:
]
class nwProblems:
"""Text problems."""
MISSING_AUTO_KEYS = "missingAutoKeys"
class nwLabels:
"""Various Common GUI Labels."""
@@ -436,6 +443,9 @@ class nwLabels:
nwStats.CHARS: QT_TRANSLATE_NOOP("Stats", "Characters: {0} ({1})"),
nwStats.WORDS: QT_TRANSLATE_NOOP("Stats", "Words: {0} ({1})"),
}
PROBLEM_NAME: ClassVar[dict[str, str]] = {
nwProblems.MISSING_AUTO_KEYS: QT_TRANSLATE_NOOP("Constant", "Missing Auto-Replace Keys"),
}
UNIT_LABELS: ClassVar[dict[nwStdUnit, str]] = {
nwStdUnit.CHARS: QT_TRANSLATE_NOOP("Units", "characters"),
nwStdUnit.WORDS: QT_TRANSLATE_NOOP("Units", "words"),

View File

@@ -61,6 +61,7 @@ class ProjectData:
"_author",
"_autoCount",
"_autoReplace",
"_autoReplaceIgnoreCase",
"_currCounts",
"_dailyGoal",
"_dailyGoalAuto",
@@ -110,6 +111,7 @@ class ProjectData:
self._rtl = False
self._spellCheck = False
self._spellLang = None
self._autoReplaceIgnoreCase = False
# Writing Target
self._targetCount = 0
@@ -285,6 +287,11 @@ class ProjectData:
"""Return the auto-replace dictionary."""
return self._autoReplace
@property
def autoReplaceIgnoreCase(self) -> bool:
"""Return True if the auto replace should ignore case."""
return self._autoReplaceIgnoreCase
@property
def itemStatus(self) -> ItemStatus:
"""Return the status settings object."""
@@ -462,8 +469,9 @@ class ProjectData:
if cNotes is not None:
self._currCounts[3] = checkInt(cNotes, 0)
def setAutoReplace(self, value: dict) -> None:
def setAutoReplace(self, value: dict, ignoreCase: Any) -> None:
"""Set the auto-replace dictionary."""
self._autoReplaceIgnoreCase = checkBool(ignoreCase, False)
if isinstance(value, dict):
self._autoReplace = {}
for key, entry in value.items():

View File

@@ -52,7 +52,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
FILE_VERSION = "1.6" # The current project file format version
FILE_REVISION = "0" # The current project file format revision
FILE_REVISION = "1" # The current project file format revision
HEX_VERSION = 0x0106 # The current project file format version as hex
LAST_BREAKING = 0x260200B2 # Last project format breaking change (not XML)
@@ -127,6 +127,8 @@ class ProjectXMLReader:
1.6 The actual format released for 26.2. All documents are now
stored with the .md file extension and a toml header. The
project XML was not changed.
Rev 1: Added ignoreCase attribute to autoReplace (26.3).
"""
def __init__(self, path: str | Path) -> None:
@@ -297,9 +299,9 @@ class ProjectXMLReader:
data.setInitLastHandles(self._parseDictKeyText(xItem))
elif xItem.tag == "autoReplace":
if self._version >= 0x0102:
data.setAutoReplace(self._parseDictKeyText(xItem))
data.setAutoReplace(self._parseDictKeyText(xItem), xItem.attrib.get("ignoreCase"))
else: # Pre 1.2 format
data.setAutoReplace(self._parseDictTagText(xItem))
data.setAutoReplace(self._parseDictTagText(xItem), False)
else:
logger.warning("Ignored <root/settings/%s> in XML", xItem.tag)
@@ -546,7 +548,12 @@ class ProjectXMLWriter:
self._packSingleValue(xSettings, "dailyTarget", str(data.dailyGoal), attrib=dailyAttr)
self._packListValues(xSettings, "targetSkipRoots", data.targetSkipRoots)
self._packDictKeyValue(xSettings, "lastHandle", data.lastHandle)
self._packDictKeyValue(xSettings, "autoReplace", data.autoReplace)
self._packDictKeyValue(
xSettings,
"autoReplace",
data.autoReplace,
attrib={"ignoreCase": yesNo(data.autoReplaceIgnoreCase)},
)
# Save Status/Importance
xStatus = ET.SubElement(xSettings, "status")
@@ -594,22 +601,40 @@ class ProjectXMLWriter:
# Internal Functions
##
def _packSingleValue(self, xParent: ET.Element, name: str, value: str | None, attrib: dict | None = None) -> None:
def _packSingleValue(
self,
xParent: ET.Element,
name: str,
value: str | None,
attrib: dict | None = None,
) -> None:
"""Pack a single value into an XML element."""
xItem = ET.SubElement(xParent, name, attrib=attrib or {})
xItem.text = str(value) or ""
def _packListValues(self, xParent: ET.Element, name: str, data: list | tuple | set) -> None:
def _packListValues(
self,
xParent: ET.Element,
name: str,
data: list | tuple | set,
attrib: dict | None = None,
) -> None:
"""Pack the entries of a sequence into an XML element."""
xItem = ET.SubElement(xParent, name)
xItem = ET.SubElement(xParent, name, attrib=attrib or {})
for value in data:
if len(str(value)) > 0: # pragma: no branch
xEntry = ET.SubElement(xItem, "entry")
xEntry.text = str(value) or ""
def _packDictKeyValue(self, xParent: ET.Element, name: str, data: Mapping[str, str | None]) -> None:
def _packDictKeyValue(
self,
xParent: ET.Element,
name: str,
data: Mapping[str, str | None],
attrib: dict | None = None,
) -> None:
"""Pack the entries of a dictionary into an XML element."""
xItem = ET.SubElement(xParent, name)
xItem = ET.SubElement(xParent, name, attrib=attrib or {})
for key, value in data.items():
if len(key) > 0: # pragma: no branch
xEntry = ET.SubElement(xItem, "entry", attrib={"key": key})

View File

@@ -230,9 +230,11 @@ class GuiProjectSettings(NDialog):
logger.debug("Updating importance labels")
project.updateStatus("i", self.importPage.getNewList())
autoReplaceIgnoreCase = self.replacePage.swtIgnoreCase.isChecked()
if self.replacePage.changed:
logger.debug("Updating auto-replace settings")
project.data.setAutoReplace(self.replacePage.getNewList())
project.data.setAutoReplace(self.replacePage.getNewList(), autoReplaceIgnoreCase)
self.newProjectSettingsReady.emit()
self.close()
@@ -839,6 +841,7 @@ class _ReplacePage(NFixedPage):
self._changed = False
wCol0 = SHARED.project.options.getInt("GuiProjectSettings", "replaceColW", 130)
iPx = SHARED.theme.baseIconHeight
# Title
self.pageTitle = NColorLabel(
@@ -872,6 +875,14 @@ class _ReplacePage(NFixedPage):
self.delButton = SHARED.theme.getFlatButton(nwToolButton.REMOVE, self)
self.delButton.clicked.connect(self._onEntryDeleted)
# Settings
self.swtIgnoreCase = NSwitch(self, height=iPx)
self.swtIgnoreCase.setChecked(SHARED.project.data.autoReplaceIgnoreCase)
self.swtIgnoreCase.toggled.connect(self._onIgnoreCaseToggled)
self.lblIgnoreCase = QLabel(self.tr("Ignore case for keywords"), self)
self.lblIgnoreCase.setBuddy(self.swtIgnoreCase)
# Edit Form
self.editKey = QLineEdit(self)
self.editKey.setPlaceholderText(self.tr("Select item to edit"))
@@ -890,6 +901,12 @@ class _ReplacePage(NFixedPage):
self.listControls.addWidget(self.delButton)
self.listControls.addStretch(1)
self.optsBox = QHBoxLayout()
self.optsBox.addWidget(self.lblIgnoreCase)
self.optsBox.addWidget(self.swtIgnoreCase)
self.optsBox.setSpacing(12)
self.optsBox.addStretch(1)
self.editBox = QHBoxLayout()
self.editBox.addWidget(self.editKey, 4)
self.editBox.addWidget(self.editValue, 5)
@@ -897,6 +914,7 @@ class _ReplacePage(NFixedPage):
self.mainBox = QVBoxLayout()
self.mainBox.addWidget(self.listBox, 1)
self.mainBox.addLayout(self.editBox, 0)
self.mainBox.addLayout(self.optsBox, 0)
self.innerBox = QHBoxLayout()
self.innerBox.addLayout(self.mainBox)
@@ -947,6 +965,11 @@ class _ReplacePage(NFixedPage):
item.setText(self.C_REPL, text)
self._changed = True
@pyqtSlot(bool)
def _onIgnoreCaseToggled(self, state: bool) -> None:
"""Mark the settings as changed."""
self._changed = True
@pyqtSlot()
def _onSelectionChanged(self) -> None:
"""Extract the details from the selected item and populate the

View File

@@ -603,6 +603,11 @@ class GuiDocEditor(QTextEdit):
selection.format = self._formatErrFormat
self._applyExtraSelections()
def updateReplaceKeys(self) -> None:
"""Rehighlight the document if the auto-replace keys changed."""
if self._docHandle and self._qDocument.syntaxHighlighter.updateReplaceKeys():
self._qDocument.syntaxHighlighter.rehighlight()
def initEditor(self) -> None:
"""Initialise or re-initialise the editor with the user's
settings. This function is both called when the editor is

View File

@@ -63,6 +63,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
"_isInactive",
"_isNovel",
"_minRules",
"_replaceKeys",
"_replaceNoCase",
"_tHandle",
"_txtRules",
)
@@ -75,6 +77,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._tHandle = None
self._isNovel = False
self._isInactive = False
self._replaceKeys: frozenset[str] = frozenset()
self._replaceNoCase = False
self._hStyles: dict[str, QTextCharFormat] = {}
self._minRules: list[tuple[re.Pattern, dict[int, QTextCharFormat]]] = []
@@ -248,15 +252,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._minRules.append((rxRule, hlRule))
self._txtRules.append((rxRule, hlRule))
# Auto-Replace Tags
rxRule = re.compile(r"<(\S+?)>")
hlRule = {
0: self._hStyles["replace"],
}
self._minRules.append((rxRule, hlRule))
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
##
# Setters
##
@@ -269,8 +264,22 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if item := SHARED.project.tree[tHandle]:
self._isNovel = item.isDocumentLayout()
self._isInactive = item.isInactiveClass()
self.updateReplaceKeys()
logger.debug("Syntax highlighter enabled for item '%s'", tHandle)
def updateReplaceKeys(self) -> bool:
"""Update the auto-replace keys, and return True if changed."""
noCase = SHARED.project.data.autoReplaceIgnoreCase
if noCase:
keys = frozenset(k.lower() for k in SHARED.project.data.autoReplace)
else:
keys = frozenset(SHARED.project.data.autoReplace)
if keys != self._replaceKeys or noCase != self._replaceNoCase:
self._replaceKeys = keys
self._replaceNoCase = noCase
return True
return False
##
# Methods
##
@@ -487,6 +496,29 @@ class GuiDocHighlighter(QSyntaxHighlighter):
cFmt.merge(hFmt)
self.setFormat(x, 1, cFmt)
if self._replaceKeys and "<" in text:
hFmt = self._hStyles["replace"]
keys = self._replaceKeys
rxFind = REGEX_PATTERNS.autoReplace.finditer(text, offset)
if self._replaceNoCase:
matches = [r for r in rxFind if r.group(1).lower() in keys]
else:
matches = [r for r in rxFind if r.group(1) in keys]
if utf16Map:
for res in matches:
for x in range(res.start(0), res.end(0)):
m = utf16Map[x]
cFmt = self.format(m)
cFmt.merge(hFmt)
self.setFormat(m, utf16Map[x + 1] - m, cFmt)
else:
for res in matches:
for x in range(res.start(0), res.end(0)):
cFmt = self.format(x)
cFmt.merge(hFmt)
self.setFormat(x, 1, cFmt)
data = self.currentBlockUserData()
if not isinstance(data, TextBlockData):
data = TextBlockData()

View File

@@ -304,7 +304,6 @@ class GuiDocViewer(QTextBrowser):
# See issue #298
try:
qDoc.setText(tHandle)
qDoc.doPreProcessing()
qDoc.tokenizeText()
qDoc.doConvert()
qDoc.closeDocument()

View File

@@ -63,6 +63,7 @@ class TextDocumentTheme:
optional: QColor = QColor(66, 113, 174)
dialog: QColor = QColor(66, 113, 174)
altdialog: QColor = QColor(129, 55, 9)
error: QColor = QColor(255, 160, 160)
# Enums
@@ -103,6 +104,8 @@ class TextFmt(IntEnum):
FNOTE = 23 # Footnote marker
FIELD = 24 # Data field
STRIP = 25 # Strip the format code
ERR_B = 26 # Begin error marker
ERR_E = 27 # End error marker
class BlockTyp(IntEnum):

View File

@@ -118,12 +118,19 @@ class ToHtml(Tokenizer):
# Class Methods
##
def doPreProcessing(self) -> None:
"""Extend the auto-replace to also properly encode some unicode
def initDocument(self) -> None:
"""Extend the initialisation to also encode the auto-replace
values into HTML entities.
"""
super().initDocument()
self._replaceLookup = {k: v.translate(self._trMap) for k, v in self._replaceLookup.items()}
def tokenizeText(self) -> None:
"""Extend the tokenizer to also properly encode some unicode
characters into their respective HTML entities.
"""
super().doPreProcessing()
self._text = self._text.translate(self._trMap)
super().tokenizeText()
def doConvert(self) -> None:
"""Convert the list of text tokens into an HTML document."""

View File

@@ -22,9 +22,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import TYPE_CHECKING, NamedTuple
from PyQt6.QtCore import QLocale
@@ -32,19 +32,33 @@ from PyQt6.QtGui import QColor, QFont
from novelwriter import CONFIG
from novelwriter.common import checkInt, fontMatcher, numberToRoman
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwShortcode, nwStats, nwStyles, nwUnicode, trConst
from novelwriter.constants import (
nwHeadFmt,
nwKeyWords,
nwLabels,
nwProblems,
nwShortcode,
nwStats,
nwStyles,
nwUnicode,
trConst,
)
from novelwriter.enum import nwComment, nwItemLayout
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt
from novelwriter.text.formats import processComment
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
if TYPE_CHECKING:
import re
from pathlib import Path
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
T_Marks = list[tuple[int, int, TextFmt, str]]
class ComStyle(NamedTuple):
"""Comment style info."""
@@ -165,8 +179,13 @@ class Tokenizer(ABC):
"_outline",
"_pages",
"_partStyle",
"_problemAnchors",
"_problemCount",
"_problems",
"_project",
"_raw",
"_replaceLookup",
"_replaceNoCase",
"_rxAltDialog",
"_rxMarkdown",
"_sceneStyle",
@@ -200,6 +219,10 @@ class Tokenizer(ABC):
self._noTokens = False # Disable tokenization if they're not needed
self._statusText = "" # The item status of the current document
self._statusFmt: T_Formats = [] # The format of the status label
self._replaceLookup: dict[str, str] = {} # The auto-replace lookup table
self._replaceNoCase = False # Whether auto-replace ignores case
self._problemAnchors = False # Whether to add anchors to text problems
self._problemCount = 0 # The number of problem anchors added
# Blocks and Meta Data (Per Document)
self._blocks: list[T_Block] = []
@@ -210,6 +233,7 @@ class Tokenizer(ABC):
self._pages: list[str] = []
self._counts: dict[str, int] = {}
self._outline: dict[str, str] = {}
self._problems: dict[str, list[tuple[str, str, str]]] = defaultdict(list)
# User Settings
self._dLocale = CONFIG.locale # The document locale
@@ -340,17 +364,22 @@ class Tokenizer(ABC):
@property
def textStats(self) -> dict[str, int]:
"""The collected stats about the text."""
"""Return the collected stats about the text."""
return self._counts
@property
def textProblems(self) -> dict[str, list[tuple[str, str, str]]]:
"""Return the discovered problems in the text."""
return self._problems
@property
def textOutline(self) -> dict[str, str]:
"""The generated outline of the text."""
"""Return the generated outline of the text."""
return self._outline
@property
def errData(self) -> list[str]:
"""The error data."""
"""Return the error data."""
return self._errData
##
@@ -539,6 +568,10 @@ class Tokenizer(ABC):
"""Keep line breaks in paragraphs."""
self._keepBreaks = state
def setProblemAnchors(self, state: bool) -> None:
"""Add anchors to text blocks with problems."""
self._problemAnchors = state
def setAnchorsEnabled(self, state: bool) -> None:
"""Enable or disable anchors."""
self._useAnchors = state
@@ -570,6 +603,13 @@ class Tokenizer(ABC):
self._classes["keyword"] = self._theme.keyword
self._classes["optional"] = self._theme.optional
data = self._project.data
self._replaceNoCase = data.autoReplaceIgnoreCase
if self._replaceNoCase:
self._replaceLookup = {k.lower(): v for k, v in data.autoReplace.items()}
else:
self._replaceLookup = data.autoReplace.copy()
def setBreakNext(self) -> None:
"""Set a page break for next block."""
self._breakNext = True
@@ -624,14 +664,6 @@ class Tokenizer(ABC):
(len(self._statusText), TextFmt.B_E, ""),
]
def doPreProcessing(self) -> None:
"""Run pre-processing jobs before the text is tokenized."""
# Process the user's auto-replace dictionary
if entry := self._project.data.autoReplace:
replace = {f"<{k}>": v for k, v in entry.items()}
rxRep = re.compile("|".join([re.escape(k) for k in replace]), flags=re.DOTALL)
self._text = rxRep.sub(lambda x: replace[x.group(0)], self._text)
def tokenizeText(self) -> None:
"""Scan the text for either lines starting with specific
characters that indicate headings, comments, commands etc, or
@@ -734,6 +766,8 @@ class Tokenizer(ABC):
if cStyle not in self._doComments:
continue
cText, pMarks = self._formatAutoReplace(cText, nHead)
if doJustify and not tStyle & BlockFmt.ALIGNED:
tStyle |= BlockFmt.JUSTIFY
@@ -745,11 +779,11 @@ class Tokenizer(ABC):
nwComment.NOTE,
):
bStyle = COMMENT_STYLE[cStyle]
tLine, tFmt = self._formatComment(bStyle, cKey, cText)
tLine, tFmt = self._formatComment(bStyle, cKey, cText, pMarks)
tBlocks.append((COMMENT_TYPE[cStyle], "", tLine, tFmt, tStyle))
elif cStyle == nwComment.FOOTNOTE:
tLine, tFmt = self._extractFormats(cText, skip=TextFmt.FNOTE)
tLine, tFmt = self._extractFormats(cText, skip=TextFmt.FNOTE, marks=pMarks)
self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt)
else: # pragma: no cover
@@ -782,6 +816,8 @@ class Tokenizer(ABC):
tType = BlockTyp.HEAD1 if isPlain else BlockTyp.TITLE
sHide = self._hidePart if isPlain else False
sText = ""
if not (isNovel and isPlain and (sHide or nwHeadFmt.TITLE not in self._fmtPart)):
tText, _ = self._formatAutoReplace(tText, nHead, True)
if not (isPlain or (isNovel and sHide)):
tStyle |= self._titleStyle
if isNovel:
@@ -820,6 +856,8 @@ class Tokenizer(ABC):
sHide = self._hideChapter if isPlain else self._hideUnNum
tFormat = self._fmtChapter if isPlain else self._fmtUnNum
sText = ""
if not (isNovel and (sHide or nwHeadFmt.TITLE not in tFormat)):
tText, _ = self._formatAutoReplace(tText, nHead, True)
if isNovel:
tType = BlockTyp.HEAD1 # Promote
if isPlain:
@@ -857,6 +895,8 @@ class Tokenizer(ABC):
sHide = self._hideScene if isPlain else self._hideHScene
tFormat = self._fmtScene if isPlain else self._fmtHScene
sText = ""
if not (isNovel and (sHide or nwHeadFmt.TITLE not in tFormat)):
tText, _ = self._formatAutoReplace(tText, nHead, True)
if isNovel:
tType = BlockTyp.HEAD2 # Promote
self._hFormatter.incScene()
@@ -897,6 +937,8 @@ class Tokenizer(ABC):
tText = aLine[5:].strip()
tType = BlockTyp.HEAD4
tFormat = self._fmtSection
if not (isNovel and (self._hideSection or nwHeadFmt.TITLE not in tFormat)):
tText, _ = self._formatAutoReplace(tText, nHead, True)
if isNovel:
tType = BlockTyp.HEAD3 # Promote
if self._hideSection:
@@ -969,7 +1011,8 @@ class Tokenizer(ABC):
tStyle |= BlockFmt.IND_R
# Process formats
tLine, tFmt = self._extractFormats(bLine, hDialog=hDialog)
bLine, pMarks = self._formatAutoReplace(bLine, nHead)
tLine, tFmt = self._extractFormats(bLine, hDialog=hDialog, marks=pMarks)
tBlocks.append((tType, "", tLine, tFmt, tStyle))
# If we have content, turn off the first page flag
@@ -1238,10 +1281,60 @@ class Tokenizer(ABC):
"""Return a localised integer."""
return self._dLocale.toString(value)
def _formatComment(self, style: ComStyle, key: str, text: str) -> tuple[str, T_Formats]:
def _formatAutoReplace(self, text: str, nHead: int, isHead: bool = False) -> tuple[str, T_Marks]:
"""Apply the auto-replace lookup table and record missing keys.
Returns the new text and any problem markers for it.
"""
if not (self._replaceLookup and "<" in text):
return text, []
lookup = self._replaceLookup
noCase = self._replaceNoCase
missing: list[tuple[str, int, int]] = []
shift = 0
def replace(match: re.Match) -> str:
nonlocal shift
key = match.group(1)
if (value := lookup.get(key.lower() if noCase else key)) is not None:
shift += len(value) - len(match.group(0))
return value
pos = match.start(0) + shift
missing.append((key, pos, pos + len(match.group(0))))
return match.group(0)
text = REGEX_PATTERNS.autoReplace.sub(replace, text)
if not missing:
return text, []
tKey = f"{self._handle or ''}:T{nHead:04d}"
anchor = ""
marks: T_Marks = []
if self._problemAnchors:
if isHead: # Headings already have an anchor
anchor = tKey
else:
anchor = f"problem_{self._problemCount}"
self._problemCount += 1
marks.append((0, 0, TextFmt.ANM_B, anchor))
for _, pos, end in missing:
marks.append((pos, 0, TextFmt.ERR_B, ""))
marks.append((end, 0, TextFmt.ERR_E, ""))
marks.append((len(text), 0, TextFmt.ANM_E, ""))
self._problems[nwProblems.MISSING_AUTO_KEYS].extend((key, tKey, anchor) for key, _, _ in missing)
return text, marks
def _formatComment(
self,
style: ComStyle,
key: str,
text: str,
marks: T_Marks | None = None,
) -> tuple[str, T_Formats]:
"""Apply formatting to comments and notes."""
rFmt = []
tTxt, tFmt = self._extractFormats(text)
tTxt, tFmt = self._extractFormats(text, marks=marks)
tFmt.insert(0, (0, TextFmt.COL_B, style.textClass))
tFmt.append((len(tTxt), TextFmt.COL_E, ""))
term = f" ({key.title()})" if key else ""
@@ -1310,12 +1403,19 @@ class Tokenizer(ABC):
return tag, "".join(txt), fmt
def _extractFormats(self, text: str, skip: TextFmt = TextFmt.NONE, hDialog: bool = False) -> tuple[str, T_Formats]:
def _extractFormats(
self,
text: str,
skip: TextFmt = TextFmt.NONE,
hDialog: bool = False,
marks: T_Marks | None = None,
) -> tuple[str, T_Formats]:
"""Extract format markers from a text paragraph. In order to
also process dialogue highlighting, the hDialog flag must be set
to True. See issues #2011 and #2013.
to True. See issues #2011 and #2013. Additional markers can be
passed in with positions relative to the input text.
"""
temp: list[tuple[int, int, TextFmt, str]] = []
temp: T_Marks = list(marks) if marks else []
# Match Markdown
for regEx, fmts in self._rxMarkdown:

View File

@@ -472,6 +472,8 @@ class ToQTextDocument(Tokenizer):
cFmt = QTextCharFormat(dFmt)
temp = text.replace("\n", nwUnicode.U_LSEP)
start = 0
marked = False
error = False
primary: QColor | None = None
for pos, fmt, data in tFmt:
# Insert buffer with previous format
@@ -495,9 +497,11 @@ class ToQTextDocument(Tokenizer):
elif fmt == TextFmt.U_E:
cFmt.setFontUnderline(self._dUnderline)
elif fmt == TextFmt.M_B:
cFmt.setBackground(self._theme.highlight)
marked = True
cFmt.setBackground(self._theme.error if error else self._theme.highlight)
elif fmt == TextFmt.M_E:
cFmt.setBackground(QtTransparent)
marked = False
cFmt.setBackground(self._theme.error if error else QtTransparent)
elif fmt == TextFmt.SUP_B:
cFmt.setVerticalAlignment(QtVAlignSuper)
elif fmt == TextFmt.SUP_E:
@@ -552,6 +556,12 @@ class ToQTextDocument(Tokenizer):
elif fmt == TextFmt.FIELD and (field := data.partition(":")[2]):
self._usedFields.append((cursor.position(), field))
cursor.insertText("0", cFmt)
elif fmt == TextFmt.ERR_B:
error = True
cFmt.setBackground(self._theme.error)
elif fmt == TextFmt.ERR_E:
error = False
cFmt.setBackground(self._theme.highlight if marked else QtTransparent)
# Move pos for next pass
start = pos

View File

@@ -797,6 +797,7 @@ class GuiMain(QMainWindow):
"""Refresh data dependent on project settings."""
logger.debug("Applying new project settings")
SHARED.updateSpellCheckLanguage()
self.docEditor.updateReplaceKeys()
self.docViewer.reloadText()
self.itemDetails.refreshDetails()
self.mainMenu.setSelectedProjectSpellCheckLanguage()

View File

@@ -125,6 +125,7 @@ class DocumentBuilder:
filtered = self._setupBuild(makeObj)
makeObj.initDocument()
makeObj.setShowNewPage(newPage)
makeObj.setProblemAnchors(True)
self._outline = True
yield from self._iterBuild(makeObj, filtered)
makeObj.closeDocument()
@@ -160,6 +161,7 @@ class DocumentBuilder:
elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD):
makeObj = ToMarkdown(self._project, bFormat == nwBuildFmt.EXT_MD)
filtered = self._setupBuild(makeObj)
makeObj.initDocument()
yield from self._iterBuild(makeObj, filtered)
makeObj.closeDocument()
if self._build.getBool("format.replaceTabs"):
@@ -168,6 +170,7 @@ class DocumentBuilder:
elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD):
makeObj = ToRaw(self._project)
filtered = self._setupBuild(makeObj)
makeObj.initDocument()
yield from self._iterBuild(makeObj, filtered)
makeObj.closeDocument()
if self._build.getBool("format.replaceTabs"):
@@ -391,7 +394,6 @@ class DocumentBuilder:
bldObj.buildOutline()
elif tItem.isFileType():
bldObj.setText(tHandle)
bldObj.doPreProcessing()
bldObj.tokenizeText()
if self._count:
bldObj.countStats()

View File

@@ -58,7 +58,7 @@ from PyQt6.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import formatInt, formatPercent, fuzzyTime
from novelwriter.constants import nwHeadFmt, nwLabels, nwStats, nwUnicode, trStats
from novelwriter.constants import nwHeadFmt, nwLabels, nwProblems, nwStats, nwUnicode, trConst, trStats
from novelwriter.enum import nwStandardButton
from novelwriter.extensions.modified import NFlatIconButton, NToolDialog
from novelwriter.extensions.progressbars import NProgressCircle
@@ -170,10 +170,14 @@ class GuiManuscript(NToolDialog):
self.buildStats = _StatisticsWidget(self)
self.buildStats.setExpandedState(SHARED.project.options.getList("GuiManuscript", "statsExpanded", []))
self.buildProblems = _ProblemsWidget(self)
self.detailsTabs = NTabWidget(self)
self.detailsTabs.addTab(self.buildDetails, self.tr("Details"))
self.detailsTabs.addTab(self.buildOutline, self.tr("Outline"))
self.detailsTabs.addTab(self.buildStats, self.tr("Statistics"))
self.detailsTabs.addTab(self.buildProblems, self.tr("Problems"))
self.detailsTabs.setTabVisible(self.detailsTabs.indexOf(self.buildProblems), False)
self.buildSplit = QSplitter(Qt.Orientation.Vertical, self)
self.buildSplit.addWidget(self.buildList)
@@ -266,6 +270,7 @@ class GuiManuscript(NToolDialog):
# Signals
self.buildOutline.outlineEntryClicked.connect(self.docPreview.navigateTo)
self.buildProblems.problemEntryClicked.connect(self.docPreview.navigateTo)
logger.debug("Ready: GuiManuscript")
@@ -417,6 +422,17 @@ class GuiManuscript(NToolDialog):
self.buildOutline.updateOutline(buildObj.textOutline)
self.buildStats.updateStats(buildObj.textStats, withDialog)
self.buildProblems.updateProblems(buildObj.textProblems)
pIndex = self.detailsTabs.indexOf(self.buildProblems)
hasProblems = self.buildProblems.hasProblems()
if hasProblems and not self.detailsTabs.isTabVisible(pIndex):
self.detailsTabs.setTabVisible(pIndex, True)
self.detailsTabs.setCurrentIndex(pIndex)
elif not hasProblems:
self.detailsTabs.setTabVisible(pIndex, False)
self.docPreview.setProblemCount(self.buildProblems.problemCount())
self._updateCountsLabel(buildObj.textStats, withDialog)
logger.debug("Build completed in %.3f ms", 1000 * (time() - start))
@@ -890,6 +906,77 @@ class _StatisticsWidget(QWidget):
header.setSectionResizeMode(1, QtHeaderToContents)
class _ProblemsWidget(QWidget):
"""The text problems of the manuscript."""
D_ANCHOR = QtUserRole
problemEntryClicked = pyqtSignal(str)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
# Tree Widget
self.listView = QTreeWidget(self)
self.listView.setHeaderHidden(True)
self.listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.listView.setIndentation(SHARED.theme.baseIconHeight)
self.listView.setFrameStyle(QFrame.Shape.NoFrame)
self.listView.itemClicked.connect(self._onItemClick)
# Assemble
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.listView)
self.outerBox.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.outerBox)
def hasProblems(self) -> bool:
"""Return True if any problems are listed."""
return self.listView.topLevelItemCount() > 0
def problemCount(self) -> int:
"""Return the number of problems listed."""
count = 0
for i in range(self.listView.topLevelItemCount()):
if item := self.listView.topLevelItem(i): # pragma: no branch
count += item.childCount()
return count
def updateProblems(self, data: dict[str, list[tuple[str, str, str]]]) -> None:
"""Update the problems list from a Tokenizer problems dict."""
self.listView.clear()
if entries := data.get(nwProblems.MISSING_AUTO_KEYS):
labels = []
for key, tKey, anchor in entries:
if tItem := SHARED.project.tree[tKey.partition(":")[0]]:
labels.append((self.tr("<{0}> in {1}").format(key, tItem.itemName), anchor or tKey))
else:
labels.append((f"<{key}>", anchor or tKey))
self._addCategory(nwProblems.MISSING_AUTO_KEYS, labels)
##
# Private Slots
##
@pyqtSlot("QTreeWidgetItem*", int)
def _onItemClick(self, item: QTreeWidgetItem, column: int) -> None:
"""Process tree item click."""
if anchor := item.data(0, self.D_ANCHOR):
self.problemEntryClicked.emit(str(anchor))
##
# Internal Functions
##
def _addCategory(self, key: str, entries: list[tuple[str, str]]) -> None:
"""Add a problem category with its entries."""
parent = QTreeWidgetItem(self.listView, [f"{trConst(nwLabels.PROBLEM_NAME[key])} ({len(entries):n})"])
for label, anchor in entries:
item = QTreeWidgetItem(parent, [label])
item.setData(0, self.D_ANCHOR, anchor)
parent.setExpanded(True)
class _PreviewWidget(QTextBrowser):
"""The manuscript preview widget."""
@@ -898,6 +985,7 @@ class _PreviewWidget(QTextBrowser):
self._docTime = 0
self._buildName = ""
self._problems = 0
self._scrollPos = 0
# Document Setup
@@ -959,6 +1047,11 @@ class _PreviewWidget(QTextBrowser):
self._buildName = name
self._updateBuildAge()
def setProblemCount(self, count: int) -> None:
"""Set the problem count for the document label."""
self._problems = count
self._updateBuildAge()
def setTextFont(self, font: QFont) -> None:
"""Set the text font properties and then reset for sub-widgets.
This needs special attention since there appears to be a bug in
@@ -1069,13 +1162,14 @@ class _PreviewWidget(QTextBrowser):
def _updateBuildAge(self) -> None:
"""Update the build time and the fuzzy age."""
if self._buildName and self._docTime > 0:
self.ageLabel.setText(
"<b>{0}</b><br>{1}: {2}".format(
self._buildName,
self.tr("Built"),
fuzzyTime(int(time()) - self._docTime),
)
text = "<b>{0}</b><br>{1}: {2}".format(
self._buildName,
self.tr("Built"),
fuzzyTime(int(time()) - self._docTime),
)
if self._problems:
text += ", {0}: {1}".format(self.tr("Problems"), formatInt(self._problems))
self.ageLabel.setText(text)
else:
self.ageLabel.setText("<b>{0}</b>".format(self.tr("No Preview")))

View File

@@ -45,6 +45,7 @@ class RegExPatterns:
_rxLink = re.compile(nwRegEx.FMT_LN)
_rxSCPlain = re.compile(nwRegEx.FMT_SC)
_rxSCValue = re.compile(nwRegEx.FMT_SV)
_rxAutoRep = re.compile(nwRegEx.AUTO_REP)
@property
def url(self) -> re.Pattern:
@@ -96,6 +97,11 @@ class RegExPatterns:
"""Plain shortcode style."""
return self._rxSCValue
@property
def autoReplace(self) -> re.Pattern:
"""Auto-replace key."""
return self._rxAutoRep
@property
def dialogStyle(self) -> re.Pattern | None:
"""Dialogue detection rule based on user settings."""

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.3b1" hexVersion="0x260300b1" fileVersion="1.6" fileRevision="0" timeStamp="2026-11-01 16:58:33">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2433" autoCount="437" editTime="112772" indexRevision="239">
<novelWriterXML appVersion="26.3b1" hexVersion="0x260300b1" fileVersion="1.6" fileRevision="1" timeStamp="2026-11-01 18:00:41">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2441" autoCount="437" editTime="112928" indexRevision="240">
<name>Sample Project</name>
<author>Jane Smith</author>
</project>
@@ -19,7 +19,7 @@
<entry key="novel">7031beac91f75</entry>
<entry key="story">7031beac91f75</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>
@@ -48,7 +48,7 @@
<name status="sc24b8f" import="ia857f0">Novel</name>
</item>
<item handle="53b69b83cdafc" parent="7031beac91f75" root="7031beac91f75" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H1" charCount="283" wordCount="50" paraCount="5" cursorPos="413" />
<meta expanded="no" heading="H1" charCount="283" wordCount="50" paraCount="5" cursorPos="79" />
<name status="sc24b8f" import="ia857f0" active="yes">Title Page</name>
</item>
<item handle="974e400180a99" parent="7031beac91f75" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
@@ -68,7 +68,7 @@
<name status="s90e6c9" import="ia857f0" active="yes">Making a Scene</name>
</item>
<item handle="bc0cbd2a407f3" parent="6a2d6d5f4f401" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="548" wordCount="108" paraCount="3" cursorPos="650" />
<meta expanded="no" heading="H3" charCount="548" wordCount="108" paraCount="3" cursorPos="18" />
<name status="s90e6c9" import="ia857f0" active="yes">Another Scene</name>
</item>
<item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2026-07-20 17:28:47">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2026-07-20 17:28:47">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2369" autoCount="429" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jane Smith</author>
@@ -19,7 +19,7 @@
<entry key="novel">7031beac91f75</entry>
<entry key="story">7031beac91f75</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="yes">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="7" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="6" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="16" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="15" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="9" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">0000000000008</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="5" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -17,7 +17,7 @@
<entry key="novel">0000000000008</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace />
<autoReplace ignoreCase="no" />
<status>
<entry key="s000000" count="5" color="faded" shape="STAR">New</entry>
<entry key="s000001" count="0" color="red" shape="TRIANGLE">Note</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2020-05-28 09:59:15">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2020-05-28 09:59:15">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="0" autoCount="0" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jay Doh</author>
@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2020-06-26 21:20:24">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2020-06-26 21:20:24">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="5" autoCount="10" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jay Doh</author>
@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2021-08-30 23:33:44">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2021-08-30 23:33:44">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="5" autoCount="10" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jay Doh</author>
@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2022-10-25 18:26:15">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2022-10-25 18:26:15">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="5" autoCount="10" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jay Doh</author>
@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2022-10-15 12:12:59">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2022-10-15 12:12:59">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="5" autoCount="10" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jay Doh</author>
@@ -17,7 +17,7 @@
<entry key="novel">None</entry>
<entry key="story">None</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="0" timeStamp="2026-07-20 17:28:47">
<novelWriterXML appVersion="26.2rc1" hexVersion="0x260200c1" fileVersion="1.6" fileRevision="1" timeStamp="2026-07-20 17:28:47">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="2369" autoCount="429" editTime="1000" indexRevision="0">
<name>Sample Project</name>
<author>Jane Smith</author>
@@ -19,7 +19,7 @@
<entry key="novel">7031beac91f75</entry>
<entry key="story">7031beac91f75</entry>
</lastHandle>
<autoReplace>
<autoReplace ignoreCase="no">
<entry key="A">B</entry>
<entry key="B">E</entry>
<entry key="C">D</entry>

View File

@@ -603,7 +603,7 @@ def testNWProject_Methods(monkeypatch: pytest.MonkeyPatch, mockGUI: MockGuiMain,
# Auto Replace
project.setProjectChanged(False)
project.data.setAutoReplace({"A": "B", "C": "D"})
project.data.setAutoReplace({"A": "B", "C": "D"}, False)
assert project.data.autoReplace == {"A": "B", "C": "D"}
assert project.projChanged

View File

@@ -155,19 +155,30 @@ def testProjectData_AutoReplace(mockGUI: MockGuiMain):
# Requires a dict
project.changed = 0
data.setAutoReplace(["not", "a", "dict"]) # type: ignore
data.setAutoReplace(["not", "a", "dict"], False) # type: ignore
assert project.changed == 0
assert data.autoReplace == {}
# Non-string entries are skipped
data.setAutoReplace({"A": "B", "C": 123}) # type: ignore
data.setAutoReplace({"A": "B", "C": 123}, False) # type: ignore
assert data.autoReplace == {"A": "B"}
# Keys are cleaned too, not just values, as both end up in the
# project XML, and a key that is only control characters is dropped
data.setAutoReplace({"foo\x1b": "bar\x1b", "\x00\x07": "baz"})
data.setAutoReplace({"foo\x1b": "bar\x1b", "\x00\x07": "baz"}, False)
assert data.autoReplace == {"foo": "bar"}
# Ignore case flag
assert data.autoReplaceIgnoreCase is False
data.setAutoReplace({}, True)
assert data.autoReplaceIgnoreCase is True
data.setAutoReplace({}, "no")
assert data.autoReplaceIgnoreCase is False
data.setAutoReplace({}, "yes")
assert data.autoReplaceIgnoreCase is True
data.setAutoReplace({}, None)
assert data.autoReplaceIgnoreCase is False
@pytest.mark.core
def testProjectData_ProjectTarget(mockGUI: MockGuiMain):

View File

@@ -179,7 +179,7 @@ def testProjectXMLReader_ReadCurrent(
assert xmlReader.state == XMLReadState.PARSED_OK
assert xmlReader.xmlRoot == "novelWriterXML"
assert xmlReader.xmlVersion == 0x0106
assert xmlReader.xmlRevision == 0
assert xmlReader.xmlRevision == 1
assert xmlReader.appVersion == "26.2rc1"
assert xmlReader.hexVersion == 0x260200C1
@@ -209,6 +209,8 @@ def testProjectXMLReader_ReadCurrent(
assert data.dailyGoal == 1000
assert data.dailyGoalAuto is False
assert data.targetSkipRoots == {"d2d81f4831814"}
assert data.autoReplace == {"A": "B", "B": "E", "C": "D"}
assert data.autoReplaceIgnoreCase is True
assert data._dailyLastDate == date.today()
assert data._dailyLastCount == 0

View File

@@ -131,7 +131,7 @@ def testGuiProjectSettings_SettingsPage(
project.data.setLanguage("en", False)
project.data.setSpellLang("en")
project.data.setAuthor("Jane Smith")
project.data.setAutoReplace({"A": "B", "C": "D"})
project.data.setAutoReplace({"A": "B", "C": "D"}, False)
# Create Dialog
projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_SETTINGS)
@@ -558,7 +558,7 @@ def testGuiProjectSettings_Replace(
# Set some values
project = SHARED.project
project.data.setAutoReplace({"A": "B", "C": "D"})
project.data.setAutoReplace({"A": "B", "C": "D"}, False)
# Create Dialog
projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_REPLACE)
@@ -632,6 +632,24 @@ def testGuiProjectSettings_Replace(
# Check Project
projSettings._doSave()
assert project.data.autoReplace == {"A": "B", "C": "D", "This": "With This Stuff"}
assert project.data.autoReplaceIgnoreCase is False
# Toggling ignore case alone is saved
projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_REPLACE)
qtbot.addWidget(projSettings)
replace = projSettings.replacePage
assert replace.swtIgnoreCase.isChecked() is False
assert replace.changed is False
replace.swtIgnoreCase.setChecked(True)
assert replace.changed is True
projSettings._doSave()
assert project.data.autoReplace == {"A": "B", "C": "D", "This": "With This Stuff"}
assert project.data.autoReplaceIgnoreCase is True
# The saved state is loaded
projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_REPLACE)
qtbot.addWidget(projSettings)
assert projSettings.replacePage.swtIgnoreCase.isChecked() is True
# qtbot.stop()

View File

@@ -1173,6 +1173,30 @@ def testGuiDocEditor_UpdateSyntaxColors(
assert lineSel.format.background().color() == QColor(7, 8, 9)
@pytest.mark.gui
def testGuiDocEditor_UpdateReplaceKeys(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that auto-replace key changes refresh the highlighter."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
syntax = docEditor._qDocument.syntaxHighlighter
# No document open, nothing is updated
nwGUI.docPanes.closeDocument()
SHARED.project.data.setAutoReplace({"Name": "Bob"}, False)
docEditor.updateReplaceKeys()
assert syntax._replaceKeys == frozenset()
# Opening a document loads the keys
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
assert syntax._replaceKeys == frozenset({"Name"})
# Changed keys are picked up
SHARED.project.data.setAutoReplace({"Name": "Bob", "Place": "Here"}, False)
docEditor.updateReplaceKeys()
assert syntax._replaceKeys == frozenset({"Name", "Place"})
@pytest.mark.gui
def testGuiDocEditor_Actions(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test the document actions. This is not an extensive test of the

View File

@@ -732,6 +732,63 @@ def testGuiDocHighlighter_MaxBlockLength(monkeypatch: pytest.MonkeyPatch, syntax
assert "Bravo" not in words
@pytest.mark.gui
def testGuiDocHighlighter_AutoReplace(syntax: GuiDocHighlighter):
"""Test highlighting of auto-replace keys."""
doc = syntax.document()
assert doc is not None
colRep = SHARED.theme.syntaxTheme.repTag.getRgb()
# No keys defined, nothing is highlighted
syntax.setHandle(T_HANDLE)
assert syntax._replaceKeys == frozenset()
doc.setPlainText("Hi <Name> and <Other>\n")
syntax.rehighlight()
pieces, _ = getFragments(syntax)
assert pieces == []
# Only defined keys are highlighted
SHARED.project.data.setAutoReplace({"Name": "Bob"}, False)
assert syntax.updateReplaceKeys() is True
assert syntax.updateReplaceKeys() is False
assert syntax._replaceKeys == frozenset({"Name"})
doc.setPlainText("Hi <Name> and <Other> <3\n% Note <Name>\n")
syntax.rehighlight()
pieces, formats = getFragments(syntax)
assert pieces == [
(0, 3, 6, "<Name>"),
(1, 0, 7, "% Note "),
(1, 7, 6, "<Name>"),
]
assert formats[0].foreground().color().getRgb() == colRep
assert formats[2].foreground().color().getRgb() == colRep
# Unicode > 0xFFFF
doc.setPlainText("\U0001f601 <Name> \U0001f601\n")
syntax.rehighlight()
pieces, formats = getFragments(syntax)
assert pieces == [(0, 3, 6, "<Name>")]
assert formats[0].foreground().color().getRgb() == colRep
# Case sensitive
doc.setPlainText("Hi <Name> <name> <NAME>\n")
syntax.rehighlight()
pieces, _ = getFragments(syntax)
assert pieces == [(0, 3, 6, "<Name>")]
# Ignore case, toggling the flag alone counts as a change
SHARED.project.data.setAutoReplace({"Name": "Bob"}, True)
assert syntax.updateReplaceKeys() is True
assert syntax.updateReplaceKeys() is False
assert syntax._replaceKeys == frozenset({"name"})
syntax.rehighlight()
pieces, _ = getFragments(syntax)
assert pieces == [(0, 3, 6, "<Name>"), (0, 10, 6, "<name>"), (0, 17, 6, "<NAME>")]
@pytest.mark.gui
def testGuiDocHighlighter_UnknownCommand(syntax: GuiDocHighlighter):
"""An unrecognised bracketed command falls back to plain rules."""

View File

@@ -765,7 +765,6 @@ def testToHtml_Save(mockGUI: MockGuiMain, fncPath: Path):
for i in range(len(docText)):
html._text = docText[i]
html.doPreProcessing()
html.tokenizeText()
html.doConvert()
@@ -856,7 +855,6 @@ def testToHtml_Methods(mockGUI: MockGuiMain):
docText = "Text with <brackets> & short\u2013dash, long\u2014dash …\n"
html._text = docText
html.setReplaceUnicode(False)
html.doPreProcessing()
html.tokenizeText()
html.doConvert()
assert html._pages[-1] == ("<p>Text with &lt;brackets&gt; &amp; short\u2013dash, long\u2014dash …</p>\n")
@@ -865,7 +863,6 @@ def testToHtml_Methods(mockGUI: MockGuiMain):
docText = "Text with <brackets> & short\u2013dash, long\u2014dash …\n"
html._text = docText
html.setReplaceUnicode(True)
html.doPreProcessing()
html.tokenizeText()
html.doConvert()
assert html._pages[-1] == ("<p>Text with &lt;brackets&gt; &amp; short&ndash;dash, long&mdash;dash &hellip;</p>\n")
@@ -873,6 +870,14 @@ def testToHtml_Methods(mockGUI: MockGuiMain):
# Result Size
assert sum(len(x) for x in html._pages) == 147
# Auto-Replace values are also encoded
project.data.setAutoReplace({"A": "Tom & Jerry\u2014"}, False)
html._text = "Text with <A>\n"
html.initDocument()
html.tokenizeText()
html.doConvert()
assert html._pages[-1] == "<p>Text with Tom &amp; Jerry&mdash;</p>\n"
# CSS
# ===

View File

@@ -28,7 +28,7 @@ import pytest
from PyQt6.QtGui import QFont
from novelwriter import CONFIG
from novelwriter.constants import nwHeadFmt, nwStyles
from novelwriter.constants import nwHeadFmt, nwProblems, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.enum import nwComment
from novelwriter.formats.shared import BlockFmt, BlockTyp, TextFmt, stripEscape
@@ -231,12 +231,11 @@ def testTokenizer_TextOps(monkeypatch: pytest.MonkeyPatch, mockGUI: MockGuiMain,
"#### Replace\n\n"
"Also, replace <A> and <B>.\n\n"
)
docTextR = docText.replace("<A>", "this").replace("<B>", "that")
nDoc = project.storage.getDocument(C.hSceneDoc)
assert nDoc.writeDocument(docText)
project.data.setAutoReplace({"A": "this", "B": "that"})
project.data.setAutoReplace({"A": "this", "B": "that"}, False)
assert project.saveProject()
@@ -324,9 +323,15 @@ def testTokenizer_TextOps(monkeypatch: pytest.MonkeyPatch, mockGUI: MockGuiMain,
assert tokens._statusText == ""
assert tokens._statusFmt == []
# Pre Processing
tokens.doPreProcessing()
assert tokens._text == docTextR
# Auto-Replace Lookup
tokens.initDocument()
assert tokens._replaceLookup == {"A": "this", "B": "that"}
assert tokens._replaceNoCase is False
project.data.setAutoReplace({"A": "this", "B": "that"}, True)
tokens.initDocument()
assert tokens._replaceLookup == {"a": "this", "b": "that"}
assert tokens._replaceNoCase is True
@pytest.mark.core
@@ -1920,6 +1925,174 @@ def testTokenizer_LineBreak(mockGUI: MockGuiMain):
assert tokens._blocks == [(BlockTyp.TEXT, "", "Hello World", [], BlockFmt.NONE)]
@pytest.mark.core
def testTokenizer_AutoReplace(mockGUI: MockGuiMain):
"""Test auto-replace of keys in the Tokenizer class."""
project = NWProject()
tokens = BareTokenizer(project)
tokens._handle = TMH
tokens.setCommentType(nwComment.PLAIN, True)
text = (
"Before <A>\n\n"
"# Head <A>\n\n"
"## Head <A>\n\n"
"### Head <X>\n\n"
"#### Head <A>\n\n"
"* Item <A>\n"
"#. Item <b>\n\n"
"Text <A> and <B> and <Y>\n\n"
"% Comment <A>\n\n"
)
# Without a lookup table, nothing is processed
tokens._text = text
tokens.initDocument()
tokens.tokenizeText()
assert tokens._blocks[0] == (BlockTyp.TEXT, "", "Before <A>", [], BlockFmt.NONE)
assert tokens.textProblems == {}
# No problem entry is added when all keys are found
project.data.setAutoReplace({"A": "this", "B": "that"}, False)
tokens._text = "Text <A> and <B>\n"
tokens.initDocument()
tokens.tokenizeText()
assert tokens._blocks == [(BlockTyp.TEXT, "", "Text this and that", [], BlockFmt.NONE)]
assert tokens.textProblems == {}
# Replace in headings, text, lists and comments
tokens._text = text
tokens.initDocument()
tokens.tokenizeText()
assert [b[2] for b in tokens._blocks] == [
"Before this",
"Head this",
"Head this",
"Head <X>",
"Head this",
"Item this",
"Item <b>",
"Text this and that and <Y>",
"Comment: Comment this",
]
assert tokens.textProblems == {
nwProblems.MISSING_AUTO_KEYS: [
("X", f"{TMH}:T0003", ""),
("b", f"{TMH}:T0004", ""),
("Y", f"{TMH}:T0004", ""),
]
}
assert all(b[3] == [] for b in tokens._blocks[:-1])
# Ignore case
project.data.setAutoReplace({"A": "this", "B": "that"}, True)
tokens._problems.clear()
tokens._text = "Text <a> and <B> and <c>\n"
tokens.initDocument()
tokens.tokenizeText()
assert tokens._blocks == [(BlockTyp.TEXT, "", "Text this and that and <c>", [], BlockFmt.NONE)]
assert tokens.textProblems == {nwProblems.MISSING_AUTO_KEYS: [("c", f"{TMH}:T0000", "")]}
# Problem anchors are added to text, lists and comments, and headings use their own
tokens.setProblemAnchors(True)
tokens._problems.clear()
tokens._text = "### Head <x>\n\nText <x> and <y>\n\n* Item <z>\n\n% Comment <z>\n\n"
tokens.tokenizeText()
assert tokens.textProblems == {
nwProblems.MISSING_AUTO_KEYS: [
("x", f"{TMH}:T0001", f"{TMH}:T0001"),
("x", f"{TMH}:T0001", "problem_0"),
("y", f"{TMH}:T0001", "problem_0"),
("z", f"{TMH}:T0001", "problem_1"),
("z", f"{TMH}:T0001", "problem_2"),
]
}
assert tokens._blocks[0] == (BlockTyp.HEAD3, f"{TMH}:T0001", "Head <x>", [], BlockFmt.NONE)
assert tokens._blocks[1] == (
BlockTyp.TEXT,
"",
"Text <x> and <y>",
[
(0, TextFmt.ANM_B, "problem_0"),
(5, TextFmt.ERR_B, ""),
(8, TextFmt.ERR_E, ""),
(13, TextFmt.ERR_B, ""),
(16, TextFmt.ANM_E, ""),
(16, TextFmt.ERR_E, ""),
],
BlockFmt.NONE,
)
assert tokens._blocks[2][3] == [
(0, TextFmt.ANM_B, "problem_1"),
(5, TextFmt.ERR_B, ""),
(8, TextFmt.ANM_E, ""),
(8, TextFmt.ERR_E, ""),
]
assert tokens._blocks[3][2] == "Comment: Comment <z>"
assert (9, TextFmt.ANM_B, "problem_2") in tokens._blocks[3][3]
assert (17, TextFmt.ERR_B, "") in tokens._blocks[3][3]
assert (20, TextFmt.ERR_E, "") in tokens._blocks[3][3]
assert (20, TextFmt.ANM_E, "") in tokens._blocks[3][3]
# Markers follow replaced keys and stripped markup
tokens._text = "Text <A> and **<y>**\n\n"
tokens.tokenizeText()
assert tokens._blocks[0][2] == "Text this and <y>"
assert tokens._blocks[0][3] == [
(0, TextFmt.ANM_B, "problem_3"),
(14, TextFmt.B_B, ""),
(14, TextFmt.ERR_B, ""),
(17, TextFmt.B_E, ""),
(17, TextFmt.ERR_E, ""),
(17, TextFmt.ANM_E, ""),
]
# Footnotes get them too
tokens.setCommentType(nwComment.FOOTNOTE, True)
tokens._text = "%Footnote.abc: Note <w>\n"
tokens.tokenizeText()
assert tokens._footnotes[f"{TMH}:abc"] == (
"Note <w>",
[
(0, TextFmt.ANM_B, "problem_4"),
(5, TextFmt.ERR_B, ""),
(8, TextFmt.ANM_E, ""),
(8, TextFmt.ERR_E, ""),
],
)
# Hidden headings in novel documents are skipped
text = "# P <p>\n\n## C <q>\n\n### S <r>\n\n#### X <s>\n\n"
tokens._isNovel = True
tokens._problems.clear()
tokens.setPartitionFormat(nwHeadFmt.TITLE, True)
tokens.setChapterFormat(nwHeadFmt.TITLE, True)
tokens.setSceneFormat(nwHeadFmt.TITLE, True)
tokens.setSectionFormat(nwHeadFmt.TITLE, True)
tokens._text = text
tokens.tokenizeText()
assert tokens.textProblems == {}
# So are headings where the format doesn't show the title, but not the main title
tokens.setPartitionFormat("Part", False)
tokens.setChapterFormat(nwHeadFmt.CH_NUM, False)
tokens.setSceneFormat("* * *", False)
tokens.setSectionFormat("", False)
tokens._text = f"#! T <t>\n\n{text}"
tokens.tokenizeText()
assert [k for k, _, _ in tokens.textProblems[nwProblems.MISSING_AUTO_KEYS]] == ["t"]
# But not visible ones
tokens._problems.clear()
tokens.setPartitionFormat(nwHeadFmt.TITLE, False)
tokens.setChapterFormat(nwHeadFmt.TITLE, False)
tokens.setSceneFormat(nwHeadFmt.TITLE, False)
tokens.setSectionFormat(nwHeadFmt.TITLE, False)
tokens._text = text
tokens.tokenizeText()
assert [k for k, _, _ in tokens.textProblems[nwProblems.MISSING_AUTO_KEYS]] == ["p", "q", "r", "s"]
@pytest.mark.core
def testTokenizer_LineForMargin(mockGUI: MockGuiMain):
"""Test using empty lines between blocks in the Tokenizer class."""

View File

@@ -366,7 +366,6 @@ def testToMarkdown_Save(mockGUI: MockGuiMain, fncPath: Path):
for i in range(len(docText)):
md._text = docText[i]
md.doPreProcessing()
md.tokenizeText()
md.doConvert()

View File

@@ -779,3 +779,32 @@ def testToQTextDocument_Fields(mockGUI: MockGuiMain):
block = doc.document.findBlockByNumber(0)
assert block.text() == "Word Count: 4, Unknown: 0"
@pytest.mark.core
def testToQTextDocument_Problems(mockGUI: MockGuiMain):
"""Test problem markers in the ToQTextDocument class."""
project = NWProject()
project.data.setAutoReplace({"A": "B"}, False)
doc = ToQTextDocument(project)
doc.setProblemAnchors(True)
doc.initDocument()
doc._text = "==a <x> b== c <==y==> d\n"
doc.tokenizeText()
doc.doConvert()
block = doc.document.findBlockByNumber(0)
assert block.text() == "a <x> b c <y> d"
assert charFmtInBlock(block, 1).anchorNames() == ["problem_0"]
# Missing keys have an error background, and restore the highlight after
assert charFmtInBlock(block, 1).background() == THEME.highlight
assert charFmtInBlock(block, 3).background() == THEME.error
assert charFmtInBlock(block, 6).background() == THEME.highlight
assert charFmtInBlock(block, 8).background() == QtTransparent
# A highlight inside the key doesn't override the error
assert charFmtInBlock(block, 11).background() == THEME.error
assert charFmtInBlock(block, 12).background() == THEME.error
assert charFmtInBlock(block, 13).background() == THEME.error
assert charFmtInBlock(block, 14).background() == QtTransparent

View File

@@ -35,7 +35,7 @@ from PyQt6.QtWidgets import QListWidgetItem
from pytestqt.qtbot import QtBot
from novelwriter import SHARED
from novelwriter.constants import nwHeadFmt, nwStats
from novelwriter.constants import nwHeadFmt, nwProblems, nwStats
from novelwriter.core.project import NWProject
from novelwriter.guimain import GuiMain
from novelwriter.manuscript.buildsettings import BuildSettings
@@ -360,6 +360,75 @@ def testGuiManuscript_Features(
# Tests are too fast to trigger this one, so we trigger it manually to ensure it isn't failing
manus.docPreview._postUpdate()
# Problems
# ========
tabs = manus.detailsTabs
problems = manus.buildProblems
assert tabs.isTabVisible(tabs.indexOf(problems)) is False
assert problems.hasProblems() is False
# Missing auto-replace keys show the tab
SHARED.project.data.setAutoReplace({"A": "B"}, False)
SHARED.project.storage.getDocument(C.hSceneDoc).writeDocument("### New Scene\n\nText <A> <X>\n\n")
manus._generatePreview()
assert tabs.isTabVisible(tabs.indexOf(problems)) is True
assert tabs.currentWidget() is problems
assert problems.hasProblems() is True
assert problems.problemCount() == 1
assert manus.docPreview.ageLabel.text().endswith(", Problems: 1")
# The tab isn't selected again if it was already visible
tabs.setCurrentWidget(manus.buildOutline)
manus._generatePreview()
assert tabs.currentWidget() is manus.buildOutline
parent = problems.listView.topLevelItem(0)
assert parent is not None
assert parent.text(0) == "Missing Auto-Replace Keys (1)"
assert parent.isExpanded() is True
child = parent.child(0)
assert child is not None
assert child.text(0) == "<X> in New Scene"
# Clicking an entry navigates, but clicking the category does not
with qtbot.assertNotEmitted(problems.problemEntryClicked):
problems._onItemClick(parent, 0)
with qtbot.waitSignal(problems.problemEntryClicked) as signal:
problems._onItemClick(child, 0)
assert signal.args == ["problem_0"]
assert manus.docPreview.source() == QUrl("#problem_0")
# The anchor exists in the preview document
names = []
preview = manus.docPreview.document()
assert preview is not None
block = preview.begin()
while block.isValid():
it = block.begin()
while not it.atEnd():
names.extend(it.fragment().charFormat().anchorNames())
it += 1
block = block.next()
assert "problem_0" in names
# Without a problem anchor, the heading anchor is used, and an unknown handle has no document name
problems.updateProblems({nwProblems.MISSING_AUTO_KEYS: [("Y", "0000000000000:T0001", "")]})
parent = problems.listView.topLevelItem(0)
assert parent is not None
child = parent.child(0)
assert child is not None
assert child.text(0) == "<Y>"
assert child.data(0, problems.D_ANCHOR) == "0000000000000:T0001"
# Fixing the problems hides the tab again
SHARED.project.storage.getDocument(C.hSceneDoc).writeDocument("### New Scene\n\nText <A>\n\n")
manus._generatePreview()
assert tabs.isTabVisible(tabs.indexOf(problems)) is False
assert problems.hasProblems() is False
assert problems.problemCount() == 0
assert "Problems" not in manus.docPreview.ageLabel.text()
# Builds
# ======