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,673 @@
1
+ """Exact section/paragraph structure from RFCXML v3 (RFC 8650 and later).
2
+
3
+ The txt chunker (chunker.py) has to guess headings from layout. For every RFC
4
+ published since RFC 8650 the RFC Editor also publishes the canonical RFCXML
5
+ source, already "prepped" (RFC 7998): every section and every block carries a
6
+ `pn` id such as 'section-9.6' or 'section-9.6-3', the same ids the official
7
+ HTML uses as anchors. So for those documents we take structure from XML and
8
+ keep the txt only as the coordinate system: each block is aligned back onto
9
+ the txt lines it rendered to, which keeps get_rfc / read / cite line ranges
10
+ identical in meaning for XML and txt-only documents.
11
+
12
+ What the XML adds beyond boundaries:
13
+ * paragraph-level ids (pn) -> citations finer than a section;
14
+ * <bcp14> markup -> which blocks state MUST/SHOULD requirements;
15
+ * typed <sourcecode type="abnf|yang|asn.1|..."> and <artwork>;
16
+ * <xref target=REF section="7.7"> -> section-precise cross-RFC edges.
17
+
18
+ Alignment works on lowercase alphanumeric tokens, so line wrapping,
19
+ indentation, hyphen breaks and bracket rendering do not matter; blocks are
20
+ anchored with 5-gram lookups and walked strictly in document order.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import xml.etree.ElementTree as ET
27
+ from dataclasses import dataclass, field
28
+
29
+ from .chunker import HARD_CHARS, MAX_CHARS, PART_CHARS, Section, _clean
30
+
31
+ # Bump when parsing/rendering/alignment changes: build_chunks folds it into
32
+ # the per-doc source hash so every XML document is re-chunked.
33
+ PARSER_VERSION = "3"
34
+
35
+ _TOK = re.compile(r"[a-z0-9]+")
36
+ _NGRAM = 5
37
+ _MAX_TRAIL = 12
38
+ _BACKTRACK = 40
39
+ # Running header/footer of xml2rfc txt pages; skipped for alignment only.
40
+ _FURNITURE = (
41
+ re.compile(r"^\f?RFC \d+ {2,}.*\S {2,}\S+ \d{4}\s*$"),
42
+ re.compile(r"\[Page \d+\]\s*$"),
43
+ )
44
+ _BLOCK_TAGS = {"t", "ul", "ol", "dl", "figure", "artwork", "sourcecode",
45
+ "table", "aside", "blockquote", "artset"}
46
+ _DOC_URL = re.compile(r"/info/(rfc|bcp|std|fyi)(\d+)\b", re.I)
47
+
48
+
49
+ @dataclass
50
+ class Block:
51
+ pn: str
52
+ kind: str # t|ul|ol|dl|figure|artwork|sourcecode|table|...
53
+ text: str
54
+ bcp14: list[str]
55
+ lang: str = "" # sourcecode/artwork type, e.g. 'abnf'
56
+ start_line: int = 0 # 1-based txt line range, 0 = not aligned
57
+ end_line: int = 0
58
+ exact: bool = False # False: range interpolated from neighbours
59
+
60
+
61
+ @dataclass
62
+ class XSection:
63
+ pn: str
64
+ anchor: str
65
+ sec_num: str # same vocabulary as chunks.sec_num
66
+ title: str
67
+ depth: int
68
+ blocks: list[Block] = field(default_factory=list)
69
+ head_line: int = 0 # txt line of the heading
70
+ end_line: int = 0 # last txt line of this section's OWN text
71
+
72
+
73
+ @dataclass
74
+ class XRef:
75
+ src_pn: str # block the citation sits in
76
+ src_sec: str
77
+ dst_doc: str # 'RFC9002'
78
+ dst_sec: str # '7.7' or '' for whole-document citations
79
+ ref_anchor: str # 'QUIC-RECOVERY'
80
+ normative: bool
81
+
82
+
83
+ @dataclass
84
+ class ParsedXml:
85
+ doc_id: str
86
+ sections: list[XSection]
87
+ xrefs: list[XRef]
88
+ refs: dict[str, tuple[str, bool]] # bib anchor -> (doc_id, normative)
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # rendering
93
+
94
+ def _ws(s: str) -> str:
95
+ return re.sub(r"\s+", " ", s).strip()
96
+
97
+
98
+ def sec_num_of(pn: str, numbered: bool) -> str:
99
+ """'section-9.6' -> '9.6', 'section-appendix.a' -> 'Appendix A',
100
+ 'section-a.1' -> 'A.1'; unnumbered sections -> '' (as the txt chunker)."""
101
+ if not numbered:
102
+ return ""
103
+ s = pn[len("section-"):] if pn.startswith("section-") else pn
104
+ if s.startswith("appendix."):
105
+ rest = s.split(".", 1)[1]
106
+ if "." not in rest:
107
+ return "Appendix " + rest.upper()
108
+ s = rest # 'appendix.b.1' is stored as 'B.1'
109
+ m = re.fullmatch(r"([a-z])((?:\.\d+)+)", s)
110
+ if m:
111
+ return m.group(1).upper() + m.group(2)
112
+ if re.fullmatch(r"\d+(?:\.\d+)*", s):
113
+ return s
114
+ return ""
115
+
116
+
117
+ class _Renderer:
118
+ def __init__(self, refs: dict[str, tuple[str, bool]]):
119
+ self.refs = refs
120
+ self.xrefs: list[tuple[ET.Element, str]] = [] # (xref, bib anchor)
121
+
122
+ def inline(self, e: ET.Element) -> str:
123
+ parts = [e.text or ""]
124
+ for c in e:
125
+ parts.append(self._inline_el(c))
126
+ parts.append(c.tail or "")
127
+ return "".join(parts)
128
+
129
+ def _inline_el(self, c: ET.Element) -> str:
130
+ tag = c.tag
131
+ if tag in ("iref", "cref"):
132
+ return ""
133
+ if tag == "br":
134
+ return " "
135
+ if tag in ("sup", "sub"): # txt renders XMSS<sup>MT</sup> as XMSS^MT
136
+ return ("^" if tag == "sup" else "_") + self.inline(c)
137
+ if tag == "contact": # <contact fullname="..."/> is empty
138
+ return c.get("fullname") or c.get("asciiFullname") or \
139
+ self.inline(c)
140
+ if tag == "eref":
141
+ return self.inline(c).strip() or c.get("target", "")
142
+ if tag == "relref": # v2 leftover, treated like xref
143
+ tag = "xref"
144
+ if tag == "xref":
145
+ inner = _ws(self.inline(c))
146
+ tgt = c.get("target", "")
147
+ dc = c.get("derivedContent", "") or tgt
148
+ if tgt in self.refs or c.get("section"):
149
+ self.xrefs.append((c, tgt))
150
+ label = f"[{dc}]"
151
+ sec = c.get("section")
152
+ if sec:
153
+ fmt = c.get("sectionFormat", "of")
154
+ if fmt == "comma":
155
+ label = f"{label}, Section {sec}"
156
+ elif fmt == "parens":
157
+ label = f"{label} (Section {sec})"
158
+ elif fmt == "bare":
159
+ label = sec
160
+ else:
161
+ label = f"Section {sec} of {label}"
162
+ return f"{inner} {label}" if inner else label
163
+ return inner or dc
164
+ return self.inline(c)
165
+
166
+ def block(self, e: ET.Element) -> str:
167
+ tag = e.tag
168
+ if tag == "t":
169
+ return _ws(self.inline(e))
170
+ if tag in ("artwork", "sourcecode"):
171
+ if e.get("type") == "svg":
172
+ return ""
173
+ return (e.text or "").strip("\n").rstrip()
174
+ if tag == "artset":
175
+ arts = e.findall("artwork")
176
+ pick = next((a for a in arts if a.get("type") == "ascii-art"),
177
+ next((a for a in arts if a.get("type") != "svg"),
178
+ None))
179
+ return self.block(pick) if pick is not None else ""
180
+ if tag == "figure":
181
+ out = []
182
+ for c in e:
183
+ if c.tag in ("artwork", "sourcecode", "artset"):
184
+ out.append(self.block(c))
185
+ out.append(_caption(e, "Figure", self))
186
+ return "\n".join(x for x in out if x)
187
+ if tag in ("ul", "ol"):
188
+ out = []
189
+ for k, li in enumerate(e.findall("li"), 1):
190
+ mark = "-" if tag == "ul" else (li.get("derivedCounter")
191
+ or f"{k}.")
192
+ out.append(f"{mark} {self.flow(li)}")
193
+ return "\n".join(out)
194
+ if tag == "dl":
195
+ out, term = [], ""
196
+ for c in e:
197
+ if c.tag == "dt":
198
+ term = _ws(self.inline(c))
199
+ elif c.tag == "dd":
200
+ out.append(f"{term}: {self.flow(c)}" if term
201
+ else self.flow(c))
202
+ term = ""
203
+ return "\n".join(out)
204
+ if tag == "table":
205
+ out = [" | ".join(self.flow(td) for td in tr
206
+ if td.tag in ("td", "th"))
207
+ for tr in e.iter("tr")]
208
+ out.append(_caption(e, "Table", self))
209
+ return "\n".join(x for x in out if x)
210
+ if tag in ("aside", "blockquote"):
211
+ return self.flow(e)
212
+ return _ws(self.inline(e))
213
+
214
+ def flow(self, e: ET.Element) -> str:
215
+ """Element that may hold either inline text or nested blocks (li,
216
+ dd, td, aside)."""
217
+ kids = [c for c in e if c.tag in _BLOCK_TAGS]
218
+ if not kids:
219
+ return _ws(self.inline(e))
220
+ lead = _ws(e.text or "")
221
+ out = [lead] if lead else []
222
+ for c in kids:
223
+ out.append(self.block(c))
224
+ if _ws(c.tail or ""):
225
+ out.append(_ws(c.tail or ""))
226
+ return "\n".join(x for x in out if x)
227
+
228
+
229
+ def _caption(e: ET.Element, kind: str, r: _Renderer) -> str:
230
+ """'Table 2: Possible Mapping ...' — placed AFTER the body, where the
231
+ txt rendering puts it, so alignment sees tokens in the same order."""
232
+ name = e.find("name")
233
+ m = re.search(r"-(\d+)$", e.get("pn") or "")
234
+ num = f" {m.group(1)}" if m else ""
235
+ if name is None:
236
+ return f"{kind}{num}" if num else ""
237
+ return f"{kind}{num}: {_ws(r.inline(name))}"
238
+
239
+
240
+ def _bcp14(e: ET.Element) -> list[str]:
241
+ seen: list[str] = []
242
+ for b in e.iter("bcp14"):
243
+ w = _ws("".join(b.itertext())).upper()
244
+ if w and w not in seen:
245
+ seen.append(w)
246
+ return seen
247
+
248
+
249
+ def _ref_doc(el: ET.Element) -> str:
250
+ """Cited document id. A reference carrying both 'BCP 145' and 'RFC 8085'
251
+ resolves to the RFC (the text a section number refers to); a
252
+ <referencegroup> for BCP 14 has no single RFC and stays 'BCP14'."""
253
+ found: dict[str, str] = {}
254
+ for si in el.findall("seriesInfo"):
255
+ name = (si.get("name") or "").upper()
256
+ if name in ("RFC", "BCP", "STD", "FYI") and \
257
+ (si.get("value") or "").isdigit():
258
+ found.setdefault(name, f"{name}{int(si.get('value'))}")
259
+ for name in ("RFC", "BCP", "STD", "FYI"):
260
+ if name in found:
261
+ return found[name]
262
+ m = _DOC_URL.search(el.get("target") or "")
263
+ return f"{m.group(1).upper()}{int(m.group(2))}" if m else ""
264
+
265
+
266
+ def _ref_text(el: ET.Element, r: _Renderer) -> str:
267
+ if el.tag == "referencegroup":
268
+ inner = " ".join(_ref_text(x, r).split("] ", 1)[-1]
269
+ for x in el.findall("reference"))
270
+ return f"[{el.get('anchor')}] {inner}"
271
+ front = el.find("front")
272
+ title = _ws(r.inline(front.find("title"))) if front is not None and \
273
+ front.find("title") is not None else ""
274
+ authors = ", ".join(a.get("fullname") or a.get("surname") or
275
+ _ws("".join(a.itertext()))
276
+ for a in (front.findall("author")
277
+ if front is not None else []))
278
+ series = ", ".join(f"{s.get('name')} {s.get('value')}"
279
+ for s in el.findall("seriesInfo")
280
+ if s.get("name") != "DOI")
281
+ date = front.find("date") if front is not None else None
282
+ when = " ".join(x for x in ((date.get("month") or "",
283
+ date.get("year") or "")
284
+ if date is not None else ()) if x)
285
+ bits = [b for b in (authors, f'"{title}"' if title else "", series, when)
286
+ if b]
287
+ return f"[{el.get('anchor')}] " + ", ".join(bits)
288
+
289
+
290
+ def _author_text(a: ET.Element) -> str:
291
+ """Name, role, organization, email. Postal lines are left out: xml2rfc
292
+ orders them per country convention, which would break alignment, and
293
+ nobody searches an RFC for a street address."""
294
+ bits = [a.get("fullname") or "",
295
+ f"({a.get('role')})" if a.get("role") else ""]
296
+ org = a.find("organization")
297
+ if org is not None:
298
+ bits.append(_ws("".join(org.itertext())))
299
+ head = _ws(" ".join(b for b in bits if b))
300
+ email = a.find("address/email")
301
+ if email is not None and email.text:
302
+ return f"{head}\nEmail: {email.text.strip()}"
303
+ return head
304
+
305
+
306
+ # --------------------------------------------------------------------------
307
+ # parsing
308
+
309
+ def parse(xml_text: str, doc_id: str) -> ParsedXml:
310
+ root = ET.fromstring(xml_text.encode("utf-8"))
311
+ if root.tag != "rfc" or root.get("version") != "3":
312
+ raise ValueError(f"not RFCXML v3 (root={root.tag} "
313
+ f"version={root.get('version')})")
314
+ refs: dict[str, tuple[str, bool]] = {}
315
+ back = root.find("back")
316
+ if back is not None:
317
+ for grp in back.iter("references"):
318
+ name = grp.find("name")
319
+ normative = name is not None and \
320
+ "normative" in "".join(name.itertext()).lower()
321
+ for el in grp:
322
+ if el.tag in ("reference", "referencegroup") and \
323
+ el.get("anchor"):
324
+ refs[el.get("anchor")] = (_ref_doc(el), normative)
325
+ r = _Renderer(refs)
326
+ sections: list[XSection] = []
327
+ xrefs: list[XRef] = []
328
+
329
+ def add_blocks(sec: XSection, elems):
330
+ for c in elems:
331
+ if c.tag not in _BLOCK_TAGS and c.tag not in (
332
+ "reference", "referencegroup", "author"):
333
+ continue
334
+ mark = len(r.xrefs)
335
+ if c.tag in ("reference", "referencegroup"):
336
+ text, kind = _ref_text(c, r), "reference"
337
+ elif c.tag == "author": # Authors' Addresses is generated
338
+ text, kind = _author_text(c), "author"
339
+ else:
340
+ text, kind = r.block(c), c.tag
341
+ if not text.strip():
342
+ continue
343
+ pn = c.get("pn") or ""
344
+ lang = ""
345
+ if c.tag == "figure":
346
+ inner = next((x for x in c if x.tag in
347
+ ("artwork", "sourcecode", "artset")), None)
348
+ if inner is not None:
349
+ pn = pn or inner.get("pn") or ""
350
+ kind = "sourcecode" if inner.tag == "sourcecode" \
351
+ else "artwork"
352
+ lang = inner.get("type") or ""
353
+ elif c.tag in ("artwork", "sourcecode"):
354
+ lang = c.get("type") or ""
355
+ if not pn:
356
+ pn = c.get("anchor") or f"{sec.pn}-x{len(sec.blocks) + 1}"
357
+ sec.blocks.append(Block(pn, kind, text, _bcp14(c), lang))
358
+ for x, anchor in r.xrefs[mark:]:
359
+ dst, norm = refs.get(anchor, ("", False))
360
+ if dst:
361
+ xrefs.append(XRef(pn, sec.sec_num, dst,
362
+ x.get("section") or "", anchor, norm))
363
+
364
+ def walk(e: ET.Element, depth: int):
365
+ for c in e:
366
+ if c.tag not in ("section", "references"):
367
+ continue
368
+ pn = c.get("pn") or ""
369
+ if pn.startswith("section-toc"):
370
+ continue
371
+ name = c.find("name")
372
+ title = _ws(r.inline(name)) if name is not None else ""
373
+ numbered = c.get("numbered", "true") != "false" and \
374
+ not pn.startswith("section-boilerplate")
375
+ sec = XSection(pn, c.get("anchor") or "",
376
+ sec_num_of(pn, numbered), title, depth)
377
+ sections.append(sec)
378
+ add_blocks(sec, list(c))
379
+ walk(c, depth + 1)
380
+
381
+ front = root.find("front")
382
+ if front is not None:
383
+ ab = front.find("abstract")
384
+ if ab is not None:
385
+ sec = XSection(ab.get("pn") or "section-abstract", "abstract",
386
+ "", "Abstract", 1)
387
+ sections.append(sec)
388
+ add_blocks(sec, list(ab))
389
+ bp = front.find("boilerplate")
390
+ if bp is not None:
391
+ walk(bp, 1)
392
+ for part in ("middle", "back"):
393
+ el = root.find(part)
394
+ if el is not None:
395
+ walk(el, 1)
396
+ return ParsedXml(doc_id, sections, xrefs, refs)
397
+
398
+
399
+ # --------------------------------------------------------------------------
400
+ # alignment onto txt lines
401
+
402
+ def _toks(s: str) -> list[str]:
403
+ return _TOK.findall(s.lower())
404
+
405
+
406
+ class _TxtIndex:
407
+ def __init__(self, lines: list[str]):
408
+ self.lines = lines
409
+ self.toks: list[str] = []
410
+ self.line_of: list[int] = []
411
+ for i, ln in enumerate(lines):
412
+ if any(p.search(ln) for p in _FURNITURE):
413
+ continue
414
+ for t in _toks(ln):
415
+ self.toks.append(t)
416
+ self.line_of.append(i + 1)
417
+ self.grams: dict[tuple, list[int]] = {}
418
+ for p in range(len(self.toks) - _NGRAM + 1):
419
+ self.grams.setdefault(tuple(self.toks[p:p + _NGRAM]), []).append(p)
420
+
421
+ def _first_at_or_after(self, gram: tuple, lo: int, hi: int) -> int | None:
422
+ for p in self.grams.get(gram, ()):
423
+ if lo <= p < hi:
424
+ return p
425
+ return None
426
+
427
+ def tok_at_line(self, line: int) -> int:
428
+ """Index of the first token on or after 1-based `line`."""
429
+ import bisect
430
+ return bisect.bisect_left(self.line_of, line)
431
+
432
+ def locate(self, bt: list[str], lo: int,
433
+ hi: int) -> tuple[int, int] | None:
434
+ """Token span [start, end] of block tokens `bt` inside [lo, hi).
435
+
436
+ [lo, hi) is the owning section's span between its heading and the
437
+ next heading, so a sentence repeated elsewhere in the document can
438
+ never capture the block. `lo` may sit a little past the true start
439
+ when the previous block's end overshot (table cell wrapping), so
440
+ n-gram search starts _BACKTRACK tokens early."""
441
+ n = len(bt)
442
+ if n == 0 or lo >= hi:
443
+ return None
444
+ if n < _NGRAM: # 'None.' etc: backtracking would re-find the last one
445
+ for p in range(lo, max(lo, min(hi, len(self.toks)) - n + 1)):
446
+ if self.toks[p:p + n] == bt:
447
+ return p, p + n - 1
448
+ return None
449
+ lo = max(0, lo - _BACKTRACK)
450
+ # Every gram votes for the start it implies; the densest cluster
451
+ # wins. Taking the first or earliest match instead lets one gram
452
+ # shared with a neighbouring paragraph drag the block (and the
453
+ # cursor) to the wrong place.
454
+ cands = []
455
+ for off in range(0, min(n - _NGRAM + 1, 80)):
456
+ p = self._first_at_or_after(tuple(bt[off:off + _NGRAM]),
457
+ lo + off, hi)
458
+ if p is not None:
459
+ cands.append(p - off)
460
+ if not cands:
461
+ return None
462
+ start = max(lo, _densest(cands))
463
+ ends = []
464
+ for off in range(n - _NGRAM, max(-1, n - _NGRAM - 80), -1):
465
+ p = self._first_at_or_after(tuple(bt[off:off + _NGRAM]),
466
+ start + off - n // 4 - 50,
467
+ min(hi, start + off + n // 4 + 50))
468
+ if p is not None and p >= start:
469
+ ends.append(p + (n - off) - 1)
470
+ end = _densest(ends) if ends else start + n - 1
471
+ end = min(end, start + n + n // 3 + 10, hi - 1, len(self.toks) - 1)
472
+ return start, max(start, end)
473
+
474
+
475
+ def _densest(vals: list[int], slack: int = 4) -> int:
476
+ """Value with the most other values within +-slack (ties: earliest)."""
477
+ best, best_n = vals[0], -1
478
+ for v in sorted(set(vals)):
479
+ c = sum(1 for w in vals if abs(w - v) <= slack)
480
+ if c > best_n:
481
+ best, best_n = v, c
482
+ return best
483
+
484
+
485
+ def _heading_tokens(sec: XSection) -> list[str]:
486
+ return _toks(f"{sec.sec_num} {sec.title}")
487
+
488
+
489
+ def _is_heading(line: str, want: list[str], numbered: bool) -> bool:
490
+ """xml2rfc txt puts section headings at column 0 (ToC entries and body
491
+ text are indented), first line only when the title wraps."""
492
+ if not line or line[0] in " \t\f" or not want:
493
+ return False
494
+ lt = _toks(line)
495
+ if not lt or want[:len(lt)] != lt:
496
+ return False
497
+ if numbered: # number + at least the first title word
498
+ return len(lt) >= min(len(want), len(_toks(want[0])) + 1)
499
+ return len(lt) == len(want) or len(lt) >= 3
500
+
501
+
502
+ def align(px: ParsedXml, txt: str) -> list[str]:
503
+ """Fill block/section line ranges in place; returns lines of txt.
504
+
505
+ Pass 1 finds every section heading in document order. Pass 2 aligns
506
+ each section's blocks only inside its own [heading, next heading) span.
507
+ """
508
+ lines = txt.lstrip("\ufeff").splitlines()
509
+ ix = _TxtIndex(lines)
510
+ secs = px.sections
511
+ at = 0
512
+ for sec in secs:
513
+ want = _heading_tokens(sec)
514
+ for ln in range(at + 1, len(lines) + 1):
515
+ if _is_heading(lines[ln - 1], want, bool(sec.sec_num)):
516
+ sec.head_line, at = ln, ln
517
+ break
518
+
519
+ heads = [s.head_line for s in secs]
520
+ for k, sec in enumerate(secs):
521
+ nxt = next((h for h in heads[k + 1:] if h), len(lines) + 1)
522
+ prev = next((h for h in reversed(heads[:k]) if h), 0)
523
+ lo = ix.tok_at_line((sec.head_line or prev) + 1)
524
+ hi = ix.tok_at_line(nxt)
525
+ cursor = lo
526
+ for b in sec.blocks:
527
+ bt = _toks(b.text)
528
+ if b.kind == "author": # name/org lines precede the postal ones
529
+ bt = _toks(b.text.split("\n", 1)[0])[:4]
530
+ span = ix.locate(bt, cursor, hi)
531
+ if span is None:
532
+ continue
533
+ # _BACKTRACK may reach above the heading; never past it.
534
+ b.start_line = max(ix.line_of[span[0]], sec.head_line + 1)
535
+ b.end_line = max(b.start_line, ix.line_of[span[1]])
536
+ b.exact = True
537
+ cursor = span[1] + 1
538
+
539
+ for k, sec in enumerate(secs):
540
+ if not sec.head_line: # unmatched heading: just before its text
541
+ own = [b for b in sec.blocks if b.exact]
542
+ sec.head_line = max(1, own[0].start_line - 1) if own else (
543
+ secs[k - 1].head_line + 1 if k else 1)
544
+ for k, sec in enumerate(secs):
545
+ nxt = next((s.head_line for s in secs[k + 1:]
546
+ if s.head_line > sec.head_line), len(lines) + 1)
547
+ # Unaligned blocks share the gap between their aligned neighbours,
548
+ # never leaving the section's own [heading, next heading) span.
549
+ prev_end = sec.head_line
550
+ for i, b in enumerate(sec.blocks):
551
+ if b.exact:
552
+ prev_end = b.end_line
553
+ continue
554
+ after = next((x.start_line for x in sec.blocks[i + 1:]
555
+ if x.exact), nxt)
556
+ b.start_line = min(prev_end + 1, max(sec.head_line, after - 1))
557
+ b.end_line = max(b.start_line, after - 1)
558
+ prev_end = b.end_line
559
+ end = nxt - 1
560
+ own_last = max((b.end_line for b in sec.blocks), default=0)
561
+ if own_last:
562
+ # A page break between the last block and the next heading is
563
+ # < ~8 lines of furniture; a bigger gap is the ToC (not a
564
+ # section in XML) and must not be billed to Copyright Notice.
565
+ end = min(end, own_last + _MAX_TRAIL)
566
+ sec.end_line = max(sec.head_line, end)
567
+ return lines
568
+
569
+
570
+ # --------------------------------------------------------------------------
571
+ # chunks
572
+
573
+ def _chunk_section(sec: XSection) -> list[Section]:
574
+ # First line mirrors the txt heading, as txt chunks start with it.
575
+ head = f"{sec.sec_num}. {sec.title}" if sec.sec_num else sec.title
576
+ bodies = [b.text for b in sec.blocks]
577
+ whole = "\n".join([head] + bodies)
578
+ if not bodies:
579
+ return []
580
+ title = sec.title or sec.sec_num
581
+ if len(whole) <= MAX_CHARS:
582
+ return [Section(sec.sec_num, title, sec.head_line, sec.end_line,
583
+ whole)]
584
+ # Pack blocks into <= PART_CHARS parts; a single oversized block is cut
585
+ # at line boundaries with proportionally interpolated line ranges.
586
+ pieces: list[tuple[str, int, int]] = []
587
+ for b in sec.blocks:
588
+ if len(b.text) <= HARD_CHARS:
589
+ pieces.append((b.text, b.start_line, b.end_line))
590
+ continue
591
+ tl = b.text.split("\n")
592
+ span = max(1, b.end_line - b.start_line + 1)
593
+ acc: list[str] = []
594
+ a0 = 0
595
+ for i, ln in enumerate(tl + [None]):
596
+ if ln is None or (acc and sum(len(x) + 1 for x in acc)
597
+ + len(ln) > PART_CHARS):
598
+ s = b.start_line + span * a0 // len(tl)
599
+ e = b.start_line + span * i // len(tl) - 1
600
+ pieces.append(("\n".join(acc), s, max(s, e)))
601
+ acc, a0 = [], i
602
+ if ln is not None:
603
+ acc.append(ln)
604
+ parts: list[list[tuple[str, int, int]]] = [[]]
605
+ size = 0
606
+ for p in pieces:
607
+ if parts[-1] and size + len(p[0]) > PART_CHARS:
608
+ parts.append([])
609
+ size = 0
610
+ parts[-1].append(p)
611
+ size += len(p[0]) + 1
612
+ out: list[Section] = []
613
+ n = len(parts)
614
+ for k, grp in enumerate(parts):
615
+ start = sec.head_line if k == 0 else grp[0][1]
616
+ end = (parts[k + 1][0][1] - 1) if k + 1 < n else sec.end_line
617
+ text = "\n".join([head] + [g[0] for g in grp])
618
+ out.append(Section(sec.sec_num, f"{title} (part {k + 1}/{n})"
619
+ if n > 1 else title, start, max(start, end), text))
620
+ return out
621
+
622
+
623
+ def xml_sections(px: ParsedXml, lines: list[str]) -> list[Section]:
624
+ """Chunker-compatible Sections for an aligned ParsedXml: front matter
625
+ (txt, up to the Abstract), then one chunk per section's own text."""
626
+ out: list[Section] = []
627
+ first = min((s.head_line for s in px.sections if s.head_line),
628
+ default=1)
629
+ if first > 1:
630
+ body = _clean(lines[:first - 1])
631
+ if body.strip():
632
+ out.append(Section("", "Front matter & boilerplate", 1,
633
+ first - 1, body))
634
+ for sec in px.sections:
635
+ out.extend(_chunk_section(sec))
636
+ return out
637
+
638
+
639
+ def blocks_rows(px: ParsedXml) -> list[tuple]:
640
+ """Rows for the `blocks` table (doc order).
641
+
642
+ Each section starts with a kind='heading' row whose pn is the section's
643
+ own id and whose range is the WHOLE section including subsections; it
644
+ exists even when the section has no text of its own (e.g. '1.
645
+ Introduction' holding only 1.1-1.4), which chunks cannot represent."""
646
+ rows, k = [], 0
647
+ secs = px.sections
648
+ for i, sec in enumerate(secs):
649
+ deep_end = next((s.head_line - 1 for s in secs[i + 1:]
650
+ if s.depth <= sec.depth and s.head_line > sec.head_line),
651
+ max([sec.end_line] + [s.end_line for s in secs[i + 1:]]))
652
+ k += 1
653
+ rows.append((px.doc_id, sec.pn, k, "heading", sec.sec_num, sec.title,
654
+ sec.anchor, sec.head_line, max(sec.head_line, deep_end),
655
+ sec.title, "", "", 1))
656
+ for b in sec.blocks:
657
+ k += 1
658
+ rows.append((px.doc_id, b.pn, k, b.kind, sec.sec_num, sec.title,
659
+ sec.anchor, b.start_line, b.end_line, b.text,
660
+ ",".join(b.bcp14), b.lang, int(b.exact)))
661
+ return rows
662
+
663
+
664
+ def xref_rows(px: ParsedXml) -> list[tuple]:
665
+ seen, rows = set(), []
666
+ for x in px.xrefs:
667
+ key = (x.src_pn, x.dst_doc, x.dst_sec)
668
+ if key in seen:
669
+ continue
670
+ seen.add(key)
671
+ rows.append((px.doc_id, x.src_pn, x.src_sec, x.dst_doc, x.dst_sec,
672
+ x.ref_anchor, int(x.normative)))
673
+ return rows