Add footnotes (#1832)

This commit is contained in:
Veronica Berglyd Olsen
2024-04-28 19:40:38 +02:00
committed by GitHub
46 changed files with 2179 additions and 1336 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ on:
jobs:
testMac:
runs-on: macos-latest
runs-on: macos-13
steps:
- name: Python Setup
uses: actions/setup-python@v5
@@ -1,6 +1,7 @@
{
"Synopsis": "Synopsis",
"Short Description": "Short Description",
"Footnotes": "Footnotes",
"Comment": "Comment",
"Notes": "Notes",
"Tag": "Tag",
+21 -7
View File
@@ -24,30 +24,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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"
+12 -3
View File
@@ -23,9 +23,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_ST = r"(?<![\w\\])([~]{2})(?![\s~])(.+?)(?<![\s\\])(\1)(?!\w)"
FMT_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:i|b|s|u|m|sup|sub)\])"
FMT_SV = r"(?<!\\)(\[(?i)(?:fn|footnote):)(.+?)(?<!\\)(\])"
FMT_SV = r"(?<!\\)(\[(?i)(?:footnote):)(.+?)(?<!\\)(\])"
# END Class nwRegEx
@@ -89,6 +91,13 @@ class nwShortcode:
SUB_O = "[sub]"
SUB_C = "[/sub]"
FOOTNOTE_B = "[footnote:"
COMMENT_STYLES = {
nwComment.FOOTNOTE: "[footnote:{0}]",
nwComment.COMMENT: "[comment:{0}]",
}
# END Class nwShortcode
+4
View File
@@ -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
+109 -26
View File
@@ -29,17 +29,20 @@ from __future__ import annotations
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 @@ if TYPE_CHECKING: # pragma: no cover
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 @@ 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 +343,11 @@ class NWIndex:
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 @@ class NWIndex:
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 @@ 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")
@@ -950,6 +967,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."""
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 @@ 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
##
@@ -1085,6 +1129,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
##
@@ -1103,6 +1151,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
@@ -1116,6 +1166,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
@@ -1302,18 +1360,43 @@ class IndexHeading:
# 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
+2 -2
View File
@@ -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]
+87 -47
View File
@@ -26,17 +26,53 @@ 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 Tokenizer, stripEscape
from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
logger = logging.getLogger(__name__)
HTML4_TAGS = {
Tokenizer.FMT_B_B: "<b>",
Tokenizer.FMT_B_E: "</b>",
Tokenizer.FMT_I_B: "<i>",
Tokenizer.FMT_I_E: "</i>",
Tokenizer.FMT_D_B: "<span style='text-decoration: line-through;'>",
Tokenizer.FMT_D_E: "</span>",
Tokenizer.FMT_U_B: "<u>",
Tokenizer.FMT_U_E: "</u>",
Tokenizer.FMT_M_B: "<mark>",
Tokenizer.FMT_M_E: "</mark>",
Tokenizer.FMT_SUP_B: "<sup>",
Tokenizer.FMT_SUP_E: "</sup>",
Tokenizer.FMT_SUB_B: "<sub>",
Tokenizer.FMT_SUB_E: "</sub>",
Tokenizer.FMT_STRIP: "",
}
HTML5_TAGS = {
Tokenizer.FMT_B_B: "<strong>",
Tokenizer.FMT_B_E: "</strong>",
Tokenizer.FMT_I_B: "<em>",
Tokenizer.FMT_I_E: "</em>",
Tokenizer.FMT_D_B: "<del>",
Tokenizer.FMT_D_E: "</del>",
Tokenizer.FMT_U_B: "<span style='text-decoration: underline;'>",
Tokenizer.FMT_U_E: "</span>",
Tokenizer.FMT_M_B: "<mark>",
Tokenizer.FMT_M_E: "</mark>",
Tokenizer.FMT_SUP_B: "<sup>",
Tokenizer.FMT_SUP_E: "</sup>",
Tokenizer.FMT_SUB_B: "<sub>",
Tokenizer.FMT_SUB_E: "</sub>",
Tokenizer.FMT_STRIP: "",
}
class ToHtml(Tokenizer):
"""Core: HTML Document Writer
@@ -58,6 +94,7 @@ class ToHtml(Tokenizer):
# Internals
self._trMap = {}
self._usedNotes: dict[str, int] = {}
self.setReplaceUnicode(False)
return
@@ -117,38 +154,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: "<b>",
self.FMT_B_E: "</b>",
self.FMT_I_B: "<i>",
self.FMT_I_E: "</i>",
self.FMT_D_B: "<span style='text-decoration: line-through;'>",
self.FMT_D_E: "</span>",
self.FMT_U_B: "<u>",
self.FMT_U_E: "</u>",
self.FMT_M_B: "<mark>",
self.FMT_M_E: "</mark>",
}
else:
htmlTags = { # HTML5 (for export)
self.FMT_B_B: "<strong>",
self.FMT_B_E: "</strong>",
self.FMT_I_B: "<em>",
self.FMT_I_E: "</em>",
self.FMT_D_B: "<del>",
self.FMT_D_E: "</del>",
self.FMT_U_B: "<span style='text-decoration: underline;'>",
self.FMT_U_E: "</span>",
self.FMT_M_B: "<mark>",
self.FMT_M_E: "</mark>",
}
htmlTags[self.FMT_SUP_B] = "<sup>"
htmlTags[self.FMT_SUP_E] = "</sup>"
htmlTags[self.FMT_SUB_B] = "<sub>"
htmlTags[self.FMT_SUB_E] = "</sub>"
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 @@ 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 +186,11 @@ class ToHtml(Tokenizer):
for c in tText:
if c == "<":
cText.append("&lt;")
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("&gt;")
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 @@ class ToHtml(Tokenizer):
lines.append(f"<p class='skip'{hStyle}>&nbsp;</p>\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 @@ class ToHtml(Tokenizer):
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"<h3>{footnotes}</h3>\n")
lines.append("<ol>\n")
for key, index in self._usedNotes.items():
if content := self._footnotes.get(key):
text = self._formatText(*content, tags)
lines.append(f"<li id='footnote_{index}'><p>{text}</p></li>\n")
lines.append("</ol>\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 @@ 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:
if data in self._footnotes:
index = len(self._usedNotes) + 1
self._usedNotes[data] = index
html = f"<sup><a href='#footnote_{index}'>{index}</a></sup>"
else:
html = "<sup>ERR</sup>"
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:
+83 -46
View File
@@ -24,18 +24,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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 @@ 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, 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 @@ 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
# 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 @@ 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
@@ -150,6 +158,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
@@ -205,6 +214,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.FMT_FNOTE,
}
return
@@ -220,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]:
@@ -387,7 +399,7 @@ class Tokenizer(ABC):
def setKeepMarkdown(self, state: bool) -> None:
"""Keep original markdown during build."""
self._keepMarkdown = state
self._keepMD = state
return
##
@@ -417,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
@@ -473,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()
@@ -481,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
@@ -533,24 +546,32 @@ class Tokenizer(ABC):
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 @@ 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(("# ", "#! ")):
@@ -600,7 +621,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(("## ", "##! ")):
@@ -635,7 +656,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(("### ", "###! ")):
@@ -676,7 +697,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("#### "):
@@ -706,7 +727,7 @@ class Tokenizer(ABC):
self._tokens.append((
tType, nHead, tText, [], tStyle
))
if self._keepMarkdown:
if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
else:
@@ -750,11 +771,11 @@ 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:
if self._keepMD:
tmpMarkdown.append(f"{aLine}\n")
# If we have content, turn off the first page flag
@@ -773,9 +794,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
# ===========
@@ -797,7 +818,9 @@ class Tokenizer(ABC):
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 @@ 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
@@ -950,7 +973,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:
@@ -961,9 +984,9 @@ class Tokenizer(ABC):
# 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 @@ 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,20 +1002,34 @@ 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),
"",
))
# Post-process text and format markers
# 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
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
+89 -46
View File
@@ -29,11 +29,50 @@ 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: "",
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 @@ class ToMarkdown(Tokenizer):
self._genMode = self.M_STD
self._fullMD: list[str] = []
self._preserveBreaks = True
self._usedNotes: dict[str, int] = {}
return
##
@@ -90,47 +130,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 +178,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 +200,27 @@ class ToMarkdown(Tokenizer):
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 @@ 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
##
# 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)
+106 -39
View File
@@ -29,17 +29,17 @@ from __future__ import annotations
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 @@ 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 +155,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 +182,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 +266,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 +288,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 +301,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
@@ -399,10 +412,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
xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens:
# Styles
@@ -444,16 +458,16 @@ class ToOdt(Tokenizer):
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 @@ 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:
@@ -495,20 +510,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(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 @@ 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]
@@ -600,8 +619,8 @@ class ToOdt(Tokenizer):
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 @@ 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.
@@ -627,7 +646,13 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 @@ 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"))
@@ -985,6 +1032,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:
@@ -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 @@ class XMLParagraph:
if c == " ":
nSpaces += 1
continue
elif nSpaces > 0:
self._processSpaces(nSpaces)
nSpaces = 0
@@ -1475,26 +1533,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
@@ -1529,6 +1583,19 @@ class XMLParagraph:
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.
+8 -2
View File
@@ -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
+54 -28
View File
@@ -39,8 +39,8 @@ from time import time
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 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
@@ -65,7 +65,7 @@ from novelwriter.text.counting import standardCounter
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 @@ class GuiDocEditor(QPlainTextEdit):
)
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 @@ class GuiDocEditor(QPlainTextEdit):
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 @@ class GuiDocEditor(QPlainTextEdit):
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 @@ class GuiDocEditor(QPlainTextEdit):
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
##
+205 -166
View File
@@ -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
@@ -57,8 +58,10 @@ BLOCK_TITLE = 4
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 @@ 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[str, QTextCharFormat]]] = []
self._txtRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
self._cmnRules: list[tuple[QRegularExpression, dict[int, QTextCharFormat]]] = []
self.initHighlighter()
@@ -90,34 +93,32 @@ 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, "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 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# 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 @@ 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(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"],
}
))
rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})")
rxRule.setPatternOptions(QRegExUnicode)
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(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 @@ class GuiDocHighlighter(QSyntaxHighlighter):
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 @@ 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
@@ -339,43 +358,58 @@ class GuiDocHighlighter(QSyntaxHighlighter):
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"])
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
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):
@@ -383,11 +417,11 @@ 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)
self.setFormat(x, 1, spFmt)
cFmt = self.format(x)
cFmt.merge(self._spellErr)
self.setFormat(x, 1, cFmt)
return
@@ -395,34 +429,37 @@ 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:
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))
return charFormat
self._hStyles[name] = charFormat
return
# END Class GuiDocHighlighter
@@ -441,14 +478,14 @@ 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.
"""
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 @@ class TextBlockData(QTextBlockUserData):
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
+1
View File
@@ -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()
+6 -8
View File
@@ -26,12 +26,14 @@ from __future__ import annotations
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 @@ class GuiItemDetails(QWidget):
# 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 @@ class GuiItemDetails(QWidget):
else:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx)))
self.labelData.setText(label)
self.labelData.setText(elide(nwItem.itemName, 100))
# Status
# ======
+6
View File
@@ -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:
+5 -1
View File
@@ -23,7 +23,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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
+1 -1
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@ flake8
flake8-pep585
flake8-pyproject
flake8-annotations
isort
+5 -3
View File
@@ -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: 25kg.
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: 25kg.[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
+6 -6
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.5a2" hexVersion="0x020500a1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-20 17:27:07">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="1852" autoCount="272" editTime="86443">
<novelWriterXML appVersion="2.5a2" hexVersion="0x020500a1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-18 17:57:37">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="1916" autoCount="274" editTime="87487">
<name>Sample Project</name>
<author>Jane Smith</author>
</project>
@@ -36,7 +36,7 @@
<entry key="i56be10" count="1" red="220" green="138" blue="221" shape="BLOCK_4">Main</entry>
</importance>
</settings>
<content items="31" novelWords="998" notesWords="416">
<content items="31" novelWords="996" notesWords="416">
<item handle="7031beac91f75" parent="None" root="7031beac91f75" order="0" type="ROOT" class="NOVEL">
<meta expanded="yes" />
<name status="sc24b8f" import="ia857f0">Novel</name>
@@ -46,7 +46,7 @@
<name status="sc24b8f" import="ia857f0" active="yes">Title Page</name>
</item>
<item handle="974e400180a99" parent="7031beac91f75" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="250" wordCount="49" paraCount="2" cursorPos="275" />
<meta expanded="no" heading="H0" charCount="233" wordCount="47" paraCount="2" cursorPos="275" />
<name status="sf12341" import="ia857f0" active="yes">Page</name>
</item>
<item handle="edca4be2fcaf8" parent="7031beac91f75" root="7031beac91f75" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
@@ -58,11 +58,11 @@
<name status="sf24ce6" import="ia857f0" active="yes">Chapter One</name>
</item>
<item handle="636b6aa9b697b" parent="6a2d6d5f4f401" root="7031beac91f75" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="2937" wordCount="520" paraCount="15" cursorPos="0" />
<meta expanded="no" heading="H3" charCount="2953" wordCount="520" paraCount="15" cursorPos="2155" />
<name status="s90e6c9" import="ia857f0" active="yes">Making a Scene</name>
</item>
<item handle="bc0cbd2a407f3" parent="6a2d6d5f4f401" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="548" wordCount="108" paraCount="3" cursorPos="531" />
<meta expanded="no" heading="H3" charCount="563" wordCount="108" paraCount="3" cursorPos="691" />
<name status="s90e6c9" import="ia857f0" active="yes">Another Scene</name>
</item>
<item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">
+5 -3
View File
@@ -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)
+12 -12
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.3a3" hexVersion="0x020300a3" fileVersion="1.5" fileRevision="2" timeStamp="2024-01-30 21:37:01">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="44" autoCount="25" editTime="2039">
<novelWriterXML appVersion="2.5a2" hexVersion="0x020500a2" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-27 16:40:24">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="45" autoCount="26" editTime="2168">
<name>Lorem Ipsum</name>
<author>lipsum.com</author>
</project>
@@ -9,7 +9,7 @@
<language>en_GB</language>
<spellChecking auto="no">None</spellChecking>
<lastHandle>
<entry key="editor">7a992350f3eb6</entry>
<entry key="editor">88d59a277361b</entry>
<entry key="viewer">None</entry>
<entry key="novelTree">b3643d0f92e32</entry>
<entry key="outline">None</entry>
@@ -19,16 +19,16 @@
<entry key="Rep2">Replace Text 2</entry>
</autoReplace>
<status>
<entry key="sbaa94f" count="3" red="100" green="100" blue="100">New</entry>
<entry key="s27bf7c" count="0" red="200" green="50" blue="0">Note</entry>
<entry key="s92a87b" count="5" red="200" green="150" blue="0">Draft</entry>
<entry key="sedd043" count="7" red="50" green="200" blue="0">Finished</entry>
<entry key="sbaa94f" count="3" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="s27bf7c" count="0" red="200" green="50" blue="0" shape="SQUARE">Note</entry>
<entry key="s92a87b" count="5" red="200" green="150" blue="0" shape="SQUARE">Draft</entry>
<entry key="sedd043" count="7" red="50" green="200" blue="0" shape="SQUARE">Finished</entry>
</status>
<importance>
<entry key="i613591" count="6" red="100" green="100" blue="100">New</entry>
<entry key="i560cbf" count="0" red="200" green="50" blue="0">Minor</entry>
<entry key="i37861c" count="0" red="200" green="150" blue="0">Major</entry>
<entry key="id6b1d0" count="0" red="50" green="200" blue="0">Main</entry>
<entry key="i613591" count="6" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="i560cbf" count="0" red="200" green="50" blue="0" shape="SQUARE">Minor</entry>
<entry key="i37861c" count="0" red="200" green="150" blue="0" shape="SQUARE">Major</entry>
<entry key="id6b1d0" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</settings>
<content items="21" novelWords="3109" notesWords="738">
@@ -45,7 +45,7 @@
<name status="sedd043" import="i613591" active="yes">Front Matter</name>
</item>
<item handle="88d59a277361b" parent="b3643d0f92e32" root="b3643d0f92e32" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="584" wordCount="92" paraCount="1" cursorPos="16" />
<meta expanded="no" heading="H2" charCount="600" wordCount="92" paraCount="1" cursorPos="931" />
<name status="s92a87b" import="i613591" active="yes">Prologue</name>
</item>
<item handle="db7e733775d4d" parent="b3643d0f92e32" root="b3643d0f92e32" order="3" type="FILE" class="NOVEL" layout="DOCUMENT">
@@ -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": {
@@ -1,13 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<office:document xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:meta>
<meta:creation-date>2024-03-14T23:26:28</meta:creation-date>
<meta:generator>novelWriter/2.4a2</meta:generator>
<meta:creation-date>2024-04-24T18:30:38</meta:creation-date>
<meta:generator>novelWriter/2.5a2</meta:generator>
<meta:initial-creator>Jane Smith</meta:initial-creator>
<meta:editing-cycles>1234</meta:editing-cycles>
<meta:editing-duration>P42DT12H34M56S</meta:editing-duration>
<dc:title>Test Project</dc:title>
<dc:date>2024-03-14T23:26:28</dc:date>
<dc:date>2024-04-24T18:30:38</dc:date>
<dc:creator>Jane Smith</dc:creator>
</office:meta>
<office:font-face-decls>
@@ -31,7 +31,7 @@
<style:text-properties style:font-name="Liberation Serif" fo:font-family="'Liberation Serif'" fo:font-size="12pt" />
</style:style>
<style:style style:name="First_20_line_20_indent" style:family="paragraph" style:display-name="First line indent" style:parent-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-indent="0.499cm" />
<style:paragraph-properties fo:text-indent="0.593cm" />
</style:style>
<style:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.247cm" fo:line-height="115%" />
@@ -64,10 +64,14 @@
<style:style style:name="Header" style:family="paragraph" style:display-name="Header" style:parent-style-name="Header_20_and_20_Footer">
<style:paragraph-properties fo:text-align="right" />
</style:style>
<style:style style:name="Footnote" style:family="paragraph" style:display-name="Footnote" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:margin-bottom="0.198cm" fo:margin-left="0.600cm" fo:text-indent="-0.600cm" />
<style:text-properties fo:font-size="10pt" />
</style:style>
</office:styles>
<office:automatic-styles>
<style:page-layout style:name="PM1">
<style:page-layout-properties fo:page-width="14.800cm" fo:page-height="21.000cm" fo:margin-top="2.000cm" fo:margin-bottom="1.800cm" fo:margin-left="1.700cm" fo:margin-right="1.500cm" fo:print-orientation="portrait" />
<style:page-layout-properties fo:page-width="14.800cm" fo:page-height="21.000cm" fo:margin-top="2.000cm" fo:margin-bottom="1.800cm" fo:margin-left="1.700cm" fo:margin-right="1.500cm" />
<style:header-style>
<style:header-footer-properties fo:min-height="0.600cm" fo:margin-left="0.000cm" fo:margin-right="0.000cm" fo:margin-bottom="0.500cm" />
</style:header-style>
@@ -21,7 +21,7 @@
<style:text-properties style:font-name="Liberation Serif" fo:font-family="'Liberation Serif'" fo:font-size="12pt" />
</style:style>
<style:style style:name="First_20_line_20_indent" style:family="paragraph" style:display-name="First line indent" style:parent-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-indent="0.499cm" />
<style:paragraph-properties fo:text-indent="0.593cm" />
</style:style>
<style:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.247cm" fo:line-height="115%" />
@@ -54,10 +54,14 @@
<style:style style:name="Header" style:family="paragraph" style:display-name="Header" style:parent-style-name="Header_20_and_20_Footer">
<style:paragraph-properties fo:text-align="right" />
</style:style>
<style:style style:name="Footnote" style:family="paragraph" style:display-name="Footnote" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:margin-bottom="0.198cm" fo:margin-left="0.600cm" fo:text-indent="-0.600cm" />
<style:text-properties fo:font-size="10pt" />
</style:style>
</office:styles>
<office:automatic-styles>
<style:page-layout style:name="PM1">
<style:page-layout-properties fo:page-width="21.0cm" fo:page-height="29.7cm" fo:margin-top="2.000cm" fo:margin-bottom="2.000cm" fo:margin-left="2.000cm" fo:margin-right="2.000cm" fo:print-orientation="portrait" />
<style:page-layout-properties fo:page-width="21.0cm" fo:page-height="29.7cm" fo:margin-top="2.000cm" fo:margin-bottom="2.000cm" fo:margin-left="2.000cm" fo:margin-right="2.000cm" />
<style:header-style>
<style:header-footer-properties fo:min-height="0.600cm" fo:margin-left="0.000cm" fo:margin-right="0.000cm" fo:margin-bottom="0.500cm" />
</style:header-style>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:31:18">
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="3" timeStamp="2024-04-14 23:46:11">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="3">
<name>New Project</name>
<author>Jane Doe</author>
@@ -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)
@@ -31,7 +31,7 @@ mark {background: rgb(255, 255, 166);}
<p>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.</p>
<h1 style='page-break-before: always;'>Prologue</h1>
<p class='synopsis'><strong>Synopsis:</strong> Explanation from the lipsum.com website.</p>
<p><em>Lorem Ipsum</em> 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.</p>
<p><em>Lorem Ipsum</em> is simply dummy text<sup><a href='#footnote_1'>1</a></sup> 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.</p>
<h1 class='title' style='text-align: center; page-break-before: always;'>Title: Act One</h1>
<p style='text-align: center;'>“Fusce maximus felis libero”</p>
<h1 style='page-break-before: always;'>Chapter: Chapter One</h1>
@@ -121,6 +121,10 @@ mark {background: rgb(255, 255, 166);}
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h3>Footnotes</h3>
<ol>
<li id='footnote_1'><p><em>Lorem ipsum</em> 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)</p></li>
</ol>
</article>
</body>
</html>
@@ -2,8 +2,8 @@
"meta": {
"projectName": "Lorem Ipsum",
"novelAuthor": "lipsum.com",
"buildTime": 1710799449,
"buildTimeStr": "2024-03-18 23:04:09"
"buildTime": 1714229171,
"buildTimeStr": "2024-04-27 16:46:11"
},
"text": {
"css": [
@@ -37,7 +37,7 @@
[
"<h1 style='page-break-before: always;'>Prologue</h1>",
"<p class='synopsis'><strong>Synopsis:</strong> Explanation from the lipsum.com website.</p>",
"<p><em>Lorem Ipsum</em> 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.</p>"
"<p><em>Lorem Ipsum</em> is simply dummy text<sup><a href='#footnote_1'>1</a></sup> 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.</p>"
],
[
"<h1 class='title' style='text-align: center; page-break-before: always;'>Title: Act One</h1>",
@@ -157,6 +157,12 @@
"<p>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.</p>",
"<p>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.</p>",
"<p>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.</p>"
],
[
"<h3>Footnotes</h3>",
"<ol>",
"<li id='footnote_1'><p><em>Lorem ipsum</em> 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)</p></li>",
"</ol>"
]
]
}
@@ -2,8 +2,8 @@
"meta": {
"projectName": "Lorem Ipsum",
"novelAuthor": "lipsum.com",
"buildTime": 1711014280,
"buildTimeStr": "2024-03-21 10:44:40"
"buildTime": 1714229171,
"buildTimeStr": "2024-04-27 16:46:11"
},
"text": {
"nwd": [
@@ -29,7 +29,9 @@
"",
"% 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)"
],
[
"# Act One",
@@ -17,7 +17,9 @@ 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[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)
# Act One
@@ -1,13 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<office:document xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:meta>
<meta:creation-date>2024-03-14T23:42:49</meta:creation-date>
<meta:generator>novelWriter/2.4a2</meta:generator>
<meta:creation-date>2024-04-27T16:43:44</meta:creation-date>
<meta:generator>novelWriter/2.5a2</meta:generator>
<meta:initial-creator>lipsum.com</meta:initial-creator>
<meta:editing-cycles>44</meta:editing-cycles>
<meta:editing-duration>P0DT0H33M59S</meta:editing-duration>
<meta:editing-cycles>45</meta:editing-cycles>
<meta:editing-duration>P0DT0H36M8S</meta:editing-duration>
<dc:title>Lorem Ipsum</dc:title>
<dc:date>2024-03-14T23:42:49</dc:date>
<dc:date>2024-04-27T16:43:44</dc:date>
<dc:creator>lipsum.com</dc:creator>
</office:meta>
<office:font-face-decls>
@@ -31,7 +31,7 @@
<style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-size="12pt" />
</style:style>
<style:style style:name="First_20_line_20_indent" style:family="paragraph" style:display-name="First line indent" style:parent-style-name="Text_20_body" style:class="text">
<style:paragraph-properties fo:text-indent="0.499cm" />
<style:paragraph-properties fo:text-indent="0.593cm" />
</style:style>
<style:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.322cm" fo:line-height="150%" />
@@ -64,10 +64,14 @@
<style:style style:name="Header" style:family="paragraph" style:display-name="Header" style:parent-style-name="Header_20_and_20_Footer">
<style:paragraph-properties fo:text-align="right" />
</style:style>
<style:style style:name="Footnote" style:family="paragraph" style:display-name="Footnote" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:margin-bottom="0.198cm" fo:margin-left="0.600cm" fo:text-indent="-0.600cm" />
<style:text-properties fo:font-size="10pt" />
</style:style>
</office:styles>
<office:automatic-styles>
<style:page-layout style:name="PM1">
<style:page-layout-properties fo:page-width="21.000cm" fo:page-height="29.700cm" fo:margin-top="2.000cm" fo:margin-bottom="2.000cm" fo:margin-left="2.000cm" fo:margin-right="2.000cm" fo:print-orientation="portrait" />
<style:page-layout-properties fo:page-width="21.000cm" fo:page-height="29.700cm" fo:margin-top="2.000cm" fo:margin-bottom="2.000cm" fo:margin-left="2.000cm" fo:margin-right="2.000cm" />
<style:header-style>
<style:header-footer-properties fo:min-height="0.600cm" fo:margin-left="0.000cm" fo:margin-right="0.000cm" fo:margin-bottom="0.500cm" />
</style:header-style>
@@ -121,7 +125,12 @@
<text:p text:style-name="First_20_line_20_indent">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.</text:p>
<text:h text:style-name="P3" text:outline-level="2">Prologue</text:h>
<text:p text:style-name="Text_20_Meta"><text:span text:style-name="T1">Synopsis:</text:span> Explanation from the lipsum.com website.</text:p>
<text:p text:style-name="Text_20_body"><text:span text:style-name="T2">Lorem Ipsum</text:span> 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.</text:p>
<text:p text:style-name="Text_20_body"><text:span text:style-name="T2">Lorem Ipsum</text:span> is simply dummy text<text:note text:id="ftn1" text:note-class="footnote">
<text:note-citation>1</text:note-citation>
<text:note-body>
<text:p text:style-name="Footnote"><text:span text:style-name="T2">Lorem ipsum</text:span> 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)</text:p>
</text:note-body>
</text:note> 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.</text:p>
<text:h text:style-name="P4" text:outline-level="1">Title: Act One</text:h>
<text:p text:style-name="P1">“Fusce maximus felis libero”</text:p>
<text:h text:style-name="P3" text:outline-level="2">Chapter: Chapter One</text:h>
@@ -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)
+50 -10
View File
@@ -21,26 +21,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import time
import pytest
from pathlib import Path
from xml.etree import ElementTree as ET
from tools import writeFile
from mocked import causeOSError
import pytest
from PyQt5.QtGui import QColor, QDesktopServices
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QColor, QDesktopServices
from novelwriter.common import (
checkBool, checkFloat, checkInt, checkIntTuple, checkPath, checkString,
checkStringNone, checkUuid, cssCol, formatFileFilter, formatInt,
formatTime, formatTimeStamp, formatVersion, fuzzyTime, getFileSize,
hexToInt, isHandle, isItemClass, isItemLayout, isItemType, isTitleTag,
jsonEncode, makeFileNameSafe, minmax, numberToRoman, NWConfigParser,
openExternalPath, readTextFile, simplified, transferCase, xmlIndent, yesNo
NWConfigParser, checkBool, checkFloat, checkInt, checkIntTuple, checkPath,
checkString, checkStringNone, checkUuid, cssCol, elide, formatFileFilter,
formatInt, formatTime, formatTimeStamp, formatVersion, fuzzyTime,
getFileSize, hexToInt, isHandle, isItemClass, isItemLayout, isItemType,
isListInstance, isTitleTag, jsonEncode, makeFileNameSafe, minmax,
numberToRoman, openExternalPath, readTextFile, simplified, transferCase,
xmlIndent, yesNo
)
from tests.mocked import causeOSError
from tests.tools import writeFile
@pytest.mark.base
def testBaseCommon_checkStringNone():
@@ -272,6 +274,24 @@ def testBaseCommon_isItemLayout():
# END Test testBaseCommon_isItemLayout
@pytest.mark.base
def testBaseCommon_isListInstance():
"""Test the isListInstance function."""
# String
assert isListInstance("stuff", str) is False
assert isListInstance(["stuff"], str) is True
# Int
assert isListInstance(1, int) is False
assert isListInstance([1], int) is True
# Mixed
assert isListInstance([1], str) is False
assert isListInstance(["stuff"], int) is False
# END Test testBaseCommon_isListInstance
@pytest.mark.base
def testBaseCommon_hexToInt():
"""Test the hexToInt function."""
@@ -368,6 +388,26 @@ def testBaseCommon_simplified():
# END Test testBaseCommon_simplified
@pytest.mark.base
def testBaseCommon_elide():
"""Test the elide function."""
assert elide("Hello World!", 12) == "Hello World!"
assert elide("Hello World!", 11) == "Hello W ..."
assert elide("Hello World!", 10) == "Hello ..."
assert elide("Hello World!", 9) == "Hello ..."
assert elide("Hello World!", 8) == "Hell ..."
assert elide("Hello World!", 7) == "Hel ..."
assert elide("Hello World!", 6) == "He ..."
assert elide("Hello World!", 5) == "H ..."
assert elide("Hello World!", 4) == " ..."
assert elide("Hello World!", 3) == " ..."
assert elide("Hello World!", 2) == " ..."
assert elide("Hello World!", 1) == " ..."
assert elide("Hello World!", 0) == " ..."
# END Test testBaseCommon_elide
@pytest.mark.base
def testBaseCommon_yesNo():
"""Test the yesNo function."""
+8 -7
View File
@@ -20,16 +20,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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"))
+170 -36
View File
@@ -21,19 +21,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import json
import pytest
from shutil import copyfile
from tools import C, buildTestProject, cmpFiles, writeFile
from mocked import causeException
import pytest
from novelwriter import SHARED
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
from novelwriter.constants import nwFiles
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, _checkModKey, processComment
from novelwriter.core.item import NWItem
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, processComment
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
@pytest.mark.core
@@ -122,12 +123,14 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert cmpFiles(testFile, compFile)
# Write an empty index file and load it
writeFile(projFile, "{}")
projFile.write_text("{}", encoding="utf-8")
assert index.loadIndex() is False
assert index.indexBroken is True
# Write an index file that passes loading, but is still empty
writeFile(projFile, '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}')
projFile.write_text(
'{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}', encoding="utf-8"
)
assert index.loadIndex() is True
assert index.indexBroken is False
@@ -306,7 +309,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
"""Check the index text scanner."""
project = NWProject()
mockRnd.reset()
@@ -376,12 +379,14 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
"@char: Jane\n\n"
"% this is a comment\n\n"
"This is a story about Jane Smith.\n\n"
"Well, not really.\n"
"Well, not really.[footnote:key]\n\n"
"%Footnote.key: Footnote text.\n\n"
))
assert index._tagsIndex.tagHandle("Jane") == cHandle
assert index._tagsIndex.tagHeading("Jane") == "T0001"
assert index._tagsIndex.tagClass("Jane") == "CHARACTER"
assert index.getItemHeading(nHandle, "T0001").title == "Hello World!" # type: ignore
assert index._itemIndex[nHandle].noteKeys("footnotes") == {"key"} # type: ignore
# Title Indexing
# ==============
@@ -548,6 +553,45 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
# END Test testCoreIndex_ScanText
@pytest.mark.core
def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd):
"""Check the index comment key generator."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
index = project.index
nKeys = 1000
# Generate footnote keys
keys = set()
for _ in range(nKeys):
key = index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE)
assert key not in keys
assert key != "err"
keys.add(key)
assert len(keys) == nKeys
# Generate comment keys
keys = set()
for _ in range(nKeys):
key = index.newCommentKey(C.hSceneDoc, nwComment.COMMENT)
assert key not in keys
keys.add(key)
assert len(keys) == nKeys
# Induce collision
with monkeypatch.context() as mp:
mp.setattr("random.choices", lambda *a, **k: "aaaa")
assert index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE) == "faaaa"
assert index.newCommentKey(C.hSceneDoc, nwComment.FOOTNOTE) == "err"
# Check invalid comment style
assert index.newCommentKey(C.hSceneDoc, None) == "err" # type: ignore
# END Test testCoreIndex_CommentKeys
@pytest.mark.core
def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
"""Check the index data extraction functions."""
@@ -1249,12 +1293,14 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
itemIndex.clear()
# Data must be dictionary
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData("stuff") # type: ignore
assert str(exc.value) == "itemIndex is not a dict"
# Keys must be valid handles
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({"stuff": "more stuff"})
assert str(exc.value) == "itemIndex keys must be handles"
# Unknown keys should be skipped
itemIndex.unpackData({C.hInvalid: {}})
@@ -1266,8 +1312,9 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
assert itemIndex[nHandle].handle == nHandle # type: ignore
# Title tags must be valid
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}})
assert str(exc.value) == "The itemIndex contains an invalid title key"
# Reference without a heading should be rejected
itemIndex.unpackData({
@@ -1281,37 +1328,66 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
itemIndex.clear()
# Tag keys must be strings
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {1234: "@pov"}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "itemIndex reference key must be a string"
# Type must be strings
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": []}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "itemIndex reference type must be a string"
# Types must be valid
with pytest.raises(ValueError):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char,@stuff"}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "The itemIndex contains an invalid reference type"
# Note type must be valid
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"stuff": [], "comments": []},
}
})
assert str(exc.value) == "The notes style is invalid"
# Note keys must be all strings
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"footnotes": ["fkey", 1], "comments": []},
}
})
assert str(exc.value) == "The notes keys must be a list of strings"
# This should pass
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"footnotes": ["fkey"], "comments": ["ckey"]},
}
})
@@ -1319,29 +1395,87 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
@pytest.mark.core
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)
def testCoreIndex_checkModKey():
"""Test the _checkModKey function."""
# Check Requirements
# 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)
assert _checkModKey("synopsis", "") is True
assert _checkModKey("synopsis", "a") is False
# 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)
# 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."""
# 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)
# 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.a: Hi") == (nwComment.PLAIN, "", "short.a: Hi", 0, 0)
# 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.a: Hi") == (nwComment.NOTE, "a", "Hi", 6, 8)
# 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.PLAIN, "", "note. term : Hi", 0, 0)
assert processComment("% note . term : Hi") == (nwComment.PLAIN, "", "note . term : Hi", 0, 0)
# END Test testCoreIndex_processComment
+14 -13
View File
@@ -21,21 +21,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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"
+83 -39
View File
@@ -20,12 +20,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import json
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
@@ -225,6 +225,22 @@ def testCoreToHtml_ConvertParagraphs(mockGUI):
"<a href='#tag_Bod'>Bod</a>, <a href='#tag_Jane'>Jane</a></p>\n"
)
# Tags
html._text = "@tag: Bod\n"
html.tokenizeText()
html.doConvert()
assert html.result == (
"<p class='meta meta-tag'><span class='tags'>Tag:</span> <a name='tag_Bod'>Bod</a></p>\n"
)
html._text = "@tag: Bod | Nobody Owens\n"
html.tokenizeText()
html.doConvert()
assert html.result == (
"<p class='meta meta-tag'><span class='tags'>Tag:</span> <a name='tag_Bod'>Bod</a> "
"| <span class='optional'>Nobody Owens</a></p>\n"
)
# Multiple Keywords
html._isFirst = False
html.setKeywords(True)
@@ -241,6 +257,30 @@ def testCoreToHtml_ConvertParagraphs(mockGUI):
"<span class='tags'>Locations:</span> <a href='#tag_Europe'>Europe</a></p>\n"
)
# Footnotes
# =========
html._text = (
"Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n"
"%footnote.fa: Footnote text A.\n\n"
)
html.tokenizeText()
html.doConvert()
assert html.result == (
"<p>Text with one<sup><a href='#footnote_1'>1</a></sup> "
"or two<sup>ERR</sup> footnotes.</p>\n"
)
html.appendFootnotes()
assert html.result == (
"<p>Text with one<sup><a href='#footnote_1'>1</a></sup> "
"or two<sup>ERR</sup> footnotes.</p>\n"
"<h3>Footnotes</h3>\n"
"<ol>\n"
"<li id='footnote_1'><p>Footnote text A.</p></li>\n"
"</ol>\n"
)
# Preview Mode
# ============
@@ -453,7 +493,7 @@ def testCoreToHtml_SpecialCases(mockGUI):
html.doConvert()
assert html.result == (
"<p class='comment'>"
"<strong>Comment:</strong> Test &gt; text _&lt;**bold**&gt;_ and more."
"<strong>Comment:</strong> Test &gt; text <em>&lt;<strong>bold</strong>&gt;</em> and more."
"</p>\n"
)
@@ -480,7 +520,7 @@ def testCoreToHtml_SpecialCases(mockGUI):
@pytest.mark.core
def testCoreToHtml_Complex(mockGUI, fncPath):
def testCoreToHtml_Save(mockGUI, fncPath):
"""Test the save method of the ToHtml class."""
project = NWProject()
html = ToHtml(project)
@@ -498,36 +538,28 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
"### Scene 2\n\nThe text of scene two.\n",
"#### A Section\n\n\tMore text in scene two.\n",
]
resText = [
(
"<h1 class='title' style='text-align: center;'>My Novel</h1>\n"
"<p><strong>By Jane Doh</strong></p>\n"
),
(
"<h1 style='page-break-before: always;'>Chapter 1</h1>\n"
"<p>The text of chapter one.</p>\n"
),
(
"<h2>Scene 1</h2>\n"
"<p>The text of scene one.</p>\n"
),
(
"<h3>A Section</h3>\n"
"<p>More text in scene one.</p>\n"
),
(
"<h1 style='page-break-before: always;'>Chapter 2</h1>\n"
"<p>The text of chapter two.</p>\n"
),
(
"<h2>Scene 2</h2>\n"
"<p>The text of scene two.</p>\n"
),
(
"<h3>A Section</h3>\n"
"<p>\tMore text in scene two.</p>\n"
),
]
resText = [(
"<h1 class='title' style='text-align: center;'>My Novel</h1>\n"
"<p><strong>By Jane Doh</strong></p>\n"
), (
"<h1 style='page-break-before: always;'>Chapter 1</h1>\n"
"<p>The text of chapter one.</p>\n"
), (
"<h2>Scene 1</h2>\n"
"<p>The text of scene one.</p>\n"
), (
"<h3>A Section</h3>\n"
"<p>More text in scene one.</p>\n"
), (
"<h1 style='page-break-before: always;'>Chapter 2</h1>\n"
"<p>The text of chapter two.</p>\n"
), (
"<h2>Scene 2</h2>\n"
"<p>The text of scene two.</p>\n"
), (
"<h3>A Section</h3>\n"
"<p>\tMore text in scene two.</p>\n"
)]
for i in range(len(docText)):
html._text = docText[i]
@@ -541,9 +573,10 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
html.replaceTabs(nSpaces=2, spaceChar="&nbsp;")
resText[6] = "<h3>A Section</h3>\n<p>&nbsp;&nbsp;More text in scene two.</p>\n"
# Check File
# ==========
# Check Files
# ===========
# HTML
hStyle = html.getStyleSheet()
htmlDoc = (
"<!DOCTYPE html>\n"
@@ -568,9 +601,20 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
saveFile = fncPath / "outFile.htm"
html.saveHtml5(saveFile)
assert readFile(saveFile) == htmlDoc
assert saveFile.read_text(encoding="utf-8") == htmlDoc
# END Test testCoreToHtml_Complex
# JSON + HTML
saveFile = fncPath / "outFile.json"
html.saveHtmlJson(saveFile)
data = json.loads(saveFile.read_text(encoding="utf-8"))
assert data["meta"]["projectName"] == ""
assert data["meta"]["novelAuthor"] == ""
assert data["meta"]["buildTime"] > 0
assert data["meta"]["buildTimeStr"] != ""
assert data["text"]["css"] == hStyle
assert len(data["text"]["html"]) == len(resText)
# END Test testCoreToHtml_Save
@pytest.mark.core
+40 -35
View File
@@ -21,14 +21,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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):
@@ -869,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
# ==========
@@ -900,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
@@ -946,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
@@ -955,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
@@ -998,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
),
@@ -1014,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
),
@@ -1030,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
),
@@ -1048,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
),
@@ -1064,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
),
@@ -2137,7 +2142,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")
+30 -7
View File
@@ -22,10 +22,8 @@ from __future__ import annotations
import pytest
from tools import readFile
from novelwriter.core.tomd import ToMarkdown
from novelwriter.core.project import NWProject
from novelwriter.core.tomd import ToMarkdown
@pytest.mark.core
@@ -134,6 +132,13 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI):
toMD.doConvert()
assert toMD.result == "Line one \nLine two \nLine three\n\n"
# Text wo/Hard Break
toMD._text = "Line one \nLine two \nLine three\n"
toMD.setPreserveBreaks(False)
toMD.tokenizeText()
toMD.doConvert()
assert toMD.result == "Line one Line two Line three\n\n"
# Synopsis, Short
toMD._text = "%synopsis: The synopsis ...\n"
toMD.tokenizeText()
@@ -188,6 +193,22 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI):
"**Locations:** Europe\n\n"
)
# Footnotes
toMD._text = (
"Text with one[footnote:fa] or two[footnote:fb] footnotes.\n\n"
"%footnote.fa: Footnote text A.\n\n"
)
toMD.tokenizeText()
toMD.doConvert()
assert toMD.result == "Text with one[1] or two[ERR] footnotes.\n\n"
toMD.appendFootnotes()
assert toMD.result == (
"Text with one[1] or two[ERR] footnotes.\n\n"
"### Footnotes\n\n"
"1. Footnote text A.\n\n"
)
# END Test testCoreToMarkdown_ConvertParagraphs
@@ -233,17 +254,18 @@ def testCoreToMarkdown_ConvertDirect(mockGUI):
@pytest.mark.core
def testCoreToMarkdown_Complex(mockGUI, fncPath):
def testCoreToMarkdown_Save(mockGUI, fncPath):
"""Test the save method of the ToMarkdown class."""
project = NWProject()
toMD = ToMarkdown(project)
toMD.setKeepMarkdown(True)
toMD._isNovel = True
# Build Project
# =============
docText = [
"# My Novel\n**By Jane Doh**\n",
"# My Novel\n\n**By Jane Doh**\n",
"## Chapter 1\n\nThe text of chapter one.\n",
"### Scene 1\n\nThe text of scene one.\n",
"#### A Section\n\nMore text in scene one.\n",
@@ -273,15 +295,16 @@ def testCoreToMarkdown_Complex(mockGUI, fncPath):
toMD.replaceTabs(nSpaces=4, spaceChar=" ")
resText[6] = "#### A Section\n\n More text in scene two.\n\n"
assert toMD.allMarkdown == resText
# Check File
# ==========
saveFile = fncPath / "outFile.md"
toMD.saveMarkdown(saveFile)
assert readFile(saveFile) == "".join(resText)
assert saveFile.read_text(encoding="utf-8") == "".join(resText)
# END Test testCoreToHtml_Complex
# END Test testCoreToMarkdown_Save
@pytest.mark.core
+85 -40
View File
@@ -20,18 +20,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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) == (
'<office:text>'
'<text:p text:style-name="Standard" />'
'</office:text>'
)
# 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) == (
'<office:text>'
'<text:p text:style-name="Standard">Hello World</text:p>'
'</office:text>'
)
# 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) == (
'<office:text>'
'<text:h text:style-name="Standard">Hello World</text:h>'
'</office:text>'
)
# 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) == (
'<office:text>'
'<text:h text:style-name="Standard" text:outline-level="1">Hello World</text:h>'
'</office:text>'
)
# 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) == (
'<office:text>'
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">bold</text:span> '
'word</text:p>'
@@ -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) == (
'<office:text>'
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">few</text:span> '
'words</text:p>'
'</office:text>'
)
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) == (
'<office:text>'
'<text:p text:style-name="Standard">A '
'<text:span text:style-name="T1">bold word</text:span></text:p>'
@@ -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) == (
'<office:text>'
'<text:p text:style-name="Standard">Hello<text:line-break /><text:tab />World</text:p>'
'</office:text>'
@@ -619,6 +621,43 @@ def testCoreToOdt_ConvertParagraphs(mockGUI):
'</office:text>'
)
# Footnotes
odt._text = (
"Text with one[footnote:fa], **two**[footnote:fd], "
"or three[footnote:fb] footnotes.[footnote:fe]\n\n"
"%footnote.fa: Footnote text A.[footnote:fc]\n\n"
"%footnote.fc: This footnote is skipped.\n\n"
"%footnote.fd: Another footnote.\n\n"
"%footnote.fe: Again?\n\n"
)
odt.tokenizeText()
odt.initDocument()
odt.doConvert()
odt.closeDocument()
assert xmlToText(odt._xText) == (
'<office:text>'
'<text:p text:style-name="Text_20_body">Text with one'
'<text:note text:id="ftn1" text:note-class="footnote">'
'<text:note-citation>1</text:note-citation>'
'<text:note-body>'
'<text:p text:style-name="Footnote">Footnote text A.</text:p>'
'</text:note-body>'
'</text:note>, <text:span text:style-name="T9">two</text:span>'
'<text:note text:id="ftn2" text:note-class="footnote">'
'<text:note-citation>2</text:note-citation>'
'<text:note-body>'
'<text:p text:style-name="Footnote">Another footnote.</text:p>'
'</text:note-body>'
'</text:note>, or three footnotes.'
'<text:note text:id="ftn3" text:note-class="footnote">'
'<text:note-citation>3</text:note-citation>'
'<text:note-body>'
'<text:p text:style-name="Footnote">Again?</text:p>'
'</text:note-body>'
'</text:note></text:p>'
'</office:text>'
)
# Test for issue #1412
# ====================
# See: https://github.com/vkbo/novelWriter/issues/1412
@@ -835,22 +874,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
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -22,10 +22,9 @@ from __future__ import annotations
import pytest
from tools import C, writeFile, buildTestProject
from PyQt5.QtGui import QTextCursor, QTextBlock
from PyQt5.QtGui import QTextBlock, QTextCursor
from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox
from tools import C, buildTestProject, writeFile
from novelwriter import CONFIG, SHARED
from novelwriter.constants import nwKeyWords, nwUnicode
@@ -146,7 +145,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
# Check comment with no space before text
nwGUI.docEditor.setCursorPosition(54)
assert nwGUI.docEditor.insertText("%")
nwGUI.docEditor.insertText("%")
fmtStr = "%Pellentesque nec erat ut nulla posuere commodo."
assert nwGUI.docEditor.getText()[54:102] == fmtStr
@@ -435,14 +434,14 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd):
nwGUI.docEditor.clear()
# Test Faulty Inserts
assert nwGUI.docEditor.insertText("hello world")
nwGUI.docEditor.insertText("hello world")
assert nwGUI.docEditor.getText() == "hello world"
nwGUI.docEditor.clear()
assert nwGUI.docEditor.insertText(nwDocInsert.NO_INSERT) is False
nwGUI.docEditor.insertText(nwDocInsert.NO_INSERT)
assert nwGUI.docEditor.isEmpty
assert nwGUI.docEditor.insertText(None) is False
nwGUI.docEditor.insertText(None)
assert nwGUI.docEditor.isEmpty
# qtbot.stop()
+2 -2
View File
@@ -50,7 +50,7 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
search.searchText.setText("Lorem")
search.searchAction.activate(QAction.ActionEvent.Trigger)
assert search.searchResult.topLevelItemCount() == 14
assert totalCount() == 42
assert totalCount() == 43
firstDoc = search.searchResult.topLevelItem(0)
firstResult = firstDoc.child(0)
@@ -98,7 +98,7 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
search.toggleCase.setChecked(True)
search.searchAction.activate(QAction.ActionEvent.Trigger)
assert search.searchResult.topLevelItemCount() == 7
assert totalCount() == 17
assert totalCount() == 18
search.toggleCase.setChecked(False)
# Whole Words