diff --git a/.github/workflows/test_mac.yml b/.github/workflows/test_mac.yml
index de4aebc5a..64c3dfa88 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
diff --git a/novelwriter/assets/i18n/project_en_GB.json b/novelwriter/assets/i18n/project_en_GB.json
index f8e30410d..39d52e6e0 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/common.py b/novelwriter/common.py
index ff36bae27..8573c1bd1 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -24,30 +24,32 @@
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.enum import nwItemClass, nwItemType, nwItemLayout
-from novelwriter.error import logException
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
+from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
+from novelwriter.error import logException
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):
@@ -272,6 +279,13 @@ def simplified(text: str) -> str:
return " ".join(str(text).strip().split())
+def elide(text: str, length: int) -> str:
+ """Elide a piece of text to a maximum length."""
+ if len(text) > (cut := max(4, length)):
+ return f"{text[:cut-4].rstrip()} ..."
+ return text
+
+
def yesNo(value: int | bool | None) -> Literal["yes", "no"]:
"""Convert a boolean evaluated variable to a yes or no."""
return "yes" if value else "no"
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 48487d777..dac0604a5 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -23,9 +23,11 @@
"""
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, 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"(? Iterable[tup
else:
yield i, False
+ makeObj.appendFootnotes()
+
if not (self._build.getBool("html.preserveTabs") or self._preview):
makeObj.replaceTabs()
@@ -231,6 +233,8 @@ def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int,
else:
yield i, False
+ makeObj.appendFootnotes()
+
self._error = None
self._cache = makeObj
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 7ca7afe56..a94493573 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -29,17 +29,20 @@
import json
import logging
+import random
-from time import time
-from typing import TYPE_CHECKING
-from pathlib import Path
from collections.abc import ItemsView, Iterable
+from pathlib import Path
+from time import time
+from typing import TYPE_CHECKING, Literal
from novelwriter import SHARED
-from novelwriter.enum import nwComment, nwItemClass, nwItemType, nwItemLayout
+from novelwriter.common import (
+ checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
+)
+from novelwriter.constants import nwFiles, nwHeaders, nwKeyWords
+from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
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
@@ -48,7 +51,12 @@
logger = logging.getLogger(__name__)
-TT_NONE = "T0000"
+T_NoteTypes = Literal["footnotes", "comments"]
+
+TT_NONE = "T0000" # Default title key
+MAX_RETRY = 1000 # Key generator recursion limit
+KEY_SOURCE = "0123456789bcdfghjklmnpqrstvwxz"
+NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]
class NWIndex:
@@ -301,9 +309,9 @@ def scanText(self, tHandle: str, text: str, blockSignal: bool = False) -> bool:
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 +343,11 @@ def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, b
self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags)
elif line.startswith("%"):
- if cTitle != TT_NONE:
- cStyle, cText, _ = processComment(line)
- if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
- self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
+ cStyle, cKey, cText, _, _ = processComment(line)
+ if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
+ self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
+ elif cStyle == nwComment.FOOTNOTE:
+ self._itemIndex.addNoteKey(tHandle, "footnotes", cKey)
# Count words for remaining text after last heading
if pTitle != TT_NONE:
@@ -506,6 +515,14 @@ def parseValue(self, text: str) -> tuple[str, str]:
name, _, display = text.partition("|")
return name.rstrip(), display.lstrip()
+ def newCommentKey(self, tHandle: str, style: nwComment) -> str:
+ """Generate a new key for a comment style."""
+ if style == nwComment.FOOTNOTE:
+ return self._itemIndex.genNewNoteKey(tHandle, "footnotes")
+ elif style == nwComment.COMMENT:
+ return self._itemIndex.genNewNoteKey(tHandle, "comments")
+ return "err"
+
##
# Extract Data
##
@@ -790,7 +807,7 @@ def unpackData(self, data: dict) -> None:
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")
@@ -950,6 +967,25 @@ def addHeadingRef(self, tHandle: str, sTitle: str, tagKeys: list[str], refType:
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."""
+ if style in NOTE_TYPES and (item := self._items.get(tHandle)):
+ 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
+ return "err"
+
##
# Pack/Unpack
##
@@ -991,12 +1027,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
@@ -1064,6 +1101,13 @@ def addHeadingRef(self, sTitle: str, tagKeys: list[str], refType: str) -> None:
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
##
@@ -1085,6 +1129,10 @@ def nextHeading(self) -> str:
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
##
@@ -1103,6 +1151,8 @@ def packData(self) -> dict:
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
@@ -1116,6 +1166,14 @@ def unpackData(self, data: dict) -> None:
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
@@ -1302,18 +1360,43 @@ def unpackReferences(self, data: dict) -> None:
# Text Processing Functions
# =============================================================================================== #
-CLASSIFIERS = {
- "short": nwComment.SHORT,
+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
}
-def processComment(text: str) -> tuple[nwComment, str, int]:
- """Extract comment style and text. Should only be called on text
- starting with a %.
+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, key and text. Should only be called on
+ text starting with a %.
"""
- check = text[1:].lstrip()
- classifier, _, content = check.partition(":")
- if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
- return CLASSIFIERS[clean], content.strip(), text.find(":") + 1
- return nwComment.PLAIN, check, 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/novelwriter/core/status.py b/novelwriter/core/status.py
index 6b85ccec7..50f7e1d2f 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -29,7 +29,7 @@
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/core/tohtml.py b/novelwriter/core/tohtml.py
index 72539c701..960f8ace6 100644
--- a/novelwriter/core/tohtml.py
+++ b/novelwriter/core/tohtml.py
@@ -26,17 +26,53 @@
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 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: "",
+ Tokenizer.FMT_STRIP: "",
+}
+
+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: "",
+ Tokenizer.FMT_STRIP: "",
+}
+
class ToHtml(Tokenizer):
"""Core: HTML Document Writer
@@ -58,6 +94,7 @@ def __init__(self, project: NWProject) -> None:
# Internals
self._trMap = {}
+ self._usedNotes: dict[str, int] = {}
self.setReplaceUnicode(False)
return
@@ -117,38 +154,9 @@ def doPreProcessing(self) -> None:
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 +171,9 @@ def doConvert(self) -> None:
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 +186,11 @@ def doConvert(self) -> None:
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)
@@ -275,21 +280,18 @@ def doConvert(self) -> None:
lines.append(f"
\n")
elif tType == self.T_TEXT:
- tTemp = tText
if pStyle is None:
pStyle = hStyle
- for pos, fmt in reversed(tFormat):
- 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)
@@ -302,6 +304,27 @@ def doConvert(self) -> None:
return
+ def appendFootnotes(self) -> None:
+ """Append the footnotes in the buffer."""
+ if self._usedNotes:
+ 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 key, index in self._usedNotes.items():
+ if content := self._footnotes.get(key):
+ text = self._formatText(*content, tags)
+ 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:
@@ -453,6 +476,23 @@ def getStyleSheet(self) -> list[str]:
# 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:
+ 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:]}"
+ return stripEscape(temp)
+
def _formatSynopsis(self, text: str, synopsis: bool) -> str:
"""Apply HTML formatting to synopsis."""
if synopsis:
diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py
index 5c26d8ea7..efeaacdef 100644
--- a/novelwriter/core/tokenizer.py
+++ b/novelwriter/core/tokenizer.py
@@ -24,18 +24,18 @@
"""
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
)
@@ -48,6 +48,9 @@
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[str, T_Formats]
+
def stripEscape(text: str) -> str:
"""Strip escaped Markdown characters from paragraph text."""
@@ -80,6 +83,8 @@ class Tokenizer(ABC):
FMT_SUP_E = 12 # End superscript
FMT_SUB_B = 13 # Begin subscript
FMT_SUB_E = 14 # End subscript
+ FMT_FNOTE = 15 # Footnote marker
+ FMT_STRIP = 16 # Strip the format code
# Block Type
T_EMPTY = 1 # Empty line (new paragraph)
@@ -117,17 +122,19 @@ def __init__(self, project: NWProject) -> None:
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
+ # Tokens and Meta Data (Per Document)
+ self._tokens: list[tuple[int, int, str, T_Formats, int]] = []
+ self._footnotes: dict[str, T_Comment] = {}
- # Processed Tokens and Meta Data
- self._tokens: list[tuple[int, int, str, list[tuple[int, int]], int]] = []
+ # 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
@@ -135,6 +142,7 @@ def __init__(self, project: NWProject) -> None:
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
@@ -150,6 +158,7 @@ def __init__(self, project: NWProject) -> None:
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
@@ -205,6 +214,9 @@ def __init__(self, project: NWProject) -> None:
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.FMT_FNOTE,
+ }
return
@@ -220,7 +232,7 @@ def result(self) -> str:
@property
def allMarkdown(self) -> list[str]:
"""The combined novelWriter Markdown text."""
- return self._allMarkdown
+ return self._markdown
@property
def textStats(self) -> dict[str, int]:
@@ -387,7 +399,7 @@ def setIgnoredKeywords(self, keywords: str) -> None:
def setKeepMarkdown(self, state: bool) -> None:
"""Keep original markdown during build."""
- self._keepMarkdown = state
+ self._keepMD = state
return
##
@@ -417,8 +429,8 @@ def addRootHeading(self, tHandle: str) -> None:
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
@@ -473,6 +485,7 @@ def tokenizeText(self) -> None:
nHead = 0
breakNext = False
tmpMarkdown = []
+ tHandle = self._handle or ""
for aLine in self._text.splitlines():
sLine = aLine.strip().lower()
@@ -481,7 +494,7 @@ def tokenizeText(self) -> None:
self._tokens.append((
self.T_EMPTY, nHead, "", [], self.A_NONE
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append("\n")
continue
@@ -533,24 +546,32 @@ def tokenizeText(self) -> None:
if aLine.startswith("%~"):
continue
- cStyle, cText, _ = processComment(aLine)
+ cStyle, cKey, cText, _, _ = processComment(aLine)
if cStyle == nwComment.SYNOPSIS:
+ tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
- self.T_SYNOPSIS, nHead, cText, [], sAlign
+ 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, cText, [], sAlign
+ 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[f"{tHandle}:{cKey}"] = (tLine, tFmt)
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
else:
+ tLine, tFmt = self._extractFormats(cText)
self._tokens.append((
- self.T_COMMENT, nHead, cText, [], sAlign
+ 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("@"):
@@ -564,7 +585,7 @@ def tokenizeText(self) -> None:
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(("# ", "#! ")):
@@ -600,7 +621,7 @@ def tokenizeText(self) -> None:
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith(("## ", "##! ")):
@@ -635,7 +656,7 @@ def tokenizeText(self) -> None:
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith(("### ", "###! ")):
@@ -676,7 +697,7 @@ def tokenizeText(self) -> None:
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
elif aLine.startswith("#### "):
@@ -706,7 +727,7 @@ def tokenizeText(self) -> None:
self._tokens.append((
tType, nHead, tText, [], tStyle
))
- if self._keepMarkdown:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
else:
@@ -750,11 +771,11 @@ def tokenizeText(self) -> None:
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:
+ if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
# If we have content, turn off the first page flag
@@ -773,9 +794,9 @@ def tokenizeText(self) -> None:
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
# ===========
@@ -797,7 +818,9 @@ def tokenizeText(self) -> None:
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], aStyle
+ )
return
@@ -935,7 +958,7 @@ def countStats(self) -> None:
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
@@ -950,7 +973,7 @@ def saveRawMarkdownJSON(self, path: str | Path) -> None:
"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:
@@ -961,9 +984,9 @@ def saveRawMarkdownJSON(self, path: str | Path) -> None:
# Internal Functions
##
- def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]:
+ def _extractFormats(self, text: str, skip: int = 0) -> tuple[str, T_Formats]:
"""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 +994,7 @@ def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]:
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,20 +1002,34 @@ def _extractFormats(self, text: str) -> tuple[str, list[tuple[int, int]]]:
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)
+ tHandle = self._handle or ""
+ while rxItt.hasNext():
+ rxMatch = rxItt.next()
+ kind = self._shortCodeVals.get(rxMatch.captured(1).lower(), 0)
+ temp.append((
+ rxMatch.capturedStart(0),
+ rxMatch.capturedLength(0),
+ self.FMT_STRIP if kind == skip else kind,
+ f"{tHandle}:{rxMatch.captured(2)}",
+ ))
- # Post-process text and format markers
+ # Post-process text and format
result = text
formats = []
- for pos, n, fmt in reversed(sorted(temp, key=lambda x: x[0])):
+ 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))
+ formats = [(p-n, f, k) for p, f, k in formats]
+ formats.insert(0, (pos, fmt, key))
return result, formats
diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py
index 82fdb78ab..e794083a5 100644
--- a/novelwriter/core/tomd.py
+++ b/novelwriter/core/tomd.py
@@ -29,11 +29,50 @@
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: "",
+ Tokenizer.FMT_STRIP: "",
+}
+
+# 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: "~",
+ Tokenizer.FMT_STRIP: "",
+}
+
+
class ToMarkdown(Tokenizer):
"""Core: Markdown Document Writer
@@ -50,6 +89,7 @@ def __init__(self, project: NWProject) -> None:
self._genMode = self.M_STD
self._fullMD: list[str] = []
self._preserveBreaks = True
+ self._usedNotes: dict[str, int] = {}
return
##
@@ -90,47 +130,15 @@ def getFullResultSize(self) -> int:
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 +178,19 @@ def doConvert(self) -> None:
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 +200,27 @@ def doConvert(self) -> None:
return
+ def appendFootnotes(self) -> None:
+ """Append the footnotes in the buffer."""
+ if self._usedNotes:
+ tags = STD_MD if self._genMode == self.M_STD else EXT_MD
+ footnotes = self._localLookup("Footnotes")
+
+ lines = []
+ lines.append(f"### {footnotes}\n\n")
+ for key, index in self._usedNotes.items():
+ if content := self._footnotes.get(key):
+ marker = f"{index}. "
+ text = self._formatText(*content, tags)
+ lines.append(f"{marker}{text}\n")
+ lines.append("\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:
@@ -206,14 +232,31 @@ def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " ") -> None:
"""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
##
# 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:
+ 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:]}"
+ 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/core/toodt.py b/novelwriter/core/toodt.py
index 22418f326..435f6d0c5 100644
--- a/novelwriter/core/toodt.py
+++ b/novelwriter/core/toodt.py
@@ -29,17 +29,17 @@
import logging
import xml.etree.ElementTree as ET
+from collections.abc import Sequence
+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
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__)
@@ -130,6 +130,10 @@ def __init__(self, project: NWProject, isFlat: bool) -> None:
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 +155,7 @@ def __init__(self, project: NWProject, isFlat: bool) -> None:
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 +182,9 @@ def __init__(self, project: NWProject, isFlat: bool) -> None:
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 +266,7 @@ def initDocument(self) -> None:
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 +288,9 @@ def initDocument(self) -> None:
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 +301,7 @@ def initDocument(self) -> None:
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
@@ -399,10 +412,11 @@ def doConvert(self) -> None:
"""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
+ xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens:
# Styles
@@ -444,16 +458,16 @@ def doConvert(self) -> None:
if len(pText) > 0 and pStyle is not None:
tTxt = ""
- tFmt = []
+ 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()
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 +477,31 @@ def doConvert(self) -> None:
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:
@@ -495,20 +510,20 @@ def doConvert(self) -> None:
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(xText, "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(xText, "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(xText, "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(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
return
@@ -569,28 +584,32 @@ def putInZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
# 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]
@@ -600,8 +619,8 @@ def _formatKeywords(self, text: str) -> tuple[str, list[tuple[int, int]]]:
return rTxt, rFmt
def _addTextPar(
- self, styleName: str, oStyle: ODTParagraphStyle, tText: str,
- tFmt: Sequence[tuple[int, int]] = [], isHead: bool = False, oLevel: str | None = None
+ 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."""
tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)}
@@ -609,7 +628,7 @@ def _addTextPar(
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.
@@ -627,7 +646,13 @@ def _addTextPar(
xFmt = 0x00
tFrag = ""
fLast = 0
- for fPos, fFmt in tFmt:
+ xNode = None
+ for fPos, fFmt, fData in tFmt:
+
+ # Add any extra nodes
+ if xNode is not None:
+ parProc.appendNode(xNode)
+ xNode = None
# Add the text up to the current fragment
if tFrag := tText[fLast:fPos]:
@@ -665,11 +690,18 @@ def _addTextPar(
xFmt |= X_SUB
elif fFmt == self.FMT_SUB_E:
xFmt &= M_SUB
+ elif fFmt == self.FMT_FNOTE:
+ xNode = self._generateFootnote(fData)
+ elif fFmt == self.FMT_STRIP:
+ pass
else:
pErr += 1
fLast = fPos
+ if xNode is not None:
+ parProc.appendNode(xNode)
+
if tFrag := tText[fLast:]:
if xFmt == 0x00:
parProc.appendText(tFrag)
@@ -735,6 +767,22 @@ def _textStyle(self, hFmt: int) -> str:
return style.name
+ 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",
+ })
+ xCite = ET.SubElement(xNote, _mkTag("text", "note-citation"))
+ xCite.text = str(self._nNote)
+ xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
+ 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."""
return f"{value*2.54/72*self._textSize:.3f}cm"
@@ -757,7 +805,6 @@ def _pageStyles(self) -> None:
_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"))
@@ -985,6 +1032,18 @@ def _useableStyles(self) -> None:
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:
@@ -1041,7 +1100,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:
@@ -1464,7 +1523,6 @@ def appendText(self, text: str) -> None:
if c == " ":
nSpaces += 1
continue
-
elif nSpaces > 0:
self._processSpaces(nSpaces)
nSpaces = 0
@@ -1475,26 +1533,22 @@ def appendText(self, text: str) -> None:
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
@@ -1529,6 +1583,19 @@ def appendSpan(self, text: str, fmt: str) -> None:
self._nState = X_ROOT_TAIL
return
+ def appendNode(self, xNode: ET.Element | None) -> None:
+ """Append an XML node to the paragraph. We only check for the
+ X_ROOT_TEXT and X_ROOT_TAIL states. X_SPAN_TEXT is not possible
+ at all, and X_SPAN_SING only happens internally in an appendSpan
+ call, returning us to an X_ROOT_TAIL state.
+ """
+ if xNode is not None and self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
+ self._xRoot.append(xNode)
+ self._xTail = xNode
+ self._xTail.tail = ""
+ self._nState = X_ROOT_TAIL
+ return
+
def checkError(self) -> tuple[int, str]:
"""Check that the number of characters written matches the
number of characters received.
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 6a6dd2cb2..eae462a08 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -65,8 +65,13 @@ class nwItemLayout(Enum):
class nwComment(Enum):
PLAIN = 0
- SYNOPSIS = 1
- SHORT = 2
+ IGNORE = 1
+ SYNOPSIS = 2
+ SHORT = 3
+ NOTE = 4
+ FOOTNOTE = 5
+ COMMENT = 6
+ STORY = 7
# END Enum nwComment
@@ -145,6 +150,7 @@ class nwDocInsert(Enum):
VSPACE_S = 8
VSPACE_M = 9
LIPSUM = 10
+ FOOTNOTE = 11
# END Enum nwDocInsert
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index bb09e72f0..a0c1a7f72 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -39,8 +39,8 @@
from typing import TYPE_CHECKING
from PyQt5.QtCore import (
- pyqtSignal, pyqtSlot, QObject, QPoint, QRegularExpression, QRunnable, Qt,
- QTimer
+ QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
+ pyqtSlot
)
from PyQt5.QtGui import (
QColor, QCursor, QFont, QKeyEvent, QKeySequence, QMouseEvent, QPalette,
@@ -55,7 +55,7 @@
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
@@ -65,7 +65,7 @@
from novelwriter.tools.lipsum import GuiLipsum
from novelwriter.types import (
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
- QtAlignRight, QtKeepAnchor, QtModCtrl, QtMouseLeft, QtModeNone, QtModShift,
+ QtAlignRight, QtKeepAnchor, QtModCtrl, QtModeNone, QtModShift, QtMouseLeft,
QtMoveAnchor, QtMoveLeft, QtMoveRight
)
@@ -840,14 +840,15 @@ def revealLocation(self) -> None:
)
return
- def insertText(self, insert: str | nwDocInsert) -> bool:
+ def insertText(self, insert: str | nwDocInsert) -> None:
"""Insert a specific type of text at the cursor position."""
if self._docHandle is None:
logger.error("No document open")
- return False
+ return
- newBlock = False
- goAfter = False
+ text = ""
+ block = False
+ after = False
if isinstance(insert, str):
text = insert
@@ -862,43 +863,41 @@ def insertText(self, insert: str | nwDocInsert) -> bool:
text = self._typDQuoteC
elif insert == nwDocInsert.SYNOPSIS:
text = "%Synopsis: "
- newBlock = True
- goAfter = True
+ block = True
+ after = True
elif insert == nwDocInsert.SHORT:
text = "%Short: "
- newBlock = True
- goAfter = True
+ block = True
+ after = True
elif insert == nwDocInsert.NEW_PAGE:
text = "[newpage]"
- newBlock = True
- goAfter = False
+ block = True
+ after = False
elif insert == nwDocInsert.VSPACE_S:
text = "[vspace]"
- newBlock = True
- goAfter = False
+ block = True
+ after = False
elif insert == nwDocInsert.VSPACE_M:
text = "[vspace:2]"
- newBlock = True
- goAfter = False
+ block = True
+ after = False
elif insert == nwDocInsert.LIPSUM:
text = GuiLipsum.getLipsum(self)
- newBlock = True
- goAfter = False
- else:
- return False
- else:
- return False
+ block = True
+ after = False
+ elif insert == nwDocInsert.FOOTNOTE:
+ self._insertCommentStructure(nwComment.FOOTNOTE)
if text:
- if newBlock:
- self.insertNewBlock(text, defaultAfter=goAfter)
+ if block:
+ self.insertNewBlock(text, defaultAfter=after)
else:
cursor = self.textCursor()
cursor.beginEditBlock()
cursor.insertText(text)
cursor.endEditBlock()
- return True
+ return
def insertNewBlock(self, text: str, defaultAfter: bool = True) -> bool:
"""Insert a piece of text on a blank line."""
@@ -1164,7 +1163,8 @@ def _openContextMenu(self, pos: QPoint) -> None:
lambda _, option=option: self._correctWord(sCursor, option)
)
else:
- ctxMenu.addAction("%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions")))
+ trNone = self.tr("No Suggestions")
+ ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {trNone}")
ctxMenu.addSeparator()
action = ctxMenu.addAction(self.tr("Add Word to Dictionary"))
@@ -1850,6 +1850,32 @@ def _removeInParLineBreaks(self) -> None:
return
+ def _insertCommentStructure(self, style: nwComment) -> None:
+ """Insert a shortcut/comment combo."""
+ 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()
+ block = cursor.block()
+ text = block.text().rstrip()
+ if not text or text.startswith("@"):
+ logger.error("Invalid footnote location")
+ return
+
+ cursor.beginEditBlock()
+ cursor.insertText(code.format(key))
+ cursor.setPosition(block.position() + block.length() - 1)
+ cursor.insertBlock()
+ cursor.insertBlock()
+ cursor.insertText(f"%Footnote.{key}: ")
+ cursor.endEditBlock()
+
+ self.setTextCursor(cursor)
+
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py
index d9919bb96..643f339bc 100644
--- a/novelwriter/gui/dochighlight.py
+++ b/novelwriter/gui/dochighlight.py
@@ -28,26 +28,27 @@
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
@@ -57,8 +58,10 @@
class GuiDocHighlighter(QSyntaxHighlighter):
- __slots__ = ("_tHandle", "_isInactive", "_spellCheck", "_spellErr",
- "_hRules", "_hStyles", "_rxRules")
+ __slots__ = (
+ "_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hStyles",
+ "_txtRules", "_cmnRules",
+ )
def __init__(self, document: QTextDocument) -> None:
super().__init__(document)
@@ -70,9 +73,9 @@ def __init__(self, document: QTextDocument) -> None:
self._spellCheck = False
self._spellErr = QTextCharFormat()
- self._hRules: list[tuple[str, dict]] = []
self._hStyles: dict[str, QTextCharFormat] = {}
- self._rxRules: list[tuple[QRegularExpression, dict[str, QTextCharFormat]]] = []
+ self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
+ self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
self.initHighlighter()
@@ -90,34 +93,32 @@ def initHighlighter(self) -> None:
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, "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)
+ 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, "err")
# Cache Spell Error Format
self._spellErr = QTextCharFormat()
@@ -126,18 +127,22 @@ def initHighlighter(self) -> None:
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
- self._hRules.append((
- r"[ ]{2,}|[ ]*$", {
- 0: self._hStyles["mspaces"],
- }
- ))
+ rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$")
+ rxRule.setPatternOptions(QRegExUnicode)
+ 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(QRegExUnicode)
+ hlRule = {
+ 0: self._hStyles["nobreak"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
# Quoted Strings
if CONFIG.highlightQuotes:
@@ -147,88 +152,100 @@ def initHighlighter(self) -> None:
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(QRegExUnicode)
+ 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(QRegExUnicode)
+ 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"],
- }
- ))
-
- # Markdown Syntax
- self._hRules.append((
- nwRegEx.FMT_EI, {
- 1: self._hStyles["hidden"],
- 2: self._hStyles["italic"],
- 3: self._hStyles["hidden"],
+ rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})")
+ rxRule.setPatternOptions(QRegExUnicode)
+ hlRule = {
+ 0: self._hStyles["dialog3"],
}
- ))
- 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"],
- }
- ))
+ self._txtRules.append((rxRule, hlRule))
+
+ # Markdown Italic
+ rxRule = QRegularExpression(nwRegEx.FMT_EI)
+ rxRule.setPatternOptions(QRegExUnicode)
+ 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(QRegExUnicode)
+ 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(QRegExUnicode)
+ 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(QRegExUnicode)
+ 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(QRegExUnicode)
+ 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(QRegExUnicode)
+ hlRule = {
+ 1: self._hStyles["markup"],
+ }
+ self._txtRules.append((rxRule, hlRule))
# Auto-Replace Tags
- self._hRules.append((
- r"<(\S+?)>", {
- 0: self._hStyles["replace"],
- }
- ))
-
- # Build a QRegExp 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(QRegExUnicode)
+ hlRule = {
+ 0: self._hStyles["replace"],
+ }
+ self._txtRules.append((rxRule, hlRule))
+ self._cmnRules.append((rxRule, hlRule))
return
@@ -282,6 +299,8 @@ def highlightBlock(self, text: str) -> None:
if self._tHandle is None or not text:
return
+ xOff = 0
+ hRules = None
if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index
@@ -300,7 +319,7 @@ def highlightBlock(self, text: str) -> None:
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
@@ -339,43 +358,58 @@ def highlightBlock(self, text: str) -> None:
elif text.startswith("%"): # Comments
self.setCurrentBlockState(BLOCK_TEXT)
- cStyle, _, cPos = processComment(text)
+ hRules = self._cmnRules
+
+ cStyle, cMod, _, cDot, cPos = processComment(text)
+ cLen = len(text) - cPos
+ xOff = cPos
if cStyle == nwComment.PLAIN:
- self.setFormat(0, len(text), self._hStyles["hidden"])
+ 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"])
+ 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
+ elif text.startswith("["): # Special Command
+ self.setCurrentBlockState(BLOCK_TEXT)
+ hRules = self._txtRules
+
+ 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
- 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
+ else: # Text Paragraph
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):
@@ -383,11 +417,11 @@ def highlightBlock(self, text: str) -> None:
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)
- self.setFormat(x, 1, spFmt)
+ cFmt = self.format(x)
+ cFmt.merge(self._spellErr)
+ self.setFormat(x, 1, cFmt)
return
@@ -395,34 +429,37 @@ def highlightBlock(self, text: str) -> None:
# 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:
+ 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 is not None:
- charFormat.setFontPointSize(int(round(size*CONFIG.textSize)))
+ if size:
+ charFormat.setFontPointSize(round(size*CONFIG.textSize))
+
+ self._hStyles[name] = charFormat
- return charFormat
+ return
# END Class GuiDocHighlighter
@@ -441,14 +478,14 @@ def spellErrors(self) -> list[tuple[int, int]]:
"""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.
"""
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)
@@ -457,12 +494,14 @@ def spellCheck(self, text: str) -> list[tuple[int, int]]:
text = text[:xPos] + " "*xLen + text[xEnd:]
self._spellErrors = []
- rxSpell = SPELLRX.globalMatch(text.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), rxMatch.capturedLength(0)))
+ self._spellErrors.append(
+ (rxMatch.capturedStart(0), rxMatch.capturedLength(0))
+ )
return self._spellErrors
# END Class TextBlockData
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index bc5caeb50..daa4566af 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -215,6 +215,7 @@ def loadText(self, tHandle: str, updateHistory: bool = True) -> bool:
aDoc.doPreProcessing()
aDoc.tokenizeText()
aDoc.doConvert()
+ aDoc.appendFootnotes()
except Exception:
logger.error("Failed to generate preview for document with handle '%s'", tHandle)
logException()
diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py
index 1a90d2a3b..486acf29a 100644
--- a/novelwriter/gui/itemdetails.py
+++ b/novelwriter/gui/itemdetails.py
@@ -26,12 +26,14 @@
import logging
from PyQt5.QtCore import pyqtSlot
-from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel
+from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget
from novelwriter import CONFIG, SHARED
-from novelwriter.constants import trConst, nwLabels
+from novelwriter.common import elide
+from novelwriter.constants import nwLabels, trConst
from novelwriter.types import (
- QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase, QtAlignRightMiddle
+ QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase,
+ QtAlignRightMiddle
)
logger = logging.getLogger(__name__)
@@ -236,10 +238,6 @@ def updateViewBox(self, tHandle: str) -> None:
# Label
# =====
- label = nwItem.itemName
- if len(label) > 100:
- label = label[:96].rstrip()+" ..."
-
if nwItem.isFileType():
if nwItem.isActive:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx)))
@@ -248,7 +246,7 @@ def updateViewBox(self, tHandle: str) -> None:
else:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx)))
- self.labelData.setText(label)
+ self.labelData.setText(elide(nwItem.itemName, 100))
# Status
# ======
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 960dee331..7d6b05cf0 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -597,6 +597,12 @@ def _buildInsertMenu(self) -> None:
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:
diff --git a/novelwriter/types.py b/novelwriter/types.py
index 49e670ce5..a9fc015db 100644
--- a/novelwriter/types.py
+++ b/novelwriter/types.py
@@ -23,7 +23,7 @@
"""
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 @@
QtSizeIgnored = QSizePolicy.Policy.Ignored
QtSizeMinimum = QSizePolicy.Policy.Minimum
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
+
+# Other
+
+QRegExUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption
diff --git a/pyproject.toml b/pyproject.toml
index d9584ccaa..75bbabb90 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 0e13adfc0..8499c6e83 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,3 +2,4 @@ flake8
flake8-pep585
flake8-pyproject
flake8-annotations
+isort
diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd
index ed5bacb6b..e1f5955ea 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: c7e664867218b3a9aac5c12119ef0ec63da2e5cc
+%%~date: Unknown/2024-04-18 17:56:30
### 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:f4xr5]
+
+%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 8dcd5bcd0..37feacecc 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
-
diff --git a/tests/lipsum/content/88d59a277361b.nwd b/tests/lipsum/content/88d59a277361b.nwd
index fd6317ac2..e0119b304 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 66aa29e55..f619c34c7 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 4c10214ad..39bf19799 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/coreToOdt_SaveFlat_document.fodt b/tests/reference/coreToOdt_SaveFlat_document.fodt
index b97f7ceaf..78c9ab59a 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 6ecb370a7..03395db14 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/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index 340008781..4c20bd70f 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
diff --git a/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md b/tests/reference/mBuildDocBuild_Extended_Markdown_Lorem_Ipsum.md
index 9690f4db5..c7c7d7434 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 c5fb9a915..11a26b332 100644
--- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm
+++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm
@@ -31,7 +31,7 @@
Lorem Ipsum
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 @@ Ancient Europe
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
+
+
+