Files
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

1174 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 (C) 2024 Veronica Berglyd Olsen and novelWriter contributors
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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import TYPE_CHECKING, NamedTuple
from zipfile import ZIP_DEFLATED, ZipFile
from PyQt6.QtCore import QMargins, QSize
from novelwriter import __version__
from novelwriter.common import firstFloat, xmlElement, xmlSubElem
from novelwriter.constants import nwHeadFmt, nwStyles
from novelwriter.formats.shared import BlockFmt, BlockTyp, T_Formats, TextFmt, stripEscape
from novelwriter.formats.tokenizer import COMMENT_BLOCKS, Tokenizer
from novelwriter.types import QtHexRgb
if TYPE_CHECKING:
from pathlib import Path
from PyQt6.QtGui import QColor
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
# RegEx
RX_TEXT = re.compile(r"([\n\t])")
# Types and Relationships
OOXML_SCM = "http://schemas.openxmlformats.org"
WORD_BASE = "application/vnd.openxmlformats-officedocument.wordprocessingml"
RELS_TYPE = "application/vnd.openxmlformats-package.relationships+xml"
RELS_BASE = f"{OOXML_SCM}/officeDocument/2006/relationships"
# Main XML NameSpaces
XML_NS = {
"cp": f"{OOXML_SCM}/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/",
"dcterms": "http://purl.org/dc/terms/",
"r": RELS_BASE,
"w": f"{OOXML_SCM}/wordprocessingml/2006/main",
"xml": "http://www.w3.org/XML/1998/namespace",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
}
for ns, uri in XML_NS.items():
ET.register_namespace(ns, uri)
def _wTag(tag: str) -> str:
"""Assemble namespace and tag name for standard w namespace."""
return f"{{{OOXML_SCM}/wordprocessingml/2006/main}}{tag}"
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 _docXCol(color: QColor) -> str:
"""Format a QColor as the DocX accepted value."""
return color.name(QtHexRgb).lstrip("#")
def _wText(parent: ET.Element, text: str) -> ET.Element:
"""Create a text element and add the preserve flag if necessary."""
attrib = {}
if len(text) > len(text.strip()):
attrib[_mkTag("xml", "space")] = "preserve"
return xmlSubElem(parent, _wTag("t"), text, attrib=attrib)
def _mmToSz(value: float) -> int:
"""Convert millimetres to internal margin size units."""
return int(value*20.0*72.0/25.4)
# Cached
W_VAL = _wTag("val")
# 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_COL = 0x080 # Coloured text
X_HRF = 0x100 # Link
# 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_COL = ~X_COL
M_HRF = ~X_HRF
# DocX Styles
S_NORM = "Normal"
S_TITLE = "Title"
S_HEAD1 = "Heading1"
S_HEAD2 = "Heading2"
S_HEAD3 = "Heading3"
S_HEAD4 = "Heading4"
S_SEP = "Separator"
S_META = "MetaText"
S_HEAD = "Header"
S_FNOTE = "FootnoteText"
class DocXXmlRel(NamedTuple):
"""DocX XML Rel Data."""
rId: str
relType: str
targetMode: str | None = None
class DocXXmlFile(NamedTuple):
"""DocX XML File Data."""
xml: ET.Element
path: str
contentType: str
class DocXParStyle(NamedTuple):
"""DocX XML Paragraph Style Data."""
name: str
styleId: str
size: float
basedOn: str | None = None
nextStyle: str | None = None
before: float | None = None
after: float | None = None
left: 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):
"""Core: DocX Document Writer.
Extend the Tokenizer class to writer DocX Document files.
"""
def __init__(self, project: NWProject) -> None:
super().__init__(project)
# Properties
self._headerFormat = ""
self._pageOffset = 0
# Internal
self._fontFamily = "Liberation Serif"
self._fontSize = 12.0
self._pageSize = QSize(_mmToSz(210.0), _mmToSz(297.0))
self._pageMargins = QMargins(_mmToSz(20.0), _mmToSz(20.0), _mmToSz(20.0), _mmToSz(20.0))
# Data Variables
self._pars: list[DocXParagraph] = []
self._rels: dict[str, DocXXmlRel] = {}
self._files: dict[str, DocXXmlFile] = {}
self._styles: dict[str, DocXParStyle] = {}
self._usedNotes: dict[str, int] = {}
self._usedFields: list[tuple[ET.Element, str]] = []
##
# Setters
##
def setPageLayout(
self, width: float, height: float, top: float, bottom: float, left: float, right: float
) -> None:
"""Set the document page size and margins in millimetres."""
self._pageSize = QSize(_mmToSz(width), _mmToSz(height))
self._pageMargins = QMargins(_mmToSz(left), _mmToSz(top), _mmToSz(right), _mmToSz(bottom))
def setHeaderFormat(self, value: str, offset: int) -> None:
"""Set the document header format."""
self._headerFormat = value.strip()
self._pageOffset = offset
##
# Class Methods
##
def initDocument(self) -> None:
"""Initialise the DocX document structure."""
super().initDocument()
self._fontFamily = self._textFont.family()
self._fontSize = self._textFont.pointSizeF()
self._generateStyles()
def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
bIndent = self._fontSize * self._blockIndent
for tType, _, tText, tFormat, tStyle in self._blocks:
# Create Paragraph
par = DocXParagraph()
self._pars.append(par)
# Styles
if tStyle & BlockFmt.LEFT:
par.setAlignment("left")
elif tStyle & BlockFmt.RIGHT:
par.setAlignment("right")
elif tStyle & BlockFmt.CENTRE:
par.setAlignment("center")
elif tStyle & BlockFmt.JUSTIFY:
par.setAlignment("both")
if tStyle & BlockFmt.PBB:
par.setPageBreakBefore(True)
if tStyle & BlockFmt.PBA:
par.setPageBreakAfter(True)
if tStyle & BlockFmt.Z_BTM:
par.setMarginBottom(0.0)
if tStyle & BlockFmt.Z_TOP:
par.setMarginTop(0.0)
if tStyle & BlockFmt.IND_T:
par.setIndentFirst(True)
if tStyle & BlockFmt.IND_L:
par.setMarginLeft(bIndent)
if tStyle & BlockFmt.IND_R:
par.setMarginRight(bIndent)
# Process Text Types
if tType == BlockTyp.TEXT:
self._processFragments(par, S_NORM, tText, tFormat)
elif tType in (BlockTyp.TITLE, BlockTyp.PART):
self._processFragments(par, S_TITLE, tText, tFormat)
elif tType == BlockTyp.HEAD1:
self._processFragments(par, S_HEAD1, tText, tFormat)
elif tType == BlockTyp.HEAD2:
self._processFragments(par, S_HEAD2, tText, tFormat)
elif tType == BlockTyp.HEAD3:
self._processFragments(par, S_HEAD3, tText, tFormat)
elif tType == BlockTyp.HEAD4:
self._processFragments(par, S_HEAD4, tText, tFormat)
elif tType == BlockTyp.SEP:
self._processFragments(par, S_SEP, tText)
elif tType == BlockTyp.SKIP:
self._processFragments(par, S_NORM, "")
elif tType in COMMENT_BLOCKS:
self._processFragments(par, S_META, tText, tFormat)
elif tType == BlockTyp.KEYWORD:
self._processFragments(par, S_META, tText, tFormat)
def closeDocument(self) -> None:
"""Generate all the XML."""
self._coreXml()
self._appXml()
self._stylesXml()
self._fontTableXml()
fId = None
dId = None
if self._headerFormat:
dId = self._defaultHeaderXml()
fId = self._firstHeaderXml()
self._documentXml(fId, dId)
self._settingsXml()
if self._usedNotes:
self._footnotesXml()
def saveDocument(self, path: Path) -> None:
"""Save the data to a .docx file."""
# Content Lists
cExts: list[tuple[str, str]] = []
cExts.append(("xml", "application/xml"))
cExts.append(("rels", RELS_TYPE))
cDocs: list[tuple[str, str]] = []
cDocs.append(("/_rels/.rels", RELS_TYPE))
cDocs.append(("/word/_rels/document.xml.rels", RELS_TYPE))
# Relationships XML
rRels = xmlElement("Relationships", attrib={
"xmlns": f"{OOXML_SCM}/package/2006/relationships"
})
wRels = xmlElement("Relationships", attrib={
"xmlns": f"{OOXML_SCM}/package/2006/relationships"
})
for name, rel in self._rels.items():
isRoot = name in ("core.xml", "app.xml", "document.xml")
if xml := self._files.get(name):
target = f"{xml.path}/{name}" if isRoot else name
cDocs.append((f"/{xml.path}/{name}", xml.contentType))
else:
target = name
attrib = {"Id": rel.rId, "Type": rel.relType, "Target": target}
if rel.targetMode:
attrib["TargetMode"] = rel.targetMode
xmlSubElem(rRels if isRoot else wRels, "Relationship", attrib=attrib)
# Content Types XML
dTypes = xmlElement("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:
xmlSubElem(dTypes, "Override", attrib={"PartName": name, "ContentType": content})
def xmlToZip(name: str, root: ET.Element, zipObj: ZipFile) -> None:
zipObj.writestr(name, ET.tostring(root, encoding="utf-8", xml_declaration=True))
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, rel in self._files.items():
xmlToZip(f"{rel.path}/{name}", rel.xml, outZip)
xmlToZip("[Content_Types].xml", dTypes, outZip)
##
# Internal Functions
##
def _processFragments(
self, par: DocXParagraph, pStyle: str, text: str, tFmt: T_Formats | None = None
) -> None:
"""Apply formatting tags to text."""
par.setStyle(self._styles.get(pStyle))
xFmt = 0x00
xNode = None
fStart = 0
fLink = ""
fClass = ""
for fPos, fFmt, fData in tFmt or []:
if xNode is not None:
par.addContent(xNode)
xNode = None
if temp := text[fStart:fPos]:
par.addContent(self._textRunToXml(temp, xFmt, fClass, fLink))
if fFmt == TextFmt.B_B:
xFmt |= X_BLD
elif fFmt == TextFmt.B_E:
xFmt &= M_BLD
elif fFmt == TextFmt.I_B:
xFmt |= X_ITA
elif fFmt == TextFmt.I_E:
xFmt &= M_ITA
elif fFmt == TextFmt.D_B:
xFmt |= X_DEL
elif fFmt == TextFmt.D_E:
xFmt &= M_DEL
elif fFmt == TextFmt.U_B:
xFmt |= X_UND
elif fFmt == TextFmt.U_E:
xFmt &= M_UND
elif fFmt == TextFmt.M_B:
xFmt |= X_MRK
elif fFmt == TextFmt.M_E:
xFmt &= M_MRK
elif fFmt == TextFmt.SUP_B:
xFmt |= X_SUP
elif fFmt == TextFmt.SUP_E:
xFmt &= M_SUP
elif fFmt == TextFmt.SUB_B:
xFmt |= X_SUB
elif fFmt == TextFmt.SUB_E:
xFmt &= M_SUB
elif fFmt == TextFmt.COL_B:
xFmt |= X_COL
fClass = fData
elif fFmt == TextFmt.COL_E:
xFmt &= M_COL
fClass = ""
elif fFmt == TextFmt.HRF_B:
xFmt |= X_HRF
fLink = fData
elif fFmt == TextFmt.HRF_E:
xFmt &= M_HRF
fLink = ""
elif fFmt == TextFmt.FNOTE:
xNode = self._generateFootnote(fData)
elif fFmt == TextFmt.FIELD:
xNode = self._generateField(fData, xFmt)
elif fFmt == TextFmt.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, fClass, fLink))
def _textRunToXml(self, text: str | None, fmt: int, fClass: str, fLink: str) -> ET.Element:
"""Encode the text run into XML."""
xR = xmlElement(_wTag("r"))
rPr = xmlSubElem(xR, _wTag("rPr"))
if fmt & X_BLD:
xmlSubElem(rPr, _wTag("b"))
if fmt & X_ITA:
xmlSubElem(rPr, _wTag("i"))
if fmt & X_UND:
xmlSubElem(rPr, _wTag("u"), attrib={W_VAL: "single"})
if fmt & X_MRK:
xmlSubElem(rPr, _wTag("shd"), attrib={
_wTag("fill"): _docXCol(self._theme.highlight), W_VAL: "clear",
})
if fmt & X_DEL:
xmlSubElem(rPr, _wTag("strike"))
if fmt & X_SUP:
xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
if fmt & X_SUB:
xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "subscript"})
if fmt & X_COL and (color := self._classes.get(fClass)):
xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(color)})
if isinstance(text, str):
for segment in RX_TEXT.split(stripEscape(text)):
if segment == "\n":
xmlSubElem(xR, _wTag("br"))
elif segment == "\t":
xmlSubElem(xR, _wTag("tab"))
elif segment:
_wText(xR, segment)
if fmt & X_HRF and fLink:
xmlSubElem(rPr, _wTag("rStyle"), attrib={W_VAL: "InternetLink"})
xH = xmlElement(_wTag("hyperlink"), attrib={
_mkTag("r", "id"): self._appendExternalRel(fLink),
})
xH.append(xR)
return xH
return xR
##
# DocX Content
##
def _generateFootnote(self, key: str) -> ET.Element | None:
"""Generate a footnote XML object."""
if key in self._footnotes:
idx = len(self._usedNotes) + 1
xR = xmlElement(_wTag("r"))
rPr = xmlSubElem(xR, _wTag("rPr"))
xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
xmlSubElem(xR, _wTag("footnoteReference"), attrib={_wTag("id"): str(idx)})
self._usedNotes[key] = idx
return xR
return None
def _generateField(self, key: str, fmt: int) -> ET.Element | None:
"""Generate a data field XML object."""
if key and (field := key.partition(":")[2]):
xR = self._textRunToXml(None, fmt, "", "")
xT = _wText(xR, "0")
self._usedFields.append((xT, field))
return xR
return None
def _generateStyles(self) -> None:
"""Generate usable styles."""
styles: list[DocXParStyle] = []
hScale = self._scaleHeads
hColor = _docXCol(self._theme.head) if self._colorHeads else None
fSz = self._fontSize
# 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=self._defaultAlign,
))
# Add Title
styles.append(DocXParStyle(
name="Title",
styleId=S_TITLE,
size=(nwStyles.H_SIZES[0] * fSz) if hScale else fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
before=fSz * self._marginTitle[0],
after=fSz * self._marginTitle[1],
line=fSz * self._lineHeight,
level=0,
bold=self._boldHeads,
))
# Add Heading 1
styles.append(DocXParStyle(
name="Heading 1",
styleId=S_HEAD1,
size=(nwStyles.H_SIZES[1] * fSz) if hScale else fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
before=fSz * self._marginHead1[0],
after=fSz * self._marginHead1[1],
line=fSz * self._lineHeight,
level=0,
color=hColor,
bold=self._boldHeads,
))
# Add Heading 2
styles.append(DocXParStyle(
name="Heading 2",
styleId=S_HEAD2,
size=(nwStyles.H_SIZES[2] * fSz) if hScale else fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
before=fSz * self._marginHead2[0],
after=fSz * self._marginHead2[1],
line=fSz * self._lineHeight,
level=1,
color=hColor,
bold=self._boldHeads,
))
# Add Heading 3
styles.append(DocXParStyle(
name="Heading 3",
styleId=S_HEAD3,
size=(nwStyles.H_SIZES[3] * fSz) if hScale else fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
before=fSz * self._marginHead3[0],
after=fSz * self._marginHead3[1],
line=fSz * self._lineHeight,
level=1,
color=hColor,
bold=self._boldHeads,
))
# Add Heading 4
styles.append(DocXParStyle(
name="Heading 4",
styleId=S_HEAD4,
size=(nwStyles.H_SIZES[4] * fSz) if hScale else fSz,
basedOn=S_NORM,
nextStyle=S_NORM,
before=fSz * self._marginHead4[0],
after=fSz * self._marginHead4[1],
line=fSz * self._lineHeight,
level=1,
color=hColor,
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="Meta Text",
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,
))
# Header
styles.append(DocXParStyle(
name="Header",
styleId=S_HEAD,
size=fSz,
basedOn=S_NORM,
align="right",
))
# Footnote
styles.append(DocXParStyle(
name="Footnote Text",
styleId=S_FNOTE,
size=nwStyles.T_SMALL * fSz,
basedOn=S_NORM,
before=0.0,
after=fSz * self._marginFoot[1],
left=fSz * self._marginFoot[0],
line=fSz * self._lineHeight,
))
# Add to Cache
for style in styles:
self._styles[style.styleId] = style
def _nextRelId(self) -> str:
"""Generate the next unique rId."""
return f"rId{len(self._rels) + 1}"
def _appendExternalRel(self, target: str) -> str:
"""Append external rel to the registry."""
if rel := self._rels.get(target):
return rel.rId
rId = self._nextRelId()
self._rels[target] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/hyperlink",
targetMode="External"
)
return rId
def _appXml(self) -> str:
"""Populate app.xml."""
rId = self._nextRelId()
xRoot = xmlElement("Properties", attrib={
"xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties"
})
self._rels["app.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/extended-properties",
)
self._files["app.xml"] = DocXXmlFile(
xml=xRoot,
path="docProps",
contentType="application/vnd.openxmlformats-officedocument.extended-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 rId
def _coreXml(self) -> str:
"""Populate app.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_mkTag("cp", "coreProperties"))
self._rels["core.xml"] = DocXXmlRel(
rId=rId,
relType=f"{OOXML_SCM}/package/2006/relationships/metadata/core-properties",
)
self._files["core.xml"] = DocXXmlFile(
xml=xRoot,
path="docProps",
contentType="application/vnd.openxmlformats-package.core-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", "language"), self._dLocale.name())
xmlSubElem(xRoot, _mkTag("cp", "revision"), str(self._project.data.saveCount))
xmlSubElem(xRoot, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
return rId
def _stylesXml(self) -> str:
"""Populate styles.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("styles"))
self._rels["styles.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/styles",
)
self._files["styles.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.styles+xml",
)
# Default Style
xStyl = xmlSubElem(xRoot, _wTag("docDefaults"))
xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
xRPr = xmlSubElem(xRDef, _wTag("rPr"))
xPPr = xmlSubElem(xPDef, _wTag("pPr"))
size = str(int(2.0 * self._fontSize))
line = str(int(20.0 * self._lineHeight * self._fontSize))
xmlSubElem(xRPr, _wTag("rFonts"), attrib={
_wTag("ascii"): self._fontFamily,
_wTag("hAnsi"): self._fontFamily,
_wTag("cs"): self._fontFamily,
})
xmlSubElem(xRPr, _wTag("sz"), attrib={W_VAL: size})
xmlSubElem(xRPr, _wTag("szCs"), attrib={W_VAL: size})
xmlSubElem(xRPr, _wTag("lang"), attrib={W_VAL: self._dLocale.name()})
xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
# Paragraph Styles
for style in self._styles.values():
sAttr = {}
sAttr[_wTag("type")] = "paragraph"
sAttr[_wTag("styleId")] = style.styleId
if style.default:
sAttr[_wTag("default")] = "1"
size = firstFloat(style.size, self._fontSize)
xStyl = xmlSubElem(xRoot, _wTag("style"), attrib=sAttr)
xmlSubElem(xStyl, _wTag("name"), attrib={W_VAL: style.name})
if style.basedOn:
xmlSubElem(xStyl, _wTag("basedOn"), attrib={W_VAL: style.basedOn})
if style.nextStyle:
xmlSubElem(xStyl, _wTag("next"), attrib={W_VAL: style.nextStyle})
# pPr Node
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))),
_wTag("lineRule"): "auto",
})
if style.left is not None:
xmlSubElem(pPr, _wTag("ind"), attrib={
_wTag("left"): str(int(20.0 * style.left)),
})
if style.align:
xmlSubElem(pPr, _wTag("jc"), attrib={W_VAL: style.align})
if style.level is not None:
xmlSubElem(pPr, _wTag("outlineLvl"), attrib={W_VAL: str(style.level)})
# rPr Node
rPr = xmlSubElem(xStyl, _wTag("rPr"))
if style.bold:
xmlSubElem(rPr, _wTag("b"))
if style.color:
xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: style.color})
xmlSubElem(rPr, _wTag("sz"), attrib={W_VAL: str(int(2.0 * size))})
xmlSubElem(rPr, _wTag("szCs"), attrib={W_VAL: str(int(2.0 * size))})
# Character Style
xStyl = xmlSubElem(xRoot, _wTag("style"), attrib={
_wTag("type"): "character",
_wTag("styleId"): "InternetLink"
})
xmlSubElem(xStyl, _wTag("name"), attrib={W_VAL: "Hyperlink"})
rPr = xmlSubElem(xStyl, _wTag("rPr"))
xmlSubElem(rPr, _wTag("color"), attrib={W_VAL: _docXCol(self._theme.link)})
xmlSubElem(rPr, _wTag("u"), attrib={W_VAL: "single"})
return rId
def _defaultHeaderXml(self) -> str:
"""Populate header1.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("hdr"))
self._rels["header1.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/header",
)
self._files["header1.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.header+xml",
)
xP = xmlSubElem(xRoot, _wTag("p"))
xPPr = xmlSubElem(xP, _wTag("pPr"))
xmlSubElem(xPPr, _wTag("pStyle"), attrib={W_VAL: S_HEAD})
xmlSubElem(xPPr, _wTag("jc"), attrib={W_VAL: "right"})
xmlSubElem(xPPr, _wTag("rPr"))
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)
wFldCT = _wTag("fldCharType")
if pre:
xR = xmlSubElem(xP, _wTag("r"))
_wText(xR, pre)
if page:
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "begin"})
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("instrText"), "PAGE", attrib={_mkTag("xml", "space"): "preserve"})
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "separate"})
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("fldChar"), attrib={wFldCT: "end"})
if post:
xR = xmlSubElem(xP, _wTag("r"))
_wText(xR, post)
return rId
def _firstHeaderXml(self) -> str:
"""Populate header2.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("hdr"))
self._rels["header2.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/header",
)
self._files["header2.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.header+xml",
)
xP = xmlSubElem(xRoot, _wTag("p"))
xPPr = xmlSubElem(xP, _wTag("pPr"))
xmlSubElem(xPPr, _wTag("pStyle"), attrib={W_VAL: S_HEAD})
xmlSubElem(xPPr, _wTag("jc"), attrib={W_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 = xmlElement(_wTag("document"))
xBody = xmlSubElem(xRoot, _wTag("body"))
self._rels["document.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/officeDocument",
)
self._files["document.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.document.main+xml",
)
# 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]
par.setPageBreakBefore(False)
prev.setPageBreakAfter(True)
pars.append(par)
# Replace fields if there are stats available
if self._usedFields and self._counts:
for xField, field in self._usedFields:
if (value := self._counts.get(field)) is not None:
xField.text = self._formatInt(value)
# Write Paragraphs
for par in pars:
par.toXml(xBody)
# 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={
W_VAL: "decimal",
})
xmlSubElem(xSect, _wTag("pgSz"), attrib={
_wTag("w"): str(self._pageSize.width()),
_wTag("h"): str(self._pageSize.height()),
_wTag("orient"): "portrait",
})
xmlSubElem(xSect, _wTag("pgMar"), attrib={
_wTag("top"): str(self._pageMargins.top()),
_wTag("right"): str(self._pageMargins.right()),
_wTag("bottom"): str(self._pageMargins.bottom()),
_wTag("left"): str(self._pageMargins.left()),
_wTag("header"): str(self._pageMargins.top() - int(35.0*self._fontSize)),
_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
def _footnotesXml(self) -> str:
"""Populate footnotes.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("footnotes"))
self._rels["footnotes.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/footnotes",
)
self._files["footnotes.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.footnotes+xml",
)
for key, idx in self._usedNotes.items():
par = DocXParagraph()
par.setIsFootnote(True)
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(idx)}))
return rId
def _fontTableXml(self) -> str:
"""Populate fontTable.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("fonts"))
self._rels["fontTable.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/fontTable",
)
self._files["fontTable.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.fontTable+xml",
)
xFont = xmlSubElem(xRoot, _wTag("font"), attrib={
_wTag("name"): self._textFont.family(),
})
xmlSubElem(xFont, _wTag("pitch"), attrib={
W_VAL: "fixed" if self._textFont.fixedPitch() else "variable",
})
return rId
def _settingsXml(self) -> str:
"""Populate settings.xml."""
rId = self._nextRelId()
xRoot = xmlElement(_wTag("settings"))
self._rels["settings.xml"] = DocXXmlRel(
rId=rId,
relType=f"{RELS_BASE}/settings",
)
self._files["settings.xml"] = DocXXmlFile(
xml=xRoot,
path="word",
contentType=f"{WORD_BASE}.settings+xml",
)
xSet = xmlSubElem(xRoot, _wTag("footnotePr"))
xmlSubElem(xSet, _wTag("numFmt"), attrib={W_VAL: "decimal"})
xSet = xmlSubElem(xRoot, _wTag("compat"))
xmlSubElem(xSet, _wTag("compatSetting"), attrib={
_wTag("name"): "compatibilityMode",
_wTag("uri"): "http://schemas.microsoft.com/office/word",
W_VAL: "12",
})
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:]}",
W_VAL: str(value),
})
return rId
class DocXParagraph:
"""DocX Text Paragraph.
This class holds a single paragraph of a DocX document.
"""
__slots__ = (
"_bottomMargin", "_breakAfter", "_breakBefore", "_content",
"_footnoteRef", "_indentFirst", "_leftMargin", "_rightMargin",
"_style", "_textAlign", "_topMargin",
)
def __init__(self) -> None:
self._content: list[ET.Element] = []
self._style: DocXParStyle | None = None
self._textAlign: str | None = None
self._topMargin: float | None = None
self._bottomMargin: float | None = None
self._leftMargin: float | None = None
self._rightMargin: float | None = None
self._indentFirst = False
self._breakBefore = False
self._breakAfter = False
self._footnoteRef = False
##
# Properties
##
@property
def pageBreakBefore(self) -> bool:
"""Has page break before."""
return self._breakBefore
##
# Setters
##
def setStyle(self, style: DocXParStyle | None) -> None:
"""Set the paragraph style."""
self._style = style
def setAlignment(self, value: str) -> None:
"""Set paragraph alignment."""
if value in ("left", "center", "right", "both"):
self._textAlign = value
def setMarginTop(self, value: float) -> None:
"""Set margin above in pt."""
self._topMargin = value
def setMarginBottom(self, value: float) -> None:
"""Set margin below in pt."""
self._bottomMargin = value
def setMarginLeft(self, value: float) -> None:
"""Set margin left in pt."""
self._leftMargin = value
def setMarginRight(self, value: float) -> None:
"""Set margin right in pt."""
self._rightMargin = value
def setIndentFirst(self, state: bool) -> None:
"""Set first line indent."""
self._indentFirst = state
def setPageBreakBefore(self, state: bool) -> None:
"""Set page break before flag."""
self._breakBefore = state
def setPageBreakAfter(self, state: bool) -> None:
"""Set page break after flag."""
self._breakAfter = state
def setIsFootnote(self, state: bool) -> None:
"""Set is footnote flag."""
self._footnoteRef = state
##
# Methods
##
def addContent(self, run: ET.Element) -> None:
"""Add a run segment to the paragraph."""
self._content.append(run)
def toXml(self, body: ET.Element) -> None:
"""Generate the XML. Call after all content is set."""
if style := self._style:
xP = 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(xP, _wTag("pPr"))
xmlSubElem(pPr, _wTag("pStyle"), attrib={W_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))),
_wTag("lineRule"): "auto",
})
if indent:
xmlSubElem(pPr, _wTag("ind"), attrib=indent)
if self._textAlign:
xmlSubElem(pPr, _wTag("jc"), attrib={W_VAL: self._textAlign})
# Text
if self._footnoteRef:
xR = xmlSubElem(xP, _wTag("r"))
rPr = xmlSubElem(xR, _wTag("rPr"))
xmlSubElem(rPr, _wTag("vertAlign"), attrib={W_VAL: "superscript"})
xmlSubElem(xR, _wTag("footnoteRef"))
if self._breakBefore:
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("br"), attrib={_wTag("type"): "page"})
for xR in self._content:
xP.append(xR)
if self._breakAfter:
xR = xmlSubElem(xP, _wTag("r"))
xmlSubElem(xR, _wTag("br"), attrib={_wTag("type"): "page"})