Files
novelWriter/novelwriter/core/toodt.py
T
Veronica Berglyd Olsen 1c45331b0a Fix and optimise code (#904)
* Fix bug in early error reporting in main init
* Update docstrings and optimise code in GuiMain
* Update docstrings and optimise code in Config
* Update docstrings and optimise code in common module
* Update docstrings and optimise code in main project classes
* Update the OptionState class
* Update the spell checker class
* Update the file converter classes and extend tests
* Update the about, merge, split and item editor classes and extend tests
* Update item editor test
* Update about dialog tests
* Some minor test cleanup
* Fix typo and add clarification in contributing guide
2021-10-14 21:35:42 +02:00

1487 lines
50 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 ODT Text Converter
================================
Extends the Tokenizer class to generate ODT and FODT files
File History:
Created: 2021-01-26 [1.2a0]
This file is a part of novelWriter
Copyright 20182021, 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/>.
"""
import logging
import novelwriter
from lxml import etree
from hashlib import sha256
from zipfile import ZipFile
from datetime import datetime
from novelwriter.constants import nwKeyWords, nwLabels
from novelwriter.core.tokenizer import Tokenizer
logger = logging.getLogger(__name__)
# Main XML NameSpaces
XML_NS = {
"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",
}
MANI_NS = {
"manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
}
OFFICE_NS = {
"office": XML_NS["office"]
}
def _mkTag(nsName, tagName, nsMap=XML_NS):
"""Assemble namespace and tag name.
"""
theNS = nsMap.get(nsName, "")
if theNS:
return f"{{{theNS}}}{tagName}"
logger.warning("Missing xml namespace '%s'", nsName)
return tagName
# Mimetype and Version
X_MIME = "application/vnd.oasis.opendocument.text"
X_VERS = "1.2"
# 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):
def __init__(self, theProject, isFlat):
Tokenizer.__init__(self, theProject)
self._isFlat = isFlat # Flat: .fodt, otherwise .odt
self._dFlat = None # FODT file XML root
self._dCont = None # ODT content.xml root
self._dMeta = None # ODT meta.xml root
self._dStyl = None # ODT styles.xml root
self._xMeta = None # Office meta root
self._xFont = None # Office font face declaration
self._xFnt2 = None # Office font face declaration, secondary
self._xStyl = None # Office styles root
self._xAuto = None # Office auto-styles root
self._xAut2 = None # Office auto-styles root, secondary
self._xMast = None # Office master-styles root
self._xBody = None # Office body root
self._xText = None # 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 = "&apos;Liberation Serif&apos;"
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._lineHeight = "115%"
self._blockIndent = "1.693cm"
self._textAlign = "left"
self._dLanguage = "en"
self._dCountry = "GB"
# Text Margings in Units of em
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 Margins
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, theLang):
"""Set language for the document.
"""
if theLang is None:
return False
langBits = theLang.split("_")
self._dLanguage = langBits[0]
if len(langBits) > 1:
self._dCountry = langBits[1]
return True
def setColourHeaders(self, doColour):
"""Enable/disable coloured headings and comments.
"""
self.colourHead = doColour
return
##
# Class Methods
##
def getErrors(self):
"""Return the list of errors."""
return self._errData
def initDocument(self):
"""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._lineHeight = f"{round(100 * self.lineHeight):d}%"
self._blockIndent = self._emToCm(self.blockIndent)
self._textAlign = "justify" if self.doJustify else "left"
# Clear Errors
self._errData = []
# Document Header
# ===============
if self.headerText == "":
theTitle = self.theProject.bookTitle
theAuth = self.theProject.getAuthors()
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 = etree.Element(tFlat, attrib=tAttr, nsmap=XML_NS)
self._xMeta = etree.SubElement(self._dFlat, _mkTag("office", "meta"))
self._xFont = etree.SubElement(self._dFlat, _mkTag("office", "font-face-decls"))
self._xStyl = etree.SubElement(self._dFlat, _mkTag("office", "styles"))
self._xAuto = etree.SubElement(self._dFlat, _mkTag("office", "automatic-styles"))
self._xMast = etree.SubElement(self._dFlat, _mkTag("office", "master-styles"))
self._xBody = etree.SubElement(self._dFlat, _mkTag("office", "body"))
etree.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 = etree.Element(tCont, attrib=tAttr, nsmap=XML_NS)
self._xFont = etree.SubElement(self._dCont, _mkTag("office", "font-face-decls"))
self._xAuto = etree.SubElement(self._dCont, _mkTag("office", "automatic-styles"))
self._xBody = etree.SubElement(self._dCont, _mkTag("office", "body"))
# meta.xml
self._dMeta = etree.Element(tMeta, attrib=tAttr, nsmap=XML_NS)
self._xMeta = etree.SubElement(self._dMeta, _mkTag("office", "meta"))
# styles.xml
self._dStyl = etree.Element(tStyl, attrib=tAttr, nsmap=XML_NS)
self._xFnt2 = etree.SubElement(self._dStyl, _mkTag("office", "font-face-decls"))
self._xStyl = etree.SubElement(self._dStyl, _mkTag("office", "styles"))
self._xAut2 = etree.SubElement(self._dStyl, _mkTag("office", "automatic-styles"))
self._xMast = etree.SubElement(self._dStyl, _mkTag("office", "master-styles"))
etree.SubElement(self._xFont, _mkTag("style", "font-face"), attrib=fAttr)
etree.SubElement(self._xFnt2, _mkTag("style", "font-face"), attrib=fAttr)
# Finalise
# ========
self._xText = etree.SubElement(self._xBody, _mkTag("office", "text"))
# Meta Data
xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "creation-date"))
xMeta.text = datetime.now().strftime(r"%Y-%m-%dT%H:%M:%S")
xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "generator"))
xMeta.text = f"novelWriter/{novelwriter.__version__}"
self._pageStyles()
self._defaultStyles()
self._useableStyles()
self._writeHeader()
return
def doConvert(self):
"""Convert the list of text tokens into XML elements.
"""
self.theResult = "" # 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
}
thisPar = []
thisFmt = []
parStyle = None
for tType, _, tText, tFormat, tStyle in self.theTokens:
# 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._blockIndent)
if tStyle & self.A_IND_R:
oStyle.setMarginRight(self._blockIndent)
# Process Text Types
if tType == self.T_EMPTY:
if len(thisPar) > 1 and parStyle is not None:
if self.doJustify:
parStyle.setTextAlign("left")
if len(thisPar) > 0:
tTemp = "\n".join(thisPar)
fTemp = " ".join(thisFmt)
tTxt = tTemp.rstrip()
tFmt = fTemp[:len(tTxt)]
self._addTextPar("Text_Body", parStyle, tTxt, theFmt=tFmt)
thisPar = []
thisFmt = []
parStyle = None
elif tType == self.T_TITLE:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Title", oStyle, tHead, isHead=True)
elif tType == self.T_UNNUM:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Heading_2", oStyle, tHead, isHead=True, oLevel="2")
elif tType == self.T_HEAD1:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Heading_1", oStyle, tHead, isHead=True, oLevel="1")
elif tType == self.T_HEAD2:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Heading_2", oStyle, tHead, isHead=True, oLevel="2")
elif tType == self.T_HEAD3:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Heading_3", oStyle, tHead, isHead=True, oLevel="3")
elif tType == self.T_HEAD4:
tHead = tText.replace(r"\\", "\n")
self._addTextPar("Heading_4", oStyle, tHead, isHead=True, oLevel="4")
elif tType == self.T_SEP:
self._addTextPar("Text_Body", oStyle, tText)
elif tType == self.T_SKIP:
self._addTextPar("Text_Body", oStyle, "")
elif tType == self.T_TEXT:
if parStyle is None:
parStyle = 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)]
thisPar.append(tTxt)
thisFmt.append(tFmt)
elif tType == self.T_SYNOPSIS and self.doSynopsis:
tTemp, fTemp = self._formatSynopsis(tText)
self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
elif tType == self.T_COMMENT and self.doComments:
tTemp, fTemp = self._formatComments(tText)
self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
elif tType == self.T_KEYWORD and self.doKeywords:
tTemp, fTemp = self._formatKeywords(tText)
self._addTextPar("Text_Meta", oStyle, tTemp, theFmt=fTemp)
return
def closeDocument(self):
"""Return the serialised 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, savePath):
"""Save the data to an .fodt file.
"""
with open(savePath, mode="wb") as outFile:
outFile.write(etree.tostring(
self._dFlat,
pretty_print=True,
encoding="utf-8",
xml_declaration=True
))
return
def saveOpenDocText(self, savePath):
"""Save the data to an .odt file.
"""
mMani = _mkTag("manifest", "manifest", nsMap=MANI_NS)
mVers = _mkTag("manifest", "version", nsMap=MANI_NS)
mPath = _mkTag("manifest", "full-path", nsMap=MANI_NS)
mType = _mkTag("manifest", "media-type", nsMap=MANI_NS)
mFile = _mkTag("manifest", "file-entry", nsMap=MANI_NS)
xMani = etree.Element(mMani, attrib={mVers: X_VERS}, nsmap=MANI_NS)
etree.SubElement(xMani, mFile, attrib={mPath: "/", mVers: X_VERS, mType: X_MIME})
etree.SubElement(xMani, mFile, attrib={mPath: "settings.xml", mType: "text/xml"})
etree.SubElement(xMani, mFile, attrib={mPath: "content.xml", mType: "text/xml"})
etree.SubElement(xMani, mFile, attrib={mPath: "meta.xml", mType: "text/xml"})
etree.SubElement(xMani, mFile, attrib={mPath: "styles.xml", mType: "text/xml"})
oRoot = _mkTag("office", "document-settings", nsMap=OFFICE_NS)
oSett = _mkTag("office", "settings", nsMap=OFFICE_NS)
xSett = etree.Element(oRoot, nsmap=OFFICE_NS)
etree.SubElement(xSett, oSett)
with ZipFile(savePath, mode="w") as outFile:
outFile.writestr("mimetype", X_MIME)
outFile.writestr("META-INF/manifest.xml", etree.tostring(
xMani, pretty_print=False, encoding="utf-8", xml_declaration=True
))
outFile.writestr("settings.xml", etree.tostring(
xSett, pretty_print=False, encoding="utf-8", xml_declaration=True
))
outFile.writestr("content.xml", etree.tostring(
self._dCont, pretty_print=False, encoding="utf-8", xml_declaration=True
))
outFile.writestr("meta.xml", etree.tostring(
self._dMeta, pretty_print=False, encoding="utf-8", xml_declaration=True
))
outFile.writestr("styles.xml", etree.tostring(
self._dStyl, pretty_print=False, encoding="utf-8", xml_declaration=True
))
return
##
# Internal Functions
##
def _formatSynopsis(self, tText):
"""Apply formatting to synopsis lines.
"""
sSynop = self._localLookup("Synopsis")
rTxt = "**{0}:** {1}".format(sSynop, tText)
rFmt = "_B{0} b_ {1}".format(" "*len(sSynop), " "*len(tText))
return rTxt, rFmt
def _formatComments(self, tText):
"""Apply formatting to comments.
"""
sComm = self._localLookup("Comment")
rTxt = "**{0}:** {1}".format(sComm, tText)
rFmt = "_B{0} b_ {1}".format(" "*len(sComm), " "*len(tText))
return rTxt, rFmt
def _formatKeywords(self, tText):
"""Apply formatting to keywords.
"""
isValid, theBits, _ = self.theParent.theIndex.scanThis("@"+tText)
if not isValid or not theBits:
return ""
rTxt = ""
rFmt = ""
if theBits[0] in nwLabels.KEY_NAME:
tText = nwLabels.KEY_NAME[theBits[0]]
rTxt += "**{0}:** ".format(tText)
rFmt += "_B{0} b_ ".format(" "*len(tText))
if len(theBits) > 1:
if theBits[0] == nwKeyWords.TAG_KEY:
rTxt += theBits[1]
rFmt += " "*len(theBits[1])
else:
tTags = ", ".join(theBits[1:])
rTxt += tTags
rFmt += (" "*len(tTags))
return rTxt, rFmt
def _addTextPar(self, styleName, oStyle, theText, theFmt="", isHead=False, oLevel=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 = etree.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr)
# It's important to set the initial text field to empty, otherwise
# lxml will add a line break if the first subelement is a span.
xElem.text = ""
if not theText:
return
##
# Process Formatting
##
if len(theText) != len(theFmt):
# Generate an empty format if there isn't any or it doesn't match
theFmt = " "*len(theText)
# The formatting loop
tTemp = ""
xFmt = 0x00
pFmt = 0x00
pErr = 0
parProc = XMLParagraph(xElem)
for i, c in enumerate(theText):
if theFmt[i] == " ":
tTemp += c
elif theFmt[i] == "_":
continue
elif theFmt[i] == "B":
xFmt |= X_BLD
elif theFmt[i] == "b":
xFmt &= M_BLD
elif theFmt[i] == "I":
xFmt |= X_ITA
elif theFmt[i] == "i":
xFmt &= M_ITA
elif theFmt[i] == "S":
xFmt |= X_DEL
elif theFmt[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, oStyle):
"""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, tFmt):
"""Return a text style for a given style code.
"""
if tFmt in self._autoText:
return self._autoText[tFmt][0]
newName = "T%d" % (len(self._autoText) + 1)
newStyle = ODTTextStyle()
if tFmt & X_BLD:
newStyle.setFontWeight("bold")
if tFmt & X_ITA:
newStyle.setFontStyle("italic")
if tFmt & X_DEL:
newStyle.setStrikeStyle("solid")
newStyle.setStrikeType("single")
self._autoText[tFmt] = (newName, newStyle)
return newName
def _emToCm(self, emVal):
"""Converts an em value to centimetres.
"""
return f"{emVal*2.54/72*self.textSize:.3f}cm"
##
# Style Elements
##
def _pageStyles(self):
"""Set the default page style.
"""
theAttr = {}
theAttr[_mkTag("style", "name")] = "PM1"
if self._isFlat:
xPage = etree.SubElement(self._xAuto, _mkTag("style", "page-layout"), attrib=theAttr)
else:
xPage = etree.SubElement(self._xAut2, _mkTag("style", "page-layout"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("fo", "margin-top")] = self._mDocTop
theAttr[_mkTag("fo", "margin-bottom")] = self._mDocBtm
theAttr[_mkTag("fo", "margin-left")] = self._mDocLeft
theAttr[_mkTag("fo", "margin-right")] = self._mDocRight
etree.SubElement(xPage, _mkTag("style", "page-layout-properties"), attrib=theAttr)
xHead = etree.SubElement(xPage, _mkTag("style", "header-style"))
theAttr = {}
theAttr[_mkTag("fo", "min-height")] = "0.600cm"
theAttr[_mkTag("fo", "margin-left")] = "0.000cm"
theAttr[_mkTag("fo", "margin-right")] = "0.000cm"
theAttr[_mkTag("fo", "margin-bottom")] = "0.500cm"
etree.SubElement(xHead, _mkTag("style", "header-footer-properties"), attrib=theAttr)
return
def _defaultStyles(self):
"""Set the default styles.
"""
# Add Paragraph Family Style
# ==========================
theAttr = {}
theAttr[_mkTag("style", "family")] = "paragraph"
xStyl = etree.SubElement(self._xStyl, _mkTag("style", "default-style"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("style", "line-break")] = "strict"
theAttr[_mkTag("style", "tab-stop-distance")] = "1.251cm"
theAttr[_mkTag("style", "writing-mode")] = "page"
etree.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("style", "font-name")] = self.textFont
theAttr[_mkTag("fo", "font-family")] = self._fontFamily
theAttr[_mkTag("fo", "font-size")] = self._fSizeText
theAttr[_mkTag("fo", "language")] = self._dLanguage
theAttr[_mkTag("fo", "country")] = self._dCountry
etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
# Add Standard Paragraph Style
# ============================
theAttr = {}
theAttr[_mkTag("style", "name")] = "Standard"
theAttr[_mkTag("style", "family")] = "paragraph"
theAttr[_mkTag("style", "class")] = "text"
xStyl = etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("style", "font-name")] = self.textFont
theAttr[_mkTag("fo", "font-family")] = self._fontFamily
theAttr[_mkTag("fo", "font-size")] = self._fSizeText
etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
# Add Default Heading Style
# =========================
theAttr = {}
theAttr[_mkTag("style", "name")] = "Heading"
theAttr[_mkTag("style", "family")] = "paragraph"
theAttr[_mkTag("style", "parent-style-name")] = "Standard"
theAttr[_mkTag("style", "next-style-name")] = "Text_Body"
theAttr[_mkTag("style", "class")] = "text"
xStyl = etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("fo", "margin-top")] = self._mTopHead
theAttr[_mkTag("fo", "margin-bottom")] = self._mBotHead
theAttr[_mkTag("fo", "keep-with-next")] = "always"
etree.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("style", "font-name")] = self.textFont
theAttr[_mkTag("fo", "font-family")] = self._fontFamily
theAttr[_mkTag("fo", "font-size")] = self._fSizeHead
etree.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr)
# Add Header and Footer Styles
# ============================
theAttr = {}
theAttr[_mkTag("style", "name")] = "Header_and_Footer"
theAttr[_mkTag("style", "display-name")] = "Header and Footer"
theAttr[_mkTag("style", "family")] = "paragraph"
theAttr[_mkTag("style", "parent-style-name")] = "Standard"
theAttr[_mkTag("style", "class")] = "extra"
etree.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr)
return
def _useableStyles(self):
"""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._lineHeight)
oStyle.setFontName(self.textFont)
oStyle.setFontFamily(self._fontFamily)
oStyle.setFontSize(self._fSizeText)
oStyle.setTextAlign(self._textAlign)
oStyle.packXML(self._xStyl, "Text_Body")
self._mainPara["Text_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._lineHeight)
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_Meta")
self._mainPara["Text_Meta"] = oStyle
# Add Title Style
# ===============
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Title")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_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 Heading 1 Style
# ===================
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Heading 1")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_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_1")
self._mainPara["Heading_1"] = oStyle
# Add Heading 2 Style
# ===================
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Heading 2")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_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_2")
self._mainPara["Heading_2"] = oStyle
# Add Heading 3 Style
# ===================
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Heading 3")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_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_3")
self._mainPara["Heading_3"] = oStyle
# Add Heading 4 Style
# ===================
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Heading 4")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_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_4")
self._mainPara["Heading_4"] = oStyle
# Add Header Style
# ================
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Header")
oStyle.setParentStyleName("Header_and_Footer")
oStyle.setTextAlign("right")
oStyle.packXML(self._xStyl, "Header")
self._mainPara["Header"] = oStyle
return
def _writeHeader(self):
"""Write the header elements.
"""
theAttr = {}
theAttr[_mkTag("style", "name")] = "Standard"
theAttr[_mkTag("style", "page-layout-name")] = "PM1"
xPage = etree.SubElement(self._xMast, _mkTag("style", "master-page"), attrib=theAttr)
# Standard Page Header
xHead = etree.SubElement(xPage, _mkTag("style", "header"))
xPar = etree.SubElement(xHead, _mkTag("text", "p"), attrib={
_mkTag("text", "style-name"): "Header"
})
xPar.text = self.headerText.strip() + " "
xTail = etree.SubElement(xPar, _mkTag("text", "page-number"), attrib={
_mkTag("text", "select-page"): "current"
})
xTail.text = "2"
# First Page Header
xHead = etree.SubElement(xPage, _mkTag("style", "header-first"))
xPar = etree.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):
# 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, theValue):
self._mAttr["display-name"][1] = str(theValue)
return
def setParentStyleName(self, theValue):
self._mAttr["parent-style-name"][1] = str(theValue)
return
def setNextStyleName(self, theValue):
self._mAttr["next-style-name"][1] = str(theValue)
return
def setOutlineLevel(self, theValue):
if theValue in self.VALID_LEVEL:
self._mAttr["default-outline-level"][1] = str(theValue)
else:
self._mAttr["default-outline-level"][1] = None
return
def setClass(self, theValue):
if theValue in self.VALID_CLASS:
self._mAttr["class"][1] = str(theValue)
else:
self._mAttr["class"][1] = None
return
##
# Paragraph Setters
##
def setMarginTop(self, theValue):
self._pAttr["margin-top"][1] = str(theValue)
return
def setMarginBottom(self, theValue):
self._pAttr["margin-bottom"][1] = str(theValue)
return
def setMarginLeft(self, theValue):
self._pAttr["margin-left"][1] = str(theValue)
return
def setMarginRight(self, theValue):
self._pAttr["margin-right"][1] = str(theValue)
return
def setLineHeight(self, theValue):
self._pAttr["line-height"][1] = str(theValue)
return
def setTextAlign(self, theValue):
if theValue in self.VALID_ALIGN:
self._pAttr["text-align"][1] = str(theValue)
else:
self._pAttr["text-align"][1] = None
return
def setBreakBefore(self, theValue):
if theValue in self.VALID_BREAK:
self._pAttr["break-before"][1] = str(theValue)
else:
self._pAttr["break-before"][1] = None
return
def setBreakAfter(self, theValue):
if theValue in self.VALID_BREAK:
self._pAttr["break-after"][1] = str(theValue)
else:
self._pAttr["break-after"][1] = None
return
##
# Text Setters
##
def setFontName(self, theValue):
self._tAttr["font-name"][1] = str(theValue)
return
def setFontFamily(self, theValue):
self._tAttr["font-family"][1] = str(theValue)
return
def setFontSize(self, theValue):
self._tAttr["font-size"][1] = str(theValue)
return
def setFontWeight(self, theValue):
if theValue in self.VALID_WEIGHT:
self._tAttr["font-weight"][1] = str(theValue)
else:
self._tAttr["font-weight"][1] = None
return
def setColor(self, theValue):
self._tAttr["color"][1] = str(theValue)
return
def setOpacity(self, theValue):
self._tAttr["opacity"][1] = str(theValue)
return
##
# Methods
##
def checkNew(self, refStyle):
"""Check if there are new settings in refStyle that differ from
those in the current object.
"""
for aName, (_, aVal) in refStyle._mAttr.items():
if aVal is not None and aVal != self._mAttr[aName][1]:
return True
for aName, (_, aVal) in refStyle._pAttr.items():
if aVal is not None and aVal != self._pAttr[aName][1]:
return True
for aName, (_, aVal) in refStyle._tAttr.items():
if aVal is not None and aVal != self._tAttr[aName][1]:
return True
return False
def getID(self):
"""Generate a unique ID from the settings.
"""
theString = (
f"Paragraph:Main:{str(self._mAttr)}:"
f"Paragraph:Para:{str(self._pAttr)}:"
f"Paragraph:Text:{str(self._tAttr)}:"
)
return sha256(theString.encode()).hexdigest()
def packXML(self, xParent, xName):
"""Pack the content into an xml element.
"""
theAttr = {}
theAttr[_mkTag("style", "name")] = xName
theAttr[_mkTag("style", "family")] = "paragraph"
for aName, (aNm, aVal) in self._mAttr.items():
if aVal is not None:
theAttr[_mkTag(aNm, aName)] = aVal
xEntry = etree.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr)
theAttr = {}
for aName, (aNm, aVal) in self._pAttr.items():
if aVal is not None:
theAttr[_mkTag(aNm, aName)] = aVal
if theAttr:
etree.SubElement(xEntry, _mkTag("style", "paragraph-properties"), attrib=theAttr)
theAttr = {}
for aName, (aNm, aVal) in self._tAttr.items():
if aVal is not None:
theAttr[_mkTag(aNm, aName)] = aVal
if theAttr:
etree.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr)
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):
# 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, theValue):
if theValue in self.VALID_WEIGHT:
self._tAttr["font-weight"][1] = str(theValue)
else:
self._tAttr["font-weight"][1] = None
return
def setFontStyle(self, theValue):
if theValue in self.VALID_STYLE:
self._tAttr["font-style"][1] = str(theValue)
else:
self._tAttr["font-style"][1] = None
return
def setStrikeStyle(self, theValue):
if theValue in self.VALID_LSTYLE:
self._tAttr["text-line-through-style"][1] = str(theValue)
else:
self._tAttr["text-line-through-style"][1] = None
return
def setStrikeType(self, theValue):
if theValue in self.VALID_LTYPE:
self._tAttr["text-line-through-type"][1] = str(theValue)
else:
self._tAttr["text-line-through-type"][1] = None
return
##
# Methods
##
def packXML(self, xParent, xName):
"""Pack the content into an xml element.
"""
theAttr = {}
theAttr[_mkTag("style", "name")] = xName
theAttr[_mkTag("style", "family")] = "text"
xEntry = etree.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr)
theAttr = {}
for aName, (aNm, aVal) in self._tAttr.items():
if aVal is not None:
theAttr[_mkTag(aNm, aName)] = aVal
if theAttr:
etree.SubElement(xEntry, _mkTag("style", "text-properties"), attrib=theAttr)
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.
See: https://lxml.de/tutorial.html#the-element-class
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):
self._xRoot = xRoot
self._xTail = None
self._xSing = None
self._nState = X_ROOT_TEXT
self._chrPos = 0
self._rawTxt = ""
self._xRoot.text = ""
return
def appendText(self, tText):
"""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 above one are concatenated
into a single tag, and must therefore be processed separately.
"""
nSpaces = 0
self._rawTxt += tText
for c in tText:
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 = etree.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 = etree.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 = etree.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 = etree.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 += c
self._chrPos += 1
elif self._nState == X_ROOT_TAIL:
self._xTail.tail += c
self._chrPos += 1
elif self._nState == X_SPAN_TEXT:
self._xTail.text += c
self._chrPos += 1
elif self._nState == X_SPAN_SING:
self._xSing.tail += c
self._chrPos += 1
if nSpaces > 0:
self._processSpaces(nSpaces)
return
def appendSpan(self, tText, tFmt):
"""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 = etree.SubElement(self._xRoot, TAG_SPAN, attrib={
TAG_STNM: tFmt
})
self._xTail.text = "" # Defaults to None
self._xTail.tail = "" # Defaults to None
self._nState = X_SPAN_TEXT
self.appendText(tText)
self._nState = X_ROOT_TAIL
return
def checkError(self):
"""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):
"""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._chrPos += 1
elif self._nState == X_ROOT_TAIL:
self._xTail.tail += " "
self._chrPos += 1
elif self._nState == X_SPAN_TEXT:
self._xTail.text += " "
self._chrPos += 1
elif self._nState == X_SPAN_SING:
self._xSing.tail += " "
self._chrPos += 1
else:
nSpaces += 1
if nSpaces == 2:
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
self._xTail = etree.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 = etree.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 = etree.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 = etree.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