Add nested Epub nav table (#3130)

This commit is contained in:
Veronica Berglyd Olsen
2026-11-02 21:36:26 +01:00
committed by GitHub
7 changed files with 190 additions and 26 deletions

View File

@@ -87,7 +87,7 @@ class ToEPub(ToHtml):
Extend the ToHtml class to write EPub output.
"""
__slots__ = ("_isFront", "_nextFnIndex", "_section", "_sections")
__slots__ = ("_isFront", "_nestedToc", "_nextFnIndex", "_section", "_sections")
def __init__(self, project: NWProject) -> None:
super().__init__(project)
@@ -97,18 +97,20 @@ class ToEPub(ToHtml):
self._brTag = "<br />"
self._nextFnIndex = 1
self._useAnchors = False
self._nestedToc = False
##
# Setters
##
def setReplaceUnicode(self, doReplace: bool) -> None:
"""Set translation map.
Replace Unicode is ignored for EPub.
"""
"""Set translation map. Replace Unicode is ignored for EPub."""
self._trMap = str.maketrans({"&": "&amp;"})
def setNestedToC(self, value: bool) -> None:
"""Set a nested table of contents."""
self._nestedToc = value
##
# Class Methods
##
@@ -382,15 +384,42 @@ class ToEPub(ToHtml):
xHtml.append("</header>")
xHtml.append('<nav epub:type="toc" role="doc-toc" aria-labelledby="toc">')
xHtml.append('<ol epub:type="list">')
xHtml.extend(
f'<li><a href="xhtml/{section.name}.xhtml#{section.sectionID}">{title}</a></li>'
for section in self._sections
if (title := section.title) and section.epubType != EPubType.COVER
)
nested = self._nestedToc and any(s.epubType == EPubType.PART for s in self._sections)
inPart = False
inList = False
for section in self._sections:
if not (title := section.title) or section.epubType == EPubType.COVER:
continue
link = f'<a href="xhtml/{section.name}.xhtml#{section.sectionID}">{title}</a>'
if inPart and section.epubType == EPubType.CHAPTER:
if not inList:
# A nested list must not be empty, so open it on first child
xHtml.append("<ol>")
inList = True
xHtml.append(f"<li>{link}</li>")
continue
if inList:
xHtml.append("</ol>")
inList = False
if inPart:
xHtml.append("</li>")
inPart = False
if nested and section.epubType == EPubType.PART:
xHtml.append(f"<li>{link}")
inPart = True
else:
xHtml.append(f"<li>{link}</li>")
if inList:
xHtml.append("</ol>")
if inPart:
xHtml.append("</li>")
xHtml.append("</ol>")
xHtml.append("</nav>")
xHtml.append("</body>")
xHtml.append("</html>")
return "\n".join(xHtml)
def _generateStyleSheet(self) -> str:

View File

@@ -138,6 +138,8 @@ SETTINGS_TEMPLATE: dict[str, tuple[type, T_BuildValue]] = {
"doc.pageCountOffset": (int, 0),
"doc.metaLanguage": (str, ""),
"epub.nestedToc": (bool, False),
"html.addStyles": (bool, True),
"html.preserveTabs": (bool, False),
}
@@ -213,6 +215,9 @@ SETTINGS_LABELS = {
"doc.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page counter offset"),
"doc.metaLanguage": QT_TRANSLATE_NOOP("Builds", "Override document language"),
"epub": QT_TRANSLATE_NOOP("Builds", "EPUB Options"),
"epub.nestedToc": QT_TRANSLATE_NOOP("Builds", "Nested table of contents"),
"html": QT_TRANSLATE_NOOP("Builds", "HTML Options"),
"html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS styles"),
"html.preserveTabs": QT_TRANSLATE_NOOP("Builds", "Preserve tab characters"),

View File

@@ -348,6 +348,9 @@ class DocumentBuilder:
bldObj.setCommentType(nwComment.STORY, self._build.getBool("text.includeStory"))
bldObj.setCommentType(nwComment.NOTE, self._build.getBool("text.includeNotes"))
if isinstance(bldObj, ToEPub):
bldObj.setNestedToC(self._build.getBool("epub.nestedToc"))
if isinstance(bldObj, ToHtml):
bldObj.setStyles(self._build.getBool("html.addStyles"))
bldObj.setReplaceUnicode(self._build.getBool("format.stripUnicode"))

View File

@@ -1524,6 +1524,18 @@ class _FormattingTab(NScrollableForm):
self.addRow(self._build.getLabel("doc.metaLanguage"), [self.lblMetaLanguage, 8, self.metaLanguage])
# EPUB Document
# =============
title = self._build.getLabel("epub")
section += 1
self._sidebar.addButton(title, section)
self.addGroupLabel(title, section)
self.nestedToc = NSwitch(self, height=iPx)
self.addRow(self._build.getLabel("epub.nestedToc"), self.nestedToc)
# HTML Document
# =============
@@ -1597,8 +1609,6 @@ class _FormattingTab(NScrollableForm):
def loadContent(self) -> None:
"""Populate the widgets."""
# Text Content
# ============
self.incBodyText.setChecked(self._build.getBool("text.includeBodyText"))
self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis"))
self.incComments.setChecked(self._build.getBool("text.includeComments"))
@@ -1610,8 +1620,6 @@ class _FormattingTab(NScrollableForm):
self._updateIgnoredKeywords()
# Text Format
# ===========
font = QFont()
font.fromString(self._build.getStr("format.textFont"))
self._textFont = fontMatcher(font)
@@ -1629,23 +1637,17 @@ class _FormattingTab(NScrollableForm):
self.showDialogue.setChecked(self._build.getBool("format.showDialogue"))
# Heading Format
# ==============
self.colorHeadings.setChecked(self._build.getBool("format.colorHeadings"))
self.boldHeadings.setChecked(self._build.getBool("format.boldHeadings"))
self.upperHeadings.setChecked(self._build.getBool("format.upperHeadings"))
self.showStatus.setChecked(self._build.getBool("format.showStatus"))
# First Line Indent
# =================
self.firstIndent.setChecked(self._build.getBool("format.firstLineIndent"))
self.indentWidth.setValue(self._build.getFloat("format.firstIndentWidth"))
self.indentFirstPar.setChecked(self._build.getBool("format.indentFirstPar"))
# Text Margins
# ============
self.titleSize.setValue(self._build.getFloat("format.titleSize"))
self.h1Size.setValue(self._build.getFloat("format.h1Size"))
self.h2Size.setValue(self._build.getFloat("format.h2Size"))
@@ -1673,8 +1675,6 @@ class _FormattingTab(NScrollableForm):
self._updateTextMarginState()
# Page Layout
# ===========
pageUnit = self._build.getStr("format.pageUnit")
index = self.pageUnit.findData(pageUnit)
if index >= 0:
@@ -1703,8 +1703,6 @@ class _FormattingTab(NScrollableForm):
self.pageSize.currentIndexChanged.connect(self._changePageSize)
# Document
# ========
self.metaLanguage.setText(processLangCode(self._build.getStr("doc.metaLanguage")))
self.pageHeader.setText(self._build.getStr("doc.pageHeader"))
self.pageHeader.setCursorPosition(0)
@@ -1712,9 +1710,10 @@ class _FormattingTab(NScrollableForm):
self.pageHeaderEven.setCursorPosition(0)
self.pageCountOffset.setValue(self._build.getInt("doc.pageCountOffset"))
# HTML Document
# =============
# EPUB Document
self.nestedToc.setChecked(self._build.getBool("epub.nestedToc"))
# HTML Document
self.htmlAddStyles.setChecked(self._build.getBool("html.addStyles"))
self.htmlPreserveTabs.setChecked(self._build.getBool("html.preserveTabs"))
@@ -1801,6 +1800,9 @@ class _FormattingTab(NScrollableForm):
self.metaLanguage.setText(metaLanguage)
# EPUB Document
self._build.setValue("epub.nestedToc", self.nestedToc.isChecked())
# HTML Document
self._build.setValue("html.addStyles", self.htmlAddStyles.isChecked())
self._build.setValue("html.preserveTabs", self.htmlPreserveTabs.isChecked())

View File

@@ -622,6 +622,83 @@ def testToEPub_ConvertDirect(mockGUI: MockGuiMain):
assert epub._sections[-1].text[-1] == "<p style='text-indent: 1.40em;'>Some text ...</p>"
@pytest.mark.core
def testToEPub_NavPage(mockGUI: MockGuiMain):
"""Test the nav page table of contents."""
project = NWProject()
def build(*docs: tuple[bool, str]) -> ToEPub:
epub = ToEPub(project)
epub.initDocument()
epub.setPartitionFormat(nwHeadFmt.TITLE)
epub.setChapterFormat(nwHeadFmt.TITLE)
for isNovel, text in docs:
epub._isNovel = isNovel
epub._text = text
epub.tokenizeText()
epub.doConvert()
epub.closeDocument()
return epub
def navList(epub: ToEPub) -> str:
text = epub._generateNavPage()
return text[text.index('<ol epub:type="list">') + 22 : text.index("</nav>") - 6]
# No parts
epub = build((True, "## Chapter One\n\n## Chapter Two\n\n"))
epub.setNestedToC(True)
assert navList(epub) == (
'<li><a href="xhtml/chapter1.xhtml#sec_1">Chapter One</a></li>\n'
'<li><a href="xhtml/chapter2.xhtml#sec_2">Chapter Two</a></li>\n'
)
# With parts, ending inside a part
text = (
"## Prologue\n\n# Part One\n\n## Chapter One\n\n## Chapter Two\n\n"
"# Part Two\n\n# Part Three\n\n## Chapter Three\n\n"
)
epub = build((True, text))
assert navList(epub) == (
'<li><a href="xhtml/chapter1.xhtml#sec_1">Prologue</a></li>\n'
'<li><a href="xhtml/part1.xhtml#sec_2">Part One</a></li>\n'
'<li><a href="xhtml/chapter2.xhtml#sec_3">Chapter One</a></li>\n'
'<li><a href="xhtml/chapter3.xhtml#sec_4">Chapter Two</a></li>\n'
'<li><a href="xhtml/part2.xhtml#sec_5">Part Two</a></li>\n'
'<li><a href="xhtml/part3.xhtml#sec_6">Part Three</a></li>\n'
'<li><a href="xhtml/chapter4.xhtml#sec_7">Chapter Three</a></li>\n'
)
epub.setNestedToC(True)
assert navList(epub) == (
'<li><a href="xhtml/chapter1.xhtml#sec_1">Prologue</a></li>\n'
'<li><a href="xhtml/part1.xhtml#sec_2">Part One</a>\n'
"<ol>\n"
'<li><a href="xhtml/chapter2.xhtml#sec_3">Chapter One</a></li>\n'
'<li><a href="xhtml/chapter3.xhtml#sec_4">Chapter Two</a></li>\n'
"</ol>\n"
"</li>\n"
'<li><a href="xhtml/part2.xhtml#sec_5">Part Two</a>\n'
"</li>\n"
'<li><a href="xhtml/part3.xhtml#sec_6">Part Three</a>\n'
"<ol>\n"
'<li><a href="xhtml/chapter4.xhtml#sec_7">Chapter Three</a></li>\n'
"</ol>\n"
"</li>\n"
)
# Notes after a part are not nested
epub = build((True, "# Part One\n\n## Chapter One\n\n"), (False, "#! Notes\n\n"))
epub.setNestedToC(True)
assert navList(epub) == (
'<li><a href="xhtml/part1.xhtml#sec_1">Part One</a>\n'
"<ol>\n"
'<li><a href="xhtml/chapter1.xhtml#sec_2">Chapter One</a></li>\n'
"</ol>\n"
"</li>\n"
'<li><a href="xhtml/notes1.xhtml#sec_3">Notes</a></li>\n'
)
@pytest.mark.core
def testToEPub_Save(mockGUI: MockGuiMain, fncPath: Path, tstPaths: TstPaths, ipsumText: list[str]):
"""Test the save method of the ToEPub class."""

View File

@@ -380,6 +380,47 @@ def testDocumentBuilder_DocX(mockGUI: MockGuiMain, prjLipsum: Path, fncPath: Pat
assert zipfile.is_zipfile(docFile)
@pytest.mark.core
def testDocumentBuilder_EPub(mockGUI: MockGuiMain, prjLipsum: Path, fncPath: Path):
"""Test building an EPub manuscript."""
project = NWProject()
project.openProject(prjLipsum)
build = BuildSettings()
build.unpack(BUILD_CONF)
build.setValue("epub.nestedToc", True)
docBuild = DocumentBuilder(project, build)
docBuild.queueAll()
assert len(docBuild) == 19
# Check Build
# ===========
docFile = fncPath / "Lorem Ipsum.epub"
count = 0
error = []
for _, success in docBuild.iterBuildDocument(docFile, nwBuildFmt.EPUB):
count += 1 if success else 0
if docBuild.error:
error.append(docBuild.error)
assert count == 19
assert error == []
assert docFile.is_file()
assert zipfile.is_zipfile(docFile)
with zipfile.ZipFile(docFile, mode="r") as zipObj:
navText = zipObj.read("OEBPS/nav.xhtml").decode("utf-8")
assert (
'<li><a href="xhtml/part1.xhtml#sec_3">Part: Act One</a>\n'
"<ol>\n"
'<li><a href="xhtml/chapter2.xhtml#sec_4">Chapter: Chapter One</a></li>\n'
) in navText
@pytest.mark.core
def testDocumentBuilder_PDF(mockGUI: MockGuiMain, prjLipsum: Path, fncPath: Path):
"""Test building a PDF manuscript."""

View File

@@ -1088,6 +1088,8 @@ def testGuiBuildSettings_FormatOutput(qtbot: QtBot, nwGUI: GuiMain):
build.setValue("doc.pageHeaderEven", nwHeadFmt.DOC_AUTO_EVEN)
build.setValue("doc.pageCountOffset", 0)
build.setValue("epub.nestedToc", False)
build.setValue("html.addStyles", False)
build.setValue("html.preserveTabs", False)
@@ -1114,6 +1116,8 @@ def testGuiBuildSettings_FormatOutput(qtbot: QtBot, nwGUI: GuiMain):
assert fmtTab.pageHeaderEven.isEnabled() is True
assert fmtTab.btnPageHeaderEven.isEnabled() is True
assert fmtTab.nestedToc.isChecked() is False
assert fmtTab.htmlAddStyles.isChecked() is False
assert fmtTab.htmlPreserveTabs.isChecked() is False
@@ -1127,6 +1131,7 @@ def testGuiBuildSettings_FormatOutput(qtbot: QtBot, nwGUI: GuiMain):
assert fmtTab.lblMetaLanguage.text() == "British English"
# Toggle all
fmtTab.nestedToc.setChecked(True)
fmtTab.htmlAddStyles.setChecked(True)
fmtTab.htmlPreserveTabs.setChecked(True)
@@ -1140,6 +1145,8 @@ def testGuiBuildSettings_FormatOutput(qtbot: QtBot, nwGUI: GuiMain):
assert sBuild.getInt("doc.pageCountOffset") == 1
assert sBuild.getStr("doc.metaLanguage") == "en-GB"
assert sBuild.getBool("epub.nestedToc") is True
assert sBuild.getBool("html.addStyles") is True
assert sBuild.getBool("html.preserveTabs") is True