Add footnotes (#1832)
This commit is contained in:
@@ -12,7 +12,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
testMac:
|
testMac:
|
||||||
runs-on: macos-latest
|
runs-on: macos-13
|
||||||
steps:
|
steps:
|
||||||
- name: Python Setup
|
- name: Python Setup
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Synopsis": "Synopsis",
|
"Synopsis": "Synopsis",
|
||||||
"Short Description": "Short Description",
|
"Short Description": "Short Description",
|
||||||
|
"Footnotes": "Footnotes",
|
||||||
"Comment": "Comment",
|
"Comment": "Comment",
|
||||||
"Notes": "Notes",
|
"Notes": "Notes",
|
||||||
"Tag": "Tag",
|
"Tag": "Tag",
|
||||||
|
|||||||
+21
-7
@@ -24,30 +24,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
import logging
|
import logging
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
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 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.parse import urljoin
|
||||||
from urllib.request import pathname2url
|
from urllib.request import pathname2url
|
||||||
|
|
||||||
from PyQt5.QtGui import QColor, QDesktopServices
|
|
||||||
from PyQt5.QtCore import QCoreApplication, QUrl
|
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.constants import nwConst, nwLabels, nwUnicode, trConst
|
||||||
|
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
|
||||||
|
from novelwriter.error import logException
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from typing import TypeGuard # Requires Python 3.10
|
from typing import TypeGuard # Requires Python 3.10
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_Type = TypeVar("_Type")
|
||||||
|
|
||||||
|
|
||||||
##
|
##
|
||||||
# Checker Functions
|
# Checker Functions
|
||||||
@@ -172,6 +174,11 @@ def isItemLayout(value: Any) -> TypeGuard[str]:
|
|||||||
return isinstance(value, str) and value in nwItemLayout.__members__
|
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:
|
def hexToInt(value: Any, default: int = 0) -> int:
|
||||||
"""Convert a hex string to an integer."""
|
"""Convert a hex string to an integer."""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -272,6 +279,13 @@ def simplified(text: str) -> str:
|
|||||||
return " ".join(str(text).strip().split())
|
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"]:
|
def yesNo(value: int | bool | None) -> Literal["yes", "no"]:
|
||||||
"""Convert a boolean evaluated variable to a yes or no."""
|
"""Convert a boolean evaluated variable to a yes or no."""
|
||||||
return "yes" if value else "no"
|
return "yes" if value else "no"
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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:
|
def trConst(text: str) -> str:
|
||||||
@@ -67,7 +69,7 @@ class nwRegEx:
|
|||||||
FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
|
FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||||
FMT_ST = 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_SC = r"(?i)(?<!\\)(\[[\/\!]?(?:i|b|s|u|m|sup|sub)\])"
|
||||||
FMT_SV = r"(?<!\\)(\[(?i)(?:fn|footnote):)(.+?)(?<!\\)(\])"
|
FMT_SV = r"(?<!\\)(\[(?i)(?:footnote):)(.+?)(?<!\\)(\])"
|
||||||
|
|
||||||
# END Class nwRegEx
|
# END Class nwRegEx
|
||||||
|
|
||||||
@@ -89,6 +91,13 @@ class nwShortcode:
|
|||||||
SUB_O = "[sub]"
|
SUB_O = "[sub]"
|
||||||
SUB_C = "[/sub]"
|
SUB_C = "[/sub]"
|
||||||
|
|
||||||
|
FOOTNOTE_B = "[footnote:"
|
||||||
|
|
||||||
|
COMMENT_STYLES = {
|
||||||
|
nwComment.FOOTNOTE: "[footnote:{0}]",
|
||||||
|
nwComment.COMMENT: "[comment:{0}]",
|
||||||
|
}
|
||||||
|
|
||||||
# END Class nwShortcode
|
# END Class nwShortcode
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ class NWBuildDocument:
|
|||||||
else:
|
else:
|
||||||
yield i, False
|
yield i, False
|
||||||
|
|
||||||
|
makeObj.appendFootnotes()
|
||||||
|
|
||||||
if not (self._build.getBool("html.preserveTabs") or self._preview):
|
if not (self._build.getBool("html.preserveTabs") or self._preview):
|
||||||
makeObj.replaceTabs()
|
makeObj.replaceTabs()
|
||||||
|
|
||||||
@@ -231,6 +233,8 @@ class NWBuildDocument:
|
|||||||
else:
|
else:
|
||||||
yield i, False
|
yield i, False
|
||||||
|
|
||||||
|
makeObj.appendFootnotes()
|
||||||
|
|
||||||
self._error = None
|
self._error = None
|
||||||
self._cache = makeObj
|
self._cache = makeObj
|
||||||
|
|
||||||
|
|||||||
+109
-26
@@ -29,17 +29,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
from time import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from pathlib import Path
|
|
||||||
from collections.abc import ItemsView, Iterable
|
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 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.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
|
from novelwriter.text.counting import standardCounter
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -48,7 +51,12 @@ if TYPE_CHECKING: # pragma: no cover
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class NWIndex:
|
||||||
@@ -301,9 +309,9 @@ class NWIndex:
|
|||||||
|
|
||||||
def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None:
|
def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None:
|
||||||
"""Scan an active document for meta data."""
|
"""Scan an active document for meta data."""
|
||||||
nTitle = 0 # Line Number of the previous title
|
nTitle = 0 # Line Number of the previous title
|
||||||
cTitle = TT_NONE # Tag of the current title
|
cTitle = TT_NONE # Tag of the current title
|
||||||
pTitle = TT_NONE # Tag of the previous title
|
pTitle = TT_NONE # Tag of the previous title
|
||||||
canSetHead = True # First heading has not yet been set
|
canSetHead = True # First heading has not yet been set
|
||||||
|
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
@@ -335,10 +343,11 @@ class NWIndex:
|
|||||||
self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags)
|
self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags)
|
||||||
|
|
||||||
elif line.startswith("%"):
|
elif line.startswith("%"):
|
||||||
if cTitle != TT_NONE:
|
cStyle, cKey, cText, _, _ = processComment(line)
|
||||||
cStyle, cText, _ = processComment(line)
|
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
|
||||||
if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT):
|
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
|
||||||
self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText)
|
elif cStyle == nwComment.FOOTNOTE:
|
||||||
|
self._itemIndex.addNoteKey(tHandle, "footnotes", cKey)
|
||||||
|
|
||||||
# Count words for remaining text after last heading
|
# Count words for remaining text after last heading
|
||||||
if pTitle != TT_NONE:
|
if pTitle != TT_NONE:
|
||||||
@@ -506,6 +515,14 @@ class NWIndex:
|
|||||||
name, _, display = text.partition("|")
|
name, _, display = text.partition("|")
|
||||||
return name.rstrip(), display.lstrip()
|
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
|
# Extract Data
|
||||||
##
|
##
|
||||||
@@ -790,7 +807,7 @@ class TagsIndex:
|
|||||||
|
|
||||||
for key, entry in data.items():
|
for key, entry in data.items():
|
||||||
if not isinstance(key, str):
|
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):
|
if not isinstance(entry, dict):
|
||||||
raise ValueError("tagsIndex entry is not a dict")
|
raise ValueError("tagsIndex entry is not a dict")
|
||||||
|
|
||||||
@@ -950,6 +967,25 @@ class ItemIndex:
|
|||||||
self._items[tHandle].addHeadingRef(sTitle, tagKeys, refType)
|
self._items[tHandle].addHeadingRef(sTitle, tagKeys, refType)
|
||||||
return
|
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
|
# Pack/Unpack
|
||||||
##
|
##
|
||||||
@@ -991,12 +1027,13 @@ class IndexItem:
|
|||||||
must be reset each time the item is re-indexed.
|
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:
|
def __init__(self, tHandle: str, nwItem: NWItem) -> None:
|
||||||
self._handle = tHandle
|
self._handle = tHandle
|
||||||
self._item = nwItem
|
self._item = nwItem
|
||||||
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
|
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
|
||||||
|
self._notes: dict[str, set[str]] = {}
|
||||||
self._count = 0
|
self._count = 0
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1064,6 +1101,13 @@ class IndexItem:
|
|||||||
self._headings[sTitle].addReference(tagKey, refType)
|
self._headings[sTitle].addReference(tagKey, refType)
|
||||||
return
|
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
|
# Data Methods
|
||||||
##
|
##
|
||||||
@@ -1085,6 +1129,10 @@ class IndexItem:
|
|||||||
self._count += 1
|
self._count += 1
|
||||||
return f"T{self._count:04d}"
|
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
|
# Pack/Unpack
|
||||||
##
|
##
|
||||||
@@ -1103,6 +1151,8 @@ class IndexItem:
|
|||||||
data["headings"] = heads
|
data["headings"] = heads
|
||||||
if refs:
|
if refs:
|
||||||
data["references"] = refs
|
data["references"] = refs
|
||||||
|
if self._notes:
|
||||||
|
data["notes"] = {style: list(keys) for style, keys in self._notes.items()}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -1116,6 +1166,14 @@ class IndexItem:
|
|||||||
tHeading.unpackData(hData)
|
tHeading.unpackData(hData)
|
||||||
tHeading.unpackReferences(references.get(sTitle, {}))
|
tHeading.unpackReferences(references.get(sTitle, {}))
|
||||||
self.addHeading(tHeading)
|
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
|
return
|
||||||
|
|
||||||
# END Class IndexItem
|
# END Class IndexItem
|
||||||
@@ -1302,18 +1360,43 @@ class IndexHeading:
|
|||||||
# Text Processing Functions
|
# Text Processing Functions
|
||||||
# =============================================================================================== #
|
# =============================================================================================== #
|
||||||
|
|
||||||
CLASSIFIERS = {
|
MODIFIERS = {
|
||||||
"short": nwComment.SHORT,
|
|
||||||
"synopsis": nwComment.SYNOPSIS,
|
"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]:
|
def _checkModKey(modifier: str, key: str) -> bool:
|
||||||
"""Extract comment style and text. Should only be called on text
|
"""Check if a modifier and key set are ok."""
|
||||||
starting with a %.
|
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()
|
if text[:2] == "%~":
|
||||||
classifier, _, content = check.partition(":")
|
return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0
|
||||||
if content and (clean := classifier.strip().lower()) in CLASSIFIERS:
|
|
||||||
return CLASSIFIERS[clean], content.strip(), text.find(":") + 1
|
check = text[1:].strip()
|
||||||
return nwComment.PLAIN, check, 0
|
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
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from PyQt5.QtCore import QPointF, Qt
|
from PyQt5.QtCore import QPointF, Qt
|
||||||
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF
|
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor, QPolygonF
|
||||||
@@ -75,7 +75,7 @@ class NWStatus:
|
|||||||
|
|
||||||
__slots__ = ("_store", "_default", "_prefix", "_height")
|
__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._store: dict[str, StatusEntry] = {}
|
||||||
self._default = None
|
self._default = None
|
||||||
self._prefix = prefix[:1]
|
self._prefix = prefix[:1]
|
||||||
|
|||||||
+87
-47
@@ -26,17 +26,53 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from time import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
|
||||||
from novelwriter import CONFIG
|
from novelwriter import CONFIG
|
||||||
from novelwriter.common import formatTimeStamp
|
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.project import NWProject
|
||||||
from novelwriter.core.tokenizer import Tokenizer, stripEscape
|
from novelwriter.core.tokenizer import T_Formats, Tokenizer, stripEscape
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class ToHtml(Tokenizer):
|
||||||
"""Core: HTML Document Writer
|
"""Core: HTML Document Writer
|
||||||
@@ -58,6 +94,7 @@ class ToHtml(Tokenizer):
|
|||||||
|
|
||||||
# Internals
|
# Internals
|
||||||
self._trMap = {}
|
self._trMap = {}
|
||||||
|
self._usedNotes: dict[str, int] = {}
|
||||||
self.setReplaceUnicode(False)
|
self.setReplaceUnicode(False)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -117,38 +154,9 @@ class ToHtml(Tokenizer):
|
|||||||
|
|
||||||
def doConvert(self) -> None:
|
def doConvert(self) -> None:
|
||||||
"""Convert the list of text tokens into an HTML document."""
|
"""Convert the list of text tokens into an HTML document."""
|
||||||
if self._genMode == self.M_PREVIEW:
|
self._result = ""
|
||||||
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>"
|
|
||||||
|
|
||||||
|
hTags = HTML4_TAGS if self._genMode == self.M_PREVIEW else HTML5_TAGS
|
||||||
if self._isNovel and self._genMode != self.M_PREVIEW:
|
if self._isNovel and self._genMode != self.M_PREVIEW:
|
||||||
# For story files, we bump the titles one level up
|
# For story files, we bump the titles one level up
|
||||||
h1Cl = " class='title'"
|
h1Cl = " class='title'"
|
||||||
@@ -163,12 +171,9 @@ class ToHtml(Tokenizer):
|
|||||||
h3 = "h3"
|
h3 = "h3"
|
||||||
h4 = "h4"
|
h4 = "h4"
|
||||||
|
|
||||||
self._result = ""
|
|
||||||
|
|
||||||
para = []
|
para = []
|
||||||
pStyle = None
|
|
||||||
lines = []
|
lines = []
|
||||||
|
pStyle = None
|
||||||
tHandle = self._handle
|
tHandle = self._handle
|
||||||
|
|
||||||
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
for tType, nHead, tText, tFormat, tStyle in self._tokens:
|
||||||
@@ -181,11 +186,11 @@ class ToHtml(Tokenizer):
|
|||||||
for c in tText:
|
for c in tText:
|
||||||
if c == "<":
|
if c == "<":
|
||||||
cText.append("<")
|
cText.append("<")
|
||||||
tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat]
|
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||||
i += 4
|
i += 4
|
||||||
elif c == ">":
|
elif c == ">":
|
||||||
cText.append(">")
|
cText.append(">")
|
||||||
tFormat = [[p + 3 if p > i else p, f] for p, f in tFormat]
|
tFormat = [(p + 3 if p > i else p, f, k) for p, f, k in tFormat]
|
||||||
i += 4
|
i += 4
|
||||||
else:
|
else:
|
||||||
cText.append(c)
|
cText.append(c)
|
||||||
@@ -275,21 +280,18 @@ class ToHtml(Tokenizer):
|
|||||||
lines.append(f"<p class='skip'{hStyle}> </p>\n")
|
lines.append(f"<p class='skip'{hStyle}> </p>\n")
|
||||||
|
|
||||||
elif tType == self.T_TEXT:
|
elif tType == self.T_TEXT:
|
||||||
tTemp = tText
|
|
||||||
if pStyle is None:
|
if pStyle is None:
|
||||||
pStyle = hStyle
|
pStyle = hStyle
|
||||||
for pos, fmt in reversed(tFormat):
|
para.append(self._formatText(tText, tFormat, hTags).rstrip())
|
||||||
tTemp = f"{tTemp[:pos]}{htmlTags[fmt]}{tTemp[pos:]}"
|
|
||||||
para.append(stripEscape(tTemp.rstrip()))
|
|
||||||
|
|
||||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
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:
|
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:
|
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:
|
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||||
tag, text = self._formatKeywords(tText)
|
tag, text = self._formatKeywords(tText)
|
||||||
@@ -302,6 +304,27 @@ class ToHtml(Tokenizer):
|
|||||||
|
|
||||||
return
|
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:
|
def saveHtml5(self, path: str | Path) -> None:
|
||||||
"""Save the data to an HTML file."""
|
"""Save the data to an HTML file."""
|
||||||
with open(path, mode="w", encoding="utf-8") as fObj:
|
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||||
@@ -453,6 +476,23 @@ class ToHtml(Tokenizer):
|
|||||||
# Internal Functions
|
# 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:
|
def _formatSynopsis(self, text: str, synopsis: bool) -> str:
|
||||||
"""Apply HTML formatting to synopsis."""
|
"""Apply HTML formatting to synopsis."""
|
||||||
if synopsis:
|
if synopsis:
|
||||||
|
|||||||
@@ -24,18 +24,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from time import time
|
|
||||||
from pathlib import Path
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
|
||||||
from PyQt5.QtCore import QCoreApplication, QRegularExpression
|
from PyQt5.QtCore import QCoreApplication, QRegularExpression
|
||||||
|
|
||||||
from novelwriter.common import formatTimeStamp, numberToRoman, checkInt
|
from novelwriter.common import checkInt, formatTimeStamp, numberToRoman
|
||||||
from novelwriter.constants import (
|
from novelwriter.constants import (
|
||||||
nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst
|
nwHeadFmt, nwKeyWords, nwLabels, nwRegEx, nwShortcode, nwUnicode, trConst
|
||||||
)
|
)
|
||||||
@@ -48,6 +48,9 @@ logger = logging.getLogger(__name__)
|
|||||||
ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""}
|
ESCAPES = {r"\*": "*", r"\~": "~", r"\_": "_", r"\[": "[", r"\]": "]", r"\ ": ""}
|
||||||
RX_ESC = re.compile("|".join([re.escape(k) for k in ESCAPES.keys()]), flags=re.DOTALL)
|
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:
|
def stripEscape(text: str) -> str:
|
||||||
"""Strip escaped Markdown characters from paragraph text."""
|
"""Strip escaped Markdown characters from paragraph text."""
|
||||||
@@ -80,6 +83,8 @@ class Tokenizer(ABC):
|
|||||||
FMT_SUP_E = 12 # End superscript
|
FMT_SUP_E = 12 # End superscript
|
||||||
FMT_SUB_B = 13 # Begin subscript
|
FMT_SUB_B = 13 # Begin subscript
|
||||||
FMT_SUB_E = 14 # End subscript
|
FMT_SUB_E = 14 # End subscript
|
||||||
|
FMT_FNOTE = 15 # Footnote marker
|
||||||
|
FMT_STRIP = 16 # Strip the format code
|
||||||
|
|
||||||
# Block Type
|
# Block Type
|
||||||
T_EMPTY = 1 # Empty line (new paragraph)
|
T_EMPTY = 1 # Empty line (new paragraph)
|
||||||
@@ -117,17 +122,19 @@ class Tokenizer(ABC):
|
|||||||
self._project = project
|
self._project = project
|
||||||
|
|
||||||
# Data Variables
|
# Data Variables
|
||||||
self._text = "" # The raw text to be tokenized
|
self._text = "" # The raw text to be tokenized
|
||||||
self._handle = None # The item handle currently being processed
|
self._handle = None # The item handle currently being processed
|
||||||
self._result = "" # The result of the last document
|
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
|
# Tokens and Meta Data (Per Document)
|
||||||
self._allMarkdown = [] # The result novelWriter markdown of all documents
|
self._tokens: list[tuple[int, int, str, T_Formats, int]] = []
|
||||||
|
self._footnotes: dict[str, T_Comment] = {}
|
||||||
|
|
||||||
# Processed Tokens and Meta Data
|
# Tokens and Meta Data (Per Instance)
|
||||||
self._tokens: list[tuple[int, int, str, list[tuple[int, int]], int]] = []
|
|
||||||
self._counts: dict[str, int] = {}
|
self._counts: dict[str, int] = {}
|
||||||
self._outline: dict[str, str] = {}
|
self._outline: dict[str, str] = {}
|
||||||
|
self._markdown: list[str] = []
|
||||||
|
|
||||||
# User Settings
|
# User Settings
|
||||||
self._textFont = "Serif" # Output text font
|
self._textFont = "Serif" # Output text font
|
||||||
@@ -135,6 +142,7 @@ class Tokenizer(ABC):
|
|||||||
self._textFixed = False # Fixed width text
|
self._textFixed = False # Fixed width text
|
||||||
self._lineHeight = 1.15 # Line height in units of em
|
self._lineHeight = 1.15 # Line height in units of em
|
||||||
self._blockIndent = 4.00 # Block indent 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._doJustify = False # Justify text
|
||||||
self._doBodyText = True # Include body text
|
self._doBodyText = True # Include body text
|
||||||
self._doSynopsis = False # Also process synopsis comments
|
self._doSynopsis = False # Also process synopsis comments
|
||||||
@@ -150,6 +158,7 @@ class Tokenizer(ABC):
|
|||||||
self._marginHead4 = (0.584, 0.500)
|
self._marginHead4 = (0.584, 0.500)
|
||||||
self._marginText = (0.000, 0.584)
|
self._marginText = (0.000, 0.584)
|
||||||
self._marginMeta = (0.000, 0.584)
|
self._marginMeta = (0.000, 0.584)
|
||||||
|
self._marginFoot = (1.417, 0.467)
|
||||||
|
|
||||||
# Title Formats
|
# Title Formats
|
||||||
self._fmtTitle = nwHeadFmt.TITLE # Formatting for titles
|
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.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,
|
nwShortcode.SUB_O: self.FMT_SUB_B, nwShortcode.SUB_C: self.FMT_SUB_E,
|
||||||
}
|
}
|
||||||
|
self._shortCodeVals = {
|
||||||
|
nwShortcode.FOOTNOTE_B: self.FMT_FNOTE,
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -220,7 +232,7 @@ class Tokenizer(ABC):
|
|||||||
@property
|
@property
|
||||||
def allMarkdown(self) -> list[str]:
|
def allMarkdown(self) -> list[str]:
|
||||||
"""The combined novelWriter Markdown text."""
|
"""The combined novelWriter Markdown text."""
|
||||||
return self._allMarkdown
|
return self._markdown
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def textStats(self) -> dict[str, int]:
|
def textStats(self) -> dict[str, int]:
|
||||||
@@ -387,7 +399,7 @@ class Tokenizer(ABC):
|
|||||||
|
|
||||||
def setKeepMarkdown(self, state: bool) -> None:
|
def setKeepMarkdown(self, state: bool) -> None:
|
||||||
"""Keep original markdown during build."""
|
"""Keep original markdown during build."""
|
||||||
self._keepMarkdown = state
|
self._keepMD = state
|
||||||
return
|
return
|
||||||
|
|
||||||
##
|
##
|
||||||
@@ -417,8 +429,8 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
self.T_TITLE, 1, title, [], textAlign
|
self.T_TITLE, 1, title, [], textAlign
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
self._allMarkdown.append(f"#! {title}\n\n")
|
self._markdown.append(f"#! {title}\n\n")
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -473,6 +485,7 @@ class Tokenizer(ABC):
|
|||||||
nHead = 0
|
nHead = 0
|
||||||
breakNext = False
|
breakNext = False
|
||||||
tmpMarkdown = []
|
tmpMarkdown = []
|
||||||
|
tHandle = self._handle or ""
|
||||||
for aLine in self._text.splitlines():
|
for aLine in self._text.splitlines():
|
||||||
sLine = aLine.strip().lower()
|
sLine = aLine.strip().lower()
|
||||||
|
|
||||||
@@ -481,7 +494,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
self.T_EMPTY, nHead, "", [], self.A_NONE
|
self.T_EMPTY, nHead, "", [], self.A_NONE
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append("\n")
|
tmpMarkdown.append("\n")
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@@ -533,24 +546,32 @@ class Tokenizer(ABC):
|
|||||||
if aLine.startswith("%~"):
|
if aLine.startswith("%~"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cStyle, cText, _ = processComment(aLine)
|
cStyle, cKey, cText, _, _ = processComment(aLine)
|
||||||
if cStyle == nwComment.SYNOPSIS:
|
if cStyle == nwComment.SYNOPSIS:
|
||||||
|
tLine, tFmt = self._extractFormats(cText)
|
||||||
self._tokens.append((
|
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")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
elif cStyle == nwComment.SHORT:
|
elif cStyle == nwComment.SHORT:
|
||||||
|
tLine, tFmt = self._extractFormats(cText)
|
||||||
self._tokens.append((
|
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")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
else:
|
else:
|
||||||
|
tLine, tFmt = self._extractFormats(cText)
|
||||||
self._tokens.append((
|
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")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
elif aLine.startswith("@"):
|
elif aLine.startswith("@"):
|
||||||
@@ -564,7 +585,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign
|
self.T_KEYWORD, nHead, aLine[1:].strip(), [], sAlign
|
||||||
))
|
))
|
||||||
if self._doKeywords and self._keepMarkdown:
|
if self._doKeywords and self._keepMD:
|
||||||
tmpMarkdown.append(f"{aLine}\n")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
elif aLine.startswith(("# ", "#! ")):
|
elif aLine.startswith(("# ", "#! ")):
|
||||||
@@ -600,7 +621,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
tType, nHead, tText, [], tStyle
|
tType, nHead, tText, [], tStyle
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append(f"{aLine}\n")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
elif aLine.startswith(("## ", "##! ")):
|
elif aLine.startswith(("## ", "##! ")):
|
||||||
@@ -635,7 +656,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
tType, nHead, tText, [], tStyle
|
tType, nHead, tText, [], tStyle
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append(f"{aLine}\n")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
elif aLine.startswith(("### ", "###! ")):
|
elif aLine.startswith(("### ", "###! ")):
|
||||||
@@ -676,7 +697,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
tType, nHead, tText, [], tStyle
|
tType, nHead, tText, [], tStyle
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append(f"{aLine}\n")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
elif aLine.startswith("#### "):
|
elif aLine.startswith("#### "):
|
||||||
@@ -706,7 +727,7 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
tType, nHead, tText, [], tStyle
|
tType, nHead, tText, [], tStyle
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append(f"{aLine}\n")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -750,11 +771,11 @@ class Tokenizer(ABC):
|
|||||||
sAlign |= self.A_IND_R
|
sAlign |= self.A_IND_R
|
||||||
|
|
||||||
# Process formats
|
# Process formats
|
||||||
tLine, fmtPos = self._extractFormats(aLine)
|
tLine, tFmt = self._extractFormats(aLine)
|
||||||
self._tokens.append((
|
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")
|
tmpMarkdown.append(f"{aLine}\n")
|
||||||
|
|
||||||
# If we have content, turn off the first page flag
|
# If we have content, turn off the first page flag
|
||||||
@@ -773,9 +794,9 @@ class Tokenizer(ABC):
|
|||||||
self._tokens.append((
|
self._tokens.append((
|
||||||
self.T_EMPTY, nHead, "", [], self.A_NONE
|
self.T_EMPTY, nHead, "", [], self.A_NONE
|
||||||
))
|
))
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
tmpMarkdown.append("\n")
|
tmpMarkdown.append("\n")
|
||||||
self._allMarkdown.append("".join(tmpMarkdown))
|
self._markdown.append("".join(tmpMarkdown))
|
||||||
|
|
||||||
# Second Pass
|
# Second Pass
|
||||||
# ===========
|
# ===========
|
||||||
@@ -797,7 +818,9 @@ class Tokenizer(ABC):
|
|||||||
aStyle |= self.A_Z_TOPMRG
|
aStyle |= self.A_Z_TOPMRG
|
||||||
if nToken[0] == self.T_KEYWORD:
|
if nToken[0] == self.T_KEYWORD:
|
||||||
aStyle |= self.A_Z_BTMMRG
|
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
|
return
|
||||||
|
|
||||||
@@ -935,7 +958,7 @@ class Tokenizer(ABC):
|
|||||||
def saveRawMarkdown(self, path: str | Path) -> None:
|
def saveRawMarkdown(self, path: str | Path) -> None:
|
||||||
"""Save the raw text to a plain text file."""
|
"""Save the raw text to a plain text file."""
|
||||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||||
for nwdPage in self._allMarkdown:
|
for nwdPage in self._markdown:
|
||||||
outFile.write(nwdPage)
|
outFile.write(nwdPage)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -950,7 +973,7 @@ class Tokenizer(ABC):
|
|||||||
"buildTimeStr": formatTimeStamp(timeStamp),
|
"buildTimeStr": formatTimeStamp(timeStamp),
|
||||||
},
|
},
|
||||||
"text": {
|
"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:
|
with open(path, mode="w", encoding="utf-8") as fObj:
|
||||||
@@ -961,9 +984,9 @@ class Tokenizer(ABC):
|
|||||||
# Internal Functions
|
# 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."""
|
"""Extract format markers from a text paragraph."""
|
||||||
temp = []
|
temp: list[tuple[int, int, int, str]] = []
|
||||||
|
|
||||||
# Match Markdown
|
# Match Markdown
|
||||||
for regEx, fmts in self._rxMarkdown:
|
for regEx, fmts in self._rxMarkdown:
|
||||||
@@ -971,7 +994,7 @@ class Tokenizer(ABC):
|
|||||||
while rxItt.hasNext():
|
while rxItt.hasNext():
|
||||||
rxMatch = rxItt.next()
|
rxMatch = rxItt.next()
|
||||||
temp.extend(
|
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
|
for n, fmt in enumerate(fmts) if fmt > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -979,20 +1002,34 @@ class Tokenizer(ABC):
|
|||||||
rxItt = self._rxShortCodes.globalMatch(text, 0)
|
rxItt = self._rxShortCodes.globalMatch(text, 0)
|
||||||
while rxItt.hasNext():
|
while rxItt.hasNext():
|
||||||
rxMatch = rxItt.next()
|
rxMatch = rxItt.next()
|
||||||
temp.append([
|
temp.append((
|
||||||
rxMatch.capturedStart(1),
|
rxMatch.capturedStart(1),
|
||||||
rxMatch.capturedLength(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
|
result = text
|
||||||
formats = []
|
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:
|
if fmt > 0:
|
||||||
result = result[:pos] + result[pos+n:]
|
result = result[:pos] + result[pos+n:]
|
||||||
formats = [(p-n, f) for p, f in formats]
|
formats = [(p-n, f, k) for p, f, k in formats]
|
||||||
formats.insert(0, (pos, fmt))
|
formats.insert(0, (pos, fmt, key))
|
||||||
|
|
||||||
return result, formats
|
return result, formats
|
||||||
|
|
||||||
|
|||||||
+89
-46
@@ -29,11 +29,50 @@ from pathlib import Path
|
|||||||
|
|
||||||
from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode
|
from novelwriter.constants import nwHeadFmt, nwLabels, nwUnicode
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
from novelwriter.core.tokenizer import Tokenizer
|
from novelwriter.core.tokenizer import T_Formats, Tokenizer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class ToMarkdown(Tokenizer):
|
||||||
"""Core: Markdown Document Writer
|
"""Core: Markdown Document Writer
|
||||||
|
|
||||||
@@ -50,6 +89,7 @@ class ToMarkdown(Tokenizer):
|
|||||||
self._genMode = self.M_STD
|
self._genMode = self.M_STD
|
||||||
self._fullMD: list[str] = []
|
self._fullMD: list[str] = []
|
||||||
self._preserveBreaks = True
|
self._preserveBreaks = True
|
||||||
|
self._usedNotes: dict[str, int] = {}
|
||||||
return
|
return
|
||||||
|
|
||||||
##
|
##
|
||||||
@@ -90,47 +130,15 @@ class ToMarkdown(Tokenizer):
|
|||||||
|
|
||||||
def doConvert(self) -> None:
|
def doConvert(self) -> None:
|
||||||
"""Convert the list of text tokens into a Markdown document."""
|
"""Convert the list of text tokens into a Markdown document."""
|
||||||
|
self._result = ""
|
||||||
|
|
||||||
if self._genMode == self.M_STD:
|
if self._genMode == self.M_STD:
|
||||||
# Standard Markdown
|
mTags = STD_MD
|
||||||
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: "",
|
|
||||||
}
|
|
||||||
cSkip = ""
|
cSkip = ""
|
||||||
else:
|
else:
|
||||||
# Extended Markdown
|
mTags = EXT_MD
|
||||||
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: "~",
|
|
||||||
}
|
|
||||||
cSkip = nwUnicode.U_MMSP
|
cSkip = nwUnicode.U_MMSP
|
||||||
|
|
||||||
self._result = ""
|
|
||||||
|
|
||||||
para = []
|
para = []
|
||||||
lines = []
|
lines = []
|
||||||
lineSep = " \n" if self._preserveBreaks else " "
|
lineSep = " \n" if self._preserveBreaks else " "
|
||||||
@@ -170,22 +178,19 @@ class ToMarkdown(Tokenizer):
|
|||||||
lines.append(f"{cSkip}\n\n")
|
lines.append(f"{cSkip}\n\n")
|
||||||
|
|
||||||
elif tType == self.T_TEXT:
|
elif tType == self.T_TEXT:
|
||||||
tTemp = tText
|
para.append(self._formatText(tText, tFormat, mTags).rstrip())
|
||||||
for pos, fmt in reversed(tFormat):
|
|
||||||
tTemp = f"{tTemp[:pos]}{mdTags[fmt]}{tTemp[pos:]}"
|
|
||||||
para.append(tTemp.rstrip())
|
|
||||||
|
|
||||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||||
label = self._localLookup("Synopsis")
|
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:
|
elif tType == self.T_SHORT and self._doSynopsis:
|
||||||
label = self._localLookup("Short Description")
|
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:
|
elif tType == self.T_COMMENT and self._doComments:
|
||||||
label = self._localLookup("Comment")
|
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:
|
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||||
lines.append(self._formatKeywords(tText, tStyle))
|
lines.append(self._formatKeywords(tText, tStyle))
|
||||||
@@ -195,6 +200,27 @@ class ToMarkdown(Tokenizer):
|
|||||||
|
|
||||||
return
|
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:
|
def saveMarkdown(self, path: str | Path) -> None:
|
||||||
"""Save the data to a plain text file."""
|
"""Save the data to a plain text file."""
|
||||||
with open(path, mode="w", encoding="utf-8") as outFile:
|
with open(path, mode="w", encoding="utf-8") as outFile:
|
||||||
@@ -206,14 +232,31 @@ class ToMarkdown(Tokenizer):
|
|||||||
"""Replace tabs with spaces."""
|
"""Replace tabs with spaces."""
|
||||||
spaces = spaceChar*nSpaces
|
spaces = spaceChar*nSpaces
|
||||||
self._fullMD = [p.replace("\t", spaces) for p in self._fullMD]
|
self._fullMD = [p.replace("\t", spaces) for p in self._fullMD]
|
||||||
if self._keepMarkdown:
|
if self._keepMD:
|
||||||
self._allMarkdown = [p.replace("\t", spaces) for p in self._allMarkdown]
|
self._markdown = [p.replace("\t", spaces) for p in self._markdown]
|
||||||
return
|
return
|
||||||
|
|
||||||
##
|
##
|
||||||
# Internal Functions
|
# 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:
|
def _formatKeywords(self, text: str, style: int) -> str:
|
||||||
"""Apply Markdown formatting to keywords."""
|
"""Apply Markdown formatting to keywords."""
|
||||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||||
|
|||||||
+106
-39
@@ -29,17 +29,17 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
from datetime import datetime
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from novelwriter import __version__
|
from novelwriter import __version__
|
||||||
from novelwriter.common import xmlIndent
|
from novelwriter.common import xmlIndent
|
||||||
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels
|
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels
|
||||||
from novelwriter.core.project import NWProject
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -130,6 +130,10 @@ class ToOdt(Tokenizer):
|
|||||||
self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles
|
self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles
|
||||||
self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text 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
|
self._errData = [] # List of errors encountered
|
||||||
|
|
||||||
# Properties
|
# Properties
|
||||||
@@ -151,6 +155,7 @@ class ToOdt(Tokenizer):
|
|||||||
self._fSizeHead4 = "14pt"
|
self._fSizeHead4 = "14pt"
|
||||||
self._fSizeHead = "14pt"
|
self._fSizeHead = "14pt"
|
||||||
self._fSizeText = "12pt"
|
self._fSizeText = "12pt"
|
||||||
|
self._fSizeFoot = "10pt"
|
||||||
self._fLineHeight = "115%"
|
self._fLineHeight = "115%"
|
||||||
self._fBlockIndent = "1.693cm"
|
self._fBlockIndent = "1.693cm"
|
||||||
self._fTextIndent = "0.499cm"
|
self._fTextIndent = "0.499cm"
|
||||||
@@ -177,6 +182,9 @@ class ToOdt(Tokenizer):
|
|||||||
self._mBotText = "0.247cm"
|
self._mBotText = "0.247cm"
|
||||||
self._mBotMeta = "0.106cm"
|
self._mBotMeta = "0.106cm"
|
||||||
|
|
||||||
|
self._mBotFoot = "0.106cm"
|
||||||
|
self._mLeftFoot = "0.600cm"
|
||||||
|
|
||||||
# Document Size and Margins
|
# Document Size and Margins
|
||||||
self._mDocWidth = "21.0cm"
|
self._mDocWidth = "21.0cm"
|
||||||
self._mDocHeight = "29.7cm"
|
self._mDocHeight = "29.7cm"
|
||||||
@@ -258,6 +266,7 @@ class ToOdt(Tokenizer):
|
|||||||
self._fSizeHead4 = f"{round(1.15 * self._textSize):d}pt"
|
self._fSizeHead4 = f"{round(1.15 * self._textSize):d}pt"
|
||||||
self._fSizeHead = 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._fSizeText = f"{self._textSize:d}pt"
|
||||||
|
self._fSizeFoot = f"{round(0.8*self._textSize):d}pt"
|
||||||
|
|
||||||
mScale = self._lineHeight/1.15
|
mScale = self._lineHeight/1.15
|
||||||
|
|
||||||
@@ -279,6 +288,9 @@ class ToOdt(Tokenizer):
|
|||||||
self._mBotText = self._emToCm(mScale * self._marginText[1])
|
self._mBotText = self._emToCm(mScale * self._marginText[1])
|
||||||
self._mBotMeta = self._emToCm(mScale * self._marginMeta[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:
|
if self._colourHead:
|
||||||
self._colHead12 = "#2a6099"
|
self._colHead12 = "#2a6099"
|
||||||
self._opaHead12 = "100%"
|
self._opaHead12 = "100%"
|
||||||
@@ -289,6 +301,7 @@ class ToOdt(Tokenizer):
|
|||||||
|
|
||||||
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
|
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
|
||||||
self._fBlockIndent = self._emToCm(self._blockIndent)
|
self._fBlockIndent = self._emToCm(self._blockIndent)
|
||||||
|
self._fTextIndent = self._emToCm(self._textIndent)
|
||||||
self._textAlign = "justify" if self._doJustify else "left"
|
self._textAlign = "justify" if self._doJustify else "left"
|
||||||
|
|
||||||
# Clear Errors
|
# Clear Errors
|
||||||
@@ -399,10 +412,11 @@ class ToOdt(Tokenizer):
|
|||||||
"""Convert the list of text tokens into XML elements."""
|
"""Convert the list of text tokens into XML elements."""
|
||||||
self._result = "" # Not used, but cleared just in case
|
self._result = "" # Not used, but cleared just in case
|
||||||
|
|
||||||
pFmt = []
|
pFmt: list[T_Formats] = []
|
||||||
pText = []
|
pText = []
|
||||||
pStyle = None
|
pStyle = None
|
||||||
pIndent = True
|
pIndent = True
|
||||||
|
xText = self._xText
|
||||||
for tType, _, tText, tFormat, tStyle in self._tokens:
|
for tType, _, tText, tFormat, tStyle in self._tokens:
|
||||||
|
|
||||||
# Styles
|
# Styles
|
||||||
@@ -444,16 +458,16 @@ class ToOdt(Tokenizer):
|
|||||||
|
|
||||||
if len(pText) > 0 and pStyle is not None:
|
if len(pText) > 0 and pStyle is not None:
|
||||||
tTxt = ""
|
tTxt = ""
|
||||||
tFmt = []
|
tFmt: T_Formats = []
|
||||||
for nText, nFmt in zip(pText, pFmt):
|
for nText, nFmt in zip(pText, pFmt):
|
||||||
tLen = len(tTxt)
|
tLen = len(tTxt)
|
||||||
tTxt += f"{nText}\n"
|
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
|
# Don't indent a paragraph if it has alignment set
|
||||||
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
|
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
|
||||||
self._addTextPar(
|
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
|
pStyle, tTxt.rstrip(), tFmt=tFmt
|
||||||
)
|
)
|
||||||
pIndent = True
|
pIndent = True
|
||||||
@@ -463,30 +477,31 @@ class ToOdt(Tokenizer):
|
|||||||
pStyle = None
|
pStyle = None
|
||||||
|
|
||||||
elif tType == self.T_TITLE:
|
elif tType == self.T_TITLE:
|
||||||
|
# Title must be text:p
|
||||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
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:
|
elif tType == self.T_HEAD1:
|
||||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
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:
|
elif tType == self.T_HEAD2:
|
||||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
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:
|
elif tType == self.T_HEAD3:
|
||||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
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:
|
elif tType == self.T_HEAD4:
|
||||||
tHead = tText.replace(nwHeadFmt.BR, "\n")
|
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:
|
elif tType == self.T_SEP:
|
||||||
self._addTextPar("Separator", oStyle, tText)
|
self._addTextPar(xText, "Separator", oStyle, tText)
|
||||||
|
|
||||||
elif tType == self.T_SKIP:
|
elif tType == self.T_SKIP:
|
||||||
self._addTextPar("Separator", oStyle, "")
|
self._addTextPar(xText, "Separator", oStyle, "")
|
||||||
|
|
||||||
elif tType == self.T_TEXT:
|
elif tType == self.T_TEXT:
|
||||||
if pStyle is None:
|
if pStyle is None:
|
||||||
@@ -495,20 +510,20 @@ class ToOdt(Tokenizer):
|
|||||||
pFmt.append(tFormat)
|
pFmt.append(tFormat)
|
||||||
|
|
||||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||||
tTemp, fTemp = self._formatSynopsis(tText, True)
|
tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
|
||||||
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
|
self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
|
||||||
|
|
||||||
elif tType == self.T_SHORT and self._doSynopsis:
|
elif tType == self.T_SHORT and self._doSynopsis:
|
||||||
tTemp, fTemp = self._formatSynopsis(tText, False)
|
tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
|
||||||
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
|
self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
|
||||||
|
|
||||||
elif tType == self.T_COMMENT and self._doComments:
|
elif tType == self.T_COMMENT and self._doComments:
|
||||||
tTemp, fTemp = self._formatComments(tText)
|
tTemp, tFmt = self._formatComments(tText, tFormat)
|
||||||
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
|
self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
|
||||||
|
|
||||||
elif tType == self.T_KEYWORD and self._doKeywords:
|
elif tType == self.T_KEYWORD and self._doKeywords:
|
||||||
tTemp, fTemp = self._formatKeywords(tText)
|
tTemp, tFmt = self._formatKeywords(tText)
|
||||||
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp)
|
self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -569,28 +584,32 @@ class ToOdt(Tokenizer):
|
|||||||
# Internal Functions
|
# 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."""
|
"""Apply formatting to synopsis lines."""
|
||||||
name = self._localLookup("Synopsis" if synopsis else "Short Description")
|
name = self._localLookup("Synopsis" if synopsis else "Short Description")
|
||||||
|
shift = len(name) + 2
|
||||||
rTxt = f"{name}: {text}"
|
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
|
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."""
|
"""Apply formatting to comments."""
|
||||||
name = self._localLookup("Comment")
|
name = self._localLookup("Comment")
|
||||||
|
shift = len(name) + 2
|
||||||
rTxt = f"{name}: {text}"
|
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
|
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."""
|
"""Apply formatting to keywords."""
|
||||||
valid, bits, _ = self._project.index.scanThis("@"+text)
|
valid, bits, _ = self._project.index.scanThis("@"+text)
|
||||||
if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
|
if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
|
||||||
return "", []
|
return "", []
|
||||||
|
|
||||||
rTxt = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: "
|
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 len(bits) > 1:
|
||||||
if bits[0] == nwKeyWords.TAG_KEY:
|
if bits[0] == nwKeyWords.TAG_KEY:
|
||||||
rTxt += bits[1]
|
rTxt += bits[1]
|
||||||
@@ -600,8 +619,8 @@ class ToOdt(Tokenizer):
|
|||||||
return rTxt, rFmt
|
return rTxt, rFmt
|
||||||
|
|
||||||
def _addTextPar(
|
def _addTextPar(
|
||||||
self, styleName: str, oStyle: ODTParagraphStyle, tText: str,
|
self, xParent: ET.Element, styleName: str, oStyle: ODTParagraphStyle, tText: str,
|
||||||
tFmt: Sequence[tuple[int, int]] = [], isHead: bool = False, oLevel: str | None = None
|
tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a text paragraph to the text XML element."""
|
"""Add a text paragraph to the text XML element."""
|
||||||
tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)}
|
tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)}
|
||||||
@@ -609,7 +628,7 @@ class ToOdt(Tokenizer):
|
|||||||
tAttr[_mkTag("text", "outline-level")] = oLevel
|
tAttr[_mkTag("text", "outline-level")] = oLevel
|
||||||
|
|
||||||
pTag = "h" if isHead else "p"
|
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
|
# 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.
|
# xmlIndent will add a line break if the first subelement is a span.
|
||||||
@@ -627,7 +646,13 @@ class ToOdt(Tokenizer):
|
|||||||
xFmt = 0x00
|
xFmt = 0x00
|
||||||
tFrag = ""
|
tFrag = ""
|
||||||
fLast = 0
|
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
|
# Add the text up to the current fragment
|
||||||
if tFrag := tText[fLast:fPos]:
|
if tFrag := tText[fLast:fPos]:
|
||||||
@@ -665,11 +690,18 @@ class ToOdt(Tokenizer):
|
|||||||
xFmt |= X_SUB
|
xFmt |= X_SUB
|
||||||
elif fFmt == self.FMT_SUB_E:
|
elif fFmt == self.FMT_SUB_E:
|
||||||
xFmt &= M_SUB
|
xFmt &= M_SUB
|
||||||
|
elif fFmt == self.FMT_FNOTE:
|
||||||
|
xNode = self._generateFootnote(fData)
|
||||||
|
elif fFmt == self.FMT_STRIP:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
pErr += 1
|
pErr += 1
|
||||||
|
|
||||||
fLast = fPos
|
fLast = fPos
|
||||||
|
|
||||||
|
if xNode is not None:
|
||||||
|
parProc.appendNode(xNode)
|
||||||
|
|
||||||
if tFrag := tText[fLast:]:
|
if tFrag := tText[fLast:]:
|
||||||
if xFmt == 0x00:
|
if xFmt == 0x00:
|
||||||
parProc.appendText(tFrag)
|
parProc.appendText(tFrag)
|
||||||
@@ -735,6 +767,22 @@ class ToOdt(Tokenizer):
|
|||||||
|
|
||||||
return style.name
|
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:
|
def _emToCm(self, value: float) -> str:
|
||||||
"""Converts an em value to centimetres."""
|
"""Converts an em value to centimetres."""
|
||||||
return f"{value*2.54/72*self._textSize:.3f}cm"
|
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-bottom"): self._mDocBtm,
|
||||||
_mkTag("fo", "margin-left"): self._mDocLeft,
|
_mkTag("fo", "margin-left"): self._mDocLeft,
|
||||||
_mkTag("fo", "margin-right"): self._mDocRight,
|
_mkTag("fo", "margin-right"): self._mDocRight,
|
||||||
_mkTag("fo", "print-orientation"): "portrait",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
xHead = ET.SubElement(xPage, _mkTag("style", "header-style"))
|
xHead = ET.SubElement(xPage, _mkTag("style", "header-style"))
|
||||||
@@ -985,6 +1032,18 @@ class ToOdt(Tokenizer):
|
|||||||
style.packXML(self._xStyl)
|
style.packXML(self._xStyl)
|
||||||
self._mainPara[style.name] = style
|
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
|
return
|
||||||
|
|
||||||
def _writeHeader(self) -> None:
|
def _writeHeader(self) -> None:
|
||||||
@@ -1041,7 +1100,7 @@ class ODTParagraphStyle:
|
|||||||
VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"]
|
VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"]
|
||||||
VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"]
|
VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"]
|
||||||
VALID_LEVEL = ["1", "2", "3", "4"]
|
VALID_LEVEL = ["1", "2", "3", "4"]
|
||||||
VALID_CLASS = ["text", "chapter"]
|
VALID_CLASS = ["text", "chapter", "extra"]
|
||||||
VALID_WEIGHT = ["normal", "inherit", "bold"]
|
VALID_WEIGHT = ["normal", "inherit", "bold"]
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
@@ -1464,7 +1523,6 @@ class XMLParagraph:
|
|||||||
if c == " ":
|
if c == " ":
|
||||||
nSpaces += 1
|
nSpaces += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif nSpaces > 0:
|
elif nSpaces > 0:
|
||||||
self._processSpaces(nSpaces)
|
self._processSpaces(nSpaces)
|
||||||
nSpaces = 0
|
nSpaces = 0
|
||||||
@@ -1475,26 +1533,22 @@ class XMLParagraph:
|
|||||||
self._xTail.tail = ""
|
self._xTail.tail = ""
|
||||||
self._nState = X_ROOT_TAIL
|
self._nState = X_ROOT_TAIL
|
||||||
self._chrPos += 1
|
self._chrPos += 1
|
||||||
|
|
||||||
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
|
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
|
||||||
self._xSing = ET.SubElement(self._xTail, TAG_BR)
|
self._xSing = ET.SubElement(self._xTail, TAG_BR)
|
||||||
self._xSing.tail = ""
|
self._xSing.tail = ""
|
||||||
self._nState = X_SPAN_SING
|
self._nState = X_SPAN_SING
|
||||||
self._chrPos += 1
|
self._chrPos += 1
|
||||||
|
|
||||||
elif c == "\t":
|
elif c == "\t":
|
||||||
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
|
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
|
||||||
self._xTail = ET.SubElement(self._xRoot, TAG_TAB)
|
self._xTail = ET.SubElement(self._xRoot, TAG_TAB)
|
||||||
self._xTail.tail = ""
|
self._xTail.tail = ""
|
||||||
self._nState = X_ROOT_TAIL
|
self._nState = X_ROOT_TAIL
|
||||||
self._chrPos += 1
|
self._chrPos += 1
|
||||||
|
|
||||||
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
|
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
|
||||||
self._xSing = ET.SubElement(self._xTail, TAG_TAB)
|
self._xSing = ET.SubElement(self._xTail, TAG_TAB)
|
||||||
self._xSing.tail = ""
|
self._xSing.tail = ""
|
||||||
self._chrPos += 1
|
self._chrPos += 1
|
||||||
self._nState = X_SPAN_SING
|
self._nState = X_SPAN_SING
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if self._nState == X_ROOT_TEXT:
|
if self._nState == X_ROOT_TEXT:
|
||||||
self._xRoot.text = (self._xRoot.text or "") + c
|
self._xRoot.text = (self._xRoot.text or "") + c
|
||||||
@@ -1529,6 +1583,19 @@ class XMLParagraph:
|
|||||||
self._nState = X_ROOT_TAIL
|
self._nState = X_ROOT_TAIL
|
||||||
return
|
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]:
|
def checkError(self) -> tuple[int, str]:
|
||||||
"""Check that the number of characters written matches the
|
"""Check that the number of characters written matches the
|
||||||
number of characters received.
|
number of characters received.
|
||||||
|
|||||||
+8
-2
@@ -65,8 +65,13 @@ class nwItemLayout(Enum):
|
|||||||
class nwComment(Enum):
|
class nwComment(Enum):
|
||||||
|
|
||||||
PLAIN = 0
|
PLAIN = 0
|
||||||
SYNOPSIS = 1
|
IGNORE = 1
|
||||||
SHORT = 2
|
SYNOPSIS = 2
|
||||||
|
SHORT = 3
|
||||||
|
NOTE = 4
|
||||||
|
FOOTNOTE = 5
|
||||||
|
COMMENT = 6
|
||||||
|
STORY = 7
|
||||||
|
|
||||||
# END Enum nwComment
|
# END Enum nwComment
|
||||||
|
|
||||||
@@ -145,6 +150,7 @@ class nwDocInsert(Enum):
|
|||||||
VSPACE_S = 8
|
VSPACE_S = 8
|
||||||
VSPACE_M = 9
|
VSPACE_M = 9
|
||||||
LIPSUM = 10
|
LIPSUM = 10
|
||||||
|
FOOTNOTE = 11
|
||||||
|
|
||||||
# END Enum nwDocInsert
|
# END Enum nwDocInsert
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ from time import time
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from PyQt5.QtCore import (
|
from PyQt5.QtCore import (
|
||||||
pyqtSignal, pyqtSlot, QObject, QPoint, QRegularExpression, QRunnable, Qt,
|
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
|
||||||
QTimer
|
pyqtSlot
|
||||||
)
|
)
|
||||||
from PyQt5.QtGui import (
|
from PyQt5.QtGui import (
|
||||||
QColor, QCursor, QFont, QKeyEvent, QKeySequence, QMouseEvent, QPalette,
|
QColor, QCursor, QFont, QKeyEvent, QKeySequence, QMouseEvent, QPalette,
|
||||||
@@ -55,7 +55,7 @@ from novelwriter import CONFIG, SHARED
|
|||||||
from novelwriter.common import minmax, transferCase
|
from novelwriter.common import minmax, transferCase
|
||||||
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
|
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
|
||||||
from novelwriter.core.document import NWDocument
|
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.eventfilters import WheelEventFilter
|
||||||
from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton
|
from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton
|
||||||
from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE
|
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.tools.lipsum import GuiLipsum
|
||||||
from novelwriter.types import (
|
from novelwriter.types import (
|
||||||
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
|
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
|
||||||
QtAlignRight, QtKeepAnchor, QtModCtrl, QtMouseLeft, QtModeNone, QtModShift,
|
QtAlignRight, QtKeepAnchor, QtModCtrl, QtModeNone, QtModShift, QtMouseLeft,
|
||||||
QtMoveAnchor, QtMoveLeft, QtMoveRight
|
QtMoveAnchor, QtMoveLeft, QtMoveRight
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -840,14 +840,15 @@ class GuiDocEditor(QPlainTextEdit):
|
|||||||
)
|
)
|
||||||
return
|
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."""
|
"""Insert a specific type of text at the cursor position."""
|
||||||
if self._docHandle is None:
|
if self._docHandle is None:
|
||||||
logger.error("No document open")
|
logger.error("No document open")
|
||||||
return False
|
return
|
||||||
|
|
||||||
newBlock = False
|
text = ""
|
||||||
goAfter = False
|
block = False
|
||||||
|
after = False
|
||||||
|
|
||||||
if isinstance(insert, str):
|
if isinstance(insert, str):
|
||||||
text = insert
|
text = insert
|
||||||
@@ -862,43 +863,41 @@ class GuiDocEditor(QPlainTextEdit):
|
|||||||
text = self._typDQuoteC
|
text = self._typDQuoteC
|
||||||
elif insert == nwDocInsert.SYNOPSIS:
|
elif insert == nwDocInsert.SYNOPSIS:
|
||||||
text = "%Synopsis: "
|
text = "%Synopsis: "
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = True
|
after = True
|
||||||
elif insert == nwDocInsert.SHORT:
|
elif insert == nwDocInsert.SHORT:
|
||||||
text = "%Short: "
|
text = "%Short: "
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = True
|
after = True
|
||||||
elif insert == nwDocInsert.NEW_PAGE:
|
elif insert == nwDocInsert.NEW_PAGE:
|
||||||
text = "[newpage]"
|
text = "[newpage]"
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = False
|
after = False
|
||||||
elif insert == nwDocInsert.VSPACE_S:
|
elif insert == nwDocInsert.VSPACE_S:
|
||||||
text = "[vspace]"
|
text = "[vspace]"
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = False
|
after = False
|
||||||
elif insert == nwDocInsert.VSPACE_M:
|
elif insert == nwDocInsert.VSPACE_M:
|
||||||
text = "[vspace:2]"
|
text = "[vspace:2]"
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = False
|
after = False
|
||||||
elif insert == nwDocInsert.LIPSUM:
|
elif insert == nwDocInsert.LIPSUM:
|
||||||
text = GuiLipsum.getLipsum(self)
|
text = GuiLipsum.getLipsum(self)
|
||||||
newBlock = True
|
block = True
|
||||||
goAfter = False
|
after = False
|
||||||
else:
|
elif insert == nwDocInsert.FOOTNOTE:
|
||||||
return False
|
self._insertCommentStructure(nwComment.FOOTNOTE)
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
if newBlock:
|
if block:
|
||||||
self.insertNewBlock(text, defaultAfter=goAfter)
|
self.insertNewBlock(text, defaultAfter=after)
|
||||||
else:
|
else:
|
||||||
cursor = self.textCursor()
|
cursor = self.textCursor()
|
||||||
cursor.beginEditBlock()
|
cursor.beginEditBlock()
|
||||||
cursor.insertText(text)
|
cursor.insertText(text)
|
||||||
cursor.endEditBlock()
|
cursor.endEditBlock()
|
||||||
|
|
||||||
return True
|
return
|
||||||
|
|
||||||
def insertNewBlock(self, text: str, defaultAfter: bool = True) -> bool:
|
def insertNewBlock(self, text: str, defaultAfter: bool = True) -> bool:
|
||||||
"""Insert a piece of text on a blank line."""
|
"""Insert a piece of text on a blank line."""
|
||||||
@@ -1164,7 +1163,8 @@ class GuiDocEditor(QPlainTextEdit):
|
|||||||
lambda _, option=option: self._correctWord(sCursor, option)
|
lambda _, option=option: self._correctWord(sCursor, option)
|
||||||
)
|
)
|
||||||
else:
|
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()
|
ctxMenu.addSeparator()
|
||||||
action = ctxMenu.addAction(self.tr("Add Word to Dictionary"))
|
action = ctxMenu.addAction(self.tr("Add Word to Dictionary"))
|
||||||
@@ -1850,6 +1850,32 @@ class GuiDocEditor(QPlainTextEdit):
|
|||||||
|
|
||||||
return
|
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
|
# Internal Functions
|
||||||
##
|
##
|
||||||
|
|||||||
+205
-166
@@ -28,26 +28,27 @@ import logging
|
|||||||
|
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, QRegularExpression
|
from PyQt5.QtCore import QRegularExpression, Qt
|
||||||
from PyQt5.QtGui import (
|
from PyQt5.QtGui import (
|
||||||
QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData,
|
QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData,
|
||||||
QTextCharFormat, QTextDocument
|
QTextCharFormat, QTextDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
from novelwriter import CONFIG, SHARED
|
from novelwriter import CONFIG, SHARED
|
||||||
from novelwriter.enum import nwComment
|
|
||||||
from novelwriter.common import checkInt
|
from novelwriter.common import checkInt
|
||||||
from novelwriter.constants import nwRegEx, nwUnicode
|
from novelwriter.constants import nwRegEx, nwUnicode
|
||||||
from novelwriter.core.index import processComment
|
from novelwriter.core.index import processComment
|
||||||
|
from novelwriter.enum import nwComment
|
||||||
|
from novelwriter.types import QRegExUnicode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
|
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
|
||||||
SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
SPELLRX.setPatternOptions(QRegExUnicode)
|
||||||
SPELLSC = QRegularExpression(nwRegEx.FMT_SC)
|
SPELLSC = QRegularExpression(nwRegEx.FMT_SC)
|
||||||
SPELLSC.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
SPELLSC.setPatternOptions(QRegExUnicode)
|
||||||
SPELLSV = QRegularExpression(nwRegEx.FMT_SV)
|
SPELLSV = QRegularExpression(nwRegEx.FMT_SV)
|
||||||
SPELLSV.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
SPELLSV.setPatternOptions(QRegExUnicode)
|
||||||
|
|
||||||
BLOCK_NONE = 0
|
BLOCK_NONE = 0
|
||||||
BLOCK_TEXT = 1
|
BLOCK_TEXT = 1
|
||||||
@@ -57,8 +58,10 @@ BLOCK_TITLE = 4
|
|||||||
|
|
||||||
class GuiDocHighlighter(QSyntaxHighlighter):
|
class GuiDocHighlighter(QSyntaxHighlighter):
|
||||||
|
|
||||||
__slots__ = ("_tHandle", "_isInactive", "_spellCheck", "_spellErr",
|
__slots__ = (
|
||||||
"_hRules", "_hStyles", "_rxRules")
|
"_tHandle", "_isInactive", "_spellCheck", "_spellErr", "_hStyles",
|
||||||
|
"_txtRules", "_cmnRules",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, document: QTextDocument) -> None:
|
def __init__(self, document: QTextDocument) -> None:
|
||||||
super().__init__(document)
|
super().__init__(document)
|
||||||
@@ -70,9 +73,9 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
self._spellCheck = False
|
self._spellCheck = False
|
||||||
self._spellErr = QTextCharFormat()
|
self._spellErr = QTextCharFormat()
|
||||||
|
|
||||||
self._hRules: list[tuple[str, dict]] = []
|
|
||||||
self._hStyles: dict[str, QTextCharFormat] = {}
|
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()
|
self.initHighlighter()
|
||||||
|
|
||||||
@@ -90,34 +93,32 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
colBreak = QColor(SHARED.theme.colEmph)
|
colBreak = QColor(SHARED.theme.colEmph)
|
||||||
colBreak.setAlpha(64)
|
colBreak.setAlpha(64)
|
||||||
|
|
||||||
self._hRules = []
|
# Create Character Formats
|
||||||
self._hStyles = {
|
self._addCharFormat("header1", SHARED.theme.colHead, "b", 1.8)
|
||||||
"header1": self._makeFormat(SHARED.theme.colHead, "bold", 1.8),
|
self._addCharFormat("header2", SHARED.theme.colHead, "b", 1.6)
|
||||||
"header2": self._makeFormat(SHARED.theme.colHead, "bold", 1.6),
|
self._addCharFormat("header3", SHARED.theme.colHead, "b", 1.4)
|
||||||
"header3": self._makeFormat(SHARED.theme.colHead, "bold", 1.4),
|
self._addCharFormat("header4", SHARED.theme.colHead, "b", 1.2)
|
||||||
"header4": self._makeFormat(SHARED.theme.colHead, "bold", 1.2),
|
self._addCharFormat("head1h", SHARED.theme.colHeadH, "b", 1.8)
|
||||||
"head1h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.8),
|
self._addCharFormat("head2h", SHARED.theme.colHeadH, "b", 1.6)
|
||||||
"head2h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.6),
|
self._addCharFormat("head3h", SHARED.theme.colHeadH, "b", 1.4)
|
||||||
"head3h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.4),
|
self._addCharFormat("head4h", SHARED.theme.colHeadH, "b", 1.2)
|
||||||
"head4h": self._makeFormat(SHARED.theme.colHeadH, "bold", 1.2),
|
self._addCharFormat("bold", colEmph, "b")
|
||||||
"bold": self._makeFormat(colEmph, "bold"),
|
self._addCharFormat("italic", colEmph, "i")
|
||||||
"italic": self._makeFormat(colEmph, "italic"),
|
self._addCharFormat("strike", SHARED.theme.colHidden, "s")
|
||||||
"strike": self._makeFormat(SHARED.theme.colHidden, "strike"),
|
self._addCharFormat("mspaces", SHARED.theme.colError, "err")
|
||||||
"mspaces": self._makeFormat(SHARED.theme.colError, "errline"),
|
self._addCharFormat("nobreak", colBreak, "bg")
|
||||||
"nobreak": self._makeFormat(colBreak, "background"),
|
self._addCharFormat("dialog1", SHARED.theme.colDialN)
|
||||||
"dialogue1": self._makeFormat(SHARED.theme.colDialN),
|
self._addCharFormat("dialog2", SHARED.theme.colDialD)
|
||||||
"dialogue2": self._makeFormat(SHARED.theme.colDialD),
|
self._addCharFormat("dialog3", SHARED.theme.colDialS)
|
||||||
"dialogue3": self._makeFormat(SHARED.theme.colDialS),
|
self._addCharFormat("replace", SHARED.theme.colRepTag)
|
||||||
"replace": self._makeFormat(SHARED.theme.colRepTag),
|
self._addCharFormat("hidden", SHARED.theme.colHidden)
|
||||||
"hidden": self._makeFormat(SHARED.theme.colHidden),
|
self._addCharFormat("markup", SHARED.theme.colHidden)
|
||||||
"code": self._makeFormat(SHARED.theme.colCode),
|
self._addCharFormat("code", SHARED.theme.colCode)
|
||||||
"keyword": self._makeFormat(SHARED.theme.colKey),
|
self._addCharFormat("keyword", SHARED.theme.colKey)
|
||||||
"modifier": self._makeFormat(SHARED.theme.colMod),
|
self._addCharFormat("modifier", SHARED.theme.colMod)
|
||||||
"value": self._makeFormat(SHARED.theme.colVal),
|
self._addCharFormat("value", SHARED.theme.colVal)
|
||||||
"optional": self._makeFormat(SHARED.theme.colOpt),
|
self._addCharFormat("optional", SHARED.theme.colOpt)
|
||||||
"codevalue": self._makeFormat(SHARED.theme.colVal),
|
self._addCharFormat("invalid", None, "err")
|
||||||
"codeinval": self._makeFormat(None, "errline"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache Spell Error Format
|
# Cache Spell Error Format
|
||||||
self._spellErr = QTextCharFormat()
|
self._spellErr = QTextCharFormat()
|
||||||
@@ -126,18 +127,22 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
|
|
||||||
# Multiple or Trailing Spaces
|
# Multiple or Trailing Spaces
|
||||||
if CONFIG.showMultiSpaces:
|
if CONFIG.showMultiSpaces:
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(r"[ ]{2,}|[ ]*$")
|
||||||
r"[ ]{2,}|[ ]*$", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
0: self._hStyles["mspaces"],
|
hlRule = {
|
||||||
}
|
0: self._hStyles["mspaces"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Non-Breaking Spaces
|
# Non-Breaking Spaces
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+")
|
||||||
f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
0: self._hStyles["nobreak"],
|
hlRule = {
|
||||||
}
|
0: self._hStyles["nobreak"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Quoted Strings
|
# Quoted Strings
|
||||||
if CONFIG.highlightQuotes:
|
if CONFIG.highlightQuotes:
|
||||||
@@ -147,88 +152,100 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
fmtSngC = CONFIG.fmtSQuoteClose
|
fmtSngC = CONFIG.fmtSQuoteClose
|
||||||
|
|
||||||
# Straight Quotes
|
# Straight Quotes
|
||||||
if not (fmtDblO == fmtDblC == "\""):
|
rxRule = QRegularExpression(r'(\B")(.*?)("\B)')
|
||||||
self._hRules.append((
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
"(\\B\")(.*?)(\"\\B)", {
|
hlRule = {
|
||||||
0: self._hStyles["dialogue1"],
|
0: self._hStyles["dialog1"],
|
||||||
}
|
}
|
||||||
))
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Double Quotes
|
# Double Quotes
|
||||||
dblEnd = "|$" if CONFIG.allowOpenDQuote else ""
|
dblEnd = "|$" if CONFIG.allowOpenDQuote else ""
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})")
|
||||||
f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
0: self._hStyles["dialogue2"],
|
hlRule = {
|
||||||
}
|
0: self._hStyles["dialog2"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Single Quotes
|
# Single Quotes
|
||||||
sngEnd = "|$" if CONFIG.allowOpenSQuote else ""
|
sngEnd = "|$" if CONFIG.allowOpenSQuote else ""
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})")
|
||||||
f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
0: self._hStyles["dialogue3"],
|
hlRule = {
|
||||||
}
|
0: self._hStyles["dialog3"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Markdown Syntax
|
# Markdown Italic
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(nwRegEx.FMT_EI)
|
||||||
nwRegEx.FMT_EI, {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
1: self._hStyles["hidden"],
|
hlRule = {
|
||||||
2: self._hStyles["italic"],
|
1: self._hStyles["markup"],
|
||||||
3: self._hStyles["hidden"],
|
2: self._hStyles["italic"],
|
||||||
}
|
3: self._hStyles["markup"],
|
||||||
))
|
}
|
||||||
self._hRules.append((
|
self._txtRules.append((rxRule, hlRule))
|
||||||
nwRegEx.FMT_EB, {
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
1: self._hStyles["hidden"],
|
|
||||||
2: self._hStyles["bold"],
|
# Markdown Bold
|
||||||
3: self._hStyles["hidden"],
|
rxRule = QRegularExpression(nwRegEx.FMT_EB)
|
||||||
}
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
))
|
hlRule = {
|
||||||
self._hRules.append((
|
1: self._hStyles["markup"],
|
||||||
nwRegEx.FMT_ST, {
|
2: self._hStyles["bold"],
|
||||||
1: self._hStyles["hidden"],
|
3: self._hStyles["markup"],
|
||||||
2: self._hStyles["strike"],
|
}
|
||||||
3: self._hStyles["hidden"],
|
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
|
# Shortcodes
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(nwRegEx.FMT_SC)
|
||||||
nwRegEx.FMT_SC, {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
1: self._hStyles["code"],
|
hlRule = {
|
||||||
}
|
1: self._hStyles["code"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Shortcodes w/Value
|
# Shortcodes w/Value
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(nwRegEx.FMT_SV)
|
||||||
nwRegEx.FMT_SV, {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
1: self._hStyles["code"],
|
hlRule = {
|
||||||
2: self._hStyles["codevalue"],
|
1: self._hStyles["code"],
|
||||||
3: self._hStyles["code"],
|
2: self._hStyles["value"],
|
||||||
}
|
3: self._hStyles["code"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Alignment Tags
|
# Alignment Tags
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(r"(^>{1,2}|<{1,2}$)")
|
||||||
r"(^>{1,2}|<{1,2}$)", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
1: self._hStyles["hidden"],
|
hlRule = {
|
||||||
}
|
1: self._hStyles["markup"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
|
|
||||||
# Auto-Replace Tags
|
# Auto-Replace Tags
|
||||||
self._hRules.append((
|
rxRule = QRegularExpression(r"<(\S+?)>")
|
||||||
r"<(\S+?)>", {
|
rxRule.setPatternOptions(QRegExUnicode)
|
||||||
0: self._hStyles["replace"],
|
hlRule = {
|
||||||
}
|
0: self._hStyles["replace"],
|
||||||
))
|
}
|
||||||
|
self._txtRules.append((rxRule, hlRule))
|
||||||
# Build a QRegExp for each highlight pattern
|
self._cmnRules.append((rxRule, hlRule))
|
||||||
self._rxRules = []
|
|
||||||
for regEx, regRules in self._hRules:
|
|
||||||
hReg = QRegularExpression(regEx)
|
|
||||||
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
|
|
||||||
self._rxRules.append((hReg, regRules))
|
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -282,6 +299,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
if self._tHandle is None or not text:
|
if self._tHandle is None or not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
xOff = 0
|
||||||
|
hRules = None
|
||||||
if text.startswith("@"): # Keywords and commands
|
if text.startswith("@"): # Keywords and commands
|
||||||
self.setCurrentBlockState(BLOCK_META)
|
self.setCurrentBlockState(BLOCK_META)
|
||||||
index = SHARED.project.index
|
index = SHARED.project.index
|
||||||
@@ -300,7 +319,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
yPos = xPos + len(bit) - len(two)
|
yPos = xPos + len(bit) - len(two)
|
||||||
self.setFormat(yPos, len(two), self._hStyles["optional"])
|
self.setFormat(yPos, len(two), self._hStyles["optional"])
|
||||||
elif not self._isInactive:
|
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,
|
# We never want to run the spell checker on keyword/values,
|
||||||
# so we force a return here
|
# so we force a return here
|
||||||
@@ -339,43 +358,58 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
|
|
||||||
elif text.startswith("%"): # Comments
|
elif text.startswith("%"): # Comments
|
||||||
self.setCurrentBlockState(BLOCK_TEXT)
|
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:
|
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:
|
else:
|
||||||
self.setFormat(0, cPos, self._hStyles["modifier"])
|
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
|
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)
|
self.setCurrentBlockState(BLOCK_TEXT)
|
||||||
for rX, xFmt in self._rxRules:
|
hRules = self._txtRules
|
||||||
rxItt = rX.globalMatch(text, 0)
|
|
||||||
|
if hRules:
|
||||||
|
for rX, hRule in hRules:
|
||||||
|
rxItt = rX.globalMatch(text, xOff)
|
||||||
while rxItt.hasNext():
|
while rxItt.hasNext():
|
||||||
rxMatch = rxItt.next()
|
rxMatch = rxItt.next()
|
||||||
for xM in xFmt:
|
for xM, hFmt in hRule.items():
|
||||||
xPos = rxMatch.capturedStart(xM)
|
xPos = rxMatch.capturedStart(xM)
|
||||||
xLen = rxMatch.capturedLength(xM)
|
xEnd = rxMatch.capturedEnd(xM)
|
||||||
for x in range(xPos, xPos+xLen):
|
for x in range(xPos, xEnd):
|
||||||
spFmt = self.format(x)
|
cFmt = self.format(x)
|
||||||
if spFmt != self._hStyles["hidden"]:
|
if cFmt.fontStyleName() != "markup":
|
||||||
spFmt.merge(xFmt[xM])
|
cFmt.merge(hFmt)
|
||||||
self.setFormat(x, 1, spFmt)
|
self.setFormat(x, 1, cFmt)
|
||||||
|
|
||||||
data = self.currentBlockUserData()
|
data = self.currentBlockUserData()
|
||||||
if not isinstance(data, TextBlockData):
|
if not isinstance(data, TextBlockData):
|
||||||
@@ -383,11 +417,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
self.setCurrentBlockUserData(data)
|
self.setCurrentBlockUserData(data)
|
||||||
|
|
||||||
if self._spellCheck:
|
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):
|
for x in range(xPos, xPos+xLen):
|
||||||
spFmt = self.format(x)
|
cFmt = self.format(x)
|
||||||
spFmt.merge(self._spellErr)
|
cFmt.merge(self._spellErr)
|
||||||
self.setFormat(x, 1, spFmt)
|
self.setFormat(x, 1, cFmt)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -395,34 +429,37 @@ class GuiDocHighlighter(QSyntaxHighlighter):
|
|||||||
# Internal Functions
|
# Internal Functions
|
||||||
##
|
##
|
||||||
|
|
||||||
def _makeFormat(self, color: QColor | None = None, style: str | None = None,
|
def _addCharFormat(
|
||||||
size: float | None = None) -> QTextCharFormat:
|
self, name: str, color: QColor | None = None,
|
||||||
"""Generate a valid character format to be applied to the text
|
style: str | None = None, size: float | None = None
|
||||||
that is to be highlighted.
|
) -> None:
|
||||||
"""
|
"""Generate a highlighter character format."""
|
||||||
charFormat = QTextCharFormat()
|
charFormat = QTextCharFormat()
|
||||||
|
charFormat.setFontStyleName(name)
|
||||||
|
|
||||||
if color is not None:
|
if color:
|
||||||
charFormat.setForeground(color)
|
charFormat.setForeground(color)
|
||||||
|
|
||||||
if style is not None:
|
if style:
|
||||||
styles = style.split(",")
|
styles = style.split(",")
|
||||||
if "bold" in styles:
|
if "b" in styles:
|
||||||
charFormat.setFontWeight(QFont.Weight.Bold)
|
charFormat.setFontWeight(QFont.Weight.Bold)
|
||||||
if "italic" in styles:
|
if "i" in styles:
|
||||||
charFormat.setFontItalic(True)
|
charFormat.setFontItalic(True)
|
||||||
if "strike" in styles:
|
if "s" in styles:
|
||||||
charFormat.setFontStrikeOut(True)
|
charFormat.setFontStrikeOut(True)
|
||||||
if "errline" in styles:
|
if "err" in styles:
|
||||||
charFormat.setUnderlineColor(SHARED.theme.colError)
|
charFormat.setUnderlineColor(SHARED.theme.colError)
|
||||||
charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
|
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))
|
charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern))
|
||||||
|
|
||||||
if size is not None:
|
if size:
|
||||||
charFormat.setFontPointSize(int(round(size*CONFIG.textSize)))
|
charFormat.setFontPointSize(round(size*CONFIG.textSize))
|
||||||
|
|
||||||
return charFormat
|
self._hStyles[name] = charFormat
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
# END Class GuiDocHighlighter
|
# END Class GuiDocHighlighter
|
||||||
|
|
||||||
@@ -441,14 +478,14 @@ class TextBlockData(QTextBlockUserData):
|
|||||||
"""Return spell error data from last check."""
|
"""Return spell error data from last check."""
|
||||||
return self._spellErrors
|
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
|
"""Run the spell checker and cache the result, and return the
|
||||||
list of spell check errors.
|
list of spell check errors.
|
||||||
"""
|
"""
|
||||||
if "[" in text:
|
if "[" in text:
|
||||||
# Strip shortcodes
|
# Strip shortcodes
|
||||||
for rX in [SPELLSC, SPELLSV]:
|
for rX in [SPELLSC, SPELLSV]:
|
||||||
rxItt = rX.globalMatch(text, 0)
|
rxItt = rX.globalMatch(text, offset)
|
||||||
while rxItt.hasNext():
|
while rxItt.hasNext():
|
||||||
rxMatch = rxItt.next()
|
rxMatch = rxItt.next()
|
||||||
xPos = rxMatch.capturedStart(0)
|
xPos = rxMatch.capturedStart(0)
|
||||||
@@ -457,12 +494,14 @@ class TextBlockData(QTextBlockUserData):
|
|||||||
text = text[:xPos] + " "*xLen + text[xEnd:]
|
text = text[:xPos] + " "*xLen + text[xEnd:]
|
||||||
|
|
||||||
self._spellErrors = []
|
self._spellErrors = []
|
||||||
rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0)
|
rxSpell = SPELLRX.globalMatch(text.replace("_", " "), offset)
|
||||||
while rxSpell.hasNext():
|
while rxSpell.hasNext():
|
||||||
rxMatch = rxSpell.next()
|
rxMatch = rxSpell.next()
|
||||||
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
|
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
|
||||||
if not rxMatch.captured(0).isnumeric() and not rxMatch.captured(0).isupper():
|
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
|
return self._spellErrors
|
||||||
|
|
||||||
# END Class TextBlockData
|
# END Class TextBlockData
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class GuiDocViewer(QTextBrowser):
|
|||||||
aDoc.doPreProcessing()
|
aDoc.doPreProcessing()
|
||||||
aDoc.tokenizeText()
|
aDoc.tokenizeText()
|
||||||
aDoc.doConvert()
|
aDoc.doConvert()
|
||||||
|
aDoc.appendFootnotes()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Failed to generate preview for document with handle '%s'", tHandle)
|
logger.error("Failed to generate preview for document with handle '%s'", tHandle)
|
||||||
logException()
|
logException()
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSlot
|
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 import CONFIG, SHARED
|
||||||
from novelwriter.constants import trConst, nwLabels
|
from novelwriter.common import elide
|
||||||
|
from novelwriter.constants import nwLabels, trConst
|
||||||
from novelwriter.types import (
|
from novelwriter.types import (
|
||||||
QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase, QtAlignRightMiddle
|
QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase,
|
||||||
|
QtAlignRightMiddle
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -236,10 +238,6 @@ class GuiItemDetails(QWidget):
|
|||||||
# Label
|
# Label
|
||||||
# =====
|
# =====
|
||||||
|
|
||||||
label = nwItem.itemName
|
|
||||||
if len(label) > 100:
|
|
||||||
label = label[:96].rstrip()+" ..."
|
|
||||||
|
|
||||||
if nwItem.isFileType():
|
if nwItem.isFileType():
|
||||||
if nwItem.isActive:
|
if nwItem.isActive:
|
||||||
self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx)))
|
self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx)))
|
||||||
@@ -248,7 +246,7 @@ class GuiItemDetails(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx)))
|
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx)))
|
||||||
|
|
||||||
self.labelData.setText(label)
|
self.labelData.setText(elide(nwItem.itemName, 100))
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
# ======
|
# ======
|
||||||
|
|||||||
@@ -597,6 +597,12 @@ class GuiMainMenu(QMenuBar):
|
|||||||
lambda: self.requestDocInsert.emit(nwDocInsert.LIPSUM)
|
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
|
return
|
||||||
|
|
||||||
def _buildFormatMenu(self) -> None:
|
def _buildFormatMenu(self) -> None:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import QRegularExpression, Qt
|
||||||
from PyQt5.QtGui import QColor, QPainter, QTextCursor
|
from PyQt5.QtGui import QColor, QPainter, QTextCursor
|
||||||
from PyQt5.QtWidgets import QDialogButtonBox, QSizePolicy, QStyle
|
from PyQt5.QtWidgets import QDialogButtonBox, QSizePolicy, QStyle
|
||||||
|
|
||||||
@@ -96,3 +96,7 @@ QtSizeFixed = QSizePolicy.Policy.Fixed
|
|||||||
QtSizeIgnored = QSizePolicy.Policy.Ignored
|
QtSizeIgnored = QSizePolicy.Policy.Ignored
|
||||||
QtSizeMinimum = QSizePolicy.Policy.Minimum
|
QtSizeMinimum = QSizePolicy.Policy.Minimum
|
||||||
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
|
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
|
||||||
|
|
||||||
|
# Other
|
||||||
|
|
||||||
|
QRegExUnicode = QRegularExpression.PatternOption.UseUnicodePropertiesOption
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ version = {attr = "novelwriter.__version__"}
|
|||||||
include = ["novelwriter*"]
|
include = ["novelwriter*"]
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
py_version="38"
|
py_version="310"
|
||||||
line_length = 99
|
line_length = 99
|
||||||
wrap_length = 79
|
wrap_length = 79
|
||||||
multi_line_output = 5
|
multi_line_output = 5
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ flake8
|
|||||||
flake8-pep585
|
flake8-pep585
|
||||||
flake8-pyproject
|
flake8-pyproject
|
||||||
flake8-annotations
|
flake8-annotations
|
||||||
|
isort
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
%%~name: Making a Scene
|
%%~name: Making a Scene
|
||||||
%%~path: 6a2d6d5f4f401/636b6aa9b697b
|
%%~path: 6a2d6d5f4f401/636b6aa9b697b
|
||||||
%%~kind: NOVEL/DOCUMENT
|
%%~kind: NOVEL/DOCUMENT
|
||||||
%%~hash: e1c58699b05b512306a534da70c2952aafc60e8b
|
%%~hash: c7e664867218b3a9aac5c12119ef0ec63da2e5cc
|
||||||
%%~date: Unknown/2024-02-25 16:33:40
|
%%~date: Unknown/2024-04-18 17:56:30
|
||||||
### Making a Scene
|
### Making a Scene
|
||||||
|
|
||||||
@pov: Jane
|
@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.
|
The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens.
|
||||||
|
|
||||||
Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.
|
Thin spaces and thin non-breaking spaces are also supported from the Insert menu, and can be used to separate numbers from their units, like: 25 kg.[footnote:f4xr5]
|
||||||
|
|
||||||
|
%Footnote.f4xr5: Using a non-breaking space is the correct way to separate a number from its unit. This ensures that line wrapping does not split the two.
|
||||||
|
|
||||||
#### Some Section Here
|
#### Some Section Here
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?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">
|
<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="1852" autoCount="272" editTime="86443">
|
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="1916" autoCount="274" editTime="87487">
|
||||||
<name>Sample Project</name>
|
<name>Sample Project</name>
|
||||||
<author>Jane Smith</author>
|
<author>Jane Smith</author>
|
||||||
</project>
|
</project>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<entry key="i56be10" count="1" red="220" green="138" blue="221" shape="BLOCK_4">Main</entry>
|
<entry key="i56be10" count="1" red="220" green="138" blue="221" shape="BLOCK_4">Main</entry>
|
||||||
</importance>
|
</importance>
|
||||||
</settings>
|
</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">
|
<item handle="7031beac91f75" parent="None" root="7031beac91f75" order="0" type="ROOT" class="NOVEL">
|
||||||
<meta expanded="yes" />
|
<meta expanded="yes" />
|
||||||
<name status="sc24b8f" import="ia857f0">Novel</name>
|
<name status="sc24b8f" import="ia857f0">Novel</name>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<name status="sc24b8f" import="ia857f0" active="yes">Title Page</name>
|
<name status="sc24b8f" import="ia857f0" active="yes">Title Page</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="974e400180a99" parent="7031beac91f75" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<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>
|
<name status="sf12341" import="ia857f0" active="yes">Page</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="edca4be2fcaf8" parent="7031beac91f75" root="7031beac91f75" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<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>
|
<name status="sf24ce6" import="ia857f0" active="yes">Chapter One</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="636b6aa9b697b" parent="6a2d6d5f4f401" root="7031beac91f75" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<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>
|
<name status="s90e6c9" import="ia857f0" active="yes">Making a Scene</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="bc0cbd2a407f3" parent="6a2d6d5f4f401" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<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>
|
<name status="s90e6c9" import="ia857f0" active="yes">Another Scene</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
%%~name: Prologue
|
%%~name: Prologue
|
||||||
%%~path: b3643d0f92e32/88d59a277361b
|
%%~path: b3643d0f92e32/88d59a277361b
|
||||||
%%~kind: NOVEL/DOCUMENT
|
%%~kind: NOVEL/DOCUMENT
|
||||||
%%~hash: 5f965566ba82bbb83b8aa24f3ab7efde0c5e61cf
|
%%~hash: 605a96ba35297cd7d49b753b3bda9a20b9b29d93
|
||||||
%%~date: Unknown/2024-01-30 21:36:00
|
%%~date: Unknown/2024-04-27 16:40:18
|
||||||
##! Prologue
|
##! Prologue
|
||||||
|
|
||||||
% Synopsis: Explanation from the lipsum.com website.
|
% 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
@@ -1,6 +1,6 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?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">
|
<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="44" autoCount="25" editTime="2039">
|
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="45" autoCount="26" editTime="2168">
|
||||||
<name>Lorem Ipsum</name>
|
<name>Lorem Ipsum</name>
|
||||||
<author>lipsum.com</author>
|
<author>lipsum.com</author>
|
||||||
</project>
|
</project>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<language>en_GB</language>
|
<language>en_GB</language>
|
||||||
<spellChecking auto="no">None</spellChecking>
|
<spellChecking auto="no">None</spellChecking>
|
||||||
<lastHandle>
|
<lastHandle>
|
||||||
<entry key="editor">7a992350f3eb6</entry>
|
<entry key="editor">88d59a277361b</entry>
|
||||||
<entry key="viewer">None</entry>
|
<entry key="viewer">None</entry>
|
||||||
<entry key="novelTree">b3643d0f92e32</entry>
|
<entry key="novelTree">b3643d0f92e32</entry>
|
||||||
<entry key="outline">None</entry>
|
<entry key="outline">None</entry>
|
||||||
@@ -19,16 +19,16 @@
|
|||||||
<entry key="Rep2">Replace Text 2</entry>
|
<entry key="Rep2">Replace Text 2</entry>
|
||||||
</autoReplace>
|
</autoReplace>
|
||||||
<status>
|
<status>
|
||||||
<entry key="sbaa94f" count="3" red="100" green="100" blue="100">New</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">Note</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">Draft</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">Finished</entry>
|
<entry key="sedd043" count="7" red="50" green="200" blue="0" shape="SQUARE">Finished</entry>
|
||||||
</status>
|
</status>
|
||||||
<importance>
|
<importance>
|
||||||
<entry key="i613591" count="6" red="100" green="100" blue="100">New</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">Minor</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">Major</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">Main</entry>
|
<entry key="id6b1d0" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
|
||||||
</importance>
|
</importance>
|
||||||
</settings>
|
</settings>
|
||||||
<content items="21" novelWords="3109" notesWords="738">
|
<content items="21" novelWords="3109" notesWords="738">
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<name status="sedd043" import="i613591" active="yes">Front Matter</name>
|
<name status="sedd043" import="i613591" active="yes">Front Matter</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="88d59a277361b" parent="b3643d0f92e32" root="b3643d0f92e32" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<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>
|
<name status="s92a87b" import="i613591" active="yes">Prologue</name>
|
||||||
</item>
|
</item>
|
||||||
<item handle="db7e733775d4d" parent="b3643d0f92e32" root="b3643d0f92e32" order="3" type="FILE" class="NOVEL" layout="DOCUMENT">
|
<item handle="db7e733775d4d" parent="b3643d0f92e32" root="b3643d0f92e32" order="3" type="FILE" class="NOVEL" layout="DOCUMENT">
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
},
|
},
|
||||||
"88d59a277361b": {
|
"88d59a277361b": {
|
||||||
"headings": {
|
"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": {
|
"db7e733775d4d": {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?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: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>
|
<office:meta>
|
||||||
<meta:creation-date>2024-03-14T23:26:28</meta:creation-date>
|
<meta:creation-date>2024-04-24T18:30:38</meta:creation-date>
|
||||||
<meta:generator>novelWriter/2.4a2</meta:generator>
|
<meta:generator>novelWriter/2.5a2</meta:generator>
|
||||||
<meta:initial-creator>Jane Smith</meta:initial-creator>
|
<meta:initial-creator>Jane Smith</meta:initial-creator>
|
||||||
<meta:editing-cycles>1234</meta:editing-cycles>
|
<meta:editing-cycles>1234</meta:editing-cycles>
|
||||||
<meta:editing-duration>P42DT12H34M56S</meta:editing-duration>
|
<meta:editing-duration>P42DT12H34M56S</meta:editing-duration>
|
||||||
<dc:title>Test Project</dc:title>
|
<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>
|
<dc:creator>Jane Smith</dc:creator>
|
||||||
</office:meta>
|
</office:meta>
|
||||||
<office:font-face-decls>
|
<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:text-properties style:font-name="Liberation Serif" fo:font-family="'Liberation Serif'" fo:font-size="12pt" />
|
||||||
</style:style>
|
</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: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:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
|
<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%" />
|
<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: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:paragraph-properties fo:text-align="right" />
|
||||||
</style:style>
|
</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:styles>
|
||||||
<office:automatic-styles>
|
<office:automatic-styles>
|
||||||
<style:page-layout style:name="PM1">
|
<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-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-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>
|
</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:text-properties style:font-name="Liberation Serif" fo:font-family="'Liberation Serif'" fo:font-size="12pt" />
|
||||||
</style:style>
|
</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: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:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
|
<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%" />
|
<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: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:paragraph-properties fo:text-align="right" />
|
||||||
</style:style>
|
</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:styles>
|
||||||
<office:automatic-styles>
|
<office:automatic-styles>
|
||||||
<style:page-layout style:name="PM1">
|
<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-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-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>
|
</style:header-style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?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">
|
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="3">
|
||||||
<name>New Project</name>
|
<name>New Project</name>
|
||||||
<author>Jane Doe</author>
|
<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.
|
**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
|
# 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.
|
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>
|
<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>
|
<h1 style='page-break-before: always;'>Prologue</h1>
|
||||||
<p class='synopsis'><strong>Synopsis:</strong> Explanation from the lipsum.com website.</p>
|
<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>
|
<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>
|
<p style='text-align: center;'>“Fusce maximus felis libero”</p>
|
||||||
<h1 style='page-break-before: always;'>Chapter: Chapter One</h1>
|
<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>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>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>
|
<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>
|
</article>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"projectName": "Lorem Ipsum",
|
"projectName": "Lorem Ipsum",
|
||||||
"novelAuthor": "lipsum.com",
|
"novelAuthor": "lipsum.com",
|
||||||
"buildTime": 1710799449,
|
"buildTime": 1714229171,
|
||||||
"buildTimeStr": "2024-03-18 23:04:09"
|
"buildTimeStr": "2024-04-27 16:46:11"
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"css": [
|
"css": [
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
[
|
[
|
||||||
"<h1 style='page-break-before: always;'>Prologue</h1>",
|
"<h1 style='page-break-before: always;'>Prologue</h1>",
|
||||||
"<p class='synopsis'><strong>Synopsis:</strong> Explanation from the lipsum.com website.</p>",
|
"<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>",
|
"<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>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>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>"
|
"<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": {
|
"meta": {
|
||||||
"projectName": "Lorem Ipsum",
|
"projectName": "Lorem Ipsum",
|
||||||
"novelAuthor": "lipsum.com",
|
"novelAuthor": "lipsum.com",
|
||||||
"buildTime": 1711014280,
|
"buildTime": 1714229171,
|
||||||
"buildTimeStr": "2024-03-21 10:44:40"
|
"buildTimeStr": "2024-04-27 16:46:11"
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"nwd": [
|
"nwd": [
|
||||||
@@ -29,7 +29,9 @@
|
|||||||
"",
|
"",
|
||||||
"% Synopsis: Explanation from the lipsum.com website.",
|
"% 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",
|
"# 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.
|
% 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
|
# Act One
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?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: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>
|
<office:meta>
|
||||||
<meta:creation-date>2024-03-14T23:42:49</meta:creation-date>
|
<meta:creation-date>2024-04-27T16:43:44</meta:creation-date>
|
||||||
<meta:generator>novelWriter/2.4a2</meta:generator>
|
<meta:generator>novelWriter/2.5a2</meta:generator>
|
||||||
<meta:initial-creator>lipsum.com</meta:initial-creator>
|
<meta:initial-creator>lipsum.com</meta:initial-creator>
|
||||||
<meta:editing-cycles>44</meta:editing-cycles>
|
<meta:editing-cycles>45</meta:editing-cycles>
|
||||||
<meta:editing-duration>P0DT0H33M59S</meta:editing-duration>
|
<meta:editing-duration>P0DT0H36M8S</meta:editing-duration>
|
||||||
<dc:title>Lorem Ipsum</dc:title>
|
<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>
|
<dc:creator>lipsum.com</dc:creator>
|
||||||
</office:meta>
|
</office:meta>
|
||||||
<office:font-face-decls>
|
<office:font-face-decls>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-size="12pt" />
|
<style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-size="12pt" />
|
||||||
</style:style>
|
</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: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:style style:name="Text_20_Meta" style:family="paragraph" style:display-name="Text Meta" style:parent-style-name="Standard" style:class="text">
|
<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%" />
|
<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: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:paragraph-properties fo:text-align="right" />
|
||||||
</style:style>
|
</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:styles>
|
||||||
<office:automatic-styles>
|
<office:automatic-styles>
|
||||||
<style:page-layout style:name="PM1">
|
<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-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-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>
|
</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: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: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_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: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: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>
|
<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.
|
**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
|
# 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.
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -21,26 +21,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from tools import writeFile
|
import pytest
|
||||||
from mocked import causeOSError
|
|
||||||
|
|
||||||
from PyQt5.QtGui import QColor, QDesktopServices
|
|
||||||
from PyQt5.QtCore import QUrl
|
from PyQt5.QtCore import QUrl
|
||||||
|
from PyQt5.QtGui import QColor, QDesktopServices
|
||||||
|
|
||||||
from novelwriter.common import (
|
from novelwriter.common import (
|
||||||
checkBool, checkFloat, checkInt, checkIntTuple, checkPath, checkString,
|
NWConfigParser, checkBool, checkFloat, checkInt, checkIntTuple, checkPath,
|
||||||
checkStringNone, checkUuid, cssCol, formatFileFilter, formatInt,
|
checkString, checkStringNone, checkUuid, cssCol, elide, formatFileFilter,
|
||||||
formatTime, formatTimeStamp, formatVersion, fuzzyTime, getFileSize,
|
formatInt, formatTime, formatTimeStamp, formatVersion, fuzzyTime,
|
||||||
hexToInt, isHandle, isItemClass, isItemLayout, isItemType, isTitleTag,
|
getFileSize, hexToInt, isHandle, isItemClass, isItemLayout, isItemType,
|
||||||
jsonEncode, makeFileNameSafe, minmax, numberToRoman, NWConfigParser,
|
isListInstance, isTitleTag, jsonEncode, makeFileNameSafe, minmax,
|
||||||
openExternalPath, readTextFile, simplified, transferCase, xmlIndent, yesNo
|
numberToRoman, openExternalPath, readTextFile, simplified, transferCase,
|
||||||
|
xmlIndent, yesNo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from tests.mocked import causeOSError
|
||||||
|
from tests.tools import writeFile
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.base
|
@pytest.mark.base
|
||||||
def testBaseCommon_checkStringNone():
|
def testBaseCommon_checkStringNone():
|
||||||
@@ -272,6 +274,24 @@ def testBaseCommon_isItemLayout():
|
|||||||
# END Test 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
|
@pytest.mark.base
|
||||||
def testBaseCommon_hexToInt():
|
def testBaseCommon_hexToInt():
|
||||||
"""Test the hexToInt function."""
|
"""Test the hexToInt function."""
|
||||||
@@ -368,6 +388,26 @@ def testBaseCommon_simplified():
|
|||||||
# END Test 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
|
@pytest.mark.base
|
||||||
def testBaseCommon_yesNo():
|
def testBaseCommon_yesNo():
|
||||||
"""Test the yesNo function."""
|
"""Test the yesNo function."""
|
||||||
|
|||||||
@@ -20,16 +20,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
import pytest
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import uuid
|
||||||
|
|
||||||
from shutil import copyfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from shutil import copyfile
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE
|
import pytest
|
||||||
from mocked import causeOSError
|
|
||||||
|
|
||||||
from novelwriter import CONFIG
|
from novelwriter import CONFIG
|
||||||
from novelwriter.constants import nwConst, nwFiles, nwItemClass
|
from novelwriter.constants import nwConst, nwFiles, nwItemClass
|
||||||
@@ -38,6 +36,9 @@ from novelwriter.core.coretools import (
|
|||||||
)
|
)
|
||||||
from novelwriter.core.project import NWProject
|
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
|
@pytest.mark.core
|
||||||
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
|
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
|
||||||
@@ -512,7 +513,7 @@ def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Create a new project from a project dictionary, with chapters."""
|
||||||
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
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
|
@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."""
|
"""Create a new project from a project dictionary, without chapters."""
|
||||||
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
monkeypatch.setattr("uuid.uuid4", lambda *a: uuid.UUID("d0f3fe10-c6e6-4310-8bfd-181eb4224eed"))
|
||||||
|
|
||||||
|
|||||||
@@ -21,19 +21,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
|
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
from tools import C, buildTestProject, cmpFiles, writeFile
|
import pytest
|
||||||
from mocked import causeException
|
|
||||||
|
|
||||||
from novelwriter import SHARED
|
from novelwriter import SHARED
|
||||||
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
|
|
||||||
from novelwriter.constants import nwFiles
|
from novelwriter.constants import nwFiles
|
||||||
|
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, _checkModKey, processComment
|
||||||
from novelwriter.core.item import NWItem
|
from novelwriter.core.item import NWItem
|
||||||
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, processComment
|
|
||||||
from novelwriter.core.project import NWProject
|
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
|
@pytest.mark.core
|
||||||
@@ -122,12 +123,14 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
|
|||||||
assert cmpFiles(testFile, compFile)
|
assert cmpFiles(testFile, compFile)
|
||||||
|
|
||||||
# Write an empty index file and load it
|
# Write an empty index file and load it
|
||||||
writeFile(projFile, "{}")
|
projFile.write_text("{}", encoding="utf-8")
|
||||||
assert index.loadIndex() is False
|
assert index.loadIndex() is False
|
||||||
assert index.indexBroken is True
|
assert index.indexBroken is True
|
||||||
|
|
||||||
# Write an index file that passes loading, but is still empty
|
# 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.loadIndex() is True
|
||||||
assert index.indexBroken is False
|
assert index.indexBroken is False
|
||||||
|
|
||||||
@@ -306,7 +309,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
|
def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
|
||||||
"""Check the index text scanner."""
|
"""Check the index text scanner."""
|
||||||
project = NWProject()
|
project = NWProject()
|
||||||
mockRnd.reset()
|
mockRnd.reset()
|
||||||
@@ -376,12 +379,14 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
|
|||||||
"@char: Jane\n\n"
|
"@char: Jane\n\n"
|
||||||
"% this is a comment\n\n"
|
"% this is a comment\n\n"
|
||||||
"This is a story about Jane Smith.\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.tagHandle("Jane") == cHandle
|
||||||
assert index._tagsIndex.tagHeading("Jane") == "T0001"
|
assert index._tagsIndex.tagHeading("Jane") == "T0001"
|
||||||
assert index._tagsIndex.tagClass("Jane") == "CHARACTER"
|
assert index._tagsIndex.tagClass("Jane") == "CHARACTER"
|
||||||
assert index.getItemHeading(nHandle, "T0001").title == "Hello World!" # type: ignore
|
assert index.getItemHeading(nHandle, "T0001").title == "Hello World!" # type: ignore
|
||||||
|
assert index._itemIndex[nHandle].noteKeys("footnotes") == {"key"} # type: ignore
|
||||||
|
|
||||||
# Title Indexing
|
# Title Indexing
|
||||||
# ==============
|
# ==============
|
||||||
@@ -548,6 +553,45 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd):
|
|||||||
# END Test testCoreIndex_ScanText
|
# 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
|
@pytest.mark.core
|
||||||
def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
|
def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
|
||||||
"""Check the index data extraction functions."""
|
"""Check the index data extraction functions."""
|
||||||
@@ -1249,12 +1293,14 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
|
|||||||
itemIndex.clear()
|
itemIndex.clear()
|
||||||
|
|
||||||
# Data must be dictionary
|
# Data must be dictionary
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData("stuff") # type: ignore
|
itemIndex.unpackData("stuff") # type: ignore
|
||||||
|
assert str(exc.value) == "itemIndex is not a dict"
|
||||||
|
|
||||||
# Keys must be valid handles
|
# Keys must be valid handles
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData({"stuff": "more stuff"})
|
itemIndex.unpackData({"stuff": "more stuff"})
|
||||||
|
assert str(exc.value) == "itemIndex keys must be handles"
|
||||||
|
|
||||||
# Unknown keys should be skipped
|
# Unknown keys should be skipped
|
||||||
itemIndex.unpackData({C.hInvalid: {}})
|
itemIndex.unpackData({C.hInvalid: {}})
|
||||||
@@ -1266,8 +1312,9 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
|
|||||||
assert itemIndex[nHandle].handle == nHandle # type: ignore
|
assert itemIndex[nHandle].handle == nHandle # type: ignore
|
||||||
|
|
||||||
# Title tags must be valid
|
# Title tags must be valid
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}})
|
itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}})
|
||||||
|
assert str(exc.value) == "The itemIndex contains an invalid title key"
|
||||||
|
|
||||||
# Reference without a heading should be rejected
|
# Reference without a heading should be rejected
|
||||||
itemIndex.unpackData({
|
itemIndex.unpackData({
|
||||||
@@ -1281,37 +1328,66 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
|
|||||||
itemIndex.clear()
|
itemIndex.clear()
|
||||||
|
|
||||||
# Tag keys must be strings
|
# Tag keys must be strings
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData({
|
itemIndex.unpackData({
|
||||||
cHandle: {
|
cHandle: {
|
||||||
"headings": {"T0001": {}},
|
"headings": {"T0001": {}},
|
||||||
"references": {"T0001": {1234: "@pov"}},
|
"references": {"T0001": {1234: "@pov"}},
|
||||||
|
"notes": {"footnotes": [], "comments": []},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
assert str(exc.value) == "itemIndex reference key must be a string"
|
||||||
|
|
||||||
# Type must be strings
|
# Type must be strings
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData({
|
itemIndex.unpackData({
|
||||||
cHandle: {
|
cHandle: {
|
||||||
"headings": {"T0001": {}},
|
"headings": {"T0001": {}},
|
||||||
"references": {"T0001": {"John": []}},
|
"references": {"T0001": {"John": []}},
|
||||||
|
"notes": {"footnotes": [], "comments": []},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
assert str(exc.value) == "itemIndex reference type must be a string"
|
||||||
|
|
||||||
# Types must be valid
|
# Types must be valid
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError) as exc:
|
||||||
itemIndex.unpackData({
|
itemIndex.unpackData({
|
||||||
cHandle: {
|
cHandle: {
|
||||||
"headings": {"T0001": {}},
|
"headings": {"T0001": {}},
|
||||||
"references": {"T0001": {"John": "@pov,@char,@stuff"}},
|
"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
|
# This should pass
|
||||||
itemIndex.unpackData({
|
itemIndex.unpackData({
|
||||||
cHandle: {
|
cHandle: {
|
||||||
"headings": {"T0001": {}},
|
"headings": {"T0001": {}},
|
||||||
"references": {"T0001": {"John": "@pov,@char"}},
|
"references": {"T0001": {"John": "@pov,@char"}},
|
||||||
|
"notes": {"footnotes": ["fkey"], "comments": ["ckey"]},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1319,29 +1395,87 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreIndex_processComment():
|
def testCoreIndex_checkModKey():
|
||||||
"""Test the comment processing function."""
|
"""Test the _checkModKey function."""
|
||||||
# Regular comment
|
# Check Requirements
|
||||||
assert processComment("%Hi") == (nwComment.PLAIN, "Hi", 0)
|
|
||||||
assert processComment("% Hi") == (nwComment.PLAIN, "Hi", 0)
|
|
||||||
assert processComment("% Hi:You") == (nwComment.PLAIN, "Hi:You", 0)
|
|
||||||
|
|
||||||
# Synopsis
|
# Synopsis
|
||||||
assert processComment("%synopsis:") == (nwComment.PLAIN, "synopsis:", 0)
|
assert _checkModKey("synopsis", "") is True
|
||||||
assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "Hi", 10)
|
assert _checkModKey("synopsis", "a") is False
|
||||||
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)
|
|
||||||
|
|
||||||
# Short Description
|
# Short
|
||||||
assert processComment("%short:") == (nwComment.PLAIN, "short:", 0)
|
assert _checkModKey("short", "") is True
|
||||||
assert processComment("%short: Hi") == (nwComment.SHORT, "Hi", 7)
|
assert _checkModKey("short", "a") is False
|
||||||
assert processComment("% short: Hi") == (nwComment.SHORT, "Hi", 8)
|
|
||||||
assert processComment("% short : Hi") == (nwComment.SHORT, "Hi", 10)
|
# Note
|
||||||
assert processComment("% Short : Hi") == (nwComment.SHORT, "Hi", 12)
|
assert _checkModKey("note", "") is True
|
||||||
assert processComment("% \t SHORT : Hi") == (nwComment.SHORT, "Hi", 13)
|
assert _checkModKey("note", "a") is True
|
||||||
assert processComment("% \t SHORT : Hi:You") == (nwComment.SHORT, "Hi:You", 13)
|
|
||||||
|
# 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
|
# END Test testCoreIndex_processComment
|
||||||
|
|||||||
@@ -21,21 +21,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
|
|
||||||
from shutil import copyfile
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from novelwriter.constants import nwFiles
|
from shutil import copyfile
|
||||||
|
|
||||||
from novelwriter.enum import nwStatusShape
|
import pytest
|
||||||
from tools import cmpFiles, writeFile
|
|
||||||
from mocked import causeOSError
|
|
||||||
|
|
||||||
from PyQt5.QtGui import QColor
|
from PyQt5.QtGui import QColor
|
||||||
|
|
||||||
|
from novelwriter.constants import nwFiles
|
||||||
from novelwriter.core.item import NWItem
|
from novelwriter.core.item import NWItem
|
||||||
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
|
|
||||||
from novelwriter.core.projectdata import NWProjectData
|
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:
|
class MockProject:
|
||||||
@@ -55,7 +56,7 @@ def mockVersion(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
|
def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath):
|
||||||
"""Test reading the current XML file format."""
|
"""Test reading the current XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.5.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.5.nwx"
|
||||||
tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx"
|
tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx"
|
||||||
@@ -249,7 +250,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Test reading the version 1.0 XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.0.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.0.nwx"
|
||||||
xmlFile = fncPath / "nwProject-1.0.nwx"
|
xmlFile = fncPath / "nwProject-1.0.nwx"
|
||||||
@@ -396,7 +397,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Test reading the version 1.1 XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.1.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.1.nwx"
|
||||||
xmlFile = fncPath / "nwProject-1.1.nwx"
|
xmlFile = fncPath / "nwProject-1.1.nwx"
|
||||||
@@ -543,7 +544,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Test reading the version 1.2 XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.2.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.2.nwx"
|
||||||
xmlFile = fncPath / "nwProject-1.2.nwx"
|
xmlFile = fncPath / "nwProject-1.2.nwx"
|
||||||
@@ -693,7 +694,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Test reading the version 1.3 XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.3.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.3.nwx"
|
||||||
xmlFile = fncPath / "nwProject-1.3.nwx"
|
xmlFile = fncPath / "nwProject-1.3.nwx"
|
||||||
@@ -843,7 +844,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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."""
|
"""Test reading the version 1.4 XML file format."""
|
||||||
refFile = tstPaths.filesDir / "nwProject-1.4.nwx"
|
refFile = tstPaths.filesDir / "nwProject-1.4.nwx"
|
||||||
xmlFile = fncPath / "nwProject-1.4.nwx"
|
xmlFile = fncPath / "nwProject-1.4.nwx"
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools import readFile
|
|
||||||
|
|
||||||
from novelwriter.core.tohtml import ToHtml
|
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
|
from novelwriter.core.tohtml import ToHtml
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@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"
|
"<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
|
# Multiple Keywords
|
||||||
html._isFirst = False
|
html._isFirst = False
|
||||||
html.setKeywords(True)
|
html.setKeywords(True)
|
||||||
@@ -241,6 +257,30 @@ def testCoreToHtml_ConvertParagraphs(mockGUI):
|
|||||||
"<span class='tags'>Locations:</span> <a href='#tag_Europe'>Europe</a></p>\n"
|
"<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
|
# Preview Mode
|
||||||
# ============
|
# ============
|
||||||
|
|
||||||
@@ -453,7 +493,7 @@ def testCoreToHtml_SpecialCases(mockGUI):
|
|||||||
html.doConvert()
|
html.doConvert()
|
||||||
assert html.result == (
|
assert html.result == (
|
||||||
"<p class='comment'>"
|
"<p class='comment'>"
|
||||||
"<strong>Comment:</strong> Test > text _<**bold**>_ and more."
|
"<strong>Comment:</strong> Test > text <em><<strong>bold</strong>></em> and more."
|
||||||
"</p>\n"
|
"</p>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -480,7 +520,7 @@ def testCoreToHtml_SpecialCases(mockGUI):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreToHtml_Complex(mockGUI, fncPath):
|
def testCoreToHtml_Save(mockGUI, fncPath):
|
||||||
"""Test the save method of the ToHtml class."""
|
"""Test the save method of the ToHtml class."""
|
||||||
project = NWProject()
|
project = NWProject()
|
||||||
html = ToHtml(project)
|
html = ToHtml(project)
|
||||||
@@ -498,36 +538,28 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
|
|||||||
"### Scene 2\n\nThe text of scene two.\n",
|
"### Scene 2\n\nThe text of scene two.\n",
|
||||||
"#### A Section\n\n\tMore text in scene two.\n",
|
"#### A Section\n\n\tMore text in scene two.\n",
|
||||||
]
|
]
|
||||||
resText = [
|
resText = [(
|
||||||
(
|
"<h1 class='title' style='text-align: center;'>My Novel</h1>\n"
|
||||||
"<h1 class='title' style='text-align: center;'>My Novel</h1>\n"
|
"<p><strong>By Jane Doh</strong></p>\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"
|
||||||
"<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"
|
||||||
(
|
), (
|
||||||
"<h2>Scene 1</h2>\n"
|
"<h3>A Section</h3>\n"
|
||||||
"<p>The text of scene one.</p>\n"
|
"<p>More text in scene one.</p>\n"
|
||||||
),
|
), (
|
||||||
(
|
"<h1 style='page-break-before: always;'>Chapter 2</h1>\n"
|
||||||
"<h3>A Section</h3>\n"
|
"<p>The text of chapter two.</p>\n"
|
||||||
"<p>More text in scene one.</p>\n"
|
), (
|
||||||
),
|
"<h2>Scene 2</h2>\n"
|
||||||
(
|
"<p>The text of scene two.</p>\n"
|
||||||
"<h1 style='page-break-before: always;'>Chapter 2</h1>\n"
|
), (
|
||||||
"<p>The text of chapter two.</p>\n"
|
"<h3>A Section</h3>\n"
|
||||||
),
|
"<p>\tMore text in scene 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)):
|
for i in range(len(docText)):
|
||||||
html._text = docText[i]
|
html._text = docText[i]
|
||||||
@@ -541,9 +573,10 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
|
|||||||
html.replaceTabs(nSpaces=2, spaceChar=" ")
|
html.replaceTabs(nSpaces=2, spaceChar=" ")
|
||||||
resText[6] = "<h3>A Section</h3>\n<p> More text in scene two.</p>\n"
|
resText[6] = "<h3>A Section</h3>\n<p> More text in scene two.</p>\n"
|
||||||
|
|
||||||
# Check File
|
# Check Files
|
||||||
# ==========
|
# ===========
|
||||||
|
|
||||||
|
# HTML
|
||||||
hStyle = html.getStyleSheet()
|
hStyle = html.getStyleSheet()
|
||||||
htmlDoc = (
|
htmlDoc = (
|
||||||
"<!DOCTYPE html>\n"
|
"<!DOCTYPE html>\n"
|
||||||
@@ -568,9 +601,20 @@ def testCoreToHtml_Complex(mockGUI, fncPath):
|
|||||||
|
|
||||||
saveFile = fncPath / "outFile.htm"
|
saveFile = fncPath / "outFile.htm"
|
||||||
html.saveHtml5(saveFile)
|
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
|
@pytest.mark.core
|
||||||
|
|||||||
@@ -21,14 +21,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools import C, buildTestProject, readFile
|
|
||||||
|
|
||||||
from novelwriter.constants import nwHeadFmt
|
from novelwriter.constants import nwHeadFmt
|
||||||
from novelwriter.core.tomd import ToMarkdown
|
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
from novelwriter.core.tokenizer import HeadingFormatter, Tokenizer, stripEscape
|
from novelwriter.core.tokenizer import HeadingFormatter, Tokenizer, stripEscape
|
||||||
|
from novelwriter.core.tomd import ToMarkdown
|
||||||
|
|
||||||
|
from tests.tools import C, buildTestProject, readFile
|
||||||
|
|
||||||
|
|
||||||
class BareTokenizer(Tokenizer):
|
class BareTokenizer(Tokenizer):
|
||||||
@@ -869,30 +870,31 @@ def testCoreToken_ExtractFormats(mockGUI):
|
|||||||
# Plain bold
|
# Plain bold
|
||||||
text, fmt = tokens._extractFormats("Text with **bold** in it.")
|
text, fmt = tokens._extractFormats("Text with **bold** in it.")
|
||||||
assert text == "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
|
# Plain italics
|
||||||
text, fmt = tokens._extractFormats("Text with _italics_ in it.")
|
text, fmt = tokens._extractFormats("Text with _italics_ in it.")
|
||||||
assert text == "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
|
# Plain strikethrough
|
||||||
text, fmt = tokens._extractFormats("Text with ~~strikethrough~~ in it.")
|
text, fmt = tokens._extractFormats("Text with ~~strikethrough~~ in it.")
|
||||||
assert text == "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
|
# Nested bold/italics
|
||||||
text, fmt = tokens._extractFormats("Text with **bold and _italics_** in it.")
|
text, fmt = tokens._extractFormats("Text with **bold and _italics_** in it.")
|
||||||
assert text == "Text with bold and italics in it."
|
assert text == "Text with bold and italics in it."
|
||||||
assert fmt == [
|
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
|
# Bold with overlapping italics
|
||||||
# Here, bold is ignored because it is not on word boundary
|
# Here, bold is ignored because it is not on word boundary
|
||||||
text, fmt = tokens._extractFormats("Text with **bold and overlapping _italics**_ in it.")
|
text, fmt = tokens._extractFormats("Text with **bold and overlapping _italics**_ in it.")
|
||||||
assert text == "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
|
# Shortcodes
|
||||||
# ==========
|
# ==========
|
||||||
@@ -900,43 +902,44 @@ def testCoreToken_ExtractFormats(mockGUI):
|
|||||||
# Plain bold
|
# Plain bold
|
||||||
text, fmt = tokens._extractFormats("Text with [b]bold[/b] in it.")
|
text, fmt = tokens._extractFormats("Text with [b]bold[/b] in it.")
|
||||||
assert text == "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
|
# Plain italics
|
||||||
text, fmt = tokens._extractFormats("Text with [i]italics[/i] in it.")
|
text, fmt = tokens._extractFormats("Text with [i]italics[/i] in it.")
|
||||||
assert text == "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
|
# Plain strikethrough
|
||||||
text, fmt = tokens._extractFormats("Text with [s]strikethrough[/s] in it.")
|
text, fmt = tokens._extractFormats("Text with [s]strikethrough[/s] in it.")
|
||||||
assert text == "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, "")]
|
||||||
|
|
||||||
# Plain underline
|
# Plain underline
|
||||||
text, fmt = tokens._extractFormats("Text with [u]underline[/u] in it.")
|
text, fmt = tokens._extractFormats("Text with [u]underline[/u] in it.")
|
||||||
assert text == "Text with underline 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
|
# Plain mark
|
||||||
text, fmt = tokens._extractFormats("Text with [m]highlight[/m] in it.")
|
text, fmt = tokens._extractFormats("Text with [m]highlight[/m] in it.")
|
||||||
assert text == "Text with highlight 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
|
# Plain superscript
|
||||||
text, fmt = tokens._extractFormats("Text with super[sup]script[/sup] in it.")
|
text, fmt = tokens._extractFormats("Text with super[sup]script[/sup] in it.")
|
||||||
assert text == "Text with superscript 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
|
# Plain subscript
|
||||||
text, fmt = tokens._extractFormats("Text with sub[sub]script[/sub] in it.")
|
text, fmt = tokens._extractFormats("Text with sub[sub]script[/sub] in it.")
|
||||||
assert text == "Text with subscript 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
|
# Nested bold/italics
|
||||||
text, fmt = tokens._extractFormats("Text with [b]bold and [i]italics[/i][/b] in it.")
|
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 text == "Text with bold and italics in it."
|
||||||
assert fmt == [
|
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
|
# Bold with overlapping italics
|
||||||
@@ -946,7 +949,8 @@ def testCoreToken_ExtractFormats(mockGUI):
|
|||||||
)
|
)
|
||||||
assert text == "Text with bold and overlapping italics in it."
|
assert text == "Text with bold and overlapping italics in it."
|
||||||
assert fmt == [
|
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
|
# So does this
|
||||||
@@ -955,7 +959,8 @@ def testCoreToken_ExtractFormats(mockGUI):
|
|||||||
)
|
)
|
||||||
assert text == "Text with bold and overlapping italics in it."
|
assert text == "Text with bold and overlapping italics in it."
|
||||||
assert fmt == [
|
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
|
# END Test testCoreToken_ExtractFormats
|
||||||
@@ -998,8 +1003,8 @@ def testCoreToken_TextFormat(mockGUI):
|
|||||||
Tokenizer.T_TEXT, 0,
|
Tokenizer.T_TEXT, 0,
|
||||||
"Some bolded text on this lines",
|
"Some bolded text on this lines",
|
||||||
[
|
[
|
||||||
(5, Tokenizer.FMT_B_B),
|
(5, Tokenizer.FMT_B_B, ""),
|
||||||
(16, Tokenizer.FMT_B_E),
|
(16, Tokenizer.FMT_B_E, ""),
|
||||||
],
|
],
|
||||||
Tokenizer.A_NONE
|
Tokenizer.A_NONE
|
||||||
),
|
),
|
||||||
@@ -1014,8 +1019,8 @@ def testCoreToken_TextFormat(mockGUI):
|
|||||||
Tokenizer.T_TEXT, 0,
|
Tokenizer.T_TEXT, 0,
|
||||||
"Some italic text on this lines",
|
"Some italic text on this lines",
|
||||||
[
|
[
|
||||||
(5, Tokenizer.FMT_I_B),
|
(5, Tokenizer.FMT_I_B, ""),
|
||||||
(16, Tokenizer.FMT_I_E),
|
(16, Tokenizer.FMT_I_E, ""),
|
||||||
],
|
],
|
||||||
Tokenizer.A_NONE
|
Tokenizer.A_NONE
|
||||||
),
|
),
|
||||||
@@ -1030,10 +1035,10 @@ def testCoreToken_TextFormat(mockGUI):
|
|||||||
Tokenizer.T_TEXT, 0,
|
Tokenizer.T_TEXT, 0,
|
||||||
"Some bold italic text on this lines",
|
"Some bold italic text on this lines",
|
||||||
[
|
[
|
||||||
(5, Tokenizer.FMT_B_B),
|
(5, Tokenizer.FMT_B_B, ""),
|
||||||
(5, Tokenizer.FMT_I_B),
|
(5, Tokenizer.FMT_I_B, ""),
|
||||||
(21, Tokenizer.FMT_I_E),
|
(21, Tokenizer.FMT_I_E, ""),
|
||||||
(21, Tokenizer.FMT_B_E),
|
(21, Tokenizer.FMT_B_E, ""),
|
||||||
],
|
],
|
||||||
Tokenizer.A_NONE
|
Tokenizer.A_NONE
|
||||||
),
|
),
|
||||||
@@ -1048,8 +1053,8 @@ def testCoreToken_TextFormat(mockGUI):
|
|||||||
Tokenizer.T_TEXT, 0,
|
Tokenizer.T_TEXT, 0,
|
||||||
"Some strikethrough text on this lines",
|
"Some strikethrough text on this lines",
|
||||||
[
|
[
|
||||||
(5, Tokenizer.FMT_D_B),
|
(5, Tokenizer.FMT_D_B, ""),
|
||||||
(23, Tokenizer.FMT_D_E),
|
(23, Tokenizer.FMT_D_E, ""),
|
||||||
],
|
],
|
||||||
Tokenizer.A_NONE
|
Tokenizer.A_NONE
|
||||||
),
|
),
|
||||||
@@ -1064,12 +1069,12 @@ def testCoreToken_TextFormat(mockGUI):
|
|||||||
Tokenizer.T_TEXT, 0,
|
Tokenizer.T_TEXT, 0,
|
||||||
"Some nested bold and italic and strikethrough text here",
|
"Some nested bold and italic and strikethrough text here",
|
||||||
[
|
[
|
||||||
(5, Tokenizer.FMT_B_B),
|
(5, Tokenizer.FMT_B_B, ""),
|
||||||
(21, Tokenizer.FMT_I_B),
|
(21, Tokenizer.FMT_I_B, ""),
|
||||||
(27, Tokenizer.FMT_I_E),
|
(27, Tokenizer.FMT_I_E, ""),
|
||||||
(32, Tokenizer.FMT_D_B),
|
(32, Tokenizer.FMT_D_B, ""),
|
||||||
(45, Tokenizer.FMT_D_E),
|
(45, Tokenizer.FMT_D_E, ""),
|
||||||
(50, Tokenizer.FMT_B_E),
|
(50, Tokenizer.FMT_B_E, ""),
|
||||||
],
|
],
|
||||||
Tokenizer.A_NONE
|
Tokenizer.A_NONE
|
||||||
),
|
),
|
||||||
@@ -2137,7 +2142,7 @@ def testCoreToken_CounterHandling(mockGUI):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreToken_HeadingFormatter(fncPath, mockRnd):
|
def testCoreToken_HeadingFormatter(fncPath, mockGUI, mockRnd):
|
||||||
"""Check the HeadingFormatter class."""
|
"""Check the HeadingFormatter class."""
|
||||||
project = NWProject()
|
project = NWProject()
|
||||||
project.setProjectLang("en_GB")
|
project.setProjectLang("en_GB")
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools import readFile
|
|
||||||
|
|
||||||
from novelwriter.core.tomd import ToMarkdown
|
|
||||||
from novelwriter.core.project import NWProject
|
from novelwriter.core.project import NWProject
|
||||||
|
from novelwriter.core.tomd import ToMarkdown
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
@@ -134,6 +132,13 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI):
|
|||||||
toMD.doConvert()
|
toMD.doConvert()
|
||||||
assert toMD.result == "Line one \nLine two \nLine three\n\n"
|
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
|
# Synopsis, Short
|
||||||
toMD._text = "%synopsis: The synopsis ...\n"
|
toMD._text = "%synopsis: The synopsis ...\n"
|
||||||
toMD.tokenizeText()
|
toMD.tokenizeText()
|
||||||
@@ -188,6 +193,22 @@ def testCoreToMarkdown_ConvertParagraphs(mockGUI):
|
|||||||
"**Locations:** Europe\n\n"
|
"**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
|
# END Test testCoreToMarkdown_ConvertParagraphs
|
||||||
|
|
||||||
|
|
||||||
@@ -233,17 +254,18 @@ def testCoreToMarkdown_ConvertDirect(mockGUI):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.core
|
@pytest.mark.core
|
||||||
def testCoreToMarkdown_Complex(mockGUI, fncPath):
|
def testCoreToMarkdown_Save(mockGUI, fncPath):
|
||||||
"""Test the save method of the ToMarkdown class."""
|
"""Test the save method of the ToMarkdown class."""
|
||||||
project = NWProject()
|
project = NWProject()
|
||||||
toMD = ToMarkdown(project)
|
toMD = ToMarkdown(project)
|
||||||
|
toMD.setKeepMarkdown(True)
|
||||||
toMD._isNovel = True
|
toMD._isNovel = True
|
||||||
|
|
||||||
# Build Project
|
# Build Project
|
||||||
# =============
|
# =============
|
||||||
|
|
||||||
docText = [
|
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",
|
"## Chapter 1\n\nThe text of chapter one.\n",
|
||||||
"### Scene 1\n\nThe text of scene one.\n",
|
"### Scene 1\n\nThe text of scene one.\n",
|
||||||
"#### A Section\n\nMore text in 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=" ")
|
toMD.replaceTabs(nSpaces=4, spaceChar=" ")
|
||||||
resText[6] = "#### A Section\n\n More text in scene two.\n\n"
|
resText[6] = "#### A Section\n\n More text in scene two.\n\n"
|
||||||
|
assert toMD.allMarkdown == resText
|
||||||
|
|
||||||
# Check File
|
# Check File
|
||||||
# ==========
|
# ==========
|
||||||
|
|
||||||
saveFile = fncPath / "outFile.md"
|
saveFile = fncPath / "outFile.md"
|
||||||
toMD.saveMarkdown(saveFile)
|
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
|
@pytest.mark.core
|
||||||
|
|||||||
@@ -20,18 +20,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
|
||||||
import zipfile
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
from tools import ODT_IGNORE, cmpFiles
|
import pytest
|
||||||
|
|
||||||
from novelwriter.common import xmlIndent
|
from novelwriter.common import xmlIndent
|
||||||
from novelwriter.constants import nwHeadFmt
|
from novelwriter.constants import nwHeadFmt
|
||||||
from novelwriter.core.toodt import ToOdt, ODTParagraphStyle, ODTTextStyle, XMLParagraph, _mkTag
|
|
||||||
from novelwriter.core.project import NWProject
|
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 = [
|
XML_NS = [
|
||||||
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
|
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
|
||||||
@@ -132,7 +133,7 @@ def testCoreToOdt_TextFormatting(mockGUI):
|
|||||||
|
|
||||||
assert list(odt._mainPara.keys()) == [
|
assert list(odt._mainPara.keys()) == [
|
||||||
"Text_20_body", "First_20_line_20_indent", "Text_20_Meta", "Title", "Separator",
|
"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"
|
key = "55db6c1d22ff5aba93f0f67c8d4a857a26e2d3813dfbcba1ef7c0d424f501be5"
|
||||||
@@ -145,51 +146,51 @@ def testCoreToOdt_TextFormatting(mockGUI):
|
|||||||
oStyle = ODTParagraphStyle("test")
|
oStyle = ODTParagraphStyle("test")
|
||||||
|
|
||||||
# No Text
|
# No Text
|
||||||
odt.initDocument()
|
xTest = ET.Element(_mkTag("office", "text"))
|
||||||
odt._addTextPar("Standard", oStyle, "")
|
odt._addTextPar(xTest, "Standard", oStyle, "")
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard" />'
|
'<text:p text:style-name="Standard" />'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# No Format
|
# No Format
|
||||||
odt.initDocument()
|
xTest = ET.Element(_mkTag("office", "text"))
|
||||||
odt._addTextPar("Standard", oStyle, "Hello World")
|
odt._addTextPar(xTest, "Standard", oStyle, "Hello World")
|
||||||
assert odt.errData == []
|
assert odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard">Hello World</text:p>'
|
'<text:p text:style-name="Standard">Hello World</text:p>'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Heading Level None
|
# Heading Level None
|
||||||
odt.initDocument()
|
xTest = ET.Element(_mkTag("office", "text"))
|
||||||
odt._addTextPar("Standard", oStyle, "Hello World", isHead=True)
|
odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True)
|
||||||
assert odt.errData == []
|
assert odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:h text:style-name="Standard">Hello World</text:h>'
|
'<text:h text:style-name="Standard">Hello World</text:h>'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Heading Level 1
|
# Heading Level 1
|
||||||
odt.initDocument()
|
xTest = ET.Element(_mkTag("office", "text"))
|
||||||
odt._addTextPar("Standard", oStyle, "Hello World", isHead=True, oLevel="1")
|
odt._addTextPar(xTest, "Standard", oStyle, "Hello World", isHead=True, oLevel="1")
|
||||||
assert odt.errData == []
|
assert odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:h text:style-name="Standard" text:outline-level="1">Hello World</text:h>'
|
'<text:h text:style-name="Standard" text:outline-level="1">Hello World</text:h>'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Formatted Text
|
# Formatted Text
|
||||||
odt.initDocument()
|
|
||||||
text = "A bold word"
|
text = "A bold word"
|
||||||
fmt = [(2, odt.FMT_B_B), (6, odt.FMT_B_E)]
|
fmt = [(2, odt.FMT_B_B, ""), (6, odt.FMT_B_E, "")]
|
||||||
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 odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">bold</text:span> '
|
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">bold</text:span> '
|
||||||
'word</text:p>'
|
'word</text:p>'
|
||||||
@@ -197,25 +198,26 @@ def testCoreToOdt_TextFormatting(mockGUI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Incorrectly Formatted Text
|
# Incorrectly Formatted Text
|
||||||
odt.initDocument()
|
|
||||||
text = "A few words"
|
text = "A few words"
|
||||||
fmt = [(2, odt.FMT_B_B), (5, odt.FMT_B_E), (7, 99999)]
|
fmt = [(2, odt.FMT_B_B, ""), (5, odt.FMT_B_E, ""), (7, 99999, "")]
|
||||||
odt._addTextPar("Standard", oStyle, text, tFmt=fmt)
|
xTest = ET.Element(_mkTag("office", "text"))
|
||||||
|
odt._addTextPar(xTest, "Standard", oStyle, text, tFmt=fmt)
|
||||||
assert odt.errData == ["Unknown format tag encountered"]
|
assert odt.errData == ["Unknown format tag encountered"]
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">few</text:span> '
|
'<text:p text:style-name="Standard">A <text:span text:style-name="T1">few</text:span> '
|
||||||
'words</text:p>'
|
'words</text:p>'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
)
|
)
|
||||||
|
odt._errData = []
|
||||||
|
|
||||||
# Unclosed format
|
# Unclosed format
|
||||||
odt.initDocument()
|
|
||||||
text = "A bold word"
|
text = "A bold word"
|
||||||
fmt = [(2, odt.FMT_B_B)]
|
fmt = [(2, odt.FMT_B_B, "")]
|
||||||
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 odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard">A '
|
'<text:p text:style-name="Standard">A '
|
||||||
'<text:span text:style-name="T1">bold word</text:span></text:p>'
|
'<text:span text:style-name="T1">bold word</text:span></text:p>'
|
||||||
@@ -223,12 +225,12 @@ def testCoreToOdt_TextFormatting(mockGUI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Tabs and Breaks
|
# Tabs and Breaks
|
||||||
odt.initDocument()
|
|
||||||
text = "Hello\n\tWorld"
|
text = "Hello\n\tWorld"
|
||||||
fmt = []
|
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 odt.errData == []
|
||||||
assert xmlToText(odt._xText) == (
|
assert xmlToText(xTest) == (
|
||||||
'<office:text>'
|
'<office:text>'
|
||||||
'<text:p text:style-name="Standard">Hello<text:line-break /><text:tab />World</text:p>'
|
'<text:p text:style-name="Standard">Hello<text:line-break /><text:tab />World</text:p>'
|
||||||
'</office:text>'
|
'</office:text>'
|
||||||
@@ -619,6 +621,43 @@ def testCoreToOdt_ConvertParagraphs(mockGUI):
|
|||||||
'</office:text>'
|
'</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
|
# Test for issue #1412
|
||||||
# ====================
|
# ====================
|
||||||
# See: https://github.com/vkbo/novelWriter/issues/1412
|
# See: https://github.com/vkbo/novelWriter/issues/1412
|
||||||
@@ -835,22 +874,28 @@ def testCoreToOdt_Format(mockGUI):
|
|||||||
project = NWProject()
|
project = NWProject()
|
||||||
odt = ToOdt(project, isFlat=True)
|
odt = ToOdt(project, isFlat=True)
|
||||||
|
|
||||||
assert odt._formatSynopsis("synopsis text", True) == (
|
assert odt._formatSynopsis("synopsis text", [(9, ToOdt.FMT_STRIP, "")], True) == (
|
||||||
"Synopsis: synopsis text", [(0, ToOdt.FMT_B_B), (9, ToOdt.FMT_B_E)]
|
"Synopsis: synopsis text", [
|
||||||
|
(0, ToOdt.FMT_B_B, ""), (9, ToOdt.FMT_B_E, ""), (19, ToOdt.FMT_STRIP, "")
|
||||||
|
]
|
||||||
)
|
)
|
||||||
assert odt._formatSynopsis("short text", False) == (
|
assert odt._formatSynopsis("short text", [(6, ToOdt.FMT_STRIP, "")], False) == (
|
||||||
"Short Description: short text", [(0, ToOdt.FMT_B_B), (18, ToOdt.FMT_B_E)]
|
"Short Description: short text", [
|
||||||
|
(0, ToOdt.FMT_B_B, ""), (18, ToOdt.FMT_B_E, ""), (25, ToOdt.FMT_STRIP, "")
|
||||||
|
]
|
||||||
)
|
)
|
||||||
assert odt._formatComments("comment text") == (
|
assert odt._formatComments("comment text", [(8, ToOdt.FMT_STRIP, "")]) == (
|
||||||
"Comment: comment text", [(0, ToOdt.FMT_B_B), (8, ToOdt.FMT_B_E)]
|
"Comment: comment text", [
|
||||||
|
(0, ToOdt.FMT_B_B, ""), (8, ToOdt.FMT_B_E, ""), (17, ToOdt.FMT_STRIP, "")
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert odt._formatKeywords("") == ("", [])
|
assert odt._formatKeywords("") == ("", [])
|
||||||
assert odt._formatKeywords("tag: Jane") == (
|
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") == (
|
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
|
# END Test testCoreToOdt_Format
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools import C, writeFile, buildTestProject
|
from PyQt5.QtGui import QTextBlock, QTextCursor
|
||||||
|
|
||||||
from PyQt5.QtGui import QTextCursor, QTextBlock
|
|
||||||
from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox
|
from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox
|
||||||
|
from tools import C, buildTestProject, writeFile
|
||||||
|
|
||||||
from novelwriter import CONFIG, SHARED
|
from novelwriter import CONFIG, SHARED
|
||||||
from novelwriter.constants import nwKeyWords, nwUnicode
|
from novelwriter.constants import nwKeyWords, nwUnicode
|
||||||
@@ -146,7 +145,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum):
|
|||||||
|
|
||||||
# Check comment with no space before text
|
# Check comment with no space before text
|
||||||
nwGUI.docEditor.setCursorPosition(54)
|
nwGUI.docEditor.setCursorPosition(54)
|
||||||
assert nwGUI.docEditor.insertText("%")
|
nwGUI.docEditor.insertText("%")
|
||||||
fmtStr = "%Pellentesque nec erat ut nulla posuere commodo."
|
fmtStr = "%Pellentesque nec erat ut nulla posuere commodo."
|
||||||
assert nwGUI.docEditor.getText()[54:102] == fmtStr
|
assert nwGUI.docEditor.getText()[54:102] == fmtStr
|
||||||
|
|
||||||
@@ -435,14 +434,14 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd):
|
|||||||
nwGUI.docEditor.clear()
|
nwGUI.docEditor.clear()
|
||||||
|
|
||||||
# Test Faulty Inserts
|
# Test Faulty Inserts
|
||||||
assert nwGUI.docEditor.insertText("hello world")
|
nwGUI.docEditor.insertText("hello world")
|
||||||
assert nwGUI.docEditor.getText() == "hello world"
|
assert nwGUI.docEditor.getText() == "hello world"
|
||||||
nwGUI.docEditor.clear()
|
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.isEmpty
|
||||||
|
|
||||||
assert nwGUI.docEditor.insertText(None) is False
|
nwGUI.docEditor.insertText(None)
|
||||||
assert nwGUI.docEditor.isEmpty
|
assert nwGUI.docEditor.isEmpty
|
||||||
|
|
||||||
# qtbot.stop()
|
# qtbot.stop()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
|
|||||||
search.searchText.setText("Lorem")
|
search.searchText.setText("Lorem")
|
||||||
search.searchAction.activate(QAction.ActionEvent.Trigger)
|
search.searchAction.activate(QAction.ActionEvent.Trigger)
|
||||||
assert search.searchResult.topLevelItemCount() == 14
|
assert search.searchResult.topLevelItemCount() == 14
|
||||||
assert totalCount() == 42
|
assert totalCount() == 43
|
||||||
|
|
||||||
firstDoc = search.searchResult.topLevelItem(0)
|
firstDoc = search.searchResult.topLevelItem(0)
|
||||||
firstResult = firstDoc.child(0)
|
firstResult = firstDoc.child(0)
|
||||||
@@ -98,7 +98,7 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
|
|||||||
search.toggleCase.setChecked(True)
|
search.toggleCase.setChecked(True)
|
||||||
search.searchAction.activate(QAction.ActionEvent.Trigger)
|
search.searchAction.activate(QAction.ActionEvent.Trigger)
|
||||||
assert search.searchResult.topLevelItemCount() == 7
|
assert search.searchResult.topLevelItemCount() == 7
|
||||||
assert totalCount() == 17
|
assert totalCount() == 18
|
||||||
search.toggleCase.setChecked(False)
|
search.toggleCase.setChecked(False)
|
||||||
|
|
||||||
# Whole Words
|
# Whole Words
|
||||||
|
|||||||
Reference in New Issue
Block a user