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