Rewrite the auto-replace feature (#3128)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -304,7 +304,6 @@ class GuiDocViewer(QTextBrowser):
|
||||
# See issue #298
|
||||
try:
|
||||
qDoc.setText(tHandle)
|
||||
qDoc.doPreProcessing()
|
||||
qDoc.tokenizeText()
|
||||
qDoc.doConvert()
|
||||
qDoc.closeDocument()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")))
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 <brackets> & 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 <brackets> & short–dash, long—dash …</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 & Jerry—</p>\n"
|
||||
|
||||
# CSS
|
||||
# ===
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ======
|
||||
|
||||
|
||||
Reference in New Issue
Block a user