3"""XHTML -> DOM, preserving everything.
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.
10@copyright Copyright (c) 2026 Brighton Sikarskie
11SPDX-License-Identifier: MIT
14from __future__
import annotations
16from html.parser
import HTMLParser
42 """De-duplicating UTF-8 string pool; offset 0 is always the empty string."""
44 def __init__(self) -> None:
45 """Start an empty pool and intern "" so offset 0 is the empty string.
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.
52 self.buf = bytearray()
56 def intern(self, text: str |
None) -> int:
57 """Return the pool offset of `text`, appending it only if new.
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.
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
71 text: String to intern, or None for the empty string.
74 Byte offset of the NUL-terminated UTF-8 copy within `buf`.
78 raw = text.encode(
"utf-8")
79 got = self._map.get(raw)
83 self.buf += raw + b
"\x00"
88class DomBuilder(HTMLParser):
89 """Lenient HTML/XHTML parser that builds a generic element/text tree.
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.
96 def __init__(self) -> None:
97 """Seed the tree with a synthetic `#root` element and open the stack.
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>`.
104 super().__init__(convert_charrefs=
True)
105 self.root = {
"tag":
"#root",
"attrs": [],
"children": []}
106 self.stack = [self.root]
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.
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.
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.
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)
126 def handle_startendtag(self, tag: str, attrs: list[tuple[str, str |
None]]) ->
None:
127 """Append a self-closing element (`<foo/>`) without descending.
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.
135 tag: Lowercased tag name.
136 attrs: (name, value) pairs, preserved verbatim.
138 self.stack[-1][
"children"].append({
"tag": tag,
"attrs": attrs,
"children": []})
140 def handle_endtag(self, tag: str) ->
None:
141 """Close the innermost open element with this name.
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.
152 tag: Lowercased tag name being closed.
154 for i
in range(len(self.stack) - 1, 0, -1):
155 if self.stack[i][
"tag"] == tag:
159 def handle_data(self, data: str) ->
None:
160 """Attach a text run to the current parent, dropping only empty runs.
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.
171 data: Decoded character data.
174 self.stack[-1][
"children"].append({
"text": data})
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:
182 hit = find_first(child, tag)