Fix variable formatting for DocX and ODT

This commit is contained in:
Veronica Berglyd Olsen
2024-10-29 13:15:58 +01:00
parent 2a3d3e46c2
commit 3749c9f21d
11 changed files with 181 additions and 95 deletions
+29 -4
View File
@@ -492,6 +492,10 @@ def jsonEncode(data: dict | list | tuple, n: int = 0, nmax: int = 0) -> str:
return "".join(buffer) return "".join(buffer)
##
# XML Helpers
##
def xmlIndent(tree: ET.Element | ET.ElementTree) -> None: def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
"""A modified version of the XML indent function in the standard """A modified version of the XML indent function in the standard
library. It behaves more closely to how the one from lxml does. library. It behaves more closely to how the one from lxml does.
@@ -535,21 +539,42 @@ def xmlIndent(tree: ET.Element | ET.ElementTree) -> None:
return return
def xmlElement(
tag: str,
text: str | int | float | bool | None = None,
*,
attrib: dict | None = None,
tail: str | None = None,
) -> ET.Element:
"""A custom implementation of Element with more arguments."""
xSub = ET.Element(tag, attrib=attrib or {})
if text is not None:
if isinstance(text, bool):
xSub.text = str(text).lower()
else:
xSub.text = str(text)
if tail is not None:
xSub.tail = tail
return xSub
def xmlSubElem( def xmlSubElem(
parent: ET.Element, parent: ET.Element,
tag: str, tag: str,
text: str | int | float | bool | None = None, text: str | int | float | bool | None = None,
attrib: dict | None = None *,
attrib: dict | None = None,
tail: str | None = None,
) -> ET.Element: ) -> ET.Element:
"""A custom implementation of SubElement that takes text as an """A custom implementation of SubElement with more arguments."""
argument.
"""
xSub = ET.SubElement(parent, tag, attrib=attrib or {}) xSub = ET.SubElement(parent, tag, attrib=attrib or {})
if text is not None: if text is not None:
if isinstance(text, bool): if isinstance(text, bool):
xSub.text = str(text).lower() xSub.text = str(text).lower()
else: else:
xSub.text = str(text) xSub.text = str(text)
if tail is not None:
xSub.tail = tail
return xSub return xSub
+2 -2
View File
@@ -334,12 +334,12 @@ class nwLabels:
nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"), nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"),
nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"), nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"),
nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"), nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"),
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"), nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "HTML 5 (.html)"),
nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"), nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"),
nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"), nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"),
nwBuildFmt.EXT_MD: QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)"), nwBuildFmt.EXT_MD: QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)"),
nwBuildFmt.PDF: QT_TRANSLATE_NOOP("Constant", "Portable Document Format (.pdf)"), nwBuildFmt.PDF: QT_TRANSLATE_NOOP("Constant", "Portable Document Format (.pdf)"),
nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)"), nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + HTML 5 (.json)"),
nwBuildFmt.J_NWD: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markup (.json)"), nwBuildFmt.J_NWD: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markup (.json)"),
} }
BUILD_EXT = { BUILD_EXT = {
+3 -3
View File
@@ -184,11 +184,11 @@ class nwBuildFmt(Enum):
ODT = 0 ODT = 0
FODT = 1 FODT = 1
DOCX = 2 DOCX = 2
HTML = 3 PDF = 3
NWD = 4 HTML = 4
STD_MD = 5 STD_MD = 5
EXT_MD = 6 EXT_MD = 6
PDF = 7 NWD = 7
J_HTML = 8 J_HTML = 8
J_NWD = 9 J_NWD = 9
+37 -37
View File
@@ -37,7 +37,7 @@ from PyQt5.QtCore import QMarginsF, QSizeF
from PyQt5.QtGui import QColor from PyQt5.QtGui import QColor
from novelwriter import __version__ from novelwriter import __version__
from novelwriter.common import firstFloat, xmlSubElem from novelwriter.common import firstFloat, xmlElement, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwStyles from novelwriter.constants import nwHeadFmt, nwStyles
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt
@@ -57,13 +57,13 @@ RELS_BASE = f"{OOXML_SCM}/officeDocument/2006/relationships"
# Main XML NameSpaces # Main XML NameSpaces
XML_NS = { XML_NS = {
"r": RELS_BASE,
"w": f"{OOXML_SCM}/wordprocessingml/2006/main",
"cp": f"{OOXML_SCM}/package/2006/metadata/core-properties", "cp": f"{OOXML_SCM}/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/", "dc": "http://purl.org/dc/elements/1.1/",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xml": "http://www.w3.org/XML/1998/namespace",
"dcterms": "http://purl.org/dc/terms/", "dcterms": "http://purl.org/dc/terms/",
"r": RELS_BASE,
"w": f"{OOXML_SCM}/wordprocessingml/2006/main",
"xml": "http://www.w3.org/XML/1998/namespace",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
} }
for ns, uri in XML_NS.items(): for ns, uri in XML_NS.items():
ET.register_namespace(ns, uri) ET.register_namespace(ns, uri)
@@ -328,10 +328,10 @@ class ToDocX(Tokenizer):
cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE)) cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
# Relationships XML # Relationships XML
rRels = ET.Element("Relationships", attrib={ rRels = xmlElement("Relationships", attrib={
"xmlns": f"{OOXML_SCM}/package/2006/relationships" "xmlns": f"{OOXML_SCM}/package/2006/relationships"
}) })
wRels = ET.Element("Relationships", attrib={ wRels = xmlElement("Relationships", attrib={
"xmlns": f"{OOXML_SCM}/package/2006/relationships" "xmlns": f"{OOXML_SCM}/package/2006/relationships"
}) })
for name, rel in self._rels.items(): for name, rel in self._rels.items():
@@ -347,7 +347,7 @@ class ToDocX(Tokenizer):
xmlSubElem(rRels if isRoot else wRels, "Relationship", attrib=attrib) xmlSubElem(rRels if isRoot else wRels, "Relationship", attrib=attrib)
# Content Types XML # Content Types XML
dTypes = ET.Element("Types", attrib={ dTypes = xmlElement("Types", attrib={
"xmlns": f"{OOXML_SCM}/package/2006/content-types" "xmlns": f"{OOXML_SCM}/package/2006/content-types"
}) })
for name, content in cExts: for name, content in cExts:
@@ -355,10 +355,8 @@ class ToDocX(Tokenizer):
for name, content in cDocs: for name, content in cDocs:
xmlSubElem(dTypes, "Override", attrib={"PartName": name, "ContentType": content}) xmlSubElem(dTypes, "Override", attrib={"PartName": name, "ContentType": content})
def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None: def xmlToZip(name: str, root: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj: zipObj.writestr(name, ET.tostring(root, encoding="utf-8", xml_declaration=True))
xml = ET.ElementTree(xObj)
xml.write(fObj, encoding="utf-8", xml_declaration=True)
with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip: with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip:
xmlToZip("_rels/.rels", rRels, outZip) xmlToZip("_rels/.rels", rRels, outZip)
@@ -435,7 +433,7 @@ class ToDocX(Tokenizer):
elif fFmt == TextFmt.FNOTE: elif fFmt == TextFmt.FNOTE:
xNode = self._generateFootnote(fData) xNode = self._generateFootnote(fData)
elif fFmt == TextFmt.FIELD: elif fFmt == TextFmt.FIELD:
xNode = self._generateField(fData) xNode = self._generateField(fData, xFmt)
elif fFmt == TextFmt.STRIP: elif fFmt == TextFmt.STRIP:
pass pass
@@ -450,9 +448,9 @@ class ToDocX(Tokenizer):
return return
def _textRunToXml(self, text: str, fmt: int, fClass: str, fLink: str) -> ET.Element: def _textRunToXml(self, text: str | None, fmt: int, fClass: str, fLink: str) -> ET.Element:
"""Encode the text run into XML.""" """Encode the text run into XML."""
xR = ET.Element(_wTag("r")) xR = xmlElement(_wTag("r"))
rPr = xmlSubElem(xR, _wTag("rPr")) rPr = xmlSubElem(xR, _wTag("rPr"))
if fmt & X_BLD: if fmt & X_BLD:
xmlSubElem(rPr, _wTag("b")) xmlSubElem(rPr, _wTag("b"))
@@ -473,18 +471,20 @@ class ToDocX(Tokenizer):
if fmt & X_COL and (color := self._classes.get(fClass)): if fmt & X_COL and (color := self._classes.get(fClass)):
xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(color)}) xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(color)})
for segment in RX_TEXT.split(text): if isinstance(text, str):
if segment == "\n": for segment in RX_TEXT.split(text):
xmlSubElem(xR, _wTag("br")) if segment == "\n":
elif segment == "\t": xmlSubElem(xR, _wTag("br"))
xmlSubElem(xR, _wTag("tab")) elif segment == "\t":
elif segment: xmlSubElem(xR, _wTag("tab"))
_wText(xR, segment) elif segment:
_wText(xR, segment)
if fmt & X_HRF and fLink: if fmt & X_HRF and fLink:
xmlSubElem(rPr, _wTag("rStyle"), attrib={W_VAL: "InternetLink"}) xmlSubElem(rPr, _wTag("rStyle"), attrib={W_VAL: "InternetLink"})
rId = self._appendExternalRel(fLink) xH = xmlElement(_wTag("hyperlink"), attrib={
xH = ET.Element(_wTag("hyperlink"), attrib={_mkTag("r", "id"): rId}) _mkTag("r", "id"): self._appendExternalRel(fLink),
})
xH.append(xR) xH.append(xR)
return xH return xH
@@ -498,7 +498,7 @@ class ToDocX(Tokenizer):
"""Generate a footnote XML object.""" """Generate a footnote XML object."""
if key in self._footnotes: if key in self._footnotes:
idx = len(self._usedNotes) + 1 idx = len(self._usedNotes) + 1
xR = ET.Element(_wTag("r")) xR = xmlElement(_wTag("r"))
rPr = xmlSubElem(xR, _wTag("rPr")) rPr = xmlSubElem(xR, _wTag("rPr"))
xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"}) xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
xmlSubElem(xR, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)}) xmlSubElem(xR, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)})
@@ -506,11 +506,11 @@ class ToDocX(Tokenizer):
return xR return xR
return None return None
def _generateField(self, key: str) -> ET.Element | None: def _generateField(self, key: str, fmt: int) -> ET.Element | None:
"""Generate a data field XML object.""" """Generate a data field XML object."""
if key and (field := key.partition(":")[2]): if key and (field := key.partition(":")[2]):
xR = ET.Element(_wTag("r")) xR = self._textRunToXml(None, fmt, "", "")
xT = xmlSubElem(xR, _wTag("t"), "0") xT = _wText(xR, "0")
self._usedFields.append((xT, field)) self._usedFields.append((xT, field))
return xR return xR
return None return None
@@ -687,7 +687,7 @@ class ToDocX(Tokenizer):
def _appXml(self) -> str: def _appXml(self) -> str:
"""Populate app.xml.""" """Populate app.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element("Properties", attrib={ xRoot = xmlElement("Properties", attrib={
"xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties" "xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties"
}) })
self._rels["app.xml"] = DocXXmlRel( self._rels["app.xml"] = DocXXmlRel(
@@ -716,7 +716,7 @@ class ToDocX(Tokenizer):
def _coreXml(self) -> str: def _coreXml(self) -> str:
"""Populate app.xml.""" """Populate app.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_mkTag("cp", "coreProperties")) xRoot = xmlElement(_mkTag("cp", "coreProperties"))
self._rels["core.xml"] = DocXXmlRel( self._rels["core.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{OOXML_SCM}/package/2006/relationships/metadata/core-properties", relType=f"{OOXML_SCM}/package/2006/relationships/metadata/core-properties",
@@ -742,7 +742,7 @@ class ToDocX(Tokenizer):
def _stylesXml(self) -> str: def _stylesXml(self) -> str:
"""Populate styles.xml.""" """Populate styles.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("styles")) xRoot = xmlElement(_wTag("styles"))
self._rels["styles.xml"] = DocXXmlRel( self._rels["styles.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/styles", relType=f"{RELS_BASE}/styles",
@@ -830,7 +830,7 @@ class ToDocX(Tokenizer):
def _defaultHeaderXml(self) -> str: def _defaultHeaderXml(self) -> str:
"""Populate header1.xml.""" """Populate header1.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("hdr")) xRoot = xmlElement(_wTag("hdr"))
self._rels["header1.xml"] = DocXXmlRel( self._rels["header1.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/header", relType=f"{RELS_BASE}/header",
@@ -877,7 +877,7 @@ class ToDocX(Tokenizer):
def _firstHeaderXml(self) -> str: def _firstHeaderXml(self) -> str:
"""Populate header2.xml.""" """Populate header2.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("hdr")) xRoot = xmlElement(_wTag("hdr"))
self._rels["header2.xml"] = DocXXmlRel( self._rels["header2.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/header", relType=f"{RELS_BASE}/header",
@@ -902,7 +902,7 @@ class ToDocX(Tokenizer):
def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str: def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str:
"""Populate document.xml.""" """Populate document.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("document")) xRoot = xmlElement(_wTag("document"))
xRoot.set("xmlns:w14", "http://schemas.microsoft.com/office/word/2010/wordml") xRoot.set("xmlns:w14", "http://schemas.microsoft.com/office/word/2010/wordml")
xBody = xmlSubElem(xRoot, _wTag("body")) xBody = xmlSubElem(xRoot, _wTag("body"))
self._rels["document.xml"] = DocXXmlRel( self._rels["document.xml"] = DocXXmlRel(
@@ -978,7 +978,7 @@ class ToDocX(Tokenizer):
def _footnotesXml(self) -> str: def _footnotesXml(self) -> str:
"""Populate footnotes.xml.""" """Populate footnotes.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("footnotes")) xRoot = xmlElement(_wTag("footnotes"))
self._rels["footnotes.xml"] = DocXXmlRel( self._rels["footnotes.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/footnotes", relType=f"{RELS_BASE}/footnotes",
@@ -1001,7 +1001,7 @@ class ToDocX(Tokenizer):
def _fontTableXml(self) -> str: def _fontTableXml(self) -> str:
"""Populate fontTable.xml.""" """Populate fontTable.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("fonts")) xRoot = xmlElement(_wTag("fonts"))
self._rels["fontTable.xml"] = DocXXmlRel( self._rels["fontTable.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/fontTable", relType=f"{RELS_BASE}/fontTable",
@@ -1024,7 +1024,7 @@ class ToDocX(Tokenizer):
def _settingsXml(self) -> str: def _settingsXml(self) -> str:
"""Populate settings.xml.""" """Populate settings.xml."""
rId = self._nextRelId() rId = self._nextRelId()
xRoot = ET.Element(_wTag("settings")) xRoot = xmlElement(_wTag("settings"))
self._rels["settings.xml"] = DocXXmlRel( self._rels["settings.xml"] = DocXXmlRel(
rId=rId, rId=rId,
relType=f"{RELS_BASE}/settings", relType=f"{RELS_BASE}/settings",
+32 -14
View File
@@ -38,7 +38,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
from PyQt5.QtGui import QColor, QFont from PyQt5.QtGui import QColor, QFont
from novelwriter import __version__ from novelwriter import __version__
from novelwriter.common import xmlIndent, xmlSubElem from novelwriter.common import xmlElement, xmlIndent, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwStyles from novelwriter.constants import nwHeadFmt, nwStyles
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
from novelwriter.formats.shared import BlockFmt, BlockTyp, TextFmt, stripEscape from novelwriter.formats.shared import BlockFmt, BlockTyp, TextFmt, stripEscape
@@ -54,6 +54,7 @@ XML_NS = {
"loext": "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0", "loext": "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0",
"manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", "manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0",
"meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
"number": "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0",
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
@@ -115,6 +116,7 @@ S_FIND = "First_20_line_20_indent"
S_TEXT = "Text_20_body" S_TEXT = "Text_20_body"
S_META = "Text_20_Meta" S_META = "Text_20_Meta"
S_HNF = "Header_20_and_20_Footer" S_HNF = "Header_20_and_20_Footer"
S_NUM = "N0"
# Font Data # Font Data
FONT_WEIGHT_NUM = ["100", "200", "300", "400", "500", "600", "700", "800", "900"] FONT_WEIGHT_NUM = ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
@@ -513,13 +515,14 @@ class ToOdt(Tokenizer):
oVers = _mkTag("office", "version") oVers = _mkTag("office", "version")
xSett = ET.Element(oRoot, attrib={oVers: X_VERS}) xSett = ET.Element(oRoot, attrib={oVers: X_VERS})
def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None: def xmlToZip(name: str, root: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj: zipObj.writestr(
xml = ET.ElementTree(xObj) name, ET.tostring(root, encoding="utf-8", xml_declaration=True),
xml.write(fObj, encoding="utf-8", xml_declaration=True) compress_type=ZIP_DEFLATED, compresslevel=3,
)
with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip: with ZipFile(path, mode="w") as outZip:
outZip.writestr("mimetype", X_MIME) outZip.writestr("mimetype", X_MIME, compress_type=None, compresslevel=None)
xmlToZip("META-INF/manifest.xml", xMani, outZip) xmlToZip("META-INF/manifest.xml", xMani, outZip)
xmlToZip("settings.xml", xSett, outZip) xmlToZip("settings.xml", xSett, outZip)
xmlToZip("content.xml", self._dCont, outZip) xmlToZip("content.xml", self._dCont, outZip)
@@ -627,7 +630,7 @@ class ToOdt(Tokenizer):
elif fFmt == TextFmt.FNOTE: elif fFmt == TextFmt.FNOTE:
xNode = self._generateFootnote(fData) xNode = self._generateFootnote(fData)
elif fFmt == TextFmt.FIELD: elif fFmt == TextFmt.FIELD:
xNode = self._generateField(fData) xNode = self._generateField(fData, xFmt)
elif fFmt == TextFmt.STRIP: elif fFmt == TextFmt.STRIP:
pass pass
@@ -713,24 +716,31 @@ class ToOdt(Tokenizer):
if content := self._footnotes.get(key): if content := self._footnotes.get(key):
self._nNote += 1 self._nNote += 1
nStyle = ODTParagraphStyle("New") nStyle = ODTParagraphStyle("New")
xNote = ET.Element(_mkTag("text", "note"), attrib={ xNote = xmlElement(_mkTag("text", "note"), attrib={
_mkTag("text", "id"): f"ftn{self._nNote}", _mkTag("text", "id"): f"ftn{self._nNote}",
_mkTag("text", "note-class"): "footnote", _mkTag("text", "note-class"): "footnote",
}) })
xmlSubElem(xNote, _mkTag("text", "note-citation"), self._nNote) xmlSubElem(xNote, _mkTag("text", "note-citation"), self._nNote)
xBody = ET.SubElement(xNote, _mkTag("text", "note-body")) xBody = xmlSubElem(xNote, _mkTag("text", "note-body"))
self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1]) self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1])
return xNote return xNote
return None return None
def _generateField(self, key: str) -> ET.Element | None: def _generateField(self, key: str, fmt: int) -> ET.Element | None:
"""Generate a data field XML object.""" """Generate a data field XML object."""
if key and (field := key.partition(":")[2]): if key and (field := key.partition(":")[2]):
xField = ET.Element(_mkTag("text", "user-field-get"), attrib={ xField = xmlElement(_mkTag("text", "user-field-get"), "0", tail="", attrib={
_mkTag("style", "data-style-name"): S_NUM,
_mkTag("text", "name"): f"Manuscript{field[:1].upper()}{field[1:]}", _mkTag("text", "name"): f"Manuscript{field[:1].upper()}{field[1:]}",
}) })
xField.text = "0" if fmt == 0x00:
return xField return xField
else:
xSpan = xmlElement(TAG_SPAN, "", tail="", attrib={
_mkTag("text", "style-name"): self._textStyle(fmt),
})
xSpan.append(xField)
return xSpan
return None return None
def _emToCm(self, value: float) -> str: def _emToCm(self, value: float) -> str:
@@ -836,6 +846,14 @@ class ToOdt(Tokenizer):
_mkTag("style", "class"): "extra", _mkTag("style", "class"): "extra",
}) })
# Numbers Style
xStyl = ET.SubElement(self._xStyl, _mkTag("number", "number-style"), attrib={
_mkTag("style", "name"): S_NUM,
})
ET.SubElement(xStyl, _mkTag("number", "number"), attrib={
_mkTag("number", "min-integer-digits"): "1",
})
return return
def _useableStyles(self) -> None: def _useableStyles(self) -> None:
@@ -33,6 +33,7 @@
<w:t xml:space="preserve">Word Count: </w:t> <w:t xml:space="preserve">Word Count: </w:t>
</w:r> </w:r>
<w:r> <w:r>
<w:rPr />
<w:t>4,035</w:t> <w:t>4,035</w:t>
</w:r> </w:r>
</w:p> </w:p>
@@ -46,6 +47,7 @@
<w:t xml:space="preserve">Character Count: </w:t> <w:t xml:space="preserve">Character Count: </w:t>
</w:r> </w:r>
<w:r> <w:r>
<w:rPr />
<w:t>27,064</w:t> <w:t>27,064</w:t>
</w:r> </w:r>
</w:p> </w:p>
@@ -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:number="urn:oasis:names:tc:opendocument:xmlns:datastyle: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-10-28T20:17:34</meta:creation-date> <meta:creation-date>2024-10-29T09:33:22</meta:creation-date>
<meta:generator>novelWriter/2.6a3</meta:generator> <meta:generator>novelWriter/2.6a3</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-10-28T20:17:34</dc:date> <dc:date>2024-10-29T09:33:22</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>
@@ -26,6 +26,9 @@
<style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" /> <style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" />
</style:style> </style:style>
<style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" /> <style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" />
<number:number-style style:name="N0">
<number:number number:min-integer-digits="1" />
</number:number-style>
<style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text"> <style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.246cm" fo:line-height="115%" fo:text-align="left" /> <style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.246cm" fo:line-height="115%" fo:text-align="left" />
<style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-size="12pt" fo:font-weight="normal" /> <style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-size="12pt" fo:font-weight="normal" />
+4 -1
View File
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<office:document-styles 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: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:document-styles 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:number="urn:oasis:names:tc:opendocument:xmlns:datastyle: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:font-face-decls> <office:font-face-decls>
<style:font-face style:name="Liberation Serif" style:font-pitch="variable" /> <style:font-face style:name="Liberation Serif" style:font-pitch="variable" />
</office:font-face-decls> </office:font-face-decls>
@@ -16,6 +16,9 @@
<style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" /> <style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" />
</style:style> </style:style>
<style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" /> <style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" />
<number:number-style style:name="N0">
<number:number number:min-integer-digits="1" />
</number:number-style>
<style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text"> <style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.246cm" fo:line-height="115%" fo:text-align="left" /> <style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.246cm" fo:line-height="115%" fo:text-align="left" />
<style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-size="12pt" fo:font-weight="normal" /> <style:text-properties style:font-name="Liberation Serif" fo:font-family="Liberation Serif" fo:font-size="12pt" fo:font-weight="normal" />
@@ -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" xmlns:xlink="http://www.w3.org/1999/xlink" 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:number="urn:oasis:names:tc:opendocument:xmlns:datastyle: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" xmlns:xlink="http://www.w3.org/1999/xlink" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:meta> <office:meta>
<meta:creation-date>2024-10-28T18:27:00</meta:creation-date> <meta:creation-date>2024-10-29T09:41:30</meta:creation-date>
<meta:generator>novelWriter/2.6a3</meta:generator> <meta:generator>novelWriter/2.6a3</meta:generator>
<meta:initial-creator>lipsum.com</meta:initial-creator> <meta:initial-creator>lipsum.com</meta:initial-creator>
<meta:editing-cycles>50</meta:editing-cycles> <meta:editing-cycles>50</meta:editing-cycles>
<meta:editing-duration>P0DT0H40M48S</meta:editing-duration> <meta:editing-duration>P0DT0H40M48S</meta:editing-duration>
<dc:title>Lorem Ipsum</dc:title> <dc:title>Lorem Ipsum</dc:title>
<dc:date>2024-10-28T18:27:00</dc:date> <dc:date>2024-10-29T09:41:30</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>
@@ -26,6 +26,9 @@
<style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" /> <style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-weight="normal" fo:font-style="normal" fo:font-size="15pt" />
</style:style> </style:style>
<style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" /> <style:style style:name="Header_20_and_20_Footer" style:display-name="Header and Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra" />
<number:number-style style:name="N0">
<number:number number:min-integer-digits="1" />
</number:number-style>
<style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text"> <style:style style:name="Text_20_body" style:family="paragraph" style:display-name="Text body" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.320cm" fo:line-height="150%" fo:text-align="left" /> <style:paragraph-properties fo:margin-top="0.000cm" fo:margin-bottom="0.320cm" fo:line-height="150%" fo:text-align="left" />
<style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-size="12pt" fo:font-weight="normal" /> <style:text-properties style:font-name="Arial" fo:font-family="Arial" fo:font-size="12pt" fo:font-weight="normal" />
@@ -164,8 +167,8 @@
</text:user-field-decls> </text:user-field-decls>
<text:p text:style-name="Title">Lorem Ipsum</text:p> <text:p text:style-name="Title">Lorem Ipsum</text:p>
<text:p text:style-name="P1"><text:span text:style-name="T1">By lipsum.com</text:span></text:p> <text:p text:style-name="P1"><text:span text:style-name="T1">By lipsum.com</text:span></text:p>
<text:p text:style-name="P1">Word Count: <text:user-field-get text:name="ManuscriptAllWords">0</text:user-field-get></text:p> <text:p text:style-name="P1">Word Count: <text:user-field-get style:data-style-name="N0" text:name="ManuscriptAllWords">0</text:user-field-get></text:p>
<text:p text:style-name="P1">Character Count: <text:user-field-get text:name="ManuscriptAllChars">0</text:user-field-get></text:p> <text:p text:style-name="P1">Character Count: <text:user-field-get style:data-style-name="N0" text:name="ManuscriptAllChars">0</text:user-field-get></text:p>
<text:p text:style-name="P1">“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”</text:p> <text:p text:style-name="P1">“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”</text:p>
<text:p text:style-name="P1">“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”</text:p> <text:p text:style-name="P1">“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”</text:p>
<text:p text:style-name="P2"><text:span text:style-name="T2">Comment:</text:span> <text:span text:style-name="T3">Exctracted from the lipsum.com website.</text:span></text:p> <text:p text:style-name="P2"><text:span text:style-name="T2">Comment:</text:span> <text:span text:style-name="T3">Exctracted from the lipsum.com website.</text:span></text:p>
+28 -2
View File
@@ -37,8 +37,8 @@ from novelwriter.common import (
formatTimeStamp, formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle, formatTimeStamp, formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle,
isItemClass, isItemLayout, isItemType, isListInstance, isTitleTag, isItemClass, isItemLayout, isItemType, isListInstance, isTitleTag,
jsonEncode, makeFileNameSafe, minmax, numberToRoman, openExternalPath, jsonEncode, makeFileNameSafe, minmax, numberToRoman, openExternalPath,
readTextFile, simplified, transferCase, uniqueCompact, xmlIndent, readTextFile, simplified, transferCase, uniqueCompact, xmlElement,
xmlSubElem, yesNo xmlIndent, xmlSubElem, yesNo
) )
from tests.mocked import causeOSError from tests.mocked import causeOSError
@@ -634,6 +634,29 @@ def testBaseCommon_xmlIndent():
assert data == "foobar" assert data == "foobar"
@pytest.mark.base
def testBaseCommon_xmlElement():
"""Test the xmlElement function."""
assert ET.tostring(
xmlElement("node", None, attrib={"a": "b"})
) == b'<node a="b" />'
assert ET.tostring(
xmlElement("node", "text", attrib={"a": "b"})
) == b'<node a="b">text</node>'
assert ET.tostring(
xmlElement("node", "text", tail="foo", attrib={"a": "b"})
) == b'<node a="b">text</node>foo'
assert ET.tostring(
xmlElement("node", 42, attrib={"a": "b"})
) == b'<node a="b">42</node>'
assert ET.tostring(
xmlElement("node", 3.14, attrib={"a": "b"})
) == b'<node a="b">3.14</node>'
assert ET.tostring(
xmlElement("node", True, attrib={"a": "b"})
) == b'<node a="b">true</node>'
@pytest.mark.base @pytest.mark.base
def testBaseCommon_xmlSubElem(): def testBaseCommon_xmlSubElem():
"""Test the xmlSubElem function.""" """Test the xmlSubElem function."""
@@ -643,6 +666,9 @@ def testBaseCommon_xmlSubElem():
assert ET.tostring( assert ET.tostring(
xmlSubElem(ET.Element("r"), "node", "text", attrib={"a": "b"}) xmlSubElem(ET.Element("r"), "node", "text", attrib={"a": "b"})
) == b'<node a="b">text</node>' ) == b'<node a="b">text</node>'
assert ET.tostring(
xmlSubElem(ET.Element("r"), "node", "text", tail="foo", attrib={"a": "b"})
) == b'<node a="b">text</node>foo'
assert ET.tostring( assert ET.tostring(
xmlSubElem(ET.Element("r"), "node", 42, attrib={"a": "b"}) xmlSubElem(ET.Element("r"), "node", 42, attrib={"a": "b"})
) == b'<node a="b">42</node>' ) == b'<node a="b">42</node>'
+30 -24
View File
@@ -321,7 +321,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths
@pytest.mark.core @pytest.mark.core
def testCoreDocBuild_DocX(monkeypatch, mockGUI, prjLipsum, fncPath): def testCoreDocBuild_DocX(mockGUI, prjLipsum, fncPath):
"""Test building a Word manuscript.""" """Test building a Word manuscript."""
project = NWProject() project = NWProject()
project.openProject(prjLipsum) project.openProject(prjLipsum)
@@ -352,22 +352,41 @@ def testCoreDocBuild_DocX(monkeypatch, mockGUI, prjLipsum, fncPath):
assert docFile.is_file() assert docFile.is_file()
assert zipfile.is_zipfile(docFile) assert zipfile.is_zipfile(docFile)
# Check Error Handling
# ====================
with monkeypatch.context() as mp: @pytest.mark.core
mp.setattr("builtins.open", causeOSError) def testCoreDocBuild_PDF(mockGUI, prjLipsum, fncPath):
"""Test building a PDF manuscript."""
project = NWProject()
project.openProject(prjLipsum)
docFile = fncPath / "Lorem Ipsum Err.fodt" build = BuildSettings()
for _ in docBuild.iterBuildDocument(docFile, nwBuildFmt.FODT): build.unpack(BUILD_CONF)
pass
assert docBuild.error == "OSError: Mock OSError" docBuild = NWBuildDocument(project, build)
assert not docFile.is_file() docBuild.queueAll()
assert len(docBuild) == 21
# Check Build
# ===========
docFile = fncPath / "Lorem Ipsum.pdf"
count = 0
error = []
for _, success in docBuild.iterBuildDocument(docFile, nwBuildFmt.PDF):
count += 1 if success else 0
if docBuild.error:
error.append(docBuild.error)
assert count == 19
assert error == []
assert docFile.is_file()
@pytest.mark.core @pytest.mark.core
def testCoreDocBuild_NWD(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): def testCoreDocBuild_NWD(mockGUI, prjLipsum, fncPath, tstPaths):
"""Test building a NWD manuscript.""" """Test building a NWD manuscript."""
project = NWProject() project = NWProject()
project.openProject(prjLipsum) project.openProject(prjLipsum)
@@ -420,19 +439,6 @@ def testCoreDocBuild_NWD(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths):
copyfile(docFile, tstFile) copyfile(docFile, tstFile)
assert cmpFiles(tstFile, cmpFile, ignoreLines=[5, 6]) assert cmpFiles(tstFile, cmpFile, ignoreLines=[5, 6])
# Check Error Handling
# ====================
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
docFile = fncPath / "Lorem Ipsum Err.md"
for _ in docBuild.iterBuildDocument(docFile, nwBuildFmt.NWD):
pass
assert docBuild.error == "OSError: Mock OSError"
assert not docFile.is_file()
@pytest.mark.core @pytest.mark.core
def testCoreDocBuild_Custom(mockGUI, fncPath: Path): def testCoreDocBuild_Custom(mockGUI, fncPath: Path):