Add a working ODT footnotes implementation
This commit is contained in:
+89
-23
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user