From 08b17daec90825ef180866ae69fe017af5c78660 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 7 Apr 2024 18:06:21 +0200
Subject: [PATCH 01/35] Add parsing for terms in comments
---
novelwriter/core/index.py | 16 ++++++----
novelwriter/core/tokenizer.py | 2 +-
novelwriter/enum.py | 4 ++-
novelwriter/gui/dochighlight.py | 27 ++++++++++++-----
tests/test_core/test_core_index.py | 47 ++++++++++++++++++------------
5 files changed, 62 insertions(+), 34 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 7ca7afe5..89938e69 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -336,7 +336,7 @@ class NWIndex:
elif line.startswith("%"):
if cTitle != TT_NONE:
- cStyle, cText, _ = processComment(line)
+ cStyle, cMod, cText, _, _ = processComment(line)
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
@@ -1303,17 +1303,23 @@ class IndexHeading:
# =============================================================================================== #
CLASSIFIERS = {
- "short": nwComment.SHORT,
"synopsis": nwComment.SYNOPSIS,
+ "summary": nwComment.SUMMARY,
+ "short": nwComment.SHORT,
+ "note": nwComment.NOTE,
}
+TERMS = ["note"]
-def processComment(text: str) -> tuple[nwComment, str, int]:
+
+def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
"""Extract comment style and text. Should only be called on text
starting with a %.
"""
check = text[1:].lstrip()
classifier, _, content = check.partition(":")
+ classifier, _, term = classifier.partition(".")
if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
- return CLASSIFIERS[clean], content.strip(), text.find(":") + 1
- return nwComment.PLAIN, check, 0
+ term = "ERR" if term and clean not in TERMS else term.strip().lower()
+ return CLASSIFIERS[clean], term, content.strip(), text.find(".") + 1, text.find(":") + 1
+ return nwComment.PLAIN, "", check, 0, 0
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index d3f2b690..abb6fc28 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -533,7 +533,7 @@ class Tokenizer(ABC):
if aLine.startswith("%~"):
continue
- cStyle, cText, _ = processComment(aLine)
+ cStyle, cMod, cText, _, _ = processComment(aLine)
if cStyle == nwComment.SYNOPSIS:
self._tokens.append((
self.T_SYNOPSIS, nHead, cText, [], sAlign
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index d905e587..703df55a 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -66,7 +66,9 @@ class nwComment(Enum):
PLAIN = 0
SYNOPSIS = 1
- SHORT = 2
+ SUMMARY = 2
+ SHORT = 3
+ NOTE = 4
# END Enum nwComment
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index 27ae33be..62f88f61 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -276,6 +276,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if self._tHandle is None or not text:
return
+ xOff = 0
if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index
@@ -333,12 +334,20 @@ class GuiDocHighlighter(QSyntaxHighlighter):
elif text.startswith("%"): # Comments
self.setCurrentBlockState(BLOCK_TEXT)
- cStyle, _, cPos = processComment(text)
- if cStyle == nwComment.PLAIN:
- self.setFormat(0, len(text), self._hStyles["hidden"])
+ cStyle, cMod, _, cDot, cPos = processComment(text)
+ cLen = len(text) - cPos
+ xOff = cPos
+ if cMod == "ERR":
+ self.setFormat(0, cPos, self._hStyles["codeinval"])
+ elif cStyle == nwComment.PLAIN:
+ self.setFormat(0, cLen, self._hStyles["hidden"])
+ elif cMod:
+ self.setFormat(0, cDot, self._hStyles["modifier"])
+ self.setFormat(cDot, cPos - cDot, self._hStyles["optional"])
+ self.setFormat(cPos, cLen, self._hStyles["hidden"])
else:
self.setFormat(0, cPos, self._hStyles["modifier"])
- self.setFormat(cPos, len(text), self._hStyles["hidden"])
+ self.setFormat(cPos, cLen, self._hStyles["hidden"])
else: # Text Paragraph
@@ -377,7 +386,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self.setCurrentBlockUserData(data)
if self._spellCheck:
- for xPos, xLen in data.spellCheck(text):
+ for xPos, xLen in data.spellCheck(text, xOff):
for x in range(xPos, xPos+xLen):
spFmt = self.format(x)
spFmt.merge(self._spellErr)
@@ -435,17 +444,19 @@ class TextBlockData(QTextBlockUserData):
"""Return spell error data from last check."""
return self._spellErrors
- def spellCheck(self, text: str) -> list[tuple[int, int]]:
+ def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]:
"""Run the spell checker and cache the result, and return the
list of spell check errors.
"""
self._spellErrors = []
- rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0)
+ rxSpell = SPELLRX.globalMatch(text[offset:].replace("_", " "), 0)
while rxSpell.hasNext():
rxMatch = rxSpell.next()
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper():
- self._spellErrors.append((rxMatch.capturedStart(0), rxMatch.capturedLength(0)))
+ self._spellErrors.append(
+ (rxMatch.capturedStart(0) + offset, rxMatch.capturedLength(0))
+ )
return self._spellErrors
# END Class TextBlockData
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index b04f8c44..e637ee95 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -1322,26 +1322,35 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
def testCoreIndex_processComment():
"""Test the comment processing function."""
# Regular comment
- assert processComment("%Hi") == (nwComment.PLAIN, "Hi", 0)
- assert processComment("% Hi") == (nwComment.PLAIN, "Hi", 0)
- assert processComment("% Hi:You") == (nwComment.PLAIN, "Hi:You", 0)
+ assert processComment("%Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
+ assert processComment("% Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
+ assert processComment("% Hi:You") == (nwComment.PLAIN, "", "Hi:You", 0, 0)
+ assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0)
- # Synopsis
- assert processComment("%synopsis:") == (nwComment.PLAIN, "synopsis:", 0)
- assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "Hi", 10)
- assert processComment("% synopsis: Hi") == (nwComment.SYNOPSIS, "Hi", 11)
- assert processComment("% synopsis : Hi") == (nwComment.SYNOPSIS, "Hi", 13)
- assert processComment("% Synopsis : Hi") == (nwComment.SYNOPSIS, "Hi", 15)
- assert processComment("% \t SYNOPSIS : Hi") == (nwComment.SYNOPSIS, "Hi", 16)
- assert processComment("% \t SYNOPSIS : Hi:You") == (nwComment.SYNOPSIS, "Hi:You", 16)
+ # Check Non-Term
+ assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9)
+ assert processComment("%summary.term: Hi") == (nwComment.SUMMARY, "ERR", "Hi", 9, 14)
- # Short Description
- assert processComment("%short:") == (nwComment.PLAIN, "short:", 0)
- assert processComment("%short: Hi") == (nwComment.SHORT, "Hi", 7)
- assert processComment("% short: Hi") == (nwComment.SHORT, "Hi", 8)
- assert processComment("% short : Hi") == (nwComment.SHORT, "Hi", 10)
- assert processComment("% Short : Hi") == (nwComment.SHORT, "Hi", 12)
- assert processComment("% \t SHORT : Hi") == (nwComment.SHORT, "Hi", 13)
- assert processComment("% \t SHORT : Hi:You") == (nwComment.SHORT, "Hi:You", 13)
+ # Check Term
+ assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6)
+ assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
+
+ # Check Padding
+ assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
+ assert processComment("% short: Hi") == (nwComment.SHORT, "", "Hi", 0, 8)
+ assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 10)
+ assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 12)
+ assert processComment("% \t short : Hi") == (nwComment.SHORT, "", "Hi", 0, 13)
+
+ assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
+ assert processComment("% note.term: Hi") == (nwComment.NOTE, "term", "Hi", 7, 12)
+ assert processComment("% note . term : Hi") == (nwComment.NOTE, "term", "Hi", 9, 16)
+ assert processComment("% note . term : Hi") == (nwComment.NOTE, "term", "Hi", 11, 20)
+
+ # Check Classifiers
+ assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
+ assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
+ assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9)
+ assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
# END Test testCoreIndex_processComment
From dff2d5996b4ddc532107cc1d5ef5b7f7936e73f8 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 7 Apr 2024 23:56:40 +0200
Subject: [PATCH 02/35] Add new index classes to store special text comments
---
novelwriter/core/index.py | 171 +++++++++++++++++++++++++++--
novelwriter/enum.py | 6 +-
tests/test_core/test_core_index.py | 6 +-
3 files changed, 169 insertions(+), 14 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 89938e69..aa01b327 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -85,6 +85,7 @@ class NWIndex:
# Storage and State
self._tagsIndex = TagsIndex()
self._itemIndex = ItemIndex(project)
+ self._textIndex = TextIndex()
self._indexBroken = False
# TimeStamps
@@ -112,6 +113,7 @@ class NWIndex:
"""Clear the index dictionaries and time stamps."""
self._tagsIndex.clear()
self._itemIndex.clear()
+ self._textIndex.clear()
self._indexChange = 0.0
self._rootChange = {}
SHARED.indexSignalProxy({"event": "clearIndex"})
@@ -135,6 +137,7 @@ class NWIndex:
for tTag in delTags:
del self._tagsIndex[tTag]
del self._itemIndex[tHandle]
+ self._textIndex.removeHandle(tHandle)
SHARED.indexSignalProxy({
"event": "updateTags",
"deleted": delTags,
@@ -189,6 +192,7 @@ class NWIndex:
try:
self._tagsIndex.unpackData(data["novelWriter.tagsIndex"])
self._itemIndex.unpackData(data["novelWriter.itemIndex"])
+ self._textIndex.unpackData(data["novelWriter.textIndex"])
except Exception:
logger.error("The index content is invalid")
logException()
@@ -224,10 +228,12 @@ class NWIndex:
try:
tagsIndex = jsonEncode(self._tagsIndex.packData(), n=1, nmax=2)
itemIndex = jsonEncode(self._itemIndex.packData(), n=1, nmax=4)
+ textIndex = jsonEncode(self._textIndex.packData(), n=1, nmax=3)
with open(indexFile, mode="w+", encoding="utf-8") as outFile:
outFile.write("{\n")
outFile.write(f' "novelWriter.tagsIndex": {tagsIndex},\n')
- outFile.write(f' "novelWriter.itemIndex": {itemIndex}\n')
+ outFile.write(f' "novelWriter.itemIndex": {itemIndex},\n')
+ outFile.write(f' "novelWriter.textIndex": {textIndex}\n')
outFile.write("}\n")
except Exception:
@@ -301,9 +307,9 @@ class NWIndex:
def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None:
"""Scan an active document for meta data."""
- nTitle = 0 # Line Number of the previous title
- cTitle = TT_NONE # Tag of the current title
- pTitle = TT_NONE # Tag of the previous title
+ nTitle = 0 # Line Number of the previous title
+ cTitle = TT_NONE # Tag of the current title
+ pTitle = TT_NONE # Tag of the previous title
canSetHead = True # First heading has not yet been set
lines = text.splitlines()
@@ -335,10 +341,14 @@ class NWIndex:
self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags)
elif line.startswith("%"):
+ cStyle, cKey, cText, _, _ = processComment(line)
if cTitle != TT_NONE:
- cStyle, cMod, cText, _, _ = processComment(line)
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
+ if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
+ self._textIndex.summary.add(f"{tHandle}:{cTitle}", tHandle, cText)
+ elif cStyle == nwComment.FOOTNOTE:
+ self._textIndex.footnotes.add(cKey, tHandle, cText)
# Count words for remaining text after last heading
if pTitle != TT_NONE:
@@ -790,7 +800,7 @@ class TagsIndex:
for key, entry in data.items():
if not isinstance(key, str):
- raise ValueError("tagsIndex keys must be a string")
+ raise ValueError("tagsIndex key must be a string")
if not isinstance(entry, dict):
raise ValueError("tagsIndex entry is not a dict")
@@ -1298,18 +1308,163 @@ class IndexHeading:
# END Class IndexHeading
+# =============================================================================================== #
+# The Text Index Object
+# =============================================================================================== #
+
+class TextIndex:
+ """Core: Text Index Wrapper Class
+
+ A wrapper class that holds various global text entries.
+ """
+
+ __slots__ = ("_summary", "_footnotes")
+
+ def __init__(self) -> None:
+ self._summary = TextRegistry()
+ self._footnotes = TextRegistry()
+ return
+
+ @property
+ def summary(self) -> TextRegistry:
+ """Return the summary text registry."""
+ return self._summary
+
+ @property
+ def footnotes(self) -> TextRegistry:
+ """Return the footnotes text registry."""
+ return self._footnotes
+
+ ##
+ # Methods
+ ##
+
+ def clear(self) -> None:
+ """Clear the index."""
+ self._summary.clear()
+ self._footnotes.clear()
+ return
+
+ def removeHandle(self, handle: str) -> None:
+ """Remove all entries for a given handle."""
+ self._summary.removeHandle(handle)
+ self._footnotes.removeHandle(handle)
+ return
+
+ ##
+ # Pack/Unpack
+ ##
+
+ def packData(self) -> dict[str, dict]:
+ """Pack all the text comments into a single dictionary."""
+ return {
+ "summaries": self._summary.packData(),
+ "footnotes": self._footnotes.packData(),
+ }
+
+ def unpackData(self, data: dict) -> None:
+ """Unpack the text comments index."""
+ self._summary.unpackData(data.get("summaries", {}))
+ self._footnotes.unpackData(data.get("footnotes", {}))
+ return
+
+# END Class TextIndex
+
+
+class TextRegistry:
+ """Core: Text Registry Index Wrapper Class
+
+ A wrapper class that holds a category of text entries.
+ """
+
+ __slots__ = ("_map", "_text")
+
+ def __init__(self) -> None:
+ self._map: dict[str, str] = {}
+ self._text: dict[str, str] = {}
+ return
+
+ def __len__(self) -> int:
+ return len(self._text)
+
+ def __getitem__(self, key: str) -> str | None:
+ return self._text.get(key, (0, None))[1]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self._text
+
+ ##
+ # Methods
+ ##
+
+ def clear(self) -> None:
+ """Clear the index."""
+ self._map.clear()
+ self._text.clear()
+ return
+
+ def add(self, key: str, handle: str, text: str) -> None:
+ """Add a new text entry."""
+ self._map[key] = handle
+ self._text[key] = text
+ return
+
+ def keysForHandle(self, handle: str) -> list[str]:
+ """Return all keys for a given handle."""
+ return [k for k, v in self._map.items() if v == handle]
+
+ def removeHandle(self, handle: str) -> None:
+ """Iterate through the data and remove entries for a handle."""
+ for key in [k for k, v in self._map.items() if v == handle]:
+ del self._text[key]
+ return
+
+ ##
+ # Pack/Unpack
+ ##
+
+ def packData(self) -> dict[str, dict[str, str]]:
+ """Pack all the text entries into a dictionary."""
+ return {k: {"handle": self._map[k], "text": v} for k, v in self._text.items()}
+
+ def unpackData(self, data: dict) -> None:
+ """Unpack text entries from a dictionary."""
+ self.clear()
+ if not isinstance(data, dict):
+ raise ValueError("textEntry is not a dict")
+
+ for key, entry in data.items():
+ if not isinstance(key, str):
+ raise ValueError("textEntry key must be a string")
+ if not isinstance(entry, dict):
+ raise ValueError("textEntry entry is not a dict")
+
+ handle = entry.get("handle")
+ text = entry.get("text")
+ if not isHandle(handle):
+ raise ValueError("textEntry handle must be a handle")
+ if not isinstance(text, str):
+ raise ValueError("textEntry text is not a string")
+
+ self.add(key, handle, text)
+
+ return
+
+# END Class TextEntry
+
+
# =============================================================================================== #
# Text Processing Functions
# =============================================================================================== #
CLASSIFIERS = {
"synopsis": nwComment.SYNOPSIS,
- "summary": nwComment.SUMMARY,
"short": nwComment.SHORT,
"note": nwComment.NOTE,
+ "footnote": nwComment.FOOTNOTE,
}
-TERMS = ["note"]
+TERMS = ["note", "footnote"]
def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 703df55a..3f630165 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -66,9 +66,9 @@ class nwComment(Enum):
PLAIN = 0
SYNOPSIS = 1
- SUMMARY = 2
- SHORT = 3
- NOTE = 4
+ SHORT = 2
+ NOTE = 3
+ FOOTNOTE = 4
# END Enum nwComment
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index e637ee95..8a4c9db1 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -1328,8 +1328,8 @@ def testCoreIndex_processComment():
assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0)
# Check Non-Term
- assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9)
- assert processComment("%summary.term: Hi") == (nwComment.SUMMARY, "ERR", "Hi", 9, 14)
+ assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
+ assert processComment("%short.term: Hi") == (nwComment.SHORT, "ERR", "Hi", 7, 12)
# Check Term
assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6)
@@ -1350,7 +1350,7 @@ def testCoreIndex_processComment():
# Check Classifiers
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
- assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9)
assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
+ assert processComment("%footnote.term: Hi") == (nwComment.FOOTNOTE, "term", "Hi", 10, 15)
# END Test testCoreIndex_processComment
From ba42285172433771d56537e55a55e5774d405579 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 8 Apr 2024 00:56:36 +0200
Subject: [PATCH 03/35] Remove summaries from text index
---
novelwriter/core/index.py | 25 +++++++++++--------------
1 file changed, 11 insertions(+), 14 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index aa01b327..05554aa7 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -342,11 +342,8 @@ class NWIndex:
elif line.startswith("%"):
cStyle, cKey, cText, _, _ = processComment(line)
- if cTitle != TT_NONE:
- if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
- self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
- self._textIndex.summary.add(f"{tHandle}:{cTitle}", tHandle, cText)
+ self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
elif cStyle == nwComment.FOOTNOTE:
self._textIndex.footnotes.add(cKey, tHandle, cText)
@@ -1318,17 +1315,17 @@ class TextIndex:
A wrapper class that holds various global text entries.
"""
- __slots__ = ("_summary", "_footnotes")
+ __slots__ = ("_comments", "_footnotes")
def __init__(self) -> None:
- self._summary = TextRegistry()
+ self._comments = TextRegistry()
self._footnotes = TextRegistry()
return
@property
- def summary(self) -> TextRegistry:
- """Return the summary text registry."""
- return self._summary
+ def comments(self) -> TextRegistry:
+ """Return the comments text registry."""
+ return self._comments
@property
def footnotes(self) -> TextRegistry:
@@ -1341,13 +1338,13 @@ class TextIndex:
def clear(self) -> None:
"""Clear the index."""
- self._summary.clear()
+ self._comments.clear()
self._footnotes.clear()
return
def removeHandle(self, handle: str) -> None:
"""Remove all entries for a given handle."""
- self._summary.removeHandle(handle)
+ self._comments.removeHandle(handle)
self._footnotes.removeHandle(handle)
return
@@ -1358,13 +1355,13 @@ class TextIndex:
def packData(self) -> dict[str, dict]:
"""Pack all the text comments into a single dictionary."""
return {
- "summaries": self._summary.packData(),
+ "comments": self._comments.packData(),
"footnotes": self._footnotes.packData(),
}
def unpackData(self, data: dict) -> None:
"""Unpack the text comments index."""
- self._summary.unpackData(data.get("summaries", {}))
+ self._comments.unpackData(data.get("comments", {}))
self._footnotes.unpackData(data.get("footnotes", {}))
return
@@ -1475,6 +1472,6 @@ def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
classifier, _, content = check.partition(":")
classifier, _, term = classifier.partition(".")
if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
- term = "ERR" if term and clean not in TERMS else term.strip().lower()
+ term = "ERR" if term and clean not in TERMS else term.strip()
return CLASSIFIERS[clean], term, content.strip(), text.find(".") + 1, text.find(":") + 1
return nwComment.PLAIN, "", check, 0, 0
From 5568b43b226356c85d0c0ba93a878f865db7be19 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 14 Apr 2024 22:17:00 +0200
Subject: [PATCH 04/35] Add key generator to text index
---
novelwriter/core/index.py | 20 ++++++++++++++++----
novelwriter/enum.py | 3 +++
novelwriter/gui/mainmenu.py | 6 ++++++
3 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 05554aa7..a75dbf7d 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -30,6 +30,7 @@ from __future__ import annotations
import json
import logging
+from random import randint
from time import time
from typing import TYPE_CHECKING
from pathlib import Path
@@ -1309,6 +1310,9 @@ class IndexHeading:
# The Text Index Object
# =============================================================================================== #
+KEY_SOURCE = "0123456789bcdfghjklmnopqrstvwxyz"
+
+
class TextIndex:
"""Core: Text Index Wrapper Class
@@ -1318,8 +1322,8 @@ class TextIndex:
__slots__ = ("_comments", "_footnotes")
def __init__(self) -> None:
- self._comments = TextRegistry()
- self._footnotes = TextRegistry()
+ self._comments = TextRegistry("c_")
+ self._footnotes = TextRegistry("f_")
return
@property
@@ -1374,11 +1378,12 @@ class TextRegistry:
A wrapper class that holds a category of text entries.
"""
- __slots__ = ("_map", "_text")
+ __slots__ = ("_map", "_text", "_prefix")
- def __init__(self) -> None:
+ def __init__(self, prefix: str) -> None:
self._map: dict[str, str] = {}
self._text: dict[str, str] = {}
+ self._prefix = prefix
return
def __len__(self) -> int:
@@ -1416,6 +1421,13 @@ class TextRegistry:
del self._text[key]
return
+ def newKey(self) -> str:
+ """Generate a new key."""
+ key = self._prefix + "".join([KEY_SOURCE[randint(0, 31)] for _ in range(4)])
+ if key in self._text:
+ key = self.newKey()
+ return key
+
##
# Pack/Unpack
##
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 3f630165..4750ef5d 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -69,6 +69,8 @@ class nwComment(Enum):
SHORT = 2
NOTE = 3
FOOTNOTE = 4
+ COMMENT = 5
+ STORY = 6
# END Enum nwComment
@@ -147,6 +149,7 @@ class nwDocInsert(Enum):
VSPACE_S = 8
VSPACE_M = 9
LIPSUM = 10
+ FOOTNOTE = 11
# END Enum nwDocInsert
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 4a9c198d..96fe2b2c 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -597,6 +597,12 @@ class GuiMainMenu(QMenuBar):
lambda: self.requestDocInsert.emit(nwDocInsert.LIPSUM)
)
+ # Insert > Footnote
+ self.aFootnote = self.insMenu.addAction(self.tr("Footnote"))
+ self.aFootnote.triggered.connect(
+ lambda: self.requestDocInsert.emit(nwDocInsert.FOOTNOTE)
+ )
+
return
def _buildFormatMenu(self) -> None:
From 70033cb0f33e8b4f8e3e3ff932dfa0ddb04de81d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 14 Apr 2024 23:33:35 +0200
Subject: [PATCH 05/35] Add insert footnote feature in editor
---
novelwriter/constants.py | 11 +++++++++--
novelwriter/core/index.py | 12 ++++++++++--
novelwriter/gui/doceditor.py | 32 +++++++++++++++++++++++++++++++-
novelwriter/gui/dochighlight.py | 29 ++++++++++++++++++++++++-----
sample/content/636b6aa9b697b.nwd | 8 +++++---
sample/nwProject.nwx | 6 +++---
6 files changed, 82 insertions(+), 16 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 48487d77..917d0049 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -25,7 +25,9 @@ from __future__ import annotations
from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP
-from novelwriter.enum import nwBuildFmt, nwItemClass, nwItemLayout, nwOutline, nwStatusShape
+from novelwriter.enum import (
+ nwBuildFmt, nwComment, nwItemClass, nwItemLayout, nwOutline, nwStatusShape
+)
def trConst(text: str) -> str:
@@ -67,7 +69,7 @@ class nwRegEx:
FMT_EB = r"(? str | None:
+ """Generate a new key for a comment style."""
+ if style == nwComment.FOOTNOTE:
+ return self._textIndex.footnotes.newKey()
+ elif style == nwComment.COMMENT:
+ return self._textIndex.comments.newKey()
+ return None
+
##
# Extract Data
##
@@ -1322,8 +1330,8 @@ class TextIndex:
__slots__ = ("_comments", "_footnotes")
def __init__(self) -> None:
- self._comments = TextRegistry("c_")
- self._footnotes = TextRegistry("f_")
+ self._comments = TextRegistry("c")
+ self._footnotes = TextRegistry("f")
return
@property
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 4d603289..b1e94046 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -55,7 +55,7 @@ from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
-from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
+from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.extensions.eventfilters import WheelEventFilter
from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton
from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE
@@ -884,6 +884,9 @@ class GuiDocEditor(QPlainTextEdit):
text = GuiLipsum.getLipsum(self)
newBlock = True
goAfter = False
+ elif insert == nwDocInsert.FOOTNOTE:
+ text = ""
+ self._insertCommentStructure(nwComment.FOOTNOTE)
else:
return False
else:
@@ -1850,6 +1853,33 @@ class GuiDocEditor(QPlainTextEdit):
return
+ def _insertCommentStructure(self, style: nwComment) -> None:
+ """Insert a shortcut/comment combo."""
+ if style == nwComment.FOOTNOTE:
+ key = SHARED.project.index.newCommentKey(style)
+ code = nwShortcode.COMMENT_STYLES[nwComment.FOOTNOTE]
+
+ cursor = self.textCursor()
+ block = cursor.block()
+ text = block.text().rstrip()
+ if not text or text.startswith(("@", "#", "%")):
+ SHARED.error(self.tr("Footnotes can only be inserted in text."))
+ return
+
+ cursor.beginEditBlock()
+ cursor.insertText(code.format(key))
+ cursor.setPosition(block.position() + block.length())
+ cursor.insertBlock()
+ cursor.insertText(f"%Footnote.{key}: ")
+ cursor.insertBlock()
+ cursor.endEditBlock()
+
+ cursor.setPosition(cursor.position() - 1)
+
+ self.setTextCursor(cursor)
+
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index 24bacd58..1debcd7e 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -44,6 +44,10 @@ logger = logging.getLogger(__name__)
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
+SPELLSC = QRegularExpression(nwRegEx.FMT_SC)
+SPELLSC.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
+SPELLSV = QRegularExpression(nwRegEx.FMT_SV)
+SPELLSV.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
BLOCK_NONE = 0
BLOCK_TEXT = 1
@@ -53,7 +57,10 @@ BLOCK_TITLE = 4
class GuiDocHighlighter(QSyntaxHighlighter):
- __slots__ = ("_tItem", "_tHandle", "_spellCheck", "_spellErr", "_hRules", "_hStyles")
+ __slots__ = (
+ "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hRules",
+ "_hStyles", "_rxRules"
+ )
def __init__(self, document: QTextDocument) -> None:
super().__init__(document)
@@ -67,6 +74,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._hRules: list[tuple[str, dict]] = []
self._hStyles: dict[str, QTextCharFormat] = {}
+ self._rxRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
self.initHighlighter()
@@ -217,12 +225,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
}
))
- # Build a QRegExp for each highlight pattern
- self.rxRules = []
+ # Build a QRegularExpression for each highlight pattern
+ self._rxRules = []
for regEx, regRules in self._hRules:
hReg = QRegularExpression(regEx)
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
- self.rxRules.append((hReg, regRules))
+ self._rxRules.append((hReg, regRules))
return
@@ -367,7 +375,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Regular Text
self.setCurrentBlockState(BLOCK_TEXT)
- for rX, xFmt in self.rxRules:
+ for rX, xFmt in self._rxRules:
rxItt = rX.globalMatch(text, 0)
while rxItt.hasNext():
rxMatch = rxItt.next()
@@ -448,6 +456,17 @@ class TextBlockData(QTextBlockUserData):
"""Run the spell checker and cache the result, and return the
list of spell check errors.
"""
+ if "[" in text:
+ # Strip shortcodes
+ for rX in [SPELLSC, SPELLSV]:
+ rxItt = rX.globalMatch(text, 0)
+ while rxItt.hasNext():
+ rxMatch = rxItt.next()
+ xPos = rxMatch.capturedStart(0)
+ xLen = rxMatch.capturedLength(0)
+ xEnd = rxMatch.capturedEnd(0)
+ text = text[:xPos] + " "*xLen + text[xEnd:]
+
self._spellErrors = []
rxSpell = SPELLRX.globalMatch(text[offset:].replace("_", " "), 0)
while rxSpell.hasNext():
diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd
index ed5bacb6..7532c006 100644
--- a/sample/content/636b6aa9b697b.nwd
+++ b/sample/content/636b6aa9b697b.nwd
@@ -1,8 +1,8 @@
%%~name: Making a Scene
%%~path: 6a2d6d5f4f401/636b6aa9b697b
%%~kind: NOVEL/DOCUMENT
-%%~hash: e1c58699b05b512306a534da70c2952aafc60e8b
-%%~date: Unknown/2024-02-25 16:33:40
+%%~hash: 06b80d830f3f4d5c703eff82067d4335b8b98151
+%%~date: Unknown/2024-04-14 23:28:43
### Making a Scene
@pov: Jane
@@ -21,7 +21,9 @@ If you have the need for it, you can also add text that can be automatically rep
The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens.
-Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.
+Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.[footnote:fq2ms]
+
+%Footnote.fq2ms: This is a footnote about non-breaking spaces.
#### Some Section Here
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index 716b21e7..3e5cec69 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Sample Project
Jane Smith
@@ -58,7 +58,7 @@
Chapter One
-
-
+
Making a Scene
-
From ec6a5712e551c6c506fe102e705f1de32d327a49 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 14 Apr 2024 23:59:57 +0200
Subject: [PATCH 06/35] Update tests
---
tests/reference/coreIndex_LoadSave_tagsIndex.json | 4 ++++
tests/reference/guiEditor_Main_Final_0000000000011.nwd | 6 +++---
tests/reference/guiEditor_Main_Final_nwProject.nwx | 4 ++--
tests/test_core/test_core_index.py | 8 +++++++-
tests/test_gui/test_gui_guimain.py | 2 +-
5 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json
index 4c10214a..5531aac2 100644
--- a/tests/reference/coreIndex_LoadSave_tagsIndex.json
+++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json
@@ -106,5 +106,9 @@
"T0001": {"level": "H1", "title": "Ancient Europe", "line": 1, "tag": "europe", "cCount": 1770, "wCount": 259, "pCount": 3, "synopsis": ""}
}
}
+ },
+ "novelWriter.textIndex": {
+ "comments": {},
+ "footnotes": {}
}
}
diff --git a/tests/reference/guiEditor_Main_Final_0000000000011.nwd b/tests/reference/guiEditor_Main_Final_0000000000011.nwd
index ed7002ab..bd73dfd8 100644
--- a/tests/reference/guiEditor_Main_Final_0000000000011.nwd
+++ b/tests/reference/guiEditor_Main_Final_0000000000011.nwd
@@ -1,10 +1,10 @@
%%~name: New Note
%%~path: 0000000000009/0000000000011
%%~kind: PLOT/NOTE
-%%~hash: 8ff26f8a18ad6390c2ce725c441ab0c792a125cf
-%%~date: 2023-08-25 18:15:35/2023-08-25 18:15:35
+%%~hash: 3d3697638a70fc86cc023df6d42202a262d93905
+%%~date: 2024-04-14 23:46:49/2024-04-14 23:46:49
# Main Plot
@tag: MainPlot
-This is a file detailing the main plot.
+This is a file [i]detailing[/i] the main plot.
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index 2c6603eb..4c20bd70 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -54,7 +54,7 @@
Plot
-
-
+
New Note
-
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 8a4c9db1..fc4ac02c 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -127,7 +127,13 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert index.indexBroken is True
# Write an index file that passes loading, but is still empty
- writeFile(projFile, '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}')
+ writeFile(projFile, (
+ '{'
+ '"novelWriter.tagsIndex": {}, '
+ '"novelWriter.itemIndex": {}, '
+ '"novelWriter.textIndex": {}'
+ '}'
+ ))
assert index.loadIndex() is True
assert index.indexBroken is False
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index 4de04a95..dc6ffcfc 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -274,7 +274,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)
- for c in "This is a file detailing the main plot.":
+ for c in "This is a file [i]detailing[/i] the main plot.":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key_Return, delay=KEY_DELAY)
From 9823677983926d4329be54a46b68d7e1a422e687 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 00:42:01 +0200
Subject: [PATCH 07/35] Add text marker processing in tokenizer class
---
novelwriter/constants.py | 2 +
novelwriter/core/tohtml.py | 4 +-
novelwriter/core/tokenizer.py | 98 +++++++++++++++++++++++------------
novelwriter/core/tomd.py | 2 +-
novelwriter/core/toodt.py | 2 +-
5 files changed, 71 insertions(+), 37 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 917d0049..6c3d6a81 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -91,6 +91,8 @@ class nwShortcode:
SUB_O = "[sub]"
SUB_C = "[/sub]"
+ FOOTNOTE_B = "[footnote:"
+
COMMENT_STYLES = {
nwComment.FOOTNOTE: "[footnote:{0}]",
nwComment.COMMENT: "[comment:{0}]",
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 72539c70..f027ae67 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -171,7 +171,7 @@ class ToHtml(Tokenizer):
tHandle = self._handle
- for tType, nHead, tText, tFormat, tStyle in self._tokens:
+ for tType, nHead, tText, tFormat, tMarkers, tStyle in self._tokens:
# Replace < and > with HTML entities
if tFormat:
@@ -280,6 +280,8 @@ class ToHtml(Tokenizer):
pStyle = hStyle
for pos, fmt in reversed(tFormat):
tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}"
+ for pos, fmt, key in reversed(tMarkers):
+ tTemp = f"{tTemp[:pos]}[x]{tTemp[pos:]}"
para.append(stripEscape(tTemp.rstrip()))
elif tType == self.T_SYNOPSIS and self._doSynopsis:
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index d8c9861e..ec671322 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -48,6 +48,9 @@ logger = logging.getLogger(__name__)
ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""}
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
+T_Formats = list[tuple[int, int]]
+T_Markers = list[tuple[int, int, str]]
+
def stripEscape(text: str) -> str:
"""Strip escaped Markdown characters from paragraph text."""
@@ -81,6 +84,10 @@ class Tokenizer(ABC):
FMT_SUB_B = 13 # Begin subscript
FMT_SUB_E = 14 # End subscript
+ # Inserted Markers
+ MRK_BOUNDARY = 20 # Marker boundary
+ MRK_FOOTNOTE = 21 # Footnote marker
+
# Block Type
T_EMPTY = 1 # Empty line (new paragraph)
T_SYNOPSIS = 2 # Synopsis comment
@@ -125,7 +132,8 @@ class Tokenizer(ABC):
self._allMarkdown = [] # The result novelWriter markdown of all documents
# Processed Tokens and Meta Data
- self._tokens: list[tuple[int, int, str, list[tuple[int, int]], int]] = []
+ self._tokens: list[tuple[int, int, str, T_Formats, T_Markers, int]] = []
+ self._markers: dict[str, tuple[int, list[str]]] = {}
self._counts: dict[str, int] = {}
self._outline: dict[str, str] = {}
@@ -205,6 +213,9 @@ class Tokenizer(ABC):
nwShortcode.SUP_O: self.FMT_SUP_B, nwShortcode.SUP_C: self.FMT_SUP_E,
nwShortcode.SUB_O: self.FMT_SUB_B, nwShortcode.SUB_C: self.FMT_SUB_E,
}
+ self._shortCodeVals = {
+ nwShortcode.FOOTNOTE_B: self.MRK_FOOTNOTE,
+ }
return
@@ -415,7 +426,7 @@ class Tokenizer(ABC):
title = f"{trNotes}: {tItem.itemName}"
self._tokens = []
self._tokens.append((
- self.T_TITLE, 1, title, [], textAlign
+ self.T_TITLE, 1, title, [], [], textAlign
))
if self._keepMarkdown:
self._allMarkdown.append(f"#! {title}\n\n")
@@ -479,7 +490,7 @@ class Tokenizer(ABC):
# Check for blank lines
if len(sLine) == 0:
self._tokens.append((
- self.T_EMPTY, nHead, "", [], self.A_NONE
+ self.T_EMPTY, nHead, "", [], [], self.A_NONE
))
if self._keepMarkdown:
tmpMarkdown.append("\n")
@@ -508,7 +519,7 @@ class Tokenizer(ABC):
elif sLine == "[vspace]":
self._tokens.append(
- (self.T_SKIP, nHead, "", [], sAlign)
+ (self.T_SKIP, nHead, "", [], [], sAlign)
)
continue
@@ -516,11 +527,11 @@ class Tokenizer(ABC):
nSkip = checkInt(sLine[8:-1], 0)
if nSkip >= 1:
self._tokens.append(
- (self.T_SKIP, nHead, "", [], sAlign)
+ (self.T_SKIP, nHead, "", [], [], sAlign)
)
if nSkip > 1:
self._tokens += (nSkip - 1) * [
- (self.T_SKIP, nHead, "", [], self.A_NONE)
+ (self.T_SKIP, nHead, "", [], [], self.A_NONE)
]
continue
@@ -536,19 +547,19 @@ class Tokenizer(ABC):
cStyle, cMod, cText, _, _ = processComment(aLine)
if cStyle == nwComment.SYNOPSIS:
self._tokens.append((
- self.T_SYNOPSIS, nHead, cText, [], sAlign
+ self.T_SYNOPSIS, nHead, cText, [], [], sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.SHORT:
self._tokens.append((
- self.T_SHORT, nHead, cText, [], sAlign
+ self.T_SHORT, nHead, cText, [], [], sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
else:
self._tokens.append((
- self.T_COMMENT, nHead, cText, [], sAlign
+ self.T_COMMENT, nHead, cText, [], [], sAlign
))
if self._doComments and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -562,7 +573,7 @@ class Tokenizer(ABC):
valid, bits, _ = self._project.index.scanThis(aLine)
if valid and bits and bits[0] not in self._skipKeywords:
self._tokens.append((
- self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign
+ self.T_KEYWORD, nHead, aLine[1:].strip(), [], [], sAlign
))
if self._doKeywords and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -598,7 +609,7 @@ class Tokenizer(ABC):
self._noSep = True
self._tokens.append((
- tType, nHead, tText, [], tStyle
+ tType, nHead, tText, [], [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -633,7 +644,7 @@ class Tokenizer(ABC):
self._noSep = True
self._tokens.append((
- tType, nHead, tText, [], tStyle
+ tType, nHead, tText, [], [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -674,7 +685,7 @@ class Tokenizer(ABC):
self._noSep = False
self._tokens.append((
- tType, nHead, tText, [], tStyle
+ tType, nHead, tText, [], [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -704,7 +715,7 @@ class Tokenizer(ABC):
tStyle = self.A_CENTRE
self._tokens.append((
- tType, nHead, tText, [], tStyle
+ tType, nHead, tText, [], [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -750,9 +761,9 @@ class Tokenizer(ABC):
sAlign |= self.A_IND_R
# Process formats
- tLine, fmtPos = self._extractFormats(aLine)
+ tLine, fmtPos, insMrk = self._extractFormats(aLine)
self._tokens.append((
- self.T_TEXT, nHead, tLine, fmtPos, sAlign
+ self.T_TEXT, nHead, tLine, fmtPos, insMrk, sAlign
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -763,15 +774,15 @@ class Tokenizer(ABC):
# Make sure the token array doesn't start with a page break
# on the very first page, adding a blank first page.
- if self._tokens[0][4] & self.A_PBB:
+ if self._tokens[0][5] & self.A_PBB:
token = self._tokens[0]
self._tokens[0] = (
- token[0], token[1], token[2], token[3], token[4] & ~self.A_PBB
+ token[0], token[1], token[2], token[3], token[4], token[5] & ~self.A_PBB
)
# Always add an empty line at the end of the file
self._tokens.append((
- self.T_EMPTY, nHead, "", [], self.A_NONE
+ self.T_EMPTY, nHead, "", [], [], self.A_NONE
))
if self._keepMarkdown:
tmpMarkdown.append("\n")
@@ -781,8 +792,8 @@ class Tokenizer(ABC):
# ===========
# Some items need a second pass
- pToken = (self.T_EMPTY, 0, "", [], self.A_NONE)
- nToken = (self.T_EMPTY, 0, "", [], self.A_NONE)
+ pToken = (self.T_EMPTY, 0, "", [], [], self.A_NONE)
+ nToken = (self.T_EMPTY, 0, "", [], [], self.A_NONE)
tCount = len(self._tokens)
for n, token in enumerate(self._tokens):
@@ -792,12 +803,14 @@ class Tokenizer(ABC):
nToken = self._tokens[n+1]
if token[0] == self.T_KEYWORD:
- aStyle = token[4]
+ aStyle = token[5]
if pToken[0] == self.T_KEYWORD:
aStyle |= self.A_Z_TOPMRG
if nToken[0] == self.T_KEYWORD:
aStyle |= self.A_Z_BTMMRG
- self._tokens[n] = (token[0], token[1], token[2], token[3], aStyle)
+ self._tokens[n] = (
+ token[0], token[1], token[2], token[3], token[4], aStyle
+ )
return
@@ -805,7 +818,7 @@ class Tokenizer(ABC):
"""Build an outline of the text up to level 3 headings."""
tHandle = self._handle or ""
isNovel = self._isNovel
- for tType, nHead, tText, _, _ in self._tokens:
+ for tType, nHead, tText, _, _, _ in self._tokens:
if tType == self.T_TITLE:
prefix = "TT"
elif tType == self.T_HEAD1:
@@ -841,7 +854,7 @@ class Tokenizer(ABC):
titleWordChars = self._counts.get("titleWordChars", 0)
para = []
- for tType, _, tText, _, _ in self._tokens:
+ for tType, _, tText, _, _, _ in self._tokens:
tText = tText.replace(nwUnicode.U_ENDASH, " ")
tText = tText.replace(nwUnicode.U_EMDASH, " ")
@@ -961,9 +974,9 @@ class Tokenizer(ABC):
# Internal Functions
##
- def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]:
+ def _extractFormats(self, text: str) -> tuple[str, T_Formats, T_Markers]:
"""Extract format markers from a text paragraph."""
- temp = []
+ temp: list[tuple[int, int, int, str]] = []
# Match Markdown
for regEx, fmts in self._rxMarkdown:
@@ -971,7 +984,7 @@ class Tokenizer(ABC):
while rxItt.hasNext():
rxMatch = rxItt.next()
temp.extend(
- [rxMatch.capturedStart(n), rxMatch.capturedLength(n), fmt]
+ (rxMatch.capturedStart(n), rxMatch.capturedLength(n), fmt, "")
for n, fmt in enumerate(fmts) if fmt > 0
)
@@ -979,22 +992,39 @@ class Tokenizer(ABC):
rxItt = self._rxShortCodes.globalMatch(text, 0)
while rxItt.hasNext():
rxMatch = rxItt.next()
- temp.append([
+ temp.append((
rxMatch.capturedStart(1),
rxMatch.capturedLength(1),
- self._shortCodeFmt.get(rxMatch.captured(1).lower(), 0)
- ])
+ self._shortCodeFmt.get(rxMatch.captured(1).lower(), 0),
+ "",
+ ))
+
+ # Match Shortcode w/Values
+ rxItt = self._rxShortCodeVals.globalMatch(text, 0)
+ while rxItt.hasNext():
+ rxMatch = rxItt.next()
+ temp.append((
+ rxMatch.capturedStart(0),
+ rxMatch.capturedLength(0),
+ self._shortCodeVals.get(rxMatch.captured(1).lower(), 0),
+ rxMatch.captured(2),
+ ))
# Post-process text and format markers
result = text
formats = []
- for pos, n, fmt in reversed(sorted(temp, key=lambda x: x[0])):
+ markers = []
+ for pos, n, fmt, key in reversed(sorted(temp, key=lambda x: x[0])):
if fmt > 0:
result = result[:pos] + result[pos+n:]
formats = [(p-n, f) for p, f in formats]
- formats.insert(0, (pos, fmt))
+ markers = [(p-n, f, k) for p, f, k in markers]
+ if fmt > self.MRK_BOUNDARY:
+ markers.insert(0, (pos, fmt, key))
+ else:
+ formats.insert(0, (pos, fmt))
- return result, formats
+ return result, formats, markers
# END Class Tokenizer
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 82fdb78a..8503af7b 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -135,7 +135,7 @@ class ToMarkdown(Tokenizer):
lines = []
lineSep = " \n" if self._preserveBreaks else " "
- for tType, _, tText, tFormat, tStyle in self._tokens:
+ for tType, _, tText, tFormat, tMarkers, tStyle in self._tokens:
if tType == self.T_EMPTY:
if para:
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index 22418f32..4d58c367 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -403,7 +403,7 @@ class ToOdt(Tokenizer):
pText = []
pStyle = None
pIndent = True
- for tType, _, tText, tFormat, tStyle in self._tokens:
+ for tType, _, tText, tFormat, tMarkers, tStyle in self._tokens:
# Styles
oStyle = ODTParagraphStyle("New")
From 7c4d4dec5ca997a8b99807d7d874ec98ddffad7d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 01:11:32 +0200
Subject: [PATCH 08/35] Use format list also for marker keys
---
novelwriter/core/tohtml.py | 19 +++++----
novelwriter/core/tokenizer.py | 74 +++++++++++++++++------------------
novelwriter/core/tomd.py | 4 +-
novelwriter/core/toodt.py | 10 ++---
4 files changed, 56 insertions(+), 51 deletions(-)
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index f027ae67..176a6aed 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -171,7 +171,7 @@ class ToHtml(Tokenizer):
tHandle = self._handle
- for tType, nHead, tText, tFormat, tMarkers, tStyle in self._tokens:
+ for tType, nHead, tText, tFormat, tStyle in self._tokens:
# Replace < and > with HTML entities
if tFormat:
@@ -181,11 +181,11 @@ class ToHtml(Tokenizer):
for c in tText:
if c == "<":
cText.append("<")
- tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat]
+ tFormat = [[p + 3 if p > i else p, f, k] for p, f, k in tFormat]
i += 4
elif c == ">":
cText.append(">")
- tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat]
+ tFormat = [[p + 3 if p > i else p, f, k] for p, f, k in tFormat]
i += 4
else:
cText.append(c)
@@ -278,10 +278,15 @@ class ToHtml(Tokenizer):
tTemp = tText
if pStyle is None:
pStyle = hStyle
- for pos, fmt in reversed(tFormat):
- tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}"
- for pos, fmt, key in reversed(tMarkers):
- tTemp = f"{tTemp[:pos]}[x]{tTemp[pos:]}"
+ for pos, fmt, key in reversed(tFormat):
+ if fmt > self.MRK_BOUNDARY:
+ if key in self._markers:
+ index = self._markers[key][0]
+ if fmt == self.MRK_FOOTNOTE:
+ ref = f"[{index+1}]"
+ tTemp = f"{tTemp[:pos]}{ref}{tTemp[pos:]}"
+ else:
+ tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}"
para.append(stripEscape(tTemp.rstrip()))
elif tType == self.T_SYNOPSIS and self._doSynopsis:
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index ec671322..eb72d70e 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -48,8 +48,7 @@ logger = logging.getLogger(__name__)
ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""}
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
-T_Formats = list[tuple[int, int]]
-T_Markers = list[tuple[int, int, str]]
+T_Formats = list[tuple[int, int, str]]
def stripEscape(text: str) -> str:
@@ -132,7 +131,7 @@ class Tokenizer(ABC):
self._allMarkdown = [] # The result novelWriter markdown of all documents
# Processed Tokens and Meta Data
- self._tokens: list[tuple[int, int, str, T_Formats, T_Markers, int]] = []
+ self._tokens: list[tuple[int, int, str, T_Formats, int]] = []
self._markers: dict[str, tuple[int, list[str]]] = {}
self._counts: dict[str, int] = {}
self._outline: dict[str, str] = {}
@@ -426,7 +425,7 @@ class Tokenizer(ABC):
title = f"{trNotes}: {tItem.itemName}"
self._tokens = []
self._tokens.append((
- self.T_TITLE, 1, title, [], [], textAlign
+ self.T_TITLE, 1, title, [], textAlign
))
if self._keepMarkdown:
self._allMarkdown.append(f"#! {title}\n\n")
@@ -490,7 +489,7 @@ class Tokenizer(ABC):
# Check for blank lines
if len(sLine) == 0:
self._tokens.append((
- self.T_EMPTY, nHead, "", [], [], self.A_NONE
+ self.T_EMPTY, nHead, "", [], self.A_NONE
))
if self._keepMarkdown:
tmpMarkdown.append("\n")
@@ -519,7 +518,7 @@ class Tokenizer(ABC):
elif sLine == "[vspace]":
self._tokens.append(
- (self.T_SKIP, nHead, "", [], [], sAlign)
+ (self.T_SKIP, nHead, "", [], sAlign)
)
continue
@@ -527,11 +526,11 @@ class Tokenizer(ABC):
nSkip = checkInt(sLine[8:-1], 0)
if nSkip >= 1:
self._tokens.append(
- (self.T_SKIP, nHead, "", [], [], sAlign)
+ (self.T_SKIP, nHead, "", [], sAlign)
)
if nSkip > 1:
self._tokens += (nSkip - 1) * [
- (self.T_SKIP, nHead, "", [], [], self.A_NONE)
+ (self.T_SKIP, nHead, "", [], self.A_NONE)
]
continue
@@ -544,22 +543,26 @@ class Tokenizer(ABC):
if aLine.startswith("%~"):
continue
- cStyle, cMod, cText, _, _ = processComment(aLine)
+ cStyle, cKey, cText, _, _ = processComment(aLine)
if cStyle == nwComment.SYNOPSIS:
self._tokens.append((
- self.T_SYNOPSIS, nHead, cText, [], [], sAlign
+ self.T_SYNOPSIS, nHead, cText, [], sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.SHORT:
self._tokens.append((
- self.T_SHORT, nHead, cText, [], [], sAlign
+ self.T_SHORT, nHead, cText, [], sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
+ elif cStyle == nwComment.FOOTNOTE:
+ if cKey not in self._markers:
+ self._markers[cKey] = (len(self._markers), [])
+ self._markers[cKey][1].append(cText)
else:
self._tokens.append((
- self.T_COMMENT, nHead, cText, [], [], sAlign
+ self.T_COMMENT, nHead, cText, [], sAlign
))
if self._doComments and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -573,7 +576,7 @@ class Tokenizer(ABC):
valid, bits, _ = self._project.index.scanThis(aLine)
if valid and bits and bits[0] not in self._skipKeywords:
self._tokens.append((
- self.T_KEYWORD, nHead, aLine[1:].strip(), [], [], sAlign
+ self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign
))
if self._doKeywords and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -609,7 +612,7 @@ class Tokenizer(ABC):
self._noSep = True
self._tokens.append((
- tType, nHead, tText, [], [], tStyle
+ tType, nHead, tText, [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -644,7 +647,7 @@ class Tokenizer(ABC):
self._noSep = True
self._tokens.append((
- tType, nHead, tText, [], [], tStyle
+ tType, nHead, tText, [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -685,7 +688,7 @@ class Tokenizer(ABC):
self._noSep = False
self._tokens.append((
- tType, nHead, tText, [], [], tStyle
+ tType, nHead, tText, [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -715,7 +718,7 @@ class Tokenizer(ABC):
tStyle = self.A_CENTRE
self._tokens.append((
- tType, nHead, tText, [], [], tStyle
+ tType, nHead, tText, [], tStyle
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -761,9 +764,9 @@ class Tokenizer(ABC):
sAlign |= self.A_IND_R
# Process formats
- tLine, fmtPos, insMrk = self._extractFormats(aLine)
+ tLine, fmtPos = self._extractFormats(aLine)
self._tokens.append((
- self.T_TEXT, nHead, tLine, fmtPos, insMrk, sAlign
+ self.T_TEXT, nHead, tLine, fmtPos, sAlign
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -774,15 +777,15 @@ class Tokenizer(ABC):
# Make sure the token array doesn't start with a page break
# on the very first page, adding a blank first page.
- if self._tokens[0][5] & self.A_PBB:
+ if self._tokens[0][4] & self.A_PBB:
token = self._tokens[0]
self._tokens[0] = (
- token[0], token[1], token[2], token[3], token[4], token[5] & ~self.A_PBB
+ token[0], token[1], token[2], token[3], token[4] & ~self.A_PBB
)
# Always add an empty line at the end of the file
self._tokens.append((
- self.T_EMPTY, nHead, "", [], [], self.A_NONE
+ self.T_EMPTY, nHead, "", [], self.A_NONE
))
if self._keepMarkdown:
tmpMarkdown.append("\n")
@@ -792,8 +795,8 @@ class Tokenizer(ABC):
# ===========
# Some items need a second pass
- pToken = (self.T_EMPTY, 0, "", [], [], self.A_NONE)
- nToken = (self.T_EMPTY, 0, "", [], [], self.A_NONE)
+ pToken = (self.T_EMPTY, 0, "", [], self.A_NONE)
+ nToken = (self.T_EMPTY, 0, "", [], self.A_NONE)
tCount = len(self._tokens)
for n, token in enumerate(self._tokens):
@@ -803,22 +806,24 @@ class Tokenizer(ABC):
nToken = self._tokens[n+1]
if token[0] == self.T_KEYWORD:
- aStyle = token[5]
+ aStyle = token[4]
if pToken[0] == self.T_KEYWORD:
aStyle |= self.A_Z_TOPMRG
if nToken[0] == self.T_KEYWORD:
aStyle |= self.A_Z_BTMMRG
self._tokens[n] = (
- token[0], token[1], token[2], token[3], token[4], aStyle
+ token[0], token[1], token[2], token[3], aStyle
)
+ print(self._markers)
+
return
def buildOutline(self) -> None:
"""Build an outline of the text up to level 3 headings."""
tHandle = self._handle or ""
isNovel = self._isNovel
- for tType, nHead, tText, _, _, _ in self._tokens:
+ for tType, nHead, tText, _, _ in self._tokens:
if tType == self.T_TITLE:
prefix = "TT"
elif tType == self.T_HEAD1:
@@ -854,7 +859,7 @@ class Tokenizer(ABC):
titleWordChars = self._counts.get("titleWordChars", 0)
para = []
- for tType, _, tText, _, _, _ in self._tokens:
+ for tType, _, tText, _, _ in self._tokens:
tText = tText.replace(nwUnicode.U_ENDASH, " ")
tText = tText.replace(nwUnicode.U_EMDASH, " ")
@@ -974,7 +979,7 @@ class Tokenizer(ABC):
# Internal Functions
##
- def _extractFormats(self, text: str) -> tuple[str, T_Formats, T_Markers]:
+ def _extractFormats(self, text: str) -> tuple[str, T_Formats]:
"""Extract format markers from a text paragraph."""
temp: list[tuple[int, int, int, str]] = []
@@ -1013,18 +1018,13 @@ class Tokenizer(ABC):
# Post-process text and format markers
result = text
formats = []
- markers = []
for pos, n, fmt, key in reversed(sorted(temp, key=lambda x: x[0])):
if fmt > 0:
result = result[:pos] + result[pos+n:]
- formats = [(p-n, f) for p, f in formats]
- markers = [(p-n, f, k) for p, f, k in markers]
- if fmt > self.MRK_BOUNDARY:
- markers.insert(0, (pos, fmt, key))
- else:
- formats.insert(0, (pos, fmt))
+ formats = [(p-n, f, k) for p, f, k in formats]
+ formats.insert(0, (pos, fmt, key))
- return result, formats, markers
+ return result, formats
# END Class Tokenizer
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 8503af7b..482751c1 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -135,7 +135,7 @@ class ToMarkdown(Tokenizer):
lines = []
lineSep = " \n" if self._preserveBreaks else " "
- for tType, _, tText, tFormat, tMarkers, tStyle in self._tokens:
+ for tType, _, tText, tFormat, tStyle in self._tokens:
if tType == self.T_EMPTY:
if para:
@@ -171,7 +171,7 @@ class ToMarkdown(Tokenizer):
elif tType == self.T_TEXT:
tTemp = tText
- for pos, fmt in reversed(tFormat):
+ for pos, fmt, _ in reversed(tFormat):
tTemp = f"{tTemp[:pos]}{mdTags[fmt]}{tTemp[pos:]}"
para.append(tTemp.rstrip())
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index 4d58c367..61d26fee 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -39,7 +39,7 @@ from novelwriter import __version__
from novelwriter.common import xmlIndent
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels
from novelwriter.core.project import NWProject
-from novelwriter.core.tokenizer import Tokenizer, stripEscape
+from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
logger = logging.getLogger(__name__)
@@ -399,11 +399,11 @@ class ToOdt(Tokenizer):
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
- pFmt = []
+ pFmt: list[T_Formats] = []
pText = []
pStyle = None
pIndent = True
- for tType, _, tText, tFormat, tMarkers, tStyle in self._tokens:
+ for tType, _, tText, tFormat, tStyle in self._tokens:
# Styles
oStyle = ODTParagraphStyle("New")
@@ -444,11 +444,11 @@ class ToOdt(Tokenizer):
if len(pText) > 0 and pStyle is not None:
tTxt = ""
- tFmt = []
+ tFmt: list[tuple[int, int]] = []
for nText, nFmt in zip(pText, pFmt):
tLen = len(tTxt)
tTxt += f"{nText}\n"
- tFmt.extend((p+tLen, fmt) for p, fmt in nFmt)
+ tFmt.extend((p+tLen, fmt) for p, fmt, _ in nFmt)
# Don't indent a paragraph if it has alignment set
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
From 8d24243d4f1dd143bcf75b76bed64d9394bb0c1b Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 18:22:29 +0200
Subject: [PATCH 09/35] Complete footnotes processing in tokenizer
---
novelwriter/core/tokenizer.py | 27 ++++++++++++---------------
novelwriter/gui/doceditor.py | 4 ++--
2 files changed, 14 insertions(+), 17 deletions(-)
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index eb72d70e..8f8161bd 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -49,6 +49,7 @@ ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
T_Formats = list[tuple[int, int, str]]
+T_Comment = tuple[int, list[tuple[str, T_Formats]]]
def stripEscape(text: str) -> str:
@@ -82,10 +83,7 @@ class Tokenizer(ABC):
FMT_SUP_E = 12 # End superscript
FMT_SUB_B = 13 # Begin subscript
FMT_SUB_E = 14 # End subscript
-
- # Inserted Markers
- MRK_BOUNDARY = 20 # Marker boundary
- MRK_FOOTNOTE = 21 # Footnote marker
+ FMT_FNOTE = 15 # Footnote marker
# Block Type
T_EMPTY = 1 # Empty line (new paragraph)
@@ -132,7 +130,7 @@ class Tokenizer(ABC):
# Processed Tokens and Meta Data
self._tokens: list[tuple[int, int, str, T_Formats, int]] = []
- self._markers: dict[str, tuple[int, list[str]]] = {}
+ self._footnotes: dict[str, T_Comment] = {}
self._counts: dict[str, int] = {}
self._outline: dict[str, str] = {}
@@ -213,7 +211,7 @@ class Tokenizer(ABC):
nwShortcode.SUB_O: self.FMT_SUB_B, nwShortcode.SUB_C: self.FMT_SUB_E,
}
self._shortCodeVals = {
- nwShortcode.FOOTNOTE_B: self.MRK_FOOTNOTE,
+ nwShortcode.FOOTNOTE_B: self.FMT_FNOTE,
}
return
@@ -544,25 +542,26 @@ class Tokenizer(ABC):
continue
cStyle, cKey, cText, _, _ = processComment(aLine)
+ tLine, fmtPos = self._extractFormats(cText)
if cStyle == nwComment.SYNOPSIS:
self._tokens.append((
- self.T_SYNOPSIS, nHead, cText, [], sAlign
+ self.T_SYNOPSIS, nHead, tLine, fmtPos, sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.SHORT:
self._tokens.append((
- self.T_SHORT, nHead, cText, [], sAlign
+ self.T_SHORT, nHead, tLine, fmtPos, sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.FOOTNOTE:
- if cKey not in self._markers:
- self._markers[cKey] = (len(self._markers), [])
- self._markers[cKey][1].append(cText)
+ if cKey not in self._footnotes:
+ self._footnotes[cKey] = (len(self._footnotes) + 1, [])
+ self._footnotes[cKey][1].append((tLine, fmtPos))
else:
self._tokens.append((
- self.T_COMMENT, nHead, cText, [], sAlign
+ self.T_COMMENT, nHead, tLine, fmtPos, sAlign
))
if self._doComments and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -815,8 +814,6 @@ class Tokenizer(ABC):
token[0], token[1], token[2], token[3], aStyle
)
- print(self._markers)
-
return
def buildOutline(self) -> None:
@@ -1015,7 +1012,7 @@ class Tokenizer(ABC):
rxMatch.captured(2),
))
- # Post-process text and format markers
+ # Post-process text and format
result = text
formats = []
for pos, n, fmt, key in reversed(sorted(temp, key=lambda x: x[0])):
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index b1e94046..bfe78f38 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -1862,8 +1862,8 @@ class GuiDocEditor(QPlainTextEdit):
cursor = self.textCursor()
block = cursor.block()
text = block.text().rstrip()
- if not text or text.startswith(("@", "#", "%")):
- SHARED.error(self.tr("Footnotes can only be inserted in text."))
+ if not text or text.startswith("@"):
+ logger.error("Invalid footnote location")
return
cursor.beginEditBlock()
From 44192c88f5d9ca4d4918b8ec7c5d6a8e3617a57b Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 18:22:55 +0200
Subject: [PATCH 10/35] Add footnote handling in html and markdown output
---
novelwriter/core/docbuild.py | 4 ++
novelwriter/core/tohtml.py | 128 +++++++++++++++++++++--------------
novelwriter/core/tomd.py | 122 +++++++++++++++++++++------------
novelwriter/gui/docviewer.py | 1 +
4 files changed, 159 insertions(+), 96 deletions(-)
diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 0c2ab461..3c791334 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -191,6 +191,8 @@ class NWBuildDocument:
else:
yield i, False
+ makeObj.appendFootnotes()
+
if not (self._build.getBool("html.preserveTabs") or self._preview):
makeObj.replaceTabs()
@@ -231,6 +233,8 @@ class NWBuildDocument:
else:
yield i, False
+ makeObj.appendFootnotes()
+
self._error = None
self._cache = makeObj
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 176a6aed..d513d4ea 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -33,10 +33,44 @@ from novelwriter import CONFIG
from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwHtmlUnicode
from novelwriter.core.project import NWProject
-from novelwriter.core.tokenizer import Tokenizer, stripEscape
+from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
logger = logging.getLogger(__name__)
+HTML4_TAGS = {
+ Tokenizer.FMT_B_B: "",
+ Tokenizer.FMT_B_E: "",
+ Tokenizer.FMT_I_B: "",
+ Tokenizer.FMT_I_E: "",
+ Tokenizer.FMT_D_B: "",
+ Tokenizer.FMT_D_E: "",
+ Tokenizer.FMT_U_B: "",
+ Tokenizer.FMT_U_E: "",
+ Tokenizer.FMT_M_B: "",
+ Tokenizer.FMT_M_E: "",
+ Tokenizer.FMT_SUP_B: "",
+ Tokenizer.FMT_SUP_E: "",
+ Tokenizer.FMT_SUB_B: "",
+ Tokenizer.FMT_SUB_E: "",
+}
+
+HTML5_TAGS = {
+ Tokenizer.FMT_B_B: "",
+ Tokenizer.FMT_B_E: "",
+ Tokenizer.FMT_I_B: "",
+ Tokenizer.FMT_I_E: "",
+ Tokenizer.FMT_D_B: "
",
+ Tokenizer.FMT_D_E: "",
+ Tokenizer.FMT_U_B: "",
+ Tokenizer.FMT_U_E: "",
+ Tokenizer.FMT_M_B: "",
+ Tokenizer.FMT_M_E: "",
+ Tokenizer.FMT_SUP_B: "",
+ Tokenizer.FMT_SUP_E: "",
+ Tokenizer.FMT_SUB_B: "",
+ Tokenizer.FMT_SUB_E: "",
+}
+
class ToHtml(Tokenizer):
"""Core: HTML Document Writer
@@ -117,38 +151,9 @@ class ToHtml(Tokenizer):
def doConvert(self) -> None:
"""Convert the list of text tokens into an HTML document."""
- if self._genMode == self.M_PREVIEW:
- htmlTags = { # HTML4 + CSS2 (for Qt)
- self.FMT_B_B: "",
- self.FMT_B_E: "",
- self.FMT_I_B: "",
- self.FMT_I_E: "",
- self.FMT_D_B: "",
- self.FMT_D_E: "",
- self.FMT_U_B: "",
- self.FMT_U_E: "",
- self.FMT_M_B: "",
- self.FMT_M_E: "",
- }
- else:
- htmlTags = { # HTML5 (for export)
- self.FMT_B_B: "",
- self.FMT_B_E: "",
- self.FMT_I_B: "",
- self.FMT_I_E: "",
- self.FMT_D_B: "",
- self.FMT_D_E: "",
- self.FMT_U_B: "",
- self.FMT_U_E: "",
- self.FMT_M_B: "",
- self.FMT_M_E: "",
- }
-
- htmlTags[self.FMT_SUP_B] = ""
- htmlTags[self.FMT_SUP_E] = ""
- htmlTags[self.FMT_SUB_B] = ""
- htmlTags[self.FMT_SUB_E] = ""
+ self._result = ""
+ hTags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
if self._isNovel and self._genMode != self.M_PREVIEW:
# For story files, we bump the titles one level up
h1Cl = " class='title'"
@@ -163,12 +168,9 @@ class ToHtml(Tokenizer):
h3 = "h3"
h4 = "h4"
- self._result = ""
-
para = []
- pStyle = None
lines = []
-
+ pStyle = None
tHandle = self._handle
for tType, nHead, tText, tFormat, tStyle in self._tokens:
@@ -181,11 +183,11 @@ class ToHtml(Tokenizer):
for c in tText:
if c == "<":
cText.append("<")
- tFormat = [[p + 3 if p > i else p, f, k] for p, f, k in tFormat]
+ tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
i += 4
elif c == ">":
cText.append(">")
- tFormat = [[p + 3 if p > i else p, f, k] for p, f, k in tFormat]
+ tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
i += 4
else:
cText.append(c)
@@ -275,28 +277,18 @@ class ToHtml(Tokenizer):
lines.append(f"
\n")
elif tType == self.T_TEXT:
- tTemp = tText
if pStyle is None:
pStyle = hStyle
- for pos, fmt, key in reversed(tFormat):
- if fmt > self.MRK_BOUNDARY:
- if key in self._markers:
- index = self._markers[key][0]
- if fmt == self.MRK_FOOTNOTE:
- ref = f"[{index+1}]"
- tTemp = f"{tTemp[:pos]}{ref}{tTemp[pos:]}"
- else:
- tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}"
- para.append(stripEscape(tTemp.rstrip()))
+ para.append(self._formatText(tText, tFormat, hTags).rstrip())
elif tType == self.T_SYNOPSIS and self._doSynopsis:
- lines.append(self._formatSynopsis(tText, True))
+ lines.append(self._formatSynopsis(self._formatText(tText, tFormat, hTags), True))
elif tType == self.T_SHORT and self._doSynopsis:
- lines.append(self._formatSynopsis(tText, False))
+ lines.append(self._formatSynopsis(self._formatText(tText, tFormat, hTags), False))
elif tType == self.T_COMMENT and self._doComments:
- lines.append(self._formatComments(tText))
+ lines.append(self._formatComments(self._formatText(tText, tFormat, hTags)))
elif tType == self.T_KEYWORD and self._doKeywords:
tag, text = self._formatKeywords(tText)
@@ -309,6 +301,25 @@ class ToHtml(Tokenizer):
return
+ def appendFootnotes(self) -> None:
+ """Append the footnotes in the buffer."""
+ tags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
+ footnotes = self._localLookup("Footnotes")
+
+ lines = []
+ lines.append(f"{footnotes}
\n")
+ lines.append("\n")
+ for index, content in self._footnotes.values():
+ text = "
".join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"
\n")
+ lines.append("\n")
+
+ result = "".join(lines)
+ self._result += result
+ self._fullHTML.append(result)
+
+ return
+
def saveHtml5(self, path: str | Path) -> None:
"""Save the data to an HTML file."""
with open(path, mode="w", encoding="utf-8") as fObj:
@@ -460,6 +471,19 @@ class ToHtml(Tokenizer):
# Internal Functions
##
+ def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str:
+ """Apply formatting tags to text."""
+ temp = text
+ for pos, fmt, data in reversed(tFmt):
+ html = ""
+ if fmt == self.FMT_FNOTE:
+ index = self._footnotes.get(data, (0, ""))[0] or "ERR"
+ html = f"[{index}]"
+ else:
+ html = tags.get(fmt, "ERR")
+ temp = f"{temp[:pos]}{html}{temp[pos:]}"
+ return stripEscape(temp)
+
def _formatSynopsis(self, text: str, synopsis: bool) -> str:
"""Apply HTML formatting to synopsis."""
if synopsis:
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 482751c1..dd32b851 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -29,11 +29,48 @@ from pathlib import Path
from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode
from novelwriter.core.project import NWProject
-from novelwriter.core.tokenizer import Tokenizer
+from novelwriter.core.tokenizer import T_Formats, Tokenizer
logger = logging.getLogger(__name__)
+# Standard Markdown
+STD_MD = {
+ Tokenizer.FMT_B_B: "**",
+ Tokenizer.FMT_B_E: "**",
+ Tokenizer.FMT_I_B: "_",
+ Tokenizer.FMT_I_E: "_",
+ Tokenizer.FMT_D_B: "",
+ Tokenizer.FMT_D_E: "",
+ Tokenizer.FMT_U_B: "",
+ Tokenizer.FMT_U_E: "",
+ Tokenizer.FMT_M_B: "",
+ Tokenizer.FMT_M_E: "",
+ Tokenizer.FMT_SUP_B: "",
+ Tokenizer.FMT_SUP_E: "",
+ Tokenizer.FMT_SUB_B: "",
+ Tokenizer.FMT_SUB_E: "",
+}
+
+# Extended Markdown
+EXT_MD = {
+ Tokenizer.FMT_B_B: "**",
+ Tokenizer.FMT_B_E: "**",
+ Tokenizer.FMT_I_B: "_",
+ Tokenizer.FMT_I_E: "_",
+ Tokenizer.FMT_D_B: "~~",
+ Tokenizer.FMT_D_E: "~~",
+ Tokenizer.FMT_U_B: "",
+ Tokenizer.FMT_U_E: "",
+ Tokenizer.FMT_M_B: "==",
+ Tokenizer.FMT_M_E: "==",
+ Tokenizer.FMT_SUP_B: "^",
+ Tokenizer.FMT_SUP_E: "^",
+ Tokenizer.FMT_SUB_B: "~",
+ Tokenizer.FMT_SUB_E: "~",
+}
+
+
class ToMarkdown(Tokenizer):
"""Core: Markdown Document Writer
@@ -90,47 +127,15 @@ class ToMarkdown(Tokenizer):
def doConvert(self) -> None:
"""Convert the list of text tokens into a Markdown document."""
+ self._result = ""
+
if self._genMode == self.M_STD:
- # Standard Markdown
- mdTags = {
- self.FMT_B_B: "**",
- self.FMT_B_E: "**",
- self.FMT_I_B: "_",
- self.FMT_I_E: "_",
- self.FMT_D_B: "",
- self.FMT_D_E: "",
- self.FMT_U_B: "",
- self.FMT_U_E: "",
- self.FMT_M_B: "",
- self.FMT_M_E: "",
- self.FMT_SUP_B: "",
- self.FMT_SUP_E: "",
- self.FMT_SUB_B: "",
- self.FMT_SUB_E: "",
- }
+ mTags = STD_MD
cSkip = ""
else:
- # Extended Markdown
- mdTags = {
- self.FMT_B_B: "**",
- self.FMT_B_E: "**",
- self.FMT_I_B: "_",
- self.FMT_I_E: "_",
- self.FMT_D_B: "~~",
- self.FMT_D_E: "~~",
- self.FMT_U_B: "",
- self.FMT_U_E: "",
- self.FMT_M_B: "==",
- self.FMT_M_E: "==",
- self.FMT_SUP_B: "^",
- self.FMT_SUP_E: "^",
- self.FMT_SUB_B: "~",
- self.FMT_SUB_E: "~",
- }
+ mTags = EXT_MD
cSkip = nwUnicode.U_MMSP
- self._result = ""
-
para = []
lines = []
lineSep = " \n" if self._preserveBreaks else " "
@@ -170,22 +175,19 @@ class ToMarkdown(Tokenizer):
lines.append(f"{cSkip}\n\n")
elif tType == self.T_TEXT:
- tTemp = tText
- for pos, fmt, _ in reversed(tFormat):
- tTemp = f"{tTemp[:pos]}{mdTags[fmt]}{tTemp[pos:]}"
- para.append(tTemp.rstrip())
+ para.append(self._formatText(tText, tFormat, mTags).rstrip())
elif tType == self.T_SYNOPSIS and self._doSynopsis:
label = self._localLookup("Synopsis")
- lines.append(f"**{label}:** {tText}\n\n")
+ lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
elif tType == self.T_SHORT and self._doSynopsis:
label = self._localLookup("Short Description")
- lines.append(f"**{label}:** {tText}\n\n")
+ lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
elif tType == self.T_COMMENT and self._doComments:
label = self._localLookup("Comment")
- lines.append(f"**{label}:** {tText}\n\n")
+ lines.append(f"**{label}:** {self._formatText(tText, tFormat, mTags)}\n\n")
elif tType == self.T_KEYWORD and self._doKeywords:
lines.append(self._formatKeywords(tText, tStyle))
@@ -195,6 +197,25 @@ class ToMarkdown(Tokenizer):
return
+ def appendFootnotes(self) -> None:
+ """Append the footnotes in the buffer."""
+ tags = STD_MD if self._genMode == self.M_STD else EXT_MD
+ footnotes = self._localLookup("Footnotes")
+
+ lines = []
+ lines.append(f"### {footnotes}\n\n")
+ for index, content in self._footnotes.values():
+ marker = f"{index}. "
+ indent = "\n\n"+" "*len(marker)
+ text = indent.join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"{marker}{text}\n")
+
+ result = "".join(lines)
+ self._result += result
+ self._fullMD.append(result)
+
+ return
+
def saveMarkdown(self, path: str | Path) -> None:
"""Save the data to a plain text file."""
with open(path, mode="w", encoding="utf-8") as outFile:
@@ -214,6 +235,19 @@ class ToMarkdown(Tokenizer):
# Internal Functions
##
+ def _formatText(self, text: str, tFmt: T_Formats, tags: dict[int, str]) -> str:
+ """Apply formatting tags to text."""
+ temp = text
+ for pos, fmt, data in reversed(tFmt):
+ md = ""
+ if fmt == self.FMT_FNOTE:
+ index = self._footnotes.get(data, (0, ""))[0] or "ERR"
+ md = f"[{index}]"
+ else:
+ md = tags.get(fmt, "")
+ temp = f"{temp[:pos]}{md}{temp[pos:]}"
+ return temp
+
def _formatKeywords(self, text: str, style: int) -> str:
"""Apply Markdown formatting to keywords."""
valid, bits, _ = self._project.index.scanThis("@"+text)
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index bc5caeb5..daa4566a 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -215,6 +215,7 @@ class GuiDocViewer(QTextBrowser):
aDoc.doPreProcessing()
aDoc.tokenizeText()
aDoc.doConvert()
+ aDoc.appendFootnotes()
except Exception:
logger.error("Failed to generate preview for document with handle '%s'", tHandle)
logException()
From 92888338f41d5d27d9bfb916a75fed7d02482067 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 18:25:13 +0200
Subject: [PATCH 11/35] Add footnotes to output translation file
---
novelwriter/assets/i18n/project_en_GB.json | 1 +
novelwriter/core/tomd.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/novelwriter/assets/i18n/project_en_GB.json b/novelwriter/assets/i18n/project_en_GB.json
index f8e30410..39d52e6e 100644
--- a/novelwriter/assets/i18n/project_en_GB.json
+++ b/novelwriter/assets/i18n/project_en_GB.json
@@ -1,6 +1,7 @@
{
"Synopsis": "Synopsis",
"Short Description": "Short Description",
+ "Footnotes": "Footnotes",
"Comment": "Comment",
"Notes": "Notes",
"Tag": "Tag",
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index dd32b851..e9e7dc65 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -209,6 +209,7 @@ class ToMarkdown(Tokenizer):
indent = "\n\n"+" "*len(marker)
text = indent.join(self._formatText(t, f, tags) for t, f in content)
lines.append(f"{marker}{text}\n")
+ lines.append("\n")
result = "".join(lines)
self._result += result
From c65dee1df9006f2693514d98c15df5bac55481c3 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 18:30:07 +0200
Subject: [PATCH 12/35] Skip footnote section if there are none
---
novelwriter/core/tohtml.py | 25 +++++++++++++------------
novelwriter/core/tomd.py | 27 ++++++++++++++-------------
2 files changed, 27 insertions(+), 25 deletions(-)
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index d513d4ea..07621512 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -303,20 +303,21 @@ class ToHtml(Tokenizer):
def appendFootnotes(self) -> None:
"""Append the footnotes in the buffer."""
- tags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
- footnotes = self._localLookup("Footnotes")
+ if self._footnotes:
+ tags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
+ footnotes = self._localLookup("Footnotes")
- lines = []
- lines.append(f"{footnotes}
\n")
- lines.append("\n")
- for index, content in self._footnotes.values():
- text = "".join(self._formatText(t, f, tags) for t, f in content)
- lines.append(f"
\n")
- lines.append("
\n")
+ lines = []
+ lines.append(f"{footnotes}
\n")
+ lines.append("\n")
+ for index, content in self._footnotes.values():
+ text = "".join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"
\n")
+ lines.append("
\n")
- result = "".join(lines)
- self._result += result
- self._fullHTML.append(result)
+ result = "".join(lines)
+ self._result += result
+ self._fullHTML.append(result)
return
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index e9e7dc65..567953ab 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -199,21 +199,22 @@ class ToMarkdown(Tokenizer):
def appendFootnotes(self) -> None:
"""Append the footnotes in the buffer."""
- tags = STD_MD if self._genMode == self.M_STD else EXT_MD
- footnotes = self._localLookup("Footnotes")
+ if self._footnotes:
+ tags = STD_MD if self._genMode == self.M_STD else EXT_MD
+ footnotes = self._localLookup("Footnotes")
- lines = []
- lines.append(f"### {footnotes}\n\n")
- for index, content in self._footnotes.values():
- marker = f"{index}. "
- indent = "\n\n"+" "*len(marker)
- text = indent.join(self._formatText(t, f, tags) for t, f in content)
- lines.append(f"{marker}{text}\n")
- lines.append("\n")
+ lines = []
+ lines.append(f"### {footnotes}\n\n")
+ for index, content in self._footnotes.values():
+ marker = f"{index}. "
+ indent = "\n\n"+" "*len(marker)
+ text = indent.join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"{marker}{text}\n")
+ lines.append("\n")
- result = "".join(lines)
- self._result += result
- self._fullMD.append(result)
+ result = "".join(lines)
+ self._result += result
+ self._fullMD.append(result)
return
From 0729dd798bfa71cdb12fca043aa9ec7ce1303619 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 15 Apr 2024 18:49:49 +0200
Subject: [PATCH 13/35] Add formatted comments for ODT
---
novelwriter/core/toodt.py | 40 +++++++++++++++++++++------------------
1 file changed, 22 insertions(+), 18 deletions(-)
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index 61d26fee..919bec21 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -444,11 +444,11 @@ class ToOdt(Tokenizer):
if len(pText) > 0 and pStyle is not None:
tTxt = ""
- tFmt: list[tuple[int, int]] = []
+ tFmt: T_Formats = []
for nText, nFmt in zip(pText, pFmt):
tLen = len(tTxt)
tTxt += f"{nText}\n"
- tFmt.extend((p+tLen, fmt) for p, fmt, _ in nFmt)
+ tFmt.extend((p+tLen, fmt, key) for p, fmt, key in nFmt)
# Don't indent a paragraph if it has alignment set
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
@@ -495,20 +495,20 @@ class ToOdt(Tokenizer):
pFmt.append(tFormat)
elif tType == self.T_SYNOPSIS and self._doSynopsis:
- tTemp, fTemp = self._formatSynopsis(tText, True)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
+ tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
+ self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_SHORT and self._doSynopsis:
- tTemp, fTemp = self._formatSynopsis(tText, False)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
+ tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
+ self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_COMMENT and self._doComments:
- tTemp, fTemp = self._formatComments(tText)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
+ tTemp, tFmt = self._formatComments(tText, tFormat)
+ self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_KEYWORD and self._doKeywords:
- tTemp, fTemp = self._formatKeywords(tText)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
+ tTemp, tFmt = self._formatKeywords(tText)
+ self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
return
@@ -569,28 +569,32 @@ class ToOdt(Tokenizer):
# Internal Functions
##
- def _formatSynopsis(self, text: str, synopsis: bool) -> tuple[str, list[tuple[int, int]]]:
+ def _formatSynopsis(self, text: str, fmt: T_Formats, synopsis: bool) -> tuple[str, T_Formats]:
"""Apply formatting to synopsis lines."""
name = self._localLookup("Synopsis" if synopsis else "Short Description")
+ shift = len(name) + 2
rTxt = f"{name}: {text}"
- rFmt = [(0, self.FMT_B_B), (len(name) + 1, self.FMT_B_E)]
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")]
+ rFmt.extend((p + shift, f, d) for p, f, d in fmt)
return rTxt, rFmt
- def _formatComments(self, text: str) -> tuple[str, list[tuple[int, int]]]:
+ def _formatComments(self, text: str, fmt: T_Formats) -> tuple[str, T_Formats]:
"""Apply formatting to comments."""
name = self._localLookup("Comment")
+ shift = len(name) + 2
rTxt = f"{name}: {text}"
- rFmt = [(0, self.FMT_B_B), (len(name) + 1, self.FMT_B_E)]
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")]
+ rFmt.extend((p + shift, f, d) for p, f, d in fmt)
return rTxt, rFmt
- def _formatKeywords(self, text: str) -> tuple[str, list[tuple[int, int]]]:
+ def _formatKeywords(self, text: str) -> tuple[str, T_Formats]:
"""Apply formatting to keywords."""
valid, bits, _ = self._project.index.scanThis("@"+text)
if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
return "", []
rTxt = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: "
- rFmt = [(0, self.FMT_B_B), (len(rTxt) - 1, self.FMT_B_E)]
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(rTxt) - 1, self.FMT_B_E, "")]
if len(bits) > 1:
if bits[0] == nwKeyWords.TAG_KEY:
rTxt += bits[1]
@@ -601,7 +605,7 @@ class ToOdt(Tokenizer):
def _addTextPar(
self, styleName: str, oStyle: ODTParagraphStyle, tText: str,
- tFmt: Sequence[tuple[int, int]] = [], isHead: bool = False, oLevel: str | None = None
+ tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None
) -> None:
"""Add a text paragraph to the text XML element."""
tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)}
@@ -627,7 +631,7 @@ class ToOdt(Tokenizer):
xFmt = 0x00
tFrag = ""
fLast = 0
- for fPos, fFmt in tFmt:
+ for fPos, fFmt, _ in tFmt:
# Add the text up to the current fragment
if tFrag := tText[fLast:fPos]:
From 26ac68e98e93b3604c7bd12d89fddafec820ebc5 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 16 Apr 2024 00:37:26 +0200
Subject: [PATCH 14/35] Block footnotes in footnotes, and only output
referenced footnotes
---
novelwriter/core/tohtml.py | 11 ++++++++---
novelwriter/core/tokenizer.py | 25 ++++++++++++++++---------
novelwriter/core/tomd.py | 15 ++++++++++-----
3 files changed, 34 insertions(+), 17 deletions(-)
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 07621512..604799be 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -52,6 +52,7 @@ HTML4_TAGS = {
Tokenizer.FMT_SUP_E: "",
Tokenizer.FMT_SUB_B: "",
Tokenizer.FMT_SUB_E: "",
+ Tokenizer.FMT_STRIP: "",
}
HTML5_TAGS = {
@@ -69,6 +70,7 @@ HTML5_TAGS = {
Tokenizer.FMT_SUP_E: "",
Tokenizer.FMT_SUB_B: "",
Tokenizer.FMT_SUB_E: "",
+ Tokenizer.FMT_STRIP: "",
}
@@ -92,6 +94,7 @@ class ToHtml(Tokenizer):
# Internals
self._trMap = {}
+ self._usedNotes = set()
self.setReplaceUnicode(False)
return
@@ -310,9 +313,10 @@ class ToHtml(Tokenizer):
lines = []
lines.append(f"{footnotes}
\n")
lines.append("\n")
- for index, content in self._footnotes.values():
- text = "".join(self._formatText(t, f, tags) for t, f in content)
- lines.append(f"
\n")
+ for key, (index, content) in self._footnotes.items():
+ if key in self._usedNotes:
+ text = "".join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"
\n")
lines.append("
\n")
result = "".join(lines)
@@ -478,6 +482,7 @@ class ToHtml(Tokenizer):
for pos, fmt, data in reversed(tFmt):
html = ""
if fmt == self.FMT_FNOTE:
+ self._usedNotes.add(data)
index = self._footnotes.get(data, (0, ""))[0] or "ERR"
html = f"[{index}]"
else:
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index 8f8161bd..db1a5f3f 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -84,6 +84,7 @@ class Tokenizer(ABC):
FMT_SUB_B = 13 # Begin subscript
FMT_SUB_E = 14 # End subscript
FMT_FNOTE = 15 # Footnote marker
+ FMT_STRIP = 16 # Strip the format code
# Block Type
T_EMPTY = 1 # Empty line (new paragraph)
@@ -140,6 +141,7 @@ class Tokenizer(ABC):
self._textFixed = False # Fixed width text
self._lineHeight = 1.15 # Line height in units of em
self._blockIndent = 4.00 # Block indent in units of em
+ self._textIndent = 1.40 # First line indent in units of em
self._doJustify = False # Justify text
self._doBodyText = True # Include body text
self._doSynopsis = False # Also process synopsis comments
@@ -155,6 +157,7 @@ class Tokenizer(ABC):
self._marginHead4 = (0.584, 0.500)
self._marginText = (0.000, 0.584)
self._marginMeta = (0.000, 0.584)
+ self._marginFoot = (1.417, 0.467)
# Title Formats
self._fmtTitle = nwHeadFmt.TITLE # Formatting for titles
@@ -542,26 +545,29 @@ class Tokenizer(ABC):
continue
cStyle, cKey, cText, _, _ = processComment(aLine)
- tLine, fmtPos = self._extractFormats(cText)
if cStyle == nwComment.SYNOPSIS:
+ tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
- self.T_SYNOPSIS, nHead, tLine, fmtPos, sAlign
+ self.T_SYNOPSIS, nHead, tLine, tFmt, sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.SHORT:
+ tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
- self.T_SHORT, nHead, tLine, fmtPos, sAlign
+ self.T_SHORT, nHead, tLine, tFmt, sAlign
))
if self._doSynopsis and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.FOOTNOTE:
+ tLine, tFmt = self._extractFormats(cText, skip=self.FMT_FNOTE)
if cKey not in self._footnotes:
self._footnotes[cKey] = (len(self._footnotes) + 1, [])
- self._footnotes[cKey][1].append((tLine, fmtPos))
+ self._footnotes[cKey][1].append((tLine, tFmt))
else:
+ tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
- self.T_COMMENT, nHead, tLine, fmtPos, sAlign
+ self.T_COMMENT, nHead, tLine, tFmt, sAlign
))
if self._doComments and self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -763,9 +769,9 @@ class Tokenizer(ABC):
sAlign |= self.A_IND_R
# Process formats
- tLine, fmtPos = self._extractFormats(aLine)
+ tLine, tFmt = self._extractFormats(aLine)
self._tokens.append((
- self.T_TEXT, nHead, tLine, fmtPos, sAlign
+ self.T_TEXT, nHead, tLine, tFmt, sAlign
))
if self._keepMarkdown:
tmpMarkdown.append(f"{aLine}\n")
@@ -976,7 +982,7 @@ class Tokenizer(ABC):
# Internal Functions
##
- def _extractFormats(self, text: str) -> tuple[str, T_Formats]:
+ def _extractFormats(self, text: str, skip: int = 0) -> tuple[str, T_Formats]:
"""Extract format markers from a text paragraph."""
temp: list[tuple[int, int, int, str]] = []
@@ -1005,10 +1011,11 @@ class Tokenizer(ABC):
rxItt = self._rxShortCodeVals.globalMatch(text, 0)
while rxItt.hasNext():
rxMatch = rxItt.next()
+ kind = self._shortCodeVals.get(rxMatch.captured(1).lower(), 0)
temp.append((
rxMatch.capturedStart(0),
rxMatch.capturedLength(0),
- self._shortCodeVals.get(rxMatch.captured(1).lower(), 0),
+ self.FMT_STRIP if kind == skip else kind,
rxMatch.captured(2),
))
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 567953ab..1d5fa318 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -50,6 +50,7 @@ STD_MD = {
Tokenizer.FMT_SUP_E: "",
Tokenizer.FMT_SUB_B: "",
Tokenizer.FMT_SUB_E: "",
+ Tokenizer.FMT_STRIP: "",
}
# Extended Markdown
@@ -68,6 +69,7 @@ EXT_MD = {
Tokenizer.FMT_SUP_E: "^",
Tokenizer.FMT_SUB_B: "~",
Tokenizer.FMT_SUB_E: "~",
+ Tokenizer.FMT_STRIP: "",
}
@@ -87,6 +89,7 @@ class ToMarkdown(Tokenizer):
self._genMode = self.M_STD
self._fullMD: list[str] = []
self._preserveBreaks = True
+ self._usedNotes = set()
return
##
@@ -205,11 +208,12 @@ class ToMarkdown(Tokenizer):
lines = []
lines.append(f"### {footnotes}\n\n")
- for index, content in self._footnotes.values():
- marker = f"{index}. "
- indent = "\n\n"+" "*len(marker)
- text = indent.join(self._formatText(t, f, tags) for t, f in content)
- lines.append(f"{marker}{text}\n")
+ for key, (index, content) in self._footnotes.items():
+ if key in self._usedNotes:
+ marker = f"{index}. "
+ indent = "\n\n"+" "*len(marker)
+ text = indent.join(self._formatText(t, f, tags) for t, f in content)
+ lines.append(f"{marker}{text}\n")
lines.append("\n")
result = "".join(lines)
@@ -243,6 +247,7 @@ class ToMarkdown(Tokenizer):
for pos, fmt, data in reversed(tFmt):
md = ""
if fmt == self.FMT_FNOTE:
+ self._usedNotes.add(data)
index = self._footnotes.get(data, (0, ""))[0] or "ERR"
md = f"[{index}]"
else:
From 17960d286f3495ebe1bd6406c8ef5a77c30ae1f0 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 16 Apr 2024 00:39:48 +0200
Subject: [PATCH 15/35] Add a working ODT footnotes implementation
---
novelwriter/core/toodt.py | 112 ++++++++++++++++++++++++++++++--------
1 file changed, 89 insertions(+), 23 deletions(-)
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index 919bec21..18cc4492 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -29,11 +29,12 @@ from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
+from collections.abc import Sequence
+from copy import deepcopy
+from datetime import datetime
from hashlib import sha256
from pathlib import Path
from zipfile import ZipFile
-from datetime import datetime
-from collections.abc import Sequence
from novelwriter import __version__
from novelwriter.common import xmlIndent
@@ -130,6 +131,10 @@ class ToOdt(Tokenizer):
self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles
self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text styles
+ # Footnotes
+ self._nNote = 0
+ self._etNotes: dict[str, ET.Element] = {} # Generated note elements
+
self._errData = [] # List of errors encountered
# Properties
@@ -151,6 +156,7 @@ class ToOdt(Tokenizer):
self._fSizeHead4 = "14pt"
self._fSizeHead = "14pt"
self._fSizeText = "12pt"
+ self._fSizeFoot = "10pt"
self._fLineHeight = "115%"
self._fBlockIndent = "1.693cm"
self._fTextIndent = "0.499cm"
@@ -177,6 +183,9 @@ class ToOdt(Tokenizer):
self._mBotText = "0.247cm"
self._mBotMeta = "0.106cm"
+ self._mBotFoot = "0.106cm"
+ self._mLeftFoot = "0.600cm"
+
# Document Size and Margins
self._mDocWidth = "21.0cm"
self._mDocHeight = "29.7cm"
@@ -258,6 +267,7 @@ class ToOdt(Tokenizer):
self._fSizeHead4 = f"{round(1.15 * self._textSize):d}pt"
self._fSizeHead = f"{round(1.15 * self._textSize):d}pt"
self._fSizeText = f"{self._textSize:d}pt"
+ self._fSizeFoot = f"{round(0.8*self._textSize):d}pt"
mScale = self._lineHeight/1.15
@@ -279,6 +289,9 @@ class ToOdt(Tokenizer):
self._mBotText = self._emToCm(mScale * self._marginText[1])
self._mBotMeta = self._emToCm(mScale * self._marginMeta[1])
+ self._mLeftFoot = self._emToCm(self._marginFoot[0])
+ self._mBotFoot = self._emToCm(self._marginFoot[1])
+
if self._colourHead:
self._colHead12 = "#2a6099"
self._opaHead12 = "100%"
@@ -289,6 +302,7 @@ class ToOdt(Tokenizer):
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
self._fBlockIndent = self._emToCm(self._blockIndent)
+ self._fTextIndent = self._emToCm(self._textIndent)
self._textAlign = "justify" if self._doJustify else "left"
# Clear Errors
@@ -398,11 +412,13 @@ class ToOdt(Tokenizer):
def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
+ self._preProcessNotes()
pFmt: list[T_Formats] = []
pText = []
pStyle = None
pIndent = True
+ xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens:
# Styles
@@ -453,7 +469,7 @@ class ToOdt(Tokenizer):
# Don't indent a paragraph if it has alignment set
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
self._addTextPar(
- "First_20_line_20_indent" if tIndent else "Text_20_body",
+ xText, "First_20_line_20_indent" if tIndent else "Text_20_body",
pStyle, tTxt.rstrip(), tFmt=tFmt
)
pIndent = True
@@ -463,30 +479,31 @@ class ToOdt(Tokenizer):
pStyle = None
elif tType == self.T_TITLE:
+ # Title must be text:p
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addTextPar("Title", oStyle, tHead, isHead=False) # Title must be text:p
+ self._addTextPar(xText, "Title", oStyle, tHead, isHead=False)
elif tType == self.T_HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addTextPar("Heading_20_1", oStyle, tHead, isHead=True, oLevel="1")
+ self._addTextPar(xText, "Heading_20_1", oStyle, tHead, isHead=True, oLevel="1")
elif tType == self.T_HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addTextPar("Heading_20_2", oStyle, tHead, isHead=True, oLevel="2")
+ self._addTextPar(xText, "Heading_20_2", oStyle, tHead, isHead=True, oLevel="2")
elif tType == self.T_HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addTextPar("Heading_20_3", oStyle, tHead, isHead=True, oLevel="3")
+ self._addTextPar(xText, "Heading_20_3", oStyle, tHead, isHead=True, oLevel="3")
elif tType == self.T_HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addTextPar("Heading_20_4", oStyle, tHead, isHead=True, oLevel="4")
+ self._addTextPar(xText, "Heading_20_4", oStyle, tHead, isHead=True, oLevel="4")
elif tType == self.T_SEP:
- self._addTextPar("Separator", oStyle, tText)
+ self._addTextPar(xText, "Separator", oStyle, tText)
elif tType == self.T_SKIP:
- self._addTextPar("Separator", oStyle, "")
+ self._addTextPar(xText, "Separator", oStyle, "")
elif tType == self.T_TEXT:
if pStyle is None:
@@ -496,19 +513,19 @@ class ToOdt(Tokenizer):
elif tType == self.T_SYNOPSIS and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
+ self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_SHORT and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
+ self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_COMMENT and self._doComments:
tTemp, tFmt = self._formatComments(tText, tFormat)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
+ self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_KEYWORD and self._doKeywords:
tTemp, tFmt = self._formatKeywords(tText)
- self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
+ self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
return
@@ -604,7 +621,7 @@ class ToOdt(Tokenizer):
return rTxt, rFmt
def _addTextPar(
- self, styleName: str, oStyle: ODTParagraphStyle, tText: str,
+ self, xParent: ET.Element, styleName: str, oStyle: ODTParagraphStyle, tText: str,
tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None
) -> None:
"""Add a text paragraph to the text XML element."""
@@ -613,7 +630,7 @@ class ToOdt(Tokenizer):
tAttr[_mkTag("text", "outline-level")] = oLevel
pTag = "h" if isHead else "p"
- xElem = ET.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr)
+ xElem = ET.SubElement(xParent, _mkTag("text", pTag), attrib=tAttr)
# It's important to set the initial text field to empty, otherwise
# xmlIndent will add a line break if the first subelement is a span.
@@ -631,7 +648,7 @@ class ToOdt(Tokenizer):
xFmt = 0x00
tFrag = ""
fLast = 0
- for fPos, fFmt, _ in tFmt:
+ for fPos, fFmt, fData in tFmt:
# Add the text up to the current fragment
if tFrag := tText[fLast:fPos]:
@@ -669,6 +686,8 @@ class ToOdt(Tokenizer):
xFmt |= X_SUB
elif fFmt == self.FMT_SUB_E:
xFmt &= M_SUB
+ elif fFmt == self.FMT_FNOTE:
+ parProc.appendNode(self._etNotes.get(fData))
else:
pErr += 1
@@ -739,6 +758,29 @@ class ToOdt(Tokenizer):
return style.name
+ def _preProcessNotes(self) -> None:
+ """Generate XML elements for footnotes."""
+ fStyle = ODTParagraphStyle("New")
+ sStyle = ODTParagraphStyle("New")
+ sStyle.setTextIndent("0.000cm")
+ sStyle.setMarginLeft(self._mLeftFoot)
+ update = [key for key in self._footnotes.keys() if key not in self._etNotes]
+ for key in update:
+ cStyle = fStyle
+ self._nNote += 1
+ xNote = ET.Element(_mkTag("text", "note"), attrib={
+ _mkTag("text", "id"): f"ftn{self._nNote}",
+ _mkTag("text", "note-class"): "footnote",
+ })
+ xCite = ET.SubElement(xNote, _mkTag("text", "note-citation"))
+ xCite.text = str(self._nNote)
+ xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
+ for text, fmt in self._footnotes[key][1]:
+ self._addTextPar(xBody, "Footnote", cStyle, text, tFmt=fmt)
+ cStyle = sStyle
+ self._etNotes[key] = xNote
+ return
+
def _emToCm(self, value: float) -> str:
"""Converts an em value to centimetres."""
return f"{value*2.54/72*self._textSize:.3f}cm"
@@ -989,6 +1031,18 @@ class ToOdt(Tokenizer):
style.packXML(self._xStyl)
self._mainPara[style.name] = style
+ # Add Footnote Style
+ style = ODTParagraphStyle("Footnote")
+ style.setDisplayName("Footnote")
+ style.setParentStyleName("Standard")
+ style.setClass("extra")
+ style.setMarginLeft(self._mLeftFoot)
+ style.setMarginBottom(self._mBotFoot)
+ style.setTextIndent("-"+self._mLeftFoot)
+ style.setFontSize(self._fSizeFoot)
+ style.packXML(self._xStyl)
+ self._mainPara[style.name] = style
+
return
def _writeHeader(self) -> None:
@@ -1045,7 +1099,7 @@ class ODTParagraphStyle:
VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"]
VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"]
VALID_LEVEL = ["1", "2", "3", "4"]
- VALID_CLASS = ["text", "chapter"]
+ VALID_CLASS = ["text", "chapter", "extra"]
VALID_WEIGHT = ["normal", "inherit", "bold"]
def __init__(self, name: str) -> None:
@@ -1468,7 +1522,6 @@ class XMLParagraph:
if c == " ":
nSpaces += 1
continue
-
elif nSpaces > 0:
self._processSpaces(nSpaces)
nSpaces = 0
@@ -1479,26 +1532,22 @@ class XMLParagraph:
self._xTail.tail = ""
self._nState = X_ROOT_TAIL
self._chrPos += 1
-
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
self._xSing = ET.SubElement(self._xTail, TAG_BR)
self._xSing.tail = ""
self._nState = X_SPAN_SING
self._chrPos += 1
-
elif c == "\t":
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
self._xTail = ET.SubElement(self._xRoot, TAG_TAB)
self._xTail.tail = ""
self._nState = X_ROOT_TAIL
self._chrPos += 1
-
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
self._xSing = ET.SubElement(self._xTail, TAG_TAB)
self._xSing.tail = ""
self._chrPos += 1
self._nState = X_SPAN_SING
-
else:
if self._nState == X_ROOT_TEXT:
self._xRoot.text = (self._xRoot.text or "") + c
@@ -1533,6 +1582,23 @@ class XMLParagraph:
self._nState = X_ROOT_TAIL
return
+ def appendNode(self, xNode: ET.Element | None) -> None:
+ """Append an XML node to the paragraph."""
+ if xNode:
+ # We must make a copy in case the node is reused
+ xCopy = deepcopy(xNode)
+ if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
+ self._xRoot.append(xCopy)
+ self._xTail = xCopy
+ self._xTail.tail = ""
+ self._nState = X_ROOT_TAIL
+ elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
+ self._xTail.append(xCopy)
+ self._xSing = xCopy
+ self._xSing.tail = ""
+ self._nState = X_SPAN_SING
+ return
+
def checkError(self) -> tuple[int, str]:
"""Check that the number of characters written matches the
number of characters received.
From 988d969de77118a8e567f08ee9a1f9dc1dc0ee27 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 16 Apr 2024 21:51:54 +0200
Subject: [PATCH 16/35] Drop the non-compliant print-orientation attribute from
the ODT class
---
novelwriter/core/toodt.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index 18cc4492..e82835e9 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -803,7 +803,6 @@ class ToOdt(Tokenizer):
_mkTag("fo", "margin-bottom"): self._mDocBtm,
_mkTag("fo", "margin-left"): self._mDocLeft,
_mkTag("fo", "margin-right"): self._mDocRight,
- _mkTag("fo", "print-orientation"): "portrait",
})
xHead = ET.SubElement(xPage, _mkTag("style", "header-style"))
From 57f9a0a84427c5b1a2968d7ea74befdce39f40df Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 16 Apr 2024 21:52:37 +0200
Subject: [PATCH 17/35] Refactor and improve highlighter class
---
novelwriter/gui/dochighlight.py | 330 +++++++++++++++++---------------
1 file changed, 178 insertions(+), 152 deletions(-)
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index 1debcd7e..bba81e55 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -58,8 +58,8 @@ BLOCK_TITLE = 4
class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = (
- "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hRules",
- "_hStyles", "_rxRules"
+ "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hStyles",
+ "_txtRules", "_cmnRules",
)
def __init__(self, document: QTextDocument) -> None:
@@ -72,9 +72,9 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._spellCheck = False
self._spellErr = QTextCharFormat()
- self._hRules: list[tuple[str, dict]] = []
self._hStyles: dict[str, QTextCharFormat] = {}
- self._rxRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
+ self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
+ self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
self.initHighlighter()
@@ -92,54 +92,58 @@ class GuiDocHighlighter(QSyntaxHighlighter):
colBreak = QColor(SHARED.theme.colEmph)
colBreak.setAlpha(64)
- self._hRules = []
- self._hStyles = {
- "header1": self._makeFormat(SHARED.theme.colHead, "bold", 1.8),
- "header2": self._makeFormat(SHARED.theme.colHead, "bold", 1.6),
- "header3": self._makeFormat(SHARED.theme.colHead, "bold", 1.4),
- "header4": self._makeFormat(SHARED.theme.colHead, "bold", 1.2),
- "head1h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.8),
- "head2h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.6),
- "head3h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.4),
- "head4h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.2),
- "bold": self._makeFormat(colEmph, "bold"),
- "italic": self._makeFormat(colEmph, "italic"),
- "strike": self._makeFormat(SHARED.theme.colHidden, "strike"),
- "mspaces": self._makeFormat(SHARED.theme.colError, "errline"),
- "nobreak": self._makeFormat(colBreak, "background"),
- "dialogue1": self._makeFormat(SHARED.theme.colDialN),
- "dialogue2": self._makeFormat(SHARED.theme.colDialD),
- "dialogue3": self._makeFormat(SHARED.theme.colDialS),
- "replace": self._makeFormat(SHARED.theme.colRepTag),
- "hidden": self._makeFormat(SHARED.theme.colHidden),
- "code": self._makeFormat(SHARED.theme.colCode),
- "keyword": self._makeFormat(SHARED.theme.colKey),
- "modifier": self._makeFormat(SHARED.theme.colMod),
- "value": self._makeFormat(SHARED.theme.colVal),
- "optional": self._makeFormat(SHARED.theme.colOpt),
- "codevalue": self._makeFormat(SHARED.theme.colVal),
- "codeinval": self._makeFormat(None, "errline"),
- }
+ # Create Character Formats
+ self._addCharFormat("header1", SHARED.theme.colHead, "bold", 1.8)
+ self._addCharFormat("header2", SHARED.theme.colHead, "bold", 1.6)
+ self._addCharFormat("header3", SHARED.theme.colHead, "bold", 1.4)
+ self._addCharFormat("header4", SHARED.theme.colHead, "bold", 1.2)
+ self._addCharFormat("head1h", SHARED.theme.colHeadH, "bold", 1.8)
+ self._addCharFormat("head2h", SHARED.theme.colHeadH, "bold", 1.6)
+ self._addCharFormat("head3h", SHARED.theme.colHeadH, "bold", 1.4)
+ self._addCharFormat("head4h", SHARED.theme.colHeadH, "bold", 1.2)
+ self._addCharFormat("bold", colEmph, "bold")
+ self._addCharFormat("italic", colEmph, "italic")
+ self._addCharFormat("strike", SHARED.theme.colHidden, "strike")
+ self._addCharFormat("mspaces", SHARED.theme.colError, "errline")
+ self._addCharFormat("nobreak", colBreak, "background")
+ self._addCharFormat("dialog1", SHARED.theme.colDialN)
+ self._addCharFormat("dialog2", SHARED.theme.colDialD)
+ self._addCharFormat("dialog3", SHARED.theme.colDialS)
+ self._addCharFormat("replace", SHARED.theme.colRepTag)
+ self._addCharFormat("hidden", SHARED.theme.colHidden)
+ self._addCharFormat("markup", SHARED.theme.colHidden)
+ self._addCharFormat("code", SHARED.theme.colCode)
+ self._addCharFormat("keyword", SHARED.theme.colKey)
+ self._addCharFormat("modifier", SHARED.theme.colMod)
+ self._addCharFormat("value", SHARED.theme.colVal)
+ self._addCharFormat("optional", SHARED.theme.colOpt)
+ self._addCharFormat("invalid", None, "errline")
# Cache Spell Error Format
self._spellErr = QTextCharFormat()
self._spellErr.setUnderlineColor(SHARED.theme.colSpell)
self._spellErr.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
+ QtUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption
+
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
- self._hRules.append((
- r"[ ]{2,}|[ ]*$", {
- 0: self._hStyles["mspaces"],
- }
- ))
+ rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["mspaces"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Non-Breaking Spaces
- self._hRules.append((
- f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", {
- 0: self._hStyles["nobreak"],
- }
- ))
+ rxRule = QRegularExpression(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["nobreak"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Quoted Strings
if CONFIG.highlightQuotes:
@@ -149,88 +153,100 @@ class GuiDocHighlighter(QSyntaxHighlighter):
fmtSngC = CONFIG.fmtSQuoteClose
# Straight Quotes
- if not (fmtDblO == fmtDblC == "\""):
- self._hRules.append((
- "(\\B\")(.*?)(\"\\B)", {
- 0: self._hStyles["dialogue1"],
- }
- ))
+ rxRule = QRegularExpression(r'(\B")(.*?)("\B)')
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["dialog1"],
+ }
+ self._txtRules.append((rxRule, hlRule))
# Double Quotes
dblEnd = "|$" if CONFIG.allowOpenDQuote else ""
- self._hRules.append((
- f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", {
- 0: self._hStyles["dialogue2"],
- }
- ))
+ rxRule = QRegularExpression(f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["dialog2"],
+ }
+ self._txtRules.append((rxRule, hlRule))
# Single Quotes
sngEnd = "|$" if CONFIG.allowOpenSQuote else ""
- self._hRules.append((
- f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", {
- 0: self._hStyles["dialogue3"],
- }
- ))
+ rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["dialog3"],
+ }
+ self._txtRules.append((rxRule, hlRule))
- # Markdown Syntax
- self._hRules.append((
- nwRegEx.FMT_EI, {
- 1: self._hStyles["hidden"],
- 2: self._hStyles["italic"],
- 3: self._hStyles["hidden"],
- }
- ))
- self._hRules.append((
- nwRegEx.FMT_EB, {
- 1: self._hStyles["hidden"],
- 2: self._hStyles["bold"],
- 3: self._hStyles["hidden"],
- }
- ))
- self._hRules.append((
- nwRegEx.FMT_ST, {
- 1: self._hStyles["hidden"],
- 2: self._hStyles["strike"],
- 3: self._hStyles["hidden"],
- }
- ))
+ # Markdown Italic
+ rxRule = QRegularExpression(nwRegEx.FMT_EI)
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["markup"],
+ 2: self._hStyles["italic"],
+ 3: self._hStyles["markup"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
+
+ # Markdown Bold
+ rxRule = QRegularExpression(nwRegEx.FMT_EB)
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["markup"],
+ 2: self._hStyles["bold"],
+ 3: self._hStyles["markup"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
+
+ # Markdown Strikethrough
+ rxRule = QRegularExpression(nwRegEx.FMT_ST)
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["markup"],
+ 2: self._hStyles["strike"],
+ 3: self._hStyles["markup"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Shortcodes
- self._hRules.append((
- nwRegEx.FMT_SC, {
- 1: self._hStyles["code"],
- }
- ))
+ rxRule = QRegularExpression(nwRegEx.FMT_SC)
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["code"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Shortcodes w/Value
- self._hRules.append((
- nwRegEx.FMT_SV, {
- 1: self._hStyles["code"],
- 2: self._hStyles["codevalue"],
- 3: self._hStyles["code"],
- }
- ))
+ rxRule = QRegularExpression(nwRegEx.FMT_SV)
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["code"],
+ 2: self._hStyles["value"],
+ 3: self._hStyles["code"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Alignment Tags
- self._hRules.append((
- r"(^>{1,2}|<{1,2}$)", {
- 1: self._hStyles["hidden"],
- }
- ))
+ rxRule = QRegularExpression(r"(^>{1,2}|<{1,2}$)")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 1: self._hStyles["markup"],
+ }
+ self._txtRules.append((rxRule, hlRule))
# Auto-Replace Tags
- self._hRules.append((
- r"<(\S+?)>", {
- 0: self._hStyles["replace"],
- }
- ))
-
- # Build a QRegularExpression for each highlight pattern
- self._rxRules = []
- for regEx, regRules in self._hRules:
- hReg = QRegularExpression(regEx)
- hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
- self._rxRules.append((hReg, regRules))
+ rxRule = QRegularExpression(r"<(\S+?)>")
+ rxRule.setPatternOptions(QtUnicode)
+ hlRule = {
+ 0: self._hStyles["replace"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
return
@@ -285,6 +301,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return
xOff = 0
+ hRules = None
if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index
@@ -303,7 +320,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
yPos = xPos + len(bit) - len(two)
self.setFormat(yPos, len(two), self._hStyles["optional"])
elif not self._isInactive:
- self.setFormat(xPos, xLen, self._hStyles["codeinval"])
+ self.setFormat(xPos, xLen, self._hStyles["invalid"])
# We never want to run the spell checker on keyword/values,
# so we force a return here
@@ -346,9 +363,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
cLen = len(text) - cPos
xOff = cPos
if cMod == "ERR":
- self.setFormat(0, cPos, self._hStyles["codeinval"])
+ self.setFormat(0, cPos, self._hStyles["invalid"])
elif cStyle == nwComment.PLAIN:
self.setFormat(0, cLen, self._hStyles["hidden"])
+ elif cStyle == nwComment.IGNORE:
+ self.setFormat(0, cLen, self._hStyles["strike"])
+ return # No more processing for these
elif cMod:
self.setFormat(0, cDot, self._hStyles["modifier"])
self.setFormat(cDot, cPos - cDot, self._hStyles["optional"])
@@ -357,36 +377,39 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self.setFormat(0, cPos, self._hStyles["modifier"])
self.setFormat(cPos, cLen, self._hStyles["hidden"])
+ hRules = self._cmnRules
+
+ elif text.startswith("["): # Special Command
+ sText = text.rstrip().lower()
+ if sText in ("[newpage]", "[new page]", "[vspace]"):
+ self.setFormat(0, len(text), self._hStyles["code"])
+ return
+ elif sText.startswith("[vspace:") and sText.endswith("]"):
+ tLen = len(sText)
+ tVal = checkInt(sText[8:-1], 0)
+ cVal = "value" if tVal > 0 else "invalid"
+ self.setFormat(0, 8, self._hStyles["code"])
+ self.setFormat(8, tLen-9, self._hStyles[cVal])
+ self.setFormat(tLen-1, tLen, self._hStyles["code"])
+ return
+
else: # Text Paragraph
-
- if text.startswith("["): # Special Command
- sText = text.rstrip().lower()
- if sText in ("[newpage]", "[new page]", "[vspace]"):
- self.setFormat(0, len(text), self._hStyles["code"])
- return
- elif sText.startswith("[vspace:") and sText.endswith("]"):
- tLen = len(sText)
- tVal = checkInt(sText[8:-1], 0)
- cVal = "codevalue" if tVal > 0 else "codeinval"
- self.setFormat(0, 8, self._hStyles["code"])
- self.setFormat(8, tLen-9, self._hStyles[cVal])
- self.setFormat(tLen-1, tLen, self._hStyles["code"])
- return
-
- # Regular Text
self.setCurrentBlockState(BLOCK_TEXT)
- for rX, xFmt in self._rxRules:
- rxItt = rX.globalMatch(text, 0)
+ hRules = self._txtRules
+
+ if hRules:
+ for rX, hRule in hRules:
+ rxItt = rX.globalMatch(text, xOff)
while rxItt.hasNext():
rxMatch = rxItt.next()
- for xM in xFmt:
+ for xM, hFmt in hRule.items():
xPos = rxMatch.capturedStart(xM)
- xLen = rxMatch.capturedLength(xM)
- for x in range(xPos, xPos+xLen):
- spFmt = self.format(x)
- if spFmt != self._hStyles["hidden"]:
- spFmt.merge(xFmt[xM])
- self.setFormat(x, 1, spFmt)
+ xEnd = rxMatch.capturedEnd(xM)
+ for x in range(xPos, xEnd):
+ cFmt = self.format(x)
+ if cFmt.fontStyleName() != "markup":
+ cFmt.merge(hFmt)
+ self.setFormat(x, 1, cFmt)
data = self.currentBlockUserData()
if not isinstance(data, TextBlockData):
@@ -396,9 +419,9 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if self._spellCheck:
for xPos, xLen in data.spellCheck(text, xOff):
for x in range(xPos, xPos+xLen):
- spFmt = self.format(x)
- spFmt.merge(self._spellErr)
- self.setFormat(x, 1, spFmt)
+ cFmt = self.format(x)
+ cFmt.merge(self._spellErr)
+ self.setFormat(x, 1, cFmt)
return
@@ -406,17 +429,18 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Internal Functions
##
- def _makeFormat(self, color: QColor | None = None, style: str | None = None,
- size: float | None = None) -> QTextCharFormat:
- """Generate a valid character format to be applied to the text
- that is to be highlighted.
- """
+ def _addCharFormat(
+ self, name: str, color: QColor | None = None,
+ style: str | None = None, size: float | None = None
+ ) -> None:
+ """Generate a highlighter character format."""
charFormat = QTextCharFormat()
+ charFormat.setFontStyleName(name)
- if color is not None:
+ if color:
charFormat.setForeground(color)
- if style is not None:
+ if style:
styles = style.split(",")
if "bold" in styles:
charFormat.setFontWeight(QFont.Weight.Bold)
@@ -430,10 +454,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if "background" in styles and color is not None:
charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern))
- if size is not None:
- charFormat.setFontPointSize(int(round(size*CONFIG.textSize)))
+ if size:
+ charFormat.setFontPointSize(round(size*CONFIG.textSize))
- return charFormat
+ self._hStyles[name] = charFormat
+
+ return
# END Class GuiDocHighlighter
@@ -459,7 +485,7 @@ class TextBlockData(QTextBlockUserData):
if "[" in text:
# Strip shortcodes
for rX in [SPELLSC, SPELLSV]:
- rxItt = rX.globalMatch(text, 0)
+ rxItt = rX.globalMatch(text, offset)
while rxItt.hasNext():
rxMatch = rxItt.next()
xPos = rxMatch.capturedStart(0)
@@ -468,13 +494,13 @@ class TextBlockData(QTextBlockUserData):
text = text[:xPos] + " "*xLen + text[xEnd:]
self._spellErrors = []
- rxSpell = SPELLRX.globalMatch(text[offset:].replace("_", " "), 0)
+ rxSpell = SPELLRX.globalMatch(text.replace("_", " "), offset)
while rxSpell.hasNext():
rxMatch = rxSpell.next()
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper():
self._spellErrors.append(
- (rxMatch.capturedStart(0) + offset, rxMatch.capturedLength(0))
+ (rxMatch.capturedStart(0), rxMatch.capturedLength(0))
)
return self._spellErrors
From bdce0e12c7b0d415dd125197e2ac3ab63a9257e3 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 16 Apr 2024 21:54:31 +0200
Subject: [PATCH 18/35] Simplify footnote handling in writer classes
---
novelwriter/core/index.py | 2 +-
novelwriter/core/tohtml.py | 17 +++++++++-------
novelwriter/core/tokenizer.py | 6 ++----
novelwriter/core/tomd.py | 18 +++++++++--------
novelwriter/core/toodt.py | 37 +++++++++++++----------------------
novelwriter/enum.py | 13 ++++++------
6 files changed, 44 insertions(+), 49 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index c46bff04..13f75407 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -1494,4 +1494,4 @@ def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
term = "ERR" if term and clean not in TERMS else term.strip()
return CLASSIFIERS[clean], term, content.strip(), text.find(".") + 1, text.find(":") + 1
- return nwComment.PLAIN, "", check, 0, 0
+ return nwComment.IGNORE if text.startswith("%~") else nwComment.PLAIN, "", check, 0, 0
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 604799be..1384c96e 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -94,7 +94,7 @@ class ToHtml(Tokenizer):
# Internals
self._trMap = {}
- self._usedNotes = set()
+ self._usedNotes: dict[str, int] = {}
self.setReplaceUnicode(False)
return
@@ -313,9 +313,9 @@ class ToHtml(Tokenizer):
lines = []
lines.append(f"{footnotes}
\n")
lines.append("\n")
- for key, (index, content) in self._footnotes.items():
- if key in self._usedNotes:
- text = "".join(self._formatText(t, f, tags) for t, f in content)
+ for key, index in self._usedNotes.items():
+ if content := self._footnotes.get(key):
+ text = self._formatText(*content, tags)
lines.append(f"
\n")
lines.append("
\n")
@@ -482,9 +482,12 @@ class ToHtml(Tokenizer):
for pos, fmt, data in reversed(tFmt):
html = ""
if fmt == self.FMT_FNOTE:
- self._usedNotes.add(data)
- index = self._footnotes.get(data, (0, ""))[0] or "ERR"
- html = f"[{index}]"
+ if data in self._footnotes:
+ index = len(self._usedNotes) + 1
+ self._usedNotes[data] = index
+ html = f"{index}"
+ else:
+ html = "ERR"
else:
html = tags.get(fmt, "ERR")
temp = f"{temp[:pos]}{html}{temp[pos:]}"
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index db1a5f3f..ad9ea768 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -49,7 +49,7 @@ ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
T_Formats = list[tuple[int, int, str]]
-T_Comment = tuple[int, list[tuple[str, T_Formats]]]
+T_Comment = tuple[str, T_Formats]
def stripEscape(text: str) -> str:
@@ -561,9 +561,7 @@ class Tokenizer(ABC):
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.FOOTNOTE:
tLine, tFmt = self._extractFormats(cText, skip=self.FMT_FNOTE)
- if cKey not in self._footnotes:
- self._footnotes[cKey] = (len(self._footnotes) + 1, [])
- self._footnotes[cKey][1].append((tLine, tFmt))
+ self._footnotes[cKey] = (tLine, tFmt)
else:
tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 1d5fa318..bcf8a639 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -89,7 +89,7 @@ class ToMarkdown(Tokenizer):
self._genMode = self.M_STD
self._fullMD: list[str] = []
self._preserveBreaks = True
- self._usedNotes = set()
+ self._usedNotes: dict[str, int] = {}
return
##
@@ -208,11 +208,10 @@ class ToMarkdown(Tokenizer):
lines = []
lines.append(f"### {footnotes}\n\n")
- for key, (index, content) in self._footnotes.items():
- if key in self._usedNotes:
+ for key, index in self._usedNotes.items():
+ if content := self._footnotes.get(key):
marker = f"{index}. "
- indent = "\n\n"+" "*len(marker)
- text = indent.join(self._formatText(t, f, tags) for t, f in content)
+ text = self._formatText(*content, tags)
lines.append(f"{marker}{text}\n")
lines.append("\n")
@@ -247,9 +246,12 @@ class ToMarkdown(Tokenizer):
for pos, fmt, data in reversed(tFmt):
md = ""
if fmt == self.FMT_FNOTE:
- self._usedNotes.add(data)
- index = self._footnotes.get(data, (0, ""))[0] or "ERR"
- md = f"[{index}]"
+ if data in self._footnotes:
+ index = len(self._usedNotes) + 1
+ self._usedNotes[data] = index
+ md = f"[{index}]"
+ else:
+ md = "[ERR]"
else:
md = tags.get(fmt, "")
temp = f"{temp[:pos]}{md}{temp[pos:]}"
diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py
index e82835e9..67867133 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -30,7 +30,6 @@ import logging
import xml.etree.ElementTree as ET
from collections.abc import Sequence
-from copy import deepcopy
from datetime import datetime
from hashlib import sha256
from pathlib import Path
@@ -412,7 +411,6 @@ class ToOdt(Tokenizer):
def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
- self._preProcessNotes()
pFmt: list[T_Formats] = []
pText = []
@@ -687,7 +685,9 @@ class ToOdt(Tokenizer):
elif fFmt == self.FMT_SUB_E:
xFmt &= M_SUB
elif fFmt == self.FMT_FNOTE:
- parProc.appendNode(self._etNotes.get(fData))
+ parProc.appendNode(self._generateFootnote(fData))
+ elif fFmt == self.FMT_STRIP:
+ pass
else:
pErr += 1
@@ -758,16 +758,11 @@ class ToOdt(Tokenizer):
return style.name
- def _preProcessNotes(self) -> None:
- """Generate XML elements for footnotes."""
- fStyle = ODTParagraphStyle("New")
- sStyle = ODTParagraphStyle("New")
- sStyle.setTextIndent("0.000cm")
- sStyle.setMarginLeft(self._mLeftFoot)
- update = [key for key in self._footnotes.keys() if key not in self._etNotes]
- for key in update:
- cStyle = fStyle
+ def _generateFootnote(self, key: str) -> ET.Element | None:
+ """Generate a footnote XML object."""
+ if content := self._footnotes.get(key):
self._nNote += 1
+ nStyle = ODTParagraphStyle("New")
xNote = ET.Element(_mkTag("text", "note"), attrib={
_mkTag("text", "id"): f"ftn{self._nNote}",
_mkTag("text", "note-class"): "footnote",
@@ -775,11 +770,9 @@ class ToOdt(Tokenizer):
xCite = ET.SubElement(xNote, _mkTag("text", "note-citation"))
xCite.text = str(self._nNote)
xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
- for text, fmt in self._footnotes[key][1]:
- self._addTextPar(xBody, "Footnote", cStyle, text, tFmt=fmt)
- cStyle = sStyle
- self._etNotes[key] = xNote
- return
+ self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1])
+ return xNote
+ return None
def _emToCm(self, value: float) -> str:
"""Converts an em value to centimetres."""
@@ -1584,16 +1577,14 @@ class XMLParagraph:
def appendNode(self, xNode: ET.Element | None) -> None:
"""Append an XML node to the paragraph."""
if xNode:
- # We must make a copy in case the node is reused
- xCopy = deepcopy(xNode)
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
- self._xRoot.append(xCopy)
- self._xTail = xCopy
+ self._xRoot.append(xNode)
+ self._xTail = xNode
self._xTail.tail = ""
self._nState = X_ROOT_TAIL
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
- self._xTail.append(xCopy)
- self._xSing = xCopy
+ self._xTail.append(xNode)
+ self._xSing = xNode
self._xSing.tail = ""
self._nState = X_SPAN_SING
return
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 1781eb2f..eae462a0 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -65,12 +65,13 @@ class nwItemLayout(Enum):
class nwComment(Enum):
PLAIN = 0
- SYNOPSIS = 1
- SHORT = 2
- NOTE = 3
- FOOTNOTE = 4
- COMMENT = 5
- STORY = 6
+ IGNORE = 1
+ SYNOPSIS = 2
+ SHORT = 3
+ NOTE = 4
+ FOOTNOTE = 5
+ COMMENT = 6
+ STORY = 7
# END Enum nwComment
From a2d8aefb219b3f05664c0cfd8b02c152f0006475 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:07:17 +0200
Subject: [PATCH 19/35] Reduce footnote index storage to keys only
---
novelwriter/common.py | 19 ++-
novelwriter/core/index.py | 233 ++++++++-----------------------
novelwriter/core/status.py | 4 +-
novelwriter/gui/doceditor.py | 11 +-
novelwriter/gui/dochighlight.py | 38 ++---
sample/content/636b6aa9b697b.nwd | 8 +-
sample/nwProject.nwx | 12 +-
7 files changed, 108 insertions(+), 217 deletions(-)
diff --git a/novelwriter/common.py b/novelwriter/common.py
index ff36bae2..8f0ffcff 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -24,30 +24,32 @@ along with this program. If not, see .
from __future__ import annotations
import json
-import uuid
import logging
import unicodedata
+import uuid
import xml.etree.ElementTree as ET
-from typing import TYPE_CHECKING, Any, Literal
-from pathlib import Path
-from datetime import datetime
from configparser import ConfigParser
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal, TypeVar
from urllib.parse import urljoin
from urllib.request import pathname2url
-from PyQt5.QtGui import QColor, QDesktopServices
from PyQt5.QtCore import QCoreApplication, QUrl
+from PyQt5.QtGui import QColor, QDesktopServices
+from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout
from novelwriter.error import logException
-from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
if TYPE_CHECKING: # pragma: no cover
from typing import TypeGuard # Requires Python 3.10
logger = logging.getLogger(__name__)
+_Type = TypeVar("_Type")
+
##
# Checker Functions
@@ -172,6 +174,11 @@ def isItemLayout(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and value in nwItemLayout.__members__
+def isListInstance(data: Any, check: type[_Type]) -> TypeGuard[list[_Type]]:
+ """Check that all items of a list is of a given type."""
+ return isinstance(data, list) and all(isinstance(item, check) for item in data)
+
+
def hexToInt(value: Any, default: int = 0) -> int:
"""Convert a hex string to an integer."""
if isinstance(value, str):
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 13f75407..230582bf 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -30,17 +30,19 @@ from __future__ import annotations
import json
import logging
+from collections.abc import ItemsView, Iterable
+from pathlib import Path
from random import randint
from time import time
-from typing import TYPE_CHECKING
-from pathlib import Path
-from collections.abc import ItemsView, Iterable
+from typing import TYPE_CHECKING, Literal
from novelwriter import SHARED
+from novelwriter.common import (
+ checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
+)
+from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders
from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
from novelwriter.error import logException
-from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode
-from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders
from novelwriter.text.counting import standardCounter
if TYPE_CHECKING: # pragma: no cover
@@ -49,7 +51,11 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
+T_NoteTypes = Literal["footnotes", "comments"]
+
TT_NONE = "T0000"
+KEY_SOURCE = "0123456789bcdfghjklmnopqrstvwxyz"
+NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]
class NWIndex:
@@ -86,7 +92,6 @@ class NWIndex:
# Storage and State
self._tagsIndex = TagsIndex()
self._itemIndex = ItemIndex(project)
- self._textIndex = TextIndex()
self._indexBroken = False
# TimeStamps
@@ -114,7 +119,6 @@ class NWIndex:
"""Clear the index dictionaries and time stamps."""
self._tagsIndex.clear()
self._itemIndex.clear()
- self._textIndex.clear()
self._indexChange = 0.0
self._rootChange = {}
SHARED.indexSignalProxy({"event": "clearIndex"})
@@ -138,7 +142,6 @@ class NWIndex:
for tTag in delTags:
del self._tagsIndex[tTag]
del self._itemIndex[tHandle]
- self._textIndex.removeHandle(tHandle)
SHARED.indexSignalProxy({
"event": "updateTags",
"deleted": delTags,
@@ -193,7 +196,6 @@ class NWIndex:
try:
self._tagsIndex.unpackData(data["novelWriter.tagsIndex"])
self._itemIndex.unpackData(data["novelWriter.itemIndex"])
- self._textIndex.unpackData(data["novelWriter.textIndex"])
except Exception:
logger.error("The index content is invalid")
logException()
@@ -229,12 +231,10 @@ class NWIndex:
try:
tagsIndex = jsonEncode(self._tagsIndex.packData(), n=1, nmax=2)
itemIndex = jsonEncode(self._itemIndex.packData(), n=1, nmax=4)
- textIndex = jsonEncode(self._textIndex.packData(), n=1, nmax=3)
with open(indexFile, mode="w+", encoding="utf-8") as outFile:
outFile.write("{\n")
outFile.write(f' "novelWriter.tagsIndex": {tagsIndex},\n')
- outFile.write(f' "novelWriter.itemIndex": {itemIndex},\n')
- outFile.write(f' "novelWriter.textIndex": {textIndex}\n')
+ outFile.write(f' "novelWriter.itemIndex": {itemIndex}\n')
outFile.write("}\n")
except Exception:
@@ -346,7 +346,7 @@ class NWIndex:
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
elif cStyle == nwComment.FOOTNOTE:
- self._textIndex.footnotes.add(cKey, tHandle, cText)
+ self._itemIndex.addNoteKey(tHandle, "footnotes", cKey)
# Count words for remaining text after last heading
if pTitle != TT_NONE:
@@ -514,13 +514,13 @@ class NWIndex:
name, _, display = text.partition("|")
return name.rstrip(), display.lstrip()
- def newCommentKey(self, style: nwComment) -> str | None:
+ def newCommentKey(self, tHandle: str, style: nwComment) -> str:
"""Generate a new key for a comment style."""
if style == nwComment.FOOTNOTE:
- return self._textIndex.footnotes.newKey()
+ return self._itemIndex.genNewNoteKey(tHandle, "footnotes")
elif style == nwComment.COMMENT:
- return self._textIndex.comments.newKey()
- return None
+ return self._itemIndex.genNewNoteKey(tHandle, "comments")
+ return "err"
##
# Extract Data
@@ -966,6 +966,25 @@ class ItemIndex:
self._items[tHandle].addHeadingRef(sTitle, tagKeys, refType)
return
+ def addNoteKey(self, tHandle: str, style: T_NoteTypes, key: str) -> None:
+ """Set notes key for a given item."""
+ if tHandle in self._items:
+ self._items[tHandle].addNoteKey(style, key)
+ return
+
+ def genNewNoteKey(self, tHandle: str, style: T_NoteTypes) -> str:
+ """Set notes key for a given item."""
+ keys = set()
+ for item in self._items.values():
+ keys.update(item.noteKeys(style))
+ if style in NOTE_TYPES and (item := self._items.get(tHandle)):
+ for _ in range(1000):
+ key = style[:1] + "".join([KEY_SOURCE[randint(0, 31)] for _ in range(4)])
+ if key not in keys:
+ item.addNoteKey(style, key)
+ return key
+ return "err"
+
##
# Pack/Unpack
##
@@ -1007,12 +1026,13 @@ class IndexItem:
must be reset each time the item is re-indexed.
"""
- __slots__ = ("_handle", "_item", "_headings", "_count")
+ __slots__ = ("_handle", "_item", "_headings", "_count", "_notes")
def __init__(self, tHandle: str, nwItem: NWItem) -> None:
self._handle = tHandle
self._item = nwItem
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
+ self._notes: dict[str, set[str]] = {}
self._count = 0
return
@@ -1080,6 +1100,13 @@ class IndexItem:
self._headings[sTitle].addReference(tagKey, refType)
return
+ def addNoteKey(self, style: T_NoteTypes, key: str) -> None:
+ """Add a note key to the index."""
+ if style not in self._notes:
+ self._notes[style] = set()
+ self._notes[style].add(key)
+ return
+
##
# Data Methods
##
@@ -1101,6 +1128,10 @@ class IndexItem:
self._count += 1
return f"T{self._count:04d}"
+ def noteKeys(self, style: T_NoteTypes) -> set[str]:
+ """Return a set of all note keys."""
+ return self._notes.get(style, set())
+
##
# Pack/Unpack
##
@@ -1119,6 +1150,8 @@ class IndexItem:
data["headings"] = heads
if refs:
data["references"] = refs
+ if self._notes:
+ data["notes"] = {style: list(keys) for style, keys in self._notes.items()}
return data
@@ -1132,6 +1165,14 @@ class IndexItem:
tHeading.unpackData(hData)
tHeading.unpackReferences(references.get(sTitle, {}))
self.addHeading(tHeading)
+
+ for style, keys in data.get("notes", {}).items():
+ if style not in NOTE_TYPES:
+ raise ValueError("The notes style is invalid")
+ if not isListInstance(keys, str):
+ raise ValueError("The notes keys must be a list of strings")
+ self._notes[style] = set(keys)
+
return
# END Class IndexItem
@@ -1314,162 +1355,6 @@ class IndexHeading:
# END Class IndexHeading
-# =============================================================================================== #
-# The Text Index Object
-# =============================================================================================== #
-
-KEY_SOURCE = "0123456789bcdfghjklmnopqrstvwxyz"
-
-
-class TextIndex:
- """Core: Text Index Wrapper Class
-
- A wrapper class that holds various global text entries.
- """
-
- __slots__ = ("_comments", "_footnotes")
-
- def __init__(self) -> None:
- self._comments = TextRegistry("c")
- self._footnotes = TextRegistry("f")
- return
-
- @property
- def comments(self) -> TextRegistry:
- """Return the comments text registry."""
- return self._comments
-
- @property
- def footnotes(self) -> TextRegistry:
- """Return the footnotes text registry."""
- return self._footnotes
-
- ##
- # Methods
- ##
-
- def clear(self) -> None:
- """Clear the index."""
- self._comments.clear()
- self._footnotes.clear()
- return
-
- def removeHandle(self, handle: str) -> None:
- """Remove all entries for a given handle."""
- self._comments.removeHandle(handle)
- self._footnotes.removeHandle(handle)
- return
-
- ##
- # Pack/Unpack
- ##
-
- def packData(self) -> dict[str, dict]:
- """Pack all the text comments into a single dictionary."""
- return {
- "comments": self._comments.packData(),
- "footnotes": self._footnotes.packData(),
- }
-
- def unpackData(self, data: dict) -> None:
- """Unpack the text comments index."""
- self._comments.unpackData(data.get("comments", {}))
- self._footnotes.unpackData(data.get("footnotes", {}))
- return
-
-# END Class TextIndex
-
-
-class TextRegistry:
- """Core: Text Registry Index Wrapper Class
-
- A wrapper class that holds a category of text entries.
- """
-
- __slots__ = ("_map", "_text", "_prefix")
-
- def __init__(self, prefix: str) -> None:
- self._map: dict[str, str] = {}
- self._text: dict[str, str] = {}
- self._prefix = prefix
- return
-
- def __len__(self) -> int:
- return len(self._text)
-
- def __getitem__(self, key: str) -> str | None:
- return self._text.get(key, (0, None))[1]
-
- def __contains__(self, key: str) -> bool:
- return key in self._text
-
- ##
- # Methods
- ##
-
- def clear(self) -> None:
- """Clear the index."""
- self._map.clear()
- self._text.clear()
- return
-
- def add(self, key: str, handle: str, text: str) -> None:
- """Add a new text entry."""
- self._map[key] = handle
- self._text[key] = text
- return
-
- def keysForHandle(self, handle: str) -> list[str]:
- """Return all keys for a given handle."""
- return [k for k, v in self._map.items() if v == handle]
-
- def removeHandle(self, handle: str) -> None:
- """Iterate through the data and remove entries for a handle."""
- for key in [k for k, v in self._map.items() if v == handle]:
- del self._text[key]
- return
-
- def newKey(self) -> str:
- """Generate a new key."""
- key = self._prefix + "".join([KEY_SOURCE[randint(0, 31)] for _ in range(4)])
- if key in self._text:
- key = self.newKey()
- return key
-
- ##
- # Pack/Unpack
- ##
-
- def packData(self) -> dict[str, dict[str, str]]:
- """Pack all the text entries into a dictionary."""
- return {k: {"handle": self._map[k], "text": v} for k, v in self._text.items()}
-
- def unpackData(self, data: dict) -> None:
- """Unpack text entries from a dictionary."""
- self.clear()
- if not isinstance(data, dict):
- raise ValueError("textEntry is not a dict")
-
- for key, entry in data.items():
- if not isinstance(key, str):
- raise ValueError("textEntry key must be a string")
- if not isinstance(entry, dict):
- raise ValueError("textEntry entry is not a dict")
-
- handle = entry.get("handle")
- text = entry.get("text")
- if not isHandle(handle):
- raise ValueError("textEntry handle must be a handle")
- if not isinstance(text, str):
- raise ValueError("textEntry text is not a string")
-
- self.add(key, handle, text)
-
- return
-
-# END Class TextEntry
-
-
# =============================================================================================== #
# Text Processing Functions
# =============================================================================================== #
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index 6b85ccec..50f7e1d2 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -29,7 +29,7 @@ import logging
import random
from collections.abc import Iterable
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
from PyQt5.QtCore import QPointF, Qt
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF
@@ -75,7 +75,7 @@ class NWStatus:
__slots__ = ("_store", "_default", "_prefix", "_height")
- def __init__(self, prefix: str) -> None:
+ def __init__(self, prefix: Literal["s", "i"]) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index d5012b59..b916cb98 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -1855,8 +1855,9 @@ class GuiDocEditor(QPlainTextEdit):
def _insertCommentStructure(self, style: nwComment) -> None:
"""Insert a shortcut/comment combo."""
- if style == nwComment.FOOTNOTE:
- key = SHARED.project.index.newCommentKey(style)
+ if self._docHandle and style == nwComment.FOOTNOTE:
+ self.saveText() # Index must be up to date
+ key = SHARED.project.index.newCommentKey(self._docHandle, style)
code = nwShortcode.COMMENT_STYLES[nwComment.FOOTNOTE]
cursor = self.textCursor()
@@ -1868,14 +1869,12 @@ class GuiDocEditor(QPlainTextEdit):
cursor.beginEditBlock()
cursor.insertText(code.format(key))
- cursor.setPosition(block.position() + block.length())
+ cursor.setPosition(block.position() + block.length() - 1)
+ cursor.insertBlock()
cursor.insertBlock()
cursor.insertText(f"%Footnote.{key}: ")
- cursor.insertBlock()
cursor.endEditBlock()
- cursor.setPosition(cursor.position() - 1)
-
self.setTextCursor(cursor)
return
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index bba81e55..57a29349 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -93,19 +93,19 @@ class GuiDocHighlighter(QSyntaxHighlighter):
colBreak.setAlpha(64)
# Create Character Formats
- self._addCharFormat("header1", SHARED.theme.colHead, "bold", 1.8)
- self._addCharFormat("header2", SHARED.theme.colHead, "bold", 1.6)
- self._addCharFormat("header3", SHARED.theme.colHead, "bold", 1.4)
- self._addCharFormat("header4", SHARED.theme.colHead, "bold", 1.2)
- self._addCharFormat("head1h", SHARED.theme.colHeadH, "bold", 1.8)
- self._addCharFormat("head2h", SHARED.theme.colHeadH, "bold", 1.6)
- self._addCharFormat("head3h", SHARED.theme.colHeadH, "bold", 1.4)
- self._addCharFormat("head4h", SHARED.theme.colHeadH, "bold", 1.2)
- self._addCharFormat("bold", colEmph, "bold")
- self._addCharFormat("italic", colEmph, "italic")
- self._addCharFormat("strike", SHARED.theme.colHidden, "strike")
- self._addCharFormat("mspaces", SHARED.theme.colError, "errline")
- self._addCharFormat("nobreak", colBreak, "background")
+ self._addCharFormat("header1", SHARED.theme.colHead, "b", 1.8)
+ self._addCharFormat("header2", SHARED.theme.colHead, "b", 1.6)
+ self._addCharFormat("header3", SHARED.theme.colHead, "b", 1.4)
+ self._addCharFormat("header4", SHARED.theme.colHead, "b", 1.2)
+ self._addCharFormat("head1h", SHARED.theme.colHeadH, "b", 1.8)
+ self._addCharFormat("head2h", SHARED.theme.colHeadH, "b", 1.6)
+ self._addCharFormat("head3h", SHARED.theme.colHeadH, "b", 1.4)
+ self._addCharFormat("head4h", SHARED.theme.colHeadH, "b", 1.2)
+ self._addCharFormat("bold", colEmph, "b")
+ self._addCharFormat("italic", colEmph, "i")
+ self._addCharFormat("strike", SHARED.theme.colHidden, "s")
+ self._addCharFormat("mspaces", SHARED.theme.colError, "err")
+ self._addCharFormat("nobreak", colBreak, "bg")
self._addCharFormat("dialog1", SHARED.theme.colDialN)
self._addCharFormat("dialog2", SHARED.theme.colDialD)
self._addCharFormat("dialog3", SHARED.theme.colDialS)
@@ -117,7 +117,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._addCharFormat("modifier", SHARED.theme.colMod)
self._addCharFormat("value", SHARED.theme.colVal)
self._addCharFormat("optional", SHARED.theme.colOpt)
- self._addCharFormat("invalid", None, "errline")
+ self._addCharFormat("invalid", None, "err")
# Cache Spell Error Format
self._spellErr = QTextCharFormat()
@@ -442,16 +442,16 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if style:
styles = style.split(",")
- if "bold" in styles:
+ if "b" in styles:
charFormat.setFontWeight(QFont.Weight.Bold)
- if "italic" in styles:
+ if "i" in styles:
charFormat.setFontItalic(True)
- if "strike" in styles:
+ if "s" in styles:
charFormat.setFontStrikeOut(True)
- if "errline" in styles:
+ if "err" in styles:
charFormat.setUnderlineColor(SHARED.theme.colError)
charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
- if "background" in styles and color is not None:
+ if "bg" in styles and color is not None:
charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern))
if size:
diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd
index 7532c006..e1f5955e 100644
--- a/sample/content/636b6aa9b697b.nwd
+++ b/sample/content/636b6aa9b697b.nwd
@@ -1,8 +1,8 @@
%%~name: Making a Scene
%%~path: 6a2d6d5f4f401/636b6aa9b697b
%%~kind: NOVEL/DOCUMENT
-%%~hash: 06b80d830f3f4d5c703eff82067d4335b8b98151
-%%~date: Unknown/2024-04-14 23:28:43
+%%~hash: c7e664867218b3a9aac5c12119ef0ec63da2e5cc
+%%~date: Unknown/2024-04-18 17:56:30
### Making a Scene
@pov: Jane
@@ -21,9 +21,9 @@ If you have the need for it, you can also add text that can be automatically rep
The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens.
-Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.[footnote:fq2ms]
+Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.[footnote:f4xr5]
-%Footnote.fq2ms: This is a footnote about non-breaking spaces.
+%Footnote.f4xr5: Using a non-breaking space is the correct way to separate a number from its unit. This ensures that line wrapping does not split the two.
#### Some Section Here
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index 3e5cec69..37feacec 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Sample Project
Jane Smith
@@ -36,7 +36,7 @@
Main
-
+
-
Novel
@@ -46,7 +46,7 @@
Title Page
-
-
+
Page
-
@@ -58,11 +58,11 @@
Chapter One
-
-
+
Making a Scene
-
-
+
Another Scene
-
From 48e653a546008df81b684e950df0fd13b6e12708 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:23:25 +0200
Subject: [PATCH 20/35] Clean up footnote key generation
---
novelwriter/core/index.py | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 230582bf..98642537 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -29,10 +29,10 @@ from __future__ import annotations
import json
import logging
+import random
from collections.abc import ItemsView, Iterable
from pathlib import Path
-from random import randint
from time import time
from typing import TYPE_CHECKING, Literal
@@ -53,8 +53,9 @@ logger = logging.getLogger(__name__)
T_NoteTypes = Literal["footnotes", "comments"]
-TT_NONE = "T0000"
-KEY_SOURCE = "0123456789bcdfghjklmnopqrstvwxyz"
+TT_NONE = "T0000" # Default title key
+MAX_RETRY = 1000 # Key generator recursion limit
+KEY_SOURCE = "0123456789bcdfghjklmnpqrstvwxz"
NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]
@@ -974,12 +975,12 @@ class ItemIndex:
def genNewNoteKey(self, tHandle: str, style: T_NoteTypes) -> str:
"""Set notes key for a given item."""
- keys = set()
- for item in self._items.values():
- keys.update(item.noteKeys(style))
if style in NOTE_TYPES and (item := self._items.get(tHandle)):
- for _ in range(1000):
- key = style[:1] + "".join([KEY_SOURCE[randint(0, 31)] for _ in range(4)])
+ keys = set()
+ for entry in self._items.values():
+ keys.update(entry.noteKeys(style))
+ for _ in range(MAX_RETRY):
+ key = style[:1] + "".join(random.choices(KEY_SOURCE, k=4))
if key not in keys:
item.addNoteKey(style, key)
return key
From b278d64d0c74bba416d7fc72926a239ca87a09e4 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 24 Apr 2024 18:38:50 +0200
Subject: [PATCH 21/35] Fix uninitialised shared class in tests
---
tests/test_core/test_core_coretools.py | 15 +++++++-------
tests/test_core/test_core_projectxml.py | 27 +++++++++++++------------
tests/test_core/test_core_tokenizer.py | 9 +++++----
3 files changed, 27 insertions(+), 24 deletions(-)
diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py
index 0dd0f113..fbceb229 100644
--- a/tests/test_core/test_core_coretools.py
+++ b/tests/test_core/test_core_coretools.py
@@ -20,16 +20,14 @@ along with this program. If not, see .
"""
from __future__ import annotations
-import uuid
-import pytest
import shutil
+import uuid
-from shutil import copyfile
from pathlib import Path
+from shutil import copyfile
from zipfile import ZipFile
-from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE
-from mocked import causeOSError
+import pytest
from novelwriter import CONFIG
from novelwriter.constants import nwConst, nwFiles, nwItemClass
@@ -38,6 +36,9 @@ from novelwriter.core.coretools import (
)
from novelwriter.core.project import NWProject
+from tests.mocked import causeOSError
+from tests.tools import NWD_IGNORE, XML_IGNORE, C, buildTestProject, cmpFiles
+
@pytest.mark.core
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
@@ -512,7 +513,7 @@ def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI):
@pytest.mark.core
-def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd):
+def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd):
"""Create a new project from a project dictionary, with chapters."""
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
@@ -547,7 +548,7 @@ def testCoreTools_ProjectBuilderA(monkeypatch, fncPath, tstPaths, mockRnd):
@pytest.mark.core
-def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockRnd):
+def testCoreTools_ProjectBuilderB(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd):
"""Create a new project from a project dictionary, without chapters."""
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py
index 394e29cf..de277dfa 100644
--- a/tests/test_core/test_core_projectxml.py
+++ b/tests/test_core/test_core_projectxml.py
@@ -21,21 +21,22 @@ along with this program. If not, see .
from __future__ import annotations
import json
-import pytest
-from shutil import copyfile
from datetime import datetime
-from novelwriter.constants import nwFiles
+from shutil import copyfile
-from novelwriter.enum import nwStatusShape
-from tools import cmpFiles, writeFile
-from mocked import causeOSError
+import pytest
from PyQt5.QtGui import QColor
+from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
-from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData
+from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
+from novelwriter.enum import nwStatusShape
+
+from tests.mocked import causeOSError
+from tests.tools import cmpFiles, writeFile
class MockProject:
@@ -55,7 +56,7 @@ def mockVersion(monkeypatch):
@pytest.mark.core
-def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
+def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath):
"""Test reading the current XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.5.nwx"
tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx"
@@ -249,7 +250,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
@pytest.mark.core
-def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
+def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockGUI, mockRnd):
"""Test reading the version 1.0 XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.0.nwx"
xmlFile = fncPath / "nwProject-1.0.nwx"
@@ -396,7 +397,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
@pytest.mark.core
-def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
+def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockGUI, mockRnd):
"""Test reading the version 1.1 XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.1.nwx"
xmlFile = fncPath / "nwProject-1.1.nwx"
@@ -543,7 +544,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
@pytest.mark.core
-def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
+def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockGUI, mockRnd):
"""Test reading the version 1.2 XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.2.nwx"
xmlFile = fncPath / "nwProject-1.2.nwx"
@@ -693,7 +694,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
@pytest.mark.core
-def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
+def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockGUI, mockRnd):
"""Test reading the version 1.3 XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.3.nwx"
xmlFile = fncPath / "nwProject-1.3.nwx"
@@ -843,7 +844,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
@pytest.mark.core
-def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd):
+def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockGUI, mockRnd):
"""Test reading the version 1.4 XML file format."""
refFile = tstPaths.filesDir / "nwProject-1.4.nwx"
xmlFile = fncPath / "nwProject-1.4.nwx"
diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py
index 86cc0d50..a98bc569 100644
--- a/tests/test_core/test_core_tokenizer.py
+++ b/tests/test_core/test_core_tokenizer.py
@@ -21,14 +21,15 @@ along with this program. If not, see .
from __future__ import annotations
import json
+
import pytest
-from tools import C, buildTestProject, readFile
-
from novelwriter.constants import nwHeadFmt
-from novelwriter.core.tomd import ToMarkdown
from novelwriter.core.project import NWProject
from novelwriter.core.tokenizer import HeadingFormatter, Tokenizer, stripEscape
+from novelwriter.core.tomd import ToMarkdown
+
+from tests.tools import C, buildTestProject, readFile
class BareTokenizer(Tokenizer):
@@ -2137,7 +2138,7 @@ def testCoreToken_CounterHandling(mockGUI):
@pytest.mark.core
-def testCoreToken_HeadingFormatter(fncPath, mockRnd):
+def testCoreToken_HeadingFormatter(fncPath, mockGUI, mockRnd):
"""Check the HeadingFormatter class."""
project = NWProject()
project.setProjectLang("en_GB")
From dc41f495be5d129d9e089908bfcf107a96d04a14 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 24 Apr 2024 18:39:06 +0200
Subject: [PATCH 22/35] Fix ODT tests
---
.../coreToOdt_SaveFlat_document.fodt | 14 +--
tests/reference/coreToOdt_SaveFull_styles.xml | 8 +-
tests/test_core/test_core_toodt.py | 88 ++++++++++---------
3 files changed, 63 insertions(+), 47 deletions(-)
diff --git a/tests/reference/coreToOdt_SaveFlat_document.fodt b/tests/reference/coreToOdt_SaveFlat_document.fodt
index b97f7cea..78c9ab59 100644
--- a/tests/reference/coreToOdt_SaveFlat_document.fodt
+++ b/tests/reference/coreToOdt_SaveFlat_document.fodt
@@ -1,13 +1,13 @@
- 2024-03-14T23:26:28
- novelWriter/2.4a2
+ 2024-04-24T18:30:38
+ novelWriter/2.5a2
Jane Smith
1234
P42DT12H34M56S
Test Project
- 2024-03-14T23:26:28
+ 2024-04-24T18:30:38
Jane Smith
@@ -31,7 +31,7 @@
-
+
@@ -64,10 +64,14 @@
+
+
+
+
-
+
diff --git a/tests/reference/coreToOdt_SaveFull_styles.xml b/tests/reference/coreToOdt_SaveFull_styles.xml
index 6ecb370a..03395db1 100644
--- a/tests/reference/coreToOdt_SaveFull_styles.xml
+++ b/tests/reference/coreToOdt_SaveFull_styles.xml
@@ -21,7 +21,7 @@
-
+
@@ -54,10 +54,14 @@
+
+
+
+
-
+
diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py
index b34cb40d..4efe8b44 100644
--- a/tests/test_core/test_core_toodt.py
+++ b/tests/test_core/test_core_toodt.py
@@ -20,18 +20,19 @@ along with this program. If not, see .
"""
from __future__ import annotations
-import pytest
-import zipfile
import xml.etree.ElementTree as ET
+import zipfile
from shutil import copyfile
-from tools import ODT_IGNORE, cmpFiles
+import pytest
from novelwriter.common import xmlIndent
from novelwriter.constants import nwHeadFmt
-from novelwriter.core.toodt import ToOdt, ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag
from novelwriter.core.project import NWProject
+from novelwriter.core.toodt import ODTParagraphStyle, ODTTextStyle, ToOdt, XMLParagraph, _mkTag
+
+from tests.tools import ODT_IGNORE, cmpFiles
XML_NS = [
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
@@ -132,7 +133,7 @@ def testCoreToOdt_TextFormatting(mockGUI):
assert list(odt._mainPara.keys()) == [
"Text_20_body", "First_20_line_20_indent", "Text_20_Meta", "Title", "Separator",
- "Heading_20_1", "Heading_20_2", "Heading_20_3", "Heading_20_4", "Header",
+ "Heading_20_1", "Heading_20_2", "Heading_20_3", "Heading_20_4", "Header", "Footnote",
]
key = "55db6c1d22ff5aba93f0f67c8d4a857a26e2d3813dfbcba1ef7c0d424f501be5"
@@ -145,51 +146,51 @@ def testCoreToOdt_TextFormatting(mockGUI):
oStyle = ODTParagraphStyle("test")
# No Text
- odt.initDocument()
- odt._addTextPar("Standard", oStyle, "")
- assert xmlToText(odt._xText) == (
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, "")
+ assert xmlToText(xTest) == (
''
''
''
)
# No Format
- odt.initDocument()
- odt._addTextPar("Standard", oStyle, "Hello World")
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, "Hello World")
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'Hello World'
''
)
# Heading Level None
- odt.initDocument()
- odt._addTextPar("Standard", oStyle, "Hello World", isHead=True)
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True)
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'Hello World'
''
)
# Heading Level 1
- odt.initDocument()
- odt._addTextPar("Standard", oStyle, "Hello World", isHead=True, oLevel="1")
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True, oLevel="1")
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'Hello World'
''
)
# Formatted Text
- odt.initDocument()
text = "A bold word"
- fmt = [(2, odt.FMT_B_B), (6, odt.FMT_B_E)]
- odt._addTextPar("Standard", oStyle, text, tFmt=fmt)
+ fmt = [(2, odt.FMT_B_B, ""), (6, odt.FMT_B_E, "")]
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt)
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'A bold '
'word'
@@ -197,25 +198,26 @@ def testCoreToOdt_TextFormatting(mockGUI):
)
# Incorrectly Formatted Text
- odt.initDocument()
text = "A few words"
- fmt = [(2, odt.FMT_B_B), (5, odt.FMT_B_E), (7, 99999)]
- odt._addTextPar("Standard", oStyle, text, tFmt=fmt)
+ fmt = [(2, odt.FMT_B_B, ""), (5, odt.FMT_B_E, ""), (7, 99999, "")]
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt)
assert odt.errData == ["Unknown format tag encountered"]
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'A few '
'words'
''
)
+ odt._errData = []
# Unclosed format
- odt.initDocument()
text = "A bold word"
- fmt = [(2, odt.FMT_B_B)]
- odt._addTextPar("Standard", oStyle, text, tFmt=fmt)
+ fmt = [(2, odt.FMT_B_B, "")]
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt)
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'A '
'bold word'
@@ -223,12 +225,12 @@ def testCoreToOdt_TextFormatting(mockGUI):
)
# Tabs and Breaks
- odt.initDocument()
text = "Hello\n\tWorld"
fmt = []
- odt._addTextPar("Standard", oStyle, text, tFmt=fmt)
+ xTest = ET.Element(_mkTag("office", "text"))
+ odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt)
assert odt.errData == []
- assert xmlToText(odt._xText) == (
+ assert xmlToText(xTest) == (
''
'HelloWorld'
''
@@ -835,22 +837,28 @@ def testCoreToOdt_Format(mockGUI):
project = NWProject()
odt = ToOdt(project, isFlat=True)
- assert odt._formatSynopsis("synopsis text", True) == (
- "Synopsis: synopsis text", [(0, ToOdt.FMT_B_B), (9, ToOdt.FMT_B_E)]
+ assert odt._formatSynopsis("synopsis text", [(9, ToOdt.FMT_STRIP, "")], True) == (
+ "Synopsis: synopsis text", [
+ (0, ToOdt.FMT_B_B, ""), (9, ToOdt.FMT_B_E, ""), (19, ToOdt.FMT_STRIP, "")
+ ]
)
- assert odt._formatSynopsis("short text", False) == (
- "Short Description: short text", [(0, ToOdt.FMT_B_B), (18, ToOdt.FMT_B_E)]
+ assert odt._formatSynopsis("short text", [(6, ToOdt.FMT_STRIP, "")], False) == (
+ "Short Description: short text", [
+ (0, ToOdt.FMT_B_B, ""), (18, ToOdt.FMT_B_E, ""), (25, ToOdt.FMT_STRIP, "")
+ ]
)
- assert odt._formatComments("comment text") == (
- "Comment: comment text", [(0, ToOdt.FMT_B_B), (8, ToOdt.FMT_B_E)]
+ assert odt._formatComments("comment text", [(8, ToOdt.FMT_STRIP, "")]) == (
+ "Comment: comment text", [
+ (0, ToOdt.FMT_B_B, ""), (8, ToOdt.FMT_B_E, ""), (17, ToOdt.FMT_STRIP, "")
+ ]
)
assert odt._formatKeywords("") == ("", [])
assert odt._formatKeywords("tag: Jane") == (
- "Tag: Jane", [(0, ToOdt.FMT_B_B), (4, ToOdt.FMT_B_E)]
+ "Tag: Jane", [(0, ToOdt.FMT_B_B, ""), (4, ToOdt.FMT_B_E, "")]
)
assert odt._formatKeywords("char: Bod, Jane") == (
- "Characters: Bod, Jane", [(0, ToOdt.FMT_B_B), (11, ToOdt.FMT_B_E)]
+ "Characters: Bod, Jane", [(0, ToOdt.FMT_B_B, ""), (11, ToOdt.FMT_B_E, "")]
)
# END Test testCoreToOdt_Format
From 96741fc08b6849fdd098e12349f212816db6871c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 25 Apr 2024 22:58:00 +0200
Subject: [PATCH 23/35] Fix remaining broken tests
---
.../coreIndex_LoadSave_tagsIndex.json | 4 --
...uildDocBuild_OpenDocument_Lorem_Ipsum.fodt | 14 ++--
tests/test_core/test_core_tohtml.py | 4 +-
tests/test_core/test_core_tokenizer.py | 66 ++++++++++---------
4 files changed, 46 insertions(+), 42 deletions(-)
diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json
index 5531aac2..4c10214a 100644
--- a/tests/reference/coreIndex_LoadSave_tagsIndex.json
+++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json
@@ -106,9 +106,5 @@
"T0001": {"level": "H1", "title": "Ancient Europe", "line": 1, "tag": "europe", "cCount": 1770, "wCount": 259, "pCount": 3, "synopsis": ""}
}
}
- },
- "novelWriter.textIndex": {
- "comments": {},
- "footnotes": {}
}
}
diff --git a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt
index 1585963a..3c1eda3a 100644
--- a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt
+++ b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt
@@ -1,13 +1,13 @@
- 2024-03-14T23:42:49
- novelWriter/2.4a2
+ 2024-04-25T07:39:02
+ novelWriter/2.5a2
lipsum.com
44
P0DT0H33M59S
Lorem Ipsum
- 2024-03-14T23:42:49
+ 2024-04-25T07:39:02
lipsum.com
@@ -31,7 +31,7 @@
-
+
@@ -64,10 +64,14 @@
+
+
+
+
-
+
diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py
index 0152607d..b9cee8e3 100644
--- a/tests/test_core/test_core_tohtml.py
+++ b/tests/test_core/test_core_tohtml.py
@@ -24,8 +24,8 @@ import pytest
from tools import readFile
-from novelwriter.core.tohtml import ToHtml
from novelwriter.core.project import NWProject
+from novelwriter.core.tohtml import ToHtml
@pytest.mark.core
@@ -453,7 +453,7 @@ def testCoreToHtml_SpecialCases(mockGUI):
html.doConvert()
assert html.result == (
"\n"
)
diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py
index a98bc569..82fd6814 100644
--- a/tests/test_core/test_core_tokenizer.py
+++ b/tests/test_core/test_core_tokenizer.py
@@ -870,30 +870,31 @@ def testCoreToken_ExtractFormats(mockGUI):
# Plain bold
text, fmt = tokens._extractFormats("Text with **bold** in it.")
assert text == "Text with bold in it."
- assert fmt == [(10, tokens.FMT_B_B), (14, tokens.FMT_B_E)]
+ assert fmt == [(10, tokens.FMT_B_B, ""), (14, tokens.FMT_B_E, "")]
# Plain italics
text, fmt = tokens._extractFormats("Text with _italics_ in it.")
assert text == "Text with italics in it."
- assert fmt == [(10, tokens.FMT_I_B), (17, tokens.FMT_I_E)]
+ assert fmt == [(10, tokens.FMT_I_B, ""), (17, tokens.FMT_I_E, "")]
# Plain strikethrough
text, fmt = tokens._extractFormats("Text with ~~strikethrough~~ in it.")
assert text == "Text with strikethrough in it."
- assert fmt == [(10, tokens.FMT_D_B), (23, tokens.FMT_D_E)]
+ assert fmt == [(10, tokens.FMT_D_B, ""), (23, tokens.FMT_D_E, "")]
# Nested bold/italics
text, fmt = tokens._extractFormats("Text with **bold and _italics_** in it.")
assert text == "Text with bold and italics in it."
assert fmt == [
- (10, tokens.FMT_B_B), (19, tokens.FMT_I_B), (26, tokens.FMT_I_E), (26, tokens.FMT_B_E)
+ (10, tokens.FMT_B_B, ""), (19, tokens.FMT_I_B, ""),
+ (26, tokens.FMT_I_E, ""), (26, tokens.FMT_B_E, ""),
]
# Bold with overlapping italics
# Here, bold is ignored because it is not on word boundary
text, fmt = tokens._extractFormats("Text with **bold and overlapping _italics**_ in it.")
assert text == "Text with **bold and overlapping italics** in it."
- assert fmt == [(33, tokens.FMT_I_B), (42, tokens.FMT_I_E)]
+ assert fmt == [(33, tokens.FMT_I_B, ""), (42, tokens.FMT_I_E, "")]
# Shortcodes
# ==========
@@ -901,43 +902,44 @@ def testCoreToken_ExtractFormats(mockGUI):
# Plain bold
text, fmt = tokens._extractFormats("Text with [b]bold[/b] in it.")
assert text == "Text with bold in it."
- assert fmt == [(10, tokens.FMT_B_B), (14, tokens.FMT_B_E)]
+ assert fmt == [(10, tokens.FMT_B_B, ""), (14, tokens.FMT_B_E, "")]
# Plain italics
text, fmt = tokens._extractFormats("Text with [i]italics[/i] in it.")
assert text == "Text with italics in it."
- assert fmt == [(10, tokens.FMT_I_B), (17, tokens.FMT_I_E)]
+ assert fmt == [(10, tokens.FMT_I_B, ""), (17, tokens.FMT_I_E, "")]
# Plain strikethrough
text, fmt = tokens._extractFormats("Text with [s]strikethrough[/s] in it.")
assert text == "Text with strikethrough in it."
- assert fmt == [(10, tokens.FMT_D_B), (23, tokens.FMT_D_E)]
+ assert fmt == [(10, tokens.FMT_D_B, ""), (23, tokens.FMT_D_E, "")]
# Plain underline
text, fmt = tokens._extractFormats("Text with [u]underline[/u] in it.")
assert text == "Text with underline in it."
- assert fmt == [(10, tokens.FMT_U_B), (19, tokens.FMT_U_E)]
+ assert fmt == [(10, tokens.FMT_U_B, ""), (19, tokens.FMT_U_E, "")]
# Plain mark
text, fmt = tokens._extractFormats("Text with [m]highlight[/m] in it.")
assert text == "Text with highlight in it."
- assert fmt == [(10, tokens.FMT_M_B), (19, tokens.FMT_M_E)]
+ assert fmt == [(10, tokens.FMT_M_B, ""), (19, tokens.FMT_M_E, "")]
# Plain superscript
text, fmt = tokens._extractFormats("Text with super[sup]script[/sup] in it.")
assert text == "Text with superscript in it."
- assert fmt == [(15, tokens.FMT_SUP_B), (21, tokens.FMT_SUP_E)]
+ assert fmt == [(15, tokens.FMT_SUP_B, ""), (21, tokens.FMT_SUP_E, "")]
# Plain subscript
text, fmt = tokens._extractFormats("Text with sub[sub]script[/sub] in it.")
assert text == "Text with subscript in it."
- assert fmt == [(13, tokens.FMT_SUB_B), (19, tokens.FMT_SUB_E)]
+ assert fmt == [(13, tokens.FMT_SUB_B, ""), (19, tokens.FMT_SUB_E, "")]
# Nested bold/italics
text, fmt = tokens._extractFormats("Text with [b]bold and [i]italics[/i][/b] in it.")
assert text == "Text with bold and italics in it."
assert fmt == [
- (10, tokens.FMT_B_B), (19, tokens.FMT_I_B), (26, tokens.FMT_I_E), (26, tokens.FMT_B_E)
+ (10, tokens.FMT_B_B, ""), (19, tokens.FMT_I_B, ""),
+ (26, tokens.FMT_I_E, ""), (26, tokens.FMT_B_E, ""),
]
# Bold with overlapping italics
@@ -947,7 +949,8 @@ def testCoreToken_ExtractFormats(mockGUI):
)
assert text == "Text with bold and overlapping italics in it."
assert fmt == [
- (10, tokens.FMT_B_B), (31, tokens.FMT_I_B), (38, tokens.FMT_B_E), (38, tokens.FMT_I_E)
+ (10, tokens.FMT_B_B, ""), (31, tokens.FMT_I_B, ""),
+ (38, tokens.FMT_B_E, ""), (38, tokens.FMT_I_E, ""),
]
# So does this
@@ -956,7 +959,8 @@ def testCoreToken_ExtractFormats(mockGUI):
)
assert text == "Text with bold and overlapping italics in it."
assert fmt == [
- (10, tokens.FMT_B_B), (31, tokens.FMT_I_B), (38, tokens.FMT_B_E), (41, tokens.FMT_I_E)
+ (10, tokens.FMT_B_B, ""), (31, tokens.FMT_I_B, ""),
+ (38, tokens.FMT_B_E, ""), (41, tokens.FMT_I_E, ""),
]
# END Test testCoreToken_ExtractFormats
@@ -999,8 +1003,8 @@ def testCoreToken_TextFormat(mockGUI):
Tokenizer.T_TEXT, 0,
"Some bolded text on this lines",
[
- (5, Tokenizer.FMT_B_B),
- (16, Tokenizer.FMT_B_E),
+ (5, Tokenizer.FMT_B_B, ""),
+ (16, Tokenizer.FMT_B_E, ""),
],
Tokenizer.A_NONE
),
@@ -1015,8 +1019,8 @@ def testCoreToken_TextFormat(mockGUI):
Tokenizer.T_TEXT, 0,
"Some italic text on this lines",
[
- (5, Tokenizer.FMT_I_B),
- (16, Tokenizer.FMT_I_E),
+ (5, Tokenizer.FMT_I_B, ""),
+ (16, Tokenizer.FMT_I_E, ""),
],
Tokenizer.A_NONE
),
@@ -1031,10 +1035,10 @@ def testCoreToken_TextFormat(mockGUI):
Tokenizer.T_TEXT, 0,
"Some bold italic text on this lines",
[
- (5, Tokenizer.FMT_B_B),
- (5, Tokenizer.FMT_I_B),
- (21, Tokenizer.FMT_I_E),
- (21, Tokenizer.FMT_B_E),
+ (5, Tokenizer.FMT_B_B, ""),
+ (5, Tokenizer.FMT_I_B, ""),
+ (21, Tokenizer.FMT_I_E, ""),
+ (21, Tokenizer.FMT_B_E, ""),
],
Tokenizer.A_NONE
),
@@ -1049,8 +1053,8 @@ def testCoreToken_TextFormat(mockGUI):
Tokenizer.T_TEXT, 0,
"Some strikethrough text on this lines",
[
- (5, Tokenizer.FMT_D_B),
- (23, Tokenizer.FMT_D_E),
+ (5, Tokenizer.FMT_D_B, ""),
+ (23, Tokenizer.FMT_D_E, ""),
],
Tokenizer.A_NONE
),
@@ -1065,12 +1069,12 @@ def testCoreToken_TextFormat(mockGUI):
Tokenizer.T_TEXT, 0,
"Some nested bold and italic and strikethrough text here",
[
- (5, Tokenizer.FMT_B_B),
- (21, Tokenizer.FMT_I_B),
- (27, Tokenizer.FMT_I_E),
- (32, Tokenizer.FMT_D_B),
- (45, Tokenizer.FMT_D_E),
- (50, Tokenizer.FMT_B_E),
+ (5, Tokenizer.FMT_B_B, ""),
+ (21, Tokenizer.FMT_I_B, ""),
+ (27, Tokenizer.FMT_I_E, ""),
+ (32, Tokenizer.FMT_D_B, ""),
+ (45, Tokenizer.FMT_D_E, ""),
+ (50, Tokenizer.FMT_B_E, ""),
],
Tokenizer.A_NONE
),
From e5ef27dfbfc60744b9444188d7b6fecb6f38d3f6 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 25 Apr 2024 23:25:23 +0200
Subject: [PATCH 24/35] Revert change in test
---
tests/test_core/test_core_index.py | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index fc4ac02c..4ce56138 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -21,19 +21,20 @@ along with this program. If not, see .
from __future__ import annotations
import json
-import pytest
from shutil import copyfile
-from tools import C, buildTestProject, cmpFiles, writeFile
+import pytest
+
from mocked import causeException
+from tools import C, buildTestProject, cmpFiles, writeFile
from novelwriter import SHARED
-from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
from novelwriter.constants import nwFiles
-from novelwriter.core.item import NWItem
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, processComment
+from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
+from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
@pytest.mark.core
@@ -127,13 +128,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert index.indexBroken is True
# Write an index file that passes loading, but is still empty
- writeFile(projFile, (
- '{'
- '"novelWriter.tagsIndex": {}, '
- '"novelWriter.itemIndex": {}, '
- '"novelWriter.textIndex": {}'
- '}'
- ))
+ writeFile(projFile, ('{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}'))
assert index.loadIndex() is True
assert index.indexBroken is False
From b585b8ecd1bc2d05a6182bb421ea94691def9e4d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 26 Apr 2024 20:12:57 +0200
Subject: [PATCH 25/35] Make the footnote registry per-file
---
novelwriter/core/tohtml.py | 6 ++--
novelwriter/core/tokenizer.py | 65 ++++++++++++++++++-----------------
novelwriter/core/tomd.py | 6 ++--
pyproject.toml | 2 +-
requirements-dev.txt | 1 +
5 files changed, 42 insertions(+), 38 deletions(-)
diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py
index 1384c96e..960f8ace 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -26,12 +26,12 @@ from __future__ import annotations
import json
import logging
-from time import time
from pathlib import Path
+from time import time
from novelwriter import CONFIG
from novelwriter.common import formatTimeStamp
-from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwHtmlUnicode
+from novelwriter.constants import nwHeadFmt, nwHtmlUnicode, nwKeyWords, nwLabels
from novelwriter.core.project import NWProject
from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
@@ -306,7 +306,7 @@ class ToHtml(Tokenizer):
def appendFootnotes(self) -> None:
"""Append the footnotes in the buffer."""
- if self._footnotes:
+ if self._usedNotes:
tags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
footnotes = self._localLookup("Footnotes")
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index ad9ea768..1c79ba24 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -24,18 +24,18 @@ along with this program. If not, see .
"""
from __future__ import annotations
-import re
import json
import logging
+import re
from abc import ABC, abstractmethod
-from time import time
-from pathlib import Path
from functools import partial
+from pathlib import Path
+from time import time
from PyQt5.QtCore import QCoreApplication, QRegularExpression
-from novelwriter.common import formatTimeStamp, numberToRoman, checkInt
+from novelwriter.common import checkInt, formatTimeStamp, numberToRoman
from novelwriter.constants import (
nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst
)
@@ -122,18 +122,19 @@ class Tokenizer(ABC):
self._project = project
# Data Variables
- self._text = "" # The raw text to be tokenized
- self._handle = None # The item handle currently being processed
- self._result = "" # The result of the last document
+ self._text = "" # The raw text to be tokenized
+ self._handle = None # The item handle currently being processed
+ self._result = "" # The result of the last document
+ self._keepMD = False # Whether to keep the markdown text
- self._keepMarkdown = False # Whether to keep the markdown text
- self._allMarkdown = [] # The result novelWriter markdown of all documents
-
- # Processed Tokens and Meta Data
+ # Tokens and Meta Data (Per Document)
self._tokens: list[tuple[int, int, str, T_Formats, int]] = []
self._footnotes: dict[str, T_Comment] = {}
+
+ # Tokens and Meta Data (Per Instance)
self._counts: dict[str, int] = {}
self._outline: dict[str, str] = {}
+ self._markdown: list[str] = []
# User Settings
self._textFont = "Serif" # Output text font
@@ -231,7 +232,7 @@ class Tokenizer(ABC):
@property
def allMarkdown(self) -> list[str]:
"""The combined novelWriter Markdown text."""
- return self._allMarkdown
+ return self._markdown
@property
def textStats(self) -> dict[str, int]:
@@ -398,7 +399,7 @@ class Tokenizer(ABC):
def setKeepMarkdown(self, state: bool) -> None:
"""Keep original markdown during build."""
- self._keepMarkdown = state
+ self._keepMD = state
return
##
@@ -428,8 +429,8 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_TITLE, 1, title, [], textAlign
))
- if self._keepMarkdown:
- self._allMarkdown.append(f"#! {title}\n\n")
+ if self._keepMD:
+ self._markdown.append(f"#! {title}\n\n")
return
@@ -484,6 +485,7 @@ class Tokenizer(ABC):
nHead = 0
breakNext = False
tmpMarkdown = []
+ tHandle = self._handle or ""
for aLine in self._text.splitlines():
sLine = aLine.strip().lower()
@@ -492,7 +494,7 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_EMPTY, nHead, "", [], self.A_NONE
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append("\n")
continue
@@ -550,24 +552,24 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_SYNOPSIS, nHead, tLine, tFmt, sAlign
))
- if self._doSynopsis and self._keepMarkdown:
+ if self._doSynopsis and self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.SHORT:
tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
self.T_SHORT, nHead, tLine, tFmt, sAlign
))
- if self._doSynopsis and self._keepMarkdown:
+ if self._doSynopsis and self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif cStyle == nwComment.FOOTNOTE:
tLine, tFmt = self._extractFormats(cText, skip=self.FMT_FNOTE)
- self._footnotes[cKey] = (tLine, tFmt)
+ self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt)
else:
tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
self.T_COMMENT, nHead, tLine, tFmt, sAlign
))
- if self._doComments and self._keepMarkdown:
+ if self._doComments and self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith("@"):
@@ -581,7 +583,7 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign
))
- if self._doKeywords and self._keepMarkdown:
+ if self._doKeywords and self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith(("# ", "#! ")):
@@ -617,7 +619,7 @@ class Tokenizer(ABC):
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith(("## ", "##! ")):
@@ -652,7 +654,7 @@ class Tokenizer(ABC):
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith(("### ", "###! ")):
@@ -693,7 +695,7 @@ class Tokenizer(ABC):
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith("#### "):
@@ -723,7 +725,7 @@ class Tokenizer(ABC):
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
else:
@@ -771,7 +773,7 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_TEXT, nHead, tLine, tFmt, sAlign
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
# If we have content, turn off the first page flag
@@ -790,9 +792,9 @@ class Tokenizer(ABC):
self._tokens.append((
self.T_EMPTY, nHead, "", [], self.A_NONE
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append("\n")
- self._allMarkdown.append("".join(tmpMarkdown))
+ self._markdown.append("".join(tmpMarkdown))
# Second Pass
# ===========
@@ -954,7 +956,7 @@ class Tokenizer(ABC):
def saveRawMarkdown(self, path: str | Path) -> None:
"""Save the raw text to a plain text file."""
with open(path, mode="w", encoding="utf-8") as outFile:
- for nwdPage in self._allMarkdown:
+ for nwdPage in self._markdown:
outFile.write(nwdPage)
return
@@ -969,7 +971,7 @@ class Tokenizer(ABC):
"buildTimeStr": formatTimeStamp(timeStamp),
},
"text": {
- "nwd": [page.rstrip("\n").split("\n") for page in self._allMarkdown],
+ "nwd": [page.rstrip("\n").split("\n") for page in self._markdown],
}
}
with open(path, mode="w", encoding="utf-8") as fObj:
@@ -1007,6 +1009,7 @@ class Tokenizer(ABC):
# Match Shortcode w/Values
rxItt = self._rxShortCodeVals.globalMatch(text, 0)
+ tHandle = self._handle or ""
while rxItt.hasNext():
rxMatch = rxItt.next()
kind = self._shortCodeVals.get(rxMatch.captured(1).lower(), 0)
@@ -1014,7 +1017,7 @@ class Tokenizer(ABC):
rxMatch.capturedStart(0),
rxMatch.capturedLength(0),
self.FMT_STRIP if kind == skip else kind,
- rxMatch.captured(2),
+ f"{tHandle}:{rxMatch.captured(2)}",
))
# Post-process text and format
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index bcf8a639..e794083a 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -202,7 +202,7 @@ class ToMarkdown(Tokenizer):
def appendFootnotes(self) -> None:
"""Append the footnotes in the buffer."""
- if self._footnotes:
+ if self._usedNotes:
tags = STD_MD if self._genMode == self.M_STD else EXT_MD
footnotes = self._localLookup("Footnotes")
@@ -232,8 +232,8 @@ class ToMarkdown(Tokenizer):
"""Replace tabs with spaces."""
spaces = spaceChar*nSpaces
self._fullMD = [p.replace("\t", spaces) for p in self._fullMD]
- if self._keepMarkdown:
- self._allMarkdown = [p.replace("\t", spaces) for p in self._allMarkdown]
+ if self._keepMD:
+ self._markdown = [p.replace("\t", spaces) for p in self._markdown]
return
##
diff --git a/pyproject.toml b/pyproject.toml
index d9584cca..75bbabb9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,7 @@ version = {attr = "novelwriter.__version__"}
include = ["novelwriter*"]
[tool.isort]
-py_version="38"
+py_version="310"
line_length = 99
wrap_length = 79
multi_line_output = 5
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 0e13adfc..8499c6e8 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,3 +2,4 @@ flake8
flake8-pep585
flake8-pyproject
flake8-annotations
+isort
From b7222248633fc97dd5e4231954830e072a088d45 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 26 Apr 2024 22:25:08 +0200
Subject: [PATCH 26/35] Improve comment processing
---
novelwriter/core/index.py | 45 +++++++++++-----
tests/test_core/test_core_index.py | 85 +++++++++++++++++++++++-------
2 files changed, 99 insertions(+), 31 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 98642537..a9449357 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -40,8 +40,8 @@ from novelwriter import SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
)
-from novelwriter.constants import nwFiles, nwKeyWords, nwHeaders
-from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
+from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords
+from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
from novelwriter.text.counting import standardCounter
@@ -1360,24 +1360,43 @@ class IndexHeading:
# Text Processing Functions
# =============================================================================================== #
-CLASSIFIERS = {
+MODIFIERS = {
"synopsis": nwComment.SYNOPSIS,
"short": nwComment.SHORT,
"note": nwComment.NOTE,
"footnote": nwComment.FOOTNOTE,
}
+KEY_REQ = {
+ "synopsis": 0, # Key not allowed
+ "short": 0, # Key not allowed
+ "note": 1, # Key optional
+ "footnote": 2, # Key required
+}
-TERMS = ["note", "footnote"]
+
+def _checkModKey(modifier: str, key: str) -> bool:
+ """Check if a modifier and key set are ok."""
+ if modifier in MODIFIERS:
+ if key == "":
+ return KEY_REQ[modifier] < 2
+ elif key.replace("_", "").isalnum():
+ return KEY_REQ[modifier] > 0
+ return False
def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
- """Extract comment style and text. Should only be called on text
- starting with a %.
+ """Extract comment style, key and text. Should only be called on
+ text starting with a %.
"""
- check = text[1:].lstrip()
- classifier, _, content = check.partition(":")
- classifier, _, term = classifier.partition(".")
- if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
- term = "ERR" if term and clean not in TERMS else term.strip()
- return CLASSIFIERS[clean], term, content.strip(), text.find(".") + 1, text.find(":") + 1
- return nwComment.IGNORE if text.startswith("%~") else nwComment.PLAIN, "", check, 0, 0
+ if text[:2] == "%~":
+ return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0
+
+ check = text[1:].strip()
+ start, _, content = check.partition(":")
+ modifier, _, key = start.rstrip().partition(".")
+ if content and (clean := modifier.lower()) and _checkModKey(clean, key):
+ col = text.find(":") + 1
+ dot = text.find(".", 0, col) + 1
+ return MODIFIERS[clean], key, content.lstrip(), dot, col
+
+ return nwComment.PLAIN, "", check, 0, 0
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 4ce56138..09d7ff03 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -26,16 +26,16 @@ from shutil import copyfile
import pytest
-from mocked import causeException
-from tools import C, buildTestProject, cmpFiles, writeFile
-
from novelwriter import SHARED
from novelwriter.constants import nwFiles
-from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, processComment
+from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, _checkModKey, processComment
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
+from tests.mocked import causeException
+from tests.tools import C, buildTestProject, cmpFiles, writeFile
+
@pytest.mark.core
def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
@@ -1319,39 +1319,88 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
# END Test testCoreIndex_ItemIndex
+@pytest.mark.core
+def testCoreIndex_checkModKey():
+ """Test the _checkModKey function."""
+ # Check Requirements
+
+ # Synopsis
+ assert _checkModKey("synopsis", "") is True
+ assert _checkModKey("synopsis", "a") is False
+
+ # Short
+ assert _checkModKey("short", "") is True
+ assert _checkModKey("short", "a") is False
+
+ # Note
+ assert _checkModKey("note", "") is True
+ assert _checkModKey("note", "a") is True
+
+ # Footnote
+ assert _checkModKey("footnote", "") is False
+ assert _checkModKey("footnote", "a") is True
+
+ # Invalid
+ assert _checkModKey("stuff", "") is False
+ assert _checkModKey("stuff", "a") is False
+
+ # Check Keys
+ assert _checkModKey("note", "a") is True
+ assert _checkModKey("note", "a1") is True
+ assert _checkModKey("note", "a1.2") is False
+ assert _checkModKey("note", "a1_2") is True
+
+# END Test testCoreIndex_checkModKey
+
+
@pytest.mark.core
def testCoreIndex_processComment():
"""Test the comment processing function."""
- # Regular comment
+ # Plain
assert processComment("%Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi:You") == (nwComment.PLAIN, "", "Hi:You", 0, 0)
assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0)
- # Check Non-Term
+ # Ignore
+ assert processComment("%~Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
+ assert processComment("%~ Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
+
+ # Invalid
+ assert processComment("") == (nwComment.PLAIN, "", "", 0, 0)
+
+ # Short : Term not allowed
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
- assert processComment("%short.term: Hi") == (nwComment.SHORT, "ERR", "Hi", 7, 12)
+ assert processComment("%short.a: Hi") == (nwComment.PLAIN, "", "short.a: Hi", 0, 0)
- # Check Term
+ # Synopsis : Term not allowed
+ assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
+ assert processComment("%synopsis.a: Hi") == (nwComment.PLAIN, "", "synopsis.a: Hi", 0, 0)
+
+ # Note : Term optional
assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6)
- assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
+ assert processComment("%note.a: Hi") == (nwComment.NOTE, "a", "Hi", 6, 8)
- # Check Padding
+ # Footnote : Term required
+ assert processComment("%footnote: Hi") == (nwComment.PLAIN, "", "footnote: Hi", 0, 0)
+ assert processComment("%footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
+
+ # Check Case
+ assert processComment("%Footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
+ assert processComment("%FOOTNOTE.A: Hi") == (nwComment.FOOTNOTE, "A", "Hi", 10, 12)
+ assert processComment("%FootNote.A_a: Hi") == (nwComment.FOOTNOTE, "A_a", "Hi", 10, 14)
+
+ # Padding without term
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("% short: Hi") == (nwComment.SHORT, "", "Hi", 0, 8)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 10)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 12)
assert processComment("% \t short : Hi") == (nwComment.SHORT, "", "Hi", 0, 13)
+ # Padding with term
assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
assert processComment("% note.term: Hi") == (nwComment.NOTE, "term", "Hi", 7, 12)
- assert processComment("% note . term : Hi") == (nwComment.NOTE, "term", "Hi", 9, 16)
- assert processComment("% note . term : Hi") == (nwComment.NOTE, "term", "Hi", 11, 20)
-
- # Check Classifiers
- assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
- assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
- assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
- assert processComment("%footnote.term: Hi") == (nwComment.FOOTNOTE, "term", "Hi", 10, 15)
+ assert processComment("% note. term : Hi") == (nwComment.PLAIN, "", "note. term : Hi", 0, 0)
+ assert processComment("% note . term : Hi") == (nwComment.PLAIN, "", "note . term : Hi", 0, 0)
# END Test testCoreIndex_processComment
From 41506911c1a80cd1c84ea19aa39af2355f748931 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 26 Apr 2024 22:25:58 +0200
Subject: [PATCH 27/35] Fix regex flag in highlighter
---
novelwriter/constants.py | 2 +-
novelwriter/gui/dochighlight.py | 37 ++++++++++++++++-----------------
novelwriter/types.py | 6 +++++-
3 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 6c3d6a81..dac0604a 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -23,7 +23,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP
+from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication
from novelwriter.enum import (
nwBuildFmt, nwComment, nwItemClass, nwItemLayout, nwOutline, nwStatusShape
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index 57a29349..3b438842 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -28,26 +28,27 @@ import logging
from time import time
-from PyQt5.QtCore import Qt, QRegularExpression
+from PyQt5.QtCore import QRegularExpression, Qt
from PyQt5.QtGui import (
QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData,
QTextCharFormat, QTextDocument
)
from novelwriter import CONFIG, SHARED
-from novelwriter.enum import nwComment
from novelwriter.common import checkInt
from novelwriter.constants import nwRegEx, nwUnicode
from novelwriter.core.index import processComment
+from novelwriter.enum import nwComment
+from novelwriter.types import QRegExUnicode
logger = logging.getLogger(__name__)
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
-SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
+SPELLRX.setPatternOptions(QRegExUnicode)
SPELLSC = QRegularExpression(nwRegEx.FMT_SC)
-SPELLSC.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
+SPELLSC.setPatternOptions(QRegExUnicode)
SPELLSV = QRegularExpression(nwRegEx.FMT_SV)
-SPELLSV.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
+SPELLSV.setPatternOptions(QRegExUnicode)
BLOCK_NONE = 0
BLOCK_TEXT = 1
@@ -124,12 +125,10 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._spellErr.setUnderlineColor(SHARED.theme.colSpell)
self._spellErr.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
- QtUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption
-
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["mspaces"],
}
@@ -138,7 +137,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Non-Breaking Spaces
rxRule = QRegularExpression(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["nobreak"],
}
@@ -154,7 +153,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Straight Quotes
rxRule = QRegularExpression(r'(\B")(.*?)("\B)')
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["dialog1"],
}
@@ -163,7 +162,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Double Quotes
dblEnd = "|$" if CONFIG.allowOpenDQuote else ""
rxRule = QRegularExpression(f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["dialog2"],
}
@@ -172,7 +171,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Single Quotes
sngEnd = "|$" if CONFIG.allowOpenSQuote else ""
rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["dialog3"],
}
@@ -180,7 +179,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Markdown Italic
rxRule = QRegularExpression(nwRegEx.FMT_EI)
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["markup"],
2: self._hStyles["italic"],
@@ -191,7 +190,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Markdown Bold
rxRule = QRegularExpression(nwRegEx.FMT_EB)
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["markup"],
2: self._hStyles["bold"],
@@ -202,7 +201,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Markdown Strikethrough
rxRule = QRegularExpression(nwRegEx.FMT_ST)
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["markup"],
2: self._hStyles["strike"],
@@ -213,7 +212,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Shortcodes
rxRule = QRegularExpression(nwRegEx.FMT_SC)
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["code"],
}
@@ -222,7 +221,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Shortcodes w/Value
rxRule = QRegularExpression(nwRegEx.FMT_SV)
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["code"],
2: self._hStyles["value"],
@@ -233,7 +232,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Alignment Tags
rxRule = QRegularExpression(r"(^>{1,2}|<{1,2}$)")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
1: self._hStyles["markup"],
}
@@ -241,7 +240,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Auto-Replace Tags
rxRule = QRegularExpression(r"<(\S+?)>")
- rxRule.setPatternOptions(QtUnicode)
+ rxRule.setPatternOptions(QRegExUnicode)
hlRule = {
0: self._hStyles["replace"],
}
diff --git a/novelwriter/types.py b/novelwriter/types.py
index 49e670ce..a9fc015d 100644
--- a/novelwriter/types.py
+++ b/novelwriter/types.py
@@ -23,7 +23,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtCore import Qt
+from PyQt5.QtCore import QRegularExpression, Qt
from PyQt5.QtGui import QColor, QPainter, QTextCursor
from PyQt5.QtWidgets import QDialogButtonBox, QSizePolicy, QStyle
@@ -96,3 +96,7 @@ QtSizeFixed = QSizePolicy.Policy.Fixed
QtSizeIgnored = QSizePolicy.Policy.Ignored
QtSizeMinimum = QSizePolicy.Policy.Minimum
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
+
+# Other
+
+QRegExUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption
From 4572d101cf63a469dbae737eacb20a45fc8b6cca Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 26 Apr 2024 22:29:26 +0200
Subject: [PATCH 28/35] Change to run MacOS test on 13
---
.github/workflows/test_mac.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/test_mac.yml b/.github/workflows/test_mac.yml
index de4aebc5..64c3dfa8 100644
--- a/.github/workflows/test_mac.yml
+++ b/.github/workflows/test_mac.yml
@@ -12,7 +12,7 @@ on:
jobs:
testMac:
- runs-on: macos-latest
+ runs-on: macos-13
steps:
- name: Python Setup
uses: actions/setup-python@v5
From db9305c2309c308760a2d1577bd0aaa74bf49857 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 27 Apr 2024 16:50:37 +0200
Subject: [PATCH 29/35] Add a footnote to the Lorem Ipsum test project
---
novelwriter/core/tokenizer.py | 2 ++
tests/lipsum/content/88d59a277361b.nwd | 8 ++++---
tests/lipsum/nwProject.nwx | 24 +++++++++----------
.../coreIndex_LoadSave_tagsIndex.json | 5 +++-
...dDocBuild_Extended_Markdown_Lorem_Ipsum.md | 6 ++++-
.../mBuildDocBuild_HTML5_Lorem_Ipsum.htm | 6 ++++-
.../mBuildDocBuild_HTML5_Lorem_Ipsum.json | 12 +++++++---
.../mBuildDocBuild_NWD_Lorem_Ipsum.json | 8 ++++---
.../mBuildDocBuild_NWD_Lorem_Ipsum.txt | 4 +++-
...uildDocBuild_OpenDocument_Lorem_Ipsum.fodt | 15 ++++++++----
...dDocBuild_Standard_Markdown_Lorem_Ipsum.md | 6 ++++-
tests/test_gui/test_gui_search.py | 4 ++--
12 files changed, 67 insertions(+), 33 deletions(-)
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index 1c79ba24..efeaacde 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -564,6 +564,8 @@ class Tokenizer(ABC):
elif cStyle == nwComment.FOOTNOTE:
tLine, tFmt = self._extractFormats(cText, skip=self.FMT_FNOTE)
self._footnotes[f"{tHandle}:{cKey}"] = (tLine, tFmt)
+ if self._keepMD:
+ tmpMarkdown.append(f"{aLine}\n")
else:
tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
diff --git a/tests/lipsum/content/88d59a277361b.nwd b/tests/lipsum/content/88d59a277361b.nwd
index fd6317ac..e0119b30 100644
--- a/tests/lipsum/content/88d59a277361b.nwd
+++ b/tests/lipsum/content/88d59a277361b.nwd
@@ -1,10 +1,12 @@
%%~name: Prologue
%%~path: b3643d0f92e32/88d59a277361b
%%~kind: NOVEL/DOCUMENT
-%%~hash: 5f965566ba82bbb83b8aa24f3ab7efde0c5e61cf
-%%~date: Unknown/2024-01-30 21:36:00
+%%~hash: 605a96ba35297cd7d49b753b3bda9a20b9b29d93
+%%~date: Unknown/2024-04-27 16:40:18
##! Prologue
% Synopsis: Explanation from the lipsum.com website.
-_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+_Lorem Ipsum_ is simply dummy text[footnote:f9kgf] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+
+%Footnote.f9kgf: _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)
diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx
index 66aa29e5..f619c34c 100644
--- a/tests/lipsum/nwProject.nwx
+++ b/tests/lipsum/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Lorem Ipsum
lipsum.com
@@ -9,7 +9,7 @@
en_GB
None
- 7a992350f3eb6
+ 88d59a277361b
None
b3643d0f92e32
None
@@ -19,16 +19,16 @@
Replace Text 2
- New
- Note
- Draft
- Finished
+ New
+ Note
+ Draft
+ Finished
- New
- Minor
- Major
- Main
+ New
+ Minor
+ Major
+ Main
@@ -45,7 +45,7 @@
Front Matter
-
-
+
Prologue
-
diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json
index 4c10214a..39bf1979 100644
--- a/tests/reference/coreIndex_LoadSave_tagsIndex.json
+++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json
@@ -17,7 +17,10 @@
},
"88d59a277361b": {
"headings": {
- "T0001": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "cCount": 584, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."}
+ "T0001": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "cCount": 600, "wCount": 92, "pCount": 1, "synopsis": "Explanation from the lipsum.com website."}
+ },
+ "notes": {
+ "footnotes": ["f9kgf"]
}
},
"db7e733775d4d": {
diff --git a/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md b/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md
index 9690f4db..c7c7d743 100644
--- a/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md
+++ b/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md
@@ -16,7 +16,7 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t
**Synopsis:** Explanation from the lipsum.com website.
-_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+_Lorem Ipsum_ is simply dummy text[1] of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
# Title: Act One
@@ -181,3 +181,7 @@ Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulpu
Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.
+### Footnotes
+
+1. _Lorem ipsum_ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)
+
diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm
index c5fb9a91..11a26b33 100644
--- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm
+++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm
@@ -31,7 +31,7 @@ mark {background: rgb(255, 255, 166);}
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
Prologue
Synopsis: Explanation from the lipsum.com website.
-Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+Lorem Ipsum is simply dummy text1 of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
Title: Act One
“Fusce maximus felis libero”
Chapter: Chapter One
@@ -121,6 +121,10 @@ mark {background: rgb(255, 255, 166);}
Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.
Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.
Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.
+Footnotes
+
+
+