From 80d2dfd9a4a48624de89bd9136478a56d5ef56e2 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 18 Oct 2024 16:37:04 +0200
Subject: [PATCH 01/16] Add structure for DocX documents
---
novelwriter/constants.py | 2 +
novelwriter/core/docbuild.py | 8 ++
novelwriter/enum.py | 15 +--
novelwriter/formats/todocx.py | 216 ++++++++++++++++++++++++++++++++++
4 files changed, 234 insertions(+), 7 deletions(-)
create mode 100644 novelwriter/formats/todocx.py
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 00b4c88cc..fa93bfe2c 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -267,6 +267,7 @@ class nwLabels:
BUILD_FMT = {
nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"),
nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"),
+ nwBuildFmt.DOCX: QT_TRANSLATE_NOOP("Constant", "Microsoft Word Document (.docx)"),
nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"),
nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"),
nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"),
@@ -278,6 +279,7 @@ class nwLabels:
BUILD_EXT = {
nwBuildFmt.ODT: ".odt",
nwBuildFmt.FODT: ".fodt",
+ nwBuildFmt.DOCX: ".docx",
nwBuildFmt.HTML: ".html",
nwBuildFmt.NWD: ".txt",
nwBuildFmt.STD_MD: ".md",
diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 732b2d76e..562ae052f 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -37,6 +37,7 @@
from novelwriter.core.project import NWProject
from novelwriter.enum import nwBuildFmt
from novelwriter.error import formatException, logException
+from novelwriter.formats.todocx import ToDocX
from novelwriter.formats.tohtml import ToHtml
from novelwriter.formats.tokenizer import Tokenizer
from novelwriter.formats.tomarkdown import ToMarkdown
@@ -185,6 +186,13 @@ def iterBuildDocument(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[i
if self._build.getBool("format.replaceTabs"):
makeObj.replaceTabs(nSpaces=4, spaceChar=" ")
+ elif bFormat == nwBuildFmt.DOCX:
+ makeObj = ToDocX(self._project)
+ filtered = self._setupBuild(makeObj)
+ makeObj.initDocument()
+
+ yield from self._iterBuild(makeObj, filtered)
+
elif bFormat == nwBuildFmt.PDF:
makeObj = ToQTextDocument(self._project)
filtered = self._setupBuild(makeObj)
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 55bcbfc6b..75081d36d 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -180,13 +180,14 @@ class nwBuildFmt(Enum):
ODT = 0
FODT = 1
- HTML = 2
- NWD = 3
- STD_MD = 4
- EXT_MD = 5
- PDF = 6
- J_HTML = 7
- J_NWD = 8
+ DOCX = 2
+ HTML = 3
+ NWD = 4
+ STD_MD = 5
+ EXT_MD = 6
+ PDF = 7
+ J_HTML = 8
+ J_NWD = 9
class nwStatusShape(Enum):
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
new file mode 100644
index 000000000..69d020e68
--- /dev/null
+++ b/novelwriter/formats/todocx.py
@@ -0,0 +1,216 @@
+"""
+novelWriter – DOCX Text Converter
+=================================
+
+File History:
+Created: 2024-10-15 [2.6b1] ToRaw
+
+This file is a part of novelWriter
+Copyright 2018–2024, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import logging
+import xml.etree.ElementTree as ET
+
+from datetime import datetime
+from pathlib import Path
+from zipfile import ZipFile
+
+from novelwriter import __version__
+from novelwriter.common import xmlIndent
+from novelwriter.core.project import NWProject
+from novelwriter.formats.tokenizer import Tokenizer
+
+logger = logging.getLogger(__name__)
+
+# Types and Relationships
+WORD_BASE = "application/vnd.openxmlformats-officedocument"
+RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
+REL_CORE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"
+REL_APP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"
+REL_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
+
+# Main XML NameSpaces
+PROPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
+TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
+RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
+XML_NS = {
+ "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
+ "dc": "http://purl.org/dc/elements/1.1/",
+ "dcterms": "http://purl.org/dc/terms/",
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
+ "xsi": "http://www.w3.org/2001/XMLSchema-instance",
+}
+for ns, uri in XML_NS.items():
+ ET.register_namespace(ns, uri)
+
+
+def _mkTag(ns: str, tag: str) -> str:
+ """Assemble namespace and tag name."""
+ if uri := XML_NS.get(ns, ""):
+ return f"{{{uri}}}{tag}"
+ logger.warning("Missing xml namespace '%s'", ns)
+ return tag
+
+
+def _addSingle(
+ parent: ET.Element,
+ tag: str,
+ text: str | int | None = None,
+ attrib: dict | None = None
+) -> None:
+ """Add a single value to a parent element."""
+ xSub = ET.SubElement(parent, tag, attrib=attrib or {})
+ if text is not None:
+ xSub.text = str(text)
+ return
+
+
+class ToDocX(Tokenizer):
+ """Core: DocX Document Writer
+
+ Extend the Tokenizer class to writer DocX Document files.
+ """
+
+ def __init__(self, project: NWProject) -> None:
+ super().__init__(project)
+
+ # XML
+ self._dDoc = ET.Element("") # document.xml
+
+ self._xBody = ET.Element("") # Text body
+
+ # Internal
+ self._dLanguage = "en-GB"
+
+ return
+
+ ##
+ # Setters
+ ##
+
+ def setLanguage(self, language: str | None) -> None:
+ """Set language for the document."""
+ if language:
+ self._dLanguage = language.replace("_", "-")
+ return
+
+ ##
+ # Class Methods
+ ##
+
+ def initDocument(self) -> None:
+ """Initialises the DocX document structure."""
+
+ self._dDoc = ET.Element(_mkTag("w", "document"))
+ self._xBody = ET.SubElement(self._dDoc, _mkTag("w", "body"))
+
+ return
+
+ def doConvert(self) -> None:
+ """Convert the list of text tokens into XML elements."""
+ self._result = "" # Not used, but cleared just in case
+ return
+
+ def saveDocument(self, path: Path) -> None:
+ """Save the data to a .docx file."""
+ timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
+
+ # .rels
+ dRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+ _addSingle(dRels, "Relationship", attrib={
+ "Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml",
+ })
+ _addSingle(dRels, "Relationship", attrib={
+ "Id": "rId2", "Type": REL_APP, "Target": "docProps/app.xml",
+ })
+ _addSingle(dRels, "Relationship", attrib={
+ "Id": "rId3", "Type": REL_DOC, "Target": "word/document.xml",
+ })
+
+ # core.xml
+ dCore = ET.Element("coreProperties")
+ tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
+ _addSingle(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
+ _addSingle(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
+ _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author)
+ _addSingle(dCore, _mkTag("dc", "title"), self._project.data.name)
+ _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author)
+ _addSingle(dCore, _mkTag("dc", "language"), self._dLanguage)
+ _addSingle(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount))
+ _addSingle(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
+
+ # app.xml
+ dApp = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
+ _addSingle(dApp, "TotalTime", self._project.data.editTime // 60)
+ _addSingle(dApp, "Application", f"novelWriter/{__version__}")
+ if count := self._counts.get("allWords"):
+ _addSingle(dApp, "Words", count)
+ if count := self._counts.get("textWordChars"):
+ _addSingle(dApp, "Characters", count)
+ if count := self._counts.get("textChars"):
+ _addSingle(dApp, "CharactersWithSpaces", count)
+ if count := self._counts.get("paragraphCount"):
+ _addSingle(dApp, "Paragraphs", count)
+
+ # document.xml.rels
+ dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+
+ # [Content_Types].xml
+ dCont = ET.Element("Types", attrib={"xmlns": TYPES_NS})
+ _addSingle(dCont, "Default", attrib={
+ "Extension": "xml", "ContentType": "application/xml",
+ })
+ _addSingle(dCont, "Default", attrib={
+ "Extension": "rels", "ContentType": RELS_TYPE,
+ })
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/_rels/.rels",
+ "ContentType": RELS_TYPE,
+ })
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/docProps/core.xml",
+ "ContentType": f"{WORD_BASE}.extended-properties+xml",
+ })
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/docProps/app.xml",
+ "ContentType": "application/vnd.openxmlformats-package.core-properties+xml",
+ })
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/word/_rels/document.xml.rels",
+ "ContentType": RELS_TYPE,
+ })
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/word/document.xml",
+ "ContentType": f"{WORD_BASE}.wordprocessingml.document.main+xml",
+ })
+
+ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
+ with zipObj.open(name, mode="w") as fObj:
+ xml = ET.ElementTree(xObj)
+ xmlIndent(xml)
+ xml.write(fObj, encoding="utf-8", xml_declaration=True)
+
+ with ZipFile(path, mode="w") as outZip:
+ xmlToZip("_rels/.rels", dRels, outZip)
+ xmlToZip("docProps/core.xml", dCore, outZip)
+ xmlToZip("docProps/app.xml", dApp, outZip)
+ xmlToZip("word/_rels/document.xml.rels", dDRels, outZip)
+ xmlToZip("word/document.xml", self._dDoc, outZip)
+ xmlToZip("[Content_Types].xml", dCont, outZip)
+
+ return
From d60b29e0a2a070a948379d081c4054fea468afa0 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 18 Oct 2024 16:37:42 +0200
Subject: [PATCH 02/16] make some improvements to the ODT writer
---
novelwriter/formats/toodt.py | 58 ++++++++++++++++++------------------
1 file changed, 29 insertions(+), 29 deletions(-)
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index 960b48aa3..ce993a25d 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -69,6 +69,19 @@ def _mkTag(ns: str, tag: str) -> str:
return tag
+def _addSingle(
+ parent: ET.Element,
+ tag: str,
+ text: str | int | None = None,
+ attrib: dict | None = None
+) -> None:
+ """Add a single value to a parent element."""
+ xSub = ET.SubElement(parent, tag, attrib=attrib or {})
+ if text is not None:
+ xSub.text = str(text)
+ return
+
+
# Mimetype and Version
X_MIME = "application/vnd.oasis.opendocument.text"
X_VERS = "1.3"
@@ -403,33 +416,21 @@ def initDocument(self) -> None:
timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
# Office Meta Data
- xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "creation-date"))
- xMeta.text = timeStamp
-
- xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "generator"))
- xMeta.text = f"novelWriter/{__version__}"
-
- xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "initial-creator"))
- xMeta.text = self._project.data.author
-
- xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "editing-cycles"))
- xMeta.text = str(self._project.data.saveCount)
+ _addSingle(self._xMeta, _mkTag("meta", "creation-date"), timeStamp)
+ _addSingle(self._xMeta, _mkTag("meta", "generator"), f"novelWriter/{__version__}")
+ _addSingle(self._xMeta, _mkTag("meta", "initial-creator"), self._project.data.author)
+ _addSingle(self._xMeta, _mkTag("meta", "editing-cycles"), self._project.data.saveCount)
# Format is: PnYnMnDTnHnMnS
# https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#duration
eT = self._project.data.editTime
- xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "editing-duration"))
- xMeta.text = f"P{eT//86400:d}DT{eT%86400//3600:d}H{eT%3600//60:d}M{eT%60:d}S"
+ fT = f"P{eT//86400:d}DT{eT%86400//3600:d}H{eT%3600//60:d}M{eT%60:d}S"
+ _addSingle(self._xMeta, _mkTag("meta", "editing-duration"), fT)
# Dublin Core Meta Data
- xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "title"))
- xMeta.text = self._project.data.name
-
- xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "date"))
- xMeta.text = timeStamp
-
- xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "creator"))
- xMeta.text = self._project.data.author
+ _addSingle(self._xMeta, _mkTag("dc", "title"), self._project.data.name)
+ _addSingle(self._xMeta, _mkTag("dc", "date"), timeStamp)
+ _addSingle(self._xMeta, _mkTag("dc", "creator"), self._project.data.author)
self._pageStyles()
self._defaultStyles()
@@ -569,18 +570,18 @@ def saveDocument(self, path: Path) -> None:
oVers = _mkTag("office", "version")
xSett = ET.Element(oRoot, attrib={oVers: X_VERS})
- def putInZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
+ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj:
xml = ET.ElementTree(xObj)
xml.write(fObj, encoding="utf-8", xml_declaration=True)
with ZipFile(path, mode="w") as outZip:
outZip.writestr("mimetype", X_MIME)
- putInZip("META-INF/manifest.xml", xMani, outZip)
- putInZip("settings.xml", xSett, outZip)
- putInZip("content.xml", self._dCont, outZip)
- putInZip("meta.xml", self._dMeta, outZip)
- putInZip("styles.xml", self._dStyl, outZip)
+ xmlToZip("META-INF/manifest.xml", xMani, outZip)
+ xmlToZip("settings.xml", xSett, outZip)
+ xmlToZip("content.xml", self._dCont, outZip)
+ xmlToZip("meta.xml", self._dMeta, outZip)
+ xmlToZip("styles.xml", self._dStyl, outZip)
logger.info("Wrote file: %s", path)
@@ -794,8 +795,7 @@ def _generateFootnote(self, key: str) -> ET.Element | None:
_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)
+ _addSingle(xNote, _mkTag("text", "note-citation"), self._nNote)
xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1])
return xNote
From 9bbebd20856894f201839a20910af33738634ad4 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 18 Oct 2024 18:35:56 +0200
Subject: [PATCH 03/16] Add DocX text styles
---
novelwriter/core/docbuild.py | 4 +-
novelwriter/formats/todocx.py | 196 ++++++++++++++++++++++++++++++++--
novelwriter/formats/toodt.py | 3 +-
3 files changed, 188 insertions(+), 15 deletions(-)
diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 562ae052f..07ce3c816 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -334,14 +334,14 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
bldObj.setStyles(self._build.getBool("html.addStyles"))
bldObj.setReplaceUnicode(self._build.getBool("format.stripUnicode"))
- if isinstance(bldObj, ToOdt):
+ if isinstance(bldObj, (ToOdt, ToDocX)):
bldObj.setLanguage(self._project.data.language)
bldObj.setHeaderFormat(
self._build.getStr("odt.pageHeader"),
self._build.getInt("odt.pageCountOffset"),
)
- if isinstance(bldObj, (ToOdt, ToQTextDocument)):
+ if isinstance(bldObj, (ToOdt, ToDocX, ToQTextDocument)):
scale = nwLabels.UNIT_SCALE.get(self._build.getStr("format.pageUnit"), 1.0)
pW, pH = nwLabels.PAPER_SIZE.get(self._build.getStr("format.pageSize"), (-1.0, -1.0))
bldObj.setPageLayout(
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 69d020e68..5040b8ab2 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -32,6 +32,7 @@
from novelwriter import __version__
from novelwriter.common import xmlIndent
+from novelwriter.constants import nwStyles
from novelwriter.core.project import NWProject
from novelwriter.formats.tokenizer import Tokenizer
@@ -41,24 +42,29 @@
WORD_BASE = "application/vnd.openxmlformats-officedocument"
RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
REL_CORE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"
-REL_APP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"
-REL_DOC = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
+REL_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
# Main XML NameSpaces
PROPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
+W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
XML_NS = {
+ "w": W_NS,
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/",
- "dcterms": "http://purl.org/dc/terms/",
- "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ "dcterms": "http://purl.org/dc/terms/",
}
for ns, uri in XML_NS.items():
ET.register_namespace(ns, uri)
+def _wTag(tag: str) -> str:
+ """Assemble namespace and tag name for standard w namespace."""
+ return f"{{{W_NS}}}{tag}"
+
+
def _mkTag(ns: str, tag: str) -> str:
"""Assemble namespace and tag name."""
if uri := XML_NS.get(ns, ""):
@@ -90,12 +96,19 @@ def __init__(self, project: NWProject) -> None:
super().__init__(project)
# XML
- self._dDoc = ET.Element("") # document.xml
+ self._dDoc = ET.Element("") # document.xml
+ self._dStyl = ET.Element("") # styles.xml
self._xBody = ET.Element("") # Text body
+ # Properties
+ self._headerFormat = ""
+ self._pageOffset = 0
+
# Internal
- self._dLanguage = "en-GB"
+ self._fontFamily = "Liberation Serif"
+ self._fontSize = 12.0
+ self._dLanguage = "en-GB"
return
@@ -106,18 +119,41 @@ def __init__(self, project: NWProject) -> None:
def setLanguage(self, language: str | None) -> None:
"""Set language for the document."""
if language:
- self._dLanguage = language.replace("_", "-")
+ self._dLanguage = language
+ return
+
+ def setPageLayout(
+ self, width: float, height: float, top: float, bottom: float, left: float, right: float
+ ) -> None:
+ """Set the document page size and margins in millimetres."""
+ return
+
+ def setHeaderFormat(self, format: str, offset: int) -> None:
+ """Set the document header format."""
+ self._headerFormat = format.strip()
+ self._pageOffset = offset
return
##
# Class Methods
##
+ def _emToSz(self, scale: float) -> int:
+ return int()
+
def initDocument(self) -> None:
"""Initialises the DocX document structure."""
- self._dDoc = ET.Element(_mkTag("w", "document"))
- self._xBody = ET.SubElement(self._dDoc, _mkTag("w", "body"))
+ self._fontFamily = self._textFont.family()
+ self._fontSize = self._textFont.pointSizeF()
+
+ self._dDoc = ET.Element(_wTag("document"))
+ self._dStyl = ET.Element(_wTag("styles"))
+
+ self._xBody = ET.SubElement(self._dDoc, _wTag("body"))
+
+ self._defaultStyles()
+ self._useableStyles()
return
@@ -136,10 +172,10 @@ def saveDocument(self, path: Path) -> None:
"Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml",
})
_addSingle(dRels, "Relationship", attrib={
- "Id": "rId2", "Type": REL_APP, "Target": "docProps/app.xml",
+ "Id": "rId2", "Type": f"{REL_BASE}/extended-properties", "Target": "docProps/app.xml",
})
_addSingle(dRels, "Relationship", attrib={
- "Id": "rId3", "Type": REL_DOC, "Target": "word/document.xml",
+ "Id": "rId3", "Type": f"{REL_BASE}/officeDocument", "Target": "word/document.xml",
})
# core.xml
@@ -169,6 +205,9 @@ def saveDocument(self, path: Path) -> None:
# document.xml.rels
dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+ _addSingle(dDRels, "Relationship", attrib={
+ "Id": "rId1", "Type": f"{REL_BASE}/styles", "Target": "styles.xml",
+ })
# [Content_Types].xml
dCont = ET.Element("Types", attrib={"xmlns": TYPES_NS})
@@ -198,6 +237,10 @@ def saveDocument(self, path: Path) -> None:
"PartName": "/word/document.xml",
"ContentType": f"{WORD_BASE}.wordprocessingml.document.main+xml",
})
+ _addSingle(dCont, "Override", attrib={
+ "PartName": "/word/styles.xml",
+ "ContentType": f"{WORD_BASE}.wordprocessingml.styles+xml",
+ })
def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj:
@@ -211,6 +254,137 @@ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
xmlToZip("docProps/app.xml", dApp, outZip)
xmlToZip("word/_rels/document.xml.rels", dDRels, outZip)
xmlToZip("word/document.xml", self._dDoc, outZip)
+ xmlToZip("word/styles.xml", self._dStyl, outZip)
xmlToZip("[Content_Types].xml", dCont, outZip)
return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _defaultStyles(self) -> None:
+ """Set the default styles."""
+ xStyl = ET.SubElement(self._dStyl, _wTag("docDefaults"))
+ xRDef = ET.SubElement(xStyl, _wTag("rPrDefault"))
+ xPDef = ET.SubElement(xStyl, _wTag("pPrDefault"))
+ xRPr = ET.SubElement(xRDef, _wTag("rPr"))
+ xPPr = ET.SubElement(xPDef, _wTag("pPr"))
+
+ size = str(int(2.0 * self._fontSize))
+ line = str(int(2.0 * self._lineHeight * self._fontSize))
+
+ ET.SubElement(xRPr, _wTag("rFonts"), attrib={
+ _wTag("ascii"): self._fontFamily,
+ _wTag("hAnsi"): self._fontFamily,
+ _wTag("cs"): self._fontFamily,
+ })
+ ET.SubElement(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
+ ET.SubElement(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
+ ET.SubElement(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
+ ET.SubElement(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
+
+ return
+
+ def _useableStyles(self) -> None:
+ """Set the usable styles."""
+ # Add Normal Style
+ self._addParStyle(
+ name="Normal",
+ styleId="Normal",
+ size=1.0,
+ default=True,
+ margins=self._marginText,
+ )
+
+ # Add Heading 1
+ hScale = self._scaleHeads
+ self._addParStyle(
+ name="Heading 1",
+ styleId="Heading1",
+ size=nwStyles.H_SIZES[1] if hScale else 1.0,
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginHead1,
+ level=0,
+ )
+
+ # Add Heading 2
+ hScale = self._scaleHeads
+ self._addParStyle(
+ name="Heading 2",
+ styleId="Heading2",
+ size=nwStyles.H_SIZES[2] if hScale else 1.0,
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginHead2,
+ level=1,
+ )
+
+ # Add Heading 3
+ hScale = self._scaleHeads
+ self._addParStyle(
+ name="Heading 3",
+ styleId="Heading3",
+ size=nwStyles.H_SIZES[3] if hScale else 1.0,
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginHead3,
+ level=1,
+ )
+
+ # Add Heading 4
+ hScale = self._scaleHeads
+ self._addParStyle(
+ name="Heading 4",
+ styleId="Heading4",
+ size=nwStyles.H_SIZES[4] if hScale else 1.0,
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginHead4,
+ level=1,
+ )
+
+ return
+
+ def _addParStyle(
+ self, *,
+ name: str,
+ styleId: str,
+ size: float,
+ basedOn: str | None = None,
+ nextStyle: str | None = None,
+ margins: tuple[float, float] | None = None,
+ default: bool = False,
+ level: int | None = None,
+ ) -> None:
+ """Add a paragraph style."""
+ sAttr = {}
+ sAttr[_wTag("type")] = "paragraph"
+ sAttr[_wTag("styleId")] = styleId
+ if default:
+ sAttr[_wTag("default")] = "1"
+
+ sz = str(int(2.0 * size * self._fontSize))
+
+ xStyl = ET.SubElement(self._dStyl, _wTag("style"), attrib=sAttr)
+ ET.SubElement(xStyl, _wTag("name"), attrib={_wTag("val"): name})
+ if basedOn:
+ ET.SubElement(xStyl, _wTag("basedOn"), attrib={_wTag("val"): basedOn})
+ if nextStyle:
+ ET.SubElement(xStyl, _wTag("next"), attrib={_wTag("val"): nextStyle})
+ if level is not None:
+ ET.SubElement(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(level)})
+
+ xPPr = ET.SubElement(xStyl, _wTag("pPr"))
+ if margins:
+ ET.SubElement(xPPr, _wTag("spacing"), attrib={
+ _wTag("before"): str(int(20.0 * margins[0])),
+ _wTag("after"): str(int(20.0 * margins[1])),
+ })
+
+ xRPr = ET.SubElement(xStyl, _wTag("rPr"))
+ ET.SubElement(xRPr, _wTag("sz"), attrib={_wTag("val"): sz})
+ ET.SubElement(xRPr, _wTag("szCs"), attrib={_wTag("val"): sz})
+
+ return
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index ce993a25d..5e68afdc1 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -258,8 +258,7 @@ def setLanguage(self, language: str | None) -> None:
return
def setPageLayout(
- self, width: int | float, height: int | float,
- top: int | float, bottom: int | float, left: int | float, right: int | float
+ self, width: float, height: float, top: float, bottom: float, left: float, right: float
) -> None:
"""Set the document page size and margins in millimetres."""
self._mDocWidth = f"{width/10.0:.3f}cm"
From 4b07dbadb2205dc622593d2c8af9553ea091fb03 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 18 Oct 2024 20:31:21 +0200
Subject: [PATCH 04/16] Basic DocX text formatting works
---
novelwriter/formats/todocx.py | 320 +++++++++++++++++++++++++++++++++-
1 file changed, 311 insertions(+), 9 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 5040b8ab2..6e88b30b7 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -32,9 +32,9 @@
from novelwriter import __version__
from novelwriter.common import xmlIndent
-from novelwriter.constants import nwStyles
+from novelwriter.constants import nwHeadFmt, nwStyles
from novelwriter.core.project import NWProject
-from novelwriter.formats.tokenizer import Tokenizer
+from novelwriter.formats.tokenizer import T_Formats, Tokenizer
logger = logging.getLogger(__name__)
@@ -54,6 +54,7 @@
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
"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/",
}
for ns, uri in XML_NS.items():
@@ -86,6 +87,29 @@ def _addSingle(
return
+# Formatting Codes
+X_BLD = 0x001 # Bold format
+X_ITA = 0x002 # Italic format
+X_DEL = 0x004 # Strikethrough format
+X_UND = 0x008 # Underline format
+X_MRK = 0x010 # Marked format
+X_SUP = 0x020 # Superscript
+X_SUB = 0x040 # Subscript
+X_DLG = 0x080 # Dialogue
+X_DLA = 0x100 # Alt. Dialogue
+
+# Formatting Masks
+M_BLD = ~X_BLD
+M_ITA = ~X_ITA
+M_DEL = ~X_DEL
+M_UND = ~X_UND
+M_MRK = ~X_MRK
+M_SUP = ~X_SUP
+M_SUB = ~X_SUB
+M_DLG = ~X_DLG
+M_DLA = ~X_DLA
+
+
class ToDocX(Tokenizer):
"""Core: DocX Document Writer
@@ -160,6 +184,89 @@ def initDocument(self) -> None:
def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
+
+ # xText = self._xText
+ for tType, _, tText, tFormat, tStyle in self._tokens:
+ par = DocXParagraph()
+
+ # Styles
+ if tStyle is not None:
+ if tStyle & self.A_LEFT:
+ par.setAlignment("left")
+ elif tStyle & self.A_RIGHT:
+ par.setAlignment("right")
+ elif tStyle & self.A_CENTRE:
+ par.setAlignment("center")
+ # elif tStyle & self.A_JUSTIFY:
+ # oStyle.setTextAlign("justify")
+
+ if tStyle & self.A_PBB:
+ par.setPageBreakBefore(True)
+ if tStyle & self.A_PBA:
+ par.setPageBreakAfter(True)
+
+ if tStyle & self.A_Z_BTMMRG:
+ par.setMarginBottom(0.0)
+ if tStyle & self.A_Z_TOPMRG:
+ par.setMarginTop(0.0)
+
+ # if tStyle & self.A_IND_L:
+ # oStyle.setMarginLeft(self._fBlockIndent)
+ # if tStyle & self.A_IND_R:
+ # oStyle.setMarginRight(self._fBlockIndent)
+
+ # Process Text Types
+ if tType == self.T_TEXT:
+ # Text indentation is processed here because there is a
+ # dedicated pre-defined style for it
+ # if tStyle & self.A_IND_T:
+ # else:
+ self._addFragments(par, "Normal", tText, tFormat)
+
+ elif tType == self.T_TITLE:
+ tHead = tText.replace(nwHeadFmt.BR, "\n")
+ self._addFragments(par, "Title", tHead, tFormat)
+
+ elif tType == self.T_HEAD1:
+ tHead = tText.replace(nwHeadFmt.BR, "\n")
+ self._addFragments(par, "Heading1", tHead, tFormat)
+
+ elif tType == self.T_HEAD2:
+ tHead = tText.replace(nwHeadFmt.BR, "\n")
+ self._addFragments(par, "Heading2", tHead, tFormat)
+
+ elif tType == self.T_HEAD3:
+ tHead = tText.replace(nwHeadFmt.BR, "\n")
+ self._addFragments(par, "Heading3", tHead, tFormat)
+
+ elif tType == self.T_HEAD4:
+ tHead = tText.replace(nwHeadFmt.BR, "\n")
+ self._addFragments(par, "Heading4", tHead, tFormat)
+
+ # elif tType == self.T_SEP:
+ # self._addTextPar(xText, S_SEP, oStyle, tText)
+
+ # elif tType == self.T_SKIP:
+ # self._addTextPar(xText, S_TEXT, oStyle, "")
+
+ # elif tType == self.T_SYNOPSIS and self._doSynopsis:
+ # tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
+ # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+
+ # elif tType == self.T_SHORT and self._doSynopsis:
+ # tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
+ # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+
+ # elif tType == self.T_COMMENT and self._doComments:
+ # tTemp, tFmt = self._formatComments(tText, tFormat)
+ # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+
+ # elif tType == self.T_KEYWORD and self._doKeywords:
+ # tTemp, tFmt = self._formatKeywords(tText)
+ # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+
+ par.finalise(self._xBody)
+
return
def saveDocument(self, path: Path) -> None:
@@ -263,6 +370,66 @@ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
# Internal Functions
##
+ def _addFragments(self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats) -> None:
+ """Apply formatting tags to text."""
+ par.setStyle(pStyle)
+ xFmt = 0x00
+ fStart = 0
+ for fPos, fFmt, fData in tFmt:
+
+ run = DocXRun(text[fStart:fPos], xFmt)
+ par.addRun(run)
+
+ if fFmt == self.FMT_B_B:
+ xFmt |= X_BLD
+ elif fFmt == self.FMT_B_E:
+ xFmt &= M_BLD
+ elif fFmt == self.FMT_I_B:
+ xFmt |= X_ITA
+ elif fFmt == self.FMT_I_E:
+ xFmt &= M_ITA
+ elif fFmt == self.FMT_D_B:
+ xFmt |= X_DEL
+ elif fFmt == self.FMT_D_E:
+ xFmt &= M_DEL
+ elif fFmt == self.FMT_U_B:
+ xFmt |= X_UND
+ elif fFmt == self.FMT_U_E:
+ xFmt &= M_UND
+ elif fFmt == self.FMT_M_B:
+ xFmt |= X_MRK
+ elif fFmt == self.FMT_M_E:
+ xFmt &= M_MRK
+ elif fFmt == self.FMT_SUP_B:
+ xFmt |= X_SUP
+ elif fFmt == self.FMT_SUP_E:
+ xFmt &= M_SUP
+ elif fFmt == self.FMT_SUB_B:
+ xFmt |= X_SUB
+ elif fFmt == self.FMT_SUB_E:
+ xFmt &= M_SUB
+ elif fFmt == self.FMT_DL_B:
+ xFmt |= X_DLG
+ elif fFmt == self.FMT_DL_E:
+ xFmt &= M_DLG
+ elif fFmt == self.FMT_ADL_B:
+ xFmt |= X_DLA
+ elif fFmt == self.FMT_ADL_E:
+ xFmt &= M_DLA
+ # elif fmt == self.FMT_FNOTE:
+ # xNode = self._generateFootnote(fData)
+ elif fFmt == self.FMT_STRIP:
+ pass
+
+ # Move pos for next pass
+ fStart = fPos
+
+ if rest := text[fStart:]:
+ run = DocXRun(rest, xFmt)
+ par.addRun(run)
+
+ return
+
def _defaultStyles(self) -> None:
"""Set the default styles."""
xStyl = ET.SubElement(self._dStyl, _wTag("docDefaults"))
@@ -272,7 +439,7 @@ def _defaultStyles(self) -> None:
xPPr = ET.SubElement(xPDef, _wTag("pPr"))
size = str(int(2.0 * self._fontSize))
- line = str(int(2.0 * self._lineHeight * self._fontSize))
+ line = str(int(20.0 * self._lineHeight * self._fontSize))
ET.SubElement(xRPr, _wTag("rFonts"), attrib={
_wTag("ascii"): self._fontFamily,
@@ -288,6 +455,8 @@ def _defaultStyles(self) -> None:
def _useableStyles(self) -> None:
"""Set the usable styles."""
+ hScale = self._scaleHeads
+
# Add Normal Style
self._addParStyle(
name="Normal",
@@ -297,8 +466,18 @@ def _useableStyles(self) -> None:
margins=self._marginText,
)
+ # Add Title
+ self._addParStyle(
+ name="Title",
+ styleId="Title",
+ size=nwStyles.H_SIZES[0] if hScale else 1.0,
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginTitle,
+ level=0,
+ )
+
# Add Heading 1
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 1",
styleId="Heading1",
@@ -310,7 +489,6 @@ def _useableStyles(self) -> None:
)
# Add Heading 2
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 2",
styleId="Heading2",
@@ -322,7 +500,6 @@ def _useableStyles(self) -> None:
)
# Add Heading 3
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 3",
styleId="Heading3",
@@ -334,7 +511,6 @@ def _useableStyles(self) -> None:
)
# Add Heading 4
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 4",
styleId="Heading4",
@@ -366,6 +542,7 @@ def _addParStyle(
sAttr[_wTag("default")] = "1"
sz = str(int(2.0 * size * self._fontSize))
+ ln = str(int(20.0 * size * self._lineHeight * self._fontSize))
xStyl = ET.SubElement(self._dStyl, _wTag("style"), attrib=sAttr)
ET.SubElement(xStyl, _wTag("name"), attrib={_wTag("val"): name})
@@ -379,8 +556,9 @@ def _addParStyle(
xPPr = ET.SubElement(xStyl, _wTag("pPr"))
if margins:
ET.SubElement(xPPr, _wTag("spacing"), attrib={
- _wTag("before"): str(int(20.0 * margins[0])),
- _wTag("after"): str(int(20.0 * margins[1])),
+ _wTag("before"): str(int(20.0 * margins[0] * self._fontSize)),
+ _wTag("after"): str(int(20.0 * margins[1] * self._fontSize)),
+ _wTag("line"): ln,
})
xRPr = ET.SubElement(xStyl, _wTag("rPr"))
@@ -388,3 +566,127 @@ def _addParStyle(
ET.SubElement(xRPr, _wTag("szCs"), attrib={_wTag("val"): sz})
return
+
+
+class DocXParagraph:
+
+ def __init__(self) -> None:
+ self._text: list[DocXRun] = []
+ self._style: str = "Normal"
+ self._textAlign: str | None = None
+ self._topMargin: int | None = None
+ self._bottomMargin: int | None = None
+ self._breakBefore = False
+ self._breakAfter = False
+ return
+
+ ##
+ # Setters
+ ##
+
+ def setStyle(self, value: str) -> None:
+ """Set the paragraph style."""
+ self._style = value
+ return
+
+ def setAlignment(self, value: str) -> None:
+ """Set paragraph alignment."""
+ if value in ("left", "center", "right"):
+ self._textAlign = value
+ return
+
+ def setMarginTop(self, value: float) -> None:
+ """Set margin above in pt."""
+ self._topMargin = int(20.0 * value)
+ return
+
+ def setMarginBottom(self, value: float) -> None:
+ """Set margin below in pt."""
+ self._bottomMargin = int(20.0 * value)
+ return
+
+ def setPageBreakBefore(self, state: bool) -> None:
+ """Set page break before flag."""
+ self._breakBefore = state
+ return
+
+ def setPageBreakAfter(self, state: bool) -> None:
+ """Set page break after flag."""
+ self._breakAfter = state
+ return
+
+ ##
+ # Methods
+ ##
+
+ def addRun(self, run: DocXRun) -> None:
+ """Add a run segment to the paragraph."""
+ self._text.append(run)
+ return
+
+ def finalise(self, body: ET.Element) -> None:
+ """Called after all content is set."""
+ par = ET.SubElement(body, _wTag("p"))
+
+ # Values
+ spacing = {}
+ if self._topMargin:
+ spacing["before"] = str(self._topMargin)
+ if self._bottomMargin:
+ spacing["after"] = str(self._bottomMargin)
+
+ # Paragraph
+ pPr = ET.SubElement(par, _wTag("pPr"))
+ _addSingle(pPr, _wTag("pStyle"), attrib={_wTag("val"): self._style})
+ if spacing:
+ _addSingle(pPr, _wTag("spacing"), attrib=spacing)
+ if self._textAlign:
+ _addSingle(pPr, _wTag("jc"), attrib={_wTag("val"): self._textAlign})
+
+ # Text
+ if self._breakBefore:
+ _addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
+ for run in self._text:
+ run.append(ET.SubElement(par, _wTag("r")))
+ if self._breakAfter:
+ _addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
+
+ return
+
+
+class DocXRun:
+
+ def __init__(self, text: str, fmt: int) -> None:
+ self._text = text
+ self._fmt = fmt
+ return
+
+ def append(self, parent: ET.Element) -> None:
+ """Append the text run to a paragraph."""
+ if text := self._text:
+ fmt = self._fmt
+ rPr = ET.SubElement(parent, _wTag("rPr"))
+ if fmt & X_BLD == X_BLD:
+ ET.SubElement(rPr, _wTag("b"))
+ if fmt & X_ITA == X_ITA:
+ ET.SubElement(rPr, _wTag("i"))
+ if fmt & X_UND == X_UND:
+ ET.SubElement(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
+ if fmt & X_DEL == X_DEL:
+ ET.SubElement(rPr, _wTag("strike"))
+ if fmt & X_SUP == X_SUP:
+ ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
+ if fmt & X_SUB == X_SUB:
+ ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
+
+ temp = text
+ while (parts := temp.partition("\n"))[0]:
+ part = parts[0]
+ attr = {}
+ if len(part) != len(part.strip()):
+ attr[_mkTag("xml", "space")] = "preserve"
+ _addSingle(parent, _wTag("t"), part, attrib=attr)
+ if parts[1]:
+ _addSingle(parent, _wTag("br"))
+ temp = parts[2]
+ return
From 717aac9dc8ca7b55633014faf9e08482395af1a7 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 18 Oct 2024 21:18:59 +0200
Subject: [PATCH 05/16] Add remaining code blocks, and add colours
---
novelwriter/formats/todocx.py | 143 ++++++++++++++++++++++++-------
novelwriter/formats/tokenizer.py | 2 -
novelwriter/formats/toodt.py | 19 ++--
3 files changed, 123 insertions(+), 41 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 6e88b30b7..6b7b28190 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -1,9 +1,9 @@
"""
-novelWriter – DOCX Text Converter
+novelWriter – DocX Text Converter
=================================
File History:
-Created: 2024-10-15 [2.6b1] ToRaw
+Created: 2024-10-18 [2.6b1] ToDocX
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -32,7 +32,7 @@
from novelwriter import __version__
from novelwriter.common import xmlIndent
-from novelwriter.constants import nwHeadFmt, nwStyles
+from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.formats.tokenizer import T_Formats, Tokenizer
@@ -110,6 +110,15 @@ def _addSingle(
M_DLA = ~X_DLA
+# Colours
+COL_HEAD_L12 = "2a6099"
+COL_HEAD_L34 = "444444"
+COL_DIALOG_M = "2a6099"
+COL_DIALOG_A = "813709"
+COL_META_TXT = "813709"
+COL_MARK_TXT = "ffffa6"
+
+
class ToDocX(Tokenizer):
"""Core: DocX Document Writer
@@ -243,27 +252,27 @@ def doConvert(self) -> None:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addFragments(par, "Heading4", tHead, tFormat)
- # elif tType == self.T_SEP:
- # self._addTextPar(xText, S_SEP, oStyle, tText)
+ elif tType == self.T_SEP:
+ self._addFragments(par, "Separator", tText)
- # elif tType == self.T_SKIP:
- # self._addTextPar(xText, S_TEXT, oStyle, "")
+ elif tType == self.T_SKIP:
+ self._addFragments(par, "Normal", "")
- # elif tType == self.T_SYNOPSIS and self._doSynopsis:
- # tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
- # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+ elif tType == self.T_SYNOPSIS and self._doSynopsis:
+ tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
+ self._addFragments(par, "TextMeta", tTemp, tFmt)
- # elif tType == self.T_SHORT and self._doSynopsis:
- # tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
- # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+ elif tType == self.T_SHORT and self._doSynopsis:
+ tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
+ self._addFragments(par, "TextMeta", tTemp, tFmt)
- # elif tType == self.T_COMMENT and self._doComments:
- # tTemp, tFmt = self._formatComments(tText, tFormat)
- # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+ elif tType == self.T_COMMENT and self._doComments:
+ tTemp, tFmt = self._formatComments(tText, tFormat)
+ self._addFragments(par, "TextMeta", tTemp, tFmt)
- # elif tType == self.T_KEYWORD and self._doKeywords:
- # tTemp, tFmt = self._formatKeywords(tText)
- # self._addTextPar(xText, S_META, oStyle, tTemp, tFmt=tFmt)
+ elif tType == self.T_KEYWORD and self._doKeywords:
+ tTemp, tFmt = self._formatKeywords(tText)
+ self._addFragments(par, "TextMeta", tTemp, tFmt)
par.finalise(self._xBody)
@@ -370,12 +379,48 @@ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
# Internal Functions
##
- def _addFragments(self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats) -> None:
+ def _formatSynopsis(self, text: str, fmt: T_Formats, synopsis: bool) -> tuple[str, T_Formats]:
+ """Apply formatting to synopsis lines."""
+ name = self._localLookup("Synopsis" if synopsis else "Short Description")
+ shift = len(name) + 2
+ rTxt = f"{name}: {text}"
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")]
+ rFmt.extend((p + shift, f, d) for p, f, d in fmt)
+ return rTxt, rFmt
+
+ def _formatComments(self, text: str, fmt: T_Formats) -> tuple[str, T_Formats]:
+ """Apply formatting to comments."""
+ name = self._localLookup("Comment")
+ shift = len(name) + 2
+ rTxt = f"{name}: {text}"
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(name) + 1, self.FMT_B_E, "")]
+ rFmt.extend((p + shift, f, d) for p, f, d in fmt)
+ return rTxt, rFmt
+
+ def _formatKeywords(self, text: str) -> tuple[str, T_Formats]:
+ """Apply formatting to keywords."""
+ valid, bits, _ = self._project.index.scanThis("@"+text)
+ if not valid or not bits or bits[0] not in nwLabels.KEY_NAME:
+ return "", []
+
+ rTxt = f"{self._localLookup(nwLabels.KEY_NAME[bits[0]])}: "
+ rFmt: T_Formats = [(0, self.FMT_B_B, ""), (len(rTxt) - 1, self.FMT_B_E, "")]
+ if len(bits) > 1:
+ if bits[0] == nwKeyWords.TAG_KEY:
+ rTxt += bits[1]
+ else:
+ rTxt += ", ".join(bits[1:])
+
+ return rTxt, rFmt
+
+ def _addFragments(
+ self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats | None = None
+ ) -> None:
"""Apply formatting tags to text."""
par.setStyle(pStyle)
xFmt = 0x00
fStart = 0
- for fPos, fFmt, fData in tFmt:
+ for fPos, fFmt, fData in tFmt or []:
run = DocXRun(text[fStart:fPos], xFmt)
par.addRun(run)
@@ -456,12 +501,12 @@ def _defaultStyles(self) -> None:
def _useableStyles(self) -> None:
"""Set the usable styles."""
hScale = self._scaleHeads
+ hColor = self._colorHeads
# Add Normal Style
self._addParStyle(
name="Normal",
styleId="Normal",
- size=1.0,
default=True,
margins=self._marginText,
)
@@ -486,6 +531,7 @@ def _useableStyles(self) -> None:
nextStyle="Normal",
margins=self._marginHead1,
level=0,
+ color=COL_HEAD_L12 if hColor else None,
)
# Add Heading 2
@@ -497,6 +543,7 @@ def _useableStyles(self) -> None:
nextStyle="Normal",
margins=self._marginHead2,
level=1,
+ color=COL_HEAD_L12 if hColor else None,
)
# Add Heading 3
@@ -508,6 +555,7 @@ def _useableStyles(self) -> None:
nextStyle="Normal",
margins=self._marginHead3,
level=1,
+ color=COL_HEAD_L34 if hColor else None,
)
# Add Heading 4
@@ -519,6 +567,27 @@ def _useableStyles(self) -> None:
nextStyle="Normal",
margins=self._marginHead4,
level=1,
+ color=COL_HEAD_L34 if hColor else None,
+ )
+
+ # Add Separator
+ self._addParStyle(
+ name="Separator",
+ styleId="Separator",
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginSep,
+ align="center",
+ )
+
+ # Add Text Meta Style
+ self._addParStyle(
+ name="Text Meta",
+ styleId="TextMeta",
+ basedOn="Normal",
+ nextStyle="Normal",
+ margins=self._marginMeta,
+ color=COL_META_TXT,
)
return
@@ -527,12 +596,14 @@ def _addParStyle(
self, *,
name: str,
styleId: str,
- size: float,
+ size: float = 1.0,
basedOn: str | None = None,
nextStyle: str | None = None,
margins: tuple[float, float] | None = None,
+ align: str | None = None,
default: bool = False,
level: int | None = None,
+ color: str | None = None,
) -> None:
"""Add a paragraph style."""
sAttr = {}
@@ -553,17 +624,21 @@ def _addParStyle(
if level is not None:
ET.SubElement(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(level)})
- xPPr = ET.SubElement(xStyl, _wTag("pPr"))
+ pPr = ET.SubElement(xStyl, _wTag("pPr"))
if margins:
- ET.SubElement(xPPr, _wTag("spacing"), attrib={
+ ET.SubElement(pPr, _wTag("spacing"), attrib={
_wTag("before"): str(int(20.0 * margins[0] * self._fontSize)),
_wTag("after"): str(int(20.0 * margins[1] * self._fontSize)),
_wTag("line"): ln,
})
+ if align:
+ ET.SubElement(pPr, _wTag("jc"), attrib={_wTag("val"): align})
- xRPr = ET.SubElement(xStyl, _wTag("rPr"))
- ET.SubElement(xRPr, _wTag("sz"), attrib={_wTag("val"): sz})
- ET.SubElement(xRPr, _wTag("szCs"), attrib={_wTag("val"): sz})
+ rPr = ET.SubElement(xStyl, _wTag("rPr"))
+ ET.SubElement(rPr, _wTag("sz"), attrib={_wTag("val"): sz})
+ ET.SubElement(rPr, _wTag("szCs"), attrib={_wTag("val"): sz})
+ if color:
+ ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): color})
return
@@ -630,9 +705,9 @@ def finalise(self, body: ET.Element) -> None:
# Values
spacing = {}
- if self._topMargin:
+ if self._topMargin is not None:
spacing["before"] = str(self._topMargin)
- if self._bottomMargin:
+ if self._bottomMargin is not None:
spacing["after"] = str(self._bottomMargin)
# Paragraph
@@ -672,12 +747,20 @@ def append(self, parent: ET.Element) -> None:
ET.SubElement(rPr, _wTag("i"))
if fmt & X_UND == X_UND:
ET.SubElement(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
+ if fmt & X_MRK == X_MRK:
+ ET.SubElement(rPr, _wTag("shd"), attrib={
+ _wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear",
+ })
if fmt & X_DEL == X_DEL:
ET.SubElement(rPr, _wTag("strike"))
if fmt & X_SUP == X_SUP:
ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
if fmt & X_SUB == X_SUB:
ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
+ if fmt & X_DLG == X_DLG:
+ ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
+ if fmt & X_DLA == X_DLA:
+ ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
temp = text
while (parts := temp.partition("\n"))[0]:
diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py
index e2c5817f1..916afab21 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -205,7 +205,6 @@ def __init__(self, project: NWProject) -> None:
self._hFormatter = HeadingFormatter(self._project)
self._noSep = True # Flag to indicate that we don't want a scene separator
self._noIndent = False # Flag to disable text indent on next paragraph
- self._showDialog = False # Flag for dialogue highlighting
# This File
self._isNovel = False # Document is a novel document
@@ -380,7 +379,6 @@ def setJustify(self, state: bool) -> None:
def setDialogueHighlight(self, state: bool) -> None:
"""Enable or disable dialogue highlighting."""
self._rxDialogue = []
- self._showDialog = state
if state:
if CONFIG.dialogStyle > 0:
self._rxDialogue.append((
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index 5e68afdc1..fd683fd97 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -237,8 +237,8 @@ def __init__(self, project: NWProject, isFlat: bool) -> None:
self._opaHead12 = None
self._colHead34 = None
self._opaHead34 = None
- self._colDialogM = None
- self._colDialogA = None
+ self._colDialogM = "#2a6099"
+ self._colDialogA = "#813709"
self._colMetaTx = "#813709"
self._opaMetaTx = "100%"
self._markText = "#ffffa6"
@@ -337,10 +337,6 @@ def initDocument(self) -> None:
self._colHead34 = "#444444"
self._opaHead34 = "100%"
- if self._showDialog:
- self._colDialogM = "#2a6099"
- self._colDialogA = "#813709"
-
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
self._fBlockIndent = self._emToCm(self._blockIndent)
self._fTextIndent = self._emToCm(self._firstWidth)
@@ -625,8 +621,13 @@ def _formatKeywords(self, text: str) -> tuple[str, T_Formats]:
return rTxt, rFmt
def _addTextPar(
- self, xParent: ET.Element, styleName: str, oStyle: ODTParagraphStyle, tText: str,
- tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None
+ self,
+ xParent: ET.Element,
+ styleName: str, oStyle: ODTParagraphStyle,
+ tText: str,
+ tFmt: Sequence[tuple[int, int, str]] | None = None,
+ isHead: bool = False,
+ oLevel: str | None = None,
) -> None:
"""Add a text paragraph to the text XML element."""
tAttr = {_mkTag("text", "style-name"): self._paraStyle(styleName, oStyle)}
@@ -653,7 +654,7 @@ def _addTextPar(
tFrag = ""
fLast = 0
xNode = None
- for fPos, fFmt, fData in tFmt:
+ for fPos, fFmt, fData in tFmt or []:
# Add any extra nodes
if xNode is not None:
From ca33f488f17a74c0dcc7d32968093657708e94c4 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 19 Oct 2024 14:43:06 +0200
Subject: [PATCH 06/16] Get rid of the DocXRun class
---
novelwriter/formats/todocx.py | 189 +++++++++++++++++-----------------
1 file changed, 97 insertions(+), 92 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 6b7b28190..4aa966d4f 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -4,6 +4,7 @@
File History:
Created: 2024-10-18 [2.6b1] ToDocX
+Created: 2024-10-18 [2.6b1] DocXParagraph
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -109,6 +110,16 @@ def _addSingle(
M_DLG = ~X_DLG
M_DLA = ~X_DLA
+# DocX Styles
+S_NORM = "Normal"
+S_TITLE = "Title"
+S_HEAD1 = "Heading1"
+S_HEAD2 = "Heading2"
+S_HEAD3 = "Heading3"
+S_HEAD4 = "Heading4"
+S_SEP = "Separator"
+S_FIND = "FirstLineIndent"
+S_META = "TextMeta"
# Colours
COL_HEAD_L12 = "2a6099"
@@ -196,9 +207,9 @@ def doConvert(self) -> None:
# xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens:
- par = DocXParagraph()
# Styles
+ par = DocXParagraph()
if tStyle is not None:
if tStyle & self.A_LEFT:
par.setAlignment("left")
@@ -230,49 +241,49 @@ def doConvert(self) -> None:
# dedicated pre-defined style for it
# if tStyle & self.A_IND_T:
# else:
- self._addFragments(par, "Normal", tText, tFormat)
+ self._processFragments(par, S_NORM, tText, tFormat)
elif tType == self.T_TITLE:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addFragments(par, "Title", tHead, tFormat)
+ self._processFragments(par, S_TITLE, tHead, tFormat)
elif tType == self.T_HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addFragments(par, "Heading1", tHead, tFormat)
+ self._processFragments(par, S_HEAD1, tHead, tFormat)
elif tType == self.T_HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addFragments(par, "Heading2", tHead, tFormat)
+ self._processFragments(par, S_HEAD2, tHead, tFormat)
elif tType == self.T_HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addFragments(par, "Heading3", tHead, tFormat)
+ self._processFragments(par, S_HEAD3, tHead, tFormat)
elif tType == self.T_HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "\n")
- self._addFragments(par, "Heading4", tHead, tFormat)
+ self._processFragments(par, S_HEAD4, tHead, tFormat)
elif tType == self.T_SEP:
- self._addFragments(par, "Separator", tText)
+ self._processFragments(par, S_SEP, tText)
elif tType == self.T_SKIP:
- self._addFragments(par, "Normal", "")
+ self._processFragments(par, S_NORM, "")
elif tType == self.T_SYNOPSIS and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
- self._addFragments(par, "TextMeta", tTemp, tFmt)
+ self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_SHORT and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
- self._addFragments(par, "TextMeta", tTemp, tFmt)
+ self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_COMMENT and self._doComments:
tTemp, tFmt = self._formatComments(tText, tFormat)
- self._addFragments(par, "TextMeta", tTemp, tFmt)
+ self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_KEYWORD and self._doKeywords:
tTemp, tFmt = self._formatKeywords(tText)
- self._addFragments(par, "TextMeta", tTemp, tFmt)
+ self._processFragments(par, S_META, tTemp, tFmt)
par.finalise(self._xBody)
@@ -413,7 +424,7 @@ def _formatKeywords(self, text: str) -> tuple[str, T_Formats]:
return rTxt, rFmt
- def _addFragments(
+ def _processFragments(
self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats | None = None
) -> None:
"""Apply formatting tags to text."""
@@ -422,8 +433,7 @@ def _addFragments(
fStart = 0
for fPos, fFmt, fData in tFmt or []:
- run = DocXRun(text[fStart:fPos], xFmt)
- par.addRun(run)
+ par.addContent(self._textRunToXml(text[fStart:fPos], xFmt))
if fFmt == self.FMT_B_B:
xFmt |= X_BLD
@@ -470,11 +480,52 @@ def _addFragments(
fStart = fPos
if rest := text[fStart:]:
- run = DocXRun(rest, xFmt)
- par.addRun(run)
+ par.addContent(self._textRunToXml(rest, xFmt))
return
+ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
+ """Encode the text run into XML."""
+ run = ET.Element(_wTag("r"))
+ rPr = ET.SubElement(run, _wTag("rPr"))
+ if fmt & X_BLD == X_BLD:
+ ET.SubElement(rPr, _wTag("b"))
+ if fmt & X_ITA == X_ITA:
+ ET.SubElement(rPr, _wTag("i"))
+ if fmt & X_UND == X_UND:
+ ET.SubElement(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
+ if fmt & X_MRK == X_MRK:
+ ET.SubElement(rPr, _wTag("shd"), attrib={
+ _wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear",
+ })
+ if fmt & X_DEL == X_DEL:
+ ET.SubElement(rPr, _wTag("strike"))
+ if fmt & X_SUP == X_SUP:
+ ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
+ if fmt & X_SUB == X_SUB:
+ ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
+ if fmt & X_DLG == X_DLG:
+ ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
+ if fmt & X_DLA == X_DLA:
+ ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
+
+ remaining = text
+ while (parts := remaining.partition("\n"))[0]:
+ segment = parts[0]
+ attr = {}
+ if len(segment) != len(segment.strip()):
+ attr[_mkTag("xml", "space")] = "preserve"
+ _addSingle(run, _wTag("t"), segment, attrib=attr)
+ if parts[1]:
+ _addSingle(run, _wTag("br"))
+ remaining = parts[2]
+
+ return run
+
+ ##
+ # Style Elements
+ ##
+
def _defaultStyles(self) -> None:
"""Set the default styles."""
xStyl = ET.SubElement(self._dStyl, _wTag("docDefaults"))
@@ -506,7 +557,7 @@ def _useableStyles(self) -> None:
# Add Normal Style
self._addParStyle(
name="Normal",
- styleId="Normal",
+ styleId=S_NORM,
default=True,
margins=self._marginText,
)
@@ -514,10 +565,10 @@ def _useableStyles(self) -> None:
# Add Title
self._addParStyle(
name="Title",
- styleId="Title",
+ styleId=S_TITLE,
size=nwStyles.H_SIZES[0] if hScale else 1.0,
- basedOn="Normal",
- nextStyle="Normal",
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginTitle,
level=0,
)
@@ -525,10 +576,10 @@ def _useableStyles(self) -> None:
# Add Heading 1
self._addParStyle(
name="Heading 1",
- styleId="Heading1",
+ styleId=S_HEAD1,
size=nwStyles.H_SIZES[1] if hScale else 1.0,
- basedOn="Normal",
- nextStyle="Normal",
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginHead1,
level=0,
color=COL_HEAD_L12 if hColor else None,
@@ -537,10 +588,10 @@ def _useableStyles(self) -> None:
# Add Heading 2
self._addParStyle(
name="Heading 2",
- styleId="Heading2",
+ styleId=S_HEAD2,
size=nwStyles.H_SIZES[2] if hScale else 1.0,
- basedOn="Normal",
- nextStyle="Normal",
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginHead2,
level=1,
color=COL_HEAD_L12 if hColor else None,
@@ -549,10 +600,10 @@ def _useableStyles(self) -> None:
# Add Heading 3
self._addParStyle(
name="Heading 3",
- styleId="Heading3",
+ styleId=S_HEAD3,
size=nwStyles.H_SIZES[3] if hScale else 1.0,
- basedOn="Normal",
- nextStyle="Normal",
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginHead3,
level=1,
color=COL_HEAD_L34 if hColor else None,
@@ -561,10 +612,10 @@ def _useableStyles(self) -> None:
# Add Heading 4
self._addParStyle(
name="Heading 4",
- styleId="Heading4",
+ styleId=S_HEAD4,
size=nwStyles.H_SIZES[4] if hScale else 1.0,
- basedOn="Normal",
- nextStyle="Normal",
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginHead4,
level=1,
color=COL_HEAD_L34 if hColor else None,
@@ -573,9 +624,9 @@ def _useableStyles(self) -> None:
# Add Separator
self._addParStyle(
name="Separator",
- styleId="Separator",
- basedOn="Normal",
- nextStyle="Normal",
+ styleId=S_SEP,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginSep,
align="center",
)
@@ -583,9 +634,9 @@ def _useableStyles(self) -> None:
# Add Text Meta Style
self._addParStyle(
name="Text Meta",
- styleId="TextMeta",
- basedOn="Normal",
- nextStyle="Normal",
+ styleId=S_META,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
margins=self._marginMeta,
color=COL_META_TXT,
)
@@ -646,8 +697,8 @@ def _addParStyle(
class DocXParagraph:
def __init__(self) -> None:
- self._text: list[DocXRun] = []
- self._style: str = "Normal"
+ self._content: list[ET.Element] = []
+ self._style: str = S_NORM
self._textAlign: str | None = None
self._topMargin: int | None = None
self._bottomMargin: int | None = None
@@ -694,9 +745,9 @@ def setPageBreakAfter(self, state: bool) -> None:
# Methods
##
- def addRun(self, run: DocXRun) -> None:
+ def addContent(self, run: ET.Element) -> None:
"""Add a run segment to the paragraph."""
- self._text.append(run)
+ self._content.append(run)
return
def finalise(self, body: ET.Element) -> None:
@@ -721,55 +772,9 @@ def finalise(self, body: ET.Element) -> None:
# Text
if self._breakBefore:
_addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
- for run in self._text:
- run.append(ET.SubElement(par, _wTag("r")))
+ for run in self._content:
+ par.append(run)
if self._breakAfter:
_addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
return
-
-
-class DocXRun:
-
- def __init__(self, text: str, fmt: int) -> None:
- self._text = text
- self._fmt = fmt
- return
-
- def append(self, parent: ET.Element) -> None:
- """Append the text run to a paragraph."""
- if text := self._text:
- fmt = self._fmt
- rPr = ET.SubElement(parent, _wTag("rPr"))
- if fmt & X_BLD == X_BLD:
- ET.SubElement(rPr, _wTag("b"))
- if fmt & X_ITA == X_ITA:
- ET.SubElement(rPr, _wTag("i"))
- if fmt & X_UND == X_UND:
- ET.SubElement(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
- if fmt & X_MRK == X_MRK:
- ET.SubElement(rPr, _wTag("shd"), attrib={
- _wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear",
- })
- if fmt & X_DEL == X_DEL:
- ET.SubElement(rPr, _wTag("strike"))
- if fmt & X_SUP == X_SUP:
- ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
- if fmt & X_SUB == X_SUB:
- ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
- if fmt & X_DLG == X_DLG:
- ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
- if fmt & X_DLA == X_DLA:
- ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
-
- temp = text
- while (parts := temp.partition("\n"))[0]:
- part = parts[0]
- attr = {}
- if len(part) != len(part.strip()):
- attr[_mkTag("xml", "space")] = "preserve"
- _addSingle(parent, _wTag("t"), part, attrib=attr)
- if parts[1]:
- _addSingle(parent, _wTag("br"))
- temp = parts[2]
- return
From 34127d78bff9ae2998c199b1913d5cee723e96bc Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 19 Oct 2024 17:08:08 +0200
Subject: [PATCH 07/16] Move custom XML sub element function to common
---
novelwriter/common.py | 26 ++++++++++++++++++++++++++
novelwriter/formats/toodt.py | 33 ++++++++++-----------------------
2 files changed, 36 insertions(+), 23 deletions(-)
diff --git a/novelwriter/common.py b/novelwriter/common.py
index 44463ca69..e96e5968a 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -204,6 +204,14 @@ def checkIntTuple(value: int, valid: tuple | list | set, default: int) -> int:
return default
+def firstFloat(*args: Any) -> float:
+ """Return the first value that is a float."""
+ for arg in args:
+ if isinstance(arg, float):
+ return arg
+ return 0.0
+
+
##
# Formatting Functions
##
@@ -515,6 +523,24 @@ def indentChildren(elem: ET.Element, level: int) -> None:
return
+def xmlSubElem(
+ parent: ET.Element,
+ tag: str,
+ text: str | int | float | bool | None = None,
+ attrib: dict | None = None
+) -> ET.Element:
+ """A custom implementation of SubElement that takes text as an
+ argument.
+ """
+ xSub = ET.SubElement(parent, tag, attrib=attrib or {})
+ if text is not None:
+ if isinstance(text, bool):
+ xSub.text = str(text).lower()
+ else:
+ xSub.text = str(text)
+ return xSub
+
+
##
# File and File System Functions
##
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index fd683fd97..3fb771006 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -38,7 +38,7 @@
from PyQt5.QtGui import QFont
from novelwriter import __version__
-from novelwriter.common import xmlIndent
+from novelwriter.common import xmlIndent, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.formats.tokenizer import T_Formats, Tokenizer, stripEscape
@@ -69,19 +69,6 @@ def _mkTag(ns: str, tag: str) -> str:
return tag
-def _addSingle(
- parent: ET.Element,
- tag: str,
- text: str | int | None = None,
- attrib: dict | None = None
-) -> None:
- """Add a single value to a parent element."""
- xSub = ET.SubElement(parent, tag, attrib=attrib or {})
- if text is not None:
- xSub.text = str(text)
- return
-
-
# Mimetype and Version
X_MIME = "application/vnd.oasis.opendocument.text"
X_VERS = "1.3"
@@ -411,21 +398,21 @@ def initDocument(self) -> None:
timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
# Office Meta Data
- _addSingle(self._xMeta, _mkTag("meta", "creation-date"), timeStamp)
- _addSingle(self._xMeta, _mkTag("meta", "generator"), f"novelWriter/{__version__}")
- _addSingle(self._xMeta, _mkTag("meta", "initial-creator"), self._project.data.author)
- _addSingle(self._xMeta, _mkTag("meta", "editing-cycles"), self._project.data.saveCount)
+ xmlSubElem(self._xMeta, _mkTag("meta", "creation-date"), timeStamp)
+ xmlSubElem(self._xMeta, _mkTag("meta", "generator"), f"novelWriter/{__version__}")
+ xmlSubElem(self._xMeta, _mkTag("meta", "initial-creator"), self._project.data.author)
+ xmlSubElem(self._xMeta, _mkTag("meta", "editing-cycles"), self._project.data.saveCount)
# Format is: PnYnMnDTnHnMnS
# https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#duration
eT = self._project.data.editTime
fT = f"P{eT//86400:d}DT{eT%86400//3600:d}H{eT%3600//60:d}M{eT%60:d}S"
- _addSingle(self._xMeta, _mkTag("meta", "editing-duration"), fT)
+ xmlSubElem(self._xMeta, _mkTag("meta", "editing-duration"), fT)
# Dublin Core Meta Data
- _addSingle(self._xMeta, _mkTag("dc", "title"), self._project.data.name)
- _addSingle(self._xMeta, _mkTag("dc", "date"), timeStamp)
- _addSingle(self._xMeta, _mkTag("dc", "creator"), self._project.data.author)
+ xmlSubElem(self._xMeta, _mkTag("dc", "title"), self._project.data.name)
+ xmlSubElem(self._xMeta, _mkTag("dc", "date"), timeStamp)
+ xmlSubElem(self._xMeta, _mkTag("dc", "creator"), self._project.data.author)
self._pageStyles()
self._defaultStyles()
@@ -795,7 +782,7 @@ def _generateFootnote(self, key: str) -> ET.Element | None:
_mkTag("text", "id"): f"ftn{self._nNote}",
_mkTag("text", "note-class"): "footnote",
})
- _addSingle(xNote, _mkTag("text", "note-citation"), self._nNote)
+ xmlSubElem(xNote, _mkTag("text", "note-citation"), self._nNote)
xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
self._addTextPar(xBody, "Footnote", nStyle, content[0], tFmt=content[1])
return xNote
From 3c76d0e7e3a27c32ff816f5fb4434b0a22fff376 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 19 Oct 2024 17:08:24 +0200
Subject: [PATCH 08/16] Fix issue with line spacing in DocX
---
novelwriter/formats/todocx.py | 357 ++++++++++++++++++----------------
1 file changed, 192 insertions(+), 165 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 4aa966d4f..b9b78ccf9 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -29,10 +29,11 @@
from datetime import datetime
from pathlib import Path
+from typing import NamedTuple
from zipfile import ZipFile
from novelwriter import __version__
-from novelwriter.common import xmlIndent
+from novelwriter.common import firstFloat, xmlIndent, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.formats.tokenizer import T_Formats, Tokenizer
@@ -75,19 +76,6 @@ def _mkTag(ns: str, tag: str) -> str:
return tag
-def _addSingle(
- parent: ET.Element,
- tag: str,
- text: str | int | None = None,
- attrib: dict | None = None
-) -> None:
- """Add a single value to a parent element."""
- xSub = ET.SubElement(parent, tag, attrib=attrib or {})
- if text is not None:
- xSub.text = str(text)
- return
-
-
# Formatting Codes
X_BLD = 0x001 # Bold format
X_ITA = 0x002 # Italic format
@@ -130,6 +118,22 @@ def _addSingle(
COL_MARK_TXT = "ffffa6"
+class DocXParStyle(NamedTuple):
+
+ name: str
+ styleId: str
+ size: float
+ basedOn: str | None = None
+ nextStyle: str | None = None
+ before: float | None = None
+ after: float | None = None
+ line: float | None = None
+ align: str | None = None
+ default: bool = False
+ level: int | None = None
+ color: str | None = None
+
+
class ToDocX(Tokenizer):
"""Core: DocX Document Writer
@@ -154,6 +158,9 @@ def __init__(self, project: NWProject) -> None:
self._fontSize = 12.0
self._dLanguage = "en-GB"
+ # Maps
+ self._styles: dict[str, DocXParStyle] = {}
+
return
##
@@ -194,7 +201,7 @@ def initDocument(self) -> None:
self._dDoc = ET.Element(_wTag("document"))
self._dStyl = ET.Element(_wTag("styles"))
- self._xBody = ET.SubElement(self._dDoc, _wTag("body"))
+ self._xBody = xmlSubElem(self._dDoc, _wTag("body"))
self._defaultStyles()
self._useableStyles()
@@ -295,76 +302,76 @@ def saveDocument(self, path: Path) -> None:
# .rels
dRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
- _addSingle(dRels, "Relationship", attrib={
+ xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml",
})
- _addSingle(dRels, "Relationship", attrib={
+ xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId2", "Type": f"{REL_BASE}/extended-properties", "Target": "docProps/app.xml",
})
- _addSingle(dRels, "Relationship", attrib={
+ xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId3", "Type": f"{REL_BASE}/officeDocument", "Target": "word/document.xml",
})
# core.xml
dCore = ET.Element("coreProperties")
tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
- _addSingle(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
- _addSingle(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
- _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author)
- _addSingle(dCore, _mkTag("dc", "title"), self._project.data.name)
- _addSingle(dCore, _mkTag("dc", "creator"), self._project.data.author)
- _addSingle(dCore, _mkTag("dc", "language"), self._dLanguage)
- _addSingle(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount))
- _addSingle(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
+ xmlSubElem(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
+ xmlSubElem(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
+ xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
+ xmlSubElem(dCore, _mkTag("dc", "title"), self._project.data.name)
+ xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
+ xmlSubElem(dCore, _mkTag("dc", "language"), self._dLanguage)
+ xmlSubElem(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount))
+ xmlSubElem(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
# app.xml
dApp = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
- _addSingle(dApp, "TotalTime", self._project.data.editTime // 60)
- _addSingle(dApp, "Application", f"novelWriter/{__version__}")
+ xmlSubElem(dApp, "TotalTime", self._project.data.editTime // 60)
+ xmlSubElem(dApp, "Application", f"novelWriter/{__version__}")
if count := self._counts.get("allWords"):
- _addSingle(dApp, "Words", count)
+ xmlSubElem(dApp, "Words", count)
if count := self._counts.get("textWordChars"):
- _addSingle(dApp, "Characters", count)
+ xmlSubElem(dApp, "Characters", count)
if count := self._counts.get("textChars"):
- _addSingle(dApp, "CharactersWithSpaces", count)
+ xmlSubElem(dApp, "CharactersWithSpaces", count)
if count := self._counts.get("paragraphCount"):
- _addSingle(dApp, "Paragraphs", count)
+ xmlSubElem(dApp, "Paragraphs", count)
# document.xml.rels
dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
- _addSingle(dDRels, "Relationship", attrib={
+ xmlSubElem(dDRels, "Relationship", attrib={
"Id": "rId1", "Type": f"{REL_BASE}/styles", "Target": "styles.xml",
})
# [Content_Types].xml
dCont = ET.Element("Types", attrib={"xmlns": TYPES_NS})
- _addSingle(dCont, "Default", attrib={
+ xmlSubElem(dCont, "Default", attrib={
"Extension": "xml", "ContentType": "application/xml",
})
- _addSingle(dCont, "Default", attrib={
+ xmlSubElem(dCont, "Default", attrib={
"Extension": "rels", "ContentType": RELS_TYPE,
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/_rels/.rels",
"ContentType": RELS_TYPE,
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/docProps/core.xml",
"ContentType": f"{WORD_BASE}.extended-properties+xml",
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/docProps/app.xml",
"ContentType": "application/vnd.openxmlformats-package.core-properties+xml",
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/word/_rels/document.xml.rels",
"ContentType": RELS_TYPE,
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/word/document.xml",
"ContentType": f"{WORD_BASE}.wordprocessingml.document.main+xml",
})
- _addSingle(dCont, "Override", attrib={
+ xmlSubElem(dCont, "Override", attrib={
"PartName": "/word/styles.xml",
"ContentType": f"{WORD_BASE}.wordprocessingml.styles+xml",
})
@@ -428,7 +435,7 @@ def _processFragments(
self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats | None = None
) -> None:
"""Apply formatting tags to text."""
- par.setStyle(pStyle)
+ par.setStyle(self._styles.get(pStyle))
xFmt = 0x00
fStart = 0
for fPos, fFmt, fData in tFmt or []:
@@ -487,27 +494,27 @@ def _processFragments(
def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
"""Encode the text run into XML."""
run = ET.Element(_wTag("r"))
- rPr = ET.SubElement(run, _wTag("rPr"))
+ rPr = xmlSubElem(run, _wTag("rPr"))
if fmt & X_BLD == X_BLD:
- ET.SubElement(rPr, _wTag("b"))
+ xmlSubElem(rPr, _wTag("b"))
if fmt & X_ITA == X_ITA:
- ET.SubElement(rPr, _wTag("i"))
+ xmlSubElem(rPr, _wTag("i"))
if fmt & X_UND == X_UND:
- ET.SubElement(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
+ xmlSubElem(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
if fmt & X_MRK == X_MRK:
- ET.SubElement(rPr, _wTag("shd"), attrib={
+ xmlSubElem(rPr, _wTag("shd"), attrib={
_wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear",
})
if fmt & X_DEL == X_DEL:
- ET.SubElement(rPr, _wTag("strike"))
+ xmlSubElem(rPr, _wTag("strike"))
if fmt & X_SUP == X_SUP:
- ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
if fmt & X_SUB == X_SUB:
- ET.SubElement(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
if fmt & X_DLG == X_DLG:
- ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
+ xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
if fmt & X_DLA == X_DLA:
- ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
+ xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
remaining = text
while (parts := remaining.partition("\n"))[0]:
@@ -515,9 +522,9 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
attr = {}
if len(segment) != len(segment.strip()):
attr[_mkTag("xml", "space")] = "preserve"
- _addSingle(run, _wTag("t"), segment, attrib=attr)
+ xmlSubElem(run, _wTag("t"), segment, attrib=attr)
if parts[1]:
- _addSingle(run, _wTag("br"))
+ xmlSubElem(run, _wTag("br"))
remaining = parts[2]
return run
@@ -528,24 +535,24 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
def _defaultStyles(self) -> None:
"""Set the default styles."""
- xStyl = ET.SubElement(self._dStyl, _wTag("docDefaults"))
- xRDef = ET.SubElement(xStyl, _wTag("rPrDefault"))
- xPDef = ET.SubElement(xStyl, _wTag("pPrDefault"))
- xRPr = ET.SubElement(xRDef, _wTag("rPr"))
- xPPr = ET.SubElement(xPDef, _wTag("pPr"))
+ xStyl = xmlSubElem(self._dStyl, _wTag("docDefaults"))
+ xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
+ xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
+ xRPr = xmlSubElem(xRDef, _wTag("rPr"))
+ xPPr = xmlSubElem(xPDef, _wTag("pPr"))
size = str(int(2.0 * self._fontSize))
line = str(int(20.0 * self._lineHeight * self._fontSize))
- ET.SubElement(xRPr, _wTag("rFonts"), attrib={
+ xmlSubElem(xRPr, _wTag("rFonts"), attrib={
_wTag("ascii"): self._fontFamily,
_wTag("hAnsi"): self._fontFamily,
_wTag("cs"): self._fontFamily,
})
- ET.SubElement(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
- ET.SubElement(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
- ET.SubElement(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
- ET.SubElement(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
+ xmlSubElem(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
+ xmlSubElem(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
+ xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
+ xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
return
@@ -553,143 +560,156 @@ def _useableStyles(self) -> None:
"""Set the usable styles."""
hScale = self._scaleHeads
hColor = self._colorHeads
+ fSz = self._fontSize
+ fSz0 = (nwStyles.H_SIZES[0] * fSz) if hScale else fSz
+ fSz1 = (nwStyles.H_SIZES[1] * fSz) if hScale else fSz
+ fSz2 = (nwStyles.H_SIZES[2] * fSz) if hScale else fSz
+ fSz3 = (nwStyles.H_SIZES[3] * fSz) if hScale else fSz
+ fSz4 = (nwStyles.H_SIZES[4] * fSz) if hScale else fSz
# Add Normal Style
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Normal",
styleId=S_NORM,
+ size=fSz,
default=True,
- margins=self._marginText,
- )
+ before=fSz * self._marginText[0],
+ after=fSz * self._marginText[1],
+ line=fSz * self._lineHeight,
+ ))
# Add Title
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Title",
styleId=S_TITLE,
- size=nwStyles.H_SIZES[0] if hScale else 1.0,
+ size=fSz0,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginTitle,
+ before=fSz * self._marginTitle[0],
+ after=fSz * self._marginTitle[1],
+ line=fSz0 * self._lineHeight,
level=0,
- )
+ ))
# Add Heading 1
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Heading 1",
styleId=S_HEAD1,
- size=nwStyles.H_SIZES[1] if hScale else 1.0,
+ size=fSz1,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginHead1,
+ before=fSz * self._marginHead1[0],
+ after=fSz * self._marginHead1[1],
+ line=fSz1 * self._lineHeight,
level=0,
color=COL_HEAD_L12 if hColor else None,
- )
+ ))
# Add Heading 2
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Heading 2",
styleId=S_HEAD2,
- size=nwStyles.H_SIZES[2] if hScale else 1.0,
+ size=fSz2,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginHead2,
+ before=fSz * self._marginHead2[0],
+ after=fSz * self._marginHead2[1],
+ line=fSz2 * self._lineHeight,
level=1,
color=COL_HEAD_L12 if hColor else None,
- )
+ ))
# Add Heading 3
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Heading 3",
styleId=S_HEAD3,
- size=nwStyles.H_SIZES[3] if hScale else 1.0,
+ size=fSz3,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginHead3,
+ before=fSz * self._marginHead3[0],
+ after=fSz * self._marginHead3[1],
+ line=fSz3 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
- )
+ ))
# Add Heading 4
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Heading 4",
styleId=S_HEAD4,
- size=nwStyles.H_SIZES[4] if hScale else 1.0,
+ size=fSz4,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginHead4,
+ before=fSz * self._marginHead4[0],
+ after=fSz * self._marginHead4[1],
+ line=fSz4 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
- )
+ ))
# Add Separator
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Separator",
styleId=S_SEP,
+ size=fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginSep,
+ before=fSz * self._marginSep[0],
+ after=fSz * self._marginSep[1],
+ line=fSz * self._lineHeight,
align="center",
- )
+ ))
# Add Text Meta Style
- self._addParStyle(
+ self._addParStyle(DocXParStyle(
name="Text Meta",
styleId=S_META,
+ size=fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
- margins=self._marginMeta,
+ before=fSz * self._marginMeta[0],
+ after=fSz * self._marginMeta[1],
+ line=fSz * self._lineHeight,
color=COL_META_TXT,
- )
+ ))
return
- def _addParStyle(
- self, *,
- name: str,
- styleId: str,
- size: float = 1.0,
- basedOn: str | None = None,
- nextStyle: str | None = None,
- margins: tuple[float, float] | None = None,
- align: str | None = None,
- default: bool = False,
- level: int | None = None,
- color: str | None = None,
- ) -> None:
+ def _addParStyle(self, style: DocXParStyle) -> None:
"""Add a paragraph style."""
sAttr = {}
sAttr[_wTag("type")] = "paragraph"
- sAttr[_wTag("styleId")] = styleId
- if default:
+ sAttr[_wTag("styleId")] = style.styleId
+ if style.default:
sAttr[_wTag("default")] = "1"
- sz = str(int(2.0 * size * self._fontSize))
- ln = str(int(20.0 * size * self._lineHeight * self._fontSize))
-
- xStyl = ET.SubElement(self._dStyl, _wTag("style"), attrib=sAttr)
- ET.SubElement(xStyl, _wTag("name"), attrib={_wTag("val"): name})
- if basedOn:
- ET.SubElement(xStyl, _wTag("basedOn"), attrib={_wTag("val"): basedOn})
- if nextStyle:
- ET.SubElement(xStyl, _wTag("next"), attrib={_wTag("val"): nextStyle})
- if level is not None:
- ET.SubElement(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(level)})
-
- pPr = ET.SubElement(xStyl, _wTag("pPr"))
- if margins:
- ET.SubElement(pPr, _wTag("spacing"), attrib={
- _wTag("before"): str(int(20.0 * margins[0] * self._fontSize)),
- _wTag("after"): str(int(20.0 * margins[1] * self._fontSize)),
- _wTag("line"): ln,
- })
- if align:
- ET.SubElement(pPr, _wTag("jc"), attrib={_wTag("val"): align})
+ size = firstFloat(style.size, self._fontSize)
+
+ xStyl = xmlSubElem(self._dStyl, _wTag("style"), attrib=sAttr)
+ xmlSubElem(xStyl, _wTag("name"), attrib={_wTag("val"): style.name})
+ if style.basedOn:
+ xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
+ if style.nextStyle:
+ xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
+ if style.level is not None:
+ xmlSubElem(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
+
+ pPr = xmlSubElem(xStyl, _wTag("pPr"))
+ xmlSubElem(pPr, _wTag("spacing"), attrib={
+ _wTag("before"): str(int(20.0 * firstFloat(style.before))),
+ _wTag("after"): str(int(20.0 * firstFloat(style.after))),
+ _wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
+ })
+ if style.align:
+ xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
+
+ rPr = xmlSubElem(xStyl, _wTag("rPr"))
+ xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
+ xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
+ if style.color:
+ xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
- rPr = ET.SubElement(xStyl, _wTag("rPr"))
- ET.SubElement(rPr, _wTag("sz"), attrib={_wTag("val"): sz})
- ET.SubElement(rPr, _wTag("szCs"), attrib={_wTag("val"): sz})
- if color:
- ET.SubElement(rPr, _wTag("color"), attrib={_wTag("val"): color})
+ self._styles[style.styleId] = style
return
@@ -698,10 +718,10 @@ class DocXParagraph:
def __init__(self) -> None:
self._content: list[ET.Element] = []
- self._style: str = S_NORM
+ self._style: DocXParStyle | None = None
self._textAlign: str | None = None
- self._topMargin: int | None = None
- self._bottomMargin: int | None = None
+ self._topMargin: float | None = None
+ self._bottomMargin: float | None = None
self._breakBefore = False
self._breakAfter = False
return
@@ -710,9 +730,9 @@ def __init__(self) -> None:
# Setters
##
- def setStyle(self, value: str) -> None:
+ def setStyle(self, style: DocXParStyle | None) -> None:
"""Set the paragraph style."""
- self._style = value
+ self._style = style
return
def setAlignment(self, value: str) -> None:
@@ -723,12 +743,12 @@ def setAlignment(self, value: str) -> None:
def setMarginTop(self, value: float) -> None:
"""Set margin above in pt."""
- self._topMargin = int(20.0 * value)
+ self._topMargin = value
return
def setMarginBottom(self, value: float) -> None:
"""Set margin below in pt."""
- self._bottomMargin = int(20.0 * value)
+ self._bottomMargin = value
return
def setPageBreakBefore(self, state: bool) -> None:
@@ -752,29 +772,36 @@ def addContent(self, run: ET.Element) -> None:
def finalise(self, body: ET.Element) -> None:
"""Called after all content is set."""
- par = ET.SubElement(body, _wTag("p"))
-
- # Values
- spacing = {}
- if self._topMargin is not None:
- spacing["before"] = str(self._topMargin)
- if self._bottomMargin is not None:
- spacing["after"] = str(self._bottomMargin)
-
- # Paragraph
- pPr = ET.SubElement(par, _wTag("pPr"))
- _addSingle(pPr, _wTag("pStyle"), attrib={_wTag("val"): self._style})
- if spacing:
- _addSingle(pPr, _wTag("spacing"), attrib=spacing)
- if self._textAlign:
- _addSingle(pPr, _wTag("jc"), attrib={_wTag("val"): self._textAlign})
-
- # Text
- if self._breakBefore:
- _addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
- for run in self._content:
- par.append(run)
- if self._breakAfter:
- _addSingle(ET.SubElement(par, _wTag("r")), _wTag("br"), attrib={_wTag("type"): "page"})
+ if style := self._style:
+ par = xmlSubElem(body, _wTag("p"))
+
+ # Values
+ spacing = {}
+ if self._topMargin is not None:
+ spacing["before"] = str(self._topMargin)
+ if self._bottomMargin is not None:
+ spacing["after"] = str(self._bottomMargin)
+
+ # Paragraph
+ pPr = xmlSubElem(par, _wTag("pPr"))
+ xmlSubElem(pPr, _wTag("pStyle"), attrib={_wTag("val"): style.styleId})
+ if self._topMargin is not None or self._bottomMargin is not None:
+ xmlSubElem(pPr, _wTag("spacing"), attrib={
+ _wTag("before"): str(int(20.0 * firstFloat(self._topMargin, style.before))),
+ _wTag("after"): str(int(20.0 * firstFloat(self._bottomMargin, style.after))),
+ _wTag("line"): str(int(20.0 * firstFloat(style.line, style.size))),
+ })
+ if self._textAlign:
+ xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): self._textAlign})
+
+ # Text
+ if self._breakBefore:
+ wr = xmlSubElem(par, _wTag("r"))
+ xmlSubElem(wr, _wTag("br"), attrib={_wTag("type"): "page"})
+ for run in self._content:
+ par.append(run)
+ if self._breakAfter:
+ wr = xmlSubElem(par, _wTag("r"))
+ xmlSubElem(wr, _wTag("br"), attrib={_wTag("type"): "page"})
return
From a0ab507ed171ec48d82a214655a2b0e9ff206592 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 19 Oct 2024 19:04:16 +0200
Subject: [PATCH 09/16] Fix DocX tabs in text, and page break before issues
---
novelwriter/core/docbuild.py | 2 +
novelwriter/formats/todocx.py | 85 ++++++++++++++++++++++++++---------
2 files changed, 65 insertions(+), 22 deletions(-)
diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 07ce3c816..8e707402f 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -193,6 +193,8 @@ def iterBuildDocument(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[i
yield from self._iterBuild(makeObj, filtered)
+ makeObj.closeDocument()
+
elif bFormat == nwBuildFmt.PDF:
makeObj = ToQTextDocument(self._project)
filtered = self._setupBuild(makeObj)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index b9b78ccf9..fac3f471d 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -25,6 +25,7 @@
from __future__ import annotations
import logging
+import re
import xml.etree.ElementTree as ET
from datetime import datetime
@@ -40,6 +41,9 @@
logger = logging.getLogger(__name__)
+# RegEx
+RX_TEXT = re.compile(r"([\n\t])", re.UNICODE)
+
# Types and Relationships
WORD_BASE = "application/vnd.openxmlformats-officedocument"
RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
@@ -158,8 +162,9 @@ def __init__(self, project: NWProject) -> None:
self._fontSize = 12.0
self._dLanguage = "en-GB"
- # Maps
+ # Data Variables
self._styles: dict[str, DocXParStyle] = {}
+ self._pars: list[DocXParagraph] = []
return
@@ -215,8 +220,11 @@ def doConvert(self) -> None:
# xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens:
- # Styles
+ # Create Paragraph
par = DocXParagraph()
+ self._pars.append(par)
+
+ # Styles
if tStyle is not None:
if tStyle & self.A_LEFT:
par.setAlignment("left")
@@ -292,7 +300,29 @@ def doConvert(self) -> None:
tTemp, tFmt = self._formatKeywords(tText)
self._processFragments(par, S_META, tTemp, tFmt)
- par.finalise(self._xBody)
+ return
+
+ def closeDocument(self) -> None:
+ """Finalise document."""
+ # Map all Page Break Before to After where possible
+ pars: list[DocXParagraph] = []
+ for i, par in enumerate(self._pars):
+ if i > 0 and par.pageBreakBefore:
+ prev = self._pars[i-1]
+ if prev.pageBreakAfter:
+ # We already have a break, so we inject a new paragraph instead
+ empty = DocXParagraph()
+ empty.setStyle(self._styles.get(S_NORM))
+ empty.setPageBreakAfter(True)
+ pars.append(empty)
+ else:
+ par.setPageBreakBefore(False)
+ prev.setPageBreakAfter(True)
+
+ pars.append(par)
+
+ for par in pars:
+ par.toXml(self._xBody)
return
@@ -486,8 +516,8 @@ def _processFragments(
# Move pos for next pass
fStart = fPos
- if rest := text[fStart:]:
- par.addContent(self._textRunToXml(rest, xFmt))
+ if temp := text[fStart:]:
+ par.addContent(self._textRunToXml(temp, xFmt))
return
@@ -516,16 +546,15 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
if fmt & X_DLA == X_DLA:
xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
- remaining = text
- while (parts := remaining.partition("\n"))[0]:
- segment = parts[0]
- attr = {}
- if len(segment) != len(segment.strip()):
- attr[_mkTag("xml", "space")] = "preserve"
- xmlSubElem(run, _wTag("t"), segment, attrib=attr)
- if parts[1]:
+ for segment in RX_TEXT.split(text):
+ if segment == "\n":
xmlSubElem(run, _wTag("br"))
- remaining = parts[2]
+ elif segment == "\t":
+ xmlSubElem(run, _wTag("tab"))
+ elif len(segment) != len(segment.strip()):
+ xmlSubElem(run, _wTag("t"), segment, attrib={_mkTag("xml", "space"): "preserve"})
+ elif segment:
+ xmlSubElem(run, _wTag("t"), segment)
return run
@@ -716,6 +745,11 @@ def _addParStyle(self, style: DocXParStyle) -> None:
class DocXParagraph:
+ __slots__ = (
+ "_content", "_style", "_textAlign", "_topMargin", "_bottomMargin",
+ "_breakBefore", "_breakAfter",
+ )
+
def __init__(self) -> None:
self._content: list[ET.Element] = []
self._style: DocXParStyle | None = None
@@ -726,6 +760,20 @@ def __init__(self) -> None:
self._breakAfter = False
return
+ ##
+ # Properties
+ ##
+
+ @property
+ def pageBreakBefore(self) -> bool:
+ """Has page break before."""
+ return self._breakBefore
+
+ @property
+ def pageBreakAfter(self) -> bool:
+ """Has page break after."""
+ return self._breakAfter
+
##
# Setters
##
@@ -770,18 +818,11 @@ def addContent(self, run: ET.Element) -> None:
self._content.append(run)
return
- def finalise(self, body: ET.Element) -> None:
+ def toXml(self, body: ET.Element) -> None:
"""Called after all content is set."""
if style := self._style:
par = xmlSubElem(body, _wTag("p"))
- # Values
- spacing = {}
- if self._topMargin is not None:
- spacing["before"] = str(self._topMargin)
- if self._bottomMargin is not None:
- spacing["after"] = str(self._bottomMargin)
-
# Paragraph
pPr = xmlSubElem(par, _wTag("pPr"))
xmlSubElem(pPr, _wTag("pStyle"), attrib={_wTag("val"): style.styleId})
From de6c3e9b4500a926d9afe94a2fed55913e0df5e7 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 19 Oct 2024 20:14:01 +0200
Subject: [PATCH 10/16] Fix issue with global justify, and add indentation to
DocX
---
novelwriter/formats/todocx.py | 78 ++++++++++++++++++++++------
novelwriter/formats/tohtml.py | 2 +-
novelwriter/formats/tokenizer.py | 31 +++++------
novelwriter/formats/toodt.py | 11 +++-
sample/nwProject.nwx | 10 ++--
tests/test_formats/test_fmt_toodt.py | 18 ++++++-
6 files changed, 112 insertions(+), 38 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index fac3f471d..e8502a256 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -110,7 +110,6 @@ def _mkTag(ns: str, tag: str) -> str:
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
S_SEP = "Separator"
-S_FIND = "FirstLineIndent"
S_META = "TextMeta"
# Colours
@@ -132,10 +131,12 @@ class DocXParStyle(NamedTuple):
before: float | None = None
after: float | None = None
line: float | None = None
+ indentFirst: float | None = None
align: str | None = None
default: bool = False
level: int | None = None
color: str | None = None
+ bold: bool = False
class ToDocX(Tokenizer):
@@ -217,7 +218,8 @@ def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
- # xText = self._xText
+ bIndent = self._fontSize * self._blockIndent
+
for tType, _, tText, tFormat, tStyle in self._tokens:
# Create Paragraph
@@ -232,8 +234,8 @@ def doConvert(self) -> None:
par.setAlignment("right")
elif tStyle & self.A_CENTRE:
par.setAlignment("center")
- # elif tStyle & self.A_JUSTIFY:
- # oStyle.setTextAlign("justify")
+ elif tStyle & self.A_JUSTIFY:
+ par.setAlignment("both")
if tStyle & self.A_PBB:
par.setPageBreakBefore(True)
@@ -245,17 +247,17 @@ def doConvert(self) -> None:
if tStyle & self.A_Z_TOPMRG:
par.setMarginTop(0.0)
- # if tStyle & self.A_IND_L:
- # oStyle.setMarginLeft(self._fBlockIndent)
- # if tStyle & self.A_IND_R:
- # oStyle.setMarginRight(self._fBlockIndent)
+ if tStyle & self.A_IND_T:
+ par.setIndentFirst(True)
+ if tStyle & self.A_IND_L:
+ par.setLeftMargin(bIndent)
+ if tStyle & self.A_IND_R:
+ par.setRightMargin(bIndent)
# Process Text Types
if tType == self.T_TEXT:
- # Text indentation is processed here because there is a
- # dedicated pre-defined style for it
- # if tStyle & self.A_IND_T:
- # else:
+ if self._doJustify and "\n" in tText:
+ par.overrideJustify(self._defaultAlign)
self._processFragments(par, S_NORM, tText, tFormat)
elif tType == self.T_TITLE:
@@ -595,6 +597,7 @@ def _useableStyles(self) -> None:
fSz2 = (nwStyles.H_SIZES[2] * fSz) if hScale else fSz
fSz3 = (nwStyles.H_SIZES[3] * fSz) if hScale else fSz
fSz4 = (nwStyles.H_SIZES[4] * fSz) if hScale else fSz
+ align = "both" if self._doJustify else "left"
# Add Normal Style
self._addParStyle(DocXParStyle(
@@ -605,6 +608,8 @@ def _useableStyles(self) -> None:
before=fSz * self._marginText[0],
after=fSz * self._marginText[1],
line=fSz * self._lineHeight,
+ indentFirst=fSz * self._firstWidth,
+ align=align,
))
# Add Title
@@ -618,6 +623,7 @@ def _useableStyles(self) -> None:
after=fSz * self._marginTitle[1],
line=fSz0 * self._lineHeight,
level=0,
+ bold=self._boldHeads,
))
# Add Heading 1
@@ -632,6 +638,7 @@ def _useableStyles(self) -> None:
line=fSz1 * self._lineHeight,
level=0,
color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 2
@@ -646,6 +653,7 @@ def _useableStyles(self) -> None:
line=fSz2 * self._lineHeight,
level=1,
color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 3
@@ -660,6 +668,7 @@ def _useableStyles(self) -> None:
line=fSz3 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 4
@@ -674,6 +683,7 @@ def _useableStyles(self) -> None:
line=fSz4 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
))
# Add Separator
@@ -737,6 +747,8 @@ def _addParStyle(self, style: DocXParStyle) -> None:
xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
if style.color:
xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
+ if style.bold:
+ xmlSubElem(rPr, _wTag("b"))
self._styles[style.styleId] = style
@@ -746,8 +758,9 @@ def _addParStyle(self, style: DocXParStyle) -> None:
class DocXParagraph:
__slots__ = (
- "_content", "_style", "_textAlign", "_topMargin", "_bottomMargin",
- "_breakBefore", "_breakAfter",
+ "_content", "_style", "_textAlign",
+ "_topMargin", "_bottomMargin", "_leftMargin", "_rightMargin",
+ "_indentFirst", "_breakBefore", "_breakAfter",
)
def __init__(self) -> None:
@@ -756,6 +769,9 @@ def __init__(self) -> None:
self._textAlign: str | None = None
self._topMargin: float | None = None
self._bottomMargin: float | None = None
+ self._leftMargin: float | None = None
+ self._rightMargin: float | None = None
+ self._indentFirst = False
self._breakBefore = False
self._breakAfter = False
return
@@ -785,7 +801,7 @@ def setStyle(self, style: DocXParStyle | None) -> None:
def setAlignment(self, value: str) -> None:
"""Set paragraph alignment."""
- if value in ("left", "center", "right"):
+ if value in ("left", "center", "right", "both"):
self._textAlign = value
return
@@ -799,6 +815,21 @@ def setMarginBottom(self, value: float) -> None:
self._bottomMargin = value
return
+ def setLeftMargin(self, value: float) -> None:
+ """Set left indent."""
+ self._leftMargin = value
+ return
+
+ def setRightMargin(self, value: float) -> None:
+ """Set right line indent."""
+ self._rightMargin = value
+ return
+
+ def setIndentFirst(self, state: bool) -> None:
+ """Set first line indent."""
+ self._indentFirst = state
+ return
+
def setPageBreakBefore(self, state: bool) -> None:
"""Set page break before flag."""
self._breakBefore = state
@@ -813,6 +844,12 @@ def setPageBreakAfter(self, state: bool) -> None:
# Methods
##
+ def overrideJustify(self, default: str) -> None:
+ """Override inherited justify setting if None is set."""
+ if self._textAlign is None:
+ self.setAlignment(default)
+ return
+
def addContent(self, run: ET.Element) -> None:
"""Add a run segment to the paragraph."""
self._content.append(run)
@@ -823,6 +860,15 @@ def toXml(self, body: ET.Element) -> None:
if style := self._style:
par = xmlSubElem(body, _wTag("p"))
+ # Values
+ indent = {}
+ if self._indentFirst and style.indentFirst is not None:
+ indent[_wTag("firstLine")] = str(int(20.0 * style.indentFirst))
+ if self._leftMargin is not None:
+ indent[_wTag("left")] = str(int(20.0 * self._leftMargin))
+ if self._rightMargin is not None:
+ indent[_wTag("right")] = str(int(20.0 * self._rightMargin))
+
# Paragraph
pPr = xmlSubElem(par, _wTag("pPr"))
xmlSubElem(pPr, _wTag("pStyle"), attrib={_wTag("val"): style.styleId})
@@ -834,6 +880,8 @@ def toXml(self, body: ET.Element) -> None:
})
if self._textAlign:
xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): self._textAlign})
+ if indent:
+ xmlSubElem(pPr, _wTag("ind"), attrib=indent)
# Text
if self._breakBefore:
diff --git a/novelwriter/formats/tohtml.py b/novelwriter/formats/tohtml.py
index 7eb4b33d0..54827a2a1 100644
--- a/novelwriter/formats/tohtml.py
+++ b/novelwriter/formats/tohtml.py
@@ -372,7 +372,7 @@ def getStyleSheet(self) -> list[str]:
"margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;"
"}}"
).format(
- "justify" if self._doJustify else "left",
+ "justify" if self._doJustify else self._defaultAlign,
round(100 * self._lineHeight),
mScale * self._marginText[0],
mScale * self._marginText[1],
diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py
index 916afab21..04ce876af 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -152,21 +152,22 @@ def __init__(self, project: NWProject) -> None:
# User Settings
self._textFont = QFont("Serif", 11) # Output text font
- self._lineHeight = 1.15 # Line height in units of em
- self._colorHeads = True # Colourise headings
- self._scaleHeads = True # Scale headings to larger font size
- self._boldHeads = True # Bold headings
- self._blockIndent = 4.00 # Block indent in units of em
- self._firstIndent = False # Enable first line indent
- self._firstWidth = 1.40 # First line indent in units of em
- self._indentFirst = False # Indent first paragraph
- self._doJustify = False # Justify text
- self._doBodyText = True # Include body text
- self._doSynopsis = False # Also process synopsis comments
- self._doComments = False # Also process comments
- self._doKeywords = False # Also process keywords like tags and references
- self._skipKeywords = set() # Keywords to ignore
- self._keepBreaks = True # Keep line breaks in paragraphs
+ self._lineHeight = 1.15 # Line height in units of em
+ self._colorHeads = True # Colourise headings
+ self._scaleHeads = True # Scale headings to larger font size
+ self._boldHeads = True # Bold headings
+ self._blockIndent = 4.00 # Block indent in units of em
+ self._firstIndent = False # Enable first line indent
+ self._firstWidth = 1.40 # First line indent in units of em
+ self._indentFirst = False # Indent first paragraph
+ self._doJustify = False # Justify text
+ self._doBodyText = True # Include body text
+ self._doSynopsis = False # Also process synopsis comments
+ self._doComments = False # Also process comments
+ self._doKeywords = False # Also process keywords like tags and references
+ self._skipKeywords = set() # Keywords to ignore
+ self._keepBreaks = True # Keep line breaks in paragraphs
+ self._defaultAlign = "left" # The default text alignment
# Margins
self._marginTitle = nwStyles.T_MARGIN["H0"]
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index 3fb771006..ad02d02cd 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -327,7 +327,7 @@ def initDocument(self) -> None:
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
self._fBlockIndent = self._emToCm(self._blockIndent)
self._fTextIndent = self._emToCm(self._firstWidth)
- self._textAlign = "justify" if self._doJustify else "left"
+ self._textAlign = "justify" if self._doJustify else self._defaultAlign
# Clear Errors
self._errData = []
@@ -457,6 +457,9 @@ def doConvert(self) -> None:
# Process Text Types
if tType == self.T_TEXT:
+ if self._doJustify and "\n" in tText:
+ oStyle.overrideJustify(self._defaultAlign)
+
# Text indentation is processed here because there is a
# dedicated pre-defined style for it
if tStyle & self.A_IND_T:
@@ -1313,6 +1316,12 @@ def setOpacity(self, value: str | None) -> None:
# Methods
##
+ def overrideJustify(self, default: str) -> None:
+ """Override inherited justify setting if None is set."""
+ if self._pAttr["text-align"][1] is None:
+ self.setTextAlign(default)
+ return
+
def checkNew(self, style: ODTParagraphStyle) -> bool:
"""Check if there are new settings in style that differ from
those in this object. Unset styles are ignored as they can be
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index b9e8108c4..8ea8f9aaa 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Sample Project
Jane Smith
@@ -46,7 +46,7 @@
Title Page
-
-
+
Page
-
@@ -58,7 +58,7 @@
Chapter One
-
-
+
Making a Scene
-
@@ -66,7 +66,7 @@
Another Scene
-
-
+
Interlude
-
diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py
index 3206b2213..9305eadae 100644
--- a/tests/test_formats/test_fmt_toodt.py
+++ b/tests/test_formats/test_fmt_toodt.py
@@ -623,7 +623,7 @@ def getStyle(styleName):
''
'Scene'
'Regular paragraph'
- 'withbreak'
+ 'withbreak'
'Left Align'
''
)
@@ -1127,6 +1127,22 @@ def testFmtToOdt_ODTParagraphStyle():
''
)
+ # Override Justify
+ # ================
+
+ aStyle = ODTParagraphStyle("test")
+
+ # When not set, override is possible
+ assert aStyle._pAttr["text-align"][1] is None
+ aStyle.overrideJustify("left")
+ assert aStyle._pAttr["text-align"][1] == "left"
+
+ # When explicitly set, not override
+ aStyle.setTextAlign("right")
+ assert aStyle._pAttr["text-align"][1] == "right"
+ aStyle.overrideJustify("left")
+ assert aStyle._pAttr["text-align"][1] == "right"
+
# Changes
# =======
From 72d41d4883bae3a4fe4a088fbbf1c2e9a9ee077a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 20 Oct 2024 17:07:33 +0200
Subject: [PATCH 11/16] Refactor XML generation for DocX
---
novelwriter/formats/todocx.py | 419 ++++++++++++++++++----------------
1 file changed, 220 insertions(+), 199 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index e8502a256..a0a006f64 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -121,6 +121,15 @@ def _mkTag(ns: str, tag: str) -> str:
COL_MARK_TXT = "ffffa6"
+class DocXXmlFile(NamedTuple):
+
+ xml: ET.Element
+ rId: str
+ path: str
+ relType: str
+ contentType: str
+
+
class DocXParStyle(NamedTuple):
name: str
@@ -148,12 +157,6 @@ class ToDocX(Tokenizer):
def __init__(self, project: NWProject) -> None:
super().__init__(project)
- # XML
- self._dDoc = ET.Element("") # document.xml
- self._dStyl = ET.Element("") # styles.xml
-
- self._xBody = ET.Element("") # Text body
-
# Properties
self._headerFormat = ""
self._pageOffset = 0
@@ -161,11 +164,12 @@ def __init__(self, project: NWProject) -> None:
# Internal
self._fontFamily = "Liberation Serif"
self._fontSize = 12.0
- self._dLanguage = "en-GB"
+ self._dLanguage = "en_GB"
# Data Variables
- self._styles: dict[str, DocXParStyle] = {}
self._pars: list[DocXParagraph] = []
+ self._files: dict[str, DocXXmlFile] = {}
+ self._styles: dict[str, DocXParStyle] = {}
return
@@ -195,23 +199,11 @@ def setHeaderFormat(self, format: str, offset: int) -> None:
# Class Methods
##
- def _emToSz(self, scale: float) -> int:
- return int()
-
def initDocument(self) -> None:
"""Initialises the DocX document structure."""
-
self._fontFamily = self._textFont.family()
self._fontSize = self._textFont.pointSizeF()
-
- self._dDoc = ET.Element(_wTag("document"))
- self._dStyl = ET.Element(_wTag("styles"))
-
- self._xBody = xmlSubElem(self._dDoc, _wTag("body"))
-
- self._defaultStyles()
- self._useableStyles()
-
+ self._generateStyles()
return
def doConvert(self) -> None:
@@ -250,9 +242,9 @@ def doConvert(self) -> None:
if tStyle & self.A_IND_T:
par.setIndentFirst(True)
if tStyle & self.A_IND_L:
- par.setLeftMargin(bIndent)
+ par.setMarginLeft(bIndent)
if tStyle & self.A_IND_R:
- par.setRightMargin(bIndent)
+ par.setMarginRight(bIndent)
# Process Text Types
if tType == self.T_TEXT:
@@ -305,108 +297,40 @@ def doConvert(self) -> None:
return
def closeDocument(self) -> None:
- """Finalise document."""
- # Map all Page Break Before to After where possible
- pars: list[DocXParagraph] = []
- for i, par in enumerate(self._pars):
- if i > 0 and par.pageBreakBefore:
- prev = self._pars[i-1]
- if prev.pageBreakAfter:
- # We already have a break, so we inject a new paragraph instead
- empty = DocXParagraph()
- empty.setStyle(self._styles.get(S_NORM))
- empty.setPageBreakAfter(True)
- pars.append(empty)
- else:
- par.setPageBreakBefore(False)
- prev.setPageBreakAfter(True)
-
- pars.append(par)
-
- for par in pars:
- par.toXml(self._xBody)
-
+ """Generate all the XML."""
+ self._coreXml()
+ self._appXml()
+ self._stylesXml()
+ self._documentXml()
return
def saveDocument(self, path: Path) -> None:
"""Save the data to a .docx file."""
- timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
-
- # .rels
- dRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
- xmlSubElem(dRels, "Relationship", attrib={
- "Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml",
- })
- xmlSubElem(dRels, "Relationship", attrib={
- "Id": "rId2", "Type": f"{REL_BASE}/extended-properties", "Target": "docProps/app.xml",
- })
- xmlSubElem(dRels, "Relationship", attrib={
- "Id": "rId3", "Type": f"{REL_BASE}/officeDocument", "Target": "word/document.xml",
- })
-
- # core.xml
- dCore = ET.Element("coreProperties")
- tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
- xmlSubElem(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
- xmlSubElem(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
- xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
- xmlSubElem(dCore, _mkTag("dc", "title"), self._project.data.name)
- xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
- xmlSubElem(dCore, _mkTag("dc", "language"), self._dLanguage)
- xmlSubElem(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount))
- xmlSubElem(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
-
- # app.xml
- dApp = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
- xmlSubElem(dApp, "TotalTime", self._project.data.editTime // 60)
- xmlSubElem(dApp, "Application", f"novelWriter/{__version__}")
- if count := self._counts.get("allWords"):
- xmlSubElem(dApp, "Words", count)
- if count := self._counts.get("textWordChars"):
- xmlSubElem(dApp, "Characters", count)
- if count := self._counts.get("textChars"):
- xmlSubElem(dApp, "CharactersWithSpaces", count)
- if count := self._counts.get("paragraphCount"):
- xmlSubElem(dApp, "Paragraphs", count)
-
- # document.xml.rels
- dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
- xmlSubElem(dDRels, "Relationship", attrib={
- "Id": "rId1", "Type": f"{REL_BASE}/styles", "Target": "styles.xml",
- })
+ # Content Lists
+ cExts: list[tuple[str, str]] = []
+ cExts.append(("xml", "application/xml"))
+ cExts.append(("rels", RELS_TYPE))
+
+ cDocs: list[tuple[str, str]] = []
+ cDocs.append(("/_rels/.rels", RELS_TYPE))
+ cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
+
+ # Relationships XML
+ rRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+ wRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+ for name, entry in self._files.items():
+ cDocs.append((f"/{entry.path}/{name}", entry.contentType))
+ xDoc = rRels if name in ("core.xml", "app.xml", "document.xml") else wRels
+ xmlSubElem(xDoc, "Relationship", attrib={
+ "Id": entry.rId, "Type": entry.relType, "Target": f"{entry.path}/{name}",
+ })
- # [Content_Types].xml
- dCont = ET.Element("Types", attrib={"xmlns": TYPES_NS})
- xmlSubElem(dCont, "Default", attrib={
- "Extension": "xml", "ContentType": "application/xml",
- })
- xmlSubElem(dCont, "Default", attrib={
- "Extension": "rels", "ContentType": RELS_TYPE,
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/_rels/.rels",
- "ContentType": RELS_TYPE,
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/docProps/core.xml",
- "ContentType": f"{WORD_BASE}.extended-properties+xml",
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/docProps/app.xml",
- "ContentType": "application/vnd.openxmlformats-package.core-properties+xml",
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/word/_rels/document.xml.rels",
- "ContentType": RELS_TYPE,
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/word/document.xml",
- "ContentType": f"{WORD_BASE}.wordprocessingml.document.main+xml",
- })
- xmlSubElem(dCont, "Override", attrib={
- "PartName": "/word/styles.xml",
- "ContentType": f"{WORD_BASE}.wordprocessingml.styles+xml",
- })
+ # Content Types XML
+ dTypes = ET.Element("Types", attrib={"xmlns": TYPES_NS})
+ for name, content in cExts:
+ xmlSubElem(dTypes, "Default", attrib={"Extension": name, "ContentType": content})
+ for name, content in cDocs:
+ xmlSubElem(dTypes, "Override", attrib={"PartName": name, "ContentType": content})
def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj:
@@ -415,13 +339,11 @@ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
xml.write(fObj, encoding="utf-8", xml_declaration=True)
with ZipFile(path, mode="w") as outZip:
- xmlToZip("_rels/.rels", dRels, outZip)
- xmlToZip("docProps/core.xml", dCore, outZip)
- xmlToZip("docProps/app.xml", dApp, outZip)
- xmlToZip("word/_rels/document.xml.rels", dDRels, outZip)
- xmlToZip("word/document.xml", self._dDoc, outZip)
- xmlToZip("word/styles.xml", self._dStyl, outZip)
- xmlToZip("[Content_Types].xml", dCont, outZip)
+ xmlToZip("_rels/.rels", rRels, outZip)
+ xmlToZip("word/_rels/document.xml.rels", wRels, outZip)
+ for name, entry in self._files.items():
+ xmlToZip(f"{entry.path}/{name}", entry.xml, outZip)
+ xmlToZip("[Content_Types].xml", dTypes, outZip)
return
@@ -561,34 +483,13 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
return run
##
- # Style Elements
+ # DocX Content
##
- def _defaultStyles(self) -> None:
- """Set the default styles."""
- xStyl = xmlSubElem(self._dStyl, _wTag("docDefaults"))
- xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
- xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
- xRPr = xmlSubElem(xRDef, _wTag("rPr"))
- xPPr = xmlSubElem(xPDef, _wTag("pPr"))
-
- size = str(int(2.0 * self._fontSize))
- line = str(int(20.0 * self._lineHeight * self._fontSize))
+ def _generateStyles(self) -> None:
+ """Generate usable styles."""
+ styles: list[DocXParStyle] = []
- xmlSubElem(xRPr, _wTag("rFonts"), attrib={
- _wTag("ascii"): self._fontFamily,
- _wTag("hAnsi"): self._fontFamily,
- _wTag("cs"): self._fontFamily,
- })
- xmlSubElem(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
- xmlSubElem(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
- xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
- xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
-
- return
-
- def _useableStyles(self) -> None:
- """Set the usable styles."""
hScale = self._scaleHeads
hColor = self._colorHeads
fSz = self._fontSize
@@ -600,7 +501,7 @@ def _useableStyles(self) -> None:
align = "both" if self._doJustify else "left"
# Add Normal Style
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Normal",
styleId=S_NORM,
size=fSz,
@@ -613,7 +514,7 @@ def _useableStyles(self) -> None:
))
# Add Title
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Title",
styleId=S_TITLE,
size=fSz0,
@@ -627,7 +528,7 @@ def _useableStyles(self) -> None:
))
# Add Heading 1
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Heading 1",
styleId=S_HEAD1,
size=fSz1,
@@ -642,7 +543,7 @@ def _useableStyles(self) -> None:
))
# Add Heading 2
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Heading 2",
styleId=S_HEAD2,
size=fSz2,
@@ -657,7 +558,7 @@ def _useableStyles(self) -> None:
))
# Add Heading 3
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Heading 3",
styleId=S_HEAD3,
size=fSz3,
@@ -672,7 +573,7 @@ def _useableStyles(self) -> None:
))
# Add Heading 4
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Heading 4",
styleId=S_HEAD4,
size=fSz4,
@@ -687,7 +588,7 @@ def _useableStyles(self) -> None:
))
# Add Separator
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Separator",
styleId=S_SEP,
size=fSz,
@@ -700,7 +601,7 @@ def _useableStyles(self) -> None:
))
# Add Text Meta Style
- self._addParStyle(DocXParStyle(
+ styles.append(DocXParStyle(
name="Text Meta",
styleId=S_META,
size=fSz,
@@ -712,45 +613,165 @@ def _useableStyles(self) -> None:
color=COL_META_TXT,
))
+ # Add to Cache
+ for style in styles:
+ self._styles[style.styleId] = style
+
+ return
+
+ def _nextRelId(self) -> str:
+ """Generate the next unique rId."""
+ return f"rId{len(self._files) + 1}"
+
+ def _appXml(self) -> None:
+ """Populate app.xml."""
+ xRoot = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
+ self._files["app.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=self._nextRelId(),
+ path="docProps",
+ relType=f"{REL_BASE}/extended-properties",
+ contentType="application/vnd.openxmlformats-package.core-properties+xml",
+ )
+
+ xmlSubElem(xRoot, "TotalTime", self._project.data.editTime // 60)
+ xmlSubElem(xRoot, "Application", f"novelWriter/{__version__}")
+ if count := self._counts.get("allWords"):
+ xmlSubElem(xRoot, "Words", count)
+ if count := self._counts.get("textWordChars"):
+ xmlSubElem(xRoot, "Characters", count)
+ if count := self._counts.get("textChars"):
+ xmlSubElem(xRoot, "CharactersWithSpaces", count)
+ if count := self._counts.get("paragraphCount"):
+ xmlSubElem(xRoot, "Paragraphs", count)
+
return
- def _addParStyle(self, style: DocXParStyle) -> None:
- """Add a paragraph style."""
- sAttr = {}
- sAttr[_wTag("type")] = "paragraph"
- sAttr[_wTag("styleId")] = style.styleId
- if style.default:
- sAttr[_wTag("default")] = "1"
-
- size = firstFloat(style.size, self._fontSize)
-
- xStyl = xmlSubElem(self._dStyl, _wTag("style"), attrib=sAttr)
- xmlSubElem(xStyl, _wTag("name"), attrib={_wTag("val"): style.name})
- if style.basedOn:
- xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
- if style.nextStyle:
- xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
- if style.level is not None:
- xmlSubElem(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
-
- pPr = xmlSubElem(xStyl, _wTag("pPr"))
- xmlSubElem(pPr, _wTag("spacing"), attrib={
- _wTag("before"): str(int(20.0 * firstFloat(style.before))),
- _wTag("after"): str(int(20.0 * firstFloat(style.after))),
- _wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
+ def _coreXml(self) -> None:
+ """Populate app.xml."""
+ xRoot = ET.Element("coreProperties")
+ self._files["core.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=self._nextRelId(),
+ path="docProps",
+ relType=REL_CORE,
+ contentType=f"{WORD_BASE}.extended-properties+xml",
+ )
+
+ timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
+ tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
+ xmlSubElem(xRoot, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
+ xmlSubElem(xRoot, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
+ xmlSubElem(xRoot, _mkTag("dc", "creator"), self._project.data.author)
+ xmlSubElem(xRoot, _mkTag("dc", "title"), self._project.data.name)
+ xmlSubElem(xRoot, _mkTag("dc", "creator"), self._project.data.author)
+ xmlSubElem(xRoot, _mkTag("dc", "language"), self._dLanguage)
+ xmlSubElem(xRoot, _mkTag("cp", "revision"), str(self._project.data.saveCount))
+ xmlSubElem(xRoot, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
+
+ return
+
+ def _stylesXml(self) -> None:
+ """Populate styles.xml."""
+ xRoot = ET.Element(_wTag("styles"))
+ self._files["styles.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=self._nextRelId(),
+ path="word",
+ relType=f"{REL_BASE}/styles",
+ contentType=f"{WORD_BASE}.wordprocessingml.styles+xml",
+ )
+
+ # Default Style
+ xStyl = xmlSubElem(xRoot, _wTag("docDefaults"))
+ xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
+ xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
+ xRPr = xmlSubElem(xRDef, _wTag("rPr"))
+ xPPr = xmlSubElem(xPDef, _wTag("pPr"))
+
+ size = str(int(2.0 * self._fontSize))
+ line = str(int(20.0 * self._lineHeight * self._fontSize))
+
+ xmlSubElem(xRPr, _wTag("rFonts"), attrib={
+ _wTag("ascii"): self._fontFamily,
+ _wTag("hAnsi"): self._fontFamily,
+ _wTag("cs"): self._fontFamily,
})
- if style.align:
- xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
-
- rPr = xmlSubElem(xStyl, _wTag("rPr"))
- xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
- xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
- if style.color:
- xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
- if style.bold:
- xmlSubElem(rPr, _wTag("b"))
+ xmlSubElem(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
+ xmlSubElem(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
+ xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
+ xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
+
+ # Paragraph Styles
+ for style in self._styles.values():
+ sAttr = {}
+ sAttr[_wTag("type")] = "paragraph"
+ sAttr[_wTag("styleId")] = style.styleId
+ if style.default:
+ sAttr[_wTag("default")] = "1"
+
+ size = firstFloat(style.size, self._fontSize)
+
+ xStyl = xmlSubElem(xRoot, _wTag("style"), attrib=sAttr)
+ xmlSubElem(xStyl, _wTag("name"), attrib={_wTag("val"): style.name})
+ if style.basedOn:
+ xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
+ if style.nextStyle:
+ xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
+ if style.level is not None:
+ xmlSubElem(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
+
+ pPr = xmlSubElem(xStyl, _wTag("pPr"))
+ xmlSubElem(pPr, _wTag("spacing"), attrib={
+ _wTag("before"): str(int(20.0 * firstFloat(style.before))),
+ _wTag("after"): str(int(20.0 * firstFloat(style.after))),
+ _wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
+ })
+ if style.align:
+ xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
+
+ rPr = xmlSubElem(xStyl, _wTag("rPr"))
+ xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
+ xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
+ if style.color:
+ xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
+ if style.bold:
+ xmlSubElem(rPr, _wTag("b"))
+
+ return
+
+ def _documentXml(self) -> None:
+ """Populate document.xml."""
+ xRoot = ET.Element(_wTag("document"))
+ self._files["document.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=self._nextRelId(),
+ path="word",
+ relType=f"{REL_BASE}/officeDocument",
+ contentType=f"{WORD_BASE}.wordprocessingml.document.main+xml",
+ )
+
+ xBody = xmlSubElem(xRoot, _wTag("body"))
+
+ # Map all Page Break Before to After where possible
+ pars: list[DocXParagraph] = []
+ for i, par in enumerate(self._pars):
+ if i > 0 and par.pageBreakBefore:
+ prev = self._pars[i-1]
+ if prev.pageBreakAfter:
+ # We already have a break, so we inject a new paragraph instead
+ empty = DocXParagraph()
+ empty.setStyle(self._styles.get(S_NORM))
+ empty.setPageBreakAfter(True)
+ pars.append(empty)
+ else:
+ par.setPageBreakBefore(False)
+ prev.setPageBreakAfter(True)
+
+ pars.append(par)
- self._styles[style.styleId] = style
+ for par in pars:
+ par.toXml(xBody)
return
@@ -815,13 +836,13 @@ def setMarginBottom(self, value: float) -> None:
self._bottomMargin = value
return
- def setLeftMargin(self, value: float) -> None:
- """Set left indent."""
+ def setMarginLeft(self, value: float) -> None:
+ """Set margin left in pt."""
self._leftMargin = value
return
- def setRightMargin(self, value: float) -> None:
- """Set right line indent."""
+ def setMarginRight(self, value: float) -> None:
+ """Set margin right in pt."""
self._rightMargin = value
return
From e9220c5e77f11a7a14f5ba5b2b33720e446c0dda Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 20 Oct 2024 21:02:53 +0200
Subject: [PATCH 12/16] Add DocX page settings and header support
---
novelwriter/formats/todocx.py | 235 +++++++++++++++++++++++++++-------
1 file changed, 189 insertions(+), 46 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index a0a006f64..079e1d5a0 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -33,6 +33,8 @@
from typing import NamedTuple
from zipfile import ZipFile
+from PyQt5.QtCore import QMarginsF, QSizeF
+
from novelwriter import __version__
from novelwriter.common import firstFloat, xmlIndent, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwStyles
@@ -45,19 +47,16 @@
RX_TEXT = re.compile(r"([\n\t])", re.UNICODE)
# Types and Relationships
+OOXML_SCM = "http://schemas.openxmlformats.org"
WORD_BASE = "application/vnd.openxmlformats-officedocument"
RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
-REL_CORE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"
-REL_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+RELS_BASE = f"{OOXML_SCM}/officeDocument/2006/relationships"
# Main XML NameSpaces
-PROPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
-TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
-RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
-W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
XML_NS = {
- "w": W_NS,
- "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
+ "r": RELS_BASE,
+ "w": f"{OOXML_SCM}/wordprocessingml/2006/main",
+ "cp": f"{OOXML_SCM}/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xml": "http://www.w3.org/XML/1998/namespace",
@@ -69,7 +68,7 @@
def _wTag(tag: str) -> str:
"""Assemble namespace and tag name for standard w namespace."""
- return f"{{{W_NS}}}{tag}"
+ return f"{{{OOXML_SCM}/wordprocessingml/2006/main}}{tag}"
def _mkTag(ns: str, tag: str) -> str:
@@ -109,6 +108,7 @@ def _mkTag(ns: str, tag: str) -> str:
S_HEAD2 = "Heading2"
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
+S_HEAD = "Header"
S_SEP = "Separator"
S_META = "TextMeta"
@@ -162,9 +162,11 @@ def __init__(self, project: NWProject) -> None:
self._pageOffset = 0
# Internal
- self._fontFamily = "Liberation Serif"
- self._fontSize = 12.0
- self._dLanguage = "en_GB"
+ self._fontFamily = "Liberation Serif"
+ self._fontSize = 12.0
+ self._dLanguage = "en_GB"
+ self._pageSize = QSizeF(210.0, 297.0)
+ self._pageMargins = QMarginsF(20.0, 20.0, 20.0, 20.0)
# Data Variables
self._pars: list[DocXParagraph] = []
@@ -187,6 +189,8 @@ def setPageLayout(
self, width: float, height: float, top: float, bottom: float, left: float, right: float
) -> None:
"""Set the document page size and margins in millimetres."""
+ self._pageSize = QSizeF(width, height)
+ self._pageMargins = QMarginsF(left, top, right, bottom)
return
def setHeaderFormat(self, format: str, offset: int) -> None:
@@ -301,7 +305,15 @@ def closeDocument(self) -> None:
self._coreXml()
self._appXml()
self._stylesXml()
- self._documentXml()
+
+ fId = None
+ dId = None
+ if self._headerFormat:
+ dId = self._defaultHeaderXml()
+ fId = self._firstHeaderXml()
+
+ self._documentXml(fId, dId)
+
return
def saveDocument(self, path: Path) -> None:
@@ -316,17 +328,24 @@ def saveDocument(self, path: Path) -> None:
cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
# Relationships XML
- rRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
- wRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
+ rRels = ET.Element("Relationships", attrib={
+ "xmlns": f"{OOXML_SCM}/package/2006/relationships"
+ })
+ wRels = ET.Element("Relationships", attrib={
+ "xmlns": f"{OOXML_SCM}/package/2006/relationships"
+ })
for name, entry in self._files.items():
cDocs.append((f"/{entry.path}/{name}", entry.contentType))
- xDoc = rRels if name in ("core.xml", "app.xml", "document.xml") else wRels
- xmlSubElem(xDoc, "Relationship", attrib={
- "Id": entry.rId, "Type": entry.relType, "Target": f"{entry.path}/{name}",
+ isRoot = name in ("core.xml", "app.xml", "document.xml")
+ xmlSubElem(rRels if isRoot else wRels, "Relationship", attrib={
+ "Id": entry.rId, "Type": entry.relType,
+ "Target": f"{entry.path}/{name}" if isRoot else name,
})
# Content Types XML
- dTypes = ET.Element("Types", attrib={"xmlns": TYPES_NS})
+ dTypes = ET.Element("Types", attrib={
+ "xmlns": f"{OOXML_SCM}/package/2006/content-types"
+ })
for name, content in cExts:
xmlSubElem(dTypes, "Default", attrib={"Extension": name, "ContentType": content})
for name, content in cDocs:
@@ -613,6 +632,15 @@ def _generateStyles(self) -> None:
color=COL_META_TXT,
))
+ # Header
+ styles.append(DocXParStyle(
+ name="Header",
+ styleId=S_HEAD,
+ size=fSz,
+ basedOn=S_NORM,
+ align="right",
+ ))
+
# Add to Cache
for style in styles:
self._styles[style.styleId] = style
@@ -623,15 +651,18 @@ def _nextRelId(self) -> str:
"""Generate the next unique rId."""
return f"rId{len(self._files) + 1}"
- def _appXml(self) -> None:
+ def _appXml(self) -> str:
"""Populate app.xml."""
- xRoot = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
+ rId = self._nextRelId()
+ xRoot = ET.Element("Properties", attrib={
+ "xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties"
+ })
self._files["app.xml"] = DocXXmlFile(
xml=xRoot,
- rId=self._nextRelId(),
+ rId=rId,
path="docProps",
- relType=f"{REL_BASE}/extended-properties",
- contentType="application/vnd.openxmlformats-package.core-properties+xml",
+ relType=f"{RELS_BASE}/extended-properties",
+ contentType="application/vnd.openxmlformats-officedocument.extended-properties+xml",
)
xmlSubElem(xRoot, "TotalTime", self._project.data.editTime // 60)
@@ -645,17 +676,18 @@ def _appXml(self) -> None:
if count := self._counts.get("paragraphCount"):
xmlSubElem(xRoot, "Paragraphs", count)
- return
+ return rId
- def _coreXml(self) -> None:
+ def _coreXml(self) -> str:
"""Populate app.xml."""
+ rId = self._nextRelId()
xRoot = ET.Element("coreProperties")
self._files["core.xml"] = DocXXmlFile(
xml=xRoot,
- rId=self._nextRelId(),
+ rId=rId,
path="docProps",
- relType=REL_CORE,
- contentType=f"{WORD_BASE}.extended-properties+xml",
+ relType=f"{OOXML_SCM}/package/2006/relationships/metadata/core-properties",
+ contentType="application/vnd.openxmlformats-package.core-properties+xml",
)
timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
@@ -669,16 +701,17 @@ def _coreXml(self) -> None:
xmlSubElem(xRoot, _mkTag("cp", "revision"), str(self._project.data.saveCount))
xmlSubElem(xRoot, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
- return
+ return rId
- def _stylesXml(self) -> None:
+ def _stylesXml(self) -> str:
"""Populate styles.xml."""
+ rId = self._nextRelId()
xRoot = ET.Element(_wTag("styles"))
self._files["styles.xml"] = DocXXmlFile(
xml=xRoot,
- rId=self._nextRelId(),
+ rId=rId,
path="word",
- relType=f"{REL_BASE}/styles",
+ relType=f"{RELS_BASE}/styles",
contentType=f"{WORD_BASE}.wordprocessingml.styles+xml",
)
@@ -718,9 +751,8 @@ def _stylesXml(self) -> None:
xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
if style.nextStyle:
xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
- if style.level is not None:
- xmlSubElem(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
+ # pPr Node
pPr = xmlSubElem(xStyl, _wTag("pPr"))
xmlSubElem(pPr, _wTag("spacing"), attrib={
_wTag("before"): str(int(20.0 * firstFloat(style.before))),
@@ -729,30 +761,102 @@ def _stylesXml(self) -> None:
})
if style.align:
xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
+ if style.level is not None:
+ xmlSubElem(pPr, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
+ # rPr Node
rPr = xmlSubElem(xStyl, _wTag("rPr"))
- xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
- xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
- if style.color:
- xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
if style.bold:
xmlSubElem(rPr, _wTag("b"))
+ if style.color:
+ xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
+ xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
+ xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
- return
+ return rId
- def _documentXml(self) -> None:
+ def _defaultHeaderXml(self) -> str:
+ """Populate header1.xml."""
+ rId = self._nextRelId()
+ xRoot = ET.Element(_wTag("hdr"))
+ self._files["header1.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=rId,
+ path="word",
+ relType=f"{RELS_BASE}/header",
+ contentType=f"{WORD_BASE}.wordprocessingml.header+xml",
+ )
+
+ xP = xmlSubElem(xRoot, _wTag("p"))
+ xPPr = xmlSubElem(xP, _wTag("pPr"))
+ xmlSubElem(xPPr, _wTag("pStyle"), attrib={_wTag("val"): S_HEAD})
+ xmlSubElem(xPPr, _wTag("jc"), attrib={_wTag("val"): "right"})
+ xmlSubElem(xPPr, _wTag("rPr"))
+
+ pre, page, post = self._headerFormat.partition(nwHeadFmt.ODT_PAGE)
+ pre = pre.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
+ pre = pre.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
+ post = post.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
+ post = post.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
+
+ xSpace = _mkTag("xml", "space")
+ wFldCT = _wTag("fldCharType")
+
+ parts: list[tuple[str, str | None, str, str]] = []
+ if pre:
+ parts.append(("t", pre, xSpace, "preserve"))
+ if page:
+ parts.append(("fldChar", None, wFldCT, "begin"))
+ parts.append(("t", " PAGE ", xSpace, "preserve"))
+ parts.append(("fldChar", None, wFldCT, "separate"))
+ parts.append(("t", "2", xSpace, "preserve"))
+ parts.append(("fldChar", None, wFldCT, "end"))
+ if post:
+ parts.append(("t", post, xSpace, "preserve"))
+
+ for part in parts:
+ xR = xmlSubElem(xP, _wTag("r"))
+ xmlSubElem(xR, _wTag("rPr"))
+ xmlSubElem(xR, _wTag(part[0]), part[1], attrib={part[2]: part[3]})
+
+ return rId
+
+ def _firstHeaderXml(self) -> str:
+ """Populate header2.xml."""
+ rId = self._nextRelId()
+ xRoot = ET.Element(_wTag("hdr"))
+ self._files["header2.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=rId,
+ path="word",
+ relType=f"{RELS_BASE}/header",
+ contentType=f"{WORD_BASE}.wordprocessingml.header+xml",
+ )
+
+ xP = xmlSubElem(xRoot, _wTag("p"))
+ xPPr = xmlSubElem(xP, _wTag("pPr"))
+ xmlSubElem(xPPr, _wTag("pStyle"), attrib={_wTag("val"): S_HEAD})
+ xmlSubElem(xPPr, _wTag("jc"), attrib={_wTag("val"): "right"})
+ xmlSubElem(xPPr, _wTag("rPr"))
+
+ xR = xmlSubElem(xP, _wTag("r"))
+ xmlSubElem(xR, _wTag("rPr"))
+
+ return rId
+
+ def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str:
"""Populate document.xml."""
+ rId = self._nextRelId()
xRoot = ET.Element(_wTag("document"))
+ xBody = xmlSubElem(xRoot, _wTag("body"))
self._files["document.xml"] = DocXXmlFile(
xml=xRoot,
- rId=self._nextRelId(),
+ rId=rId,
path="word",
- relType=f"{REL_BASE}/officeDocument",
+ relType=f"{RELS_BASE}/officeDocument",
contentType=f"{WORD_BASE}.wordprocessingml.document.main+xml",
)
- xBody = xmlSubElem(xRoot, _wTag("body"))
-
# Map all Page Break Before to After where possible
pars: list[DocXParagraph] = []
for i, par in enumerate(self._pars):
@@ -770,10 +874,49 @@ def _documentXml(self) -> None:
pars.append(par)
+ # Write Paragraphs
for par in pars:
par.toXml(xBody)
- return
+ def szScale(value: float) -> str:
+ return str(int(value*2.0*72.0/2.54))
+
+ # Write Settings
+ xSect = xmlSubElem(xBody, _wTag("sectPr"))
+ if hFirst and hDefault:
+ xmlSubElem(xSect, _wTag("headerReference"), attrib={
+ _wTag("type"): "first", _mkTag("r", "id"): hFirst,
+ })
+ xmlSubElem(xSect, _wTag("headerReference"), attrib={
+ _wTag("type"): "default", _mkTag("r", "id"): hDefault,
+ })
+
+ xFn = xmlSubElem(xSect, _wTag("footnotePr"))
+ xmlSubElem(xFn, _wTag("numFmt"), attrib={
+ _wTag("val"): "decimal",
+ })
+
+ xmlSubElem(xSect, _wTag("pgSz"), attrib={
+ _wTag("w"): szScale(self._pageSize.width()),
+ _wTag("h"): szScale(self._pageSize.height()),
+ _wTag("orient"): "portrait",
+ })
+ xmlSubElem(xSect, _wTag("pgMar"), attrib={
+ _wTag("top"): szScale(self._pageMargins.top()),
+ _wTag("right"): szScale(self._pageMargins.right()),
+ _wTag("bottom"): szScale(self._pageMargins.bottom()),
+ _wTag("left"): szScale(self._pageMargins.left()),
+ _wTag("header"): szScale(self._pageMargins.top()/2.0),
+ _wTag("footer"): "0",
+ _wTag("gutter"): "0",
+ })
+ xmlSubElem(xSect, _wTag("pgNumType"), attrib={
+ _wTag("start"): str(1 - self._pageOffset),
+ _wTag("fmt"): "decimal",
+ })
+ xmlSubElem(xSect, _wTag("titlePg"))
+
+ return rId
class DocXParagraph:
From 7e1a676e0aebc3d70903e9484a95e25504125b8a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 20 Oct 2024 22:01:13 +0200
Subject: [PATCH 13/16] Add footnote support to DocX
---
novelwriter/formats/todocx.py | 80 +++++++++++++++++++++++++++++++----
1 file changed, 72 insertions(+), 8 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 079e1d5a0..dd3f9c5f3 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -48,7 +48,7 @@
# Types and Relationships
OOXML_SCM = "http://schemas.openxmlformats.org"
-WORD_BASE = "application/vnd.openxmlformats-officedocument"
+WORD_BASE = "application/vnd.openxmlformats-officedocument.wordprocessingml"
RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
RELS_BASE = f"{OOXML_SCM}/officeDocument/2006/relationships"
@@ -108,9 +108,10 @@ def _mkTag(ns: str, tag: str) -> str:
S_HEAD2 = "Heading2"
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
-S_HEAD = "Header"
S_SEP = "Separator"
S_META = "TextMeta"
+S_HEAD = "Header"
+S_FNOTE = "FootnoteText"
# Colours
COL_HEAD_L12 = "2a6099"
@@ -141,6 +142,7 @@ class DocXParStyle(NamedTuple):
after: float | None = None
line: float | None = None
indentFirst: float | None = None
+ indentHangning: float | None = None
align: str | None = None
default: bool = False
level: int | None = None
@@ -172,6 +174,7 @@ def __init__(self, project: NWProject) -> None:
self._pars: list[DocXParagraph] = []
self._files: dict[str, DocXXmlFile] = {}
self._styles: dict[str, DocXParStyle] = {}
+ self._usedNotes: list[str] = []
return
@@ -314,6 +317,9 @@ def closeDocument(self) -> None:
self._documentXml(fId, dId)
+ if self._usedNotes:
+ self._footnotesXml()
+
return
def saveDocument(self, path: Path) -> None:
@@ -410,9 +416,14 @@ def _processFragments(
"""Apply formatting tags to text."""
par.setStyle(self._styles.get(pStyle))
xFmt = 0x00
+ xNode = None
fStart = 0
for fPos, fFmt, fData in tFmt or []:
+ if xNode is not None:
+ par.addContent(xNode)
+ xNode = None
+
par.addContent(self._textRunToXml(text[fStart:fPos], xFmt))
if fFmt == self.FMT_B_B:
@@ -451,14 +462,17 @@ def _processFragments(
xFmt |= X_DLA
elif fFmt == self.FMT_ADL_E:
xFmt &= M_DLA
- # elif fmt == self.FMT_FNOTE:
- # xNode = self._generateFootnote(fData)
+ elif fFmt == self.FMT_FNOTE:
+ xNode = self._generateFootnote(fData)
elif fFmt == self.FMT_STRIP:
pass
# Move pos for next pass
fStart = fPos
+ if xNode is not None:
+ par.addContent(xNode)
+
if temp := text[fStart:]:
par.addContent(self._textRunToXml(temp, xFmt))
@@ -505,6 +519,18 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
# DocX Content
##
+ def _generateFootnote(self, key: str) -> ET.Element | None:
+ """Generate a footnote XML object."""
+ if self._footnotes.get(key):
+ idx = len(self._usedNotes)
+ run = ET.Element(_wTag("r"))
+ rPr = xmlSubElem(run, _wTag("rPr"))
+ xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
+ xmlSubElem(run, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)})
+ self._usedNotes.append(key)
+ return run
+ return None
+
def _generateStyles(self) -> None:
"""Generate usable styles."""
styles: list[DocXParStyle] = []
@@ -512,6 +538,7 @@ def _generateStyles(self) -> None:
hScale = self._scaleHeads
hColor = self._colorHeads
fSz = self._fontSize
+ fnSz = 0.8 * self._fontSize
fSz0 = (nwStyles.H_SIZES[0] * fSz) if hScale else fSz
fSz1 = (nwStyles.H_SIZES[1] * fSz) if hScale else fSz
fSz2 = (nwStyles.H_SIZES[2] * fSz) if hScale else fSz
@@ -641,6 +668,18 @@ def _generateStyles(self) -> None:
align="right",
))
+ # Footnote
+ styles.append(DocXParStyle(
+ name="Footnote Text",
+ styleId=S_FNOTE,
+ size=fnSz,
+ basedOn=S_NORM,
+ before=0.0,
+ after=fnSz * self._marginFoot[1],
+ line=fnSz * self._lineHeight,
+ indentHangning=fnSz * self._marginFoot[0],
+ ))
+
# Add to Cache
for style in styles:
self._styles[style.styleId] = style
@@ -712,7 +751,7 @@ def _stylesXml(self) -> str:
rId=rId,
path="word",
relType=f"{RELS_BASE}/styles",
- contentType=f"{WORD_BASE}.wordprocessingml.styles+xml",
+ contentType=f"{WORD_BASE}.styles+xml",
)
# Default Style
@@ -759,6 +798,11 @@ def _stylesXml(self) -> str:
_wTag("after"): str(int(20.0 * firstFloat(style.after))),
_wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
})
+ if style.indentHangning is not None:
+ xmlSubElem(pPr, _wTag("ind"), attrib={
+ _wTag("left"): str(int(20.0 * style.indentHangning)),
+ _wTag("hanging"): str(int(20.0 * style.indentHangning)),
+ })
if style.align:
xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
if style.level is not None:
@@ -784,7 +828,7 @@ def _defaultHeaderXml(self) -> str:
rId=rId,
path="word",
relType=f"{RELS_BASE}/header",
- contentType=f"{WORD_BASE}.wordprocessingml.header+xml",
+ contentType=f"{WORD_BASE}.header+xml",
)
xP = xmlSubElem(xRoot, _wTag("p"))
@@ -830,7 +874,7 @@ def _firstHeaderXml(self) -> str:
rId=rId,
path="word",
relType=f"{RELS_BASE}/header",
- contentType=f"{WORD_BASE}.wordprocessingml.header+xml",
+ contentType=f"{WORD_BASE}.header+xml",
)
xP = xmlSubElem(xRoot, _wTag("p"))
@@ -854,7 +898,7 @@ def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str:
rId=rId,
path="word",
relType=f"{RELS_BASE}/officeDocument",
- contentType=f"{WORD_BASE}.wordprocessingml.document.main+xml",
+ contentType=f"{WORD_BASE}.document.main+xml",
)
# Map all Page Break Before to After where possible
@@ -918,6 +962,26 @@ def szScale(value: float) -> str:
return rId
+ def _footnotesXml(self) -> str:
+ """Populate footnotes.xml."""
+ rId = self._nextRelId()
+ xRoot = ET.Element(_wTag("footnotes"))
+ self._files["footnotes.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=rId,
+ path="word",
+ relType=f"{RELS_BASE}/footnotes",
+ contentType=f"{WORD_BASE}.footnotes+xml",
+ )
+
+ for i, key in enumerate(self._usedNotes):
+ par = DocXParagraph()
+ if content := self._footnotes.get(key):
+ self._processFragments(par, S_FNOTE, content[0], content[1])
+ par.toXml(xmlSubElem(xRoot, _wTag("footnote"), attrib={_wTag("id"): str(i)}))
+
+ return rId
+
class DocXParagraph:
From 148d46fcbcc0134681ae756f5f2febc000a95939 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 20 Oct 2024 23:08:14 +0200
Subject: [PATCH 14/16] Add settings to DocX and compression to ODT and DocX
---
novelwriter/formats/todocx.py | 44 +++++++++++++++++++++++++++--------
novelwriter/formats/toodt.py | 4 ++--
2 files changed, 36 insertions(+), 12 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index dd3f9c5f3..ef3305fc1 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -31,12 +31,12 @@
from datetime import datetime
from pathlib import Path
from typing import NamedTuple
-from zipfile import ZipFile
+from zipfile import ZIP_DEFLATED, ZipFile
from PyQt5.QtCore import QMarginsF, QSizeF
from novelwriter import __version__
-from novelwriter.common import firstFloat, xmlIndent, xmlSubElem
+from novelwriter.common import firstFloat, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.formats.tokenizer import T_Formats, Tokenizer
@@ -174,7 +174,7 @@ def __init__(self, project: NWProject) -> None:
self._pars: list[DocXParagraph] = []
self._files: dict[str, DocXXmlFile] = {}
self._styles: dict[str, DocXParStyle] = {}
- self._usedNotes: list[str] = []
+ self._usedNotes: dict[str, int] = {}
return
@@ -316,7 +316,7 @@ def closeDocument(self) -> None:
fId = self._firstHeaderXml()
self._documentXml(fId, dId)
-
+ self._settingsXml()
if self._usedNotes:
self._footnotesXml()
@@ -360,10 +360,9 @@ def saveDocument(self, path: Path) -> None:
def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
with zipObj.open(name, mode="w") as fObj:
xml = ET.ElementTree(xObj)
- xmlIndent(xml)
xml.write(fObj, encoding="utf-8", xml_declaration=True)
- with ZipFile(path, mode="w") as outZip:
+ with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip:
xmlToZip("_rels/.rels", rRels, outZip)
xmlToZip("word/_rels/document.xml.rels", wRels, outZip)
for name, entry in self._files.items():
@@ -522,12 +521,12 @@ def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
def _generateFootnote(self, key: str) -> ET.Element | None:
"""Generate a footnote XML object."""
if self._footnotes.get(key):
- idx = len(self._usedNotes)
+ idx = len(self._usedNotes) + 1
run = ET.Element(_wTag("r"))
rPr = xmlSubElem(run, _wTag("rPr"))
xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
xmlSubElem(run, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)})
- self._usedNotes.append(key)
+ self._usedNotes[key] = idx
return run
return None
@@ -974,11 +973,36 @@ def _footnotesXml(self) -> str:
contentType=f"{WORD_BASE}.footnotes+xml",
)
- for i, key in enumerate(self._usedNotes):
+ for key, idx in self._usedNotes.items():
par = DocXParagraph()
if content := self._footnotes.get(key):
self._processFragments(par, S_FNOTE, content[0], content[1])
- par.toXml(xmlSubElem(xRoot, _wTag("footnote"), attrib={_wTag("id"): str(i)}))
+ par.toXml(xmlSubElem(xRoot, _wTag("footnote"), attrib={_wTag("id"): str(idx)}))
+
+ return rId
+
+ def _settingsXml(self) -> str:
+ """Populate settings.xml."""
+ rId = self._nextRelId()
+ xRoot = ET.Element(_wTag("settings"))
+ self._files["settings.xml"] = DocXXmlFile(
+ xml=xRoot,
+ rId=rId,
+ path="word",
+ relType=f"{RELS_BASE}/settings",
+ contentType=f"{WORD_BASE}.settings+xml",
+ )
+
+ xFn = xmlSubElem(xRoot, _wTag("footnotePr"))
+ xmlSubElem(xFn, _wTag("numFmt"), attrib={_wTag("val"): "decimal"})
+
+ if self._counts:
+ xVars = xmlSubElem(xRoot, _wTag("docVars"))
+ for key, value in self._counts.items():
+ xmlSubElem(xVars, _wTag("docVar"), attrib={
+ _wTag("name"): f"Manuscript{key[:1].upper()}{key[1:]}",
+ _wTag("val"): str(value),
+ })
return rId
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index ad02d02cd..019076f80 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -33,7 +33,7 @@
from datetime import datetime
from hashlib import sha256
from pathlib import Path
-from zipfile import ZipFile
+from zipfile import ZIP_DEFLATED, ZipFile
from PyQt5.QtGui import QFont
@@ -560,7 +560,7 @@ def xmlToZip(name: str, xObj: ET.Element, zipObj: ZipFile) -> None:
xml = ET.ElementTree(xObj)
xml.write(fObj, encoding="utf-8", xml_declaration=True)
- with ZipFile(path, mode="w") as outZip:
+ with ZipFile(path, mode="w", compression=ZIP_DEFLATED, compresslevel=3) as outZip:
outZip.writestr("mimetype", X_MIME)
xmlToZip("META-INF/manifest.xml", xMani, outZip)
xmlToZip("settings.xml", xSett, outZip)
From 787e1a46d13856a1205607e538bc1e16ce99bfb9 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 21 Oct 2024 16:59:43 +0200
Subject: [PATCH 15/16] Add DocX test coverage
---
novelwriter/formats/todocx.py | 23 +-
.../fmtToDocX_SaveDocument_Content_Types.xml | 15 +
.../reference/fmtToDocX_SaveDocument_app.xml | 9 +
.../reference/fmtToDocX_SaveDocument_core.xml | 11 +
.../fmtToDocX_SaveDocument_document.xml | 1136 +++++++++++++++++
.../fmtToDocX_SaveDocument_document.xml.rels | 8 +
.../fmtToDocX_SaveDocument_footnotes.xml | 20 +
.../fmtToDocX_SaveDocument_header1.xml | 38 +
.../fmtToDocX_SaveDocument_header2.xml | 13 +
tests/reference/fmtToDocX_SaveDocument_rels | 6 +
.../fmtToDocX_SaveDocument_settings.xml | 19 +
.../fmtToDocX_SaveDocument_styles.xml | 153 +++
tests/test_base/test_base_common.py | 40 +-
tests/test_formats/test_fmt_todocx.py | 566 ++++++++
tests/test_formats/test_fmt_toodt.py | 6 +-
tests/tools.py | 4 +-
16 files changed, 2041 insertions(+), 26 deletions(-)
create mode 100644 tests/reference/fmtToDocX_SaveDocument_Content_Types.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_app.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_core.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_document.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_document.xml.rels
create mode 100644 tests/reference/fmtToDocX_SaveDocument_footnotes.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_header1.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_header2.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_rels
create mode 100644 tests/reference/fmtToDocX_SaveDocument_settings.xml
create mode 100644 tests/reference/fmtToDocX_SaveDocument_styles.xml
create mode 100644 tests/test_formats/test_fmt_todocx.py
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index ef3305fc1..78230ca50 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -109,7 +109,7 @@ def _mkTag(ns: str, tag: str) -> str:
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
S_SEP = "Separator"
-S_META = "TextMeta"
+S_META = "MetaText"
S_HEAD = "Header"
S_FNOTE = "FootnoteText"
@@ -423,7 +423,8 @@ def _processFragments(
par.addContent(xNode)
xNode = None
- par.addContent(self._textRunToXml(text[fStart:fPos], xFmt))
+ if temp := text[fStart:fPos]:
+ par.addContent(self._textRunToXml(temp, xFmt))
if fFmt == self.FMT_B_B:
xFmt |= X_BLD
@@ -647,7 +648,7 @@ def _generateStyles(self) -> None:
# Add Text Meta Style
styles.append(DocXParStyle(
- name="Text Meta",
+ name="Meta Text",
styleId=S_META,
size=fSz,
basedOn=S_NORM,
@@ -905,15 +906,8 @@ def _documentXml(self, hFirst: str | None, hDefault: str | None) -> str:
for i, par in enumerate(self._pars):
if i > 0 and par.pageBreakBefore:
prev = self._pars[i-1]
- if prev.pageBreakAfter:
- # We already have a break, so we inject a new paragraph instead
- empty = DocXParagraph()
- empty.setStyle(self._styles.get(S_NORM))
- empty.setPageBreakAfter(True)
- pars.append(empty)
- else:
- par.setPageBreakBefore(False)
- prev.setPageBreakAfter(True)
+ par.setPageBreakBefore(False)
+ prev.setPageBreakAfter(True)
pars.append(par)
@@ -1037,11 +1031,6 @@ def pageBreakBefore(self) -> bool:
"""Has page break before."""
return self._breakBefore
- @property
- def pageBreakAfter(self) -> bool:
- """Has page break after."""
- return self._breakAfter
-
##
# Setters
##
diff --git a/tests/reference/fmtToDocX_SaveDocument_Content_Types.xml b/tests/reference/fmtToDocX_SaveDocument_Content_Types.xml
new file mode 100644
index 000000000..2d38ec4c2
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_Content_Types.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_app.xml b/tests/reference/fmtToDocX_SaveDocument_app.xml
new file mode 100644
index 000000000..105372238
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_app.xml
@@ -0,0 +1,9 @@
+
+
+ 36
+ novelWriter/2.6a1
+ 4029
+ 21251
+ 24914
+ 42
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_core.xml b/tests/reference/fmtToDocX_SaveDocument_core.xml
new file mode 100644
index 000000000..7435332fc
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_core.xml
@@ -0,0 +1,11 @@
+
+
+ 2024-10-21T13:54:49
+ 2024-10-21T13:54:49
+ lipsum.com
+ Lorem Ipsum
+ lipsum.com
+ en_GB
+ 45
+ lipsum.com
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_document.xml b/tests/reference/fmtToDocX_SaveDocument_document.xml
new file mode 100644
index 000000000..91dabb463
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_document.xml
@@ -0,0 +1,1136 @@
+
+
+
+
+
+
+
+
+
+
+ Lorem Ipsum
+
+
+
+
+
+
+
+
+
+
+
+ By lipsum.com
+
+
+
+
+
+
+
+
+
+ “Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”
+
+
+
+
+
+
+
+
+
+ “There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Comment:
+
+
+
+ Exctracted from the lipsum.com website.
+
+
+
+
+
+
+
+
+
+ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.
+
+
+
+
+
+
+
+
+
+ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
+
+
+
+
+
+
+
+
+
+
+
+ Prologue
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Explanation from the lipsum.com website.
+
+
+
+
+
+
+
+
+
+
+ Lorem Ipsum
+
+
+
+ is simply dummy text
+
+
+
+
+
+
+
+
+
+ of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
+
+
+
+
+
+
+
+
+
+
+
+
+ Act One
+
+
+
+
+
+
+
+
+
+ “Fusce maximus felis libero”
+
+
+
+
+
+
+
+
+
+
+
+ Chapter One
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar.
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.
+
+
+
+
+
+
+
+
+
+ Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada.
+
+
+
+
+
+
+
+
+
+ Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex.
+
+
+
+
+
+
+
+
+
+ Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst.
+
+
+
+
+
+
+
+
+
+ Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend.
+
+
+
+
+
+
+
+
+
+ * * *
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci.
+
+
+
+
+
+
+
+
+ Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien.
+
+
+
+
+
+
+
+
+
+ Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis.
+
+
+
+
+
+
+
+
+
+ Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi.
+
+
+
+
+
+
+
+
+
+ Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu.
+
+
+
+
+
+
+
+
+
+ Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor.
+
+
+
+
+
+
+
+
+
+ Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus.
+
+
+
+
+
+
+
+
+
+
+
+ Chapter Two
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.
+
+
+
+
+
+
+
+
+ Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at.
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
+
+
+
+
+
+
+
+
+
+ Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis.
+
+
+
+
+
+
+
+
+
+ Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis.
+
+
+
+
+
+
+
+
+
+ Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus.
+
+
+
+
+
+
+
+
+
+ Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac.
+
+
+
+
+
+
+
+
+
+ * * *
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.
+
+
+
+
+
+
+
+
+ Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien.
+
+
+
+
+
+
+
+
+
+ Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum.
+
+
+
+
+
+
+
+
+
+ Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit.
+
+
+
+
+
+
+
+
+
+ Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien.
+
+
+
+
+
+
+
+
+
+ Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui.
+
+
+
+
+
+
+
+
+
+ Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero.
+
+
+
+
+
+
+
+
+
+ * * *
+
+
+
+
+
+
+
+
+
+
+
+ Point of View:
+
+
+
+ Bod
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+
+
+
+ Locations:
+
+
+
+ Europe
+
+
+
+
+
+
+
+
+
+
+ Synopsis:
+
+
+
+ Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
+
+
+
+
+
+
+
+
+ Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet.
+
+
+
+
+
+
+
+
+
+ In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere.
+
+
+
+
+
+
+
+
+
+ Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.
+
+
+
+
+
+
+
+
+
+ Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.
+
+
+
+
+
+
+
+
+
+ Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.
+
+
+
+
+
+
+
+
+
+
+
+
+ Notes: Characters
+
+
+
+
+
+
+
+
+ Nobody Owens
+
+
+
+
+
+
+
+
+
+
+
+ Tag:
+
+
+
+ Bod | Nobody Owens
+
+
+
+
+
+
+
+
+
+
+
+ Plot:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+ Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus.
+
+
+
+
+
+
+
+
+
+ Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat.
+
+
+
+
+
+
+
+
+
+ Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque.
+
+
+
+
+
+
+
+
+
+
+
+
+ Notes: Plot
+
+
+
+
+
+
+
+
+ Main Plot
+
+
+
+
+
+
+
+
+
+
+ Tag:
+
+
+
+ Main
+
+
+
+
+
+
+
+
+ Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet.
+
+
+
+
+
+
+
+
+
+ Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur.
+
+
+
+
+
+
+
+
+
+
+
+
+ Notes: World
+
+
+
+
+
+
+
+
+ Ancient Europe
+
+
+
+
+
+
+
+
+
+
+ Tag:
+
+
+
+ Europe | Ancient Europe
+
+
+
+
+
+
+
+
+ Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.
+
+
+
+
+
+
+
+
+
+ Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.
+
+
+
+
+
+
+
+
+
+ Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_document.xml.rels b/tests/reference/fmtToDocX_SaveDocument_document.xml.rels
new file mode 100644
index 000000000..e811d4850
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_document.xml.rels
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_footnotes.xml b/tests/reference/fmtToDocX_SaveDocument_footnotes.xml
new file mode 100644
index 000000000..7203ae35d
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_footnotes.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+ Lorem ipsum
+
+
+
+ is typically a corrupted version of De finibus bonorum et malorum, a 1st-century BC text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical and improper Latin. (Source: Wikipedia)
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_header1.xml b/tests/reference/fmtToDocX_SaveDocument_header1.xml
new file mode 100644
index 000000000..929f26105
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_header1.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+ Page
+
+
+
+
+
+
+
+ PAGE
+
+
+
+
+
+
+
+ 2
+
+
+
+
+
+
+
+ - Lorem Ipsum (lipsum.com)
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_header2.xml b/tests/reference/fmtToDocX_SaveDocument_header2.xml
new file mode 100644
index 000000000..a94b4ff3a
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_header2.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_rels b/tests/reference/fmtToDocX_SaveDocument_rels
new file mode 100644
index 000000000..a86764bdd
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_rels
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_settings.xml b/tests/reference/fmtToDocX_SaveDocument_settings.xml
new file mode 100644
index 000000000..348826a94
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_settings.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/reference/fmtToDocX_SaveDocument_styles.xml b/tests/reference/fmtToDocX_SaveDocument_styles.xml
new file mode 100644
index 000000000..a8c8a16fa
--- /dev/null
+++ b/tests/reference/fmtToDocX_SaveDocument_styles.xml
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py
index faf48dd36..9c1f8dff1 100644
--- a/tests/test_base/test_base_common.py
+++ b/tests/test_base/test_base_common.py
@@ -33,11 +33,12 @@
from novelwriter.common import (
NWConfigParser, checkBool, checkFloat, checkInt, checkIntTuple, checkPath,
checkString, checkStringNone, checkUuid, compact, cssCol, describeFont,
- elide, formatFileFilter, formatInt, formatTime, formatTimeStamp,
- formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle, isItemClass,
- isItemLayout, isItemType, isListInstance, isTitleTag, jsonEncode,
- makeFileNameSafe, minmax, numberToRoman, openExternalPath, readTextFile,
- simplified, transferCase, uniqueCompact, xmlIndent, yesNo
+ elide, firstFloat, formatFileFilter, formatInt, formatTime,
+ formatTimeStamp, formatVersion, fuzzyTime, getFileSize, hexToInt, isHandle,
+ isItemClass, isItemLayout, isItemType, isListInstance, isTitleTag,
+ jsonEncode, makeFileNameSafe, minmax, numberToRoman, openExternalPath,
+ readTextFile, simplified, transferCase, uniqueCompact, xmlIndent,
+ xmlSubElem, yesNo
)
from tests.mocked import causeOSError
@@ -291,6 +292,15 @@ def testBaseCommon_checkIntTuple():
assert checkIntTuple(5, (0, 1, 2), 3) == 3
+@pytest.mark.base
+def testBaseCommon_firstFloat():
+ """Test the firstFloat function."""
+ assert firstFloat(None, 1.0) == 1.0
+ assert firstFloat(1.0, None) == 1.0
+ assert firstFloat(None, 1) == 0.0
+ assert firstFloat(None, "1.0") == 0.0
+
+
@pytest.mark.base
def testBaseCommon_formatTimeStamp():
"""Test the formatTimeStamp function."""
@@ -624,6 +634,26 @@ def testBaseCommon_xmlIndent():
assert data == "foobar"
+@pytest.mark.base
+def testBaseCommon_xmlSubElem():
+ """Test the xmlSubElem function."""
+ assert ET.tostring(
+ xmlSubElem(ET.Element("r"), "node", None, attrib={"a": "b"})
+ ) == b''
+ assert ET.tostring(
+ xmlSubElem(ET.Element("r"), "node", "text", attrib={"a": "b"})
+ ) == b'text'
+ assert ET.tostring(
+ xmlSubElem(ET.Element("r"), "node", 42, attrib={"a": "b"})
+ ) == b'42'
+ assert ET.tostring(
+ xmlSubElem(ET.Element("r"), "node", 3.14, attrib={"a": "b"})
+ ) == b'3.14'
+ assert ET.tostring(
+ xmlSubElem(ET.Element("r"), "node", True, attrib={"a": "b"})
+ ) == b'true'
+
+
@pytest.mark.base
def testBaseCommon_readTextFile(monkeypatch, fncPath, ipsumText):
"""Test the readTextFile function."""
diff --git a/tests/test_formats/test_fmt_todocx.py b/tests/test_formats/test_fmt_todocx.py
new file mode 100644
index 000000000..3f947b296
--- /dev/null
+++ b/tests/test_formats/test_fmt_todocx.py
@@ -0,0 +1,566 @@
+"""
+novelWriter – ToDocX Class Tester
+=================================
+
+This file is a part of novelWriter
+Copyright 2018–2024, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+import zipfile
+
+import pytest
+
+from novelwriter.common import xmlIndent
+from novelwriter.constants import nwHeadFmt
+from novelwriter.core.buildsettings import BuildSettings
+from novelwriter.core.docbuild import NWBuildDocument
+from novelwriter.core.project import NWProject
+from novelwriter.enum import nwBuildFmt
+from novelwriter.formats.todocx import (
+ S_FNOTE, S_HEAD1, S_HEAD2, S_HEAD3, S_HEAD4, S_META, S_NORM, S_SEP,
+ S_TITLE, ToDocX, _mkTag, _wTag
+)
+
+from tests.tools import DOCX_IGNORE, cmpFiles
+
+OOXML_SCM = "http://schemas.openxmlformats.org"
+XML_NS = [
+ f' xmlns:r="{OOXML_SCM}/officeDocument/2006/relationships"',
+ f' xmlns:w="{OOXML_SCM}/wordprocessingml/2006/main"',
+ f' xmlns:cp="{OOXML_SCM}/package/2006/metadata/core-properties"',
+ ' xmlns:dc="http://purl.org/dc/elements/1.1/"',
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
+ ' xmlns:xml="http://www.w3.org/XML/1998/namespace"',
+ ' xmlns:dcterms="http://purl.org/dc/terms/"',
+]
+
+
+def xmlToText(xElem):
+ """Get the text content of an XML element."""
+ rTxt = ET.tostring(xElem, encoding="utf-8", xml_declaration=False).decode()
+ for ns in XML_NS:
+ rTxt = rTxt.replace(ns, "")
+ return rTxt
+
+
+@pytest.mark.core
+def testFmtToDocX_ParagraphStyles(mockGUI):
+ """Test formatting of paragraphs."""
+ project = NWProject()
+ doc = ToDocX(project)
+ doc.setSynopsis(True)
+ doc.setComments(True)
+ doc.setKeywords(True)
+ doc.initDocument()
+
+ # Normal Text
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Title
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TITLE, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Heading Level 1
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_HEAD1, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Heading Level 2
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_HEAD2, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Heading Level 3
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_HEAD3, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Heading Level 4
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_HEAD4, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Separator
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_SEP, 0, "* * *", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ '* * *'
+ )
+
+ # Empty Paragraph
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_SKIP, 0, "* * *", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ )
+
+ # Synopsis
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_SYNOPSIS, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Synopsis:'
+ ' Hello World'
+ ''
+ )
+
+ # Short
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_SHORT, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Short Description:'
+ ' Hello World'
+ ''
+ )
+
+ # Comment
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_COMMENT, 0, "Hello World", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Comment:'
+ ' Hello World'
+ ''
+ )
+
+ # Tags and References (Single)
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_KEYWORD, 0, "tag: Stuff", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Tag:'
+ ' Stuff'
+ ''
+ )
+
+ # Tags and References (Multiple)
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_KEYWORD, 0, "char: Jane, John", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Characters:'
+ ' Jane, John'
+ ''
+ )
+
+ # Tags and References (Invalid)
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_KEYWORD, 0, "stuff: Stuff", [], doc.A_NONE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ )
+
+
+@pytest.mark.core
+def testFmtToDocX_ParagraphFormatting(mockGUI):
+ """Test formatting of paragraphs."""
+ project = NWProject()
+ doc = ToDocX(project)
+ doc.setSynopsis(True)
+ doc.setComments(True)
+ doc.setKeywords(True)
+ doc.initDocument()
+
+ # Left Align
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_LEFT)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Right Align
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_RIGHT)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Center Align
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_CENTRE)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Justify
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_JUSTIFY)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ )
+
+ # Page Break Before
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_PBB)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ ''
+ 'Hello World'
+ ''
+ )
+
+ # Page Break After
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_PBA)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Hello World'
+ ''
+ ''
+ )
+
+ # Zero Margins
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_Z_TOPMRG | doc.A_Z_BTMMRG)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ ''
+ 'Hello World'
+ )
+
+ # Indent
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_IND_L | doc.A_IND_R)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ ''
+ 'Hello World'
+ )
+
+ # First Line Indent
+ xTest = ET.Element(_wTag("body"))
+ doc._tokens = [(doc.T_TEXT, 0, "Hello World", [], doc.A_IND_T)]
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ ''
+ 'Hello World'
+ )
+
+
+@pytest.mark.core
+def testFmtToDocX_TextFormatting(mockGUI):
+ """Test formatting of text."""
+ project = NWProject()
+ doc = ToDocX(project)
+ doc.initDocument()
+
+ # Markdown
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Text **bold**, _italic_, ~~strike~~."
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Text '
+ 'bold'
+ ', '
+ 'italic'
+ ', '
+ 'strike'
+ '.'
+ ''
+ )
+
+ # Nested Shortcode Text, Emphasis
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Some [s]nested [b]bold[/b] [u]and[/u] [i]italics[/i] text[/s] text."
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Some '
+ 'nested '
+ 'bold'
+ ' '
+ 'and'
+ ' '
+ 'italics'
+ ' text'
+ ' text.'
+ ''
+ )
+
+ # Shortcode Text, Super/Subscript
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Some super[sup]script[/sup] and sub[sub]script[/sub] text."
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Some super'
+ 'script'
+ ' and sub'
+ 'script'
+ ' text.'
+ ''
+ )
+
+ # Shortcode Text, Underline/Highlight
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Some [u]underlined and [m]highlighted[/m][/u] text."
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Some '
+ ''
+ 'underlined and '
+ ''
+ 'highlighted'
+ ' text.'
+ ''
+ )
+
+ # Hard Break
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Some text.\nNext line\n"
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Some text.Next line'
+ ''
+ )
+
+ # Tab
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "\tItem 1\tItem 2\n"
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Item 1Item 2'
+ ''
+ )
+
+ # Tab in Format
+ xTest = ET.Element(_wTag("body"))
+ doc._text = "Some **bold\ttext**"
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Some '
+ 'boldtext'
+ ''
+ )
+
+
+@pytest.mark.core
+def testFmtToDocX_Footnotes(mockGUI):
+ """Test formatting of footnotes."""
+ project = NWProject()
+ doc = ToDocX(project)
+ doc.initDocument()
+
+ # Text
+ xTest = ET.Element(_wTag("body"))
+ doc._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"
+ )
+ doc.tokenizeText()
+ doc.doConvert()
+ doc._pars[-1].toXml(xTest)
+ assert xmlToText(xTest) == (
+ f''
+ 'Text with one'
+ ''
+ ''
+ ', '
+ 'two'
+ ''
+ ''
+ ', or three'
+ ' footnotes.'
+ ''
+ ''
+ ''
+ )
+
+ # Footnotes
+ doc._footnotesXml()
+ assert xmlToText(doc._files["footnotes.xml"].xml) == (
+ ''
+ f''
+ 'Footnote text A.'
+ f''
+ 'Another footnote.'
+ f''
+ 'Again?'
+ ''
+ )
+
+
+@pytest.mark.core
+def testFmtToDocX_SaveDocument(mockGUI, prjLipsum, fncPath, tstPaths):
+ """Test document output."""
+ project = NWProject()
+ project.openProject(prjLipsum)
+
+ pageHeader = f"Page {nwHeadFmt.ODT_PAGE} - {nwHeadFmt.ODT_PROJECT} ({nwHeadFmt.ODT_AUTHOR})"
+
+ build = BuildSettings()
+ build.setValue("filter.includeNovel", True)
+ build.setValue("filter.includeNotes", True)
+ build.setValue("filter.includeInactive", False)
+ build.setValue("text.includeSynopsis", True)
+ build.setValue("text.includeComments", True)
+ build.setValue("text.includeKeywords", True)
+ build.setValue("format.textFont", "Source Sans Pro,12")
+ build.setValue("format.firstLineIndent", True)
+ build.setValue("odt.pageHeader", pageHeader)
+
+ docBuild = NWBuildDocument(project, build)
+ docBuild.queueAll()
+
+ docPath = fncPath / "document.docx"
+ assert list(docBuild.iterBuildDocument(docPath, nwBuildFmt.DOCX)) == [
+ (0, True), (1, True), (2, True), (3, True), (4, True), (5, False),
+ (6, True), (7, True), (8, True), (9, False), (10, False), (11, True),
+ (12, True), (13, True), (14, True), (15, True), (16, True), (17, True),
+ (18, True), (19, True), (20, True),
+ ]
+
+ assert docPath.exists()
+ assert zipfile.is_zipfile(docPath)
+
+ with zipfile.ZipFile(docPath, mode="r") as zipObj:
+ zipObj.extractall(fncPath / "extract")
+
+ def prettifyXml(inFile, outFile):
+ with open(outFile, mode="wb") as fStream:
+ xml = ET.parse(inFile)
+ xmlIndent(xml)
+ xml.write(fStream, encoding="utf-8", xml_declaration=True)
+
+ expected = [
+ fncPath / "extract" / "[Content_Types].xml",
+ fncPath / "extract" / "_rels" / ".rels",
+ fncPath / "extract" / "docProps" / "app.xml",
+ fncPath / "extract" / "docProps" / "core.xml",
+ fncPath / "extract" / "word" / "_rels" / "document.xml.rels",
+ fncPath / "extract" / "word" / "document.xml",
+ fncPath / "extract" / "word" / "footnotes.xml",
+ fncPath / "extract" / "word" / "header1.xml",
+ fncPath / "extract" / "word" / "header2.xml",
+ fncPath / "extract" / "word" / "settings.xml",
+ fncPath / "extract" / "word" / "styles.xml",
+ ]
+
+ outDir = tstPaths.outDir / "fmtToDocX_SaveDocument"
+ outDir.mkdir()
+ for file in expected:
+ assert file.is_file()
+ name = file.name.replace("[", "").replace("]", "").lstrip(".")
+ outFile = outDir / name
+ refFile = tstPaths.refDir / f"fmtToDocX_SaveDocument_{name}"
+ prettifyXml(file, outFile)
+ assert cmpFiles(outFile, refFile, ignoreStart=DOCX_IGNORE)
+
+
+@pytest.mark.core
+def testFmtToDocX_MkTag():
+ """Test the tag maker function."""
+ assert _mkTag("r", "id") == f"{{{OOXML_SCM}/officeDocument/2006/relationships}}id"
+ assert _mkTag("w", "t") == f"{{{OOXML_SCM}/wordprocessingml/2006/main}}t"
+ assert _mkTag("q", "t") == "t"
diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py
index 9305eadae..8f2f34dbc 100644
--- a/tests/test_formats/test_fmt_toodt.py
+++ b/tests/test_formats/test_fmt_toodt.py
@@ -1,6 +1,6 @@
"""
novelWriter – ToOdt Class Tester
-=================================
+================================
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -48,8 +48,8 @@
def xmlToText(xElem):
"""Get the text content of an XML element."""
rTxt = ET.tostring(xElem, encoding="utf-8", xml_declaration=False).decode()
- for nSpace in XML_NS:
- rTxt = rTxt.replace(nSpace, "")
+ for ns in XML_NS:
+ rTxt = rTxt.replace(ns, "")
return rTxt
diff --git a/tests/tools.py b/tests/tools.py
index 6bcfbe312..98c8b0d68 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -30,6 +30,7 @@
XML_IGNORE = (" bool:
From ec7781fb9fb921492fbad64c2968a1a5016a041b Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 21 Oct 2024 17:00:44 +0200
Subject: [PATCH 16/16] Generalise ODT variables to DOC
---
novelwriter/constants.py | 10 ++---
novelwriter/core/buildsettings.py | 30 +++++++++------
novelwriter/core/docbuild.py | 10 ++---
novelwriter/formats/todocx.py | 10 ++---
novelwriter/formats/toodt.py | 10 ++---
novelwriter/tools/manussettings.py | 40 ++++++++++----------
tests/test_core/test_core_buildsettings.py | 2 +-
tests/test_core/test_core_docbuild.py | 2 +-
tests/test_formats/test_fmt_todocx.py | 4 +-
tests/test_formats/test_fmt_toodt.py | 6 +--
tests/test_tools/test_tools_manussettings.py | 24 ++++++------
11 files changed, 77 insertions(+), 71 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index fa93bfe2c..03886a57a 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -369,11 +369,11 @@ class nwHeadFmt:
CHAR_POV, CHAR_FOCUS
]
- # ODT Document Page Header
- ODT_PROJECT = "{Project}"
- ODT_AUTHOR = "{Author}"
- ODT_PAGE = "{Page}"
- ODT_AUTO = "{Project} / {Author} / {Page}"
+ # Document Page Header
+ DOC_PROJECT = "{Project}"
+ DOC_AUTHOR = "{Author}"
+ DOC_PAGE = "{Page}"
+ DOC_AUTO = "{Project} / {Author} / {Page}"
class nwQuotes:
diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py
index 80c0cd72e..c26c94b1f 100644
--- a/novelwriter/core/buildsettings.py
+++ b/novelwriter/core/buildsettings.py
@@ -109,11 +109,11 @@
"format.bottomMargin": (float, 2.0),
"format.leftMargin": (float, 2.0),
"format.rightMargin": (float, 2.0),
- "odt.pageHeader": (str, nwHeadFmt.ODT_AUTO),
- "odt.pageCountOffset": (int, 0),
- "odt.colorHeadings": (bool, True),
- "odt.scaleHeadings": (bool, True),
- "odt.boldHeadings": (bool, True),
+ "doc.pageHeader": (str, nwHeadFmt.DOC_AUTO),
+ "doc.pageCountOffset": (int, 0),
+ "doc.colorHeadings": (bool, True),
+ "doc.scaleHeadings": (bool, True),
+ "doc.boldHeadings": (bool, True),
"html.addStyles": (bool, True),
"html.preserveTabs": (bool, False),
}
@@ -165,18 +165,24 @@
"format.pageSize": QT_TRANSLATE_NOOP("Builds", "Page Size"),
"format.pageMargins": QT_TRANSLATE_NOOP("Builds", "Page Margins"),
- "odt": QT_TRANSLATE_NOOP("Builds", "Document Options"),
- "odt.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
- "odt.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
- "odt.colorHeadings": QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"),
- "odt.scaleHeadings": QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"),
- "odt.boldHeadings": QT_TRANSLATE_NOOP("Builds", "Bold Headings"),
+ "doc": QT_TRANSLATE_NOOP("Builds", "Document Style"),
+ "doc.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"),
+ "doc.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
+ "doc.colorHeadings": QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"),
+ "doc.scaleHeadings": QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"),
+ "doc.boldHeadings": QT_TRANSLATE_NOOP("Builds", "Bold Headings"),
"html": QT_TRANSLATE_NOOP("Builds", "HTML Options"),
"html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"),
"html.preserveTabs": QT_TRANSLATE_NOOP("Builds", "Preserve Tab Characters"),
}
+RENAMED = {
+ "odt.addColours": "doc.addColours",
+ "odt.pageHeader": "doc.pageHeader",
+ "odt.pageCountOffset": "doc.pageCountOffset",
+}
+
class FilterMode(Enum):
"""The decision reason for an item in a filtered project."""
@@ -490,7 +496,7 @@ def unpack(self, data: dict) -> None:
self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()}
if isinstance(settings, dict):
for key, value in settings.items():
- self.setValue(key, value)
+ self.setValue(RENAMED.get(key, key), value)
self._changed = False
diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py
index 8e707402f..f753b61b4 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -292,9 +292,9 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
self._build.getBool("format.indentFirstPar"),
)
bldObj.setHeadingStyles(
- self._build.getBool("odt.colorHeadings"),
- self._build.getBool("odt.scaleHeadings"),
- self._build.getBool("odt.boldHeadings"),
+ self._build.getBool("doc.colorHeadings"),
+ self._build.getBool("doc.scaleHeadings"),
+ self._build.getBool("doc.boldHeadings"),
)
bldObj.setTitleMargins(
@@ -339,8 +339,8 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
if isinstance(bldObj, (ToOdt, ToDocX)):
bldObj.setLanguage(self._project.data.language)
bldObj.setHeaderFormat(
- self._build.getStr("odt.pageHeader"),
- self._build.getInt("odt.pageCountOffset"),
+ self._build.getStr("doc.pageHeader"),
+ self._build.getInt("doc.pageCountOffset"),
)
if isinstance(bldObj, (ToOdt, ToDocX, ToQTextDocument)):
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 78230ca50..e417d0ae6 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -837,11 +837,11 @@ def _defaultHeaderXml(self) -> str:
xmlSubElem(xPPr, _wTag("jc"), attrib={_wTag("val"): "right"})
xmlSubElem(xPPr, _wTag("rPr"))
- pre, page, post = self._headerFormat.partition(nwHeadFmt.ODT_PAGE)
- pre = pre.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
- pre = pre.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
- post = post.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
- post = post.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
+ pre, page, post = self._headerFormat.partition(nwHeadFmt.DOC_PAGE)
+ pre = pre.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
+ pre = pre.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
+ post = post.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
+ post = post.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
xSpace = _mkTag("xml", "space")
wFldCT = _wTag("fldCharType")
diff --git a/novelwriter/formats/toodt.py b/novelwriter/formats/toodt.py
index 019076f80..fdacf287e 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -1076,12 +1076,12 @@ def _writeHeader(self) -> None:
# Standard Page Header
if self._headerFormat:
- pre, page, post = self._headerFormat.partition(nwHeadFmt.ODT_PAGE)
+ pre, page, post = self._headerFormat.partition(nwHeadFmt.DOC_PAGE)
- pre = pre.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
- pre = pre.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
- post = post.replace(nwHeadFmt.ODT_PROJECT, self._project.data.name)
- post = post.replace(nwHeadFmt.ODT_AUTHOR, self._project.data.author)
+ pre = pre.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
+ pre = pre.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
+ post = post.replace(nwHeadFmt.DOC_PROJECT, self._project.data.name)
+ post = post.replace(nwHeadFmt.DOC_AUTHOR, self._project.data.author)
xHead = ET.SubElement(xPage, _mkTag("style", "header"))
xPar = ET.SubElement(xHead, _mkTag("text", "p"), attrib={
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index 284371c41..350e48a42 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -1226,7 +1226,7 @@ def buildForm(self) -> None:
# Open Document
# =============
- title = self._build.getLabel("odt")
+ title = self._build.getLabel("doc")
section += 1
self._sidebar.addButton(title, section)
self.addGroupLabel(title, section)
@@ -1237,7 +1237,7 @@ def buildForm(self) -> None:
self.btnPageHeader = NIconToolButton(self, iSz, "revert")
self.btnPageHeader.clicked.connect(self._resetPageHeader)
self.addRow(
- self._build.getLabel("odt.pageHeader"), self.odtPageHeader,
+ self._build.getLabel("doc.pageHeader"), self.odtPageHeader,
button=self.btnPageHeader, stretch=(1, 1)
)
@@ -1246,16 +1246,16 @@ def buildForm(self) -> None:
self.odtPageCountOffset.setMaximum(999)
self.odtPageCountOffset.setSingleStep(1)
self.odtPageCountOffset.setMinimumWidth(spW)
- self.addRow(self._build.getLabel("odt.pageCountOffset"), self.odtPageCountOffset)
+ self.addRow(self._build.getLabel("doc.pageCountOffset"), self.odtPageCountOffset)
# Headings
self.colorHeadings = NSwitch(self, height=iPx)
self.scaleHeadings = NSwitch(self, height=iPx)
self.boldHeadings = NSwitch(self, height=iPx)
- self.addRow(self._build.getLabel("odt.colorHeadings"), self.colorHeadings)
- self.addRow(self._build.getLabel("odt.scaleHeadings"), self.scaleHeadings)
- self.addRow(self._build.getLabel("odt.boldHeadings"), self.boldHeadings)
+ self.addRow(self._build.getLabel("doc.colorHeadings"), self.colorHeadings)
+ self.addRow(self._build.getLabel("doc.scaleHeadings"), self.scaleHeadings)
+ self.addRow(self._build.getLabel("doc.boldHeadings"), self.boldHeadings)
# HTML Document
# =============
@@ -1356,14 +1356,14 @@ def loadContent(self) -> None:
self.pageUnit.currentIndexChanged.connect(self._changeUnit)
self.pageSize.currentIndexChanged.connect(self._changePageSize)
- # ODT Document
- # ============
+ # Document
+ # ========
- self.colorHeadings.setChecked(self._build.getBool("odt.colorHeadings"))
- self.scaleHeadings.setChecked(self._build.getBool("odt.scaleHeadings"))
- self.boldHeadings.setChecked(self._build.getBool("odt.boldHeadings"))
- self.odtPageHeader.setText(self._build.getStr("odt.pageHeader"))
- self.odtPageCountOffset.setValue(self._build.getInt("odt.pageCountOffset"))
+ self.colorHeadings.setChecked(self._build.getBool("doc.colorHeadings"))
+ self.scaleHeadings.setChecked(self._build.getBool("doc.scaleHeadings"))
+ self.boldHeadings.setChecked(self._build.getBool("doc.boldHeadings"))
+ self.odtPageHeader.setText(self._build.getStr("doc.pageHeader"))
+ self.odtPageCountOffset.setValue(self._build.getInt("doc.pageCountOffset"))
self.odtPageHeader.setCursorPosition(0)
# HTML Document
@@ -1426,12 +1426,12 @@ def saveContent(self) -> None:
self._build.setValue("format.leftMargin", self.leftMargin.value())
self._build.setValue("format.rightMargin", self.rightMargin.value())
- # ODT Document
- self._build.setValue("odt.colorHeadings", self.colorHeadings.isChecked())
- self._build.setValue("odt.scaleHeadings", self.scaleHeadings.isChecked())
- self._build.setValue("odt.boldHeadings", self.boldHeadings.isChecked())
- self._build.setValue("odt.pageHeader", self.odtPageHeader.text())
- self._build.setValue("odt.pageCountOffset", self.odtPageCountOffset.value())
+ # Documents
+ self._build.setValue("doc.colorHeadings", self.colorHeadings.isChecked())
+ self._build.setValue("doc.scaleHeadings", self.scaleHeadings.isChecked())
+ self._build.setValue("doc.boldHeadings", self.boldHeadings.isChecked())
+ self._build.setValue("doc.pageHeader", self.odtPageHeader.text())
+ self._build.setValue("doc.pageCountOffset", self.odtPageCountOffset.value())
# HTML Document
self._build.setValue("html.addStyles", self.htmlAddStyles.isChecked())
@@ -1537,7 +1537,7 @@ def _pageSizeValueChanged(self) -> None:
def _resetPageHeader(self) -> None:
"""Reset the ODT header format to default."""
- self.odtPageHeader.setText(nwHeadFmt.ODT_AUTO)
+ self.odtPageHeader.setText(nwHeadFmt.DOC_AUTO)
self.odtPageHeader.setCursorPosition(0)
return
diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py
index 72f34274a..5d0048345 100644
--- a/tests/test_core/test_core_buildsettings.py
+++ b/tests/test_core/test_core_buildsettings.py
@@ -148,7 +148,7 @@ def testCoreBuildSettings_BuildValues():
build = BuildSettings()
strSetting = "headings.fmtPart"
- intSetting = "odt.pageCountOffset"
+ intSetting = "doc.pageCountOffset"
boolSetting = "filter.includeNovel"
floatSetting = "format.lineHeight"
diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py
index 147e68704..df979e746 100644
--- a/tests/test_core/test_core_docbuild.py
+++ b/tests/test_core/test_core_docbuild.py
@@ -66,7 +66,7 @@
"format.stripUnicode": False,
"format.replaceTabs": True,
"format.firstLineIndent": True,
- "odt.colorHeadings": True,
+ "doc.colorHeadings": True,
"html.addStyles": True,
},
"content": {
diff --git a/tests/test_formats/test_fmt_todocx.py b/tests/test_formats/test_fmt_todocx.py
index 3f947b296..6a4ac9784 100644
--- a/tests/test_formats/test_fmt_todocx.py
+++ b/tests/test_formats/test_fmt_todocx.py
@@ -497,7 +497,7 @@ def testFmtToDocX_SaveDocument(mockGUI, prjLipsum, fncPath, tstPaths):
project = NWProject()
project.openProject(prjLipsum)
- pageHeader = f"Page {nwHeadFmt.ODT_PAGE} - {nwHeadFmt.ODT_PROJECT} ({nwHeadFmt.ODT_AUTHOR})"
+ pageHeader = f"Page {nwHeadFmt.DOC_PAGE} - {nwHeadFmt.DOC_PROJECT} ({nwHeadFmt.DOC_AUTHOR})"
build = BuildSettings()
build.setValue("filter.includeNovel", True)
@@ -508,7 +508,7 @@ def testFmtToDocX_SaveDocument(mockGUI, prjLipsum, fncPath, tstPaths):
build.setValue("text.includeKeywords", True)
build.setValue("format.textFont", "Source Sans Pro,12")
build.setValue("format.firstLineIndent", True)
- build.setValue("odt.pageHeader", pageHeader)
+ build.setValue("doc.pageHeader", pageHeader)
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
diff --git a/tests/test_formats/test_fmt_toodt.py b/tests/test_formats/test_fmt_toodt.py
index 8f2f34dbc..dda51c0fb 100644
--- a/tests/test_formats/test_fmt_toodt.py
+++ b/tests/test_formats/test_fmt_toodt.py
@@ -772,8 +772,8 @@ def testFmtToOdt_SaveFlat(mockGUI, fncPath, tstPaths):
assert odt._dLanguage == ""
odt.setLanguage("nb_NO")
assert odt._dLanguage == "nb"
- odt.setHeaderFormat(nwHeadFmt.ODT_AUTO, 1)
- assert odt._headerFormat == nwHeadFmt.ODT_AUTO
+ odt.setHeaderFormat(nwHeadFmt.DOC_AUTO, 1)
+ assert odt._headerFormat == nwHeadFmt.DOC_AUTO
odt.setFirstLineIndent(True, 1.4, False)
assert odt._firstIndent is True
assert odt._fTextIndent == "0.499cm"
@@ -822,7 +822,7 @@ def testFmtToOdt_SaveFull(mockGUI, fncPath, tstPaths):
odt._isNovel = True
# Set a format without page number
- odt.setHeaderFormat(f"{nwHeadFmt.ODT_PROJECT} - {nwHeadFmt.ODT_AUTHOR}", 0)
+ odt.setHeaderFormat(f"{nwHeadFmt.DOC_PROJECT} - {nwHeadFmt.DOC_AUTHOR}", 0)
odt._text = (
"## Chapter One\n\n"
diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py
index 756a5eb17..8adfd8243 100644
--- a/tests/test_tools/test_tools_manussettings.py
+++ b/tests/test_tools/test_tools_manussettings.py
@@ -707,11 +707,11 @@ def testToolBuildSettings_FormatOutput(qtbot, nwGUI):
"""Test the format-specific settings."""
build = BuildSettings()
- build.setValue("odt.pageHeader", nwHeadFmt.ODT_AUTO)
- build.setValue("odt.pageCountOffset", 0)
- build.setValue("odt.colorHeadings", True)
- build.setValue("odt.scaleHeadings", True)
- build.setValue("odt.boldHeadings", True)
+ build.setValue("doc.pageHeader", nwHeadFmt.DOC_AUTO)
+ build.setValue("doc.pageCountOffset", 0)
+ build.setValue("doc.colorHeadings", True)
+ build.setValue("doc.scaleHeadings", True)
+ build.setValue("doc.boldHeadings", True)
build.setValue("html.addStyles", False)
build.setValue("html.preserveTabs", False)
@@ -726,7 +726,7 @@ def testToolBuildSettings_FormatOutput(qtbot, nwGUI):
assert bSettings.toolStack.currentWidget() is fmtTab
# Check initial values
- assert fmtTab.odtPageHeader.text() == nwHeadFmt.ODT_AUTO
+ assert fmtTab.odtPageHeader.text() == nwHeadFmt.DOC_AUTO
assert fmtTab.odtPageCountOffset.value() == 0
assert fmtTab.colorHeadings.isChecked() is True
assert fmtTab.scaleHeadings.isChecked() is True
@@ -749,18 +749,18 @@ def testToolBuildSettings_FormatOutput(qtbot, nwGUI):
# Save values
fmtTab.saveContent()
- assert build.getStr("odt.pageHeader") == "Stuff"
- assert build.getInt("odt.pageCountOffset") == 1
- assert build.getBool("odt.colorHeadings") is False
- assert build.getBool("odt.scaleHeadings") is False
- assert build.getBool("odt.boldHeadings") is False
+ assert build.getStr("doc.pageHeader") == "Stuff"
+ assert build.getInt("doc.pageCountOffset") == 1
+ assert build.getBool("doc.colorHeadings") is False
+ assert build.getBool("doc.scaleHeadings") is False
+ assert build.getBool("doc.boldHeadings") is False
assert build.getBool("html.addStyles") is True
assert build.getBool("html.preserveTabs") is True
# Reset header format
fmtTab.btnPageHeader.click()
- assert fmtTab.odtPageHeader.text() == nwHeadFmt.ODT_AUTO
+ assert fmtTab.odtPageHeader.text() == nwHeadFmt.DOC_AUTO
# Finish
bSettings._dialogButtonClicked(bSettings.buttonBox.button(QtDialogClose))