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,585 @@
|
|
|
1
|
+
"""Section/paragraph segmentation for RFCs that exist only as .txt.
|
|
2
|
+
|
|
3
|
+
The legacy chunker (chunker.py) guesses headings line by line and keeps a
|
|
4
|
+
"section numbers only go up" rule, so one false heading early on (a status
|
|
5
|
+
code like '400 (Bad Request)' at column 0) silently discards every real
|
|
6
|
+
section after it: RFC 7230 came out with 2 of 85 headings. This module
|
|
7
|
+
segments the document the way RFCXML documents are segmented instead:
|
|
8
|
+
|
|
9
|
+
1. The document's own Table of Contents is the answer key. Most RFCs have
|
|
10
|
+
one; its entries (number + title) say exactly which headings to find.
|
|
11
|
+
2. Headings are located in order after the ToC, as short lines at the left
|
|
12
|
+
margin whose tokens start with the entry's number and title words.
|
|
13
|
+
Numbered entries are anchored first; unnumbered ones (Security
|
|
14
|
+
Considerations, Authors' Addresses) are then searched only between their
|
|
15
|
+
anchored neighbours, so a stray word can't capture them.
|
|
16
|
+
3. Headings deeper than the ToC lists (ToCs often stop at depth 2) are
|
|
17
|
+
accepted between anchors only when they are a structural successor of
|
|
18
|
+
the previous heading (first child or next sibling).
|
|
19
|
+
4. Without a ToC, a left-margin scan accepts a candidate only when it is a
|
|
20
|
+
plausible successor of the last accepted heading, so a false '400' is
|
|
21
|
+
skipped instead of poisoning the rest of the document.
|
|
22
|
+
|
|
23
|
+
Every section is then split into paragraph blocks (blank-line separated,
|
|
24
|
+
re-joined across page breaks), classified as text / artwork / ABNF, and
|
|
25
|
+
the result is returned in rfcxml's ParsedXml shape: the `blocks` table,
|
|
26
|
+
paragraph refs (RFC791§3.1¶4), view=code/normative, grep labels and
|
|
27
|
+
chunking all work unchanged. Published RFC text never changes, so the
|
|
28
|
+
synthetic paragraph numbers are as stable as the RFC Editor's pn ids.
|
|
29
|
+
|
|
30
|
+
Quality is recorded per document (ToC entries matched, method), so weak
|
|
31
|
+
segmentations are visible instead of silent.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
|
|
39
|
+
from .chunker import _UNNUMBERED, _clean_title
|
|
40
|
+
from .rfcxml import _FURNITURE, Block, ParsedXml, XRef, XSection, _toks
|
|
41
|
+
|
|
42
|
+
VERSION = "2"
|
|
43
|
+
|
|
44
|
+
# Up to 8 levels: RFC 8878 numbers sections to depth 7 (3.1.1.3.2.1.1).
|
|
45
|
+
_NUM = r"\d{1,3}(?:\.\d{1,3}){0,7}"
|
|
46
|
+
_APPX = r"(?:Appendix|APPENDIX|Annex)\s+[A-Z](?:\.\d{1,3})*"
|
|
47
|
+
_LETTER = r"[A-Z](?:\.\d{1,3})+|[A-Z](?=\.)"
|
|
48
|
+
_LABEL = rf"(?P<lab>{_APPX}|{_LETTER}|{_NUM})"
|
|
49
|
+
_LEADER = r"(?:\s*\.(?:\s?\.)+\s*|\s{2,}|\s(?=\d+\s*$))"
|
|
50
|
+
_PAGE = r"(?P<page>\d{1,3}|[ivxlc]{1,7})" # 4 digits is a year
|
|
51
|
+
_TOC_NUM = re.compile(rf"^\s*{_LABEL}\.?[:]?\s+(?P<title>\S.*?)"
|
|
52
|
+
rf"(?:{_LEADER}{_PAGE})?\s*$")
|
|
53
|
+
_TOC_UNNUM = re.compile(rf"^\s*(?P<title>[A-Z][A-Za-z'’()/,&\- ]{{2,70}}?)"
|
|
54
|
+
rf"{_LEADER}{_PAGE}\s*$")
|
|
55
|
+
_TOC_HEAD = re.compile(r"^\s*(table\s+of\s+contents|contents)\s*:?\s*$", re.I)
|
|
56
|
+
_HEAD = re.compile(rf"^(?P<ind> {{0,4}}){_LABEL}\.?[:]?\s+(?P<title>\S.*)$")
|
|
57
|
+
_ABNF = re.compile(r"^\s*[A-Za-z][\w\-]*\s*=/?\s*\S")
|
|
58
|
+
_ART = re.compile(r"[+|]-{2,}|-{2,}[+|]|^\s*\|.*\|\s*$|\s{6,}\S.*\s{6,}\S")
|
|
59
|
+
_REF_LABEL = re.compile(r"^\s*\[([^\]]{1,40})\]")
|
|
60
|
+
_SERIES = re.compile(r"\b(RFC|BCP|STD|FYI)[\s\-]?(\d{1,5})\b")
|
|
61
|
+
_NORM = re.compile(r"\b(MUST NOT|MUST|SHALL NOT|SHALL|SHOULD NOT|SHOULD|"
|
|
62
|
+
r"NOT RECOMMENDED|RECOMMENDED|MAY|REQUIRED|OPTIONAL)\b")
|
|
63
|
+
_SEC_OF = re.compile(
|
|
64
|
+
r"[Ss]ections?\s+(?P<sec>\d{1,3}(?:\.\d{1,3})*|[A-Z](?:\.\d{1,3})+)"
|
|
65
|
+
r"\s+of\s+(?:\[(?P<lab>[^\]]{1,40})\]|(?P<ser>RFC|BCP|STD)\s*(?P<n>\d+))")
|
|
66
|
+
_LAB_SEC = re.compile(
|
|
67
|
+
r"\[(?P<lab>[^\]]{1,40})\],?\s+[Ss]ection\s+"
|
|
68
|
+
r"(?P<sec>\d{1,3}(?:\.\d{1,3})*|[A-Z](?:\.\d{1,3})+)")
|
|
69
|
+
_LAB = re.compile(r"\[(?P<lab>[^\]]{1,40})\]")
|
|
70
|
+
_SENT_END = re.compile(r"[.:;!?)\]>\"]\s*$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class TocEntry:
|
|
75
|
+
sec_num: str # canonical, chunks vocabulary
|
|
76
|
+
title: str
|
|
77
|
+
line: int # 1-based ToC line
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Quality:
|
|
82
|
+
method: str # toc | scan
|
|
83
|
+
toc_entries: int = 0
|
|
84
|
+
matched: int = 0 # ToC entries located in the body
|
|
85
|
+
extra: int = 0 # headings found that the ToC did not list
|
|
86
|
+
headings: int = 0
|
|
87
|
+
notes: list[str] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def score(self) -> float | None:
|
|
91
|
+
if self.method == "toc" and self.toc_entries:
|
|
92
|
+
return round(self.matched / self.toc_entries, 3)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------
|
|
97
|
+
# labels
|
|
98
|
+
|
|
99
|
+
def canon(label: str) -> str:
|
|
100
|
+
"""ToC/heading label -> chunks sec_num: '3.3.3', 'Appendix A', 'A.1'."""
|
|
101
|
+
lab = label.strip().rstrip(".:")
|
|
102
|
+
m = re.fullmatch(r"(?:Appendix|APPENDIX|Annex)\s+([A-Z])((?:\.\d+)*)", lab)
|
|
103
|
+
if m:
|
|
104
|
+
return f"Appendix {m.group(1)}" if not m.group(2) else \
|
|
105
|
+
m.group(1) + m.group(2)
|
|
106
|
+
if re.fullmatch(r"[A-Z]", lab):
|
|
107
|
+
return f"Appendix {lab}"
|
|
108
|
+
return lab
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _depth(sec: str) -> int:
|
|
112
|
+
if sec.startswith("Appendix"):
|
|
113
|
+
return 1
|
|
114
|
+
return sec.count(".") + 1 if sec else 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pn(sec: str, title: str, used: set[str]) -> str:
|
|
118
|
+
if sec.startswith("Appendix"):
|
|
119
|
+
base = "section-appendix." + sec.split()[1].lower()
|
|
120
|
+
elif sec and sec[0].isalpha():
|
|
121
|
+
base = "section-" + sec.lower()
|
|
122
|
+
elif sec:
|
|
123
|
+
base = "section-" + sec
|
|
124
|
+
else:
|
|
125
|
+
base = "section-u." + ("-".join(_toks(title)[:4]) or "x")
|
|
126
|
+
pn, k = base, 2
|
|
127
|
+
while pn in used:
|
|
128
|
+
pn, k = f"{base}.{k}", k + 1
|
|
129
|
+
used.add(pn)
|
|
130
|
+
return pn
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _furniture(ln: str) -> bool:
|
|
134
|
+
return any(p.search(ln) for p in _FURNITURE)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
# table of contents
|
|
139
|
+
|
|
140
|
+
def parse_toc(lines: list[str]) -> tuple[list[TocEntry], int, int]:
|
|
141
|
+
"""(entries, first line, last line) of the ToC, or ([], 0, 0)."""
|
|
142
|
+
limit = min(len(lines), max(400, len(lines) // 5))
|
|
143
|
+
start = next((i for i in range(limit) if _TOC_HEAD.match(lines[i])), -1)
|
|
144
|
+
if start < 0:
|
|
145
|
+
return [], 0, 0
|
|
146
|
+
entries: list[TocEntry] = []
|
|
147
|
+
misses, last = 0, start
|
|
148
|
+
for i in range(start + 1, len(lines)):
|
|
149
|
+
ln = lines[i].replace("\f", "")
|
|
150
|
+
if not ln.strip() or _furniture(ln):
|
|
151
|
+
continue
|
|
152
|
+
m = _TOC_NUM.match(ln)
|
|
153
|
+
if m and m.group("page") is None and entries and \
|
|
154
|
+
not ln.startswith(" "):
|
|
155
|
+
break # page-less ToCs are indented; a margin heading = body
|
|
156
|
+
if m:
|
|
157
|
+
title = re.sub(r"[\s.]+$", "", m.group("title")).strip()
|
|
158
|
+
entries.append(TocEntry(canon(m.group("lab")), title, i + 1))
|
|
159
|
+
misses, last = 0, i
|
|
160
|
+
continue
|
|
161
|
+
wrapped = entries and _indent(ln) > _indent(lines[entries[-1].line - 1])
|
|
162
|
+
m = _TOC_UNNUM.match(ln)
|
|
163
|
+
if m and not wrapped:
|
|
164
|
+
entries.append(TocEntry("", m.group("title").strip(), i + 1))
|
|
165
|
+
misses, last = 0, i
|
|
166
|
+
continue
|
|
167
|
+
if wrapped and re.search(rf"{_LEADER}{_PAGE}\s*$", ln):
|
|
168
|
+
# second line of a wrapped title: "...Interaction with" /
|
|
169
|
+
# " Proxies ........ 12"
|
|
170
|
+
entries[-1].title += " " + re.sub(rf"{_LEADER}{_PAGE}\s*$", "",
|
|
171
|
+
ln).strip()
|
|
172
|
+
misses, last = 0, i
|
|
173
|
+
continue
|
|
174
|
+
misses += 1
|
|
175
|
+
if misses >= 3:
|
|
176
|
+
break
|
|
177
|
+
_unnumber_quoted(entries, lines)
|
|
178
|
+
return entries, start + 1, last + 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _unnumber_quoted(entries: list[TocEntry], lines: list[str]) -> None:
|
|
182
|
+
"""Numeric entries indented under an "Appendix X" entry belong to a quoted document.
|
|
183
|
+
|
|
184
|
+
RFC 9592's Appendix A reprints the last edition of the Tao, whose own "1 Introduction ...
|
|
185
|
+
2.2.1" numbering the ToC lists beneath the appendix; the RFCXML marks those sections
|
|
186
|
+
unnumbered. An RFC's own numbered sections never sit inside an appendix, so the label is
|
|
187
|
+
dropped and the entry is kept as an unnumbered one. Scoped by indentation: a numeric entry
|
|
188
|
+
back at the appendix's indent (or shallower) ends the quoted span.
|
|
189
|
+
"""
|
|
190
|
+
app_ind: int | None = None
|
|
191
|
+
for e in entries:
|
|
192
|
+
ln = lines[e.line - 1].replace("\f", "")
|
|
193
|
+
ind = _indent(ln)
|
|
194
|
+
if re.match(r"(?:Appendix|APPENDIX|Annex)\s", ln.strip()):
|
|
195
|
+
app_ind = ind
|
|
196
|
+
continue
|
|
197
|
+
if app_ind is not None and ind <= app_ind:
|
|
198
|
+
app_ind = None
|
|
199
|
+
if app_ind is not None and re.fullmatch(r"\d+(?:\.\d+)*", e.sec_num):
|
|
200
|
+
e.sec_num = ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
# headings
|
|
205
|
+
|
|
206
|
+
def _heading_tokens(sec: str, title: str) -> list[tuple[list[str],
|
|
207
|
+
list[str]]]:
|
|
208
|
+
"""(label tokens, full title tokens) a body heading may start with."""
|
|
209
|
+
tt = _toks(title)
|
|
210
|
+
if sec.startswith("Appendix"):
|
|
211
|
+
letter = sec.split()[1].lower()
|
|
212
|
+
return [(["appendix", letter], tt), ([letter], tt)]
|
|
213
|
+
if sec[:1].isalpha(): # 'F.1' may be written 'Appendix F.1.'
|
|
214
|
+
return [(_toks(sec), tt), (["appendix"] + _toks(sec), tt)]
|
|
215
|
+
return [(_toks(sec), tt)]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _indent(line: str) -> int:
|
|
219
|
+
return len(line) - len(line.lstrip(" "))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _margin(line: str) -> bool:
|
|
223
|
+
"""At the left margin, or centered (RFC 791-era top-level headings);
|
|
224
|
+
body prose sits at a 3-space indent and never qualifies."""
|
|
225
|
+
ind = _indent(line)
|
|
226
|
+
return ind <= 1 or (ind >= 10 and len(line.strip()) <= 60
|
|
227
|
+
and abs((80 - len(line.rstrip())) - ind) <= 12)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _matches(line: str, wants, numbered: bool) -> bool:
|
|
231
|
+
"""Left-margin line that starts with the label and the title's first
|
|
232
|
+
words (case/punctuation-insensitive; a wrapped title may stop early)."""
|
|
233
|
+
if not line.strip():
|
|
234
|
+
return False
|
|
235
|
+
if re.search(r"(\.\s?){4,}\s*\d+\s*$", line): # ToC leader line
|
|
236
|
+
return False
|
|
237
|
+
deep = _indent(line) > 4 and not _margin(line)
|
|
238
|
+
lt = _toks(line)
|
|
239
|
+
for lab, tt in wants:
|
|
240
|
+
if not lab: # unnumbered: whole title, at the margin
|
|
241
|
+
if tt and lt[:len(tt)] == tt and len(lt) <= len(tt) + 2 and \
|
|
242
|
+
_margin(line):
|
|
243
|
+
return True
|
|
244
|
+
continue
|
|
245
|
+
if lt[:len(lab)] != lab:
|
|
246
|
+
continue
|
|
247
|
+
rest = lt[len(lab):]
|
|
248
|
+
if deep: # indented by level (RFC 1122): the exact heading only
|
|
249
|
+
if tt and rest == tt and _indent(line) <= 16:
|
|
250
|
+
return True
|
|
251
|
+
continue
|
|
252
|
+
tt = tt[:4]
|
|
253
|
+
n = min(len(rest), len(tt))
|
|
254
|
+
if not tt:
|
|
255
|
+
return bool(numbered and lab)
|
|
256
|
+
if n and rest[:n] == tt[:n] and (n >= min(2, len(tt))
|
|
257
|
+
or n == len(rest)):
|
|
258
|
+
if numbered or len(lt) <= len(lab) + len(tt) + 6:
|
|
259
|
+
return True
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _successor(prev: str, cand: str) -> bool:
|
|
264
|
+
"""cand can follow prev in an outline: a first child, the next sibling
|
|
265
|
+
of prev or of an ancestor (small gaps tolerated), or the first
|
|
266
|
+
appendix."""
|
|
267
|
+
if not prev:
|
|
268
|
+
return bool(re.fullmatch(r"[12](?:\.1)?", cand))
|
|
269
|
+
if cand.startswith("Appendix") or (cand[:1].isalpha()):
|
|
270
|
+
return True
|
|
271
|
+
if prev.startswith("Appendix") or prev[:1].isalpha():
|
|
272
|
+
return False
|
|
273
|
+
p = [int(x) for x in prev.split(".")]
|
|
274
|
+
c = [int(x) for x in cand.split(".")]
|
|
275
|
+
if len(c) == len(p) + 1 and c[:-1] == p and 1 <= c[-1] <= 2:
|
|
276
|
+
return True
|
|
277
|
+
for k in range(1, len(p) + 1): # next sibling at level k
|
|
278
|
+
if len(c) in (k, k + 1) and c[:k - 1] == p[:k - 1] and \
|
|
279
|
+
p[k - 1] < c[k - 1] <= p[k - 1] + 3:
|
|
280
|
+
return len(c) == k or c[k] == 1
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _followed_by_body(lines: list[str], i: int, label: str = "") -> bool:
|
|
285
|
+
"""True when the heading at ``i`` is followed by body text, or directly by its own first child.
|
|
286
|
+
|
|
287
|
+
A heading followed straight by its child ("3.5.1" then "3.5.1.1") used to read as a run of numbers, so it
|
|
288
|
+
was rejected and, with it, every descendant, since none of them was a successor any more
|
|
289
|
+
(docs/validation.md, rfc: 41 such headings and 194 descendants, e.g. rfc9288 line 338)."""
|
|
290
|
+
for j in range(i + 1, min(len(lines), i + 12)):
|
|
291
|
+
s = lines[j].strip()
|
|
292
|
+
if not s or _furniture(lines[j]):
|
|
293
|
+
continue
|
|
294
|
+
m = _HEAD.match(lines[j])
|
|
295
|
+
if m and label and canon(m.group("lab")).startswith(label + "."):
|
|
296
|
+
return True
|
|
297
|
+
return not m or lines[j].startswith(" ")
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _scan_candidates(lines: list[str], lo: int, hi: int,
|
|
302
|
+
indent: int = 0) -> list[tuple[int, str, str]]:
|
|
303
|
+
out = []
|
|
304
|
+
for i in range(lo, min(hi, len(lines))):
|
|
305
|
+
ln = lines[i]
|
|
306
|
+
m = _HEAD.match(ln)
|
|
307
|
+
if not m or len(m.group("ind")) > indent:
|
|
308
|
+
continue
|
|
309
|
+
if re.search(r"(\.\s?){4,}\s*\d+\s*$", ln):
|
|
310
|
+
continue
|
|
311
|
+
# Both callers keep only structural successors of the previous heading
|
|
312
|
+
# (and, here, headings followed by body text), which is what makes the
|
|
313
|
+
# wider title rules below safe on this path and nowhere else.
|
|
314
|
+
lab = canon(m.group("lab"))
|
|
315
|
+
title = _clean_title(m.group("title"), allow_lowercase=True)
|
|
316
|
+
if title is None:
|
|
317
|
+
title = _guarded_title(m.group("title"), lab)
|
|
318
|
+
if not title:
|
|
319
|
+
continue
|
|
320
|
+
out.append((i + 1, lab, title))
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# A relaxed title that ends on one of these is a wrapped line of prose.
|
|
325
|
+
_CONNECTIVE = frozenset(
|
|
326
|
+
"and or but the a an of to in for with is are by as on that which where".split())
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _guarded_title(raw: str, lab: str) -> str | None:
|
|
330
|
+
"""Titles `_clean_title` refuses that are real when the number is structurally sound.
|
|
331
|
+
|
|
332
|
+
- An XML element or similar led by '<' (RFC 9022 4.6.2.1 "<rdeCsv:csv> Element").
|
|
333
|
+
- A comma title under a multi-level number (RFC 9788 C.1.2.1). Single-level numbers are
|
|
334
|
+
excluded: in old RFCs without a ToC they are numbered prose lists ("2. Any mapping of
|
|
335
|
+
COTS onto X.25 (usually X.25(80)), where X.25 is" in RFC 1277), which would otherwise take
|
|
336
|
+
a real heading's place in the successor chain.
|
|
337
|
+
A trailing connective means the line wrapped mid-sentence, so it is prose either way.
|
|
338
|
+
"""
|
|
339
|
+
t = re.split(r"\s{2,}", raw.strip())[0].strip()
|
|
340
|
+
if not t or t.endswith(",") or re.search(r"\.\d{1,3}$", t):
|
|
341
|
+
return None
|
|
342
|
+
words = t.split()
|
|
343
|
+
if words[-1].lower() in _CONNECTIVE:
|
|
344
|
+
return None
|
|
345
|
+
if len(words) > 16 or (len(words) > 1 and t.endswith(".")):
|
|
346
|
+
return None
|
|
347
|
+
if t.startswith("<"):
|
|
348
|
+
return t
|
|
349
|
+
multi = "." in lab and not lab.startswith("Appendix")
|
|
350
|
+
if "," in t and multi:
|
|
351
|
+
return t
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def find_headings(lines: list[str]) -> tuple[list[tuple[int, str, str]],
|
|
356
|
+
Quality, tuple[int, int]]:
|
|
357
|
+
"""[(line, sec_num, title)] in document order, quality, ToC span."""
|
|
358
|
+
toc, t0, t1 = parse_toc(lines)
|
|
359
|
+
heads: list[tuple[int, str, str]] = []
|
|
360
|
+
numbered = [e for e in toc if e.sec_num]
|
|
361
|
+
q = Quality("toc" if len(numbered) >= 3 else "scan",
|
|
362
|
+
toc_entries=len(toc) if len(numbered) >= 3 else 0)
|
|
363
|
+
body0 = t1 if t1 else 0
|
|
364
|
+
|
|
365
|
+
if q.method == "toc":
|
|
366
|
+
at = body0
|
|
367
|
+
anchor: dict[int, int] = {} # toc index -> line
|
|
368
|
+
for k, e in enumerate(toc):
|
|
369
|
+
if not e.sec_num:
|
|
370
|
+
continue
|
|
371
|
+
wants = _heading_tokens(e.sec_num, e.title)
|
|
372
|
+
for i in range(at, len(lines)):
|
|
373
|
+
if _matches(lines[i], wants, True):
|
|
374
|
+
anchor[k] = i + 1
|
|
375
|
+
at = i + 1
|
|
376
|
+
break
|
|
377
|
+
for k, e in enumerate(toc): # unnumbered, between anchors
|
|
378
|
+
if e.sec_num:
|
|
379
|
+
continue
|
|
380
|
+
lo = max([anchor[j] for j in anchor if j < k], default=body0)
|
|
381
|
+
hi = min([anchor[j] for j in anchor if j > k],
|
|
382
|
+
default=len(lines) + 1)
|
|
383
|
+
wants = [([], _toks(e.title))]
|
|
384
|
+
for i in range(lo, hi - 1):
|
|
385
|
+
if _matches(lines[i], wants, False):
|
|
386
|
+
anchor[k] = i + 1
|
|
387
|
+
break
|
|
388
|
+
q.matched = len(anchor)
|
|
389
|
+
heads = sorted((anchor[k], toc[k].sec_num, toc[k].title)
|
|
390
|
+
for k in anchor)
|
|
391
|
+
# deeper headings the ToC omitted: structural successors only
|
|
392
|
+
extra = []
|
|
393
|
+
bounds = [h[0] for h in heads] + [len(lines) + 1]
|
|
394
|
+
for idx, (ln, sec, _t) in enumerate(heads):
|
|
395
|
+
if not sec or sec.startswith("Appendix"):
|
|
396
|
+
continue
|
|
397
|
+
prev = sec
|
|
398
|
+
for cl, cs, ct in _scan_candidates(lines, ln, bounds[idx + 1] - 1):
|
|
399
|
+
if cs.startswith(sec + ".") and _successor(prev, cs) and \
|
|
400
|
+
_followed_by_body(lines, cl - 1, cs):
|
|
401
|
+
extra.append((cl, cs, ct))
|
|
402
|
+
prev = cs
|
|
403
|
+
q.extra = len(extra)
|
|
404
|
+
heads = sorted(heads + extra)
|
|
405
|
+
else:
|
|
406
|
+
prev = ""
|
|
407
|
+
for cl, cs, ct in _scan_candidates(lines, body0, len(lines)):
|
|
408
|
+
if _successor(prev, cs) and _followed_by_body(lines, cl - 1, cs):
|
|
409
|
+
heads.append((cl, cs, ct))
|
|
410
|
+
prev = cs
|
|
411
|
+
q.notes.append("no usable ToC; left-margin successor scan")
|
|
412
|
+
|
|
413
|
+
# unnumbered boilerplate / back matter at the left margin: before the
|
|
414
|
+
# ToC (Abstract, Status of This Memo) and after the last numbered one
|
|
415
|
+
known = {u.lower() for u in _UNNUMBERED} | {
|
|
416
|
+
"status of this memo", "acknowledgements", "authors' addresses",
|
|
417
|
+
"author's address", "intellectual property", "full copyright statement",
|
|
418
|
+
"index"}
|
|
419
|
+
have = {h[0] for h in heads}
|
|
420
|
+
last_num = max((h[0] for h in heads if h[1]), default=body0)
|
|
421
|
+
for i, ln in enumerate(lines):
|
|
422
|
+
if i + 1 in have or not ln or ln[0] == " ":
|
|
423
|
+
continue
|
|
424
|
+
s = ln.strip().rstrip(".:")
|
|
425
|
+
if s.lower() not in known or s.lower() == "table of contents":
|
|
426
|
+
continue
|
|
427
|
+
if (t0 and i + 1 < t0) or i + 1 > last_num:
|
|
428
|
+
heads.append((i + 1, "", s))
|
|
429
|
+
heads.sort()
|
|
430
|
+
q.headings = len(heads)
|
|
431
|
+
return heads, q, (t0, t1)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# --------------------------------------------------------------------------
|
|
435
|
+
# paragraphs
|
|
436
|
+
|
|
437
|
+
def _kind(chunk: list[str]) -> tuple[str, str]:
|
|
438
|
+
body = [ln for ln in chunk if ln.strip()]
|
|
439
|
+
if not body:
|
|
440
|
+
return "t", ""
|
|
441
|
+
abnf = sum(1 for ln in body if _ABNF.match(ln))
|
|
442
|
+
if abnf and abnf >= max(1, len(body) // 3) and any(
|
|
443
|
+
re.search(r"=\s*\S.*(/|\*|%x|\"|\()", ln)
|
|
444
|
+
or re.match(r"\s*;", ln) for ln in body):
|
|
445
|
+
return "sourcecode", "abnf"
|
|
446
|
+
art = sum(1 for ln in body if _ART.search(ln))
|
|
447
|
+
alpha = sum(sum(ch.isalpha() for ch in ln) for ln in body)
|
|
448
|
+
chars = sum(len(ln.strip()) for ln in body) or 1
|
|
449
|
+
if (len(body) >= 2 and art >= max(1, len(body) // 3)) or \
|
|
450
|
+
(len(body) >= 3 and alpha / chars < 0.45):
|
|
451
|
+
return "artwork", ""
|
|
452
|
+
return "t", ""
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def paragraphs(lines: list[str], a: int, b: int) -> list[tuple[int, int]]:
|
|
456
|
+
"""(start, end) line spans of blank-separated paragraphs in [a, b];
|
|
457
|
+
a paragraph broken by a page break (furniture + blanks, previous line
|
|
458
|
+
not ending a sentence) is re-joined."""
|
|
459
|
+
spans: list[list[int]] = []
|
|
460
|
+
cur: list[int] | None = None
|
|
461
|
+
gap_furn = False
|
|
462
|
+
for n in range(a, b + 1):
|
|
463
|
+
ln = lines[n - 1].replace("\f", "")
|
|
464
|
+
if _furniture(ln) or lines[n - 1].startswith("\f"):
|
|
465
|
+
gap_furn = True
|
|
466
|
+
if cur:
|
|
467
|
+
spans.append(cur)
|
|
468
|
+
cur = None
|
|
469
|
+
continue
|
|
470
|
+
if not ln.strip():
|
|
471
|
+
if cur:
|
|
472
|
+
spans.append(cur)
|
|
473
|
+
cur = None
|
|
474
|
+
continue
|
|
475
|
+
if cur is None:
|
|
476
|
+
if gap_furn and spans and not _SENT_END.search(
|
|
477
|
+
lines[spans[-1][1] - 1]) and ln.startswith(" "):
|
|
478
|
+
cur = spans.pop() # continue the paragraph split by a page
|
|
479
|
+
else:
|
|
480
|
+
cur = [n, n]
|
|
481
|
+
gap_furn = False
|
|
482
|
+
cur[1] = n
|
|
483
|
+
if cur:
|
|
484
|
+
spans.append(cur)
|
|
485
|
+
return [(s, e) for s, e in spans]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _text(lines: list[str], s: int, e: int, kind: str) -> str:
|
|
489
|
+
rows = [lines[n - 1].replace("\f", "").rstrip() for n in range(s, e + 1)
|
|
490
|
+
if not _furniture(lines[n - 1])]
|
|
491
|
+
rows = [r for r in rows if r.strip()]
|
|
492
|
+
if kind in ("artwork", "sourcecode"):
|
|
493
|
+
ind = min((len(r) - len(r.lstrip()) for r in rows), default=0)
|
|
494
|
+
return "\n".join(r[ind:] for r in rows)
|
|
495
|
+
return re.sub(r"\s+", " ", " ".join(r.strip() for r in rows)).strip()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# --------------------------------------------------------------------------
|
|
499
|
+
# document
|
|
500
|
+
|
|
501
|
+
def segment(txt: str, doc_id: str) -> tuple[ParsedXml, Quality]:
|
|
502
|
+
lines = txt.lstrip("").splitlines()
|
|
503
|
+
heads, q, (t0, t1) = find_headings(lines)
|
|
504
|
+
if not heads: # early RFCs without any structure: one body section
|
|
505
|
+
heads = [(1, "", "Entire document")]
|
|
506
|
+
q.notes.append("no headings found; single body section")
|
|
507
|
+
used: set[str] = set()
|
|
508
|
+
sections: list[XSection] = []
|
|
509
|
+
for k, (ln, sec, title) in enumerate(heads):
|
|
510
|
+
nxt = heads[k + 1][0] if k + 1 < len(heads) else len(lines) + 1
|
|
511
|
+
end = nxt - 1
|
|
512
|
+
if t0 and ln < t0 <= end: # the ToC belongs to no section
|
|
513
|
+
end = t0 - 2
|
|
514
|
+
# a heading may wrap onto the next line (indented continuation)
|
|
515
|
+
body0 = ln + 1
|
|
516
|
+
if body0 <= end and lines[body0 - 1].startswith(" ") and \
|
|
517
|
+
not lines[body0 - 1].strip().endswith(".") and \
|
|
518
|
+
len(lines[body0 - 1].strip()) < 60 and \
|
|
519
|
+
lines[ln - 1].rstrip()[-1:].isalpha() and \
|
|
520
|
+
body0 + 1 <= end and not lines[body0].strip():
|
|
521
|
+
title = f"{title} {lines[body0 - 1].strip()}"
|
|
522
|
+
body0 += 1
|
|
523
|
+
x = XSection(_pn(sec, title, used), "", sec, title, _depth(sec),
|
|
524
|
+
head_line=ln, end_line=max(ln, end))
|
|
525
|
+
for s, e in paragraphs(lines, body0, x.end_line):
|
|
526
|
+
kind, lang = _kind([lines[n - 1] for n in range(s, e + 1)])
|
|
527
|
+
text = _text(lines, s, e, kind)
|
|
528
|
+
if not text:
|
|
529
|
+
continue
|
|
530
|
+
x.blocks.append(Block(f"{x.pn}-{len(x.blocks) + 1}", kind, text,
|
|
531
|
+
sorted(set(_NORM.findall(text)),
|
|
532
|
+
key=text.find)
|
|
533
|
+
if kind == "t" else [], lang, s, e,
|
|
534
|
+
exact=True))
|
|
535
|
+
sections.append(x)
|
|
536
|
+
px = ParsedXml(doc_id, sections, [], {})
|
|
537
|
+
px.xrefs = _xrefs(px)
|
|
538
|
+
return px, q
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _xrefs(px: ParsedXml) -> list[XRef]:
|
|
542
|
+
"""Citations with their source section, from reference lists + body
|
|
543
|
+
phrases ('Section 4.2 of [RFC1234]', '[X], Section 3', '[X]')."""
|
|
544
|
+
refs: dict[str, tuple[str, bool]] = {}
|
|
545
|
+
for sec in px.sections:
|
|
546
|
+
if "reference" not in sec.title.lower():
|
|
547
|
+
continue
|
|
548
|
+
norm = "normative" in sec.title.lower()
|
|
549
|
+
for b in sec.blocks:
|
|
550
|
+
m = _REF_LABEL.match(b.text)
|
|
551
|
+
if not m:
|
|
552
|
+
continue
|
|
553
|
+
lab = m.group(1).strip()
|
|
554
|
+
s = _SERIES.search(lab.replace(" ", "")) or _SERIES.search(b.text)
|
|
555
|
+
if s:
|
|
556
|
+
refs[lab] = (f"{s.group(1)}{int(s.group(2))}", norm)
|
|
557
|
+
b.kind = "reference"
|
|
558
|
+
out: list[XRef] = []
|
|
559
|
+
for sec in px.sections:
|
|
560
|
+
if "reference" in sec.title.lower():
|
|
561
|
+
continue
|
|
562
|
+
for b in sec.blocks:
|
|
563
|
+
if b.kind != "t":
|
|
564
|
+
continue
|
|
565
|
+
seen = set()
|
|
566
|
+
for m in _SEC_OF.finditer(b.text):
|
|
567
|
+
dst = (refs.get(m.group("lab"), ("", False))[0]
|
|
568
|
+
if m.group("lab") else
|
|
569
|
+
f"{m.group('ser')}{int(m.group('n'))}")
|
|
570
|
+
if dst:
|
|
571
|
+
seen.add((dst, m.group("sec")))
|
|
572
|
+
for m in _LAB_SEC.finditer(b.text):
|
|
573
|
+
dst = refs.get(m.group("lab"), ("", False))[0]
|
|
574
|
+
if dst:
|
|
575
|
+
seen.add((dst, m.group("sec")))
|
|
576
|
+
for m in _LAB.finditer(b.text):
|
|
577
|
+
dst = refs.get(m.group("lab"), ("", False))[0]
|
|
578
|
+
if dst and not any(d == dst for d, _s in seen):
|
|
579
|
+
seen.add((dst, ""))
|
|
580
|
+
for dst, ds in seen:
|
|
581
|
+
lab = next((k for k, v in refs.items() if v[0] == dst), dst)
|
|
582
|
+
out.append(XRef(b.pn, sec.sec_num, dst, ds, lab,
|
|
583
|
+
refs.get(lab, ("", False))[1]))
|
|
584
|
+
px.refs = refs
|
|
585
|
+
return out
|