fux-decoder-html 4.1.1__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,12 @@
1
+ """`fux_decoder_html` — the fux-decoder-html package (W-289, SR-PLUGINS).
2
+
3
+ One fux decoder module per format it reads: `fux_decoder_html.html`. Each is the
4
+ decoder protocol of SR-DECODE — `EXTENSIONS`, `VERSION`, `USES` and
5
+ `decode(raw, rel_path)` — moved here unchanged from `src/fux/decode/` in
6
+ W-289 phase 2.
7
+
8
+ **fux loads a module from here only when `.fux/plugins.toml` declares this
9
+ package**, after verifying its version and digest (SR-PLUGINS P3, P4), by its
10
+ fixed import name (P7). A same-named file in `.fux/decoders/` overrides it
11
+ (P8). This file runs nothing: importing the package imports no decoder.
12
+ """
@@ -0,0 +1,421 @@
1
+ """HTML -> Markdown. **Lifted, not written.**
2
+
3
+ This parser was already in the repo — twice. `.fux/fetchers/http.py` carried it
4
+ and `.fux/fetchers/cdp.py` carried a copy marked *"Kept identical to…"*, with
5
+ nothing keeping it identical. `http.py`'s own docstring stated the consequence
6
+ as a rule it could not enforce:
7
+
8
+ Both fetchers must produce the same markdown from the same bytes, or which
9
+ fetcher retrieved a document would change the committed index.
10
+
11
+ That is **same sources -> same index** (L4) demoted to a code comment. Moving
12
+ the pass here makes the requirement structurally true instead of asked for:
13
+ there is one implementation, and both fetchers reach it.
14
+
15
+ **Behaviour is preserved deliberately, including its rough edges.** Anything
16
+ this emits differently from the fetcher copies would silently re-rank every
17
+ URL-sourced document already in a committed index. Changes to the conversion
18
+ belong in their own change, with the goldens re-blessed on purpose.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import codecs
24
+ import re
25
+ from html.parser import HTMLParser
26
+ from fux.constants import fixed
27
+ from fux_extension_table import table as _table
28
+ from fux.decode.ext.limits import limit
29
+
30
+ #: **The reuse key's handle on this decoder** (W-166). Bump it by hand in the
31
+ #: same change as any edit that can change what `decode()` returns, and the next
32
+ #: `fux ingest` re-extracts the documents bound to THIS decoder and no others.
33
+ #: Leaving it alone is the claim that the edit cannot move a byte of output.
34
+ #: `tests/decode/test_decoder_versions.py` fails on a changed module that did
35
+ #: not bump it. [SR-DECODE](../../../../records/0139_decode.md) decision 11a.
36
+ VERSION = fixed("decoders.html", "version") # 2 (W-287 A2/A4): thead/th header, colspan/rowspan, caption, nested tables, escaped pipes; not bumped by W-288 (imports moved to decode/ext, same output; USES now carries the extensions' VERSIONs)
37
+
38
+ EXTENSIONS = tuple(fixed("decoders.html", "extensions"))
39
+ #: The decoder extensions this decoder calls, read by the reuse key (SR-DECODE decision 24).
40
+ USES = tuple(fixed("decoders.html", "uses"))
41
+
42
+ _SKIP = {"script", "style", "head", "noscript", "template", "svg", "iframe"}
43
+ _BLOCK_BREAK = {"p", "div", "section", "article", "main", "header", "footer", "figure"}
44
+ #: `<h1>`..`<h6>` -- HTML's six levels are Markdown's (`constants.toml [markdown]`).
45
+ _HEADINGS = {f"h{level}": level for level in range(1, fixed("markdown", "max_heading") + 1)}
46
+
47
+ #: `<meta charset=...>` / `<meta http-equiv=content-type ... charset=...>`.
48
+ #: Only the first 4 KiB is searched, which is where the spec requires it and
49
+ #: where every real document puts it — and a bounded search keeps a pathological
50
+ #: file from turning encoding detection into a scan of the whole document.
51
+ _CHARSET_RE = re.compile(rb"""charset\s*=\s*["']?\s*([A-Za-z0-9_\-.:]+)""", re.IGNORECASE)
52
+
53
+ PREPEND_TITLE_HEADING = True
54
+
55
+ #: WHATWG's span clamps (`constants.toml [decoders.html.format]`).
56
+ _MAX_COLSPAN = fixed("decoders.html.format", "max_colspan")
57
+ _MAX_ROWSPAN = fixed("decoders.html.format", "max_rowspan")
58
+ _MAX_HEADING = fixed("markdown", "max_heading")
59
+
60
+
61
+ class _Table:
62
+ """One `<table>` being read: its rows, the cell in progress, its caption.
63
+
64
+ W-287 A2. A row is `(cells, section)` and a cell `(text, is_th, colspan,
65
+ rowspan)` — the spans are expanded only when the table closes, because a
66
+ `rowspan` reaches rows that have not been read yet.
67
+ """
68
+
69
+ def __init__(self) -> None:
70
+ self.rows: list[tuple[list[tuple[str, bool, int, int]], str]] = []
71
+ self.cell: list[str] | None = None
72
+ self.cell_th = False
73
+ self.cell_span = (1, 1)
74
+ self.caption: list[str] | None = None
75
+ self.caption_text = ""
76
+ self.section = "tbody"
77
+
78
+
79
+ def decode(raw: bytes, rel_path: str) -> str | None:
80
+ """One HTML document -> Markdown, or `None` when nothing was extractable.
81
+
82
+ `None` rather than an exception for an empty result: a page that is all
83
+ chrome and images has no prose to index, which is a fact about the page and
84
+ not a failure of the run.
85
+ """
86
+ html = raw.decode(_charset(raw), errors="replace")
87
+ markdown = html_to_markdown(html)
88
+ title = extract_title(html)
89
+ heading = "# " + title
90
+ if PREPEND_TITLE_HEADING and title and not markdown.startswith(heading + "\n"):
91
+ markdown = heading + "\n\n" + markdown
92
+ return markdown if markdown.strip() else None
93
+
94
+
95
+ def _charset(raw: bytes) -> str:
96
+ """The document's declared encoding, or utf-8.
97
+
98
+ A fetcher learns this from the HTTP `Content-Type` header; a file on disk
99
+ has no header, so the declaration inside the bytes is all there is. An
100
+ unknown or hostile label falls back to utf-8 rather than raising — the
101
+ caller passes `errors="replace"`, so a wrong guess costs a few characters
102
+ and never the document.
103
+ """
104
+ if raw.startswith(codecs.BOM_UTF8):
105
+ return "utf-8-sig"
106
+ match = _CHARSET_RE.search(raw[: limit("html", "charset_scan_bytes")])
107
+ if match:
108
+ label = match.group(1).decode("ascii", errors="replace").strip().lower()
109
+ try:
110
+ "".encode(label) # cheapest way to ask the codec registry
111
+ except (LookupError, TypeError, ValueError):
112
+ return "utf-8"
113
+ return label
114
+ return "utf-8"
115
+
116
+
117
+ def html_to_markdown(html: str) -> str:
118
+ parser = _MdParser()
119
+ parser.feed(html)
120
+ parser.close()
121
+ return parser.result()
122
+
123
+
124
+ def extract_title(html: str) -> str:
125
+ parser = _TitleParser()
126
+ parser.feed(html)
127
+ parser.close()
128
+ return parser.title.strip()
129
+
130
+
131
+ class _MdParser(HTMLParser):
132
+ def __init__(self) -> None:
133
+ super().__init__(convert_charrefs=True)
134
+ self.blocks: list[str] = []
135
+ self.inline: list[str] = []
136
+ self.skip_depth = 0
137
+ self.pre_depth = 0
138
+ self.pre_text: list[str] = []
139
+ self.heading: int | None = None
140
+ self.quote_depth = 0
141
+ self.list_stack: list[tuple[str, int]] = [] # (kind, counter)
142
+ self.href: list[str] = []
143
+ #: Open tables, innermost last. A table opened inside a cell is NESTED:
144
+ #: it is flattened into that cell when it closes (W-287 A2), where it
145
+ #: used to reset the outer table and lose every row read so far.
146
+ self.tables: list[_Table] = []
147
+ #: The level of the last heading emitted — a `<caption>` becomes a
148
+ #: heading one level below it. The title the decoder prepends is H1.
149
+ self.last_level = 1
150
+
151
+ # -- emit helpers ------------------------------------------------------
152
+
153
+ def _flush_inline(self, prefix: str = "") -> None:
154
+ text = " ".join("".join(self.inline).split())
155
+ self.inline = []
156
+ if text:
157
+ quote = "> " * self.quote_depth
158
+ self.blocks.append(f"{quote}{prefix}{text}")
159
+
160
+ def _text(self, data: str) -> None:
161
+ table = self.tables[-1] if self.tables else None
162
+ if self.pre_depth:
163
+ self.pre_text.append(data)
164
+ elif table is not None and table.caption is not None:
165
+ table.caption.append(data)
166
+ elif table is not None and table.cell is not None:
167
+ table.cell.append(data)
168
+ else:
169
+ self.inline.append(data)
170
+
171
+ # -- parser events -----------------------------------------------------
172
+
173
+ def handle_starttag(self, tag: str, attrs) -> None:
174
+ attrs = dict(attrs)
175
+ if tag in _SKIP:
176
+ self.skip_depth += 1
177
+ return
178
+ if self.skip_depth:
179
+ return
180
+ if tag == "pre":
181
+ self._flush_inline()
182
+ self.pre_depth += 1
183
+ elif tag in _HEADINGS:
184
+ self._flush_inline()
185
+ self.heading = _HEADINGS[tag]
186
+ elif tag in ("ul", "ol"):
187
+ self._flush_inline()
188
+ self.list_stack.append((tag, 0))
189
+ elif tag == "li":
190
+ self._flush_inline()
191
+ elif tag == "blockquote":
192
+ self._flush_inline()
193
+ self.quote_depth += 1
194
+ elif tag == "table":
195
+ if not self.tables:
196
+ self._flush_inline()
197
+ self.tables.append(_Table())
198
+ elif tag == "caption" and self.tables:
199
+ self.tables[-1].caption = []
200
+ elif tag in ("thead", "tbody", "tfoot") and self.tables:
201
+ self.tables[-1].section = tag
202
+ elif tag == "tr" and self.tables:
203
+ table = self.tables[-1]
204
+ self._close_cell(table)
205
+ table.rows.append(([], table.section))
206
+ elif tag in ("td", "th") and self.tables:
207
+ table = self.tables[-1]
208
+ self._close_cell(table)
209
+ table.cell = []
210
+ table.cell_th = tag == "th"
211
+ table.cell_span = (_span(attrs.get("colspan"), _MAX_COLSPAN), _span(attrs.get("rowspan"), _MAX_ROWSPAN))
212
+ elif tag == "a":
213
+ self.href.append(attrs.get("href", ""))
214
+ self._text("[")
215
+ elif tag == "img":
216
+ alt = attrs.get("alt", "") or "image"
217
+ src = attrs.get("src", "")
218
+ self._text(f"![{alt}]({src})")
219
+ elif tag == "code" and not self.pre_depth:
220
+ self._text("`")
221
+ elif tag in ("b", "strong"):
222
+ self._text("**")
223
+ elif tag in ("i", "em"):
224
+ self._text("*")
225
+ elif tag == "br":
226
+ self._flush_inline()
227
+ elif tag in ("hr",):
228
+ self._flush_inline()
229
+ self.blocks.append("---")
230
+ elif tag in _BLOCK_BREAK:
231
+ self._flush_inline()
232
+
233
+ def handle_endtag(self, tag: str) -> None:
234
+ if tag in _SKIP:
235
+ self.skip_depth = max(0, self.skip_depth - 1)
236
+ return
237
+ if self.skip_depth:
238
+ return
239
+ if tag == "pre":
240
+ self.pre_depth = max(0, self.pre_depth - 1)
241
+ if not self.pre_depth:
242
+ code = "\n".join(
243
+ line.rstrip() for line in "".join(self.pre_text).strip("\n").split("\n")
244
+ )
245
+ self.pre_text = []
246
+ self.blocks.append(f"```\n{code}\n```")
247
+ elif tag in _HEADINGS:
248
+ level = self.heading or _HEADINGS[tag]
249
+ self._flush_inline(prefix="#" * level + " ")
250
+ self.heading = None
251
+ self.last_level = level
252
+ elif tag in ("ul", "ol"):
253
+ self._flush_inline()
254
+ if self.list_stack:
255
+ self.list_stack.pop()
256
+ elif tag == "li":
257
+ indent = " " * max(0, len(self.list_stack) - 1)
258
+ if self.list_stack and self.list_stack[-1][0] == "ol":
259
+ kind, count = self.list_stack[-1]
260
+ self.list_stack[-1] = (kind, count + 1)
261
+ self._flush_inline(prefix=f"{indent}{count + 1}. ")
262
+ else:
263
+ self._flush_inline(prefix=f"{indent}- ")
264
+ elif tag == "blockquote":
265
+ self._flush_inline()
266
+ self.quote_depth = max(0, self.quote_depth - 1)
267
+ elif tag in ("td", "th") and self.tables:
268
+ self._close_cell(self.tables[-1])
269
+ elif tag == "caption" and self.tables:
270
+ table = self.tables[-1]
271
+ if table.caption is not None:
272
+ table.caption_text = " ".join("".join(table.caption).split())
273
+ table.caption = None
274
+ elif tag in ("thead", "tbody", "tfoot") and self.tables:
275
+ self.tables[-1].section = "tbody"
276
+ elif tag == "table" and self.tables:
277
+ table = self.tables.pop()
278
+ self._close_cell(table)
279
+ if self.tables:
280
+ self._nest(table, self.tables[-1])
281
+ else:
282
+ self._add_table(table)
283
+ elif tag == "a":
284
+ href = self.href.pop() if self.href else ""
285
+ self._text(f"]({href})" if href else "]")
286
+ elif tag == "code" and not self.pre_depth:
287
+ self._text("`")
288
+ elif tag in ("b", "strong"):
289
+ self._text("**")
290
+ elif tag in ("i", "em"):
291
+ self._text("*")
292
+ elif tag in _BLOCK_BREAK:
293
+ self._flush_inline()
294
+
295
+ def handle_data(self, data: str) -> None:
296
+ if not self.skip_depth:
297
+ self._text(data)
298
+
299
+ def _close_cell(self, table: _Table) -> None:
300
+ """End the cell in progress, if any — `</td>` is optional in HTML."""
301
+ if table.cell is None:
302
+ return
303
+ if table.rows:
304
+ text = " ".join("".join(table.cell).split())
305
+ colspan, rowspan = table.cell_span
306
+ table.rows[-1][0].append((text, table.cell_th, colspan, rowspan))
307
+ table.cell = None
308
+
309
+ def _nest(self, inner: _Table, outer: _Table) -> None:
310
+ """A nested table, flattened into the outer cell it sits in: its caption,
311
+ then each row's cells, rows separated by `;`."""
312
+ rows = [" ".join(t for t, *_ in cells if t) for cells, _section in inner.rows]
313
+ text = "; ".join(r for r in [inner.caption_text, *rows] if r)
314
+ if outer.cell is not None and text:
315
+ outer.cell.append(" " + text + " ")
316
+
317
+ def _add_table(self, table: _Table) -> None:
318
+ """W-287 A2/A4 — the table as a `Grid` for `ext.table.emit`, which writes
319
+ it; this only places the result, and writes no table of its own (W-288).
320
+ A caption is its heading, one level below the last heading emitted."""
321
+ grid = _grid(table.rows)
322
+ # Read only when the table declares no header: a `<th>` table needs no
323
+ # threshold, and `html_to_markdown` is called with no repository bound.
324
+ percent = None if grid.header else limit("html", "header_fill_percent")
325
+ out = _table.emit(
326
+ grid,
327
+ header_fill_percent=percent,
328
+ row_limit=None,
329
+ max_cell_chars=None,
330
+ )
331
+ if not out.markdown:
332
+ if table.caption_text:
333
+ self.blocks.append(table.caption_text)
334
+ return
335
+ if table.caption_text:
336
+ level = min(self.last_level + 1, _MAX_HEADING)
337
+ self.blocks.append("#" * level + " " + table.caption_text)
338
+ if out.prose:
339
+ self.blocks.append("\n".join(out.prose))
340
+ self.blocks.append(out.markdown)
341
+
342
+ def result(self) -> str:
343
+ self._flush_inline()
344
+ out = "\n\n".join(b for b in self.blocks if b.strip())
345
+ return out + "\n" if out else ""
346
+
347
+
348
+ def _span(value, cap: int) -> int:
349
+ """A `colspan`/`rowspan` attribute as a count: 1 when absent or nonsense,
350
+ clamped to WHATWG's ceiling. `rowspan="0"` spans the rest of the table."""
351
+ try:
352
+ n = int(str(value).strip())
353
+ except (TypeError, ValueError):
354
+ return 1
355
+ if n == 0:
356
+ return cap
357
+ return max(1, min(n, cap))
358
+
359
+
360
+ def _grid(rows) -> _table.Grid:
361
+ """The rows an HTML table held -> a `_table.Grid` (W-287 A2).
362
+
363
+ A cell is placed at the first column no earlier `rowspan` still holds;
364
+ `colspan`/`rowspan` become a span; a row with no cells is no row. The
365
+ declared header is the `<thead>` rows, else a leading run of rows whose
366
+ every cell is a `<th>`; with neither, `_table.emit` finds it by fill.
367
+ """
368
+ grid = _table.Grid()
369
+ pending: dict[int, int] = {} # column -> rows still covered below
370
+ sections: list[str] = []
371
+ all_th: list[bool] = []
372
+ r = -1
373
+ for cells, section in rows:
374
+ if not cells:
375
+ continue
376
+ r += 1
377
+ sections.append(section)
378
+ all_th.append(all(th for _t, th, *_ in cells))
379
+ occupied = set(pending)
380
+ for col, left in list(pending.items()):
381
+ if left <= 1:
382
+ del pending[col]
383
+ else:
384
+ pending[col] = left - 1
385
+ col = 0
386
+ for text, _th, colspan, rowspan in cells:
387
+ while col in occupied:
388
+ col += 1
389
+ grid.cells[(r, col)] = _table.Cell(text, "text")
390
+ if colspan > 1 or rowspan > 1:
391
+ grid.spans.append((r, col, r + rowspan - 1, col + colspan - 1))
392
+ for k in range(colspan):
393
+ occupied.add(col + k)
394
+ if rowspan > 1:
395
+ pending[col + k] = rowspan - 1
396
+ col += colspan
397
+ head = [i for i, section in enumerate(sections) if section == "thead"]
398
+ if not head:
399
+ while len(head) < len(all_th) and all_th[len(head)]:
400
+ head.append(len(head))
401
+ grid.header = tuple(head)
402
+ return grid
403
+
404
+
405
+ class _TitleParser(HTMLParser):
406
+ def __init__(self) -> None:
407
+ super().__init__(convert_charrefs=True)
408
+ self.title = ""
409
+ self._in_title = False
410
+
411
+ def handle_starttag(self, tag: str, attrs) -> None:
412
+ if tag == "title" and not self.title:
413
+ self._in_title = True
414
+
415
+ def handle_endtag(self, tag: str) -> None:
416
+ if tag == "title":
417
+ self._in_title = False
418
+
419
+ def handle_data(self, data: str) -> None:
420
+ if self._in_title:
421
+ self.title += data
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: fux-decoder-html
3
+ Version: 4.1.1
4
+ Summary: Fux decoder package: HTML.
5
+ Project-URL: Homepage, https://github.com/arpitarya/fux
6
+ Project-URL: Repository, https://github.com/arpitarya/fux
7
+ Author-email: arpit arya <arpitarya.me@gmail.com>
8
+ License-Expression: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: fux-core==4.1.1
11
+ Requires-Dist: fux-extension-table==4.1.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # fux-decoder-html
15
+
16
+ Part of [fux](https://github.com/arpitarya/fux) — rank your written knowledge
17
+ from a small index committed to git; fetch content from the systems that own
18
+ it; verify freshness at answer time.
19
+
20
+ **`fux-decoder-html` is a fux decoder package: HTML.** Only `fux-engine` is mandatory;
21
+ everything else is declared per repository in the committed
22
+ `.fux/plugins.toml`, pinned to one version and digest, and refused loudly when
23
+ it is missing or differs, so two machines on one repository never build
24
+ different indexes.
25
+
26
+ It reads `.html` `.htm` `.xhtml`. Install it and declare it in `.fux/plugins.toml` --
27
+ `fux setup` proposes it when your sources hold those files -- or fux does not
28
+ read them, and `fux doctor` says so.
29
+
30
+ It is released on fux's own version, together with every other fux package.
31
+ Documentation: <https://github.com/arpitarya/fux#readme>
@@ -0,0 +1,5 @@
1
+ fux_decoder_html/__init__.py,sha256=lJT0fwRaqTd-ggFuvSwf9gU3ZO6_MgTIGX74AlAKGHQ,623
2
+ fux_decoder_html/html.py,sha256=W5onAZJKcDmF3gDQt2yOQikRXg8E3g6zeaQHdsUiubo,16818
3
+ fux_decoder_html-4.1.1.dist-info/METADATA,sha256=uxKoo42GkKxICGfGk_JbRI6gR6zQ8NNDG1ajVf5vW_A,1290
4
+ fux_decoder_html-4.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ fux_decoder_html-4.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any