brewdoc 0.2.0__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.
- brewdoc/__init__.py +16 -0
- brewdoc/cli.py +97 -0
- brewdoc/common.py +405 -0
- brewdoc/docx.py +131 -0
- brewdoc/htmltext.py +812 -0
- brewdoc/output.py +93 -0
- brewdoc/pdf.py +1185 -0
- brewdoc/pptx.py +362 -0
- brewdoc/selfcheck.py +383 -0
- brewdoc/service.py +116 -0
- brewdoc/sheets.py +341 -0
- brewdoc-0.2.0.dist-info/METADATA +232 -0
- brewdoc-0.2.0.dist-info/RECORD +16 -0
- brewdoc-0.2.0.dist-info/WHEEL +4 -0
- brewdoc-0.2.0.dist-info/entry_points.txt +2 -0
- brewdoc-0.2.0.dist-info/licenses/LICENSE +21 -0
brewdoc/htmltext.py
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"""Static HTML adapter with strict UTF-8 input and literal Unicode text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from brewdoc.common import (MARKDOWN_ESCAPES, BrewdocError, Rendered, Route, _assemble,
|
|
12
|
+
new_tally, reading)
|
|
13
|
+
|
|
14
|
+
_SUPPORTED_RUNTIME = platform.python_implementation() == "CPython" and (3, 12) <= sys.version_info < (3, 15)
|
|
15
|
+
_PARSER_ERROR = ""
|
|
16
|
+
LexborHTMLParser = None
|
|
17
|
+
if _SUPPORTED_RUNTIME:
|
|
18
|
+
try:
|
|
19
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
20
|
+
except (ImportError, OSError, RuntimeError, ValueError, SystemError) as exc:
|
|
21
|
+
_PARSER_ERROR = str(exc)
|
|
22
|
+
|
|
23
|
+
_EXCLUDED = frozenset({"head", "script", "style", "template", "object", "embed", "iframe",
|
|
24
|
+
"audio", "video", "applet"})
|
|
25
|
+
_BLOCKS = frozenset({"body", "address", "article", "aside", "div", "footer", "header", "main",
|
|
26
|
+
"nav", "p", "section", "figure", "figcaption", "dl", "dt", "dd", "hr",
|
|
27
|
+
"legend", "summary"})
|
|
28
|
+
_CONTROL_BOUNDARIES = frozenset({"form", "fieldset", "label", "select", "optgroup", "datalist",
|
|
29
|
+
"progress", "meter", "details"})
|
|
30
|
+
_CONTROL_TAGS = _CONTROL_BOUNDARIES | frozenset({"button", "input", "textarea", "output", "option"})
|
|
31
|
+
_EMPHASIS = {"em": "*", "i": "*", "strong": "**", "b": "**"}
|
|
32
|
+
_HEADINGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
|
|
33
|
+
_SPACE = re.compile(r"[ \t\r\n\f]+")
|
|
34
|
+
_TAG_NAME = re.compile(r"</?([A-Za-z][^\t\n\f\r />]*)")
|
|
35
|
+
_ATTRIBUTE = re.compile(r"([^\s/=<>]+)(?:\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s>]+)))?")
|
|
36
|
+
_INPUT_BYTE_LIMIT = 8 * 1048576
|
|
37
|
+
_DEPTH_LIMIT = 256
|
|
38
|
+
_ELEMENT_LIMIT = 200_000
|
|
39
|
+
_ATTRIBUTE_BYTE_LIMIT = 1048576
|
|
40
|
+
_VISIBLE_TEXT_BYTE_LIMIT = 32 * 1048576
|
|
41
|
+
_TABLE_CELL_LIMIT = 100_000
|
|
42
|
+
_MARKDOWN_BYTE_LIMIT = 64 * 1048576
|
|
43
|
+
_LITERAL_ESCAPES = MARKDOWN_ESCAPES | {ord("\n"): " ", ord("\t"): "	", ord("\r"): " "}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tag_end(prefix: str, start: int) -> int | None:
|
|
47
|
+
"""Find a complete tag boundary without treating quoted attribute text as markup."""
|
|
48
|
+
quote_character = ""
|
|
49
|
+
for index in range(start, len(prefix)):
|
|
50
|
+
character = prefix[index]
|
|
51
|
+
if quote_character:
|
|
52
|
+
if character == quote_character:
|
|
53
|
+
quote_character = ""
|
|
54
|
+
elif character in "\"'":
|
|
55
|
+
quote_character = character
|
|
56
|
+
elif character == ">":
|
|
57
|
+
return index
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _meta_attributes(prefix: str):
|
|
62
|
+
"""Yield real meta start tags within the prefix, excluding comments and raw text."""
|
|
63
|
+
position = 0
|
|
64
|
+
while (start := prefix.find("<", position)) >= 0:
|
|
65
|
+
if prefix.startswith("<!--", start):
|
|
66
|
+
empty = re.match(r"<!---?>", prefix[start:])
|
|
67
|
+
closing = re.search(r"--!?>", prefix[start + 4:])
|
|
68
|
+
if empty:
|
|
69
|
+
position = start + empty.end()
|
|
70
|
+
elif closing:
|
|
71
|
+
position = start + 4 + closing.end()
|
|
72
|
+
else:
|
|
73
|
+
return
|
|
74
|
+
continue
|
|
75
|
+
match = _TAG_NAME.match(prefix, start)
|
|
76
|
+
if match is None and not prefix.startswith(("<!", "<?"), start):
|
|
77
|
+
position = start + 1
|
|
78
|
+
continue
|
|
79
|
+
end = _tag_end(prefix, start + 1)
|
|
80
|
+
if end is None:
|
|
81
|
+
return
|
|
82
|
+
position = end + 1
|
|
83
|
+
if match is None or prefix.startswith("</", start):
|
|
84
|
+
continue
|
|
85
|
+
name = match.group(1).lower()
|
|
86
|
+
if name == "meta":
|
|
87
|
+
yield prefix[match.end():end]
|
|
88
|
+
elif name == "plaintext":
|
|
89
|
+
return
|
|
90
|
+
elif name in ("script", "style", "xmp", "iframe", "noembed", "noframes", "textarea", "title"):
|
|
91
|
+
closing = re.search(r"</%s(?=[\t\n\f\r />])" % name, prefix[position:], re.I)
|
|
92
|
+
if closing is None:
|
|
93
|
+
return
|
|
94
|
+
end = _tag_end(prefix, position + closing.end())
|
|
95
|
+
if end is None:
|
|
96
|
+
return
|
|
97
|
+
position = end + 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _decode_html(raw: bytes) -> str:
|
|
101
|
+
"""Reject declared non-UTF-8 encodings before strict decoding, preserving raw hashes."""
|
|
102
|
+
payload = raw.removeprefix(b"\xef\xbb\xbf")
|
|
103
|
+
for source_attributes in _meta_attributes(payload[:1024].decode("latin1")):
|
|
104
|
+
attributes = [(item.group(1).lower(), next((value for value in item.groups()[1:]
|
|
105
|
+
if value is not None), None))
|
|
106
|
+
for item in _ATTRIBUTE.finditer(source_attributes)]
|
|
107
|
+
declarations = [value for key, value in attributes if key == "charset"]
|
|
108
|
+
if any(key == "http-equiv" and (value or "").strip().lower() == "content-type"
|
|
109
|
+
for key, value in attributes):
|
|
110
|
+
for key, value in attributes:
|
|
111
|
+
if key != "content":
|
|
112
|
+
continue
|
|
113
|
+
for charset in re.finditer(r"(?:^|;)\s*charset\b([^;]*)", value or "", re.I):
|
|
114
|
+
declared = re.fullmatch(r"\s*=\s*([^\s]+)\s*", charset.group(1))
|
|
115
|
+
declarations.append(declared.group(1).strip("\"'") if declared else None)
|
|
116
|
+
if any(value is None or value.strip().lower() not in ("utf-8", "utf8")
|
|
117
|
+
for value in declarations):
|
|
118
|
+
raise BrewdocError("HTML charset must declare UTF-8 or UTF8")
|
|
119
|
+
try:
|
|
120
|
+
return payload.decode("utf-8")
|
|
121
|
+
except UnicodeDecodeError as exc:
|
|
122
|
+
raise BrewdocError("HTML UTF-8 decoding failed: %s" % exc) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _escape(text: str) -> str:
|
|
126
|
+
"""Escape source syntax without folding or replacing meaningful Unicode."""
|
|
127
|
+
return text.translate(MARKDOWN_ESCAPES)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _close_emphasis(parts: list[str], marker: str) -> None:
|
|
131
|
+
"""Keep trailing word separation outside a generated emphasis delimiter."""
|
|
132
|
+
whitespace = []
|
|
133
|
+
while parts:
|
|
134
|
+
trimmed = parts[-1].rstrip()
|
|
135
|
+
if len(trimmed) == len(parts[-1]):
|
|
136
|
+
break
|
|
137
|
+
whitespace.append(parts[-1][len(trimmed):])
|
|
138
|
+
if trimmed:
|
|
139
|
+
parts[-1] = trimmed
|
|
140
|
+
break
|
|
141
|
+
parts.pop()
|
|
142
|
+
parts.append(marker)
|
|
143
|
+
parts.extend(reversed(whitespace))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _hidden(node) -> bool:
|
|
147
|
+
"""Apply only source-declared hiding and supported inline CSS precedence."""
|
|
148
|
+
attributes = node.attributes
|
|
149
|
+
if (node.tag in _EXCLUDED or "hidden" in attributes
|
|
150
|
+
or (attributes.get("aria-hidden") or "").strip().lower() == "true"):
|
|
151
|
+
return True
|
|
152
|
+
effective = {}
|
|
153
|
+
for declaration in (attributes.get("style") or "").split(";"):
|
|
154
|
+
name, separator, value = declaration.partition(":")
|
|
155
|
+
name, value = name.strip().lower(), value.strip().lower()
|
|
156
|
+
if not separator or name not in ("display", "visibility"):
|
|
157
|
+
continue
|
|
158
|
+
important = re.search(r"!\s*important\s*$", value) is not None
|
|
159
|
+
value = re.sub(r"!\s*important\s*$", "", value).strip()
|
|
160
|
+
supported = ({"none", "block", "inline", "inline-block", "flex", "grid", "table",
|
|
161
|
+
"table-row", "table-cell", "list-item"} if name == "display"
|
|
162
|
+
else {"hidden", "visible", "collapse"})
|
|
163
|
+
if value not in supported:
|
|
164
|
+
continue
|
|
165
|
+
if name not in effective or important or not effective[name][1]:
|
|
166
|
+
effective[name] = (value, important)
|
|
167
|
+
return (effective.get("display", ("", False))[0] == "none"
|
|
168
|
+
or effective.get("visibility", ("", False))[0] == "hidden")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _children(node) -> list:
|
|
172
|
+
"""Read direct children in recovered source order, including text nodes."""
|
|
173
|
+
children, child = [], node.child
|
|
174
|
+
while child is not None:
|
|
175
|
+
children.append(child)
|
|
176
|
+
child = child.next
|
|
177
|
+
return children
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validate_html(parser) -> None:
|
|
181
|
+
"""Check recovered structure and decoded text iteratively, including excluded elements."""
|
|
182
|
+
elements = visible_bytes = 0
|
|
183
|
+
pending = [(parser.root, 0, False)]
|
|
184
|
+
while pending:
|
|
185
|
+
node, depth, excluded = pending.pop()
|
|
186
|
+
tag = node.tag
|
|
187
|
+
if tag == "-text":
|
|
188
|
+
if depth and not excluded:
|
|
189
|
+
visible_bytes += len(node.text().encode("utf-8"))
|
|
190
|
+
if visible_bytes > _VISIBLE_TEXT_BYTE_LIMIT:
|
|
191
|
+
raise BrewdocError("HTML visible text byte limit exceeded: %d" % _VISIBLE_TEXT_BYTE_LIMIT)
|
|
192
|
+
continue
|
|
193
|
+
if tag.startswith("-"):
|
|
194
|
+
continue
|
|
195
|
+
elements += 1
|
|
196
|
+
if elements > _ELEMENT_LIMIT:
|
|
197
|
+
raise BrewdocError("HTML element limit exceeded: %d" % _ELEMENT_LIMIT)
|
|
198
|
+
attributes = node.attributes
|
|
199
|
+
if any(len((value or "").encode("utf-8")) > _ATTRIBUTE_BYTE_LIMIT for value in attributes.values()):
|
|
200
|
+
raise BrewdocError("HTML attribute byte limit exceeded: %d" % _ATTRIBUTE_BYTE_LIMIT)
|
|
201
|
+
if tag == "body":
|
|
202
|
+
depth = 1
|
|
203
|
+
elif depth:
|
|
204
|
+
depth += 1
|
|
205
|
+
if depth > _DEPTH_LIMIT:
|
|
206
|
+
raise BrewdocError("HTML depth limit exceeded: %d" % _DEPTH_LIMIT)
|
|
207
|
+
excluded = excluded or _hidden(node) or (tag == "input" and (
|
|
208
|
+
attributes.get("type") or "").strip().lower() == "hidden")
|
|
209
|
+
pending.extend((child, depth, excluded) for child in reversed(_children(node)))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _literal_text(node) -> str:
|
|
213
|
+
"""Collect included descendant text iteratively without whitespace reduction."""
|
|
214
|
+
parts, pending = [], [node]
|
|
215
|
+
while pending:
|
|
216
|
+
current = pending.pop()
|
|
217
|
+
if current.tag == "-text":
|
|
218
|
+
parts.append(current.text())
|
|
219
|
+
elif current.tag == "br":
|
|
220
|
+
parts.append("\n")
|
|
221
|
+
elif current.tag != "-comment" and not _hidden(current):
|
|
222
|
+
pending.extend(reversed(_children(current)))
|
|
223
|
+
return "".join(parts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _target(source: str | None) -> tuple[str, str | None]:
|
|
227
|
+
"""Classify a decoded destination without fetching or activating it."""
|
|
228
|
+
if not source:
|
|
229
|
+
return "missing", None
|
|
230
|
+
cleaned = re.sub(r"[\x00-\x20\x7f]", "", source)
|
|
231
|
+
scheme = re.match(r"([A-Za-z][A-Za-z0-9+.-]*):", cleaned)
|
|
232
|
+
if scheme is None:
|
|
233
|
+
return "relative", source
|
|
234
|
+
name = scheme.group(1).lower()
|
|
235
|
+
if name == "data":
|
|
236
|
+
return "data", None
|
|
237
|
+
if name not in ("http", "https", "mailto"):
|
|
238
|
+
return "unsupported", None
|
|
239
|
+
return "external", source
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _literal_links(node) -> list[str]:
|
|
243
|
+
"""Retain each included safe anchor inside consumed literal content in DOM order."""
|
|
244
|
+
records, pending = [], [node]
|
|
245
|
+
while pending:
|
|
246
|
+
current = pending.pop()
|
|
247
|
+
if current.tag in ("-text", "-comment") or _hidden(current):
|
|
248
|
+
continue
|
|
249
|
+
if current.tag == "a":
|
|
250
|
+
target = _target(current.attributes.get("href"))[1]
|
|
251
|
+
if target is not None and (label := _literal_text(current)):
|
|
252
|
+
records.append('[Link target](%s); text="%s"' % (
|
|
253
|
+
quote(target, safe="/:#?&=@%+;,"), _escape_literal(label)))
|
|
254
|
+
pending.extend(reversed(_children(current)))
|
|
255
|
+
return records
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _image(node) -> str:
|
|
259
|
+
"""Emit source-declared image metadata with an inert destination."""
|
|
260
|
+
attributes = node.attributes
|
|
261
|
+
kind, target = _target(attributes.get("src"))
|
|
262
|
+
return 'Image: alt="%s"; title="%s"; source=%s; target="%s"' % (
|
|
263
|
+
_escape(attributes.get("alt") or "none"), _escape(attributes.get("title") or "none"),
|
|
264
|
+
kind, _escape(target) if target is not None else "omitted")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _escape_literal(text: str) -> str:
|
|
268
|
+
"""Preserve literal whitespace in quoted metadata without activating source entities."""
|
|
269
|
+
return text.translate(_LITERAL_ESCAPES)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _quoted(attributes: dict, name: str) -> str:
|
|
273
|
+
"""Distinguish absent strings from supplied empty strings in source records."""
|
|
274
|
+
return '"%s"' % _escape_literal(attributes[name] or "") if name in attributes else "unspecified"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _declared(attributes: dict, name: str) -> str:
|
|
278
|
+
"""Record boolean attribute presence without interpreting its supplied value."""
|
|
279
|
+
return "true" if name in attributes else "false"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _ancestors(node):
|
|
283
|
+
"""Read declared ancestors without inferring browser state."""
|
|
284
|
+
parent = node.parent
|
|
285
|
+
while parent is not None:
|
|
286
|
+
yield parent
|
|
287
|
+
parent = parent.parent
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _control(node, leaving: bool = False) -> tuple[list[str], bool]:
|
|
291
|
+
"""Return fixed source records and whether those records consume descendant text."""
|
|
292
|
+
tag, attributes = node.tag, node.attributes
|
|
293
|
+
if tag == "label":
|
|
294
|
+
if leaving and ("id" in attributes or "for" in attributes):
|
|
295
|
+
return ["Label: id=%s; for=%s" % (_quoted(attributes, "id"), _quoted(attributes, "for"))], False
|
|
296
|
+
return [], False
|
|
297
|
+
if leaving:
|
|
298
|
+
return [], False
|
|
299
|
+
role_button = (attributes.get("role") or "").strip().lower() == "button"
|
|
300
|
+
if tag not in _CONTROL_TAGS and not role_button:
|
|
301
|
+
return [], False
|
|
302
|
+
kind = (attributes.get("type") or "").strip().lower()
|
|
303
|
+
if tag == "input" and kind == "hidden":
|
|
304
|
+
return [], True
|
|
305
|
+
sensitive = tag == "input" and kind in ("password", "file")
|
|
306
|
+
declared_value = '"redacted"' if sensitive and "value" in attributes else _quoted(attributes, "value")
|
|
307
|
+
disabled = "true" if any(parent.tag == "fieldset" and "disabled" in parent.attributes
|
|
308
|
+
for parent in _ancestors(node)) else "false"
|
|
309
|
+
fields, consumes = [], False
|
|
310
|
+
if tag == "button" or role_button or (
|
|
311
|
+
tag == "input" and kind in ("button", "submit", "reset")):
|
|
312
|
+
if tag == "input":
|
|
313
|
+
visible = "" if sensitive else attributes.get("value") or ""
|
|
314
|
+
else:
|
|
315
|
+
visible = _SPACE.sub(" ", _literal_text(node)).strip()
|
|
316
|
+
label = visible or attributes.get("aria-label") or attributes.get("title") or "Unlabelled"
|
|
317
|
+
fields = [("label", '"%s"' % _escape_literal(label)),
|
|
318
|
+
("aria_label", _quoted(attributes, "aria-label"))]
|
|
319
|
+
fields.extend((name, _quoted(attributes, name)) for name in ("title", "role", "type", "id", "name"))
|
|
320
|
+
fields.append(("value", declared_value))
|
|
321
|
+
fields.extend((("declared_disabled", _declared(attributes, "disabled")),
|
|
322
|
+
("aria_disabled", _quoted(attributes, "aria-disabled")), ("fieldset_disabled", disabled)))
|
|
323
|
+
fields.extend((name, _quoted(attributes, name))
|
|
324
|
+
for name in ("formaction", "formmethod", "formenctype", "formtarget"))
|
|
325
|
+
fields.append(("formnovalidate", _declared(attributes, "formnovalidate")))
|
|
326
|
+
label, consumes = "Button", True
|
|
327
|
+
elif tag == "input":
|
|
328
|
+
fields = [(name, _quoted(attributes, name)) for name in ("type", "id", "name")]
|
|
329
|
+
fields.append(("value", declared_value))
|
|
330
|
+
if kind in ("checkbox", "radio"):
|
|
331
|
+
fields.append(("declared_checked", _declared(attributes, "checked")))
|
|
332
|
+
fields.append(("required", _declared(attributes, "required")))
|
|
333
|
+
fields.extend((("declared_disabled", _declared(attributes, "disabled")), ("fieldset_disabled", disabled)))
|
|
334
|
+
else:
|
|
335
|
+
fields.append(("placeholder", _quoted(attributes, "placeholder")))
|
|
336
|
+
fields.extend((name, _declared(attributes, name)) for name in ("readonly", "required"))
|
|
337
|
+
fields.extend((("declared_disabled", _declared(attributes, "disabled")), ("fieldset_disabled", disabled)))
|
|
338
|
+
fields.extend((name, _quoted(attributes, name)) for name in (
|
|
339
|
+
"min", "max", "step", "pattern", "minlength", "maxlength", "size"))
|
|
340
|
+
fields.append(("multiple", _declared(attributes, "multiple")))
|
|
341
|
+
fields.extend((name, _quoted(attributes, name)) for name in ("accept", "autocomplete", "list"))
|
|
342
|
+
if kind == "image":
|
|
343
|
+
fields.extend((name, _quoted(attributes, name))
|
|
344
|
+
for name in ("formaction", "formmethod", "formenctype", "formtarget"))
|
|
345
|
+
fields.append(("formnovalidate", _declared(attributes, "formnovalidate")))
|
|
346
|
+
label, consumes = "Input", True
|
|
347
|
+
elif tag == "textarea":
|
|
348
|
+
fields = [(name, _quoted(attributes, name)) for name in ("id", "name", "placeholder")]
|
|
349
|
+
fields.extend((name, _declared(attributes, name)) for name in ("readonly", "required"))
|
|
350
|
+
fields.extend((("declared_disabled", _declared(attributes, "disabled")), ("fieldset_disabled", disabled),
|
|
351
|
+
("text", '"%s"' % _escape_literal(_literal_text(node)))))
|
|
352
|
+
label, consumes = "Textarea", True
|
|
353
|
+
elif tag == "output":
|
|
354
|
+
fields = [(name, _quoted(attributes, name)) for name in ("id", "name", "for")]
|
|
355
|
+
fields.append(("text", '"%s"' % _escape_literal(_literal_text(node))))
|
|
356
|
+
label, consumes = "Output", True
|
|
357
|
+
elif tag == "option":
|
|
358
|
+
group = next((parent for parent in _ancestors(node) if parent.tag == "optgroup"), None)
|
|
359
|
+
fields = [("text", '"%s"' % _escape_literal(_SPACE.sub(" ", _literal_text(node)).strip())),
|
|
360
|
+
("value", _quoted(attributes, "value")),
|
|
361
|
+
("declared_selected", _declared(attributes, "selected")),
|
|
362
|
+
("declared_disabled", _declared(attributes, "disabled")),
|
|
363
|
+
("group", _quoted(group.attributes, "label") if group is not None else "unspecified")]
|
|
364
|
+
label, consumes = "Option", True
|
|
365
|
+
elif tag == "select":
|
|
366
|
+
fields = [(name, _quoted(attributes, name)) for name in ("id", "name")]
|
|
367
|
+
fields.extend((name, _declared(attributes, name)) for name in ("multiple", "required"))
|
|
368
|
+
fields.extend((("declared_disabled", _declared(attributes, "disabled")), ("fieldset_disabled", disabled)))
|
|
369
|
+
label = "Select"
|
|
370
|
+
elif tag == "fieldset":
|
|
371
|
+
fields = [(name, _quoted(attributes, name)) for name in ("id", "name")]
|
|
372
|
+
fields.append(("declared_disabled", _declared(attributes, "disabled")))
|
|
373
|
+
label = "Fieldset"
|
|
374
|
+
elif tag == "optgroup":
|
|
375
|
+
fields = [("label", _quoted(attributes, "label")), ("declared_disabled", _declared(attributes, "disabled"))]
|
|
376
|
+
label = "Optgroup"
|
|
377
|
+
elif tag == "details":
|
|
378
|
+
fields = [("id", _quoted(attributes, "id")), ("declared_open", _declared(attributes, "open"))]
|
|
379
|
+
label = "Details"
|
|
380
|
+
elif tag in ("form", "datalist", "progress", "meter"):
|
|
381
|
+
names = {"form": ("action", "method", "id", "name"), "datalist": ("id",),
|
|
382
|
+
"progress": ("id", "value", "max"),
|
|
383
|
+
"meter": ("id", "value", "min", "max", "low", "high", "optimum")}[tag]
|
|
384
|
+
fields = [(name, _quoted(attributes, name)) for name in names]
|
|
385
|
+
label = tag.title()
|
|
386
|
+
else:
|
|
387
|
+
return [], False
|
|
388
|
+
records = [label + ": " + "; ".join("%s=%s" % pair for pair in fields)]
|
|
389
|
+
if tag == "input" and kind == "image":
|
|
390
|
+
records.append(_image(node))
|
|
391
|
+
return records, consumes
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _span(node, name: str, remaining_rows: int) -> int:
|
|
395
|
+
"""Read declared positive spans; zero rowspan covers the rest of its row group."""
|
|
396
|
+
source = (node.attributes.get(name) or "").strip()
|
|
397
|
+
if re.fullmatch(r"\+?[0-9]+", source) is None:
|
|
398
|
+
return 1
|
|
399
|
+
digits = source.lstrip("+0") or "0"
|
|
400
|
+
if len(digits) > len(str(_TABLE_CELL_LIMIT)) or int(digits) > _TABLE_CELL_LIMIT:
|
|
401
|
+
raise BrewdocError("HTML table cells limit exceeded: %d" % _TABLE_CELL_LIMIT)
|
|
402
|
+
value = int(digits)
|
|
403
|
+
return value or (remaining_rows if name == "rowspan" else 1)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _table_layout(table) -> tuple[int, int, list[tuple], list]:
|
|
407
|
+
"""Plan origin coordinates and spans without allocating an expanded cell grid."""
|
|
408
|
+
groups, captions = [], []
|
|
409
|
+
for child in _children(table):
|
|
410
|
+
if child.tag == "-text" or _hidden(child):
|
|
411
|
+
continue
|
|
412
|
+
if child.tag == "caption":
|
|
413
|
+
captions.append(child)
|
|
414
|
+
elif child.tag == "tr":
|
|
415
|
+
groups.append([child])
|
|
416
|
+
elif child.tag in ("thead", "tbody", "tfoot"):
|
|
417
|
+
groups.append([row for row in _children(child) if row.tag == "tr" and not _hidden(row)])
|
|
418
|
+
origins, active = [], []
|
|
419
|
+
row_index, height, width = 0, sum(map(len, groups)), 0
|
|
420
|
+
for group in groups:
|
|
421
|
+
for group_row, row in enumerate(group):
|
|
422
|
+
active = [span for span in active if span[0] > row_index]
|
|
423
|
+
occupied = sorted((start, end) for _end_row, start, end in active)
|
|
424
|
+
column = occupied_index = 0
|
|
425
|
+
for cell in _children(row):
|
|
426
|
+
if cell.tag not in ("td", "th") or _hidden(cell):
|
|
427
|
+
continue
|
|
428
|
+
rowspan = _span(cell, "rowspan", len(group) - group_row)
|
|
429
|
+
colspan = _span(cell, "colspan", len(group) - group_row)
|
|
430
|
+
while occupied_index < len(occupied):
|
|
431
|
+
start, end = occupied[occupied_index]
|
|
432
|
+
if column + colspan <= start:
|
|
433
|
+
break
|
|
434
|
+
if column < end:
|
|
435
|
+
column = end
|
|
436
|
+
occupied_index += 1
|
|
437
|
+
height, width = max(height, row_index + rowspan), max(width, column + colspan)
|
|
438
|
+
if height * width > _TABLE_CELL_LIMIT:
|
|
439
|
+
raise BrewdocError("HTML table cells limit exceeded: %d" % _TABLE_CELL_LIMIT)
|
|
440
|
+
origins.append((cell, row_index, column, rowspan, colspan))
|
|
441
|
+
if rowspan > 1:
|
|
442
|
+
active.append((row_index + rowspan, column, column + colspan))
|
|
443
|
+
column += colspan
|
|
444
|
+
row_index += 1
|
|
445
|
+
return height, width, origins, captions
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _cell_text(cell, tables: dict) -> str:
|
|
449
|
+
"""Render included cell data iteratively, keeping nested tables at their source position."""
|
|
450
|
+
parts, links, emphasis, pending = [], [], [], [(cell, False)]
|
|
451
|
+
while pending:
|
|
452
|
+
node, leaving = pending.pop()
|
|
453
|
+
tag = node.tag
|
|
454
|
+
if tag == "-text":
|
|
455
|
+
text = _escape(_SPACE.sub(" ", node.text()))
|
|
456
|
+
first = next((frame for frame in emphasis if frame["first"]), None)
|
|
457
|
+
if first is not None:
|
|
458
|
+
trimmed = text.lstrip()
|
|
459
|
+
leading = text[:len(text) - len(trimmed)]
|
|
460
|
+
if leading:
|
|
461
|
+
index = first["start"]
|
|
462
|
+
parts.insert(index, leading)
|
|
463
|
+
for frame in emphasis:
|
|
464
|
+
if frame["start"] >= index:
|
|
465
|
+
frame["start"] += 1
|
|
466
|
+
text = trimmed
|
|
467
|
+
if text:
|
|
468
|
+
for frame in emphasis:
|
|
469
|
+
frame["first"] = False
|
|
470
|
+
parts.append(text)
|
|
471
|
+
continue
|
|
472
|
+
if tag == "-comment" or (not leaving and _hidden(node)):
|
|
473
|
+
continue
|
|
474
|
+
records, consumes = _control(node, leaving)
|
|
475
|
+
if records:
|
|
476
|
+
parts.extend(" " + record + " " for record in records)
|
|
477
|
+
if consumes:
|
|
478
|
+
continue
|
|
479
|
+
if tag == "table":
|
|
480
|
+
parts.append(" " + tables[node]["nested"] + " ")
|
|
481
|
+
continue
|
|
482
|
+
if tag in ("code", "pre"):
|
|
483
|
+
parts.append('Code: text="%s"' % _escape_literal(_literal_text(node)))
|
|
484
|
+
parts.extend(" " + record for record in _literal_links(node))
|
|
485
|
+
continue
|
|
486
|
+
if tag == "img":
|
|
487
|
+
parts.append(" " + _image(node) + " ")
|
|
488
|
+
continue
|
|
489
|
+
if tag == "br" or tag in _BLOCKS or tag in _CONTROL_BOUNDARIES:
|
|
490
|
+
parts.append(" ")
|
|
491
|
+
if tag in _EMPHASIS and _literal_text(node).strip():
|
|
492
|
+
if leaving:
|
|
493
|
+
emphasis.pop()
|
|
494
|
+
_close_emphasis(parts, _EMPHASIS[tag])
|
|
495
|
+
else:
|
|
496
|
+
emphasis.append({"start": len(parts), "first": True})
|
|
497
|
+
parts.append(_EMPHASIS[tag])
|
|
498
|
+
if tag == "a":
|
|
499
|
+
if leaving:
|
|
500
|
+
target = links.pop()
|
|
501
|
+
if target is not None:
|
|
502
|
+
parts.append("](%s)" % quote(target, safe="/:#?&=@%+;,"))
|
|
503
|
+
else:
|
|
504
|
+
target = _target(node.attributes.get("href"))[1]
|
|
505
|
+
links.append(target)
|
|
506
|
+
if target is not None:
|
|
507
|
+
parts.append("[")
|
|
508
|
+
if not leaving:
|
|
509
|
+
pending.append((node, True))
|
|
510
|
+
pending.extend((child, False) for child in reversed(_children(node)))
|
|
511
|
+
return "".join(parts).strip()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _prepare_tables(body) -> dict:
|
|
515
|
+
"""Validate total expansion first, then build nested records from the deepest table outward."""
|
|
516
|
+
layouts, pending, expanded = {}, [body], 0
|
|
517
|
+
while pending:
|
|
518
|
+
node = pending.pop()
|
|
519
|
+
if node.tag in ("-text", "-comment") or _hidden(node):
|
|
520
|
+
continue
|
|
521
|
+
if node.tag == "table":
|
|
522
|
+
layout = _table_layout(node)
|
|
523
|
+
expanded += layout[0] * layout[1]
|
|
524
|
+
if expanded > _TABLE_CELL_LIMIT:
|
|
525
|
+
raise BrewdocError("HTML table cells limit exceeded: %d" % _TABLE_CELL_LIMIT)
|
|
526
|
+
layouts[node] = layout
|
|
527
|
+
pending.extend(reversed(_children(node)))
|
|
528
|
+
tables = {}
|
|
529
|
+
for table, (height, width, origins, caption_nodes) in reversed(layouts.items()):
|
|
530
|
+
captions = [caption for node in caption_nodes if (caption := _cell_text(node, tables))]
|
|
531
|
+
cells = [(row, column, rowspan, colspan, _cell_text(cell, tables))
|
|
532
|
+
for cell, row, column, rowspan, colspan in origins]
|
|
533
|
+
records = ["Caption: " + caption for caption in captions]
|
|
534
|
+
records.extend('row=%d; column=%d; rowspan=%d; colspan=%d; text="%s"'
|
|
535
|
+
% (row + 1, column + 1, rowspan, colspan, text.replace('"', """))
|
|
536
|
+
for row, column, rowspan, colspan, text in cells)
|
|
537
|
+
tables[table] = {"rows": height, "columns": width, "cells": cells, "captions": captions,
|
|
538
|
+
"nested": "Nested table: " + "; ".join(records) if records else ""}
|
|
539
|
+
return tables
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _table_rows(table: dict) -> list[list[str]]:
|
|
543
|
+
"""Allocate the validated rectangular grid, with empty span continuations."""
|
|
544
|
+
rows = [[""] * table["columns"] for _ in range(table["rows"])]
|
|
545
|
+
for row, column, _rowspan, _colspan, text in table["cells"]:
|
|
546
|
+
rows[row][column] = text
|
|
547
|
+
return rows
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _scoped_items(body, tables: dict) -> set:
|
|
551
|
+
"""Find items and ancestors whose standalone child blocks need explicit boundaries."""
|
|
552
|
+
scoped, ancestors, pending = set(), [], [(body, False)]
|
|
553
|
+
while pending:
|
|
554
|
+
node, leaving = pending.pop()
|
|
555
|
+
tag = node.tag
|
|
556
|
+
if tag in ("-text", "-comment") or (not leaving and _hidden(node)):
|
|
557
|
+
continue
|
|
558
|
+
if leaving:
|
|
559
|
+
if _control(node, True)[0]:
|
|
560
|
+
scoped.update(ancestors)
|
|
561
|
+
if tag == "li":
|
|
562
|
+
ancestors.pop()
|
|
563
|
+
continue
|
|
564
|
+
if tag == "li":
|
|
565
|
+
ancestors.append(node)
|
|
566
|
+
records, consumes = _control(node)
|
|
567
|
+
standalone = bool(records) or tag == "img"
|
|
568
|
+
if tag in ("pre", "code"):
|
|
569
|
+
standalone = bool(_literal_text(node))
|
|
570
|
+
elif tag == "blockquote":
|
|
571
|
+
standalone = bool(_literal_text(node).strip())
|
|
572
|
+
elif tag == "table":
|
|
573
|
+
standalone = bool(tables[node]["columns"] or tables[node]["captions"])
|
|
574
|
+
if standalone:
|
|
575
|
+
scoped.update(ancestors)
|
|
576
|
+
if consumes or tag in ("pre", "code", "table", "img"):
|
|
577
|
+
continue
|
|
578
|
+
pending.append((node, True))
|
|
579
|
+
pending.extend((child, False) for child in reversed(_children(node)))
|
|
580
|
+
return scoped
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _html_blocks(body, tally: dict) -> list[tuple]:
|
|
584
|
+
"""Walk the recovered body in DOM order without recursive Python calls."""
|
|
585
|
+
blocks, fragments, lists, items, contexts, quotes = [], [], [], [], [], []
|
|
586
|
+
heading, last_list = "", False
|
|
587
|
+
tables = _prepare_tables(body)
|
|
588
|
+
scoped = _scoped_items(body, tables)
|
|
589
|
+
|
|
590
|
+
def append_text(text: str) -> None:
|
|
591
|
+
"""Open generated contexts only when a segment contains meaningful source text."""
|
|
592
|
+
if text.strip():
|
|
593
|
+
if any(context["emphasis"] and not context["active"] for context in contexts):
|
|
594
|
+
trimmed = text.lstrip()
|
|
595
|
+
fragments.append(text[:len(text) - len(trimmed)])
|
|
596
|
+
text = trimmed
|
|
597
|
+
for context in contexts:
|
|
598
|
+
if not context["active"]:
|
|
599
|
+
fragments.append(context["opening"])
|
|
600
|
+
context["active"] = True
|
|
601
|
+
context["text"] = True
|
|
602
|
+
fragments.append(text)
|
|
603
|
+
|
|
604
|
+
def flush() -> None:
|
|
605
|
+
"""Emit one semantic region, preserving list grouping and source-derived context."""
|
|
606
|
+
nonlocal last_list
|
|
607
|
+
for context in reversed(contexts):
|
|
608
|
+
if context["active"]:
|
|
609
|
+
if context["emphasis"]:
|
|
610
|
+
_close_emphasis(fragments, context["closing"])
|
|
611
|
+
else:
|
|
612
|
+
fragments.append(context["closing"])
|
|
613
|
+
context["active"] = False
|
|
614
|
+
text = "".join(fragments).strip()
|
|
615
|
+
fragments.clear()
|
|
616
|
+
if not text:
|
|
617
|
+
return
|
|
618
|
+
lines = text.split("\n")
|
|
619
|
+
if heading:
|
|
620
|
+
lines[0] = ("#" * (int(heading[1]) + 2) + " " + lines[0] if heading < "h5"
|
|
621
|
+
else "**Heading %s: %s**" % (heading[1], lines[0]))
|
|
622
|
+
plain = bool(items) and not items[-1]["scoped"]
|
|
623
|
+
first = plain and not items[-1]["emitted"]
|
|
624
|
+
if plain:
|
|
625
|
+
prefix = items[-1]["prefix"]
|
|
626
|
+
continuation = " " * len(prefix)
|
|
627
|
+
lines = [(prefix if first else continuation) + lines[0],
|
|
628
|
+
*(continuation + line for line in lines[1:])]
|
|
629
|
+
items[-1]["emitted"] = True
|
|
630
|
+
tally["text_regions"] += 1
|
|
631
|
+
if first and last_list:
|
|
632
|
+
blocks[-1][1].extend(lines)
|
|
633
|
+
else:
|
|
634
|
+
blocks.append(("text", lines))
|
|
635
|
+
last_list = plain
|
|
636
|
+
|
|
637
|
+
pending = [(body, False)]
|
|
638
|
+
while pending:
|
|
639
|
+
node, leaving = pending.pop()
|
|
640
|
+
tag = node.tag
|
|
641
|
+
if tag == "-text":
|
|
642
|
+
append_text(_escape(_SPACE.sub(" ", node.text())))
|
|
643
|
+
continue
|
|
644
|
+
if tag == "-comment" or (not leaving and _hidden(node)):
|
|
645
|
+
continue
|
|
646
|
+
if leaving:
|
|
647
|
+
if tag in _CONTROL_BOUNDARIES:
|
|
648
|
+
flush()
|
|
649
|
+
records, _consumes = _control(node, True)
|
|
650
|
+
for record in records:
|
|
651
|
+
blocks.append(("text", [record]))
|
|
652
|
+
tally["text_regions"] += 1
|
|
653
|
+
last_list = False
|
|
654
|
+
elif tag in _EMPHASIS or tag == "a":
|
|
655
|
+
context = contexts.pop()
|
|
656
|
+
if context["active"]:
|
|
657
|
+
if context["emphasis"]:
|
|
658
|
+
_close_emphasis(fragments, context["closing"])
|
|
659
|
+
else:
|
|
660
|
+
fragments.append(context["closing"])
|
|
661
|
+
if tag == "a" and context["fallback"] and not context["text"] and context["target"] is not None:
|
|
662
|
+
flush()
|
|
663
|
+
blocks.append(("text", ["[Link target](%s)" % context["target"]]))
|
|
664
|
+
tally["text_regions"] += 1
|
|
665
|
+
last_list = False
|
|
666
|
+
elif tag == "li":
|
|
667
|
+
flush()
|
|
668
|
+
item = items.pop()
|
|
669
|
+
if item["scoped"]:
|
|
670
|
+
if len(blocks) == item["start"] + 1:
|
|
671
|
+
del blocks[item["start"]:]
|
|
672
|
+
else:
|
|
673
|
+
blocks.append(("text", ["End list item (level %d)." % item["level"]]))
|
|
674
|
+
last_list = False
|
|
675
|
+
elif tag in ("ul", "ol"):
|
|
676
|
+
flush()
|
|
677
|
+
lists.pop()
|
|
678
|
+
elif tag == "blockquote":
|
|
679
|
+
flush()
|
|
680
|
+
start = quotes.pop()
|
|
681
|
+
if len(blocks) == start + 1:
|
|
682
|
+
del blocks[start:]
|
|
683
|
+
else:
|
|
684
|
+
blocks.append(("text", ["End quote (level %d)." % (len(quotes) + 1)]))
|
|
685
|
+
last_list = False
|
|
686
|
+
elif tag in _HEADINGS:
|
|
687
|
+
flush()
|
|
688
|
+
heading = ""
|
|
689
|
+
elif tag in _BLOCKS:
|
|
690
|
+
flush()
|
|
691
|
+
continue
|
|
692
|
+
records, consumes = _control(node)
|
|
693
|
+
if consumes and not records:
|
|
694
|
+
continue
|
|
695
|
+
if records or tag in _CONTROL_BOUNDARIES:
|
|
696
|
+
flush()
|
|
697
|
+
for record in records:
|
|
698
|
+
blocks.append(("text", [record]))
|
|
699
|
+
tally["text_regions"] += 1
|
|
700
|
+
last_list = False
|
|
701
|
+
if consumes:
|
|
702
|
+
continue
|
|
703
|
+
if tag == "table":
|
|
704
|
+
flush()
|
|
705
|
+
table = tables[node]
|
|
706
|
+
for caption in table["captions"]:
|
|
707
|
+
blocks.append(("text", ["Caption: " + caption]))
|
|
708
|
+
tally["text_regions"] += 1
|
|
709
|
+
if table["columns"]:
|
|
710
|
+
blocks.append(("table", _table_rows(table)))
|
|
711
|
+
tally["tables"] += 1
|
|
712
|
+
last_list = False
|
|
713
|
+
continue
|
|
714
|
+
if tag in ("pre", "code"):
|
|
715
|
+
flush()
|
|
716
|
+
literal = _literal_text(node)
|
|
717
|
+
if literal:
|
|
718
|
+
blocks.append(("block", ["```", *literal.split("\n"), "```"]))
|
|
719
|
+
tally["text_regions"] += 1
|
|
720
|
+
last_list = False
|
|
721
|
+
for context in contexts:
|
|
722
|
+
context["fallback"] = True
|
|
723
|
+
for record in _literal_links(node):
|
|
724
|
+
blocks.append(("text", [record]))
|
|
725
|
+
tally["text_regions"] += 1
|
|
726
|
+
continue
|
|
727
|
+
if tag == "img":
|
|
728
|
+
flush()
|
|
729
|
+
blocks.append(("text", [_image(node)]))
|
|
730
|
+
tally["text_regions"] += 1
|
|
731
|
+
last_list = False
|
|
732
|
+
for context in contexts:
|
|
733
|
+
context["fallback"] = True
|
|
734
|
+
continue
|
|
735
|
+
if tag == "br":
|
|
736
|
+
append_text("\n")
|
|
737
|
+
continue
|
|
738
|
+
if tag in _BLOCKS or tag in ("ul", "ol", "li", "blockquote") or tag in _HEADINGS:
|
|
739
|
+
flush()
|
|
740
|
+
if tag in ("ul", "ol"):
|
|
741
|
+
try:
|
|
742
|
+
start = int(node.attributes.get("start") or "1")
|
|
743
|
+
except ValueError:
|
|
744
|
+
start = 1
|
|
745
|
+
lists.append([tag, start])
|
|
746
|
+
elif tag == "li":
|
|
747
|
+
marker = "%d." % lists[-1][1] if lists and lists[-1][0] == "ol" else "-"
|
|
748
|
+
if lists:
|
|
749
|
+
lists[-1][1] += 1
|
|
750
|
+
level = max(1, len(lists))
|
|
751
|
+
base = next((item["level"] for item in reversed(items) if item["scoped"]), 0)
|
|
752
|
+
item = {"level": level, "scoped": node in scoped, "start": len(blocks), "emitted": False,
|
|
753
|
+
"prefix": " " * max(0, level - base - 1) + marker + " "}
|
|
754
|
+
items.append(item)
|
|
755
|
+
if item["scoped"]:
|
|
756
|
+
blocks.append(("text", ['List item (level %d, marker "%s"):' % (level, marker)]))
|
|
757
|
+
last_list = False
|
|
758
|
+
elif tag == "blockquote":
|
|
759
|
+
quotes.append(len(blocks))
|
|
760
|
+
blocks.append(("text", ["Quote (level %d):" % len(quotes)]))
|
|
761
|
+
last_list = False
|
|
762
|
+
elif tag in _HEADINGS:
|
|
763
|
+
heading = tag
|
|
764
|
+
elif tag in _EMPHASIS or tag == "a":
|
|
765
|
+
target = _target(node.attributes.get("href"))[1] if tag == "a" else None
|
|
766
|
+
target = quote(target, safe="/:#?&=@%+;,") if target is not None else None
|
|
767
|
+
opening = "[" if target is not None else _EMPHASIS.get(tag, "")
|
|
768
|
+
closing = "](%s)" % target if target is not None else opening
|
|
769
|
+
contexts.append({"opening": opening, "closing": closing, "active": False,
|
|
770
|
+
"text": False, "fallback": False, "target": target, "emphasis": tag in _EMPHASIS})
|
|
771
|
+
pending.append((node, True))
|
|
772
|
+
pending.extend((child, False) for child in reversed(_children(node)))
|
|
773
|
+
flush()
|
|
774
|
+
return blocks
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _render_html(path: Path, _sheets=None) -> Rendered:
|
|
778
|
+
"""Render one static chapter, or explicitly refuse an unavailable HTML prerequisite."""
|
|
779
|
+
if not _SUPPORTED_RUNTIME:
|
|
780
|
+
raise BrewdocError("HTML requires CPython 3.12-3.14")
|
|
781
|
+
if LexborHTMLParser is None:
|
|
782
|
+
raise BrewdocError("HTML parser unavailable: selectolax 0.4.13: %s" % _PARSER_ERROR)
|
|
783
|
+
tally = new_tally()
|
|
784
|
+
with reading(path, "HTML"):
|
|
785
|
+
with path.open("rb") as source:
|
|
786
|
+
raw = source.read(_INPUT_BYTE_LIMIT + 1)
|
|
787
|
+
if len(raw) > _INPUT_BYTE_LIMIT:
|
|
788
|
+
raise BrewdocError("HTML input byte limit exceeded: %d" % _INPUT_BYTE_LIMIT)
|
|
789
|
+
parser = LexborHTMLParser(_decode_html(raw))
|
|
790
|
+
_validate_html(parser)
|
|
791
|
+
title = next((label for node in parser.head.css("title")
|
|
792
|
+
if (label := _SPACE.sub(" ", node.text()).strip())), "Untitled")
|
|
793
|
+
blocks = _html_blocks(parser.body, tally)
|
|
794
|
+
rendered = _assemble(path, ROUTE.name, ROUTE.unit_kind, 1, [(1, title, blocks)], tally,
|
|
795
|
+
not_carried=ROUTE.not_carried)
|
|
796
|
+
if len(rendered[0].encode("utf-8")) > _MARKDOWN_BYTE_LIMIT:
|
|
797
|
+
raise BrewdocError("HTML Markdown byte limit exceeded: %d" % _MARKDOWN_BYTE_LIMIT)
|
|
798
|
+
return rendered
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
ROUTE = Route("html", "chapter", (".html",), _render_html,
|
|
802
|
+
("dynamic browser content, scripts and stylesheets",
|
|
803
|
+
"graphical SVG content, canvas pixels and MathML semantics",
|
|
804
|
+
"active media and embeds",
|
|
805
|
+
"inline code layout outside tables: rendered as fenced blocks",
|
|
806
|
+
"inline code monospace inside table cells",
|
|
807
|
+
"hidden inputs and supplied password or file values"))
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def render_html(path) -> tuple[str, dict]:
|
|
811
|
+
"""Return Markdown and tally for one UTF-8 static HTML chapter."""
|
|
812
|
+
return _render_html(Path(path))[:2]
|