# -*- coding: utf-8 -*-
"""
novelWriter – ODT Text Converter
================================
Extends the Tokenizer class to generate ODT and FODT files
File History:
Created: 2021-01-26 [1.1rc1]
This file is a part of novelWriter
Copyright 2018–2021, 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 .
"""
import nw
import logging
import os
from lxml import etree
from hashlib import sha256
from nw.core.tokenizer import Tokenizer
logger = logging.getLogger(__name__)
XML_NS = {
"office" : "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style" : "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
"text" : "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
"fo" : "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
}
class ToOdt(Tokenizer):
def __init__(self, theProject, theParent):
Tokenizer.__init__(self, theProject, theParent)
self.mainConf = nw.CONFIG
self._xRoot = None
self._xStyl = None
self._xBody = None
self._xText = None
self._xAuto = None
self._mainPara = {}
self._autoPara = {}
self._autoText = {}
self._dLanguage = "en"
self._dCountry = "GB"
self._dFontFace = "Liberation Serif"
self._dFontSize = 12
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 setFont(self, fontFace, fontSize):
"""Set font and font size.
"""
self._dFontFace = fontFace
self._dFontSize = fontSize
return
##
# Class Methods
##
def initDocument(self):
"""Initialises a new open document XML tree.
"""
rAttr = {
_mkTag("office", "version") : "1.3",
_mkTag("office", "mimetype") : "application/vnd.oasis.opendocument.text",
}
self._xRoot = etree.Element(_mkTag("office", "document"), attrib=rAttr, nsmap=XML_NS)
self._xStyl = etree.SubElement(self._xRoot, _mkTag("office", "styles"))
self._xAuto = etree.SubElement(self._xRoot, _mkTag("office", "automatic-styles"))
self._xBody = etree.SubElement(self._xRoot, _mkTag("office", "body"))
self._xText = etree.SubElement(self._xBody, _mkTag("office", "text"))
# Add Styles
self._defaultStyles()
self._useableStyles()
return
def doConvert(self):
"""Convert the list of text tokens into a HTML document saved
to theResult.
"""
self.theResult = ""
for tType, tLine, tText, tFormat, tStyle in self.theTokens:
# Styles
oStyle = ODTParagraphStyle()
if tStyle is not None:
if tStyle & self.A_LEFT:
oStyle.setTextAlign("left")
if tStyle & self.A_RIGHT:
oStyle.setTextAlign("right")
if tStyle & self.A_CENTRE:
oStyle.setTextAlign("center")
if tStyle & self.A_JUSTIFY:
oStyle.setTextAlign("justify")
if tStyle & self.A_PBB:
oStyle.setBreakBefore("page")
if tStyle & self.A_PBA:
oStyle.setBreakAfter("page")
# Process Text Type
if tType == self.T_EMPTY:
continue
elif tType == self.T_TITLE:
tHead = tText.replace(r"\\", "
")
self._addTextPar("Title", oStyle, tHead)
elif tType == self.T_HEAD1:
tHead = tText.replace(r"\\", "
")
self._addTextPar("Heading_1", oStyle, tHead, oLevel="1")
elif tType == self.T_HEAD2:
tHead = tText.replace(r"\\", "
")
self._addTextPar("Heading_2", oStyle, tHead, oLevel="2")
elif tType == self.T_HEAD3:
tHead = tText.replace(r"\\", "
")
self._addTextPar("Heading_3", oStyle, tHead, oLevel="3")
elif tType == self.T_HEAD4:
tHead = tText.replace(r"\\", "
")
self._addTextPar("Heading_4", oStyle, tHead, oLevel="4")
elif tType == self.T_SEP:
self._addTextPar("Text_Body", oStyle, tText)
elif tType == self.T_SKIP:
self._addTextPar("Text_Body", oStyle, "")
return
def closeDocument(self):
"""Return the serialised XML document
"""
for styleName, styleObj in self._autoPara.values():
styleObj.packXML(self._xAuto, styleName)
self.theResult = etree.tostring(
self._xRoot,
pretty_print = True,
encoding = "utf-8",
xml_declaration = True
)
cacheFile = os.path.join(os.path.expanduser("~"), "Temp", "odtGen.fodt")
with open(cacheFile, mode="wb") as outFile:
outFile.write(self.theResult)
return
##
# Internal Functions
##
def _addTextPar(self, styleName, oStyle, theText, 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
xElem = etree.SubElement(self._xText, _mkTag("text", "p"), attrib=tAttr)
xElem.text = theText
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
##
# Style Elements
##
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._dFontFace
theAttr[_mkTag("fo", "font-size")] = "%dpt" % self._dFontSize
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"
etree.SubElement(self._xStyl, _mkTag("style", "style"), 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")] = "0.423cm"
theAttr[_mkTag("fo", "margin-bottom")] = "0.212cm"
theAttr[_mkTag("fo", "keep-with-next")] = "always"
etree.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr)
theAttr = {}
theAttr[_mkTag("style", "font-name")] = self._dFontFace
theAttr[_mkTag("fo", "font-family")] = "'%s'" % self._dFontFace
theAttr[_mkTag("style", "font-pitch")] = "variable"
theAttr[_mkTag("fo", "font-size")] = "14pt"
etree.SubElement(xStyl, _mkTag("style", "text-properties"), 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("0cm")
oStyle.setMarginBottom("0.247cm")
oStyle.setLineHeight("115%")
oStyle.packXML(self._xStyl, "Text_Body")
self._mainPara["Text_Body"] = oStyle
# Add Title Style
# ===============
oStyle = ODTParagraphStyle()
oStyle.setDisplayName("Title")
oStyle.setParentStyleName("Heading")
oStyle.setNextStyleName("Text_Body")
oStyle.setClass("chapter")
oStyle.setTextAlign("center")
oStyle.setFontSize("28pt")
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("0.423cm")
oStyle.setMarginBottom("0.212cm")
oStyle.setFontSize("200%")
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("0.353cm")
oStyle.setMarginBottom("0.212cm")
oStyle.setFontSize("140%")
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("0.247cm")
oStyle.setMarginBottom("0.212cm")
oStyle.setFontSize("125%")
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("0.247cm")
oStyle.setMarginBottom("0.212cm")
oStyle.setFontSize("110%")
oStyle.setFontWeight("bold")
oStyle.packXML(self._xStyl, "Heading_4")
self._mainPara["Heading_4"] = oStyle
return
# END Class ToOdt
# =============================================================================================== #
# Auto-Style Classes
# =============================================================================================== #
class ODTParagraphStyle():
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],
"line-height": ["fo", None],
"text-align": ["fo", None],
"break-before": ["fo", None],
"break-after": ["fo", None],
}
# text Attributes
self._tAttr = {
"font-size": ["fo", None],
"font-weight": ["fo", 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)
return
def setClass(self, theValue):
if theValue in self.VALID_CLASS:
self._mAttr["class"][1] = str(theValue)
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 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)
return
def setBreakBefore(self, theValue):
if theValue in self.VALID_BREAK:
self._pAttr["break-before"][1] = str(theValue)
return
def setBreakAfter(self, theValue):
if theValue in self.VALID_BREAK:
self._pAttr["break-after"][1] = str(theValue)
return
##
# Text Setters
##
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)
return
##
# Methods
##
def checkNew(self, refStyle):
"""Check if there are new settings in refStyle that differ from
those in the current object.
"""
for aName, (aNm, aVal) in refStyle._mAttr.items():
if aVal is not None and aVal != self._mAttr[aName][1]:
return True
for aName, (aNm, aVal) in refStyle._pAttr.items():
if aVal is not None and aVal != self._pAttr[aName][1]:
return True
for aName, (aNm, 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
# =============================================================================================== #
# Local Functions
# =============================================================================================== #
def _mkTag(nsName, tagName):
"""Assemble namespace and tag name.
"""
theNS = XML_NS.get(nsName, "")
if theNS:
return "{%s}%s" % (theNS, tagName)
logger.warning("Missing xml namespace '%s'" % nsName)
return tagName