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.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- 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
|