texmodel 0.2.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.
- texmodel/__init__.py +6 -0
- texmodel/_internal/__init__.py +1 -0
- texmodel/_internal/render.py +323 -0
- texmodel/_internal/scan.py +23 -0
- texmodel/body.py +364 -0
- texmodel/bundle.py +69 -0
- texmodel/cli.py +211 -0
- texmodel/components.py +757 -0
- texmodel/config.py +32 -0
- texmodel/drift.py +55 -0
- texmodel/errors.py +38 -0
- texmodel/package.py +482 -0
- texmodel/plan.py +92 -0
- texmodel/pointer.py +70 -0
- texmodel/preamble.py +110 -0
- texmodel/py.typed +0 -0
- texmodel/spec.py +235 -0
- texmodel/std/__init__.py +37 -0
- texmodel/std/adjustbox.py +19 -0
- texmodel/std/anyfontsize.py +7 -0
- texmodel/std/calc.py +7 -0
- texmodel/std/enumitem.py +18 -0
- texmodel/std/eso_pic.py +49 -0
- texmodel/std/etoolbox.py +127 -0
- texmodel/std/fontspec.py +36 -0
- texmodel/std/geometry.py +17 -0
- texmodel/std/graphicx.py +26 -0
- texmodel/std/hyphenat.py +7 -0
- texmodel/std/latex.py +516 -0
- texmodel/std/microtype.py +27 -0
- texmodel/std/pdfrender.py +17 -0
- texmodel/std/tcolorbox.py +43 -0
- texmodel/std/tikz.py +36 -0
- texmodel/std/xcolor.py +47 -0
- texmodel/testing.py +297 -0
- texmodel/types.py +264 -0
- texmodel-0.2.0.dist-info/METADATA +525 -0
- texmodel-0.2.0.dist-info/RECORD +61 -0
- texmodel-0.2.0.dist-info/WHEEL +4 -0
- texmodel-0.2.0.dist-info/entry_points.txt +9 -0
- texmodel-0.2.0.dist-info/licenses/LICENSE +21 -0
- texmodel_build/__init__.py +10 -0
- texmodel_build/_internal/__init__.py +1 -0
- texmodel_build/_internal/converge.py +19 -0
- texmodel_build/_internal/env.py +42 -0
- texmodel_build/_internal/logparse.py +170 -0
- texmodel_build/_internal/proofpdf.py +52 -0
- texmodel_build/_internal/spawn.py +36 -0
- texmodel_build/_internal/styscan.py +37 -0
- texmodel_build/cli.py +349 -0
- texmodel_build/compiler.py +341 -0
- texmodel_build/config.py +58 -0
- texmodel_build/diagnostics.py +225 -0
- texmodel_build/engines.py +35 -0
- texmodel_build/errors.py +17 -0
- texmodel_build/py.typed +0 -0
- texmodel_build/result.py +65 -0
- texmodel_build/roots.py +41 -0
- texmodel_build/targets.py +43 -0
- texmodel_build/testkit.py +59 -0
- texmodel_build/verify.py +158 -0
texmodel/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private implementation. Nothing here is public API and any of it may change at any time."""
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Turning a component declaration into TeX text (G1): body holes, lints, `\\newcommand`, the package header.
|
|
2
|
+
|
|
3
|
+
Output is a pure function of the declaration: no timestamps, no hashes, no dict or set ordering.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from ..body import HOLE, Hole, Library, Syntax, text_of
|
|
12
|
+
from ..errors import DeclarationError
|
|
13
|
+
from ..types import Choice, Colour, Length, Raw, balanced, escape, pdf_escape
|
|
14
|
+
|
|
15
|
+
_HOLE = re.compile(rf"{HOLE.pattern}|#(?P<digit>\d)")
|
|
16
|
+
_BODY_TOKEN = re.compile(r"\\.|##|#(?P<name>[A-Za-z_]\w*)|#(?P<digit>\d)", re.DOTALL) # `\#` is a character, not a hole
|
|
17
|
+
_CONTROL_WORD = re.compile(r"[A-Z][A-Za-z]*")
|
|
18
|
+
_TOKEN = re.compile(r"%[^\n]*|\\(?P<word>[A-Za-z@]+)|\\.", re.DOTALL) # comment | control word | control symbol
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def snake(name: str) -> str:
|
|
22
|
+
"""`FactGap` -> `fact_gap`."""
|
|
23
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(frozen=True)
|
|
27
|
+
class Arg:
|
|
28
|
+
name: str
|
|
29
|
+
optional: bool = False # TeX's one optional argument: always first, empty when absent
|
|
30
|
+
raw: bool = False # a `Raw` field passes through; a `Text` field is escaped (T3)
|
|
31
|
+
choice: type[Choice] | None = None # a closed set: the member's value is the TeX (T1)
|
|
32
|
+
pdf: bool = False # a `PdfString`: PDF-escaped, then inert to TeX (§21a 9)
|
|
33
|
+
renamed_from: str | None = None # the field's old name in data (§21a 15)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True)
|
|
37
|
+
class Spec:
|
|
38
|
+
"""What the renderer needs to know about one command."""
|
|
39
|
+
|
|
40
|
+
name: str # the class name; the control sequence is `cs`, else prefix + name
|
|
41
|
+
doc: str
|
|
42
|
+
args: tuple[Arg, ...] # declared order; the optional one (at most one) is first in TeX
|
|
43
|
+
body: str
|
|
44
|
+
origin: str # "fact.tex" or "inline", for the provenance comment
|
|
45
|
+
qualname: str # module.Class, for the provenance comment
|
|
46
|
+
unused: tuple[str, ...] = ()
|
|
47
|
+
refs: tuple[type, ...] = () # components its body calls (§21a 17)
|
|
48
|
+
cs: str | None = None # the control sequence when the class names it, else prefix + name
|
|
49
|
+
syntax: Syntax = Syntax.LATEX2E # the body's (§21a 12)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass(frozen=True)
|
|
53
|
+
class EnvSpec:
|
|
54
|
+
"""What the renderer needs to know about one environment: two bodies, begin and end."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
doc: str
|
|
58
|
+
args: tuple[Arg, ...]
|
|
59
|
+
begin: str
|
|
60
|
+
end: str
|
|
61
|
+
origin: str # "card_back.begin.tex + card_back.end.tex" or "inline", for the provenance comment
|
|
62
|
+
qualname: str
|
|
63
|
+
unused: tuple[str, ...] = ()
|
|
64
|
+
refs: tuple[type, ...] = ()
|
|
65
|
+
cs: str | None = None
|
|
66
|
+
syntax: Syntax = Syntax.LATEX2E
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclasses.dataclass(frozen=True)
|
|
70
|
+
class ValueSpec:
|
|
71
|
+
"""What the renderer needs to know about one `Value`: its type is its default's type, which decides the TeX."""
|
|
72
|
+
|
|
73
|
+
name: str # the class name
|
|
74
|
+
doc: str
|
|
75
|
+
default: (
|
|
76
|
+
Length | Colour | bool | str | Raw | None
|
|
77
|
+
) # None: a `Value[Text]` or `Value[Raw]` whose default the hand-written package has
|
|
78
|
+
kind: str # "Length" | "Colour" | "Bool" | "Text" | "Raw", for the provenance comment
|
|
79
|
+
qualname: str
|
|
80
|
+
needs: tuple[str, ...] # packages it needs (M14)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_name(name: str, what: str) -> None:
|
|
84
|
+
"""A TeX control word has letters only, so a component name has no digits or underscores."""
|
|
85
|
+
if not _CONTROL_WORD.fullmatch(name):
|
|
86
|
+
raise DeclarationError(
|
|
87
|
+
f"{what} name {name!r} must be letters only and start with a capital (it becomes a TeX control word)",
|
|
88
|
+
code="TM-M001",
|
|
89
|
+
path=name,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def tex_arguments(args: tuple[Arg, ...]) -> tuple[Arg, ...]:
|
|
94
|
+
"""TeX order: the optional argument first, then the required ones as declared."""
|
|
95
|
+
return (*(a for a in args if a.optional), *(a for a in args if not a.optional))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def rewrite_body(spec: Spec | EnvSpec, body: str) -> str:
|
|
99
|
+
"""Replace `#name` with its `#k`; `##` stays; unknown names, `#1` and unused arguments are refused."""
|
|
100
|
+
position = {a.name: i for i, a in enumerate(tex_arguments(spec.args), start=1)}
|
|
101
|
+
used: set[str] = set()
|
|
102
|
+
|
|
103
|
+
def sub(m: re.Match[str]) -> str:
|
|
104
|
+
if m.group(0) == "##" or m.group(0).startswith("\\"):
|
|
105
|
+
return m.group(0)
|
|
106
|
+
if m["digit"]:
|
|
107
|
+
raise DeclarationError(
|
|
108
|
+
f"{spec.qualname}: write #name, not #{m['digit']} (arguments are named by their fields)",
|
|
109
|
+
code="TM-M008",
|
|
110
|
+
path=spec.name,
|
|
111
|
+
)
|
|
112
|
+
if m["name"] not in position:
|
|
113
|
+
raise DeclarationError(
|
|
114
|
+
f"{spec.qualname}: body uses #{m['name']}, which is not a field; fields: {sorted(position)}",
|
|
115
|
+
code="TM-M009",
|
|
116
|
+
path=spec.name,
|
|
117
|
+
)
|
|
118
|
+
used.add(m["name"])
|
|
119
|
+
return f"#{position[m['name']]}"
|
|
120
|
+
|
|
121
|
+
# a comment line is not TeX: its `#` are text (a `Comment` body piece may hold any)
|
|
122
|
+
text = "\n".join(ln if ln.lstrip().startswith("%") else _BODY_TOKEN.sub(sub, ln) for ln in body.split("\n"))
|
|
123
|
+
if unused := sorted(set(position) - used - set(spec.unused)):
|
|
124
|
+
raise DeclarationError(
|
|
125
|
+
f"{spec.qualname}: body never uses {unused}; use them, or list them in unused=(...)",
|
|
126
|
+
code="TM-M010",
|
|
127
|
+
path=spec.name,
|
|
128
|
+
)
|
|
129
|
+
return text
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check_braces(spec: Spec | EnvSpec, *bodies: str) -> None:
|
|
133
|
+
"""Balanced braces: an unbalanced body would swallow the rest of the file."""
|
|
134
|
+
if not all(balanced(b) for b in bodies):
|
|
135
|
+
raise DeclarationError(f"{spec.qualname}: unbalanced braces in the body", code="TM-M011", path=spec.name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def signature(args: tuple[Arg, ...]) -> str:
|
|
139
|
+
n = len(args)
|
|
140
|
+
return f"[{n}]" + ("[]" if any(a.optional for a in args) else "") if n else ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _comment(doc: str) -> list[str]:
|
|
144
|
+
return [f"% {line}".rstrip() for line in doc.splitlines()]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _wrapped(syntax: Syntax, lines: list[str]) -> list[str]:
|
|
148
|
+
"""An expl3 definition sits between `\\ExplSyntaxOn` and `\\ExplSyntaxOff`."""
|
|
149
|
+
return ["\\ExplSyntaxOn", *lines, "\\ExplSyntaxOff"] if syntax is Syntax.EXPL3 else lines
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _brace(syntax: Syntax) -> str:
|
|
153
|
+
"""The `{` that opens a body: `%` hides the line end from TeX, which expl3 ignores anyway."""
|
|
154
|
+
return "{" if syntax is Syntax.EXPL3 else "{%"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_command(prefix: str, spec: Spec) -> str:
|
|
158
|
+
check_braces(spec, spec.body)
|
|
159
|
+
body = rewrite_body(spec, spec.body).replace("\r\n", "\n").rstrip()
|
|
160
|
+
head = f"\\newcommand{{\\{spec.cs or prefix + spec.name}}}{signature(spec.args)}{_brace(spec.syntax)}"
|
|
161
|
+
lines = [*_comment(spec.doc), f"% from {spec.qualname} (body: {spec.origin})"]
|
|
162
|
+
return "\n".join([*lines, *_wrapped(spec.syntax, [head, *body.splitlines(), "}"])])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def render_environment(prefix: str, spec: EnvSpec) -> str:
|
|
166
|
+
"""`\\newenvironment`: arguments belong to the begin body; the end body runs after them and cannot read them."""
|
|
167
|
+
check_braces(spec, spec.begin, spec.end)
|
|
168
|
+
if hole := _HOLE.search(spec.end.replace("##", "")):
|
|
169
|
+
raise DeclarationError(
|
|
170
|
+
f"{spec.qualname}: the end body uses {hole.group(0)}; TeX gives it no arguments; use them in begin",
|
|
171
|
+
code="TM-M018",
|
|
172
|
+
path=spec.name,
|
|
173
|
+
)
|
|
174
|
+
begin = rewrite_body(spec, spec.begin).replace("\r\n", "\n").rstrip()
|
|
175
|
+
end = spec.end.replace("\r\n", "\n").rstrip()
|
|
176
|
+
brace = _brace(spec.syntax)
|
|
177
|
+
definition = [
|
|
178
|
+
f"\\newenvironment{{{spec.cs or prefix + spec.name}}}{signature(spec.args)}{brace}",
|
|
179
|
+
*begin.splitlines(),
|
|
180
|
+
f"}}{brace}",
|
|
181
|
+
*end.splitlines(),
|
|
182
|
+
"}",
|
|
183
|
+
]
|
|
184
|
+
lines = [*_comment(spec.doc), f"% from {spec.qualname} (body: {spec.origin})"]
|
|
185
|
+
return "\n".join([*lines, *_wrapped(spec.syntax, definition)])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def argument_text(arg: Arg, value: object, syntax: Syntax = Syntax.LATEX2E) -> str:
|
|
189
|
+
"""The TeX of one argument value, without its braces: `Text` escaped, `Raw` as it is, a hole as `#name`; in expl3 a
|
|
190
|
+
space of text is `~`."""
|
|
191
|
+
if isinstance(value, Choice):
|
|
192
|
+
value = value.value
|
|
193
|
+
if isinstance(value, Hole):
|
|
194
|
+
return f"#{value.name}"
|
|
195
|
+
if isinstance(value, Raw):
|
|
196
|
+
return value.tex
|
|
197
|
+
if arg.pdf:
|
|
198
|
+
pdf = f"\\detokenize{{{pdf_escape(str(value))}}}"
|
|
199
|
+
return pdf.replace(" ", "~") if syntax is Syntax.EXPL3 else pdf
|
|
200
|
+
return text_of(str(value), syntax)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def argument(arg: Arg, value: object, syntax: Syntax = Syntax.LATEX2E) -> str:
|
|
204
|
+
"""One call argument. `Text` is escaped, `Raw` passes through; a `]` in the optional one is wrapped (T3)."""
|
|
205
|
+
text = argument_text(arg, value, syntax)
|
|
206
|
+
if not arg.optional:
|
|
207
|
+
return f"{{{text}}}"
|
|
208
|
+
return f"[{{{text}}}]" if "]" in text else f"[{text}]"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def arguments(args: tuple[Arg, ...], values: Mapping[str, object], syntax: Syntax = Syntax.LATEX2E) -> str:
|
|
212
|
+
"""`[Sound]{A soft woof.}`: the optional argument first, and left out when it is `None`."""
|
|
213
|
+
return "".join(argument(a, values[a.name], syntax) for a in tex_arguments(args) if values[a.name] is not None)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_EXPL_SWITCH = re.compile(r"\\ExplSyntax(?:On|Off)(?![A-Za-z])")
|
|
217
|
+
_COMMENT = re.compile(r"(?<!\\)%.*")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def lint_library(lib: Library) -> None:
|
|
221
|
+
"""The static checks a library block gets, all of them (§21a 12): balanced braces, and no syntax switch inside an
|
|
222
|
+
expl3 block, which texmodel writes itself. Everything else is the compile's to find."""
|
|
223
|
+
if not balanced(lib.text):
|
|
224
|
+
raise DeclarationError(f"library {lib.name}: unbalanced braces", code="TM-M011", path=lib.name)
|
|
225
|
+
if lib.syntax is Syntax.EXPL3 and _EXPL_SWITCH.search(_COMMENT.sub("", lib.text)):
|
|
226
|
+
raise DeclarationError(
|
|
227
|
+
f"library {lib.name}: \\ExplSyntaxOn / \\ExplSyntaxOff inside an expl3 block",
|
|
228
|
+
code="TM-M034",
|
|
229
|
+
path=lib.name,
|
|
230
|
+
hint="texmodel writes them around the block; leave them out",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def render_library(lib: Library) -> str:
|
|
235
|
+
"""A block as it was given, under its provenance comment, in `\\ExplSyntaxOn` for expl3."""
|
|
236
|
+
lines = [f"% from library {lib.name} (source: {lib.origin})"]
|
|
237
|
+
return "\n".join([*lines, *_wrapped(lib.syntax, lib.text.splitlines())])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def colour_name(prefix: str, name: str) -> str:
|
|
241
|
+
"""`Ink` -> `ac-ink`, `BrandRed` -> `ac-brand-red` (T5); a different namespace from control sequences."""
|
|
242
|
+
return f"{prefix}-{snake(name).replace('_', '-')}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def bool_names(prefix: str, name: str) -> tuple[str, ...]:
|
|
246
|
+
"""The control sequences `\\newif\\if<prefix><name>` defines besides the conditional itself."""
|
|
247
|
+
return (f"{prefix}{name}true", f"{prefix}{name}false")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def setting(prefix: str, name: str, value: Length | Colour | bool | str | Raw) -> str:
|
|
251
|
+
"""The line that gives a value its content: the default in the `.sty`, a document's `settings=` in the `.tex`."""
|
|
252
|
+
control = f"{prefix}{name}"
|
|
253
|
+
if isinstance(value, Raw):
|
|
254
|
+
return f"\\renewcommand{{\\{control}}}{{{value.tex}}}"
|
|
255
|
+
if isinstance(value, str):
|
|
256
|
+
return f"\\renewcommand{{\\{control}}}{{{escape(value)}}}"
|
|
257
|
+
if isinstance(value, Length):
|
|
258
|
+
return f"\\setlength{{\\{control}}}{{{value}}}"
|
|
259
|
+
if isinstance(value, bool):
|
|
260
|
+
return f"\\{control}{'true' if value else 'false'}"
|
|
261
|
+
return f"\\definecolor{{{colour_name(prefix, name)}}}{{{value.model}}}{{{value}}}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def render_value(prefix: str, spec: ValueSpec) -> str:
|
|
265
|
+
d, control = spec.default, f"{prefix}{spec.name}"
|
|
266
|
+
if isinstance(d, Raw):
|
|
267
|
+
return "\n".join(
|
|
268
|
+
[*_comment(spec.doc), f"% from {spec.qualname} (value: Raw)", f"\\newcommand{{\\{control}}}{{{d.tex}}}"]
|
|
269
|
+
)
|
|
270
|
+
if isinstance(d, str):
|
|
271
|
+
return "\n".join(
|
|
272
|
+
[
|
|
273
|
+
*_comment(spec.doc),
|
|
274
|
+
f"% from {spec.qualname} (value: Text)",
|
|
275
|
+
f"\\newcommand{{\\{control}}}{{{escape(d)}}}",
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
d = cast("Length | Colour | bool", d) # Package.value refuses a missing default
|
|
279
|
+
decl = (
|
|
280
|
+
[f"\\newlength{{\\{control}}}"]
|
|
281
|
+
if isinstance(d, Length)
|
|
282
|
+
else [f"\\newif\\if{control}"]
|
|
283
|
+
if isinstance(d, bool)
|
|
284
|
+
else []
|
|
285
|
+
)
|
|
286
|
+
return "\n".join(
|
|
287
|
+
[*_comment(spec.doc), f"% from {spec.qualname} (value: {spec.kind})", *decl, setting(prefix, spec.name, d)]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def undeclared(prefix: str, text: str, declared: Mapping[str, str]) -> list[str]:
|
|
292
|
+
"""The `\\<prefix>Name` control words in `text` that `declared` lacks, in order; internals and other names are not
|
|
293
|
+
ours. A package with no prefix has no namespace to police."""
|
|
294
|
+
if not prefix:
|
|
295
|
+
return []
|
|
296
|
+
public = re.compile(rf"{prefix}[A-Z][A-Za-z]*")
|
|
297
|
+
words = (m["word"] for m in _TOKEN.finditer(text))
|
|
298
|
+
return [w for w in words if w and public.fullmatch(w) and w not in declared]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def check_references(prefix: str, spec: Spec | EnvSpec, text: str, declared: Mapping[str, str]) -> None:
|
|
302
|
+
"""Every `\\<prefix>Name` in a body is declared in the package (M8, G2)."""
|
|
303
|
+
if words := undeclared(prefix, text, declared):
|
|
304
|
+
raise DeclarationError(
|
|
305
|
+
f"{spec.qualname}: body uses \\{words[0]}, which no command or value of this package declares",
|
|
306
|
+
code="TM-M015",
|
|
307
|
+
path=spec.name,
|
|
308
|
+
hint="declare it, or fix the name",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def header_lines(name: str, version: str, date: str, description: str, stamp: str) -> list[str]:
|
|
313
|
+
"""The first three lines of a generated `.sty`."""
|
|
314
|
+
return [
|
|
315
|
+
f"% {name}.sty -- {stamp}. Do not edit; run `texmodel build`.",
|
|
316
|
+
"\\NeedsTeXFormat{LaTeX2e}",
|
|
317
|
+
f"\\ProvidesPackage{{{name}}}[{date} v{version} {description or name}]",
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def require_lines(requires: Sequence[tuple[str, Sequence[str]]]) -> list[str]:
|
|
322
|
+
"""The `\\RequirePackage` lines: `\\RequirePackage[table]{xcolor}`."""
|
|
323
|
+
return [f"\\RequirePackage{f'[{chr(44).join(o)}]' if o else ''}{{{n}}}" for n, o in requires]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Reading the declaration forms of a hand-written package (M9): `\\newcommand`, `\\providecommand`, `\\newenvironment`.
|
|
2
|
+
|
|
3
|
+
Only these forms are read, so a name defined any other way is reported as missing rather than guessed at.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_COMMENT = re.compile(r"(?<!\\)%[^\n]*")
|
|
9
|
+
_DEFINITION = re.compile(
|
|
10
|
+
r"\\(?:newcommand|providecommand|newenvironment)\*?\s*\{?\s*\\?(?P<name>[A-Za-z@]+)\s*\}?\s*"
|
|
11
|
+
r"(?:\[(?P<n>\d)\])?(?P<default>\[)?"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan_declarations(text: str, prefix: str = "") -> dict[str, str]:
|
|
16
|
+
"""`{"cdTalkAboutMe": "[2][]"}`: each name starting with `prefix` that `text` defines, with its signature in the
|
|
17
|
+
form `render.signature` writes (`""` for none, `[n]`, or `[n][]` when the first argument is optional). The first
|
|
18
|
+
definition of a name wins."""
|
|
19
|
+
found: dict[str, str] = {}
|
|
20
|
+
for m in _DEFINITION.finditer(_COMMENT.sub("", text)):
|
|
21
|
+
if m["name"].startswith(prefix) and m["name"] not in found:
|
|
22
|
+
found[m["name"]] = f"[{m['n']}]" + ("[]" if m["default"] else "") if m["n"] else ""
|
|
23
|
+
return found
|