primarysource 0.1.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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,1247 @@
1
+ """HTML to readable markdown, standard library only.
2
+
3
+ A ZIM article is whatever the scraper wrote. The devdocs ZIMs wrap each page in an application shell (a
4
+ navbar custom element, preload links, a stylesheet) and inline every image as a base64 data URI, so a naive
5
+ tag stripper returns kilobytes of base64 before the first heading. This converter keeps what an agent needs:
6
+ headings (with their anchors), paragraphs, lists, tables, definition lists, block quotes and, above all,
7
+ **code blocks**, with their language when the markup names one. It drops scripts, styles, navigation chrome
8
+ and data URIs.
9
+
10
+ The output is a list of *blocks*. A block is one paragraph, heading, list item, table, code fence or
11
+ definition: the unit that paging counts, that a `¶n` reference points at, and that is never split.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import posixpath
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from html.parser import HTMLParser
20
+ from urllib.parse import unquote, urlsplit
21
+
22
+ SKIP_TAGS = frozenset(
23
+ {"script", "style", "svg", "nav", "head", "template", "noscript", "button", "select", "iframe", "canvas"}
24
+ | {"devdocs-navbar", "form", "audio", "video", "object", "dialog"}
25
+ )
26
+ VOID_TAGS = frozenset({"br", "hr", "img", "meta", "link", "input", "wbr", "source", "area", "base", "col", "embed"})
27
+ BLOCK_TAGS = frozenset(
28
+ {"p", "div", "section", "article", "header", "footer", "main", "details", "summary", "figure", "figcaption"}
29
+ | {"dl", "dd", "address", "fieldset", "center"}
30
+ )
31
+ PAGER_RELS = frozenset({"next", "prev", "previous"})
32
+ """HTML link types for pagination. A link with one of them that wraps *block* content (a card with a label and a
33
+ heading) is a pager widget and is dropped whole; the same link around inline prose text stays an ordinary link."""
34
+ HEADINGS = {f"h{n}": n for n in range(1, 7)}
35
+ CLOSES_HEAD = frozenset(HEADINGS) | {"dt", "dd", "pre", "ul", "ol", "li", "blockquote", "table", "hr", "dl"}
36
+ """Start tags that end an open heading or `<dt>` even though its end tag is missing. Other block tags (`p`, `div`)
37
+ end it only once it holds text, so `<dt><p>name</p></dt>` still reads as one definition."""
38
+ CLOSES_HEAD_END = frozenset({"dl", "ul", "ol", "li", "table", "blockquote", "section", "article", "details"})
39
+ MAX_INDENT = 8
40
+ """Nesting levels that are indented. Deeper list items stay at this level: every block repeats its indentation,
41
+ so unbounded indentation made the output quadratic in the input (n nested `<ul>` cost 130 MB at n = 8,000)."""
42
+ MAX_OUTPUT_CHARS = 4_000_000
43
+ """Ceiling on the converted text of one document. Past it blocks are dropped and `Document.truncated` is set, so a
44
+ crafted or merely enormous page cannot grow the process without bound."""
45
+ # ARIA landmark roles that are page chrome, whatever the element is called: Sphinx's Read the Docs theme puts the
46
+ # whole site menu in a <div role="navigation"> and the Previous/Next buttons in <div role="navigation"> inside a
47
+ # <footer>; it has no <main> element to select the content with.
48
+ SKIP_ROLES = frozenset({"navigation", "search", "contentinfo"})
49
+ CHROME_CLASS_TAGS = frozenset({"div", "span", "ul", "ol", "section", "aside", "header", "footer", "table"})
50
+ """Elements whose class names are read as page chrome. Not `body`, `html`, `a`, headings or `p`: a `<body
51
+ class="has-toolbar">` must not empty the page and a `<p class="sidebar-title">` is the sidebar directive's content."""
52
+ CHROME_CLASSES = frozenset(
53
+ {"toolbar", "breadcrumb", "breadcrumbs", "pagination", "pager", "prev-next", "page-nav"}
54
+ | {"toc", "table-of-contents", "nav-panel", "mini-toc", "ttc", "lineno", "permalink", "navpath"}
55
+ )
56
+ """Whole class names that mark chrome. `nav-panel` and `mini-toc` are Texinfo's per-node Next/Previous/Up bar and
57
+ chapter menu, `ttc` is Doxygen's hover tooltip, `lineno` its source line number, `permalink` its member-heading
58
+ glyph, `navpath` its breadcrumb; `pagination`, `pager`, `prev-next` and `page-nav` are the previous/next widgets
59
+ of doc themes."""
60
+ CHROME_WORDS = frozenset({"toolbar", "breadcrumb", "breadcrumbs", "pagination"})
61
+ """A class token whose hyphen- or underscore-separated words include one of these is chrome too (`tsd-page-toolbar`,
62
+ `md-breadcrumbs`, `s-pagination--item`). `sidebar` is handled apart: see `is_chrome`."""
63
+ REGION_CANDIDATES = ("main", "role", "id", "article")
64
+ """Content-region rules in order of preference: a `<main>` element, an element with `role="main"`, an element
65
+ with `id="main-content"`, a *single* `<article>`. See `_region_candidates`."""
66
+ # Icon-font glyphs (Font Awesome's permalink U+F0C1, for one) live in the Private Use Area and are noise in text.
67
+ PRIVATE_USE = re.compile("[\ue000-\uf8ff]")
68
+ TABLE_PARTS = frozenset({"tr", "td", "th", "thead", "tbody", "tfoot"})
69
+ CELL_BREAKS = BLOCK_TAGS | frozenset({"li", "ul", "ol", "dt", "blockquote", "tr"}) | frozenset(HEADINGS)
70
+ """Tags that start or end a block inside a table cell. A cell is collected as one string with `\\x01` at each block
71
+ boundary, `\\x03` before a list item and `\\x02<n>\\x02` where the n-th `<pre>` of the table sat; the end of the table
72
+ decides whether those become one flattened pipe-table cell or real blocks (see `_Builder._finish_table`)."""
73
+ CELL_PRE = re.compile("\x02(\\d+)\x02")
74
+ CELL_MARKS = re.compile("[\x01\x03]")
75
+ CONTROL = re.compile("[\x01-\x03]")
76
+ LINE_CLASSES = frozenset({"line", "code-line", "token-line"})
77
+ """Class tokens of the element a highlighter wraps around one source line inside a `<pre>` (Shiki and Chroma
78
+ `span.line`, Prism `token-line`). Their end is a line break unless the text already ends with one."""
79
+ LANG_NOISE = frozenset({"default", "none", "nohighlight", "notranslate", "line", "lines", "block", "wrapper"})
80
+ LANG_PREFIX = re.compile(r"(?:language|lang)[-_](.+)", re.IGNORECASE)
81
+ LANG_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_+#.\-]{0,31}")
82
+ LANG_MARKERS = frozenset({"highlight", "highlighter-rouge", "sourcecode", "codehilite", "code-block", "codeblock"})
83
+ """A wrapper element only names a fence language through `language-x` when it also carries one of these, so
84
+ `<body class="language-en">` (a page language) is not mistaken for a code language."""
85
+ FOLD_NOISE = frozenset({"hljs", "highlight", "numberlines", "numbersource", "sourcecode", "prettyprint", "linenums"})
86
+ GLYPHS = frozenset({"§", "¶"})
87
+ """Permalink glyphs: a pilcrow (Sphinx) or a section sign (rustdoc) that is the whole text of an anchor."""
88
+ GRID_COLUMNS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}
89
+
90
+
91
+ def fence_language(classes: str, *, wrapper: bool = False) -> str:
92
+ """The code language a class list names, or "". Understands `language-x` / `lang-x` (CommonMark, Prism,
93
+ highlight.js, Docusaurus), Sphinx's and GitHub's `highlight-x` / `highlight-source-x`, SyntaxHighlighter's
94
+ `brush: x`, and the bare second token of pandoc's `sourceCode python` and highlight.js's `hljs python`.
95
+ With `wrapper` set the list belongs to an enclosing element, where `language-x` needs a marker class."""
96
+ tokens = classes.split()
97
+ lowered = [token.lower() for token in tokens]
98
+ candidates: list[str] = []
99
+ for index, token in enumerate(tokens):
100
+ low = lowered[index]
101
+ named = LANG_PREFIX.match(token)
102
+ if named:
103
+ if not wrapper or LANG_MARKERS.intersection(lowered):
104
+ candidates.append(named.group(1))
105
+ elif low.startswith("highlight-"):
106
+ rest = token[10:]
107
+ candidates.append(rest[7:] if rest.lower().startswith("source-") else rest)
108
+ elif low.startswith("brush:"):
109
+ candidates.append(token[6:] or (tokens[index + 1] if index + 1 < len(tokens) else ""))
110
+ if not candidates and ("sourcecode" in lowered or "hljs" in lowered):
111
+ candidates = [t for t, low in zip(tokens, lowered, strict=True) if low not in FOLD_NOISE]
112
+ for candidate in candidates:
113
+ if LANG_NAME.fullmatch(candidate) and candidate.lower() not in LANG_NOISE:
114
+ return candidate
115
+ return ""
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Heading:
120
+ """An addressable place in a document: a real heading, or a definition (`<dt id=...>`) in an API reference."""
121
+
122
+ level: int
123
+ text: str
124
+ anchor: str
125
+ block: int
126
+ kind: str = "heading"
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Link:
131
+ text: str
132
+ href: str
133
+ path: str | None # resolved ZIM path for an internal link, None for an external one
134
+ anchor: str
135
+
136
+
137
+ @dataclass
138
+ class Document:
139
+ title: str
140
+ blocks: list[str]
141
+ headings: list[Heading] = field(default_factory=list)
142
+ links: list[Link] = field(default_factory=list)
143
+ dropped_images: int = 0
144
+ tight: list[bool] = field(default_factory=list)
145
+ truncated: bool = False
146
+ aliases: dict[str, int] = field(default_factory=dict)
147
+ """Anchor -> position in `headings`, for ids that sit on a wrapper element next to a heading rather than on the
148
+ heading itself (Texinfo's `<div class="section-level-extent" id="Exit-Status">`). Not outline entries."""
149
+
150
+ @property
151
+ def markdown(self) -> str:
152
+ return join_blocks(self.blocks, self.tight)
153
+
154
+ def span(self, position: int) -> tuple[int, int]:
155
+ """Half-open block range owned by `self.headings[position]`. A heading owns everything up to the next
156
+ heading of the same or a shallower level; a definition owns its body up to the next addressable place."""
157
+ head = self.headings[position]
158
+ end = len(self.blocks)
159
+ for later in self.headings[position + 1 :]:
160
+ if head.kind == "definition" or (later.kind == "heading" and later.level <= head.level):
161
+ end = later.block
162
+ break
163
+ return head.block, max(end, head.block + 1)
164
+
165
+ def section(self, anchor: str) -> tuple[int, int] | None:
166
+ """Block range of the section or definition an anchor names. Anchors are unique within a document
167
+ (the converter suffixes repeats, see `_Builder._unique`), so there is exactly one."""
168
+ for position, head in enumerate(self.headings):
169
+ if head.anchor == anchor:
170
+ return self.span(position)
171
+ if anchor in self.aliases:
172
+ return self.span(self.aliases[anchor])
173
+ return None
174
+
175
+
176
+ def join_blocks(blocks: list[str], tight: list[bool] | None = None) -> str:
177
+ """Blank line between blocks, except that consecutive list items stay together."""
178
+ out: list[str] = []
179
+ for index, block in enumerate(blocks):
180
+ if index:
181
+ out.append("\n" if tight and tight[index] and tight[index - 1] else "\n\n")
182
+ out.append(block)
183
+ return "".join(out)
184
+
185
+
186
+ def resolve_href(base: str, href: str) -> tuple[str | None, str]:
187
+ """Resolve a link found in the article at `base` to (ZIM path, anchor). A scheme or `//host` target is
188
+ external and has no path. ZIM paths have no leading slash and `..` is resolved against the article's own
189
+ directory, which is how the scrapers write relative links."""
190
+ parts = urlsplit(href.strip())
191
+ anchor = unquote(parts.fragment)
192
+ if parts.scheme or parts.netloc:
193
+ return None, anchor
194
+ target = unquote(parts.path)
195
+ if not target:
196
+ return base, anchor
197
+ if target.startswith("/"):
198
+ return posixpath.normpath(target.lstrip("/")), anchor
199
+ joined = posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
200
+ return ("" if joined == "." else joined), anchor
201
+
202
+
203
+ class _Depth:
204
+ """One element being tracked by its own tag name: open it, count only the same-named tags nested inside, and
205
+ it ends when that count returns to zero. Stray, void or misnested tags of other names are ignored, which is
206
+ what real HTML needs: Starlette's table-of-contents template writes `<li></li><a>..</a></li>` inside a `<nav>`,
207
+ and a counter that decremented on *every* end tag reached zero while the nav was still open."""
208
+
209
+ __slots__ = ("depth", "tag")
210
+
211
+ def __init__(self) -> None:
212
+ self.tag = ""
213
+ self.depth = 0
214
+
215
+ def __bool__(self) -> bool:
216
+ return self.depth > 0
217
+
218
+ def open(self, tag: str) -> None:
219
+ self.tag = tag
220
+ self.depth = 1
221
+
222
+ def nest(self, tag: str) -> None:
223
+ if self.depth and tag == self.tag:
224
+ self.depth += 1
225
+
226
+ def close(self, tag: str) -> bool:
227
+ """True when this end tag closes the tracked element itself."""
228
+ if self.depth and tag == self.tag:
229
+ self.depth -= 1
230
+ return self.depth == 0
231
+ return False
232
+
233
+
234
+ def is_chrome(tag: str, attrs: dict[str, str], in_container: bool = False, *, links: bool = False) -> bool:
235
+ """Is this element page chrome (navigation, toolbar, tooltip) whose content is not documentation?
236
+
237
+ `in_container` says that an `<article>` or `<section>` is open, where a `<footer>` is the article's own byline
238
+ rather than the page footer. With `links=True` only the chrome whose links are noise too is reported."""
239
+ if tag in VOID_TAGS:
240
+ return False
241
+ if tag in SKIP_TAGS or attrs.get("role", "").lower() in SKIP_ROLES:
242
+ return True
243
+ if tag == "footer" and not in_container:
244
+ return True
245
+ if tag not in CHROME_CLASS_TAGS or not attrs.get("class"):
246
+ return False
247
+ for token in attrs["class"].lower().split():
248
+ words = re.split(r"[-_]", token)
249
+ # A bare `sidebar` is Sphinx's own directive, whose content is documentation (the asyncio Hello World
250
+ # example); a compound such as `col-sidebar` or `sidebar-drawer` is the site's column.
251
+ sidebar = len(words) > 1 and "sidebar" in words and tag != "span"
252
+ if sidebar or "toolbar" in words:
253
+ return True
254
+ # Pager, breadcrumb and Texinfo node-bar links stay in `zim_links`: they are the neighbouring chapters,
255
+ # while a sidebar tree is thousands of site-wide links.
256
+ if not links and (token in CHROME_CLASSES or CHROME_WORDS.intersection(words)):
257
+ return True
258
+ return False
259
+
260
+
261
+ def _region_candidates(html: str) -> list[str]:
262
+ """The content-region rules that this page might satisfy, in order of preference. A page that has no `<main>`
263
+ often has `role="main"` on a div or article (Furo, Read the Docs, Stack Exchange), `id="main-content"`, or a
264
+ single `<article>`. Several `<article>`s (a blog index, a list of questions) are the content *list*, not one
265
+ region, and are never selected. The scan is textual, so a candidate can be a false positive (inside a script
266
+ or a comment): `convert` falls back to the next rule when the parser never enters the region."""
267
+ found: list[str] = []
268
+ if re.search(r"<main\b", html, re.IGNORECASE):
269
+ found.append("main")
270
+ if re.search(r"\brole\s*=\s*[\"']?main(?![\w-])", html, re.IGNORECASE):
271
+ found.append("role")
272
+ if re.search(r"\bid\s*=\s*[\"']?main-content(?![\w-])", html, re.IGNORECASE):
273
+ found.append("id")
274
+ if len(re.findall(r"<article\b", html, re.IGNORECASE)) == 1:
275
+ found.append("article")
276
+ return found
277
+
278
+
279
+ class _Region:
280
+ """The part of the page that is read. `kind` is one of `REGION_CANDIDATES`, or empty for the whole body."""
281
+
282
+ def __init__(self, kind: str) -> None:
283
+ self.kind = kind
284
+ self.element = _Depth()
285
+ self.entered = False
286
+
287
+ @property
288
+ def inside(self) -> bool:
289
+ return not self.kind or bool(self.element)
290
+
291
+ def start(self, tag: str, attrs: dict[str, str]) -> bool:
292
+ """Feed a start tag; True when it opens the region's own element."""
293
+ if self.element:
294
+ self.element.nest(tag)
295
+ return False
296
+ if tag in VOID_TAGS or not self.kind:
297
+ return False
298
+ matches = {
299
+ "main": tag == "main",
300
+ "role": attrs.get("role", "").strip().lower() == "main",
301
+ "id": attrs.get("id", "") == "main-content",
302
+ "article": tag == "article",
303
+ }[self.kind]
304
+ if matches:
305
+ self.element.open(tag)
306
+ self.entered = True
307
+ return matches
308
+
309
+ def end(self, tag: str) -> bool:
310
+ """Feed an end tag; True when it closes the region's own element."""
311
+ return self.element.close(tag)
312
+
313
+
314
+ class _Builder(HTMLParser):
315
+ def __init__(self, base: str, region: str = "") -> None:
316
+ super().__init__(convert_charrefs=True)
317
+ self.base = base
318
+ self.region = _Region(region)
319
+ self.title_outside = False # an <h1> was seen outside the region (and outside any chrome)
320
+ self.title = ""
321
+ self.blocks: list[str] = []
322
+ self.headings: list[Heading] = []
323
+ self.links: list[Link] = []
324
+ self.dropped_images = 0
325
+ self._skip = _Depth() # the chrome element being skipped, if any
326
+ self._frag = _Depth() # a Doxygen `div.fragment` source listing being read as one code block
327
+ self._pending_ids: list[str] = [] # ids seen since the last block, candidates for a heading's aliases
328
+ self._head_aliases: list[str] = []
329
+ self.aliases: dict[str, int] = {}
330
+ self._in_title = False
331
+ self._title_parts: list[str] = []
332
+ self._buf: list[str] = []
333
+ self._ids: list[tuple[str, str, str]] = [] # (tag, id, class) of every open element
334
+ self._lists: list[list[int]] = [] # [ordered?, counter] per open list
335
+ self._quote = 0
336
+ self._item_prefix: str | None = None
337
+ self._head: tuple[int, str, str] | None = None # level, anchor, kind of the heading being read
338
+ self._pre: list[str] | None = None
339
+ self._pre_lang = ""
340
+ self._table: list[list[str]] | None = None
341
+ self._row: list[str] | None = None
342
+ self._cell: list[str] | None = None
343
+ self._cell_pre: list[str] | None = None # a <pre> inside a table cell: see _finish_table
344
+ self._cell_pres: list[tuple[str, str]] = [] # (language, code) of each <pre> met in the table's cells
345
+ self._nested = 0 # open <table>s inside a cell: their markup is flattened into the cell
346
+ self._grid: _Grid | None = None # a javadoc div.summary-table being read as a table
347
+ self._code_stack: list[str] = [] # open elements inside the current <pre>
348
+ self._code_break = False # a line ended inside the <pre> and its newline has not been written
349
+ self._code_closed = 0 # <code> children of the current <pre> already closed
350
+ self._tab: list[str] | None = None # id, aria-controls, text of the <button role=tab> being read
351
+ self._tabs: list[tuple[str, str, str]] = [] # (id, controls, label) of tabs not yet matched to a panel
352
+ self._lead: str | None = None # admonition kind waiting to prefix the next paragraph
353
+ self._title: tuple[str, int] | None = None # tag and depth of an open admonition-title
354
+ self._glyph: int | None = None # buffer index where an <a> inside a heading began
355
+ self._header_row = False
356
+ self._code = 0
357
+ self._link_mark: tuple[int, str] | None = None # buffer index of the "[" and the target
358
+ self._cell_link: tuple[int, str] | None = None
359
+ self._tight_marks: list[bool] = []
360
+ self._chars = 0
361
+ self.truncated = False
362
+ self._anchors: dict[str, int] = {} # anchor -> how many headings have claimed it
363
+ self._pager: tuple[int, int, int] | None = (
364
+ None # blocks, headings, buffer length at an open rel=next/prev link
365
+ )
366
+
367
+ # --- region selection -------------------------------------------------------------------------------
368
+ @property
369
+ def _live(self) -> bool:
370
+ return not self._skip and self.region.inside
371
+
372
+ def _in_container(self) -> bool:
373
+ """Is an `<article>` or `<section>` open? A `<footer>` there is its byline, not the page footer."""
374
+ return any(entry[0] in {"article", "section"} for entry in self._ids)
375
+
376
+ # --- output helpers ---------------------------------------------------------------------------------
377
+ def _text(self) -> str:
378
+ return re.sub(r"[ \t\r\f\v\n]+", " ", "".join(self._buf)).strip()
379
+
380
+ def _emit(self, block: str, *, tight: bool = False) -> None:
381
+ self._pending_ids = [] # text came between an earlier id and whatever heading follows
382
+ if self._lead is not None:
383
+ self._emit_lead() # the next block is not a paragraph (a fence, a table, a definition)
384
+ if self._quote:
385
+ block = "\n".join(("> " + line).rstrip() for line in block.split("\n"))
386
+ if self._chars + len(block) > MAX_OUTPUT_CHARS:
387
+ self.truncated = True
388
+ return
389
+ self._chars += len(block) + 2
390
+ self.blocks.append(block)
391
+ self._tight_marks.append(tight)
392
+
393
+ def _emit_lead(self) -> None:
394
+ """An admonition's kind (`Note`, `Warning`) as a line of its own, when no paragraph came to carry it."""
395
+ lead, self._lead = self._lead, None
396
+ if lead is not None:
397
+ self._emit(f"**{lead}:**")
398
+
399
+ def _flush(self) -> None:
400
+ if self._head is not None:
401
+ return # a heading collects its own text on close
402
+ if self._link_mark is not None:
403
+ # A block boundary inside an open link (<a><h2>..</h2></a>, card links wrapping <div>s): markdown
404
+ # links cannot span blocks, so the link is dropped and its text kept. Without this the "[" already in
405
+ # the buffer became a block of its own - on 91% of mankier pages in a sample of 1,802.
406
+ index = self._link_mark[0]
407
+ if index < len(self._buf) and self._buf[index] == "[":
408
+ del self._buf[index]
409
+ self._link_mark = None
410
+ if self._pager is not None:
411
+ # Prose before a rel=next/prev link is not part of the card: emit it, then start the card here.
412
+ tail = self._buf[index:]
413
+ self._buf = self._buf[:index]
414
+ self._flush()
415
+ self._buf = tail
416
+ self._pager = (len(self.blocks), len(self.headings), 0)
417
+ text = self._text()
418
+ self._buf = []
419
+ if not text:
420
+ return
421
+ if self._lead is not None:
422
+ text, self._lead = f"**{self._lead}:** {text}", None
423
+ if self._item_prefix is not None:
424
+ depth = min(len(self._lists) - 1, MAX_INDENT)
425
+ self._emit(" " * max(depth, 0) + self._item_prefix + text, tight=True)
426
+ self._item_prefix = None
427
+ else:
428
+ indent = " " * min(len(self._lists), MAX_INDENT)
429
+ self._emit(indent + text)
430
+
431
+ def _unique(self, anchor: str) -> str:
432
+ """An anchor no earlier heading has claimed: the second claimant of `content` becomes `content-2`. Without
433
+ this every heading under `<div id=content>` shared one anchor and only the first could be addressed."""
434
+ if not anchor:
435
+ return ""
436
+ seen = self._anchors.get(anchor, 0)
437
+ self._anchors[anchor] = seen + 1
438
+ if not seen:
439
+ return anchor
440
+ while True:
441
+ seen += 1
442
+ candidate = f"{anchor}-{seen}"
443
+ if candidate not in self._anchors:
444
+ self._anchors[candidate] = 1
445
+ self._anchors[anchor] = seen
446
+ return candidate
447
+
448
+ def _anchor_here(self, attrs: dict[str, str]) -> str:
449
+ if attrs.get("id"):
450
+ return self._unique(attrs["id"])
451
+ for _, ident, _ in reversed(self._ids):
452
+ if ident:
453
+ return self._unique(ident)
454
+ return ""
455
+
456
+ def _note_id(self, tag: str, attrs: dict[str, str]) -> None:
457
+ """Remember an id that sits on something other than a heading, until text or a heading follows it. Texinfo
458
+ names a node on a wrapper `<div id="Exit-Status">` and gives the heading inside it another id, and Doxygen
459
+ puts `<a id=...>` just before a member's heading; links and `#anchor` reads use the wrapper's id."""
460
+ if tag in HEADINGS or tag == "dt":
461
+ return
462
+ ident = attrs.get("id") or (attrs.get("name", "") if tag == "a" else "")
463
+ if ident:
464
+ self._pending_ids.append(ident)
465
+ del self._pending_ids[:-8] # an index chapter is thousands of empty anchors in a row
466
+
467
+ def _heading_head(self, tag: str, attrs: dict[str, str]) -> tuple[int, str, str]:
468
+ """What a heading start tag opens. The ids pending since the last block become its aliases, except for a
469
+ Doxygen member title (`h2.memtitle`): that is a definition, the way a Sphinx `<dt id>` is, anchored by the
470
+ `<a id>` just before it, and not a section at the level of the `Function Documentation` it sits under."""
471
+ pending, self._pending_ids = self._pending_ids, []
472
+ if "memtitle" in attrs.get("class", "").lower().split():
473
+ return 0, pending[-1] if pending else "", "definition"
474
+ self._head_aliases = pending
475
+ return HEADINGS[tag], self._anchor_here(attrs), "heading"
476
+
477
+ def _fragment_opens(self, tag: str, attrs: dict[str, str]) -> bool:
478
+ """Doxygen writes a source listing and `@code` examples as `<div class="fragment">` holding one
479
+ `<div class="line">` per line: read it as a pre-formatted block instead of one paragraph per line."""
480
+ if tag != "div" or self._pre is not None or self._cell is not None:
481
+ return False
482
+ if "fragment" not in attrs.get("class", "").lower().split():
483
+ return False
484
+ if self._head is not None and self._text():
485
+ self._close_head()
486
+ self._flush()
487
+ self._pre = []
488
+ self._pre_lang = ""
489
+ self._frag.open("div")
490
+ return True
491
+
492
+ def _fragment_done(self) -> None:
493
+ body = "".join(self._pre or []).strip("\n").rstrip()
494
+ self._pre = None
495
+ if body.strip():
496
+ self._emit(_fenced(body))
497
+
498
+ def _close_head(self) -> None:
499
+ """Finish the heading or definition being read, as if its end tag had arrived. HTML lets `</dt>`,
500
+ `</dd>` and `</p>` be omitted, and a browser reads `<h2>Title<p>Body</p>` as a heading and a paragraph.
501
+ Waiting for an end tag that never comes swallowed the rest of the page (found in review, C1)."""
502
+ if self._head is None:
503
+ return
504
+ level, anchor, kind = self._head
505
+ self._head = None
506
+ text = self._text().rstrip(chr(0xB6)).rstrip() # a trailing pilcrow is a permalink glyph
507
+ self._buf = []
508
+ aliases, self._head_aliases = self._head_aliases, []
509
+ if not text:
510
+ return
511
+ if kind == "heading":
512
+ for ident in aliases:
513
+ if ident not in self._anchors: # a real heading anchor always wins over a wrapper's id
514
+ self.aliases.setdefault(ident, len(self.headings))
515
+ self.headings.append(Heading(level, text, anchor, len(self.blocks), kind))
516
+ self._emit("#" * level + " " + text)
517
+ else:
518
+ if kind == "definition" and text.lower() == "expand description":
519
+ return # rustdoc's toggle label, not a term
520
+ if anchor:
521
+ self.headings.append(Heading(7, text, self._unique(anchor), len(self.blocks), kind))
522
+ self._emit("**" + _escape_asterisks(text) + "**")
523
+
524
+ # --- parser callbacks -------------------------------------------------------------------------------
525
+ def handle_starttag(self, tag: str, attrs_list: list[tuple[str, str | None]]) -> None:
526
+ attrs = {key: value or "" for key, value in attrs_list}
527
+ if tag == "title":
528
+ self._in_title = True
529
+ return
530
+ if self._skip:
531
+ self._skip.nest(tag)
532
+ return
533
+ if is_chrome(tag, attrs, tag == "footer" and self._in_container()):
534
+ if tag == "button" and attrs.get("role", "").lower() == "tab" and self._live:
535
+ # sphinx-tabs: the label is the only thing that says which shell or language a panel is for.
536
+ self._tab = [attrs.get("id", ""), attrs.get("aria-controls", ""), ""]
537
+ self._skip.open(tag)
538
+ return
539
+ if self.region.start(tag, attrs) and tag == "main":
540
+ return
541
+ if tag == "h1" and not self.region.inside:
542
+ self.title_outside = True
543
+ if not self._live:
544
+ return
545
+ if self._frag:
546
+ self._frag.nest(tag) # inside a source listing the tags are layout; the text goes to the fence
547
+ return
548
+ if self._fragment_opens(tag, attrs):
549
+ return
550
+ self._note_id(tag, attrs)
551
+ if tag not in VOID_TAGS:
552
+ self._ids.append((tag, attrs.get("id", ""), attrs.get("class", "")))
553
+ if self._grid is not None and self._grid_start(tag, attrs):
554
+ return
555
+ if self._table is not None and self._table_start(tag, attrs):
556
+ return
557
+ if self._cell is not None:
558
+ self._cell_start(tag, attrs)
559
+ return
560
+ self._start_block(tag, attrs)
561
+
562
+ # --- tables -----------------------------------------------------------------------------------------
563
+ def _table_start(self, tag: str, attrs: dict[str, str]) -> bool:
564
+ """Row and cell structure of an open `<table>`, with HTML's implied end tags: `<tr>` closes the open row
565
+ and cell, `<td>`/`<th>` close the open cell. Returns True when the tag was consumed."""
566
+ if tag == "table" and self._cell is not None:
567
+ self._nested += 1 # a table inside a cell is read as text of that cell
568
+ self._cell.append("\x01")
569
+ return True
570
+ if tag not in TABLE_PARTS:
571
+ return False
572
+ if self._nested:
573
+ if self._cell is not None:
574
+ self._cell.append("\x01" if tag == "tr" else " ")
575
+ return True
576
+ if tag in {"td", "th"}:
577
+ self._close_cell()
578
+ if self._row is None:
579
+ self._row = [] # `<td>` without `<tr>`: the row is implied
580
+ self._cell = []
581
+ if tag == "th":
582
+ self._header_row = True
583
+ else:
584
+ self._close_row()
585
+ if tag == "tr":
586
+ self._row = []
587
+ return True
588
+
589
+ def _close_cell(self) -> None:
590
+ if self._cell is None:
591
+ return
592
+ if self._cell_pre is not None:
593
+ self._end_cell_pre()
594
+ if self._row is not None:
595
+ self._row.append("".join(self._cell))
596
+ self._cell = None
597
+ self._cell_link = None
598
+
599
+ def _close_row(self) -> None:
600
+ self._close_cell()
601
+ if self._row and self._table is not None:
602
+ self._table.append(self._row)
603
+ self._row = None
604
+
605
+ def _end_cell_pre(self) -> None:
606
+ if self._cell is None or self._cell_pre is None:
607
+ return
608
+ code = "".join(self._cell_pre).strip("\n").rstrip()
609
+ self._cell_pre = None
610
+ if code.strip():
611
+ self._cell_pres.append((self._pre_lang, code))
612
+ self._cell.append(f"\x01\x02{len(self._cell_pres) - 1}\x02\x01")
613
+ self._pre_lang = ""
614
+
615
+ def _table_end(self, tag: str) -> bool:
616
+ if self._nested:
617
+ if tag == "table":
618
+ self._nested -= 1
619
+ if tag in TABLE_PARTS or tag == "table":
620
+ if self._cell is not None:
621
+ self._cell.append("\x01" if tag in {"tr", "table"} else " ")
622
+ return True
623
+ return False
624
+ if tag in {"td", "th"}:
625
+ self._close_cell()
626
+ elif tag in TABLE_PARTS:
627
+ self._close_row()
628
+ elif tag == "table":
629
+ self._finish_table()
630
+ else:
631
+ return False
632
+ return True
633
+
634
+ def _finish_table(self) -> None:
635
+ self._close_row()
636
+ rows, self._table = self._table or [], None
637
+ pres, self._cell_pres = self._cell_pres, []
638
+ self._cell = None
639
+ self._row = None
640
+ if not rows:
641
+ return
642
+ if _is_box(rows):
643
+ self._emit_box(rows, pres)
644
+ else:
645
+ self._emit(_render_table([[_gfm_cell(cell, pres) for cell in row] for row in rows]))
646
+
647
+ def _emit_box(self, rows: list[list[str]], pres: list[tuple[str, str]]) -> None:
648
+ """A layout table (see `_is_box`): its cells become ordinary blocks, in reading order."""
649
+ indent = " " * min(len(self._lists), MAX_INDENT)
650
+ for row in rows:
651
+ for cell in row:
652
+ for segment in cell.split("\x01"):
653
+ pieces = CELL_PRE.split(segment)
654
+ for index, piece in enumerate(pieces):
655
+ if index % 2:
656
+ lang, code = pres[int(piece)]
657
+ self._emit(_fenced(code, lang))
658
+ continue
659
+ bullet = "\x03" in piece
660
+ text = re.sub(r"\s+", " ", CELL_MARKS.sub(" ", piece)).strip()
661
+ if text:
662
+ self._emit(indent + ("- " if bullet else "") + text)
663
+
664
+ # --- div grids (javadoc 17+ summary tables) ---------------------------------------------------------
665
+ def _grid_start(self, tag: str, attrs: dict[str, str]) -> bool:
666
+ grid = self._grid
667
+ if grid is None or tag != "div":
668
+ return False
669
+ grid.depth += 1
670
+ if grid.depth != 2:
671
+ return False # a div inside a cell is cell content
672
+ grid.header = "table-header" in attrs.get("class", "").split()
673
+ self._cell = []
674
+ return True
675
+
676
+ def _grid_end(self, tag: str) -> bool:
677
+ grid = self._grid
678
+ if grid is None or tag != "div":
679
+ return False
680
+ grid.depth -= 1
681
+ if grid.depth == 1 and self._cell is not None:
682
+ if self._cell_pre is not None:
683
+ self._end_cell_pre()
684
+ grid.cells.append((grid.header, "".join(self._cell)))
685
+ self._cell = None
686
+ self._cell_link = None
687
+ return True
688
+ if grid.depth == 0:
689
+ self._grid = None
690
+ headers = [text for is_head, text in grid.cells if is_head]
691
+ body = [text for is_head, text in grid.cells if not is_head]
692
+ width = len(headers) or grid.columns
693
+ pres, self._cell_pres = self._cell_pres, []
694
+ rows = [headers or [""] * width]
695
+ rows += [body[i : i + width] for i in range(0, len(body), width)]
696
+ if body or headers:
697
+ self._emit(_render_table([[_gfm_cell(cell, pres) for cell in row] for row in rows]))
698
+ return True
699
+ return False
700
+
701
+ def _cell_start(self, tag: str, attrs: dict[str, str]) -> None:
702
+ if self._cell is None:
703
+ return
704
+ if self._cell_pre is not None:
705
+ self._code_start(tag, attrs, self._cell_pre)
706
+ return
707
+ if tag == "pre":
708
+ self._cell_pre = []
709
+ self._code_reset()
710
+ self._pre_lang = self._language_for(attrs)
711
+ elif tag == "br":
712
+ self._cell.append(" ")
713
+ elif tag == "code":
714
+ self._cell.append("`")
715
+ elif tag in CELL_BREAKS:
716
+ self._cell.append("\x01\x03" if tag == "li" else "\x01")
717
+ elif tag == "a" and attrs.get("href"):
718
+ self._cell_link = self._open_link(self._cell, attrs["href"])
719
+ elif tag == "img":
720
+ self._cell.append(self._image(attrs))
721
+
722
+ def _image(self, attrs: dict[str, str]) -> str:
723
+ self.dropped_images += 1
724
+ alt = re.sub(r"\s+", " ", attrs.get("alt", "")).strip()
725
+ return f"[image: {alt}]" if alt and not alt.startswith("_images/") else "[image]"
726
+
727
+ # --- code blocks ------------------------------------------------------------------------------------
728
+ def _code_reset(self) -> None:
729
+ self._code_stack = []
730
+ self._code_break = False
731
+ self._code_closed = 0
732
+
733
+ def _code_text(self, sink: list[str], data: str) -> None:
734
+ if self._code_break:
735
+ self._code_break = False
736
+ if not data.startswith("\n"):
737
+ sink.append("\n")
738
+ sink.append(data)
739
+
740
+ def _code_newline(self, sink: list[str]) -> None:
741
+ """A line break the markup asks for. It is written lazily: a newline character that follows (Shiki writes
742
+ `</span>\\n<span class=line>`) is the same break, not a blank line, and a break at the very end of the
743
+ block is not written at all. A second break in a row does write the first, so `<br><br>` is a blank line."""
744
+ if self._code_break:
745
+ sink.append("\n")
746
+ self._code_break = True
747
+
748
+ def _code_start(self, tag: str, attrs: dict[str, str], sink: list[str]) -> None:
749
+ """A tag inside `<pre>`: `<br>` and per-line wrappers end lines (TypeDoc, Dokka, javadoc 8 separate the
750
+ lines of a snippet with `<br>` and no newline character at all), `<img>` is dropped, the rest is inline
751
+ markup around text that already is the code."""
752
+ if tag == "br":
753
+ self._code_newline(sink)
754
+ return
755
+ if tag == "img":
756
+ self.dropped_images += 1 # javadoc 8's inheritance-tree gifs: not code
757
+ return
758
+ if tag in VOID_TAGS:
759
+ return
760
+ direct = not self._code_stack
761
+ if tag == "code" and direct:
762
+ if self._code_closed and sink and not sink[-1].endswith("\n"):
763
+ self._code_break = True # one <code> per line
764
+ if sink is self._pre and not self._pre_lang:
765
+ self._pre_lang = fence_language(attrs.get("class", "")) or attrs.get("data-language", "")
766
+ elif self._is_line(tag, attrs) and self._code_break:
767
+ self._code_break = False
768
+ sink.append("\n")
769
+ self._code_stack.append(tag + ("\x00line" if self._is_line(tag, attrs) else ""))
770
+
771
+ @staticmethod
772
+ def _is_line(tag: str, attrs: dict[str, str]) -> bool:
773
+ return tag in {"div", "span", "p", "li"} and bool(
774
+ LINE_CLASSES.intersection(attrs.get("class", "").lower().split())
775
+ )
776
+
777
+ def _code_pop(self, tag: str) -> bool:
778
+ """Close `tag` inside the pre if it is open there. Returns whether it was."""
779
+ for index in range(len(self._code_stack) - 1, -1, -1):
780
+ if self._code_stack[index].split("\x00")[0] == tag:
781
+ del self._code_stack[index:]
782
+ return True
783
+ return False
784
+
785
+ def _code_end(self, tag: str, sink: list[str]) -> None:
786
+ for index in range(len(self._code_stack) - 1, -1, -1):
787
+ entry = self._code_stack[index]
788
+ if entry.split("\x00")[0] != tag:
789
+ continue
790
+ del self._code_stack[index:]
791
+ if "\x00line" in entry:
792
+ if sink and not sink[-1].endswith("\n"):
793
+ self._code_break = True
794
+ elif tag == "code" and index == 0:
795
+ self._code_closed += 1
796
+ return
797
+
798
+ def _language_for(self, attrs: dict[str, str]) -> str:
799
+ """Fence language of a `<pre>` that has just been pushed on `_ids`: its own attributes, then its
800
+ wrapper (Sphinx `div.highlight-python`, Material `div.language-python.highlight`, pandoc)."""
801
+ own = attrs.get("data-language") or attrs.get("data-lang") or fence_language(attrs.get("class", ""))
802
+ if own:
803
+ return own
804
+ for _, _, classes in reversed(self._ids[-5:-1]):
805
+ found = fence_language(classes, wrapper=True)
806
+ if found:
807
+ return found
808
+ return ""
809
+
810
+ def _tab_panel(self, attrs: dict[str, str]) -> None:
811
+ """The label of the `<button role=tab>` a `role=tabpanel` belongs to, as a bold line before it."""
812
+ if not self._tabs:
813
+ return
814
+ wanted = {attrs.get("id", ""), attrs.get("aria-labelledby", "")} - {""}
815
+ pick = next((i for i, (ident, controls, _) in enumerate(self._tabs) if {ident, controls} & wanted), 0)
816
+ label = self._tabs.pop(pick)[2]
817
+ self._flush()
818
+ self._emit("**" + _escape_asterisks(label) + "**")
819
+
820
+ def _start_block(self, tag: str, attrs: dict[str, str]) -> None:
821
+ if self._pre is not None:
822
+ self._code_start(tag, attrs, self._pre)
823
+ return
824
+ if self._head is not None and (tag in CLOSES_HEAD or (tag in BLOCK_TAGS and self._text())):
825
+ self._close_head()
826
+ if attrs.get("role", "").lower() == "tabpanel":
827
+ self._tab_panel(attrs)
828
+ if "admonition-title" in attrs.get("class", "").split():
829
+ self._flush()
830
+ self._title = (tag, len(self._ids))
831
+ return
832
+ if tag in HEADINGS:
833
+ self._flush()
834
+ self._head = self._heading_head(tag, attrs)
835
+ elif tag == "dt":
836
+ self._flush()
837
+ self._head = (0, attrs.get("id", ""), "definition")
838
+ elif tag == "pre":
839
+ self._flush()
840
+ self._pre = []
841
+ self._code_reset()
842
+ self._pre_lang = self._language_for(attrs)
843
+ elif tag == "summary":
844
+ self._flush()
845
+ parent = self._ids[-2] if len(self._ids) > 1 else None
846
+ if parent is not None and parent[0] == "details" and parent[1]:
847
+ # <details id=...><summary>Example (X)</summary>: the summary names the part, so it is a
848
+ # definition and the id is its anchor. Without an id the summary stays a plain line.
849
+ self._head = (0, parent[1], "definition")
850
+ elif tag in {"ul", "ol"}:
851
+ self._flush()
852
+ self._lists.append([1 if tag == "ol" else 0, 0])
853
+ elif tag == "li":
854
+ self._flush()
855
+ if self._lists:
856
+ kind = self._lists[-1]
857
+ kind[1] += 1
858
+ self._item_prefix = f"{kind[1]}. " if kind[0] else "- "
859
+ else:
860
+ self._item_prefix = "- "
861
+ elif tag == "blockquote":
862
+ self._flush()
863
+ self._quote += 1
864
+ elif tag == "table":
865
+ self._flush()
866
+ self._table = []
867
+ self._row = None
868
+ self._nested = 0
869
+ self._cell_pres = []
870
+ self._header_row = False
871
+ elif tag == "div" and "summary-table" in attrs.get("class", "").split():
872
+ self._flush()
873
+ columns = next((n for word, n in GRID_COLUMNS.items() if f"{word}-column-summary" in attrs["class"]), 1)
874
+ self._grid = _Grid(columns)
875
+ self._table = None
876
+ elif tag == "br":
877
+ self._buf.append(" ")
878
+ elif tag == "hr":
879
+ self._flush()
880
+ self._emit("---")
881
+ elif tag == "code" and self._pre is None:
882
+ self._code += 1
883
+ self._buf.append("`")
884
+ elif tag in {"em", "i"} and self._pre is None and self._head is None:
885
+ self._buf.append("*")
886
+ elif tag in {"strong", "b"} and self._pre is None and self._head is None:
887
+ self._buf.append("**")
888
+ elif tag == "a" and self._head is not None and not (self._head[2] == "definition" and not self._head[1]):
889
+ self._glyph = len(self._buf) # a link inside a heading is plain text, but its glyph is dropped
890
+ elif tag == "a" and attrs.get("href"):
891
+ if PAGER_RELS.intersection(attrs.get("rel", "").lower().split()):
892
+ self._pager = (len(self.blocks), len(self.headings), len(self._buf))
893
+ self._link_mark = self._open_link(self._buf, attrs["href"])
894
+ elif tag == "img":
895
+ self._buf.append(self._image(attrs))
896
+ elif tag in BLOCK_TAGS:
897
+ self._flush()
898
+
899
+ def _open_link(self, sink: list[str], href: str) -> tuple[int, str] | None:
900
+ """Start a markdown link in `sink`. Script and mailto targets are not links; the rest is resolved
901
+ against this article so an internal target reads as a ZIM path an agent can follow."""
902
+ if href.lower().startswith(("javascript:", "data:", "mailto:", "tel:")):
903
+ return None
904
+ path, anchor = resolve_href(self.base, href)
905
+ target = (path or "") + ("#" + anchor if anchor else "") if path is not None else href.strip()
906
+ sink.append("[")
907
+ return len(sink) - 1, target
908
+
909
+ @staticmethod
910
+ def _close_link(sink: list[str], mark: tuple[int, str] | None) -> None:
911
+ if mark is None:
912
+ return
913
+ index, target = mark
914
+ label = "".join(sink[index + 1 :]).strip()
915
+ if not label or label in {"¶", "#", "§"}:
916
+ del sink[index:] # an empty or permalink-glyph anchor carries nothing
917
+ else:
918
+ sink.append(f"]({target})")
919
+
920
+ def handle_endtag(self, tag: str) -> None:
921
+ if tag == "title":
922
+ self._in_title = False
923
+ self.title = re.sub(r"\s+", " ", "".join(self._title_parts)).strip()
924
+ return
925
+ if self._skip:
926
+ if self._skip.close(tag) and self._tab is not None and tag == "button":
927
+ label = re.sub(r"\s+", " ", self._tab[2]).strip()
928
+ if label:
929
+ self._tabs.append((self._tab[0], self._tab[1], label))
930
+ self._tab = None
931
+ return
932
+ closed = self.region.end(tag)
933
+ if closed:
934
+ self._close_head()
935
+ self._flush()
936
+ if tag == "main":
937
+ return
938
+ elif not self._live:
939
+ return
940
+ if self._frag:
941
+ depth = self._frag.depth
942
+ if self._frag.close(tag):
943
+ self._fragment_done()
944
+ elif tag == "div" and depth == 2 and self._pre is not None:
945
+ self._pre.append("\n") # `</div>` of a `div.line`: one source line per line element
946
+ return
947
+ if tag not in VOID_TAGS:
948
+ while self._ids:
949
+ if self._ids.pop()[0] == tag:
950
+ break
951
+ if self._grid is not None and self._grid_end(tag):
952
+ return
953
+ if self._table is not None and self._table_end(tag):
954
+ return
955
+ if self._cell is not None:
956
+ if self._cell_pre is not None:
957
+ if tag == "pre" and not self._code_pop(tag):
958
+ self._end_cell_pre()
959
+ elif tag != "pre":
960
+ self._code_end(tag, self._cell_pre)
961
+ return
962
+ if tag == "code":
963
+ self._cell.append("`")
964
+ elif tag == "a":
965
+ self._close_link(self._cell, self._cell_link)
966
+ self._cell_link = None
967
+ elif tag in CELL_BREAKS:
968
+ self._cell.append("\x01")
969
+ elif tag in {"td", "th"}:
970
+ self._cell.append(" ")
971
+ return
972
+ self._end_block(tag)
973
+
974
+ def _end_block(self, tag: str) -> None:
975
+ if self._pre is not None and (tag != "pre" or self._code_pop(tag)):
976
+ self._code_end(tag, self._pre)
977
+ return
978
+ if self._title is not None and tag == self._title[0] and len(self._ids) == self._title[1] - 1:
979
+ kind = self._text().rstrip(":").strip()
980
+ self._buf = []
981
+ self._title = None
982
+ self._lead = kind if kind and len(kind) <= 60 else None
983
+ return
984
+ if self._head is not None and tag in CLOSES_HEAD_END:
985
+ self._close_head()
986
+ if tag in HEADINGS or tag == "dt":
987
+ self._close_head()
988
+ elif tag == "summary":
989
+ if self._head is not None:
990
+ self._close_head()
991
+ else:
992
+ if self._text().lower() == "expand description":
993
+ self._buf = [] # rustdoc's toggle label
994
+ self._flush()
995
+ elif tag == "pre" and self._pre is not None:
996
+ body = "".join(self._pre).strip("\n").rstrip()
997
+ lang = self._pre_lang
998
+ self._pre = None
999
+ self._pre_lang = ""
1000
+ if body.strip():
1001
+ self._emit(_fenced(body, lang))
1002
+ elif tag in {"ul", "ol"}:
1003
+ self._flush()
1004
+ if self._lists:
1005
+ self._lists.pop()
1006
+ elif tag == "li":
1007
+ self._flush()
1008
+ self._item_prefix = None
1009
+ elif tag == "blockquote":
1010
+ self._flush()
1011
+ self._quote = max(0, self._quote - 1)
1012
+ elif tag == "code" and self._pre is None:
1013
+ self._code = max(0, self._code - 1)
1014
+ self._buf.append("`")
1015
+ elif tag in {"em", "i"} and self._pre is None and self._head is None:
1016
+ self._buf.append("*")
1017
+ elif tag in {"strong", "b"} and self._pre is None and self._head is None:
1018
+ self._buf.append("**")
1019
+ elif tag == "a" and self._glyph is not None:
1020
+ if "".join(self._buf[self._glyph :]).strip() in GLYPHS:
1021
+ del self._buf[self._glyph :]
1022
+ self._glyph = None
1023
+ elif tag == "a" and self._pager is not None:
1024
+ blocks, heads, buffered = self._pager
1025
+ self._pager = None
1026
+ if len(self.blocks) > blocks:
1027
+ # A pager card: rel=next/prev around block content. Everything it emitted is navigation, not text.
1028
+ del self.blocks[blocks:], self._tight_marks[blocks:], self.headings[heads:]
1029
+ del self._buf[buffered:]
1030
+ self._link_mark = None
1031
+ elif self._link_mark is not None:
1032
+ self._close_link(self._buf, self._link_mark)
1033
+ self._link_mark = None
1034
+ elif tag == "a" and self._link_mark is not None:
1035
+ self._close_link(self._buf, self._link_mark)
1036
+ self._link_mark = None
1037
+ elif tag in BLOCK_TAGS or tag == "dd":
1038
+ self._flush()
1039
+ if self._lead is not None and tag in {"div", "section", "article", "details", "figure"}:
1040
+ self._emit_lead() # the admonition ended without a paragraph to prefix
1041
+
1042
+ def handle_data(self, data: str) -> None:
1043
+ if self._in_title:
1044
+ self._title_parts.append(data)
1045
+ return
1046
+ if self._tab is not None and self._skip:
1047
+ self._tab[2] += data
1048
+ return
1049
+ if not self._live:
1050
+ return
1051
+ data = PRIVATE_USE.sub("", data)
1052
+ if self._cell is not None or self._grid is not None:
1053
+ data = CONTROL.sub("", data) # the cell markers are control characters; none belongs in text
1054
+ if not data:
1055
+ return
1056
+ if self._frag and self._frag.depth == 1 and not data.strip():
1057
+ return # the newline Doxygen writes between two line elements is not a line
1058
+ if self._pre is not None:
1059
+ self._code_text(self._pre, data)
1060
+ elif self._cell_pre is not None:
1061
+ self._code_text(self._cell_pre, data)
1062
+ elif self._cell is not None:
1063
+ self._cell.append(data)
1064
+ else:
1065
+ self._buf.append(data)
1066
+
1067
+
1068
+ def _fenced(body: str, lang: str = "") -> str:
1069
+ fence = "```"
1070
+ while fence in body:
1071
+ fence += "`"
1072
+ return f"{fence}{lang}\n{body}\n{fence}"
1073
+
1074
+
1075
+ def _gfm_cell(raw: str, pres: list[tuple[str, str]]) -> str:
1076
+ """One pipe-table cell from the marked-up text `_Builder` collected. A pipe table cell cannot hold a fence
1077
+ or a newline, so each `<pre>` in it becomes one code span with its lines joined by `<br>`."""
1078
+
1079
+ def span(match: re.Match[str]) -> str:
1080
+ code = pres[int(match.group(1))][1]
1081
+ return "`" + code.replace("`", "'").replace("\n", "<br>") + "`"
1082
+
1083
+ text = CELL_MARKS.sub(" ", CELL_PRE.sub(span, raw))
1084
+ return re.sub(r"\s+", " ", text).strip().replace("|", "\\|")
1085
+
1086
+
1087
+ def _is_box(rows: list[list[str]]) -> bool:
1088
+ """Is this table a layout box rather than data? One column (or one filled cell) whose content has block
1089
+ structure - a `<pre>`, or two or more paragraphs/items. Texinfo's `@cartouche` is exactly that: a one-cell table
1090
+ around a whole box of prose and examples, and a pipe table cannot hold it. Such a table is emitted as ordinary
1091
+ blocks. Everything else, including a single column of plain values with a header, stays a pipe table, where a
1092
+ `<pre>` degrades to an inline code span."""
1093
+ cells = [cell for row in rows for cell in row if CELL_MARKS.sub("", cell).strip()]
1094
+ if max(len(row) for row in rows) != 1 and len(cells) != 1:
1095
+ return False
1096
+ return any(CELL_PRE.search(cell) or sum(bool(s.strip()) for s in CELL_MARKS.split(cell)) > 1 for cell in cells)
1097
+
1098
+
1099
+ _VERBATIM = re.compile(r"(`[^`]*`|\]\([^)]*\))")
1100
+
1101
+
1102
+ def _escape_asterisks(text: str) -> str:
1103
+ """Backslash every literal `*` that is outside a code span or a link target, so `*args, **kwargs` survives
1104
+ being wrapped in `**...**`. Deleting the asterisks instead turned `**kwargs` into `kwargs`."""
1105
+ parts = _VERBATIM.split(text)
1106
+ return "".join(part if index % 2 else part.replace("*", "\\*") for index, part in enumerate(parts))
1107
+
1108
+
1109
+ def _render_table(rows: list[list[str]]) -> str:
1110
+ width = max(len(row) for row in rows)
1111
+ padded = [row + [""] * (width - len(row)) for row in rows]
1112
+ lines = ["| " + " | ".join(padded[0]) + " |", "|" + "|".join(" --- " for _ in range(width)) + "|"]
1113
+ lines += ["| " + " | ".join(row) + " |" for row in padded[1:]]
1114
+ return "\n".join(lines)
1115
+
1116
+
1117
+ @dataclass
1118
+ class _Grid:
1119
+ """A `div` grid read as a table: `depth` counts open divs (1 between cells), `cells` is (is_header, text)."""
1120
+
1121
+ columns: int
1122
+ depth: int = 1
1123
+ header: bool = False
1124
+ cells: list[tuple[bool, str]] = field(default_factory=list)
1125
+
1126
+
1127
+ class _LinkCollector(HTMLParser):
1128
+ """Second, tiny pass: anchors with their visible text. Kept separate from the block builder so that links
1129
+ inside skipped regions (the navbar) are not reported and the builder stays about layout."""
1130
+
1131
+ def __init__(self, base: str, region: str = "") -> None:
1132
+ super().__init__(convert_charrefs=True)
1133
+ self.base = base
1134
+ self.region = _Region(region)
1135
+ self.links: list[Link] = []
1136
+ self._skip = _Depth()
1137
+ self._open: list[str] = [] # open tags, to tell a page footer from an article's own
1138
+ self._href: str | None = None
1139
+ self._text: list[str] = []
1140
+
1141
+ def handle_starttag(self, tag: str, attrs_list: list[tuple[str, str | None]]) -> None:
1142
+ if tag in VOID_TAGS:
1143
+ return
1144
+ attrs = {key: value or "" for key, value in attrs_list}
1145
+ if self._skip:
1146
+ self._skip.nest(tag)
1147
+ return
1148
+ if is_chrome(tag, attrs, any(t in {"article", "section"} for t in self._open), links=True):
1149
+ self._skip.open(tag)
1150
+ return
1151
+ self.region.start(tag, attrs)
1152
+ self._open.append(tag)
1153
+ if tag == "a":
1154
+ href = dict(attrs_list).get("href") or ""
1155
+ self._href = href if href and not href.lower().startswith(("javascript:", "data:", "mailto:")) else None
1156
+ self._text = []
1157
+
1158
+ def handle_endtag(self, tag: str) -> None:
1159
+ if tag in VOID_TAGS:
1160
+ return
1161
+ if self._skip:
1162
+ self._skip.close(tag)
1163
+ return
1164
+ in_region = self.region.inside
1165
+ self.region.end(tag)
1166
+ while self._open:
1167
+ if self._open.pop() == tag:
1168
+ break
1169
+ if tag == "a" and self._href is not None:
1170
+ if in_region:
1171
+ text = re.sub(r"\s+", " ", PRIVATE_USE.sub("", "".join(self._text))).strip()
1172
+ path, anchor = resolve_href(self.base, self._href)
1173
+ # A link with no text left (a permalink glyph, an icon) points nowhere an agent would follow.
1174
+ if text and (path is not None or self._href.lower().startswith(("http:", "https:"))):
1175
+ self.links.append(Link(text, self._href if path is None else "", path, anchor))
1176
+ self._href = None
1177
+
1178
+ def handle_data(self, data: str) -> None:
1179
+ if self._href is not None and not self._skip:
1180
+ self._text.append(data)
1181
+
1182
+
1183
+ def _build(html: str, base_path: str, region: str) -> _Builder:
1184
+ builder = _Builder(base_path, region)
1185
+ builder.feed(html)
1186
+ builder.close()
1187
+ builder._close_head()
1188
+ builder._flush()
1189
+ return builder
1190
+
1191
+
1192
+ def convert(html: str, base_path: str = "") -> Document:
1193
+ """Convert one article. `base_path` is the article's own ZIM path, against which links are resolved."""
1194
+ builder = None
1195
+ for kind in _region_candidates(html):
1196
+ attempt = _build(html, base_path, kind)
1197
+ # A textual candidate inside a script or comment never opens. A `role=main` that leaves the page's own `<h1>`
1198
+ # outside is not the article: Stack Exchange puts the question title in a header above `#mainbar`.
1199
+ if attempt.region.entered and (kind == "main" or not attempt.title_outside):
1200
+ builder = attempt
1201
+ break
1202
+ if builder is None:
1203
+ builder = _build(html, base_path, "")
1204
+ links = _LinkCollector(base_path, builder.region.kind)
1205
+ links.feed(html)
1206
+ links.close()
1207
+ title = builder.title
1208
+ if not title:
1209
+ title = next((h.text for h in builder.headings if h.level == 1), "")
1210
+ return Document(
1211
+ title,
1212
+ builder.blocks,
1213
+ builder.headings,
1214
+ links.links,
1215
+ builder.dropped_images,
1216
+ builder._tight_marks,
1217
+ builder.truncated,
1218
+ builder.aliases,
1219
+ )
1220
+
1221
+
1222
+ _LINK = re.compile(r"\[([^\]]{0,500})\]\([^)]{0,2000}\)")
1223
+ """Bounded on both sides. Unbounded `[^]]*` made a run of `[` quadratic: each one scanned to the end of the line
1224
+ looking for a `]` (40,000 of them took 2.3 s, found in review, D3)."""
1225
+
1226
+
1227
+ def to_plain(markdown: str) -> str:
1228
+ """Plain text from the converter's own markdown: heading marks, emphasis, backticks and fences removed,
1229
+ links reduced to their text. Applied to the converter's output, not to arbitrary markdown."""
1230
+ out: list[str] = []
1231
+ in_fence = ""
1232
+ for line in markdown.split("\n"):
1233
+ stripped = line.lstrip("> ").lstrip()
1234
+ if in_fence:
1235
+ if stripped.startswith(in_fence):
1236
+ in_fence = ""
1237
+ continue
1238
+ out.append(line)
1239
+ continue
1240
+ if stripped.startswith("```"):
1241
+ in_fence = re.match(r"`+", stripped).group(0) # type: ignore[union-attr]
1242
+ continue
1243
+ line = re.sub(r"^(\s*)#{1,6} ", r"\1", line)
1244
+ line = _LINK.sub(r"\1", line)
1245
+ line = line.replace("**", "").replace("`", "")
1246
+ out.append(line)
1247
+ return "\n".join(out)