Add a working ODT footnotes implementation
This commit is contained in:
+89
-23
@@ -29,11 +29,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from collections.abc import Sequence
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
from datetime import datetime
|
||||
from collections.abc import Sequence
|
||||
|
||||
from novelwriter import __version__
|
||||
from novelwriter.common import xmlIndent
|
||||
@@ -130,6 +131,10 @@ class ToOdt(Tokenizer):
|
||||
self._autoPara: dict[str, ODTParagraphStyle] = {} # Auto-generated paragraph 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
|
||||
|
||||
# Properties
|
||||
@@ -151,6 +156,7 @@ class ToOdt(Tokenizer):
|
||||
self._fSizeHead4 = "14pt"
|
||||
self._fSizeHead = "14pt"
|
||||
self._fSizeText = "12pt"
|
||||
self._fSizeFoot = "10pt"
|
||||
self._fLineHeight = "115%"
|
||||
self._fBlockIndent = "1.693cm"
|
||||
self._fTextIndent = "0.499cm"
|
||||
@@ -177,6 +183,9 @@ class ToOdt(Tokenizer):
|
||||
self._mBotText = "0.247cm"
|
||||
self._mBotMeta = "0.106cm"
|
||||
|
||||
self._mBotFoot = "0.106cm"
|
||||
self._mLeftFoot = "0.600cm"
|
||||
|
||||
# Document Size and Margins
|
||||
self._mDocWidth = "21.0cm"
|
||||
self._mDocHeight = "29.7cm"
|
||||
@@ -258,6 +267,7 @@ class ToOdt(Tokenizer):
|
||||
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"
|
||||
self._fSizeFoot = f"{round(0.8*self._textSize):d}pt"
|
||||
|
||||
mScale = self._lineHeight/1.15
|
||||
|
||||
@@ -279,6 +289,9 @@ class ToOdt(Tokenizer):
|
||||
self._mBotText = self._emToCm(mScale * self._marginText[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:
|
||||
self._colHead12 = "#2a6099"
|
||||
self._opaHead12 = "100%"
|
||||
@@ -289,6 +302,7 @@ class ToOdt(Tokenizer):
|
||||
|
||||
self._fLineHeight = f"{round(100 * self._lineHeight):d}%"
|
||||
self._fBlockIndent = self._emToCm(self._blockIndent)
|
||||
self._fTextIndent = self._emToCm(self._textIndent)
|
||||
self._textAlign = "justify" if self._doJustify else "left"
|
||||
|
||||
# Clear Errors
|
||||
@@ -398,11 +412,13 @@ class ToOdt(Tokenizer):
|
||||
def doConvert(self) -> None:
|
||||
"""Convert the list of text tokens into XML elements."""
|
||||
self._result = "" # Not used, but cleared just in case
|
||||
self._preProcessNotes()
|
||||
|
||||
pFmt: list[T_Formats] = []
|
||||
pText = []
|
||||
pStyle = None
|
||||
pIndent = True
|
||||
xText = self._xText
|
||||
for tType, _, tText, tFormat, tStyle in self._tokens:
|
||||
|
||||
# Styles
|
||||
@@ -453,7 +469,7 @@ class ToOdt(Tokenizer):
|
||||
# Don't indent a paragraph if it has alignment set
|
||||
tIndent = self._firstIndent and pIndent and pStyle.isUnaligned()
|
||||
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
|
||||
)
|
||||
pIndent = True
|
||||
@@ -463,30 +479,31 @@ class ToOdt(Tokenizer):
|
||||
pStyle = None
|
||||
|
||||
elif tType == self.T_TITLE:
|
||||
# Title must be text:p
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
self._addTextPar("Separator", oStyle, tText)
|
||||
self._addTextPar(xText, "Separator", oStyle, tText)
|
||||
|
||||
elif tType == self.T_SKIP:
|
||||
self._addTextPar("Separator", oStyle, "")
|
||||
self._addTextPar(xText, "Separator", oStyle, "")
|
||||
|
||||
elif tType == self.T_TEXT:
|
||||
if pStyle is None:
|
||||
@@ -496,19 +513,19 @@ class ToOdt(Tokenizer):
|
||||
|
||||
elif tType == self.T_SYNOPSIS and self._doSynopsis:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -604,7 +621,7 @@ class ToOdt(Tokenizer):
|
||||
return rTxt, rFmt
|
||||
|
||||
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
|
||||
) -> None:
|
||||
"""Add a text paragraph to the text XML element."""
|
||||
@@ -613,7 +630,7 @@ class ToOdt(Tokenizer):
|
||||
tAttr[_mkTag("text", "outline-level")] = oLevel
|
||||
|
||||
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
|
||||
# xmlIndent will add a line break if the first subelement is a span.
|
||||
@@ -631,7 +648,7 @@ class ToOdt(Tokenizer):
|
||||
xFmt = 0x00
|
||||
tFrag = ""
|
||||
fLast = 0
|
||||
for fPos, fFmt, _ in tFmt:
|
||||
for fPos, fFmt, fData in tFmt:
|
||||
|
||||
# Add the text up to the current fragment
|
||||
if tFrag := tText[fLast:fPos]:
|
||||
@@ -669,6 +686,8 @@ class ToOdt(Tokenizer):
|
||||
xFmt |= X_SUB
|
||||
elif fFmt == self.FMT_SUB_E:
|
||||
xFmt &= M_SUB
|
||||
elif fFmt == self.FMT_FNOTE:
|
||||
parProc.appendNode(self._etNotes.get(fData))
|
||||
else:
|
||||
pErr += 1
|
||||
|
||||
@@ -739,6 +758,29 @@ class ToOdt(Tokenizer):
|
||||
|
||||
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:
|
||||
"""Converts an em value to centimetres."""
|
||||
return f"{value*2.54/72*self._textSize:.3f}cm"
|
||||
@@ -989,6 +1031,18 @@ class ToOdt(Tokenizer):
|
||||
style.packXML(self._xStyl)
|
||||
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
|
||||
|
||||
def _writeHeader(self) -> None:
|
||||
@@ -1045,7 +1099,7 @@ 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_CLASS = ["text", "chapter", "extra"]
|
||||
VALID_WEIGHT = ["normal", "inherit", "bold"]
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
@@ -1468,7 +1522,6 @@ class XMLParagraph:
|
||||
if c == " ":
|
||||
nSpaces += 1
|
||||
continue
|
||||
|
||||
elif nSpaces > 0:
|
||||
self._processSpaces(nSpaces)
|
||||
nSpaces = 0
|
||||
@@ -1479,26 +1532,22 @@ class XMLParagraph:
|
||||
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
|
||||
@@ -1533,6 +1582,23 @@ class XMLParagraph:
|
||||
self._nState = X_ROOT_TAIL
|
||||
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]:
|
||||
"""Check that the number of characters written matches the
|
||||
number of characters received.
|
||||
|
||||
Reference in New Issue
Block a user