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.
@@ -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,12 @@
1
+ from enum import Enum, auto
2
+
3
+
4
+ class TableRender(Enum):
5
+ HTML = auto()
6
+ CLIPPING = auto()
7
+
8
+
9
+ class LaTeXRender(Enum):
10
+ MATHML = auto()
11
+ SVG = auto()
12
+ CLIPPING = auto()
@@ -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