Files
novelWriter/novelwriter/formats/todocx.py
T
2024-10-19 20:14:01 +02:00

897 lines
30 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 ZipFile
from novelwriter import __version__
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
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"
REL_CORE = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"
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/",
"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"{{{W_NS}}}{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 = "TextMeta"
# 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 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
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)
# 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._fontFamily = "Liberation Serif"
self._fontSize = 12.0
self._dLanguage = "en-GB"
# Data Variables
self._styles: dict[str, DocXParStyle] = {}
self._pars: list[DocXParagraph] = []
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."""
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._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()
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.setLeftMargin(bIndent)
if tStyle & self.A_IND_R:
par.setRightMargin(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:
"""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
def saveDocument(self, path: Path) -> None:
"""Save the data to a .docx file."""
timeStamp = datetime.now().isoformat(sep="T", timespec="seconds")
# .rels
dRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId1", "Type": REL_CORE, "Target": "docProps/core.xml",
})
xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId2", "Type": f"{REL_BASE}/extended-properties", "Target": "docProps/app.xml",
})
xmlSubElem(dRels, "Relationship", attrib={
"Id": "rId3", "Type": f"{REL_BASE}/officeDocument", "Target": "word/document.xml",
})
# core.xml
dCore = ET.Element("coreProperties")
tsAttr = {_mkTag("xsi", "type"): "dcterms:W3CDTF"}
xmlSubElem(dCore, _mkTag("dcterms", "created"), timeStamp, attrib=tsAttr)
xmlSubElem(dCore, _mkTag("dcterms", "modified"), timeStamp, attrib=tsAttr)
xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
xmlSubElem(dCore, _mkTag("dc", "title"), self._project.data.name)
xmlSubElem(dCore, _mkTag("dc", "creator"), self._project.data.author)
xmlSubElem(dCore, _mkTag("dc", "language"), self._dLanguage)
xmlSubElem(dCore, _mkTag("cp", "revision"), str(self._project.data.saveCount))
xmlSubElem(dCore, _mkTag("cp", "lastModifiedBy"), self._project.data.author)
# app.xml
dApp = ET.Element("Properties", attrib={"xmlns": PROPS_NS})
xmlSubElem(dApp, "TotalTime", self._project.data.editTime // 60)
xmlSubElem(dApp, "Application", f"novelWriter/{__version__}")
if count := self._counts.get("allWords"):
xmlSubElem(dApp, "Words", count)
if count := self._counts.get("textWordChars"):
xmlSubElem(dApp, "Characters", count)
if count := self._counts.get("textChars"):
xmlSubElem(dApp, "CharactersWithSpaces", count)
if count := self._counts.get("paragraphCount"):
xmlSubElem(dApp, "Paragraphs", count)
# document.xml.rels
dDRels = ET.Element("Relationships", attrib={"xmlns": RELS_NS})
xmlSubElem(dDRels, "Relationship", attrib={
"Id": "rId1", "Type": f"{REL_BASE}/styles", "Target": "styles.xml",
})
# [Content_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",
})
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("word/styles.xml", self._dStyl, outZip)
xmlToZip("[Content_Types].xml", dCont, 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
fStart = 0
for fPos, fFmt, fData in tFmt or []:
par.addContent(self._textRunToXml(text[fStart:fPos], 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 fmt == self.FMT_FNOTE:
# xNode = self._generateFootnote(fData)
elif fFmt == self.FMT_STRIP:
pass
# Move pos for next pass
fStart = fPos
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
##
# Style Elements
##
def _defaultStyles(self) -> None:
"""Set the default styles."""
xStyl = xmlSubElem(self._dStyl, _wTag("docDefaults"))
xRDef = xmlSubElem(xStyl, _wTag("rPrDefault"))
xPDef = xmlSubElem(xStyl, _wTag("pPrDefault"))
xRPr = xmlSubElem(xRDef, _wTag("rPr"))
xPPr = xmlSubElem(xPDef, _wTag("pPr"))
size = str(int(2.0 * self._fontSize))
line = str(int(20.0 * self._lineHeight * self._fontSize))
xmlSubElem(xRPr, _wTag("rFonts"), attrib={
_wTag("ascii"): self._fontFamily,
_wTag("hAnsi"): self._fontFamily,
_wTag("cs"): self._fontFamily,
})
xmlSubElem(xRPr, _wTag("sz"), attrib={_wTag("val"): size})
xmlSubElem(xRPr, _wTag("szCs"), attrib={_wTag("val"): size})
xmlSubElem(xRPr, _wTag("lang"), attrib={_wTag("val"): self._dLanguage})
xmlSubElem(xPPr, _wTag("spacing"), attrib={_wTag("line"): line})
return
def _useableStyles(self) -> None:
"""Set the usable styles."""
hScale = self._scaleHeads
hColor = self._colorHeads
fSz = self._fontSize
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
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,
))
# 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,
))
# 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,
))
return
def _addParStyle(self, style: DocXParStyle) -> None:
"""Add a paragraph style."""
sAttr = {}
sAttr[_wTag("type")] = "paragraph"
sAttr[_wTag("styleId")] = style.styleId
if style.default:
sAttr[_wTag("default")] = "1"
size = firstFloat(style.size, self._fontSize)
xStyl = xmlSubElem(self._dStyl, _wTag("style"), attrib=sAttr)
xmlSubElem(xStyl, _wTag("name"), attrib={_wTag("val"): style.name})
if style.basedOn:
xmlSubElem(xStyl, _wTag("basedOn"), attrib={_wTag("val"): style.basedOn})
if style.nextStyle:
xmlSubElem(xStyl, _wTag("next"), attrib={_wTag("val"): style.nextStyle})
if style.level is not None:
xmlSubElem(xStyl, _wTag("outlineLvl"), attrib={_wTag("val"): str(style.level)})
pPr = xmlSubElem(xStyl, _wTag("pPr"))
xmlSubElem(pPr, _wTag("spacing"), attrib={
_wTag("before"): str(int(20.0 * firstFloat(style.before))),
_wTag("after"): str(int(20.0 * firstFloat(style.after))),
_wTag("line"): str(int(20.0 * firstFloat(style.line, size))),
})
if style.align:
xmlSubElem(pPr, _wTag("jc"), attrib={_wTag("val"): style.align})
rPr = xmlSubElem(xStyl, _wTag("rPr"))
xmlSubElem(rPr, _wTag("sz"), attrib={_wTag("val"): str(int(2.0 * size))})
xmlSubElem(rPr, _wTag("szCs"), attrib={_wTag("val"): str(int(2.0 * size))})
if style.color:
xmlSubElem(rPr, _wTag("color"), attrib={_wTag("val"): style.color})
if style.bold:
xmlSubElem(rPr, _wTag("b"))
self._styles[style.styleId] = style
return
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
@property
def pageBreakAfter(self) -> bool:
"""Has page break after."""
return self._breakAfter
##
# 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 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
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