ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
epub_dom.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""XHTML -> DOM, preserving everything.
4
5Fidelity is the rule: every tag, attribute and text run in a spine document
6survives into the blob, because the on-device reader never parses XHTML and
7cannot recover anything this stage drops. Nothing is filtered to match what
8the renderer happens to understand today.
9
10@copyright Copyright (c) 2026 Brighton Sikarskie
11SPDX-License-Identifier: MIT
12"""
13
14from __future__ import annotations
15
16from html.parser import HTMLParser
17
18#: One DOM node: either an element (``tag`` / ``attrs`` / ``children``) or a
19#: text run (``text``). Kept as a plain dict rather than a class because the
20#: whole tree is walked once and flattened straight into the blob tables.
21DomNode = dict
22
23VOID_TAGS = {
24 "area",
25 "base",
26 "br",
27 "col",
28 "embed",
29 "hr",
30 "img",
31 "input",
32 "link",
33 "meta",
34 "param",
35 "source",
36 "track",
37 "wbr",
38}
39
40
41class StringPool:
42 """De-duplicating UTF-8 string pool; offset 0 is always the empty string."""
43
44 def __init__(self) -> None:
45 """Start an empty pool and intern "" so offset 0 is the empty string.
46
47 That first intern is load-bearing, not a convenience: the blob format
48 uses offset 0 as its "no string" sentinel (an absent title, a text node
49 on an element record). Constructing the pool without it would put real
50 text at offset 0 and make every such sentinel read back as that text.
51 """
52 self.buf = bytearray()
53 self._map = {}
54 self.intern("")
55
56 def intern(self, text: str | None) -> int:
57 """Return the pool offset of `text`, appending it only if new.
58
59 De-duplication is by exact UTF-8 bytes, so two strings that differ only
60 in Unicode normalisation get separate slots. `None` is folded to "" and
61 therefore always yields offset 0, which lets callers pass a missing
62 metadata field straight through without a guard.
63
64 Offsets are stable for the life of the pool: entries are only appended,
65 never moved or removed. Callers may hold an offset across later
66 `intern()` calls -- but NOT across a snapshot of `buf`, which is why
67 `BlobBuilder.serialize()` interns the metadata strings before it copies
68 the buffer.
69
70 Args:
71 text: String to intern, or None for the empty string.
72
73 Returns:
74 Byte offset of the NUL-terminated UTF-8 copy within `buf`.
75 """
76 if text is None:
77 text = ""
78 raw = text.encode("utf-8")
79 got = self._map.get(raw)
80 if got is not None:
81 return got
82 off = len(self.buf)
83 self.buf += raw + b"\x00"
84 self._map[raw] = off
85 return off
86
87
88class DomBuilder(HTMLParser):
89 """Lenient HTML/XHTML parser that builds a generic element/text tree.
90
91 convert_charrefs resolves entities to Unicode, and <style>/<script> bodies
92 are captured as text (so inline CSS survives). Tag names and attributes are
93 preserved exactly; inline <svg> becomes ordinary elements.
94 """
95
96 def __init__(self) -> None:
97 """Seed the tree with a synthetic `#root` element and open the stack.
98
99 The sentinel root exists so `handle_data`/`handle_starttag` never have
100 to special-case an empty stack: a document with leading text, or one
101 with several top-level elements, still has somewhere to attach. It is
102 also the fallback chapter root when `compile_epub` finds no `<body>`.
103 """
104 super().__init__(convert_charrefs=True)
105 self.root = {"tag": "#root", "attrs": [], "children": []}
106 self.stack = [self.root]
107
108 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
109 """Append an element to the current parent and descend into it.
110
111 Void tags (`<br>`, `<img>`, ...) are appended but NOT pushed, because
112 XHTML in the wild spells them both `<br>` and `<br/>`; pushing them
113 would leave the stack permanently deeper on the unclosed spelling and
114 nest every following sibling inside the void element.
115
116 Args:
117 tag: Lowercased tag name from HTMLParser.
118 attrs: HTMLParser's (name, value) pairs, preserved verbatim -- order
119 and duplicates included, since the blob stores them as written.
120 """
121 node = {"tag": tag, "attrs": attrs, "children": []}
122 self.stack[-1]["children"].append(node)
123 if tag not in VOID_TAGS:
124 self.stack.append(node)
125
126 def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
127 """Append a self-closing element (`<foo/>`) without descending.
128
129 HTMLParser routes the self-closed spelling here instead of through
130 `handle_starttag` + `handle_endtag`, so this must not push the stack.
131 It applies to any tag, not just the void set -- `<div/>` and inline
132 `<svg><path/></svg>` both arrive here.
133
134 Args:
135 tag: Lowercased tag name.
136 attrs: (name, value) pairs, preserved verbatim.
137 """
138 self.stack[-1]["children"].append({"tag": tag, "attrs": attrs, "children": []})
139
140 def handle_endtag(self, tag: str) -> None:
141 """Close the innermost open element with this name.
142
143 Any elements left open inside it are discarded from the stack.
144 Scanning inward rather than popping one frame lets malformed markup
145 (`<p><b>text</p>`) close correctly: the unclosed `<b>` is dropped from
146 the stack along with `<p>`, but its already-attached subtree survives in
147 the tree. A stray end tag with no matching open element is ignored
148 rather than raising -- index 0 is excluded from the scan so the
149 synthetic `#root` can never be popped.
150
151 Args:
152 tag: Lowercased tag name being closed.
153 """
154 for i in range(len(self.stack) - 1, 0, -1):
155 if self.stack[i]["tag"] == tag:
156 del self.stack[i:]
157 return
158
159 def handle_data(self, data: str) -> None:
160 """Attach a text run to the current parent, dropping only empty runs.
161
162 Whitespace is deliberately kept: the device-side renderer performs its
163 own CSS whitespace collapsing, and stripping here would destroy the
164 single significant space between two inline elements. Because
165 `convert_charrefs=True`, entities have already been resolved to Unicode
166 by the time this is called, and one logical text run may still arrive
167 split across several calls -- each becomes its own text node, which the
168 renderer treats identically to one merged node.
169
170 Args:
171 data: Decoded character data.
172 """
173 if data:
174 self.stack[-1]["children"].append({"text": data})
175
176
177def find_first(node: DomNode, tag: str) -> DomNode | None:
178 """Depth-first search for the first element with the given tag name."""
179 for child in node.get("children", []):
180 if child.get("tag") == tag:
181 return child
182 hit = find_first(child, tag)
183 if hit is not None:
184 return hit
185 return None