Add a working ODT footnotes implementation

This commit is contained in:
Veronica Berglyd Olsen
2024-04-16 00:39:48 +02:00
parent 26ac68e98e
commit 17960d286f
+89 -23
View File
@@ -29,11 +29,12 @@ from __future__ import annotations
import logging import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections.abc import Sequence
from copy import deepcopy
from datetime import datetime
from hashlib import sha256 from hashlib import sha256
from pathlib import Path from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from datetime import datetime
from collections.abc import Sequence
from novelwriter import __version__ from novelwriter import __version__
from novelwriter.common import xmlIndent from novelwriter.common import xmlIndent
@@ -130,6 +131,10 @@ class ToOdt(Tokenizer):
self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph styles
self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text styles self._autoText: dict[int, ODTTextStyle] = {} # Auto-generated text styles
# Footnotes
self._nNote = 0
self._etNotes: dict[str, ET.Element] = {} # Generated note elements
self._errData = [] # List of errors encountered self._errData = [] # List of errors encountered
# Properties # Properties
@@ -151,6 +156,7 @@ class ToOdt(Tokenizer):
self._fSizeHead4 = "14pt" self._fSizeHead4 = "14pt"
self._fSizeHead = "14pt" self._fSizeHead = "14pt"
self._fSizeText = "12pt" self._fSizeText = "12pt"
self._fSizeFoot = "10pt"
self._fLineHeight = "115%" self._fLineHeight = "115%"
self._fBlockIndent = "1.693cm" self._fBlockIndent = "1.693cm"
self._fTextIndent = "0.499cm" self._fTextIndent = "0.499cm"
@@ -177,6 +183,9 @@ class ToOdt(Tokenizer):
self._mBotText = "0.247cm" self._mBotText = "0.247cm"
self._mBotMeta = "0.106cm" self._mBotMeta = "0.106cm"
self._mBotFoot = "0.106cm"
self._mLeftFoot = "0.600cm"
# Document Size and Margins # Document Size and Margins
self._mDocWidth = "21.0cm" self._mDocWidth = "21.0cm"
self._mDocHeight = "29.7cm" self._mDocHeight = "29.7cm"
@@ -258,6 +267,7 @@ class ToOdt(Tokenizer):
self._fSizeHead4 = f"{round(1.15 * 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._fSizeHead = f"{round(1.15 * self._textSize):d}pt"
self._fSizeText = f"{self._textSize:d}pt" self._fSizeText = f"{self._textSize:d}pt"
self._fSizeFoot = f"{round(0.8*self._textSize):d}pt"
mScale = self._lineHeight/1.15 mScale = self._lineHeight/1.15
@@ -279,6 +289,9 @@ class ToOdt(Tokenizer):
self._mBotText = self._emToCm(mScale * self._marginText[1]) self._mBotText = self._emToCm(mScale * self._marginText[1])
self._mBotMeta = self._emToCm(mScale * self._marginMeta[1]) self._mBotMeta = self._emToCm(mScale * self._marginMeta[1])
self._mLeftFoot = self._emToCm(self._marginFoot[0])
self._mBotFoot = self._emToCm(self._marginFoot[1])
if self._colourHead: if self._colourHead:
self._colHead12 = "#2a6099" self._colHead12 = "#2a6099"
self._opaHead12 = "100%" self._opaHead12 = "100%"
@@ -289,6 +302,7 @@ class ToOdt(Tokenizer):
self._fLineHeight = f"{round(100 * self._lineHeight):d}%" self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
self._fBlockIndent = self._emToCm(self._blockIndent) self._fBlockIndent = self._emToCm(self._blockIndent)
self._fTextIndent = self._emToCm(self._textIndent)
self._textAlign = "justify" if self._doJustify else "left" self._textAlign = "justify" if self._doJustify else "left"
# Clear Errors # Clear Errors
@@ -398,11 +412,13 @@ class ToOdt(Tokenizer):
def doConvert(self) -> None: def doConvert(self) -> None:
"""Convert the list of text tokens into XML elements.""" """Convert the list of text tokens into XML elements."""
self._result = "" # Not used, but cleared just in case self._result = "" # Not used, but cleared just in case
self._preProcessNotes()
pFmt: list[T_Formats] = [] pFmt: list[T_Formats] = []
pText = [] pText = []
pStyle = None pStyle = None
pIndent = True pIndent = True
xText = self._xText
for tType, _, tText, tFormat, tStyle in self._tokens: for tType, _, tText, tFormat, tStyle in self._tokens:
# Styles # Styles
@@ -453,7 +469,7 @@ class ToOdt(Tokenizer):
# Don't indent a paragraph if it has alignment set # Don't indent a paragraph if it has alignment set
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned() tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
self._addTextPar( self._addTextPar(
"First_20_line_20_indent" if tIndent else "Text_20_body", xText, "First_20_line_20_indent" if tIndent else "Text_20_body",
pStyle, tTxt.rstrip(), tFmt=tFmt pStyle, tTxt.rstrip(), tFmt=tFmt
) )
pIndent = True pIndent = True
@@ -463,30 +479,31 @@ class ToOdt(Tokenizer):
pStyle = None pStyle = None
elif tType == self.T_TITLE: elif tType == self.T_TITLE:
# Title must be text:p
tHead = tText.replace(nwHeadFmt.BR, "\n") tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addTextPar("Title", oStyle, tHead, isHead=False) # Title must be text:p self._addTextPar(xText, "Title", oStyle, tHead, isHead=False)
elif tType == self.T_HEAD1: elif tType == self.T_HEAD1:
tHead = tText.replace(nwHeadFmt.BR, "\n") tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addTextPar("Heading_20_1", oStyle, tHead, isHead=True, oLevel="1") self._addTextPar(xText, "Heading_20_1", oStyle, tHead, isHead=True, oLevel="1")
elif tType == self.T_HEAD2: elif tType == self.T_HEAD2:
tHead = tText.replace(nwHeadFmt.BR, "\n") tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addTextPar("Heading_20_2", oStyle, tHead, isHead=True, oLevel="2") self._addTextPar(xText, "Heading_20_2", oStyle, tHead, isHead=True, oLevel="2")
elif tType == self.T_HEAD3: elif tType == self.T_HEAD3:
tHead = tText.replace(nwHeadFmt.BR, "\n") tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addTextPar("Heading_20_3", oStyle, tHead, isHead=True, oLevel="3") self._addTextPar(xText, "Heading_20_3", oStyle, tHead, isHead=True, oLevel="3")
elif tType == self.T_HEAD4: elif tType == self.T_HEAD4:
tHead = tText.replace(nwHeadFmt.BR, "\n") tHead = tText.replace(nwHeadFmt.BR, "\n")
self._addTextPar("Heading_20_4", oStyle, tHead, isHead=True, oLevel="4") self._addTextPar(xText, "Heading_20_4", oStyle, tHead, isHead=True, oLevel="4")
elif tType == self.T_SEP: elif tType == self.T_SEP:
self._addTextPar("Separator", oStyle, tText) self._addTextPar(xText, "Separator", oStyle, tText)
elif tType == self.T_SKIP: elif tType == self.T_SKIP:
self._addTextPar("Separator", oStyle, "") self._addTextPar(xText, "Separator", oStyle, "")
elif tType == self.T_TEXT: elif tType == self.T_TEXT:
if pStyle is None: if pStyle is None:
@@ -496,19 +513,19 @@ class ToOdt(Tokenizer):
elif tType == self.T_SYNOPSIS and self._doSynopsis: elif tType == self.T_SYNOPSIS and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, True) tTemp, tFmt = self._formatSynopsis(tText, tFormat, True)
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt) self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_SHORT and self._doSynopsis: elif tType == self.T_SHORT and self._doSynopsis:
tTemp, tFmt = self._formatSynopsis(tText, tFormat, False) tTemp, tFmt = self._formatSynopsis(tText, tFormat, False)
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt) self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_COMMENT and self._doComments: elif tType == self.T_COMMENT and self._doComments:
tTemp, tFmt = self._formatComments(tText, tFormat) tTemp, tFmt = self._formatComments(tText, tFormat)
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt) self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
elif tType == self.T_KEYWORD and self._doKeywords: elif tType == self.T_KEYWORD and self._doKeywords:
tTemp, tFmt = self._formatKeywords(tText) tTemp, tFmt = self._formatKeywords(tText)
self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=tFmt) self._addTextPar(xText, "Text_20_Meta", oStyle, tTemp, tFmt=tFmt)
return return
@@ -604,7 +621,7 @@ class ToOdt(Tokenizer):
return rTxt, rFmt return rTxt, rFmt
def _addTextPar( def _addTextPar(
self, styleName: str, oStyle: ODTParagraphStyle, tText: str, self, xParent: ET.Element, styleName: str, oStyle: ODTParagraphStyle, tText: str,
tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None tFmt: Sequence[tuple[int, int, str]] = [], isHead: bool = False, oLevel: str | None = None
) -> None: ) -> None:
"""Add a text paragraph to the text XML element.""" """Add a text paragraph to the text XML element."""
@@ -613,7 +630,7 @@ class ToOdt(Tokenizer):
tAttr[_mkTag("text", "outline-level")] = oLevel tAttr[_mkTag("text", "outline-level")] = oLevel
pTag = "h" if isHead else "p" pTag = "h" if isHead else "p"
xElem = ET.SubElement(self._xText, _mkTag("text", pTag), attrib=tAttr) xElem = ET.SubElement(xParent, _mkTag("text", pTag), attrib=tAttr)
# It's important to set the initial text field to empty, otherwise # 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. # xmlIndent will add a line break if the first subelement is a span.
@@ -631,7 +648,7 @@ class ToOdt(Tokenizer):
xFmt = 0x00 xFmt = 0x00
tFrag = "" tFrag = ""
fLast = 0 fLast = 0
for fPos, fFmt, _ in tFmt: for fPos, fFmt, fData in tFmt:
# Add the text up to the current fragment # Add the text up to the current fragment
if tFrag := tText[fLast:fPos]: if tFrag := tText[fLast:fPos]:
@@ -669,6 +686,8 @@ class ToOdt(Tokenizer):
xFmt |= X_SUB xFmt |= X_SUB
elif fFmt == self.FMT_SUB_E: elif fFmt == self.FMT_SUB_E:
xFmt &= M_SUB xFmt &= M_SUB
elif fFmt == self.FMT_FNOTE:
parProc.appendNode(self._etNotes.get(fData))
else: else:
pErr += 1 pErr += 1
@@ -739,6 +758,29 @@ class ToOdt(Tokenizer):
return style.name return style.name
def _preProcessNotes(self) -> None:
"""Generate XML elements for footnotes."""
fStyle = ODTParagraphStyle("New")
sStyle = ODTParagraphStyle("New")
sStyle.setTextIndent("0.000cm")
sStyle.setMarginLeft(self._mLeftFoot)
update = [key for key in self._footnotes.keys() if key not in self._etNotes]
for key in update:
cStyle = fStyle
self._nNote += 1
xNote = ET.Element(_mkTag("text", "note"), attrib={
_mkTag("text", "id"): f"ftn{self._nNote}",
_mkTag("text", "note-class"): "footnote",
})
xCite = ET.SubElement(xNote, _mkTag("text", "note-citation"))
xCite.text = str(self._nNote)
xBody = ET.SubElement(xNote, _mkTag("text", "note-body"))
for text, fmt in self._footnotes[key][1]:
self._addTextPar(xBody, "Footnote", cStyle, text, tFmt=fmt)
cStyle = sStyle
self._etNotes[key] = xNote
return
def _emToCm(self, value: float) -> str: def _emToCm(self, value: float) -> str:
"""Converts an em value to centimetres.""" """Converts an em value to centimetres."""
return f"{value*2.54/72*self._textSize:.3f}cm" return f"{value*2.54/72*self._textSize:.3f}cm"
@@ -989,6 +1031,18 @@ class ToOdt(Tokenizer):
style.packXML(self._xStyl) style.packXML(self._xStyl)
self._mainPara[style.name] = style self._mainPara[style.name] = style
# Add Footnote Style
style = ODTParagraphStyle("Footnote")
style.setDisplayName("Footnote")
style.setParentStyleName("Standard")
style.setClass("extra")
style.setMarginLeft(self._mLeftFoot)
style.setMarginBottom(self._mBotFoot)
style.setTextIndent("-"+self._mLeftFoot)
style.setFontSize(self._fSizeFoot)
style.packXML(self._xStyl)
self._mainPara[style.name] = style
return return
def _writeHeader(self) -> None: def _writeHeader(self) -> None:
@@ -1045,7 +1099,7 @@ class ODTParagraphStyle:
VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"] VALID_ALIGN = ["start", "center", "end", "justify", "inside", "outside", "left", "right"]
VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"] VALID_BREAK = ["auto", "column", "page", "even-page", "odd-page", "inherit"]
VALID_LEVEL = ["1", "2", "3", "4"] VALID_LEVEL = ["1", "2", "3", "4"]
VALID_CLASS = ["text", "chapter"] VALID_CLASS = ["text", "chapter", "extra"]
VALID_WEIGHT = ["normal", "inherit", "bold"] VALID_WEIGHT = ["normal", "inherit", "bold"]
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
@@ -1468,7 +1522,6 @@ class XMLParagraph:
if c == " ": if c == " ":
nSpaces += 1 nSpaces += 1
continue continue
elif nSpaces > 0: elif nSpaces > 0:
self._processSpaces(nSpaces) self._processSpaces(nSpaces)
nSpaces = 0 nSpaces = 0
@@ -1479,26 +1532,22 @@ class XMLParagraph:
self._xTail.tail = "" self._xTail.tail = ""
self._nState = X_ROOT_TAIL self._nState = X_ROOT_TAIL
self._chrPos += 1 self._chrPos += 1
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
self._xSing = ET.SubElement(self._xTail, TAG_BR) self._xSing = ET.SubElement(self._xTail, TAG_BR)
self._xSing.tail = "" self._xSing.tail = ""
self._nState = X_SPAN_SING self._nState = X_SPAN_SING
self._chrPos += 1 self._chrPos += 1
elif c == "\t": elif c == "\t":
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL): if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
self._xTail = ET.SubElement(self._xRoot, TAG_TAB) self._xTail = ET.SubElement(self._xRoot, TAG_TAB)
self._xTail.tail = "" self._xTail.tail = ""
self._nState = X_ROOT_TAIL self._nState = X_ROOT_TAIL
self._chrPos += 1 self._chrPos += 1
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING): elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
self._xSing = ET.SubElement(self._xTail, TAG_TAB) self._xSing = ET.SubElement(self._xTail, TAG_TAB)
self._xSing.tail = "" self._xSing.tail = ""
self._chrPos += 1 self._chrPos += 1
self._nState = X_SPAN_SING self._nState = X_SPAN_SING
else: else:
if self._nState == X_ROOT_TEXT: if self._nState == X_ROOT_TEXT:
self._xRoot.text = (self._xRoot.text or "") + c self._xRoot.text = (self._xRoot.text or "") + c
@@ -1533,6 +1582,23 @@ class XMLParagraph:
self._nState = X_ROOT_TAIL self._nState = X_ROOT_TAIL
return return
def appendNode(self, xNode: ET.Element | None) -> None:
"""Append an XML node to the paragraph."""
if xNode:
# We must make a copy in case the node is reused
xCopy = deepcopy(xNode)
if self._nState in (X_ROOT_TEXT, X_ROOT_TAIL):
self._xRoot.append(xCopy)
self._xTail = xCopy
self._xTail.tail = ""
self._nState = X_ROOT_TAIL
elif self._nState in (X_SPAN_TEXT, X_SPAN_SING):
self._xTail.append(xCopy)
self._xSing = xCopy
self._xSing.tail = ""
self._nState = X_SPAN_SING
return
def checkError(self) -> tuple[int, str]: def checkError(self) -> tuple[int, str]:
"""Check that the number of characters written matches the """Check that the number of characters written matches the
number of characters received. number of characters received.