Files
novelWriter/novelwriter/formats/todocx.py
T
Veronica Berglyd Olsen 787e1a46d1 Add DocX test coverage
2024-10-21 16:59:43 +02:00

1138 lines
38 KiB
Python
Raw 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 20182024, 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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import NamedTuple
from zipfile import ZIP_DEFLATED, ZipFile
from PyQt5.QtCore import QMarginsF, QSizeF
from novelwriter import __version__
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
logger = logging.getLogger(__name__)
# RegEx
RX_TEXT = re.compile(r"([\n\t])", re.UNICODE)
# 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 = {
"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",
"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"{{{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
# 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
# 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"
# 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 DocXXmlFile(NamedTuple):
xml: ET.Element
rId: str
path: str
relType: str
contentType: str
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
indentFirst: float | None = None
indentHangning: 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._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] = []
self._files: dict[str, DocXXmlFile] = {}
self._styles: dict[str, DocXParStyle] = {}
self._usedNotes: dict[str, int] = {}
return
##
# Setters
##
def setLanguage(self, language: str | None) -> None:
"""Set language for the document."""
if language:
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."""
self._pageSize = QSizeF(width, height)
self._pageMargins = QMarginsF(left, top, right, bottom)
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 initDocument(self) -> None:
"""Initialises the DocX document structure."""
self._fontFamily = self._textFont.family()
self._fontSize = self._textFont.pointSizeF()
self._generateStyles()
return
def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case
bIndent = self._fontSize * self._blockIndent
for tType, _, tText, tFormat, tStyle in self._tokens:
# Create Paragraph
par = DocXParagraph()
self._pars.append(par)
# 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:
par.setAlignment("both")
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_T:
par.setIndentFirst(True)
if tStyle & self.A_IND_L:
par.setMarginLeft(bIndent)
if tStyle & self.A_IND_R:
par.setMarginRight(bIndent)
# Process Text Types
if tType == self.T_TEXT:
if self._doJustify and "\n" in tText:
par.overrideJustify(self._defaultAlign)
self._processFragments(par, S_NORM, tText, tFormat)
elif tType == self.T_TITLE:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_TITLE, tHead, tFormat)
elif tType == self.T_HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD1, tHead, tFormat)
elif tType == self.T_HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD2, tHead, tFormat)
elif tType == self.T_HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD3, tHead, tFormat)
elif tType == self.T_HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "\n")
self._processFragments(par, S_HEAD4, tHead, tFormat)
elif tType == self.T_SEP:
self._processFragments(par, S_SEP, tText)
elif tType == self.T_SKIP:
self._processFragments(par, S_NORM, "")
elif tType == self.T_SYNOPSIS and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_SHORT and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_COMMENT and self._doComments:
tTemp, tFmt = self._formatComments(tText, tFormat)
self._processFragments(par, S_META, tTemp, tFmt)
elif tType == self.T_KEYWORD and self._doKeywords:
tTemp, tFmt = self._formatKeywords(tText)
self._processFragments(par, S_META, tTemp, tFmt)
return
def closeDocument(self) -> None:
"""Generate all the XML."""
self._coreXml()
self._appXml()
self._stylesXml()
fId = None
dId = None
if self._headerFormat:
dId = self._defaultHeaderXml()
fId = self._firstHeaderXml()
self._documentXml(fId, dId)
self._settingsXml()
if self._usedNotes:
self._footnotesXml()
return
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 = 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))
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": 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, 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", 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():
xmlToZip(f"{entry.path}/{name}", entry.xml, outZip)
xmlToZip("[Content_Types].xml", dTypes, outZip)
return
##
# Internal Functions
##
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 _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
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))
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 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))
return
def _textRunToXml(self, text: str, fmt: int) -> ET.Element:
"""Encode the text run into XML."""
run = ET.Element(_wTag("r"))
rPr = xmlSubElem(run, _wTag("rPr"))
if fmt & X_BLD == X_BLD:
xmlSubElem(rPr, _wTag("b"))
if fmt & X_ITA == X_ITA:
xmlSubElem(rPr, _wTag("i"))
if fmt & X_UND == X_UND:
xmlSubElem(rPr, _wTag("u"), attrib={_wTag("val"): "single"})
if fmt & X_MRK == X_MRK:
xmlSubElem(rPr, _wTag("shd"), attrib={
_wTag("fill"): COL_MARK_TXT, _wTag("val"): "clear",
})
if fmt & X_DEL == X_DEL:
xmlSubElem(rPr, _wTag("strike"))
if fmt & X_SUP == X_SUP:
xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "superscript"})
if fmt & X_SUB == X_SUB:
xmlSubElem(rPr, _wTag("vertAlign"), attrib={_wTag("val"): "subscript"})
if fmt & X_DLG == X_DLG:
xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_M})
if fmt & X_DLA == X_DLA:
xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): COL_DIALOG_A})
for segment in RX_TEXT.split(text):
if segment == "\n":
xmlSubElem(run, _wTag("br"))
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
##
# DocX Content
##
def _generateFootnote(self, key: str) -> ET.Element | None:
"""Generate a footnote XML object."""
if self._footnotes.get(key):
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[key] = idx
return run
return None
def _generateStyles(self) -> None:
"""Generate usable styles."""
styles: list[DocXParStyle] = []
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
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="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,
color=COL_META_TXT,
))
# 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=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
return
def _nextRelId(self) -> str:
"""Generate the next unique rId."""
return f"rId{len(self._files) + 1}"
def _appXml(self) -> str:
"""Populate app.xml."""
rId = self._nextRelId()
xRoot = ET.Element("Properties", attrib={
"xmlns": f"{OOXML_SCM}/officeDocument/2006/extended-properties"
})
self._files["app.xml"] = DocXXmlFile(
xml=xRoot,
rId=rId,
path="docProps",
relType=f"{RELS_BASE}/extended-properties",
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 = ET.Element("coreProperties")
self._files["core.xml"] = DocXXmlFile(
xml=xRoot,
rId=rId,
path="docProps",
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")
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 rId
def _stylesXml(self) -> str:
"""Populate styles.xml."""
rId = self._nextRelId()
xRoot = ET.Element(_wTag("styles"))
self._files["styles.xml"] = DocXXmlFile(
xml=xRoot,
rId=rId,
path="word",
relType=f"{RELS_BASE}/styles",
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={_wTag("val"): size})
xmlSubElem(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
# Paragraph Styles
for style in self._styles.values():
sAttr = {}
sAttr[_wTag("type")] = "paragraph"
sAttr[_wTag("styleId")] = style.styleId
if style.default:
sAttr[_wTag("default")] = "1"
size = firstFloat(style.size, self._fontSize)
xStyl = xmlSubElem(xRoot, _wTag("style"), attrib=sAttr)
xmlSubElem(xStyl, _wTag("name"), attrib={_wTag("val"): style.name})
if style.basedOn:
xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
if style.nextStyle:
xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
# 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))),
})
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:
xmlSubElem(pPr, _wTag("outlineLvl"), attrib={_wTag("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={_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 rId
def _defaultHeaderXml(self) -> str:
"""Populate header1.xml."""
rId = self._nextRelId()
xRoot = ET.Element(_wTag("hdr"))
self._files["header1.xml"] = DocXXmlFile(
xml=xRoot,
rId=rId,
path="word",
relType=f"{RELS_BASE}/header",
contentType=f"{WORD_BASE}.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}.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}.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)
# Write Paragraphs
for par in pars:
par.toXml(xBody)
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
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 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(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
class DocXParagraph:
__slots__ = (
"_content", "_style", "_textAlign",
"_topMargin", "_bottomMargin", "_leftMargin", "_rightMargin",
"_indentFirst", "_breakBefore", "_breakAfter",
)
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
return
##
# 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
return
def setAlignment(self, value: str) -> None:
"""Set paragraph alignment."""
if value in ("left", "center", "right", "both"):
self._textAlign = value
return
def setMarginTop(self, value: float) -> None:
"""Set margin above in pt."""
self._topMargin = value
return
def setMarginBottom(self, value: float) -> None:
"""Set margin below in pt."""
self._bottomMargin = value
return
def setMarginLeft(self, value: float) -> None:
"""Set margin left in pt."""
self._leftMargin = value
return
def setMarginRight(self, value: float) -> None:
"""Set margin right in pt."""
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
return
def setPageBreakAfter(self, state: bool) -> None:
"""Set page break after flag."""
self._breakAfter = state
return
##
# 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)
return
def toXml(self, body: ET.Element) -> None:
"""Called after all content is set."""
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})
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})
if indent:
xmlSubElem(pPr, _wTag("ind"), attrib=indent)
# 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