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,315 @@
|
|
|
1
|
+
r"""A minimal LaTeX reader for the Gentoo Package Manager Specification, whose source is a set of `.tex` files.
|
|
2
|
+
|
|
3
|
+
This is not a TeX engine. It reads the structure the specification uses and nothing else:
|
|
4
|
+
|
|
5
|
+
- `\chapter`, `\section`, `\subsection`, `\subsubsection` (and the starred, unnumbered forms) are sections; the
|
|
6
|
+
numbers are LaTeX's own (chapters 1, 2, ...; after `\appendix`, chapters A, B, ...; then `8.2.4`), and a
|
|
7
|
+
`\label{...}` after a heading becomes the anchor, so `\ref{sec:slot-names}` in the text is replaced by the
|
|
8
|
+
number it names (`section~\ref{...}` reads `section 8.2.4`);
|
|
9
|
+
- `\include{name}` and `\input{name}` are expanded **only from the mapping of files the caller passes in**
|
|
10
|
+
(a file of the same archive), to a depth of four, never twice in one chain and never from disk. A missing name
|
|
11
|
+
becomes the block `[include] name`;
|
|
12
|
+
- blocks (the unit): a paragraph; each `\item` of `description`, `compactdesc`, `itemize`, `compactitem`,
|
|
13
|
+
`enumerate` and `compactenum` (a description item reads `TERM: text`); and each `verbatim`, `listing`,
|
|
14
|
+
`algorithm`, `tabular`, `centertable`, `longtable` environment as one block written as `[environment]` and the
|
|
15
|
+
raw text;
|
|
16
|
+
- text between the preamble and the first heading (the title page's copyright and licence notice) is section `0`.
|
|
17
|
+
|
|
18
|
+
Markup is reduced to its text: `\t{x}`, `\emph{x}`, `\textbf{x}` become `x`; `\cite{k}` becomes `[k]`; `~` is a
|
|
19
|
+
space; `---` and `--` become dashes; escapes (`\_`, `\&`, `\%`, `\$`, `\#`) are resolved. Any other command with
|
|
20
|
+
one argument keeps the argument, any other bare command is dropped, and the document carries one warning naming
|
|
21
|
+
the commands that were dropped, so a reader knows the text was reduced. Math, floats and macros defined in the
|
|
22
|
+
class file are not interpreted. Braces are scanned with a depth counter and a fixed number of passes, so
|
|
23
|
+
unbalanced or deeply nested input costs time linear in its size.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
from primarysource.model import Unit
|
|
33
|
+
from primarysource.pkgdocs.model import Doc, Sec
|
|
34
|
+
from primarysource.pkgdocs.readers._common import decode, slugify
|
|
35
|
+
|
|
36
|
+
MAX_DEPTH = 4
|
|
37
|
+
MAX_LINES = 200_000
|
|
38
|
+
_PASSES = 8
|
|
39
|
+
_LEVELS = {"chapter": 0, "section": 1, "subsection": 2, "subsubsection": 3}
|
|
40
|
+
_LIST_ENVS = frozenset({"description", "compactdesc", "itemize", "compactitem", "enumerate", "compactenum"})
|
|
41
|
+
_RAW_ENVS = frozenset({"verbatim", "listing", "algorithm", "tabular", "centertable", "longtable"})
|
|
42
|
+
_COMMENT = re.compile(r"(?<!\\)%.*$")
|
|
43
|
+
_INCLUDE = re.compile(r"\s*\\(?:include|input)\{([^{}]*)\}\s*")
|
|
44
|
+
_HEADING = re.compile(r"\s*\\(chapter|section|subsection|subsubsection)(\*?)\s*(?:\[[^\]]*\])?\s*\{")
|
|
45
|
+
_BEGIN = re.compile(r"\s*\\begin\{([A-Za-z*]+)\}")
|
|
46
|
+
_END = re.compile(r"\s*\\end\{([A-Za-z*]+)\}")
|
|
47
|
+
_ITEM = re.compile(r"\s*\\item\b\s*(?:\[([^\]]*)\])?\s*(.*)")
|
|
48
|
+
_LABEL = re.compile(r"\s*\\label\{([^{}]*)\}\s*")
|
|
49
|
+
_ONE_ARG = re.compile(r"\\([A-Za-z]+)\*?(?:\[[^\]]*\])?\{([^{}]*)\}")
|
|
50
|
+
_BARE = re.compile(r"\\([A-Za-z]+)\*?")
|
|
51
|
+
_REF = re.compile(r"\\(?:ref|autoref|pageref)\{([^{}]*)\}")
|
|
52
|
+
_LAYOUT = re.compile(
|
|
53
|
+
r"\s*\\(?:thispagestyle|vspace|tableofcontents|listof\w+|maketitle|bibliography|nobreakpar|clearpage|newpage"
|
|
54
|
+
r"|noindent|small|centering|vfill|hfill)\b.*"
|
|
55
|
+
)
|
|
56
|
+
_SPACE = re.compile(r"[ \t]+")
|
|
57
|
+
_SYMBOLS = (
|
|
58
|
+
("\\textasciicircum", "^"),
|
|
59
|
+
("\\textbackslash", "\x01"),
|
|
60
|
+
("\\textasciitilde", "~"),
|
|
61
|
+
("\\slash", "/"),
|
|
62
|
+
("\\allowbreak", ""),
|
|
63
|
+
("\\dots", "..."),
|
|
64
|
+
)
|
|
65
|
+
_DROP_ARG = frozenset({"featurelabel", "ChangeWhenAddingAnEAPI", "hspace", "vspace", "index"})
|
|
66
|
+
_KEEP_ARG = frozenset(
|
|
67
|
+
{"t", "emph", "textbf", "textit", "texttt", "path", "url", "code", "textsc", "textsf", "underline", "mbox"}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class _Section:
|
|
73
|
+
id: str
|
|
74
|
+
title: str
|
|
75
|
+
depth: int
|
|
76
|
+
anchor: str
|
|
77
|
+
units: list[tuple[str, bool]] = field(default_factory=list) # (text, already clean)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _braced(text: str, start: int) -> tuple[str, int]:
|
|
81
|
+
"""The brace-balanced group whose `{` is just before `start`, and the index after its `}`."""
|
|
82
|
+
depth = 1
|
|
83
|
+
i = start
|
|
84
|
+
while i < len(text):
|
|
85
|
+
char = text[i]
|
|
86
|
+
if char == "\\":
|
|
87
|
+
i += 2
|
|
88
|
+
continue
|
|
89
|
+
if char == "{":
|
|
90
|
+
depth += 1
|
|
91
|
+
elif char == "}":
|
|
92
|
+
depth -= 1
|
|
93
|
+
if depth == 0:
|
|
94
|
+
return text[start:i], i + 1
|
|
95
|
+
i += 1
|
|
96
|
+
return text[start:], len(text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _expand(
|
|
100
|
+
files: Mapping[str, bytes], name: str, chain: tuple[str, ...], out: list[str], missing: list[str]
|
|
101
|
+
) -> None:
|
|
102
|
+
key = name if name in files else name + ".tex"
|
|
103
|
+
if key not in files or key in chain or len(chain) >= MAX_DEPTH:
|
|
104
|
+
missing.append(name)
|
|
105
|
+
out.append(f"[include] {name}")
|
|
106
|
+
return
|
|
107
|
+
for line in decode(files[key], key).split("\n"):
|
|
108
|
+
if len(out) >= MAX_LINES:
|
|
109
|
+
return
|
|
110
|
+
line = _COMMENT.sub("", line).rstrip()
|
|
111
|
+
found = _INCLUDE.fullmatch(line)
|
|
112
|
+
if found:
|
|
113
|
+
_expand(files, found.group(1).strip(), (*chain, key), out, missing)
|
|
114
|
+
else:
|
|
115
|
+
out.append(line)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _letter(n: int) -> str:
|
|
119
|
+
return chr(ord("A") + (n - 1) % 26)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def read_latex(files: Mapping[str, bytes], root: str, doc_id: str, title: str) -> Doc:
|
|
123
|
+
"""Read `root` (a key of `files`) with its includes into one document. Raises `LibError` for a NUL byte or
|
|
124
|
+
invalid UTF-8 in any file it reads; a `root` that is not in `files` is an empty document with a warning."""
|
|
125
|
+
lines: list[str] = []
|
|
126
|
+
missing: list[str] = []
|
|
127
|
+
_expand(files, root, (), lines, missing)
|
|
128
|
+
started = False
|
|
129
|
+
appendix = False
|
|
130
|
+
counters = [0, 0, 0, 0]
|
|
131
|
+
unnumbered = 0
|
|
132
|
+
sections: list[_Section] = [_Section("0", title, 1, slugify(title) or "preamble")]
|
|
133
|
+
labels: dict[str, str] = {}
|
|
134
|
+
para: list[str] = []
|
|
135
|
+
item: list[str] | None = None
|
|
136
|
+
list_depth = 0
|
|
137
|
+
i, count = 0, len(lines)
|
|
138
|
+
|
|
139
|
+
def emit(text: str, clean: bool = False) -> None:
|
|
140
|
+
if text.strip():
|
|
141
|
+
sections[-1].units.append((text, clean))
|
|
142
|
+
|
|
143
|
+
def flush() -> None:
|
|
144
|
+
if para:
|
|
145
|
+
emit(" ".join(para))
|
|
146
|
+
para.clear()
|
|
147
|
+
|
|
148
|
+
def flush_item() -> None:
|
|
149
|
+
nonlocal item
|
|
150
|
+
if item is not None:
|
|
151
|
+
emit(" ".join(item))
|
|
152
|
+
item = None
|
|
153
|
+
|
|
154
|
+
while i < count:
|
|
155
|
+
line = lines[i]
|
|
156
|
+
i += 1
|
|
157
|
+
if not started:
|
|
158
|
+
if line.strip().startswith("\\begin{document}"):
|
|
159
|
+
started = True
|
|
160
|
+
continue
|
|
161
|
+
if not line.strip():
|
|
162
|
+
flush()
|
|
163
|
+
continue
|
|
164
|
+
if line.strip().startswith("\\end{document}"):
|
|
165
|
+
break
|
|
166
|
+
heading = _HEADING.match(line)
|
|
167
|
+
if heading:
|
|
168
|
+
flush()
|
|
169
|
+
flush_item()
|
|
170
|
+
joined, extra = line, 0
|
|
171
|
+
while joined.count("{") > joined.count("}") and i < count and extra < 3:
|
|
172
|
+
joined += " " + lines[i].strip()
|
|
173
|
+
i += 1
|
|
174
|
+
extra += 1
|
|
175
|
+
start = _HEADING.match(joined)
|
|
176
|
+
if start is None:
|
|
177
|
+
continue
|
|
178
|
+
kind, star = start.group(1), start.group(2)
|
|
179
|
+
text, _ = _braced(joined, start.end())
|
|
180
|
+
level = _LEVELS[kind]
|
|
181
|
+
if star:
|
|
182
|
+
unnumbered += 1
|
|
183
|
+
section_id = f"u{unnumbered}"
|
|
184
|
+
else:
|
|
185
|
+
counters[level] += 1
|
|
186
|
+
for deeper in range(level + 1, 4):
|
|
187
|
+
counters[deeper] = 0
|
|
188
|
+
first = _letter(counters[0]) if appendix else str(counters[0])
|
|
189
|
+
section_id = ".".join([first, *(str(n) for n in counters[1 : level + 1])])
|
|
190
|
+
sections.append(_Section(section_id, _plain(text, labels), level + 1, ""))
|
|
191
|
+
while i < count and not lines[i].strip():
|
|
192
|
+
i += 1
|
|
193
|
+
key = _LABEL.fullmatch(lines[i]) if i < count else None
|
|
194
|
+
if key:
|
|
195
|
+
sections[-1].anchor = key.group(1)
|
|
196
|
+
labels[key.group(1)] = section_id
|
|
197
|
+
i += 1
|
|
198
|
+
continue
|
|
199
|
+
if line.strip() == "\\appendix":
|
|
200
|
+
flush()
|
|
201
|
+
appendix = True
|
|
202
|
+
counters = [0, 0, 0, 0]
|
|
203
|
+
continue
|
|
204
|
+
begin = _BEGIN.match(line)
|
|
205
|
+
if begin and begin.group(1) in _RAW_ENVS:
|
|
206
|
+
flush()
|
|
207
|
+
flush_item()
|
|
208
|
+
env = begin.group(1)
|
|
209
|
+
raw = [line.strip()]
|
|
210
|
+
depth = 1
|
|
211
|
+
while i < count and depth:
|
|
212
|
+
nxt = lines[i]
|
|
213
|
+
i += 1
|
|
214
|
+
raw.append(nxt)
|
|
215
|
+
inner = _BEGIN.match(nxt)
|
|
216
|
+
if inner and inner.group(1) == env:
|
|
217
|
+
depth += 1
|
|
218
|
+
closing = _END.match(nxt)
|
|
219
|
+
if closing and closing.group(1) == env:
|
|
220
|
+
depth -= 1
|
|
221
|
+
emit(f"[{env}]\n" + "\n".join(raw), clean=True)
|
|
222
|
+
continue
|
|
223
|
+
if begin and begin.group(1) in _LIST_ENVS:
|
|
224
|
+
flush()
|
|
225
|
+
flush_item()
|
|
226
|
+
list_depth += 1
|
|
227
|
+
continue
|
|
228
|
+
end = _END.match(line)
|
|
229
|
+
if end and end.group(1) in _LIST_ENVS:
|
|
230
|
+
flush()
|
|
231
|
+
flush_item()
|
|
232
|
+
list_depth = max(0, list_depth - 1)
|
|
233
|
+
continue
|
|
234
|
+
entry = _ITEM.match(line) if list_depth else None
|
|
235
|
+
if entry:
|
|
236
|
+
flush()
|
|
237
|
+
flush_item()
|
|
238
|
+
item = ([f"{entry.group(1)}:"] if entry.group(1) is not None else []) + (
|
|
239
|
+
[entry.group(2)] if entry.group(2) else []
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
if _LAYOUT.fullmatch(line):
|
|
243
|
+
continue
|
|
244
|
+
(item if item is not None else para).append(line.strip())
|
|
245
|
+
flush()
|
|
246
|
+
flush_item()
|
|
247
|
+
unknown: dict[str, int] = {}
|
|
248
|
+
built: list[Sec] = []
|
|
249
|
+
for section in sections:
|
|
250
|
+
units = tuple(t if clean else _plain(t, labels, unknown) for t, clean in section.units)
|
|
251
|
+
units = tuple(u for u in units if u.strip())
|
|
252
|
+
if section.id == "0" and not units:
|
|
253
|
+
continue
|
|
254
|
+
anchor = section.anchor or slugify(section.title) or section.id
|
|
255
|
+
built.append(Sec(section.id, section.title, section.depth, anchor, units))
|
|
256
|
+
warnings: list[str] = []
|
|
257
|
+
if unknown:
|
|
258
|
+
names = ", ".join(sorted(unknown, key=lambda k: (-unknown[k], k))[:12])
|
|
259
|
+
warnings.append(f"LaTeX commands dropped (their arguments kept): {names}")
|
|
260
|
+
if missing:
|
|
261
|
+
warnings.append(f"not in the archive selection: {', '.join(sorted(set(missing)))}")
|
|
262
|
+
return Doc(doc_id, title, Unit.BLOCK, _unique(built), {}, tuple(warnings))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _unique(sections: list[Sec]) -> tuple[Sec, ...]:
|
|
266
|
+
seen: set[str] = set()
|
|
267
|
+
out: list[Sec] = []
|
|
268
|
+
for section in sections:
|
|
269
|
+
anchor, n = section.anchor, 0
|
|
270
|
+
while anchor in seen:
|
|
271
|
+
n += 1
|
|
272
|
+
anchor = f"{section.anchor}-{n}"
|
|
273
|
+
seen.add(anchor)
|
|
274
|
+
out.append(Sec(section.id, section.title, section.depth, anchor, section.units))
|
|
275
|
+
return tuple(out)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _plain(text: str, labels: Mapping[str, str], unknown: dict[str, int] | None = None) -> str:
|
|
279
|
+
"""Reduce LaTeX markup in `text` to its words (see the module docstring)."""
|
|
280
|
+
text = _REF.sub(lambda m: labels.get(m.group(1), m.group(1)), text)
|
|
281
|
+
text = re.sub(r"\\label\{[^{}]*\}", "", text)
|
|
282
|
+
text = re.sub(r"\\cite\{([^{}]*)\}", r"[\1]", text)
|
|
283
|
+
text = text.replace("\\\\", " ").replace("\\@", "").replace("\\,", " ").replace("\\ ", " ").replace("~", " ")
|
|
284
|
+
for command, plain in _SYMBOLS:
|
|
285
|
+
text = text.replace(command, plain)
|
|
286
|
+
for _ in range(_PASSES):
|
|
287
|
+
|
|
288
|
+
def one(match: re.Match[str]) -> str:
|
|
289
|
+
name, arg = match.group(1), match.group(2)
|
|
290
|
+
if name == "note":
|
|
291
|
+
return "Note: " + arg
|
|
292
|
+
if name in _DROP_ARG:
|
|
293
|
+
return ""
|
|
294
|
+
if name not in _KEEP_ARG and unknown is not None:
|
|
295
|
+
unknown[name] = unknown.get(name, 0) + 1
|
|
296
|
+
return arg
|
|
297
|
+
|
|
298
|
+
after = _ONE_ARG.sub(one, text)
|
|
299
|
+
if after == text:
|
|
300
|
+
break
|
|
301
|
+
text = after
|
|
302
|
+
|
|
303
|
+
def bare(match: re.Match[str]) -> str:
|
|
304
|
+
if unknown is not None:
|
|
305
|
+
unknown[match.group(1)] = unknown.get(match.group(1), 0) + 1
|
|
306
|
+
return ""
|
|
307
|
+
|
|
308
|
+
text = _BARE.sub(bare, text)
|
|
309
|
+
for escape in ("_", "&", "%", "$", "#"):
|
|
310
|
+
text = text.replace("\\" + escape, escape)
|
|
311
|
+
text = text.replace("\\{", "\x02").replace("\\}", "\x03").replace("{", "").replace("}", "")
|
|
312
|
+
text = text.replace("\x02", "{").replace("\x03", "}").replace("\x01", "\\")
|
|
313
|
+
text = text.replace("---", "\u2014").replace("--", "\u2013")
|
|
314
|
+
text = text.replace("``", '"').replace("''", '"')
|
|
315
|
+
return _SPACE.sub(" ", text).strip()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""A minimal Markdown reader for rpm's manual and Debian's autopkgtest specification.
|
|
2
|
+
|
|
3
|
+
Headings are ATX (`#` to `######`) or setext (`===` / `---` under a paragraph). Section ids are the ordinal path
|
|
4
|
+
(`2`, `2.1`); the anchor is the GitHub-style slug of the title, duplicates suffixed `-1`, `-2`. That slug is the
|
|
5
|
+
rule used here; this module does not claim CommonMark defines it. Text before the first heading is section `0`,
|
|
6
|
+
titled with the document title. YAML front matter (a first line `---`) is not a section; its `title:` is used
|
|
7
|
+
when no title is given.
|
|
8
|
+
|
|
9
|
+
Blocks (the unit): a paragraph (a run of non-blank lines), a fenced code block as one block with its fences, an
|
|
10
|
+
indented code block, a block quote, a table, and each **top-level** list item with everything nested inside it.
|
|
11
|
+
HTML comments are dropped (a `<!-- -->` hiding a `#` line hides it from the outline too); an unclosed fence or
|
|
12
|
+
comment runs to the end of the file, so no text is lost to a typo. Inline markup is left as written.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from primarysource.model import Unit
|
|
20
|
+
from primarysource.pkgdocs.model import Doc
|
|
21
|
+
from primarysource.pkgdocs.readers._common import Builder, decode
|
|
22
|
+
|
|
23
|
+
_FENCE = re.compile(r" {0,3}(`{3,}|~{3,})")
|
|
24
|
+
_ITEM = re.compile(r" {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]+\S|[ \t]*$)")
|
|
25
|
+
_RULE = re.compile(r" {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$")
|
|
26
|
+
_SETEXT = re.compile(r" {0,3}(=+|-+)[ \t]*$")
|
|
27
|
+
_TABLE_SEP = re.compile(r"[ \t|:-]+$")
|
|
28
|
+
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _atx(line: str) -> tuple[int, str] | None:
|
|
32
|
+
stripped = line.lstrip(" ")
|
|
33
|
+
if len(line) - len(stripped) > 3 or not stripped.startswith("#"):
|
|
34
|
+
return None
|
|
35
|
+
body = stripped.lstrip("#")
|
|
36
|
+
level = len(stripped) - len(body)
|
|
37
|
+
if level > 6 or (body and body[0] not in " \t"):
|
|
38
|
+
return None
|
|
39
|
+
title = body.strip()
|
|
40
|
+
if title.endswith("#"):
|
|
41
|
+
shorter = title.rstrip("#")
|
|
42
|
+
if not shorter or shorter[-1] in " \t":
|
|
43
|
+
title = shorter.strip()
|
|
44
|
+
return level, title
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _front_matter(lines: list[str]) -> tuple[int, str]:
|
|
48
|
+
"""(first line after the front matter, its `title:`), or (0, "") when there is none."""
|
|
49
|
+
if not lines or lines[0].strip() != "---":
|
|
50
|
+
return 0, ""
|
|
51
|
+
for end in range(1, min(len(lines), 200)):
|
|
52
|
+
if lines[end].strip() in ("---", "..."):
|
|
53
|
+
title = ""
|
|
54
|
+
for line in lines[1:end]:
|
|
55
|
+
key, _, value = line.partition(":")
|
|
56
|
+
if key.strip() == "title" and value.strip():
|
|
57
|
+
title = value.strip().strip("\"'")
|
|
58
|
+
return end + 1, title
|
|
59
|
+
return 0, ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_markdown(data: bytes, doc_id: str, title: str | None = None) -> Doc:
|
|
63
|
+
"""Read one Markdown file. Raises `LibError` for NUL bytes or invalid UTF-8; everything else reads."""
|
|
64
|
+
text = decode(data, f"Markdown file {doc_id!r}")
|
|
65
|
+
lines = [ln.rstrip() for ln in text.expandtabs(4).split("\n")]
|
|
66
|
+
i, fm_title = _front_matter(lines)
|
|
67
|
+
doc_title = title or fm_title
|
|
68
|
+
builder = Builder()
|
|
69
|
+
first_h1 = ""
|
|
70
|
+
count = len(lines)
|
|
71
|
+
para: list[str] = []
|
|
72
|
+
|
|
73
|
+
def flush() -> None:
|
|
74
|
+
if para:
|
|
75
|
+
builder.unit("\n".join(para))
|
|
76
|
+
para.clear()
|
|
77
|
+
|
|
78
|
+
def heading(level: int, name: str) -> None:
|
|
79
|
+
nonlocal first_h1
|
|
80
|
+
name = _LINK.sub(chr(92) + "1", name)
|
|
81
|
+
first_h1 = first_h1 or (name if level == 1 else "")
|
|
82
|
+
builder.heading(level, name)
|
|
83
|
+
|
|
84
|
+
while i < count:
|
|
85
|
+
line = lines[i]
|
|
86
|
+
i += 1
|
|
87
|
+
if not line.strip():
|
|
88
|
+
flush()
|
|
89
|
+
continue
|
|
90
|
+
fence = _FENCE.match(line)
|
|
91
|
+
if fence:
|
|
92
|
+
flush()
|
|
93
|
+
marker = fence.group(1)
|
|
94
|
+
block = [line]
|
|
95
|
+
while i < count:
|
|
96
|
+
closing = lines[i].strip()
|
|
97
|
+
block.append(lines[i])
|
|
98
|
+
i += 1
|
|
99
|
+
if closing.startswith(marker[0] * len(marker)) and closing.strip(marker[0]) == "":
|
|
100
|
+
break
|
|
101
|
+
builder.unit("\n".join(block).rstrip("\n"))
|
|
102
|
+
continue
|
|
103
|
+
if line.lstrip().startswith("<!--"):
|
|
104
|
+
flush()
|
|
105
|
+
i -= 1
|
|
106
|
+
while i < count and "-->" not in lines[i]:
|
|
107
|
+
i += 1
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
atx = _atx(line)
|
|
111
|
+
if atx:
|
|
112
|
+
flush()
|
|
113
|
+
if atx[1]:
|
|
114
|
+
heading(*atx)
|
|
115
|
+
continue
|
|
116
|
+
setext = _SETEXT.match(line)
|
|
117
|
+
if setext and para:
|
|
118
|
+
name = " ".join(para)
|
|
119
|
+
para.clear()
|
|
120
|
+
heading(1 if setext.group(1)[0] == "=" else 2, name)
|
|
121
|
+
continue
|
|
122
|
+
if _RULE.match(line) and not para:
|
|
123
|
+
continue
|
|
124
|
+
if para:
|
|
125
|
+
starts = _ITEM.match(line) or line.lstrip().startswith(">") or "|" in line
|
|
126
|
+
if not starts:
|
|
127
|
+
para.append(line)
|
|
128
|
+
continue
|
|
129
|
+
flush()
|
|
130
|
+
if _ITEM.match(line):
|
|
131
|
+
item = [line]
|
|
132
|
+
while i < count:
|
|
133
|
+
nxt = lines[i]
|
|
134
|
+
if not nxt.strip():
|
|
135
|
+
j = i
|
|
136
|
+
while j < count and not lines[j].strip():
|
|
137
|
+
j += 1
|
|
138
|
+
if j < count and (len(lines[j]) - len(lines[j].lstrip(" "))) >= 2:
|
|
139
|
+
item.extend([""] * (j - i))
|
|
140
|
+
i = j
|
|
141
|
+
continue
|
|
142
|
+
break
|
|
143
|
+
indented = (len(nxt) - len(nxt.lstrip(" "))) >= 2
|
|
144
|
+
if not indented and (_ITEM.match(nxt) or _atx(nxt) or _FENCE.match(nxt) or _RULE.match(nxt)):
|
|
145
|
+
break
|
|
146
|
+
item.append(nxt)
|
|
147
|
+
i += 1
|
|
148
|
+
builder.unit("\n".join(item))
|
|
149
|
+
continue
|
|
150
|
+
if line.lstrip().startswith(">"):
|
|
151
|
+
block = [line]
|
|
152
|
+
while i < count and lines[i].strip() and (lines[i].lstrip().startswith(">") or not _atx(lines[i])):
|
|
153
|
+
block.append(lines[i])
|
|
154
|
+
i += 1
|
|
155
|
+
builder.unit("\n".join(block))
|
|
156
|
+
continue
|
|
157
|
+
if "|" in line and i < count and _TABLE_SEP.match(lines[i]) and "-" in lines[i] and "|" in lines[i]:
|
|
158
|
+
block = [line]
|
|
159
|
+
while i < count and lines[i].strip() and "|" in lines[i]:
|
|
160
|
+
block.append(lines[i])
|
|
161
|
+
i += 1
|
|
162
|
+
builder.unit("\n".join(block))
|
|
163
|
+
continue
|
|
164
|
+
if line.startswith(" ") and not para:
|
|
165
|
+
block = [line]
|
|
166
|
+
while i < count and (lines[i].startswith(" ") or not lines[i].strip()):
|
|
167
|
+
block.append(lines[i])
|
|
168
|
+
i += 1
|
|
169
|
+
builder.unit("\n".join(block).rstrip())
|
|
170
|
+
continue
|
|
171
|
+
para.append(line)
|
|
172
|
+
flush()
|
|
173
|
+
return builder.doc(doc_id, doc_title or first_h1 or doc_id, unit=Unit.BLOCK)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""A minimal POD reader for the dpkg manual pages, which are `.pod` files in dpkg's `man/` directory.
|
|
2
|
+
|
|
3
|
+
`=head1`..`=head4` are sections (level 1 to 4). Blocks, the unit: an ordinary paragraph, a verbatim (indented)
|
|
4
|
+
paragraph, and an `=item` with every paragraph up to the next item (so a tag and its description are one block,
|
|
5
|
+
the way a man page's `.TP` is). `=over` and `=back` only delimit lists; nesting is flattened. `=begin`/`=end`
|
|
6
|
+
regions and `=for` lines are dropped, as are `=encoding`, `=pod`, `=cut` and the `#` licence comment that dpkg's
|
|
7
|
+
files carry before the first command.
|
|
8
|
+
|
|
9
|
+
Formatting codes (`B<>`, `I<>`, `C<>`, `F<>`, `S<>`, `L<>`, `E<>`, `X<>`, `Z<>`) are resolved to their text,
|
|
10
|
+
including the `C<< ... >>` form, in a fixed number of passes. A paragraph over `MAX_CODED` characters is left
|
|
11
|
+
unresolved and the document carries a warning, so a hostile run of `C<<` costs time linear in the input.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from primarysource.model import Unit
|
|
19
|
+
from primarysource.pkgdocs.model import Doc
|
|
20
|
+
from primarysource.pkgdocs.readers._common import Builder, decode
|
|
21
|
+
|
|
22
|
+
MAX_CODED = 20_000
|
|
23
|
+
_PASSES = 12
|
|
24
|
+
_ENTITIES = {"lt": "<", "gt": ">", "verbar": "|", "sol": "/", "amp": "&", "quot": '"', "apos": "'", "nbsp": " "}
|
|
25
|
+
_WIDE = re.compile(r"([A-Z])(<{2,}) ([^<>]*?) (>{2,})")
|
|
26
|
+
_NARROW = re.compile(r"([A-Z])<([^<>]*)>")
|
|
27
|
+
_CMD = re.compile(r"=([a-z]+\d?)(?:[ \t]+(.*))?$", re.DOTALL)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _entity(name: str) -> str:
|
|
31
|
+
if name in _ENTITIES:
|
|
32
|
+
return _ENTITIES[name]
|
|
33
|
+
if name.isdigit():
|
|
34
|
+
return chr(int(name)) if 0 < int(name) < 0x110000 else ""
|
|
35
|
+
if name.lower().startswith("0x") and re.fullmatch(r"0[xX][0-9a-fA-F]{1,6}", name):
|
|
36
|
+
return chr(int(name, 16)) if int(name, 16) < 0x110000 else ""
|
|
37
|
+
return f"&{name};"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _code(match: re.Match[str]) -> str:
|
|
41
|
+
kind, inner = match.group(1), match.group(2) if match.re is _NARROW else match.group(3)
|
|
42
|
+
if kind == "E":
|
|
43
|
+
return _entity(inner.strip())
|
|
44
|
+
if kind in ("X", "Z"):
|
|
45
|
+
return ""
|
|
46
|
+
if kind == "L":
|
|
47
|
+
label = inner.split("|", 1)[0] if "|" in inner else inner
|
|
48
|
+
return label
|
|
49
|
+
if kind in ("B", "I", "C", "F", "S", "U"):
|
|
50
|
+
return inner
|
|
51
|
+
return match.group(0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_codes(text: str) -> str:
|
|
55
|
+
"""Resolve POD formatting codes to their text. Unknown letters are left as written."""
|
|
56
|
+
for _ in range(_PASSES):
|
|
57
|
+
after = _NARROW.sub(_code, _WIDE.sub(_code, text))
|
|
58
|
+
if after == text:
|
|
59
|
+
break
|
|
60
|
+
text = after
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_pod(data: bytes, doc_id: str, title: str | None = None) -> Doc:
|
|
65
|
+
"""Read one POD file. Raises `LibError` for NUL bytes or invalid UTF-8."""
|
|
66
|
+
text = decode(data, f"POD file {doc_id!r}")
|
|
67
|
+
builder = Builder()
|
|
68
|
+
uncoded = 0
|
|
69
|
+
paragraphs = re.split(r"\n[ \t]*\n+", text.strip("\n"))
|
|
70
|
+
item: list[str] | None = None
|
|
71
|
+
skip_region = False
|
|
72
|
+
doc_title = title or ""
|
|
73
|
+
in_name = False
|
|
74
|
+
started = False # the first `=` command seen
|
|
75
|
+
|
|
76
|
+
def emit(block: str) -> None:
|
|
77
|
+
nonlocal item
|
|
78
|
+
if item is not None:
|
|
79
|
+
item.append(block)
|
|
80
|
+
else:
|
|
81
|
+
builder.unit(block)
|
|
82
|
+
|
|
83
|
+
def close_item() -> None:
|
|
84
|
+
nonlocal item
|
|
85
|
+
if item is not None:
|
|
86
|
+
builder.unit("\n\n".join(item))
|
|
87
|
+
item = None
|
|
88
|
+
|
|
89
|
+
def coded(block: str) -> str:
|
|
90
|
+
nonlocal uncoded
|
|
91
|
+
if len(block) > MAX_CODED:
|
|
92
|
+
uncoded += 1
|
|
93
|
+
return block
|
|
94
|
+
return resolve_codes(block)
|
|
95
|
+
|
|
96
|
+
for paragraph in paragraphs:
|
|
97
|
+
if not started and not paragraph.startswith("="):
|
|
98
|
+
lines = [ln for ln in paragraph.split("\n") if not ln.startswith("#")]
|
|
99
|
+
if not lines:
|
|
100
|
+
continue # the licence comment block
|
|
101
|
+
paragraph = "\n".join(lines)
|
|
102
|
+
command = _CMD.match(paragraph) if paragraph.startswith("=") else None
|
|
103
|
+
if skip_region:
|
|
104
|
+
skip_region = not (command and command.group(1) == "end")
|
|
105
|
+
continue
|
|
106
|
+
if command:
|
|
107
|
+
started = True
|
|
108
|
+
name, argument = command.group(1), (command.group(2) or "").strip()
|
|
109
|
+
if name.startswith("head") and name[4:].isdigit():
|
|
110
|
+
close_item()
|
|
111
|
+
title_text = coded(argument.replace("\n", " "))
|
|
112
|
+
in_name = title_text.strip().upper() == "NAME"
|
|
113
|
+
builder.heading(int(name[4:]), title_text)
|
|
114
|
+
elif name == "item":
|
|
115
|
+
close_item()
|
|
116
|
+
item = [coded(argument)]
|
|
117
|
+
elif name in ("back",):
|
|
118
|
+
close_item()
|
|
119
|
+
elif name == "begin":
|
|
120
|
+
skip_region = True
|
|
121
|
+
continue
|
|
122
|
+
if paragraph.lstrip().startswith("="):
|
|
123
|
+
continue
|
|
124
|
+
body = coded(paragraph.rstrip()) if paragraph[:1] not in " \t" else paragraph.rstrip()
|
|
125
|
+
if in_name and not doc_title:
|
|
126
|
+
doc_title = " ".join(body.split())
|
|
127
|
+
in_name = False
|
|
128
|
+
emit(body)
|
|
129
|
+
close_item()
|
|
130
|
+
warnings = (f"{uncoded} paragraphs over {MAX_CODED} characters left unresolved",) if uncoded else ()
|
|
131
|
+
builder.set_preamble_title(doc_title or doc_id)
|
|
132
|
+
return builder.doc(doc_id, doc_title or doc_id, unit=Unit.BLOCK, warnings=warnings)
|