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 00b4c88c..fa93bfe2 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 732b2d76..562ae052 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -37,6 +37,7 @@ from novelwriter.core.item import NWItem
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 @@ class NWBuildDocument:
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 55bcbfc6..75081d36 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 00000000..69d020e6
--- /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 960b48aa..ce993a25 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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
_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 562ae052..07ce3c81 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -334,14 +334,14 @@ class NWBuildDocument:
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 69d020e6..5040b8ab 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -32,6 +32,7 @@ from zipfile import ZipFile
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 @@ logger = logging.getLogger(__name__)
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
"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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
"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 @@ class ToDocX(Tokenizer):
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 ce993a25..5e68afdc 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -258,8 +258,7 @@ class ToOdt(Tokenizer):
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 5040b8ab..6e88b30b 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -32,9 +32,9 @@ from zipfile import ZipFile
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 @@ XML_NS = {
"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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
def _useableStyles(self) -> None:
"""Set the usable styles."""
+ hScale = self._scaleHeads
+
# Add Normal Style
self._addParStyle(
name="Normal",
@@ -297,8 +466,18 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
)
# Add Heading 2
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 2",
styleId="Heading2",
@@ -322,7 +500,6 @@ class ToDocX(Tokenizer):
)
# Add Heading 3
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 3",
styleId="Heading3",
@@ -334,7 +511,6 @@ class ToDocX(Tokenizer):
)
# Add Heading 4
- hScale = self._scaleHeads
self._addParStyle(
name="Heading 4",
styleId="Heading4",
@@ -366,6 +542,7 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 6e88b30b..6b7b2819 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 zipfile import ZipFile
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 @@ M_DLG = ~X_DLG
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
nextStyle="Normal",
margins=self._marginHead1,
level=0,
+ color=COL_HEAD_L12 if hColor else None,
)
# Add Heading 2
@@ -497,6 +543,7 @@ class ToDocX(Tokenizer):
nextStyle="Normal",
margins=self._marginHead2,
level=1,
+ color=COL_HEAD_L12 if hColor else None,
)
# Add Heading 3
@@ -508,6 +555,7 @@ class ToDocX(Tokenizer):
nextStyle="Normal",
margins=self._marginHead3,
level=1,
+ color=COL_HEAD_L34 if hColor else None,
)
# Add Heading 4
@@ -519,6 +567,27 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class DocXParagraph:
# 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 @@ class DocXRun:
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 e2c5817f..916afab2 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -205,7 +205,6 @@ class Tokenizer(ABC):
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 @@ class Tokenizer(ABC):
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 5e68afdc..fd683fd9 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -237,8 +237,8 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
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 6b7b2819..4aa966d4 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -4,6 +4,7 @@ novelWriter – DocX Text Converter
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 @@ M_SUB = ~X_SUB
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# Add Normal Style
self._addParStyle(
name="Normal",
- styleId="Normal",
+ styleId=S_NORM,
default=True,
margins=self._marginText,
)
@@ -514,10 +565,10 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class DocXParagraph:
# 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 @@ class DocXParagraph:
# 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 44463ca6..e96e5968 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 xmlIndent(tree: ET.Element | ET.ElementTree) -> 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 fd683fd9..3fb77100 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -38,7 +38,7 @@ from zipfile import ZipFile
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 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
_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 | 347 ++++++++++++++++++----------------
1 file changed, 187 insertions(+), 160 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index 4aa966d4..b9b78ccf 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -29,10 +29,11 @@ import xml.etree.ElementTree as ET
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 @@ COL_META_TXT = "813709"
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 @@ class ToDocX(Tokenizer):
self._fontSize = 12.0
self._dLanguage = "en-GB"
+ # Maps
+ self._styles: dict[str, DocXParStyle] = {}
+
return
##
@@ -194,7 +201,7 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# .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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
"""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))
+ size = firstFloat(style.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)})
+ 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 = 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})
+ 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 = 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})
+ 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})
+
+ 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 @@ class DocXParagraph:
# 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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
def finalise(self, body: ET.Element) -> None:
"""Called after all content is set."""
- par = ET.SubElement(body, _wTag("p"))
+ 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)
+ # 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})
+ # 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:
- _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"})
+ # 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 07ce3c81..8e707402 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -193,6 +193,8 @@ class NWBuildDocument:
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 b9b78ccf..fac3f471 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -25,6 +25,7 @@ along with this program. If not, see .
from __future__ import annotations
import logging
+import re
import xml.etree.ElementTree as ET
from datetime import datetime
@@ -40,6 +41,9 @@ from novelwriter.formats.tokenizer import T_Formats, Tokenizer
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
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 fac3f471..e8502a25 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -110,7 +110,6 @@ S_HEAD2 = "Heading2"
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 @@ class ToDocX(Tokenizer):
"""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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
after=fSz * self._marginTitle[1],
line=fSz0 * self._lineHeight,
level=0,
+ bold=self._boldHeads,
))
# Add Heading 1
@@ -632,6 +638,7 @@ class ToDocX(Tokenizer):
line=fSz1 * self._lineHeight,
level=0,
color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 2
@@ -646,6 +653,7 @@ class ToDocX(Tokenizer):
line=fSz2 * self._lineHeight,
level=1,
color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 3
@@ -660,6 +668,7 @@ class ToDocX(Tokenizer):
line=fSz3 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
))
# Add Heading 4
@@ -674,6 +683,7 @@ class ToDocX(Tokenizer):
line=fSz4 * self._lineHeight,
level=1,
color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
))
# Add Separator
@@ -737,6 +747,8 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
# 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 @@ class DocXParagraph:
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 @@ class DocXParagraph:
})
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 7eb4b33d..54827a2a 100644
--- a/novelwriter/formats/tohtml.py
+++ b/novelwriter/formats/tohtml.py
@@ -372,7 +372,7 @@ class ToHtml(Tokenizer):
"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 916afab2..04ce876a 100644
--- a/novelwriter/formats/tokenizer.py
+++ b/novelwriter/formats/tokenizer.py
@@ -152,21 +152,22 @@ class Tokenizer(ABC):
# 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 3fb77100..ad02d02c 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -327,7 +327,7 @@ class ToOdt(Tokenizer):
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 @@ class ToOdt(Tokenizer):
# 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 @@ class ODTParagraphStyle:
# 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 b9e8108c..8ea8f9aa 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 3206b221..9305eada 100644
--- a/tests/test_formats/test_fmt_toodt.py
+++ b/tests/test_formats/test_fmt_toodt.py
@@ -623,7 +623,7 @@ def testFmtToOdt_ConvertParagraphs(mockGUI):
''
'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 | 597 ++++++++++++++++++----------------
1 file changed, 309 insertions(+), 288 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index e8502a25..a0a006f6 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -121,6 +121,15 @@ COL_META_TXT = "813709"
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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")
+ # Content Lists
+ cExts: list[tuple[str, str]] = []
+ cExts.append(("xml", "application/xml"))
+ cExts.append(("rels", RELS_TYPE))
- # .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",
- })
+ cDocs: list[tuple[str, str]] = []
+ cDocs.append(("/_rels/.rels", RELS_TYPE))
+ cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
- # 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)
+ # 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}",
+ })
- # 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_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 @@ class ToDocX(Tokenizer):
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,12 +483,207 @@ class ToDocX(Tokenizer):
return run
##
- # Style Elements
+ # DocX Content
##
- def _defaultStyles(self) -> None:
- """Set the default styles."""
- xStyl = xmlSubElem(self._dStyl, _wTag("docDefaults"))
+ def _generateStyles(self) -> None:
+ """Generate usable styles."""
+ styles: list[DocXParStyle] = []
+
+ 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
+ align = "both" if self._doJustify else "left"
+
+ # Add Normal Style
+ styles.append(DocXParStyle(
+ name="Normal",
+ styleId=S_NORM,
+ size=fSz,
+ default=True,
+ before=fSz * self._marginText[0],
+ after=fSz * self._marginText[1],
+ line=fSz * self._lineHeight,
+ indentFirst=fSz * self._firstWidth,
+ align=align,
+ ))
+
+ # Add Title
+ styles.append(DocXParStyle(
+ name="Title",
+ styleId=S_TITLE,
+ size=fSz0,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginTitle[0],
+ after=fSz * self._marginTitle[1],
+ line=fSz0 * self._lineHeight,
+ level=0,
+ bold=self._boldHeads,
+ ))
+
+ # Add Heading 1
+ styles.append(DocXParStyle(
+ name="Heading 1",
+ styleId=S_HEAD1,
+ size=fSz1,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginHead1[0],
+ after=fSz * self._marginHead1[1],
+ line=fSz1 * self._lineHeight,
+ level=0,
+ color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
+ ))
+
+ # Add Heading 2
+ styles.append(DocXParStyle(
+ name="Heading 2",
+ styleId=S_HEAD2,
+ size=fSz2,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginHead2[0],
+ after=fSz * self._marginHead2[1],
+ line=fSz2 * self._lineHeight,
+ level=1,
+ color=COL_HEAD_L12 if hColor else None,
+ bold=self._boldHeads,
+ ))
+
+ # Add Heading 3
+ styles.append(DocXParStyle(
+ name="Heading 3",
+ styleId=S_HEAD3,
+ size=fSz3,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginHead3[0],
+ after=fSz * self._marginHead3[1],
+ line=fSz3 * self._lineHeight,
+ level=1,
+ color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
+ ))
+
+ # Add Heading 4
+ styles.append(DocXParStyle(
+ name="Heading 4",
+ styleId=S_HEAD4,
+ size=fSz4,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginHead4[0],
+ after=fSz * self._marginHead4[1],
+ line=fSz4 * self._lineHeight,
+ level=1,
+ color=COL_HEAD_L34 if hColor else None,
+ bold=self._boldHeads,
+ ))
+
+ # Add Separator
+ styles.append(DocXParStyle(
+ name="Separator",
+ styleId=S_SEP,
+ size=fSz,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginSep[0],
+ after=fSz * self._marginSep[1],
+ line=fSz * self._lineHeight,
+ align="center",
+ ))
+
+ # Add Text Meta Style
+ styles.append(DocXParStyle(
+ name="Text Meta",
+ styleId=S_META,
+ size=fSz,
+ basedOn=S_NORM,
+ nextStyle=S_NORM,
+ before=fSz * self._marginMeta[0],
+ after=fSz * self._marginMeta[1],
+ line=fSz * self._lineHeight,
+ 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 _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"))
@@ -585,172 +702,76 @@ class ToDocX(Tokenizer):
xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
- return
+ # 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"
- 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
- align = "both" if self._doJustify else "left"
+ size = firstFloat(style.size, self._fontSize)
- # Add Normal Style
- self._addParStyle(DocXParStyle(
- name="Normal",
- styleId=S_NORM,
- size=fSz,
- default=True,
- before=fSz * self._marginText[0],
- after=fSz * self._marginText[1],
- line=fSz * self._lineHeight,
- indentFirst=fSz * self._firstWidth,
- align=align,
- ))
+ 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)})
- # Add Title
- self._addParStyle(DocXParStyle(
- name="Title",
- styleId=S_TITLE,
- size=fSz0,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginTitle[0],
- after=fSz * self._marginTitle[1],
- line=fSz0 * self._lineHeight,
- level=0,
- bold=self._boldHeads,
- ))
+ 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})
- # Add Heading 1
- self._addParStyle(DocXParStyle(
- name="Heading 1",
- styleId=S_HEAD1,
- size=fSz1,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginHead1[0],
- after=fSz * self._marginHead1[1],
- line=fSz1 * self._lineHeight,
- level=0,
- color=COL_HEAD_L12 if hColor else None,
- bold=self._boldHeads,
- ))
-
- # Add Heading 2
- self._addParStyle(DocXParStyle(
- name="Heading 2",
- styleId=S_HEAD2,
- size=fSz2,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginHead2[0],
- after=fSz * self._marginHead2[1],
- line=fSz2 * self._lineHeight,
- level=1,
- color=COL_HEAD_L12 if hColor else None,
- bold=self._boldHeads,
- ))
-
- # Add Heading 3
- self._addParStyle(DocXParStyle(
- name="Heading 3",
- styleId=S_HEAD3,
- size=fSz3,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginHead3[0],
- after=fSz * self._marginHead3[1],
- line=fSz3 * self._lineHeight,
- level=1,
- color=COL_HEAD_L34 if hColor else None,
- bold=self._boldHeads,
- ))
-
- # Add Heading 4
- self._addParStyle(DocXParStyle(
- name="Heading 4",
- styleId=S_HEAD4,
- size=fSz4,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginHead4[0],
- after=fSz * self._marginHead4[1],
- line=fSz4 * self._lineHeight,
- level=1,
- color=COL_HEAD_L34 if hColor else None,
- bold=self._boldHeads,
- ))
-
- # Add Separator
- self._addParStyle(DocXParStyle(
- name="Separator",
- styleId=S_SEP,
- size=fSz,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginSep[0],
- after=fSz * self._marginSep[1],
- line=fSz * self._lineHeight,
- align="center",
- ))
-
- # Add Text Meta Style
- self._addParStyle(DocXParStyle(
- name="Text Meta",
- styleId=S_META,
- size=fSz,
- basedOn=S_NORM,
- nextStyle=S_NORM,
- before=fSz * self._marginMeta[0],
- after=fSz * self._marginMeta[1],
- line=fSz * self._lineHeight,
- color=COL_META_TXT,
- ))
+ 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 _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"
+ 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",
+ )
- size = firstFloat(style.size, self._fontSize)
+ xBody = xmlSubElem(xRoot, _wTag("body"))
- 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)})
+ # 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)
- 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})
+ pars.append(par)
- 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"))
-
- self._styles[style.styleId] = style
+ for par in pars:
+ par.toXml(xBody)
return
@@ -815,13 +836,13 @@ class DocXParagraph:
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 | 239 +++++++++++++++++++++++++++-------
1 file changed, 191 insertions(+), 48 deletions(-)
diff --git a/novelwriter/formats/todocx.py b/novelwriter/formats/todocx.py
index a0a006f6..079e1d5a 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -33,6 +33,8 @@ from pathlib import Path
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 @@ logger = logging.getLogger(__name__)
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 @@ for ns, uri in XML_NS.items():
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 @@ S_HEAD1 = "Heading1"
S_HEAD2 = "Heading2"
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
+S_HEAD = "Header"
S_SEP = "Separator"
S_META = "TextMeta"
@@ -162,9 +162,11 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
"""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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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,29 +761,101 @@ class ToDocX(Tokenizer):
})
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:
- """Populate document.xml."""
- xRoot = ET.Element(_wTag("document"))
- self._files["document.xml"] = DocXXmlFile(
+ def _defaultHeaderXml(self) -> str:
+ """Populate header1.xml."""
+ rId = self._nextRelId()
+ xRoot = ET.Element(_wTag("hdr"))
+ self._files["header1.xml"] = DocXXmlFile(
xml=xRoot,
- rId=self._nextRelId(),
+ rId=rId,
path="word",
- relType=f"{REL_BASE}/officeDocument",
- contentType=f"{WORD_BASE}.wordprocessingml.document.main+xml",
+ 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=rId,
+ path="word",
+ relType=f"{RELS_BASE}/officeDocument",
+ contentType=f"{WORD_BASE}.wordprocessingml.document.main+xml",
+ )
# Map all Page Break Before to After where possible
pars: list[DocXParagraph] = []
@@ -770,10 +874,49 @@ class ToDocX(Tokenizer):
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 079e1d5a..dd3f9c5f 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -48,7 +48,7 @@ RX_TEXT = re.compile(r"([\n\t])", re.UNICODE)
# 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 @@ S_HEAD1 = "Heading1"
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 @@ class ToDocX(Tokenizer):
self._pars: list[DocXParagraph] = []
self._files: dict[str, DocXXmlFile] = {}
self._styles: dict[str, DocXParStyle] = {}
+ self._usedNotes: list[str] = []
return
@@ -314,6 +317,9 @@ class ToDocX(Tokenizer):
self._documentXml(fId, dId)
+ if self._usedNotes:
+ self._footnotesXml()
+
return
def saveDocument(self, path: Path) -> None:
@@ -410,9 +416,14 @@ class ToDocX(Tokenizer):
"""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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
_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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 dd3f9c5f..ef3305fc 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -31,12 +31,12 @@ import xml.etree.ElementTree as ET
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
fId = self._firstHeaderXml()
self._documentXml(fId, dId)
-
+ self._settingsXml()
if self._usedNotes:
self._footnotesXml()
@@ -360,10 +360,9 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
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 ad02d02c..019076f8 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -33,7 +33,7 @@ from collections.abc import Sequence
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 @@ class ToOdt(Tokenizer):
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 ef3305fc..78230ca5 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -109,7 +109,7 @@ S_HEAD2 = "Heading2"
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 @@ class ToDocX(Tokenizer):
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 @@ class ToDocX(Tokenizer):
# 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 @@ class ToDocX(Tokenizer):
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 @@ class DocXParagraph:
"""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 00000000..2d38ec4c
--- /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 00000000..10537223
--- /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 00000000..7435332f
--- /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 00000000..91dabb46
--- /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 00000000..e811d485
--- /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 00000000..7203ae35
--- /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 00000000..929f2610
--- /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 00000000..a94b4ff3
--- /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 00000000..a86764bd
--- /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 00000000..348826a9
--- /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 00000000..a8c8a16f
--- /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 faf48dd3..9c1f8dff 100644
--- a/tests/test_base/test_base_common.py
+++ b/tests/test_base/test_base_common.py
@@ -33,11 +33,12 @@ from PyQt5.QtGui import QColor, QDesktopServices, QFontDatabase
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 00000000..3f947b29
--- /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 9305eada..8f2f34db 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 @@ XML_NS = [
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 6bcfbe31..98c8b0d6 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -30,6 +30,7 @@ from PyQt5.QtWidgets import QDialog, QVBoxLayout, QWidget
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 fa93bfe2..03886a57 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 80c0cd72..c26c94b1 100644
--- a/novelwriter/core/buildsettings.py
+++ b/novelwriter/core/buildsettings.py
@@ -109,11 +109,11 @@ SETTINGS_TEMPLATE: dict[str, tuple[type, str | int | float | bool]] = {
"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 @@ SETTINGS_LABELS = {
"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 @@ class BuildSettings:
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 8e707402..f753b61b 100644
--- a/novelwriter/core/docbuild.py
+++ b/novelwriter/core/docbuild.py
@@ -292,9 +292,9 @@ class NWBuildDocument:
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 @@ class NWBuildDocument:
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 78230ca5..e417d0ae 100644
--- a/novelwriter/formats/todocx.py
+++ b/novelwriter/formats/todocx.py
@@ -837,11 +837,11 @@ class ToDocX(Tokenizer):
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 019076f8..fdacf287 100644
--- a/novelwriter/formats/toodt.py
+++ b/novelwriter/formats/toodt.py
@@ -1076,12 +1076,12 @@ class ToOdt(Tokenizer):
# 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 284371c4..350e48a4 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -1226,7 +1226,7 @@ class _FormattingTab(NScrollableForm):
# 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 @@ class _FormattingTab(NScrollableForm):
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 @@ class _FormattingTab(NScrollableForm):
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 @@ class _FormattingTab(NScrollableForm):
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 @@ class _FormattingTab(NScrollableForm):
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 @@ class _FormattingTab(NScrollableForm):
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 72f34274..5d004834 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 147e6870..df979e74 100644
--- a/tests/test_core/test_core_docbuild.py
+++ b/tests/test_core/test_core_docbuild.py
@@ -66,7 +66,7 @@ BUILD_CONF = {
"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 3f947b29..6a4ac978 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 8f2f34db..dda51c0f 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 756a5eb1..8adfd824 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))