epub-generator 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- epub_generator/__init__.py +41 -0
- epub_generator/context.py +141 -0
- epub_generator/data/container.xml.jinja +6 -0
- epub_generator/data/content.opf.jinja +68 -0
- epub_generator/data/cover.xhtml.jinja +16 -0
- epub_generator/data/mimetype.jinja +1 -0
- epub_generator/data/nav.xhtml.jinja +43 -0
- epub_generator/data/part.xhtml.jinja +24 -0
- epub_generator/data/style.css.jinja +68 -0
- epub_generator/generation/__init__.py +1 -0
- epub_generator/generation/gen_asset.py +156 -0
- epub_generator/generation/gen_chapter.py +177 -0
- epub_generator/generation/gen_epub.py +198 -0
- epub_generator/generation/gen_nav.py +92 -0
- epub_generator/generation/gen_toc.py +88 -0
- epub_generator/generation/xml_utils.py +31 -0
- epub_generator/html_tag.py +11 -0
- epub_generator/i18n.py +17 -0
- epub_generator/options.py +12 -0
- epub_generator/template.py +52 -0
- epub_generator/types.py +154 -0
- epub_generator-0.1.3.dist-info/LICENSE +21 -0
- epub_generator-0.1.3.dist-info/METADATA +570 -0
- epub_generator-0.1.3.dist-info/RECORD +25 -0
- epub_generator-0.1.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
from xml.etree.ElementTree import Element
|
|
3
|
+
|
|
4
|
+
from ..context import Context
|
|
5
|
+
from ..i18n import I18N
|
|
6
|
+
from ..types import (
|
|
7
|
+
Chapter,
|
|
8
|
+
ContentBlock,
|
|
9
|
+
Formula,
|
|
10
|
+
HTMLTag,
|
|
11
|
+
Image,
|
|
12
|
+
Mark,
|
|
13
|
+
Table,
|
|
14
|
+
TextBlock,
|
|
15
|
+
TextKind,
|
|
16
|
+
)
|
|
17
|
+
from .gen_asset import process_formula, process_image, process_table
|
|
18
|
+
from .xml_utils import serialize_element, set_epub_type
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_chapter(
|
|
22
|
+
context: Context,
|
|
23
|
+
chapter: Chapter,
|
|
24
|
+
i18n: I18N,
|
|
25
|
+
) -> str:
|
|
26
|
+
return context.template.render(
|
|
27
|
+
template="part.xhtml",
|
|
28
|
+
i18n=i18n,
|
|
29
|
+
content=[
|
|
30
|
+
serialize_element(child)
|
|
31
|
+
for child in _render_contents(context, chapter)
|
|
32
|
+
],
|
|
33
|
+
citations=[
|
|
34
|
+
serialize_element(child)
|
|
35
|
+
for child in _render_footnotes(context, chapter)
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def _render_contents(
|
|
40
|
+
context: Context,
|
|
41
|
+
chapter: Chapter,
|
|
42
|
+
) -> Generator[Element, None, None]:
|
|
43
|
+
for block in chapter.elements:
|
|
44
|
+
layout = _render_content_block(context, block)
|
|
45
|
+
if layout is not None:
|
|
46
|
+
yield layout
|
|
47
|
+
|
|
48
|
+
def _render_footnotes(
|
|
49
|
+
context: Context,
|
|
50
|
+
chapter: Chapter,
|
|
51
|
+
) -> Generator[Element, None, None]:
|
|
52
|
+
for footnote in chapter.footnotes:
|
|
53
|
+
if not footnote.has_mark or not footnote.contents:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# Use <aside> with EPUB 3.0 semantic attributes
|
|
57
|
+
citation_aside = Element("aside")
|
|
58
|
+
citation_aside.attrib = {
|
|
59
|
+
"id": f"fn-{footnote.id}",
|
|
60
|
+
"class": "footnote",
|
|
61
|
+
}
|
|
62
|
+
set_epub_type(citation_aside, "footnote")
|
|
63
|
+
|
|
64
|
+
for block in footnote.contents:
|
|
65
|
+
layout = _render_content_block(context, block)
|
|
66
|
+
if layout is not None:
|
|
67
|
+
citation_aside.append(layout)
|
|
68
|
+
|
|
69
|
+
if len(citation_aside) == 0:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Back-reference link with EPUB 3.0 attributes
|
|
73
|
+
ref = Element("a")
|
|
74
|
+
ref.text = f"[{footnote.id}]"
|
|
75
|
+
ref.attrib = {
|
|
76
|
+
"href": f"#ref-{footnote.id}",
|
|
77
|
+
}
|
|
78
|
+
first_layout = citation_aside[0]
|
|
79
|
+
if first_layout.tag == "p":
|
|
80
|
+
ref.tail = first_layout.text
|
|
81
|
+
first_layout.text = None
|
|
82
|
+
first_layout.insert(0, ref)
|
|
83
|
+
else:
|
|
84
|
+
inject_p = Element("p")
|
|
85
|
+
inject_p.append(ref)
|
|
86
|
+
citation_aside.insert(0, inject_p)
|
|
87
|
+
|
|
88
|
+
yield citation_aside
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_content_block(context: Context, block: ContentBlock) -> Element | None:
|
|
92
|
+
if isinstance(block, TextBlock):
|
|
93
|
+
if block.kind == TextKind.HEADLINE:
|
|
94
|
+
container = Element("h1")
|
|
95
|
+
elif block.kind == TextKind.QUOTE:
|
|
96
|
+
container = Element("p")
|
|
97
|
+
elif block.kind == TextKind.BODY:
|
|
98
|
+
container = Element("p")
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(f"Unknown TextKind: {block.kind}")
|
|
101
|
+
|
|
102
|
+
_render_text_content(
|
|
103
|
+
context=context,
|
|
104
|
+
parent=container,
|
|
105
|
+
content=block.content,
|
|
106
|
+
)
|
|
107
|
+
if block.kind == TextKind.QUOTE:
|
|
108
|
+
blockquote = Element("blockquote")
|
|
109
|
+
blockquote.append(container)
|
|
110
|
+
return blockquote
|
|
111
|
+
|
|
112
|
+
return container
|
|
113
|
+
|
|
114
|
+
elif isinstance(block, Table):
|
|
115
|
+
return process_table(context, block)
|
|
116
|
+
|
|
117
|
+
elif isinstance(block, Formula):
|
|
118
|
+
return process_formula(context, block, inline_mode=False)
|
|
119
|
+
|
|
120
|
+
elif isinstance(block, Image):
|
|
121
|
+
return process_image(context, block)
|
|
122
|
+
|
|
123
|
+
else:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render_text_content(context: Context, parent: Element, content: list[str | Mark | Formula | HTMLTag]) -> None:
|
|
128
|
+
"""Render text content with inline citation marks."""
|
|
129
|
+
current_element = parent
|
|
130
|
+
for item in content:
|
|
131
|
+
if isinstance(item, str):
|
|
132
|
+
if current_element is parent:
|
|
133
|
+
if parent.text is None:
|
|
134
|
+
parent.text = item
|
|
135
|
+
else:
|
|
136
|
+
parent.text += item
|
|
137
|
+
else:
|
|
138
|
+
if current_element.tail is None:
|
|
139
|
+
current_element.tail = item
|
|
140
|
+
else:
|
|
141
|
+
current_element.tail += item
|
|
142
|
+
|
|
143
|
+
elif isinstance(item, HTMLTag):
|
|
144
|
+
tag_element = Element(item.name)
|
|
145
|
+
for attr, value in item.attributes:
|
|
146
|
+
tag_element.set(attr, value)
|
|
147
|
+
_render_text_content(
|
|
148
|
+
context=context,
|
|
149
|
+
parent=tag_element,
|
|
150
|
+
content=item.content,
|
|
151
|
+
)
|
|
152
|
+
parent.append(tag_element)
|
|
153
|
+
current_element = tag_element
|
|
154
|
+
|
|
155
|
+
elif isinstance(item, Formula):
|
|
156
|
+
formula_element = process_formula(
|
|
157
|
+
context=context,
|
|
158
|
+
formula=item,
|
|
159
|
+
inline_mode=True,
|
|
160
|
+
)
|
|
161
|
+
if formula_element is not None:
|
|
162
|
+
parent.append(formula_element)
|
|
163
|
+
current_element = formula_element
|
|
164
|
+
|
|
165
|
+
elif isinstance(item, Mark):
|
|
166
|
+
# EPUB 3.0 noteref with semantic attributes
|
|
167
|
+
anchor = Element("a")
|
|
168
|
+
anchor.attrib = {
|
|
169
|
+
"id": f"ref-{item.id}",
|
|
170
|
+
"href": f"#fn-{item.id}",
|
|
171
|
+
"class": "super",
|
|
172
|
+
}
|
|
173
|
+
# Set epub:type using utility function (avoids global namespace pollution)
|
|
174
|
+
set_epub_type(anchor, "noteref")
|
|
175
|
+
anchor.text = f"[{item.id}]"
|
|
176
|
+
parent.append(anchor)
|
|
177
|
+
current_element = anchor
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from os import PathLike
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, Literal
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
from zipfile import ZipFile
|
|
7
|
+
|
|
8
|
+
from ..context import Context, Template
|
|
9
|
+
from ..html_tag import search_content
|
|
10
|
+
from ..i18n import I18N
|
|
11
|
+
from ..options import LaTeXRender, TableRender
|
|
12
|
+
from ..types import Chapter, EpubData, Formula, TextBlock
|
|
13
|
+
from .gen_chapter import generate_chapter
|
|
14
|
+
from .gen_nav import gen_nav
|
|
15
|
+
from .gen_toc import NavPoint, gen_toc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_epub(
|
|
19
|
+
epub_data: EpubData,
|
|
20
|
+
epub_file_path: PathLike,
|
|
21
|
+
lan: Literal["zh", "en"] = "zh",
|
|
22
|
+
table_render: TableRender = TableRender.HTML,
|
|
23
|
+
latex_render: LaTeXRender = LaTeXRender.MATHML,
|
|
24
|
+
assert_not_aborted: Callable[[], None] = lambda: None,
|
|
25
|
+
) -> None:
|
|
26
|
+
i18n = I18N(lan)
|
|
27
|
+
template = Template()
|
|
28
|
+
epub_file_path = Path(epub_file_path)
|
|
29
|
+
|
|
30
|
+
# Generate navigation points from TOC structure
|
|
31
|
+
has_cover = epub_data.cover_image_path is not None
|
|
32
|
+
nav_points = gen_toc(epub_data=epub_data, has_cover=has_cover)
|
|
33
|
+
|
|
34
|
+
epub_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
with ZipFile(epub_file_path, "w") as file:
|
|
37
|
+
context = Context(
|
|
38
|
+
file=file,
|
|
39
|
+
template=template,
|
|
40
|
+
table_render=table_render,
|
|
41
|
+
latex_render=latex_render,
|
|
42
|
+
)
|
|
43
|
+
file.writestr(
|
|
44
|
+
zinfo_or_arcname="mimetype",
|
|
45
|
+
data=template.render("mimetype").encode("utf-8"),
|
|
46
|
+
)
|
|
47
|
+
assert_not_aborted()
|
|
48
|
+
|
|
49
|
+
_write_chapters_from_data(
|
|
50
|
+
context=context,
|
|
51
|
+
i18n=i18n,
|
|
52
|
+
nav_points=nav_points,
|
|
53
|
+
epub_data=epub_data,
|
|
54
|
+
latex_render=latex_render,
|
|
55
|
+
assert_not_aborted=assert_not_aborted,
|
|
56
|
+
)
|
|
57
|
+
nav_xhtml = gen_nav(
|
|
58
|
+
template=template,
|
|
59
|
+
i18n=i18n,
|
|
60
|
+
epub_data=epub_data,
|
|
61
|
+
nav_points=nav_points,
|
|
62
|
+
has_cover=has_cover,
|
|
63
|
+
)
|
|
64
|
+
file.writestr(
|
|
65
|
+
zinfo_or_arcname="OEBPS/nav.xhtml",
|
|
66
|
+
data=nav_xhtml.encode("utf-8"),
|
|
67
|
+
)
|
|
68
|
+
assert_not_aborted()
|
|
69
|
+
|
|
70
|
+
_write_basic_files(
|
|
71
|
+
context=context,
|
|
72
|
+
i18n=i18n,
|
|
73
|
+
epub_data=epub_data,
|
|
74
|
+
nav_points=nav_points,
|
|
75
|
+
)
|
|
76
|
+
assert_not_aborted()
|
|
77
|
+
|
|
78
|
+
_write_assets_from_data(
|
|
79
|
+
context=context,
|
|
80
|
+
i18n=i18n,
|
|
81
|
+
epub_data=epub_data,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _write_assets_from_data(
|
|
85
|
+
context: Context,
|
|
86
|
+
i18n: I18N,
|
|
87
|
+
epub_data: EpubData,
|
|
88
|
+
):
|
|
89
|
+
context.file.writestr(
|
|
90
|
+
zinfo_or_arcname="OEBPS/styles/style.css",
|
|
91
|
+
data=context.template.render("style.css").encode("utf-8"),
|
|
92
|
+
)
|
|
93
|
+
if epub_data.cover_image_path:
|
|
94
|
+
context.file.writestr(
|
|
95
|
+
zinfo_or_arcname="OEBPS/Text/cover.xhtml",
|
|
96
|
+
data=context.template.render(
|
|
97
|
+
template="cover.xhtml",
|
|
98
|
+
i18n=i18n,
|
|
99
|
+
).encode("utf-8"),
|
|
100
|
+
)
|
|
101
|
+
if epub_data.cover_image_path:
|
|
102
|
+
context.file.write(
|
|
103
|
+
filename=epub_data.cover_image_path,
|
|
104
|
+
arcname="OEBPS/assets/cover.png",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _write_chapters_from_data(
|
|
108
|
+
context: Context,
|
|
109
|
+
i18n: I18N,
|
|
110
|
+
nav_points: list[NavPoint],
|
|
111
|
+
epub_data: EpubData,
|
|
112
|
+
latex_render: LaTeXRender,
|
|
113
|
+
assert_not_aborted: Callable[[], None],
|
|
114
|
+
):
|
|
115
|
+
if epub_data.get_head is not None:
|
|
116
|
+
chapter = epub_data.get_head()
|
|
117
|
+
data = generate_chapter(context, chapter, i18n)
|
|
118
|
+
context.file.writestr(
|
|
119
|
+
zinfo_or_arcname="OEBPS/Text/head.xhtml",
|
|
120
|
+
data=data.encode("utf-8"),
|
|
121
|
+
)
|
|
122
|
+
if latex_render == LaTeXRender.MATHML and _chapter_has_formula(chapter):
|
|
123
|
+
context.mark_chapter_has_mathml("head.xhtml")
|
|
124
|
+
assert_not_aborted()
|
|
125
|
+
|
|
126
|
+
for nav_point in nav_points:
|
|
127
|
+
if nav_point.get_chapter is not None:
|
|
128
|
+
chapter = nav_point.get_chapter()
|
|
129
|
+
data = generate_chapter(context, chapter, i18n)
|
|
130
|
+
context.file.writestr(
|
|
131
|
+
zinfo_or_arcname="OEBPS/Text/" + nav_point.file_name,
|
|
132
|
+
data=data.encode("utf-8"),
|
|
133
|
+
)
|
|
134
|
+
if latex_render == LaTeXRender.MATHML and _chapter_has_formula(chapter):
|
|
135
|
+
context.mark_chapter_has_mathml(nav_point.file_name)
|
|
136
|
+
assert_not_aborted()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _chapter_has_formula(chapter: Chapter) -> bool:
|
|
140
|
+
"""Check if chapter contains any formulas (block-level or inline)."""
|
|
141
|
+
for element in chapter.elements:
|
|
142
|
+
if isinstance(element, Formula):
|
|
143
|
+
return True
|
|
144
|
+
if isinstance(element, TextBlock):
|
|
145
|
+
for item in search_content(element.content):
|
|
146
|
+
if isinstance(item, Formula):
|
|
147
|
+
return True
|
|
148
|
+
for footnote in chapter.footnotes:
|
|
149
|
+
for content_block in footnote.contents:
|
|
150
|
+
if isinstance(content_block, Formula):
|
|
151
|
+
return True
|
|
152
|
+
if isinstance(content_block, TextBlock):
|
|
153
|
+
for item in search_content(content_block.content):
|
|
154
|
+
if isinstance(item, Formula):
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def _write_basic_files(
|
|
159
|
+
context: Context,
|
|
160
|
+
i18n: I18N,
|
|
161
|
+
epub_data: EpubData,
|
|
162
|
+
nav_points: list[NavPoint],
|
|
163
|
+
):
|
|
164
|
+
meta = epub_data.meta
|
|
165
|
+
has_cover = epub_data.cover_image_path is not None
|
|
166
|
+
has_head_chapter = epub_data.get_head is not None
|
|
167
|
+
|
|
168
|
+
context.file.writestr(
|
|
169
|
+
zinfo_or_arcname="META-INF/container.xml",
|
|
170
|
+
data=context.template.render("container.xml").encode("utf-8"),
|
|
171
|
+
)
|
|
172
|
+
isbn = (meta.isbn if meta else None) or str(uuid4())
|
|
173
|
+
if meta and meta.modified:
|
|
174
|
+
modified_timestamp = meta.modified.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
175
|
+
else:
|
|
176
|
+
modified_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
177
|
+
|
|
178
|
+
chapters_with_mathml = {
|
|
179
|
+
nav_point.file_name
|
|
180
|
+
for nav_point in nav_points
|
|
181
|
+
if context.chapter_has_mathml(nav_point.file_name)
|
|
182
|
+
}
|
|
183
|
+
content = context.template.render(
|
|
184
|
+
template="content.opf",
|
|
185
|
+
meta=meta,
|
|
186
|
+
i18n=i18n,
|
|
187
|
+
ISBN=isbn,
|
|
188
|
+
modified_timestamp=modified_timestamp,
|
|
189
|
+
nav_points=nav_points,
|
|
190
|
+
has_head_chapter=has_head_chapter,
|
|
191
|
+
has_cover=has_cover,
|
|
192
|
+
asset_files=context.used_files,
|
|
193
|
+
chapters_with_mathml=chapters_with_mathml,
|
|
194
|
+
)
|
|
195
|
+
context.file.writestr(
|
|
196
|
+
zinfo_or_arcname="OEBPS/content.opf",
|
|
197
|
+
data=content.encode("utf-8"),
|
|
198
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from html import escape
|
|
2
|
+
|
|
3
|
+
from ..context import Template
|
|
4
|
+
from ..i18n import I18N
|
|
5
|
+
from ..types import BookMeta, EpubData, TocItem
|
|
6
|
+
from .gen_toc import NavPoint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def gen_nav(
|
|
10
|
+
template: Template,
|
|
11
|
+
i18n: I18N,
|
|
12
|
+
epub_data: EpubData,
|
|
13
|
+
nav_points: list[NavPoint],
|
|
14
|
+
has_cover: bool = False,
|
|
15
|
+
) -> str:
|
|
16
|
+
meta: BookMeta | None = epub_data.meta
|
|
17
|
+
has_head_chapter = epub_data.get_head is not None
|
|
18
|
+
toc_list = _generate_toc_list(epub_data.prefaces, epub_data.chapters, nav_points)
|
|
19
|
+
first_chapter_file = nav_points[0].file_name if nav_points else None
|
|
20
|
+
head_chapter_title = ""
|
|
21
|
+
if has_head_chapter and epub_data.get_head:
|
|
22
|
+
# Try to extract title from first heading if available
|
|
23
|
+
head_chapter_title = "Preface" # Default title
|
|
24
|
+
|
|
25
|
+
return template.render(
|
|
26
|
+
template="nav.xhtml",
|
|
27
|
+
i18n=i18n,
|
|
28
|
+
meta=meta,
|
|
29
|
+
has_cover=has_cover,
|
|
30
|
+
has_head_chapter=has_head_chapter,
|
|
31
|
+
head_chapter_title=head_chapter_title,
|
|
32
|
+
toc_list=toc_list,
|
|
33
|
+
first_chapter_file=first_chapter_file,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _generate_toc_list(
|
|
38
|
+
prefaces: list[TocItem],
|
|
39
|
+
chapters: list[TocItem],
|
|
40
|
+
nav_points: list[NavPoint],
|
|
41
|
+
) -> str:
|
|
42
|
+
nav_point_index = 0
|
|
43
|
+
|
|
44
|
+
html_parts = []
|
|
45
|
+
for chapters_list in (prefaces, chapters):
|
|
46
|
+
for toc_item in chapters_list:
|
|
47
|
+
nav_point_index, item_html = _generate_toc_item(
|
|
48
|
+
toc_item, nav_points, nav_point_index
|
|
49
|
+
)
|
|
50
|
+
html_parts.append(item_html)
|
|
51
|
+
|
|
52
|
+
return "\n".join(html_parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _generate_toc_item(
|
|
56
|
+
toc_item: TocItem,
|
|
57
|
+
nav_points: list[NavPoint],
|
|
58
|
+
nav_point_index: int,
|
|
59
|
+
) -> tuple[int, str]:
|
|
60
|
+
title_escaped = escape(toc_item.title)
|
|
61
|
+
file_name = None
|
|
62
|
+
if toc_item.get_chapter is not None and nav_point_index < len(nav_points):
|
|
63
|
+
file_name = nav_points[nav_point_index].file_name
|
|
64
|
+
nav_point_index += 1
|
|
65
|
+
|
|
66
|
+
children_html = []
|
|
67
|
+
for child in toc_item.children:
|
|
68
|
+
nav_point_index, child_html = _generate_toc_item(
|
|
69
|
+
child, nav_points, nav_point_index
|
|
70
|
+
)
|
|
71
|
+
children_html.append(child_html)
|
|
72
|
+
|
|
73
|
+
if file_name is None and children_html:
|
|
74
|
+
if nav_point_index > 0:
|
|
75
|
+
for i in range(nav_point_index - len(toc_item.children), nav_point_index):
|
|
76
|
+
if i < len(nav_points):
|
|
77
|
+
file_name = nav_points[i].file_name
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
if file_name:
|
|
81
|
+
html_parts = [f' <li>\n <a href="Text/{file_name}">{title_escaped}</a>']
|
|
82
|
+
else:
|
|
83
|
+
html_parts = [f' <li>\n <span>{title_escaped}</span>']
|
|
84
|
+
|
|
85
|
+
if children_html:
|
|
86
|
+
html_parts.append(' <ol>')
|
|
87
|
+
html_parts.extend(children_html)
|
|
88
|
+
html_parts.append(' </ol>')
|
|
89
|
+
|
|
90
|
+
html_parts.append(' </li>')
|
|
91
|
+
|
|
92
|
+
return nav_point_index, "\n".join(html_parts)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from ..types import EpubData, TocItem
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class NavPoint:
|
|
9
|
+
toc_id: int
|
|
10
|
+
file_name: str
|
|
11
|
+
order: int
|
|
12
|
+
get_chapter: Callable[[], Any] | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def gen_toc(
|
|
16
|
+
epub_data: EpubData,
|
|
17
|
+
has_cover: bool = False,
|
|
18
|
+
) -> list[NavPoint]:
|
|
19
|
+
prefaces = epub_data.prefaces
|
|
20
|
+
chapters = epub_data.chapters
|
|
21
|
+
|
|
22
|
+
nav_point_generation = _NavPointGenerator(
|
|
23
|
+
has_cover=has_cover,
|
|
24
|
+
chapters_count=(
|
|
25
|
+
_count_toc_items(prefaces) +
|
|
26
|
+
_count_toc_items(chapters)
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
for chapters_list in (prefaces, chapters):
|
|
30
|
+
for toc_item in chapters_list:
|
|
31
|
+
nav_point_generation.generate(toc_item)
|
|
32
|
+
|
|
33
|
+
return nav_point_generation.nav_points
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _count_toc_items(items: list[TocItem]) -> int:
|
|
37
|
+
count: int = 0
|
|
38
|
+
for item in items:
|
|
39
|
+
count += 1 + _count_toc_items(item.children)
|
|
40
|
+
return count
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _max_depth_toc_items(items: list[TocItem]) -> int:
|
|
44
|
+
max_depth: int = 0
|
|
45
|
+
for item in items:
|
|
46
|
+
max_depth = max(
|
|
47
|
+
max_depth,
|
|
48
|
+
_max_depth_toc_items(item.children) + 1,
|
|
49
|
+
)
|
|
50
|
+
return max_depth
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _NavPointGenerator:
|
|
54
|
+
def __init__(self, has_cover: bool, chapters_count: int):
|
|
55
|
+
self._nav_points: list[NavPoint] = []
|
|
56
|
+
self._next_order: int = 2 if has_cover else 1
|
|
57
|
+
self._next_id: int = 1
|
|
58
|
+
self._digits = len(str(chapters_count))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def nav_points(self) -> list[NavPoint]:
|
|
62
|
+
return self._nav_points
|
|
63
|
+
|
|
64
|
+
def generate(self, toc_item: TocItem) -> None:
|
|
65
|
+
self._create_nav_point(toc_item)
|
|
66
|
+
|
|
67
|
+
def _create_nav_point(self, toc_item: TocItem) -> NavPoint:
|
|
68
|
+
nav_point: NavPoint | None = None
|
|
69
|
+
if toc_item.get_chapter is not None:
|
|
70
|
+
toc_id = self._next_id
|
|
71
|
+
self._next_id += 1
|
|
72
|
+
part_id = str(toc_id).zfill(self._digits)
|
|
73
|
+
nav_point = NavPoint(
|
|
74
|
+
toc_id=toc_id,
|
|
75
|
+
file_name=f"part{part_id}.xhtml",
|
|
76
|
+
order=self._next_order,
|
|
77
|
+
get_chapter=toc_item.get_chapter,
|
|
78
|
+
)
|
|
79
|
+
self._nav_points.append(nav_point)
|
|
80
|
+
self._next_order += 1
|
|
81
|
+
|
|
82
|
+
for child in toc_item.children:
|
|
83
|
+
child_nav_point = self._create_nav_point(child)
|
|
84
|
+
if nav_point is None:
|
|
85
|
+
nav_point = child_nav_point
|
|
86
|
+
|
|
87
|
+
assert nav_point is not None, "TocItem has no chapter and no valid children"
|
|
88
|
+
return nav_point
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from xml.etree.ElementTree import Element, tostring
|
|
3
|
+
|
|
4
|
+
_EPUB_NS = "http://www.idpf.org/2007/ops"
|
|
5
|
+
_MATHML_NS = "http://www.w3.org/1998/Math/MathML"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_epub_type(element: Element, epub_type: str) -> None:
|
|
9
|
+
element.set(f"{{{_EPUB_NS}}}type", epub_type)
|
|
10
|
+
|
|
11
|
+
def serialize_element(element: Element) -> str:
|
|
12
|
+
xml_string = tostring(element, encoding="unicode")
|
|
13
|
+
for prefix, namespace_uri, keep_xmlns in (
|
|
14
|
+
("epub", _EPUB_NS, False), # EPUB namespace: remove xmlns (declared at root)
|
|
15
|
+
("m", _MATHML_NS, True), # MathML namespace: keep xmlns with clean prefix
|
|
16
|
+
):
|
|
17
|
+
xml_string = xml_string.replace(f"{{{namespace_uri}}}", f"{prefix}:")
|
|
18
|
+
pattern = r"xmlns:(ns\d+)=\"" + re.escape(namespace_uri) + r"\""
|
|
19
|
+
matches = re.findall(pattern, xml_string)
|
|
20
|
+
|
|
21
|
+
for ns_prefix in matches:
|
|
22
|
+
if keep_xmlns:
|
|
23
|
+
xml_string = xml_string.replace(
|
|
24
|
+
f" xmlns:{ns_prefix}=\"{namespace_uri}\"",
|
|
25
|
+
f" xmlns:{prefix}=\"{namespace_uri}\""
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
xml_string = xml_string.replace(f" xmlns:{ns_prefix}=\"{namespace_uri}\"", "")
|
|
29
|
+
xml_string = xml_string.replace(f"{ns_prefix}:", f"{prefix}:")
|
|
30
|
+
|
|
31
|
+
return xml_string
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
|
|
3
|
+
from .types import Formula, HTMLTag, Mark
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def search_content(content: list[str | Mark | Formula | HTMLTag]) -> Generator[str | Mark | Formula, None, None]:
|
|
7
|
+
for child in content:
|
|
8
|
+
if isinstance(child, HTMLTag):
|
|
9
|
+
yield from search_content(child.content)
|
|
10
|
+
else:
|
|
11
|
+
yield child
|
epub_generator/i18n.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class I18N:
|
|
5
|
+
def __init__(self, lan: Literal["zh", "en"]):
|
|
6
|
+
if lan == "zh":
|
|
7
|
+
self.unnamed: str = "未命名"
|
|
8
|
+
self.cover: str = "封面"
|
|
9
|
+
self.table_of_contents: str = "目录"
|
|
10
|
+
self.landmarks: str = "路标"
|
|
11
|
+
self.start_of_content: str = "正文开始"
|
|
12
|
+
elif lan == "en":
|
|
13
|
+
self.unnamed: str = "Unnamed"
|
|
14
|
+
self.cover: str = "Cover"
|
|
15
|
+
self.table_of_contents: str = "Table of Contents"
|
|
16
|
+
self.landmarks: str = "Landmarks"
|
|
17
|
+
self.start_of_content: str = "Start of Content"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Callable, Tuple
|
|
4
|
+
|
|
5
|
+
from jinja2 import BaseLoader, Environment, TemplateNotFound
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_env(dir_path: Path) -> Environment:
|
|
9
|
+
return Environment(
|
|
10
|
+
loader=_DSLoader(dir_path),
|
|
11
|
+
autoescape=True,
|
|
12
|
+
trim_blocks=True,
|
|
13
|
+
keep_trailing_newline=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_LoaderResult = Tuple[str, str | None, Callable[[], bool] | None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _DSLoader(BaseLoader):
|
|
21
|
+
def __init__(self, dir_path: Path):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._dir_path: Path = dir_path
|
|
24
|
+
|
|
25
|
+
def get_source(self, environment: Environment, template: str) -> _LoaderResult:
|
|
26
|
+
template = self._norm_template(template)
|
|
27
|
+
target_path = (self._dir_path / template).resolve()
|
|
28
|
+
|
|
29
|
+
if not target_path.exists():
|
|
30
|
+
raise TemplateNotFound(f"cannot find {template}")
|
|
31
|
+
|
|
32
|
+
return self._get_source_with_path(target_path)
|
|
33
|
+
|
|
34
|
+
def _norm_template(self, template: str) -> str:
|
|
35
|
+
if bool(re.match(r"^\.+/", template)):
|
|
36
|
+
raise TemplateNotFound(f"invalid path {template}")
|
|
37
|
+
|
|
38
|
+
template = re.sub(r"^/", "", template)
|
|
39
|
+
template = re.sub(r"\.jinja$", "", template, flags=re.IGNORECASE)
|
|
40
|
+
template = f"{template}.jinja"
|
|
41
|
+
|
|
42
|
+
return template
|
|
43
|
+
|
|
44
|
+
def _get_source_with_path(self, path: Path) -> _LoaderResult:
|
|
45
|
+
mtime = path.stat().st_mtime
|
|
46
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
47
|
+
source = f.read()
|
|
48
|
+
|
|
49
|
+
def is_updated() -> bool:
|
|
50
|
+
return mtime == path.stat().st_mtime
|
|
51
|
+
|
|
52
|
+
return source, str(path), is_updated
|