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/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"): "&#10;", ord("\t"): "&#9;", ord("\r"): "&#13;"}
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('"', "&quot;"))
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]