""" novelWriter – ODT Text Converter ================================ File History: Created: 2021-01-26 [1.2b1] ToOdt Created: 2021-01-27 [1.2b1] ODTParagraphStyle Created: 2021-01-27 [1.2b1] ODTTextStyle Created: 2021-08-14 [1.5b1] XMLParagraph This file is a part of novelWriter Copyright 2018–2023, 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 hashlib import sha256 from pathlib import Path from zipfile import ZipFile from datetime import datetime from novelwriter import __version__ from novelwriter.common import xmlIndent from novelwriter.constants import nwHeadFmt, nwKeyWords, nwLabels from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape logger = logging.getLogger(__name__) # Main XML NameSpaces XML_NS = { "manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", "loext": "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0", "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", "dc": "http://purl.org/dc/elements/1.1/", } for ns, uri in XML_NS.items(): ET.register_namespace(ns, uri) def _mkTag(ns: str, tag: str) -> str: """Assemble namespace and tag name.""" uri = XML_NS.get(ns, "") if uri: return f"{{{uri}}}{tag}" logger.warning("Missing xml namespace '%s'", ns) return tag # Mimetype and Version X_MIME = "application/vnd.oasis.opendocument.text" X_VERS = "1.3" # Text Formatting Tags TAG_BR = _mkTag("text", "line-break") TAG_SPC = _mkTag("text", "s") TAG_NSPC = _mkTag("text", "c") TAG_TAB = _mkTag("text", "tab") TAG_SPAN = _mkTag("text", "span") TAG_STNM = _mkTag("text", "style-name") # Formatting Codes X_BLD = 0x01 # Bold format X_ITA = 0x02 # Italic format X_DEL = 0x04 # Strikethrough format # Formatting Masks M_BLD = ~X_BLD M_ITA = ~X_ITA M_DEL = ~X_DEL class ToOdt(Tokenizer): """Core: Open Document Writer Extend the Tokenizer class to writer Open Document files. The output should conform to the 1.3 Extended standard. Test with: https://odfvalidator.org/ """ def __init__(self, project: NWProject, isFlat: bool) -> None: super().__init__(project) self._isFlat = isFlat # Flat: .fodt, otherwise .odt self._dFlat = ET.Element("") # FODT file XML root self._dCont = ET.Element("") # ODT content.xml root self._dMeta = ET.Element("") # ODT meta.xml root self._dStyl = ET.Element("") # ODT styles.xml root self._xMeta = ET.Element("") # Office meta root self._xFont = ET.Element("") # Office font face declaration self._xFnt2 = ET.Element("") # Office font face declaration, secondary self._xStyl = ET.Element("") # Office styles root self._xAuto = ET.Element("") # Office auto-styles root self._xAut2 = ET.Element("") # Office auto-styles root, secondary self._xMast = ET.Element("") # Office master-styles root self._xBody = ET.Element("") # Office body root self._xText = ET.Element("") # Office text root self._mainPara = {} # User-accessible paragraph styles self._autoPara = {} # Auto-generated paragraph styles self._autoText = {} # Auto-generated text styles self._errData = [] # List of errors encountered # Properties self._textFont = "Liberation Serif" self._textSize = 12 self._textFixed = False self._colourHead = False self._headerText = "" # Internal self._fontFamily = "'Liberation Serif'" self._fontPitch = "variable" self._fSizeTitle = "30pt" self._fSizeHead1 = "24pt" self._fSizeHead2 = "20pt" self._fSizeHead3 = "16pt" self._fSizeHead4 = "14pt" self._fSizeHead = "14pt" self._fSizeText = "12pt" self._fLineHeight = "115%" self._fBlockIndent = "1.693cm" self._textAlign = "left" self._dLanguage = "en" self._dCountry = "GB" # Text Margins self._mTopTitle = "0.423cm" self._mTopHead1 = "0.423cm" self._mTopHead2 = "0.353cm" self._mTopHead3 = "0.247cm" self._mTopHead4 = "0.247cm" self._mTopHead = "0.423cm" self._mTopText = "0.000cm" self._mTopMeta = "0.000cm" self._mBotTitle = "0.212cm" self._mBotHead1 = "0.212cm" self._mBotHead2 = "0.212cm" self._mBotHead3 = "0.212cm" self._mBotHead4 = "0.212cm" self._mBotHead = "0.212cm" self._mBotText = "0.247cm" self._mBotMeta = "0.106cm" # Document Size and Margins self._mDocWidth = "21.0cm" self._mDocHeight = "29.7cm" self._mDocTop = "2.000cm" self._mDocBtm = "2.000cm" self._mDocLeft = "2.000cm" self._mDocRight = "2.000cm" # Colour self._colHead12 = None self._opaHead12 = None self._colHead34 = None self._opaHead34 = None self._colMetaTx = None self._opaMetaTx = None return ## # Setters ## def setLanguage(self, language: str) -> None: """Set language for the document.""" if language: langBits = language.split("_") self._dLanguage = langBits[0] if len(langBits) > 1: self._dCountry = langBits[1] return def setColourHeaders(self, state: bool) -> None: """Enable/disable coloured headings and comments.""" self._colourHead = state return def setPageLayout( self, width: int | float, height: int | float, top: int | float, bottom: int | float, left: int | float, right: int | float ) -> None: """Set the document page size and margins in millimetres.""" self._mDocWidth = f"{width/10.0:.3f}cm" self._mDocHeight = f"{height/10.0:.3f}cm" self._mDocTop = f"{top/10.0:.3f}cm" self._mDocBtm = f"{bottom/10.0:.3f}cm" self._mDocLeft = f"{left/10.0:.3f}cm" self._mDocRight = f"{right/10.0:.3f}cm" return ## # Class Methods ## def initDocument(self) -> None: """Initialises a new open document XML tree.""" # Initialise Variables # ==================== self._fontFamily = self._textFont if len(self._textFont.split()) > 1: self._fontFamily = f"'{self._textFont}'" self._fontPitch = "fixed" if self._textFixed else "variable" self._fSizeTitle = f"{round(2.50 * self._textSize):d}pt" self._fSizeHead1 = f"{round(2.00 * self._textSize):d}pt" self._fSizeHead2 = f"{round(1.60 * self._textSize):d}pt" self._fSizeHead3 = f"{round(1.30 * self._textSize):d}pt" self._fSizeHead4 = f"{round(1.15 * self._textSize):d}pt" self._fSizeHead = f"{round(1.15 * self._textSize):d}pt" self._fSizeText = f"{self._textSize:d}pt" mScale = self._lineHeight/1.15 self._mTopTitle = self._emToCm(mScale * self._marginTitle[0]) self._mTopHead1 = self._emToCm(mScale * self._marginHead1[0]) self._mTopHead2 = self._emToCm(mScale * self._marginHead2[0]) self._mTopHead3 = self._emToCm(mScale * self._marginHead3[0]) self._mTopHead4 = self._emToCm(mScale * self._marginHead4[0]) self._mTopHead = self._emToCm(mScale * self._marginHead4[0]) self._mTopText = self._emToCm(mScale * self._marginText[0]) self._mTopMeta = self._emToCm(mScale * self._marginMeta[0]) self._mBotTitle = self._emToCm(mScale * self._marginTitle[1]) self._mBotHead1 = self._emToCm(mScale * self._marginHead1[1]) self._mBotHead2 = self._emToCm(mScale * self._marginHead2[1]) self._mBotHead3 = self._emToCm(mScale * self._marginHead3[1]) self._mBotHead4 = self._emToCm(mScale * self._marginHead4[1]) self._mBotHead = self._emToCm(mScale * self._marginHead4[1]) self._mBotText = self._emToCm(mScale * self._marginText[1]) self._mBotMeta = self._emToCm(mScale * self._marginMeta[1]) if self._colourHead: self._colHead12 = "#2a6099" self._opaHead12 = "100%" self._colHead34 = "#444444" self._opaHead34 = "100%" self._colMetaTx = "#813709" self._opaMetaTx = "100%" self._fLineHeight = f"{round(100 * self._lineHeight):d}%" self._fBlockIndent = self._emToCm(self._blockIndent) self._textAlign = "justify" if self._doJustify else "left" # Clear Errors self._errData = [] # Document Header # =============== if self._headerText == "": theTitle = self._project.data.title or self._project.data.name theAuth = self._project.data.author self._headerText = f"{theTitle} / {theAuth} /" # Create Roots # ============ tAttr = {} tAttr[_mkTag("office", "version")] = X_VERS fAttr = {} fAttr[_mkTag("style", "name")] = self._textFont fAttr[_mkTag("style", "font-pitch")] = self._fontPitch if self._isFlat: # FODT File # ========= tAttr[_mkTag("office", "mimetype")] = X_MIME tFlat = _mkTag("office", "document") self._dFlat = ET.Element(tFlat, attrib=tAttr) self._xMeta = ET.SubElement(self._dFlat, _mkTag("office", "meta")) self._xFont = ET.SubElement(self._dFlat, _mkTag("office", "font-face-decls")) self._xStyl = ET.SubElement(self._dFlat, _mkTag("office", "styles")) self._xAuto = ET.SubElement(self._dFlat, _mkTag("office", "automatic-styles")) self._xMast = ET.SubElement(self._dFlat, _mkTag("office", "master-styles")) self._xBody = ET.SubElement(self._dFlat, _mkTag("office", "body")) ET.SubElement(self._xFont, _mkTag("style", "font-face"), attrib=fAttr) else: # ODT File # ======== tCont = _mkTag("office", "document-content") tMeta = _mkTag("office", "document-meta") tStyl = _mkTag("office", "document-styles") # content.xml self._dCont = ET.Element(tCont, attrib=tAttr) self._xFont = ET.SubElement(self._dCont, _mkTag("office", "font-face-decls")) self._xAuto = ET.SubElement(self._dCont, _mkTag("office", "automatic-styles")) self._xBody = ET.SubElement(self._dCont, _mkTag("office", "body")) # meta.xml self._dMeta = ET.Element(tMeta, attrib=tAttr) self._xMeta = ET.SubElement(self._dMeta, _mkTag("office", "meta")) # styles.xml self._dStyl = ET.Element(tStyl, attrib=tAttr) self._xFnt2 = ET.SubElement(self._dStyl, _mkTag("office", "font-face-decls")) self._xStyl = ET.SubElement(self._dStyl, _mkTag("office", "styles")) self._xAut2 = ET.SubElement(self._dStyl, _mkTag("office", "automatic-styles")) self._xMast = ET.SubElement(self._dStyl, _mkTag("office", "master-styles")) ET.SubElement(self._xFont, _mkTag("style", "font-face"), attrib=fAttr) ET.SubElement(self._xFnt2, _mkTag("style", "font-face"), attrib=fAttr) # Finalise # ======== self._xText = ET.SubElement(self._xBody, _mkTag("office", "text")) 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) # 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" # Dublin Core Meta Data xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "title")) xMeta.text = self._project.data.title or 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 self._pageStyles() self._defaultStyles() self._useableStyles() self._writeHeader() return def doConvert(self) -> None: """Convert the list of text tokens into XML elements.""" self._result = "" # Not used, but cleared just in case odtTags = { self.FMT_B_B: "_B", # Bold open format self.FMT_B_E: "b_", # Bold close format self.FMT_I_B: "I", # Italic open format self.FMT_I_E: "i", # Italic close format self.FMT_D_B: "_S", # Strikethrough open format self.FMT_D_E: "s_", # Strikethrough close format } fmt = [] para = [] pStyle = None for tType, _, tText, tFormat, tStyle in self._tokens: # Styles oStyle = ODTParagraphStyle() if tStyle is not None: if tStyle & self.A_LEFT: oStyle.setTextAlign("left") elif tStyle & self.A_RIGHT: oStyle.setTextAlign("right") elif tStyle & self.A_CENTRE: oStyle.setTextAlign("center") elif tStyle & self.A_JUSTIFY: oStyle.setTextAlign("justify") if tStyle & self.A_PBB: oStyle.setBreakBefore("page") if tStyle & self.A_PBA: oStyle.setBreakAfter("page") if tStyle & self.A_Z_BTMMRG: oStyle.setMarginBottom("0.000cm") if tStyle & self.A_Z_TOPMRG: oStyle.setMarginTop("0.000cm") 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_EMPTY: if len(para) > 1 and pStyle is not None: if self._doJustify: pStyle.setTextAlign("left") if len(para) > 0 and pStyle is not None: tTemp = "\n".join(para) fTemp = " ".join(fmt) tTxt = tTemp.rstrip() tFmt = fTemp[:len(tTxt)] self._addTextPar("Text_20_body", pStyle, tTxt, tFmt=tFmt) fmt = [] para = [] pStyle = None elif tType == self.T_TITLE: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Title", oStyle, tHead, isHead=False) # Title must be text:p elif tType == self.T_UNNUM: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Heading_20_2", oStyle, tHead, isHead=True, oLevel="2") elif tType == self.T_HEAD1: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Heading_20_1", oStyle, tHead, isHead=True, oLevel="1") elif tType == self.T_HEAD2: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Heading_20_2", oStyle, tHead, isHead=True, oLevel="2") elif tType == self.T_HEAD3: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Heading_20_3", oStyle, tHead, isHead=True, oLevel="3") elif tType == self.T_HEAD4: tHead = tText.replace(nwHeadFmt.BR, "\n") self._addTextPar("Heading_20_4", oStyle, tHead, isHead=True, oLevel="4") elif tType == self.T_SEP: self._addTextPar("Separator", oStyle, tText) elif tType == self.T_SKIP: self._addTextPar("Separator", oStyle, "") elif tType == self.T_TEXT: if pStyle is None: pStyle = oStyle tFmt = " "*len(tText) for xPos, xLen, xFmt in tFormat: tFmt = tFmt[:xPos] + odtTags[xFmt] + tFmt[xPos+xLen:] tTxt = tText.rstrip() tFmt = tFmt[:len(tTxt)] para.append(tTxt) fmt.append(tFmt) elif tType == self.T_SYNOPSIS and self._doSynopsis: tTemp, fTemp = self._formatSynopsis(tText) self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) elif tType == self.T_COMMENT and self._doComments: tTemp, fTemp = self._formatComments(tText) self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) elif tType == self.T_KEYWORD and self._doKeywords: tTemp, fTemp = self._formatKeywords(tText) self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) return def closeDocument(self) -> None: """Pack the styles of the XML document.""" # Build the auto-generated styles for styleName, styleObj in self._autoPara.values(): styleObj.packXML(self._xAuto, styleName) for styleName, styleObj in self._autoText.values(): styleObj.packXML(self._xAuto, styleName) return def saveFlatXML(self, path: str | Path) -> None: """Save the data to an .fodt file.""" with open(path, mode="wb") as fObj: xml = ET.ElementTree(self._dFlat) xmlIndent(xml) xml.write(fObj, encoding="utf-8", xml_declaration=True) logger.info("Wrote file: %s", path) return def saveOpenDocText(self, path: str | Path) -> None: """Save the data to an .odt file.""" mMani = _mkTag("manifest", "manifest") mVers = _mkTag("manifest", "version") mPath = _mkTag("manifest", "full-path") mType = _mkTag("manifest", "media-type") mFile = _mkTag("manifest", "file-entry") xMani = ET.Element(mMani, attrib={mVers: X_VERS}) ET.SubElement(xMani, mFile, attrib={mPath: "/", mVers: X_VERS, mType: X_MIME}) ET.SubElement(xMani, mFile, attrib={mPath: "settings.xml", mType: "text/xml"}) ET.SubElement(xMani, mFile, attrib={mPath: "content.xml", mType: "text/xml"}) ET.SubElement(xMani, mFile, attrib={mPath: "meta.xml", mType: "text/xml"}) ET.SubElement(xMani, mFile, attrib={mPath: "styles.xml", mType: "text/xml"}) oRoot = _mkTag("office", "document-settings") oVers = _mkTag("office", "version") xSett = ET.Element(oRoot, attrib={oVers: X_VERS}) def putInZip(name, xObj, zipObj): 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) logger.info("Wrote file: %s", path) return ## # Internal Functions ## def _formatSynopsis(self, text: str) -> tuple[str, str]: """Apply formatting to synopsis lines.""" sSynop = self._localLookup("Synopsis") rTxt = "**{0}:** {1}".format(sSynop, text) rFmt = "_B{0} b_ {1}".format(" "*len(sSynop), " "*len(text)) return rTxt, rFmt def _formatComments(self, text: str) -> tuple[str, str]: """Apply formatting to comments.""" sComm = self._localLookup("Comment") rTxt = "**{0}:** {1}".format(sComm, text) rFmt = "_B{0} b_ {1}".format(" "*len(sComm), " "*len(text)) return rTxt, rFmt def _formatKeywords(self, text: str) -> tuple[str, str]: """Apply formatting to keywords.""" valid, bits, _ = self._project.index.scanThis("@"+text) if not valid or not bits: return "", "" rTxt = "" rFmt = "" if bits[0] in nwLabels.KEY_NAME: text = nwLabels.KEY_NAME[bits[0]] rTxt += "**{0}:** ".format(text) rFmt += "_B{0} b_ ".format(" "*len(text)) if len(bits) > 1: if bits[0] == nwKeyWords.TAG_KEY: rTxt += bits[1] rFmt += " "*len(bits[1]) else: tTags = ", ".join(bits[1:]) rTxt += tTags rFmt += (" "*len(tTags)) return rTxt, rFmt def _addTextPar( self, styleName: str, oStyle: ODTParagraphStyle, tText: str, tFmt: str = "", isHead: bool = False, oLevel: str | None = None ) -> None: """Add a text paragraph to the text XML element.""" tAttr = {} tAttr[_mkTag("text", "style-name")] = self._paraStyle(styleName, oStyle) if oLevel is not None: tAttr[_mkTag("text", "outline-level")] = oLevel pTag = "h" if isHead else "p" xElem = ET.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr) # It's important to set the initial text field to empty, otherwise # xmlIndent will add a line break if the first subelement is a span. xElem.text = "" if not tText: return ## # Process Formatting ## if len(tText) != len(tFmt): # Generate an empty format if there isn't any or it doesn't match tFmt = " "*len(tText) # The formatting loop tTemp = "" xFmt = 0x00 pFmt = 0x00 pErr = 0 parProc = XMLParagraph(xElem) for i, c in enumerate(tText): if tFmt[i] == " ": tTemp += c elif tFmt[i] == "_": continue elif tFmt[i] == "B": xFmt |= X_BLD elif tFmt[i] == "b": xFmt &= M_BLD elif tFmt[i] == "I": xFmt |= X_ITA elif tFmt[i] == "i": xFmt &= M_ITA elif tFmt[i] == "S": xFmt |= X_DEL elif tFmt[i] == "s": xFmt &= M_DEL else: pErr += 1 if xFmt != pFmt: if pFmt == 0x00: parProc.appendText(tTemp) tTemp = "" else: parProc.appendSpan(tTemp, self._textStyle(pFmt)) tTemp = "" pFmt = xFmt # Save what remains in the buffer if pFmt == 0x00: parProc.appendText(tTemp) else: parProc.appendSpan(tTemp, self._textStyle(pFmt)) if pErr > 0: self._errData.append("Unknown format tag encountered") nErr, errMsg = parProc.checkError() if nErr > 0: # pragma: no cover # This one should only capture bugs self._errData.append(errMsg) return def _paraStyle(self, parName: str, oStyle: ODTParagraphStyle) -> str: """Return a name for a style object.""" refStyle = self._mainPara.get(parName, None) if refStyle is None: logger.error("Unknown paragraph style '%s'", parName) return "Standard" if not refStyle.checkNew(oStyle): return parName oStyle.setParentStyleName(parName) theID = oStyle.getID() if theID in self._autoPara: return self._autoPara[theID][0] newName = "P%d" % (len(self._autoPara) + 1) self._autoPara[theID] = (newName, oStyle) return newName def _textStyle(self, hFmt: int) -> str: """Return a text style for a given style code.""" if hFmt in self._autoText: return self._autoText[hFmt][0] newName = "T%d" % (len(self._autoText) + 1) newStyle = ODTTextStyle() if hFmt & X_BLD: newStyle.setFontWeight("bold") if hFmt & X_ITA: newStyle.setFontStyle("italic") if hFmt & X_DEL: newStyle.setStrikeStyle("solid") newStyle.setStrikeType("single") self._autoText[hFmt] = (newName, newStyle) return newName def _emToCm(self, value: float) -> str: """Converts an em value to centimetres.""" return f"{value*2.54/72*self._textSize:.3f}cm" ## # Style Elements ## def _pageStyles(self) -> None: """Set the default page style.""" tAttr = {} tAttr[_mkTag("style", "name")] = "PM1" if self._isFlat: xPage = ET.SubElement(self._xAuto, _mkTag("style", "page-layout"), attrib=tAttr) else: xPage = ET.SubElement(self._xAut2, _mkTag("style", "page-layout"), attrib=tAttr) tAttr = {} tAttr[_mkTag("fo", "page-width")] = self._mDocWidth tAttr[_mkTag("fo", "page-height")] = self._mDocHeight tAttr[_mkTag("fo", "margin-top")] = self._mDocTop tAttr[_mkTag("fo", "margin-bottom")] = self._mDocBtm tAttr[_mkTag("fo", "margin-left")] = self._mDocLeft tAttr[_mkTag("fo", "margin-right")] = self._mDocRight tAttr[_mkTag("fo", "print-orientation")] = "portrait" ET.SubElement(xPage, _mkTag("style", "page-layout-properties"), attrib=tAttr) xHead = ET.SubElement(xPage, _mkTag("style", "header-style")) tAttr = {} tAttr[_mkTag("fo", "min-height")] = "0.600cm" tAttr[_mkTag("fo", "margin-left")] = "0.000cm" tAttr[_mkTag("fo", "margin-right")] = "0.000cm" tAttr[_mkTag("fo", "margin-bottom")] = "0.500cm" ET.SubElement(xHead, _mkTag("style", "header-footer-properties"), attrib=tAttr) return def _defaultStyles(self) -> None: """Set the default styles.""" # Add Paragraph Family Style # ========================== tAttr = {} tAttr[_mkTag("style", "family")] = "paragraph" xStyl = ET.SubElement(self._xStyl, _mkTag("style", "default-style"), attrib=tAttr) tAttr = {} tAttr[_mkTag("style", "line-break")] = "strict" tAttr[_mkTag("style", "tab-stop-distance")] = "1.251cm" tAttr[_mkTag("style", "writing-mode")] = "page" ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=tAttr) tAttr = {} tAttr[_mkTag("style", "font-name")] = self._textFont tAttr[_mkTag("fo", "font-family")] = self._fontFamily tAttr[_mkTag("fo", "font-size")] = self._fSizeText tAttr[_mkTag("fo", "language")] = self._dLanguage tAttr[_mkTag("fo", "country")] = self._dCountry ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Standard Paragraph Style # ============================ tAttr = {} tAttr[_mkTag("style", "name")] = "Standard" tAttr[_mkTag("style", "family")] = "paragraph" tAttr[_mkTag("style", "class")] = "text" xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) tAttr = {} tAttr[_mkTag("style", "font-name")] = self._textFont tAttr[_mkTag("fo", "font-family")] = self._fontFamily tAttr[_mkTag("fo", "font-size")] = self._fSizeText ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Default Heading Style # ========================= tAttr = {} tAttr[_mkTag("style", "name")] = "Heading" tAttr[_mkTag("style", "family")] = "paragraph" tAttr[_mkTag("style", "parent-style-name")] = "Standard" tAttr[_mkTag("style", "next-style-name")] = "Text_20_body" tAttr[_mkTag("style", "class")] = "text" xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) tAttr = {} tAttr[_mkTag("fo", "margin-top")] = self._mTopHead tAttr[_mkTag("fo", "margin-bottom")] = self._mBotHead tAttr[_mkTag("fo", "keep-with-next")] = "always" ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=tAttr) tAttr = {} tAttr[_mkTag("style", "font-name")] = self._textFont tAttr[_mkTag("fo", "font-family")] = self._fontFamily tAttr[_mkTag("fo", "font-size")] = self._fSizeHead ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Header and Footer Styles # ============================ tAttr = {} tAttr[_mkTag("style", "name")] = "Header_20_and_20_Footer" tAttr[_mkTag("style", "display-name")] = "Header and Footer" tAttr[_mkTag("style", "family")] = "paragraph" tAttr[_mkTag("style", "parent-style-name")] = "Standard" tAttr[_mkTag("style", "class")] = "extra" ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) return def _useableStyles(self) -> None: """Set the usable styles.""" # Add Text Body Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Text body") oStyle.setParentStyleName("Standard") oStyle.setClass("text") oStyle.setMarginTop(self._mTopText) oStyle.setMarginBottom(self._mBotText) oStyle.setLineHeight(self._fLineHeight) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeText) oStyle.setTextAlign(self._textAlign) oStyle.packXML(self._xStyl, "Text_20_body") self._mainPara["Text_20_body"] = oStyle # Add Text Meta Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Text Meta") oStyle.setParentStyleName("Standard") oStyle.setClass("text") oStyle.setMarginTop(self._mTopMeta) oStyle.setMarginBottom(self._mBotMeta) oStyle.setLineHeight(self._fLineHeight) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeText) oStyle.setColor(self._colMetaTx) oStyle.setOpacity(self._opaMetaTx) oStyle.packXML(self._xStyl, "Text_20_Meta") self._mainPara["Text_20_Meta"] = oStyle # Add Title Style # =============== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Title") oStyle.setParentStyleName("Heading") oStyle.setNextStyleName("Text_20_body") oStyle.setClass("chapter") oStyle.setTextAlign("center") oStyle.setMarginTop(self._mTopTitle) oStyle.setMarginBottom(self._mBotTitle) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeTitle) oStyle.setFontWeight("bold") oStyle.packXML(self._xStyl, "Title") self._mainPara["Title"] = oStyle # Add Separator Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Separator") oStyle.setParentStyleName("Standard") oStyle.setNextStyleName("Text_20_body") oStyle.setClass("text") oStyle.setTextAlign("center") oStyle.setMarginTop(self._mTopText) oStyle.setMarginBottom(self._mBotText) oStyle.setLineHeight(self._fLineHeight) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeText) oStyle.packXML(self._xStyl, "Separator") self._mainPara["Separator"] = oStyle # Add Heading 1 Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Heading 1") oStyle.setParentStyleName("Heading") oStyle.setNextStyleName("Text_20_body") oStyle.setOutlineLevel("1") oStyle.setClass("text") oStyle.setMarginTop(self._mTopHead1) oStyle.setMarginBottom(self._mBotHead1) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeHead1) oStyle.setColor(self._colHead12) oStyle.setOpacity(self._opaHead12) oStyle.setFontWeight("bold") oStyle.packXML(self._xStyl, "Heading_20_1") self._mainPara["Heading_20_1"] = oStyle # Add Heading 2 Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Heading 2") oStyle.setParentStyleName("Heading") oStyle.setNextStyleName("Text_20_body") oStyle.setOutlineLevel("2") oStyle.setClass("text") oStyle.setMarginTop(self._mTopHead2) oStyle.setMarginBottom(self._mBotHead2) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeHead2) oStyle.setColor(self._colHead12) oStyle.setOpacity(self._opaHead12) oStyle.setFontWeight("bold") oStyle.packXML(self._xStyl, "Heading_20_2") self._mainPara["Heading_20_2"] = oStyle # Add Heading 3 Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Heading 3") oStyle.setParentStyleName("Heading") oStyle.setNextStyleName("Text_20_body") oStyle.setOutlineLevel("3") oStyle.setClass("text") oStyle.setMarginTop(self._mTopHead3) oStyle.setMarginBottom(self._mBotHead3) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeHead3) oStyle.setColor(self._colHead34) oStyle.setOpacity(self._opaHead34) oStyle.setFontWeight("bold") oStyle.packXML(self._xStyl, "Heading_20_3") self._mainPara["Heading_20_3"] = oStyle # Add Heading 4 Style # =================== oStyle = ODTParagraphStyle() oStyle.setDisplayName("Heading 4") oStyle.setParentStyleName("Heading") oStyle.setNextStyleName("Text_20_body") oStyle.setOutlineLevel("4") oStyle.setClass("text") oStyle.setMarginTop(self._mTopHead4) oStyle.setMarginBottom(self._mBotHead4) oStyle.setFontName(self._textFont) oStyle.setFontFamily(self._fontFamily) oStyle.setFontSize(self._fSizeHead4) oStyle.setColor(self._colHead34) oStyle.setOpacity(self._opaHead34) oStyle.setFontWeight("bold") oStyle.packXML(self._xStyl, "Heading_20_4") self._mainPara["Heading_20_4"] = oStyle # Add Header Style # ================ oStyle = ODTParagraphStyle() oStyle.setDisplayName("Header") oStyle.setParentStyleName("Header_20_and_20_Footer") oStyle.setTextAlign("right") oStyle.packXML(self._xStyl, "Header") self._mainPara["Header"] = oStyle return def _writeHeader(self) -> None: """Write the header elements.""" tAttr = {} tAttr[_mkTag("style", "name")] = "Standard" tAttr[_mkTag("style", "page-layout-name")] = "PM1" xPage = ET.SubElement(self._xMast, _mkTag("style", "master-page"), attrib=tAttr) # Standard Page Header xHead = ET.SubElement(xPage, _mkTag("style", "header")) xPar = ET.SubElement(xHead, _mkTag("text", "p"), attrib={ _mkTag("text", "style-name"): "Header" }) xPar.text = self._headerText.strip() + " " xTail = ET.SubElement(xPar, _mkTag("text", "page-number"), attrib={ _mkTag("text", "select-page"): "current" }) xTail.text = "2" xTail.tail = "" # Prevent line break in indented XML # First Page Header xHead = ET.SubElement(xPage, _mkTag("style", "header-first")) xPar = ET.SubElement(xHead, _mkTag("text", "p"), attrib={ _mkTag("text", "style-name"): "Header" }) return # END Class ToOdt # =============================================================================================== # # Auto-Style Classes # =============================================================================================== # class ODTParagraphStyle: """Wrapper class for the paragraph style setting used by the exporter. Only the used settings are exposed here to keep the class minimal and fast. """ VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"] VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"] VALID_LEVEL = ["1", "2", "3", "4"] VALID_CLASS = ["text", "chapter"] VALID_WEIGHT = ["normal", "inherit", "bold"] def __init__(self) -> None: # Attributes self._mAttr = { "display-name": ["style", None], "parent-style-name": ["style", None], "next-style-name": ["style", None], "default-outline-level": ["style", None], "class": ["style", None], } # Paragraph Attributes self._pAttr = { "margin-top": ["fo", None], "margin-bottom": ["fo", None], "margin-left": ["fo", None], "margin-right": ["fo", None], "line-height": ["fo", None], "text-align": ["fo", None], "break-before": ["fo", None], "break-after": ["fo", None], } # Text Attributes self._tAttr = { "font-name": ["style", None], "font-family": ["fo", None], "font-size": ["fo", None], "font-weight": ["fo", None], "color": ["fo", None], "opacity": ["loext", None], } return ## # Attribute Setters ## def setDisplayName(self, value: str | None) -> None: self._mAttr["display-name"][1] = value return def setParentStyleName(self, value: str | None) -> None: self._mAttr["parent-style-name"][1] = value return def setNextStyleName(self, value: str | None) -> None: self._mAttr["next-style-name"][1] = value return def setOutlineLevel(self, value: str | None) -> None: if value in self.VALID_LEVEL: self._mAttr["default-outline-level"][1] = value else: self._mAttr["default-outline-level"][1] = None return def setClass(self, value: str | None) -> None: if value in self.VALID_CLASS: self._mAttr["class"][1] = value else: self._mAttr["class"][1] = None return ## # Paragraph Setters ## def setMarginTop(self, value: str | None) -> None: self._pAttr["margin-top"][1] = value return def setMarginBottom(self, value: str | None) -> None: self._pAttr["margin-bottom"][1] = value return def setMarginLeft(self, value: str | None) -> None: self._pAttr["margin-left"][1] = value return def setMarginRight(self, value: str | None) -> None: self._pAttr["margin-right"][1] = value return def setLineHeight(self, value: str | None) -> None: self._pAttr["line-height"][1] = value return def setTextAlign(self, value: str | None) -> None: if value in self.VALID_ALIGN: self._pAttr["text-align"][1] = value else: self._pAttr["text-align"][1] = None return def setBreakBefore(self, value: str | None) -> None: if value in self.VALID_BREAK: self._pAttr["break-before"][1] = value else: self._pAttr["break-before"][1] = None return def setBreakAfter(self, value: str | None) -> None: if value in self.VALID_BREAK: self._pAttr["break-after"][1] = value else: self._pAttr["break-after"][1] = None return ## # Text Setters ## def setFontName(self, value: str | None) -> None: self._tAttr["font-name"][1] = value return def setFontFamily(self, value: str | None) -> None: self._tAttr["font-family"][1] = value return def setFontSize(self, value: str | None) -> None: self._tAttr["font-size"][1] = value return def setFontWeight(self, value: str | None) -> None: if value in self.VALID_WEIGHT: self._tAttr["font-weight"][1] = value else: self._tAttr["font-weight"][1] = None return def setColor(self, value: str | None) -> None: self._tAttr["color"][1] = value return def setOpacity(self, value: str | None) -> None: self._tAttr["opacity"][1] = value return ## # Methods ## def checkNew(self, style: ODTParagraphStyle) -> bool: """Check if there are new settings in refStyle that differ from those in the current object. """ for name, (_, aVal) in style._mAttr.items(): if aVal is not None and aVal != self._mAttr[name][1]: return True for name, (_, aVal) in style._pAttr.items(): if aVal is not None and aVal != self._pAttr[name][1]: return True for name, (_, aVal) in style._tAttr.items(): if aVal is not None and aVal != self._tAttr[name][1]: return True return False def getID(self) -> str: """Generate a unique ID from the settings.""" string = ( f"Paragraph:Main:{str(self._mAttr)}:" f"Paragraph:Para:{str(self._pAttr)}:" f"Paragraph:Text:{str(self._tAttr)}:" ) return sha256(string.encode()).hexdigest() def packXML(self, xParent: ET.Element, name: str) -> None: """Pack the content into an xml element.""" attr = {} attr[_mkTag("style", "name")] = name attr[_mkTag("style", "family")] = "paragraph" for aName, (aNm, aVal) in self._mAttr.items(): if aVal is not None: attr[_mkTag(aNm, aName)] = aVal xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=attr) attr = {} for aName, (aNm, aVal) in self._pAttr.items(): if aVal is not None: attr[_mkTag(aNm, aName)] = aVal if attr: ET.SubElement(xEntry, _mkTag("style", "paragraph-properties"), attrib=attr) attr = {} for aName, (aNm, aVal) in self._tAttr.items(): if aVal is not None: attr[_mkTag(aNm, aName)] = aVal if attr: ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=attr) return # END Class ODTParagraphStyle class ODTTextStyle: """Wrapper class for the text style setting used by the exporter. Only the used settings are exposed here to keep the class minimal and fast. """ VALID_WEIGHT = ["normal", "inherit", "bold"] VALID_STYLE = ["normal", "inherit", "italic"] VALID_LSTYLE = ["none", "solid"] VALID_LTYPE = ["none", "single", "double"] def __init__(self) -> None: # Text Attributes self._tAttr = { "font-weight": ["fo", None], "font-style": ["fo", None], "text-line-through-style": ["style", None], "text-line-through-type": ["style", None], } return ## # Setters ## def setFontWeight(self, value: str | None) -> None: if value in self.VALID_WEIGHT: self._tAttr["font-weight"][1] = value else: self._tAttr["font-weight"][1] = None return def setFontStyle(self, value: str | None) -> None: if value in self.VALID_STYLE: self._tAttr["font-style"][1] = value else: self._tAttr["font-style"][1] = None return def setStrikeStyle(self, value: str | None) -> None: if value in self.VALID_LSTYLE: self._tAttr["text-line-through-style"][1] = value else: self._tAttr["text-line-through-style"][1] = None return def setStrikeType(self, value: str | None) -> None: if value in self.VALID_LTYPE: self._tAttr["text-line-through-type"][1] = value else: self._tAttr["text-line-through-type"][1] = None return ## # Methods ## def packXML(self, xParent: ET.Element, name: str) -> None: """Pack the content into an xml element.""" attr = {} attr[_mkTag("style", "name")] = name attr[_mkTag("style", "family")] = "text" xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=attr) attr = {} for aName, (aNm, aVal) in self._tAttr.items(): if aVal is not None: attr[_mkTag(aNm, aName)] = aVal if attr: ET.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=attr) return # END Class ODTTextStyle # =============================================================================================== # # XML Complex Element Helper Class # =============================================================================================== # X_ROOT_TEXT = 0 X_ROOT_TAIL = 1 X_SPAN_TEXT = 2 X_SPAN_SING = 3 class XMLParagraph: """This is a helper class to manage the text content of a single XML element using mixed content tags. Rules: * The root tag can only have text set, never tail. * Any span must be under root, and the text in the span is set in one pass. The span then becomes the new base element using tail for further added text, permanently replacing root. * Any single special tags like tabs, line breaks or multi-spaces, should never have text set. After insertion, they become the next tail tag if on root level, or if in a span, only exists within the lifetime of the span. In this case, the span becomes the new tail. The four constants associated with this class represent the only allowed states the class can exist in, which dictates which XML object and attribute is written to, """ def __init__(self, xRoot: ET.Element) -> None: self._xRoot = xRoot self._xTail = ET.Element("") self._xSing = ET.Element("") self._nState = X_ROOT_TEXT self._chrPos = 0 self._rawTxt = "" self._xRoot.text = "" return def appendText(self, text: str) -> None: """Append text to the XML element. We do this one character at the time in order to be able to process line breaks, tabs and spaces separately. Multiple spaces are concatenated into a single tag, and must therefore be processed separately. """ text = stripEscape(text) nSpaces = 0 self._rawTxt += text for c in text: if c == " ": nSpaces += 1 continue elif nSpaces > 0: self._processSpaces(nSpaces) nSpaces = 0 if c == "\n": if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): self._xTail = ET.SubElement(self._xRoot, TAG_BR) self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += 1 elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_BR) self._xSing.tail = "" self._nState = X_SPAN_SING self._chrPos += 1 elif c == "\t": if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): self._xTail = ET.SubElement(self._xRoot, TAG_TAB) self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += 1 elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_TAB) self._xSing.tail = "" self._chrPos += 1 self._nState = X_SPAN_SING else: if self._nState == X_ROOT_TEXT: self._xRoot.text = (self._xRoot.text or "") + c self._chrPos += 1 elif self._nState == X_ROOT_TAIL: self._xTail.tail = (self._xTail.tail or "") + c self._chrPos += 1 elif self._nState == X_SPAN_TEXT: self._xTail.text = (self._xTail.text or "") + c self._chrPos += 1 elif self._nState == X_SPAN_SING: self._xSing.tail = (self._xSing.tail or "") + c self._chrPos += 1 if nSpaces > 0: # Handle trailing spaces self._processSpaces(nSpaces) return def appendSpan(self, text: str, fmt: str) -> None: """Append a text span to the XML element. The span is always closed since we do not allow nested spans (like Libre Office). Therefore we return to the root element level when we're done processing the text of the span. """ self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={TAG_STNM: fmt}) self._xTail.text = "" # Defaults to None self._xTail.tail = "" # Defaults to None self._nState = X_SPAN_TEXT self.appendText(text) self._nState = X_ROOT_TAIL return def checkError(self) -> tuple[int, str]: """Check that the number of characters written matches the number of characters received. """ errMsg = "" nMissed = len(self._rawTxt) - self._chrPos if nMissed != 0: errMsg = "%d char(s) were not written: '%s'" % (nMissed, self._rawTxt) return nMissed, errMsg ## # Internal Functions ## def _processSpaces(self, nSpaces: int) -> None: """Add spaces to paragraph. The first space is always written as-is (unless it's the first character of the paragraph). The second space uses the dedicated tag for spaces, and from the third space and on, a counter is added to the tag. See: http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html Sections: 6.1.2, 6.1.3, and 19.763 """ if nSpaces > 0: if self._chrPos > 0: if self._nState == X_ROOT_TEXT: self._xRoot.text = (self._xRoot.text or "") + " " self._chrPos += 1 elif self._nState == X_ROOT_TAIL: self._xTail.tail = (self._xTail.tail or "") + " " self._chrPos += 1 elif self._nState == X_SPAN_TEXT: self._xTail.text = (self._xTail.text or "") + " " self._chrPos += 1 elif self._nState == X_SPAN_SING: self._xSing.tail = (self._xSing.tail or "") + " " self._chrPos += 1 else: nSpaces += 1 if nSpaces == 2: if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): self._xTail = ET.SubElement(self._xRoot, TAG_SPC) self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += nSpaces - 1 elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_SPC) self._xSing.tail = "" self._nState = X_SPAN_SING self._chrPos += nSpaces - 1 elif nSpaces > 2: if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): self._xTail = ET.SubElement(self._xRoot, TAG_SPC, attrib={ TAG_NSPC: str(nSpaces - 1) }) self._xTail.tail = "" self._nState = X_ROOT_TAIL self._chrPos += nSpaces - 1 elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): self._xSing = ET.SubElement(self._xTail, TAG_SPC, attrib={ TAG_NSPC: str(nSpaces - 1) }) self._xSing.tail = "" self._nState = X_SPAN_SING self._chrPos += nSpaces - 1 return # END Class XMLParagraph