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.
Files changed (61) hide show
  1. texmodel/__init__.py +6 -0
  2. texmodel/_internal/__init__.py +1 -0
  3. texmodel/_internal/render.py +323 -0
  4. texmodel/_internal/scan.py +23 -0
  5. texmodel/body.py +364 -0
  6. texmodel/bundle.py +69 -0
  7. texmodel/cli.py +211 -0
  8. texmodel/components.py +757 -0
  9. texmodel/config.py +32 -0
  10. texmodel/drift.py +55 -0
  11. texmodel/errors.py +38 -0
  12. texmodel/package.py +482 -0
  13. texmodel/plan.py +92 -0
  14. texmodel/pointer.py +70 -0
  15. texmodel/preamble.py +110 -0
  16. texmodel/py.typed +0 -0
  17. texmodel/spec.py +235 -0
  18. texmodel/std/__init__.py +37 -0
  19. texmodel/std/adjustbox.py +19 -0
  20. texmodel/std/anyfontsize.py +7 -0
  21. texmodel/std/calc.py +7 -0
  22. texmodel/std/enumitem.py +18 -0
  23. texmodel/std/eso_pic.py +49 -0
  24. texmodel/std/etoolbox.py +127 -0
  25. texmodel/std/fontspec.py +36 -0
  26. texmodel/std/geometry.py +17 -0
  27. texmodel/std/graphicx.py +26 -0
  28. texmodel/std/hyphenat.py +7 -0
  29. texmodel/std/latex.py +516 -0
  30. texmodel/std/microtype.py +27 -0
  31. texmodel/std/pdfrender.py +17 -0
  32. texmodel/std/tcolorbox.py +43 -0
  33. texmodel/std/tikz.py +36 -0
  34. texmodel/std/xcolor.py +47 -0
  35. texmodel/testing.py +297 -0
  36. texmodel/types.py +264 -0
  37. texmodel-0.2.0.dist-info/METADATA +525 -0
  38. texmodel-0.2.0.dist-info/RECORD +61 -0
  39. texmodel-0.2.0.dist-info/WHEEL +4 -0
  40. texmodel-0.2.0.dist-info/entry_points.txt +9 -0
  41. texmodel-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. texmodel_build/__init__.py +10 -0
  43. texmodel_build/_internal/__init__.py +1 -0
  44. texmodel_build/_internal/converge.py +19 -0
  45. texmodel_build/_internal/env.py +42 -0
  46. texmodel_build/_internal/logparse.py +170 -0
  47. texmodel_build/_internal/proofpdf.py +52 -0
  48. texmodel_build/_internal/spawn.py +36 -0
  49. texmodel_build/_internal/styscan.py +37 -0
  50. texmodel_build/cli.py +349 -0
  51. texmodel_build/compiler.py +341 -0
  52. texmodel_build/config.py +58 -0
  53. texmodel_build/diagnostics.py +225 -0
  54. texmodel_build/engines.py +35 -0
  55. texmodel_build/errors.py +17 -0
  56. texmodel_build/py.typed +0 -0
  57. texmodel_build/result.py +65 -0
  58. texmodel_build/roots.py +41 -0
  59. texmodel_build/targets.py +43 -0
  60. texmodel_build/testkit.py +59 -0
  61. texmodel_build/verify.py +158 -0
texmodel/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Typed document-family models rendered to TeX."""
2
+
3
+ import logging
4
+
5
+ # A library never configures logging or prints (E5); the application decides.
6
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -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