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/testing.py ADDED
@@ -0,0 +1,297 @@
1
+ """Unit tests of generated code with stubbed dependencies (§21a 20).
2
+
3
+ Two tiers. The fast tier needs no TeX: `expand(node, stubs=[Fact])` walks the bodies the generator wrote, with the
4
+ node's field values, and returns the text and the `Call` of every stubbed component, in order. The TeX tier is
5
+ `Package.test_document(node, stub=[Fact])`: the real packages, each stub replaced by a macro that writes
6
+ `texmodel:probe:callN=<call>` to the log, which `texmodel_build.testkit.run_probes` reads back as `Call`s. Both give the
7
+ same `Call`s for the same node, so a test asserts what a component passes on, not a PDF. Layout is not covered.
8
+
9
+ ```python
10
+ calls = [c for c in expand(card, stubs=[Fact]) if isinstance(c, Call)]
11
+ assert calls == [Call("bkFact", ("First",)), Call("bkFact", ("R\\&D 50\\%",))]
12
+ ```
13
+
14
+ A call's arguments are the TeX the stub received, as `\\detokenize` shows it: a space follows each control word and
15
+ runs of spaces are one, so both tiers agree byte for byte.
16
+ """
17
+
18
+ import dataclasses
19
+ import re
20
+ import typing
21
+ from collections.abc import Mapping, Sequence
22
+
23
+ from ._internal.render import Arg, argument_text, signature, tex_arguments
24
+ from .body import HOLE, Comment, Cs, Group, Hole, Library, Lit, Ref, RefKind, Seq, TexRaw
25
+ from .components import Command, Environment, Node, Passthrough, Resolved, control_name
26
+ from .errors import DeclarationError, Violation
27
+ from .types import Raw, escape
28
+
29
+ type Component = type[Command] | type[Environment]
30
+
31
+ if typing.TYPE_CHECKING:
32
+ from .package import Package
33
+
34
+ _CONTROL_WORD = re.compile(r"\\[A-Za-z]+")
35
+ _COUNTER = r"\tmstubcalls"
36
+ _HEADS = {RefKind.CALL: "", RefKind.OPEN: "begin:", RefKind.CLOSE: "end:"}
37
+
38
+
39
+ def _groups(text: str) -> list[str]:
40
+ """The top-level `{...}` groups of `text`; a backslash escapes the next character."""
41
+ out: list[str] = []
42
+ depth, start, i = 0, 0, 0
43
+ while i < len(text):
44
+ c = text[i]
45
+ if c == "\\":
46
+ i += 1
47
+ elif c == "{":
48
+ depth += 1
49
+ start = i + 1 if depth == 1 else start
50
+ elif c == "}":
51
+ depth -= 1
52
+ if depth == 0:
53
+ out.append(text[start:i])
54
+ i += 1
55
+ return out
56
+
57
+
58
+ @dataclasses.dataclass(frozen=True)
59
+ class Call:
60
+ """A stubbed component called: its control sequence name (`cdFact`, no backslash), its arguments in TeX order (the
61
+ optional one first, empty when left out) as `\\detokenize` shows them, and whether it is a command call or the
62
+ start or end of an environment."""
63
+
64
+ name: str
65
+ args: tuple[str, ...] = ()
66
+ kind: RefKind = RefKind.CALL
67
+
68
+ def probe_value(self) -> str:
69
+ """What the TeX stub writes after `texmodel:probe:callN=`: `cdFact{a}{b}`, `begin:cdBox{a}`, `end:cdBox`."""
70
+ return _HEADS[self.kind] + self.name + "".join(f"{{{a}}}" for a in self.args)
71
+
72
+ @classmethod
73
+ def parse(cls, value: str) -> "Call":
74
+ """The `Call` a probe value stands for; braces inside an argument are balanced or backslash-escaped."""
75
+ kind = next((k for k, head in _HEADS.items() if head and value.startswith(head)), RefKind.CALL)
76
+ name, brace, rest = value.removeprefix(_HEADS[kind]).partition("{")
77
+ return cls(name, tuple(_groups(brace + rest)), kind)
78
+
79
+
80
+ def detokenized(tex: str) -> str:
81
+ """`tex` as `\\detokenize` shows a macro argument: leading spaces gone, a run of spaces one, and a space after
82
+ each control word (which also swallows the spaces that followed it)."""
83
+ out: list[str] = []
84
+ i, text = 0, tex.lstrip()
85
+ while i < len(text):
86
+ word = _CONTROL_WORD.match(text, i)
87
+ if word:
88
+ out.append(word[0] + " ")
89
+ i = word.end()
90
+ elif text[i] == "\\":
91
+ out.append(text[i : i + 2])
92
+ i += 2
93
+ else:
94
+ out.append(" " if text[i].isspace() else text[i])
95
+ i += 1
96
+ if out[-1].endswith(" "): # after a control word or a space: further spaces are skipped
97
+ while i < len(text) and text[i].isspace():
98
+ i += 1
99
+ return "".join(out)
100
+
101
+
102
+ def _args(cls: type) -> tuple[Arg, ...]:
103
+ return typing.cast("tuple[Arg, ...]", getattr(cls, "__tm_args__")) # noqa: B009
104
+
105
+
106
+ def _control(cls: type) -> str:
107
+ """The control sequence name of a registered component, or a refusal to stub what no package registered."""
108
+ if not hasattr(cls, "__tm_cs__"):
109
+ raise DeclarationError(
110
+ f"{cls.__name__}: not registered with a package, so it cannot be stubbed",
111
+ code="TM-M019",
112
+ path=cls.__name__,
113
+ hint="stub a class decorated with its package's command / environment",
114
+ )
115
+ return control_name(cls)
116
+
117
+
118
+ def _call(cls: type, values: Mapping[str, str], kind: RefKind) -> Call:
119
+ shown = () if kind is RefKind.CLOSE else tuple(detokenized(values[a.name]) for a in tex_arguments(_args(cls)))
120
+ return Call(_control(cls), shown, kind)
121
+
122
+
123
+ class _Out:
124
+ """The result under construction: text accumulates until a call splits it."""
125
+
126
+ def __init__(self) -> None:
127
+ self.items: list[Call | str] = []
128
+ self.text = ""
129
+
130
+ def write(self, text: str) -> None:
131
+ self.text += text
132
+
133
+ def call(self, call: Call) -> None:
134
+ self.flush()
135
+ self.items.append(call)
136
+
137
+ def flush(self) -> None:
138
+ if self.text:
139
+ self.items.append(self.text)
140
+ self.text = ""
141
+
142
+
143
+ def _inline(piece: object, values: Mapping[str, str], stubs: frozenset[type], out: _Out) -> None:
144
+ if isinstance(piece, Ref):
145
+ _ref(piece, values, stubs, out)
146
+ elif isinstance(piece, Lit):
147
+ out.write(escape(piece.text))
148
+ elif isinstance(piece, Raw):
149
+ out.write(piece.tex)
150
+ elif isinstance(piece, Hole):
151
+ out.write(values[piece.name])
152
+ elif isinstance(piece, Cs):
153
+ out.write(f"\\{piece.name}")
154
+ elif isinstance(piece, Group | Seq):
155
+ braces = isinstance(piece, Group)
156
+ out.write("{" if braces else "")
157
+ for inner in typing.cast("tuple[object, ...]", piece.pieces):
158
+ _inline(inner, values, stubs, out)
159
+ out.write("}" if braces else "")
160
+
161
+
162
+ def _pieces(pieces: Sequence[Resolved], values: Mapping[str, str], stubs: frozenset[type], out: _Out) -> None:
163
+ """One line per piece, as `join` writes them; a comment is no text."""
164
+ for piece in pieces:
165
+ if isinstance(piece, Comment):
166
+ continue
167
+ if isinstance(piece, TexRaw):
168
+ out.write(HOLE.sub(lambda m: "#" if m[0] == "##" else values[m["name"]], piece.body))
169
+ else:
170
+ _inline(piece, values, stubs, out)
171
+ out.write("\n")
172
+
173
+
174
+ def _body(cls: type, which: int | None, values: Mapping[str, str], stubs: frozenset[type], out: _Out) -> None:
175
+ """The body of a registered component (`which` 0 or 1 for an environment's begin and end); an adopted component
176
+ has none, so it adds nothing."""
177
+ source = getattr(cls, "__tm_inline__") # noqa: B009
178
+ found = source if which is None else source[which]
179
+ if found is not None:
180
+ _pieces(found[0], values, stubs, out)
181
+
182
+
183
+ def _ref(ref: Ref, values: Mapping[str, str], stubs: frozenset[type], out: _Out) -> None:
184
+ mine = {
185
+ a.name: "" if v is None else values[v.name] if isinstance(v, Hole) else argument_text(a, v)
186
+ for a, v in zip(_args(ref.target), ref.values, strict=True)
187
+ }
188
+ if ref.target in stubs:
189
+ out.call(_call(ref.target, mine, ref.kind))
190
+ else:
191
+ _body(ref.target, None if ref.kind is RefKind.CALL else int(ref.kind is RefKind.CLOSE), mine, stubs, out)
192
+
193
+
194
+ def _walk(node: Node, stubs: frozenset[type], out: _Out) -> None:
195
+ if isinstance(node, Comment):
196
+ return
197
+ if isinstance(node, Passthrough):
198
+ out.write(node.tex.tex + "\n")
199
+ return
200
+ cls = type(node)
201
+ values = {
202
+ a.name: "" if getattr(node, a.name) is None else argument_text(a, getattr(node, a.name)) for a in _args(cls)
203
+ }
204
+ if isinstance(node, Command):
205
+ if cls in stubs:
206
+ out.call(_call(cls, values, RefKind.CALL))
207
+ else:
208
+ _body(cls, None, values, stubs, out)
209
+ return
210
+ stubbed = cls in stubs
211
+ out.call(_call(cls, values, RefKind.OPEN)) if stubbed else _body(cls, 0, values, stubs, out)
212
+ for child in node.content:
213
+ _walk(child, stubs, out)
214
+ out.call(_call(cls, values, RefKind.CLOSE)) if stubbed else _body(cls, 1, values, stubs, out)
215
+
216
+
217
+ def expand(node: Node, stubs: Sequence[Component] = ()) -> list[Call | str]:
218
+ """What `node` makes TeX do, without TeX: its body text and the `Call` of every stubbed component, in order.
219
+
220
+ A non-stubbed component is walked into through its body, with the arguments it was called with; a stubbed one
221
+ (a class registered with a package) becomes a `Call` and its body is not read. Children of an environment are
222
+ walked in order. Deterministic and pure."""
223
+ for cls in stubs:
224
+ _control(cls) # refuses a class no package registered
225
+ out = _Out()
226
+ _walk(node, frozenset(stubs), out)
227
+ out.flush()
228
+ return out.items
229
+
230
+
231
+ def _probe(head: str, tail: str) -> str:
232
+ return rf"\global\advance{_COUNTER} by 1 \typeout{{texmodel:probe:call\the{_COUNTER}={head}{tail}}}"
233
+
234
+
235
+ def stub_tex(stubs: Sequence[Component]) -> str:
236
+ """The TeX that replaces each stub by a macro writing `texmodel:probe:callN=<Call.probe_value()>`, N counting every
237
+ call in the document, so the log keeps their order. Meant for `PreambleSlots.after_package`."""
238
+ lines = [rf"\newcount{_COUNTER}", r"\AtEndDocument{\null}"] # the document needs a page to be compiled
239
+ for cls in stubs:
240
+ name, args = _control(cls), _args(cls)
241
+ shown = "".join(f"{{#{i}}}" for i in range(1, len(args) + 1))
242
+ arg_part = rf"\detokenize{{{shown}}}" if shown else ""
243
+ if issubclass(cls, Environment):
244
+ lines.append(rf"\providecommand{{\{name}}}{{}}\providecommand{{\end{name}}}{{}}")
245
+ lines.append(
246
+ rf"\renewenvironment{{{name}}}{signature(args)}{{{_probe(f'begin:{name}', arg_part)}}}"
247
+ rf"{{{_probe(f'end:{name}', '')}}}"
248
+ )
249
+ else:
250
+ lines.append(rf"\providecommand{{\{name}}}{{}}")
251
+ lines.append(rf"\renewcommand{{\{name}}}{signature(args)}{{{_probe(name, arg_part)}}}")
252
+ return "\n".join(lines)
253
+
254
+
255
+ def _defined(package: "Package") -> list[tuple[Component, Library]]:
256
+ """The registered components a library block defines, with the block."""
257
+ return [
258
+ (c, c.defined_by) for c in typing.cast("tuple[Component, ...]", package.registered) if c.defined_by is not None
259
+ ]
260
+
261
+
262
+ _EXPORT = (
263
+ r"\newcommand\tmexport[1]{\ifcsname #1\endcsname\typeout{texmodel:probe:def.#1=yes}"
264
+ r"\edef\tmexportmeaning{\expandafter\meaning\csname #1\endcsname}"
265
+ r'\directlua{local m=[==[\tmexportmeaning]==]; local _,e=m:find("macro:",1,true); local n="?"; '
266
+ r'if e then local t=m:find("->",e,true); local _,k=m:sub(e+1,t-1):gsub(string.char(35),""); n=k end; '
267
+ r'texio.write_nl("texmodel:probe:arity.#1="..n)}'
268
+ r"\else\typeout{texmodel:probe:def.#1=no}\fi}"
269
+ )
270
+
271
+
272
+ def export_probes(package: "Package") -> str:
273
+ """TeX for `PreambleSlots.after_package` that writes, per component a library block defines,
274
+ `texmodel:probe:def.<cs>` (`yes` or `no`) and `texmodel:probe:arity.<cs>` (the `#` parameters of its
275
+ `\\meaning`, `?` when that is not a macro's), for `check_exports` (§21a 12). Needs LuaTeX."""
276
+ calls = [rf"\tmexport{{{control_name(c)}}}" for c, _ in _defined(package)]
277
+ return "\n".join([_EXPORT, *calls]) if calls else ""
278
+
279
+
280
+ def check_exports(package: "Package", probes: Mapping[str, str]) -> list[Violation]:
281
+ """The components a library block defines against what the compile found (`TM-C010` not defined, `TM-C011` another
282
+ number of arguments: an optional first one counts, and `?` checks existence only). `probes` are the `name=value`
283
+ pairs of the `texmodel:probe:` lines."""
284
+ out: list[Violation] = []
285
+ for cls, lib in _defined(package):
286
+ control, want = control_name(cls), str(len(cls.__tm_args__))
287
+ got = probes.get(f"arity.{control}", "?")
288
+ if probes.get(f"def.{control}") != "yes":
289
+ message = f"\\{control} is declared as defined by library {lib.name}, but the compile does not define it"
290
+ out.append(Violation("TM-C010", cls.__name__, message))
291
+ elif got not in ("?", want):
292
+ message = f"\\{control}: declared {want} argument(s), the compile defines {got}"
293
+ out.append(Violation("TM-C011", cls.__name__, message, hint="change the declaration or the block"))
294
+ return out
295
+
296
+
297
+ __all__ = ["Call", "check_exports", "detokenized", "expand", "export_probes", "stub_tex"]
texmodel/types.py ADDED
@@ -0,0 +1,264 @@
1
+ """Field types (T1): the closed vocabulary a component field may use.
2
+
3
+ `Text` is plain `str` to a type checker, so `Fact("A soft woof.")` type-checks, and a marker at runtime, so the renderer
4
+ and (later) the escaper know the field holds untrusted text that must be escaped on render (T3).
5
+ """
6
+
7
+ import dataclasses
8
+ import enum
9
+ import math
10
+ import re
11
+ from decimal import Decimal
12
+ from fractions import Fraction
13
+ from typing import TYPE_CHECKING, Annotated, ClassVar, Final
14
+
15
+ from .errors import DeclarationError, ValidationError
16
+
17
+ if TYPE_CHECKING:
18
+ from pydantic_core import CoreSchema
19
+
20
+
21
+ class _TextMarker:
22
+ def __repr__(self) -> str:
23
+ return "Text"
24
+
25
+
26
+ TEXT: Final = _TextMarker()
27
+ Text = Annotated[str, TEXT]
28
+
29
+
30
+ class _PdfMarker:
31
+ def __repr__(self) -> str:
32
+ return "PdfString"
33
+
34
+
35
+ PDF: Final = _PdfMarker()
36
+ PdfString = Annotated[str, PDF] # text for the inside of a PDF literal string, see `pdf_escape`
37
+
38
+ _ESCAPES = str.maketrans(
39
+ {
40
+ "\\": r"\textbackslash{}",
41
+ "&": r"\&",
42
+ "%": r"\%",
43
+ "$": r"\$",
44
+ "#": r"\#",
45
+ "_": r"\_",
46
+ "{": r"\{",
47
+ "}": r"\}",
48
+ "~": r"\textasciitilde{}",
49
+ "^": r"\textasciicircum{}",
50
+ }
51
+ )
52
+
53
+
54
+ def escape(text: str) -> str:
55
+ """`Text` on render (T3): every TeX special becomes its literal; one pass, so output is never re-escaped."""
56
+ return text.translate(_ESCAPES)
57
+
58
+
59
+ _PDF_SAFE = re.compile(r"[\\(){}%#&$_^~\x00-\x1f\x7f]")
60
+
61
+
62
+ def pdf_escape(text: str) -> str:
63
+ """`PdfString` on render (§21a 9): the body of a PDF literal string that cannot end early and that a reader decodes
64
+ to `text`.
65
+
66
+ A character that opens or ends a string (`\\ ( )`), that TeX reads as markup (`% # & $ _ ^ ~ { }`) or that is a
67
+ control character becomes a three-digit octal escape (`\\050`); so does a space after a space, since TeX reads a
68
+ run of spaces as one. Text with a non-ASCII character is written as UTF-16BE with a byte order mark, every byte an
69
+ octal escape: a PDF text string is PDFDocEncoding or UTF-16BE, never UTF-8. A lone surrogate raises
70
+ `UnicodeEncodeError`. The result is emitted inside `\\detokenize{}`."""
71
+ if not text.isascii():
72
+ return "".join(f"\\{b:03o}" for b in ("\ufeff" + text).encode("utf-16-be"))
73
+ out = _PDF_SAFE.sub(lambda m: f"\\{ord(m[0]):03o}", text)
74
+ return re.sub(r"(?<= ) ", r"\\040", out)
75
+
76
+
77
+ def balanced(tex: str) -> bool:
78
+ """Braces balance, ignoring escaped ones and comments: unbalanced TeX would swallow what follows it."""
79
+ depth = 0
80
+ i = 0
81
+ while i < len(tex):
82
+ c = tex[i]
83
+ if c == "\\":
84
+ i += 1 # the escaped character is not a brace
85
+ elif c == "%":
86
+ while i < len(tex) and tex[i] != "\n":
87
+ i += 1
88
+ elif c == "{":
89
+ depth += 1
90
+ elif c == "}":
91
+ depth -= 1
92
+ if depth < 0:
93
+ return False
94
+ i += 1
95
+ return depth == 0
96
+
97
+
98
+ @dataclasses.dataclass(frozen=True)
99
+ class Raw:
100
+ """TeX passed through unescaped, only where a field is declared `Raw` (T3). A distinct class, never a `str`."""
101
+
102
+ tex: str
103
+
104
+ def __post_init__(self) -> None:
105
+ if not balanced(self.tex):
106
+ raise ValidationError("Raw: unbalanced braces", code="TM-C007", hint="close every { and } in the TeX")
107
+
108
+ @classmethod
109
+ def __get_pydantic_core_schema__(cls, source: object, handler: object) -> CoreSchema:
110
+ """Data gives `Raw` as a string (S, D2); a `Raw` passes as it is; unbalanced braces are a data error."""
111
+ from pydantic_core import core_schema as cs
112
+
113
+ def make(value: object) -> Raw:
114
+ if isinstance(value, Raw):
115
+ return value
116
+ if not isinstance(value, str):
117
+ raise ValueError("expected TeX as a string") # noqa: TRY004 # pydantic reports ValueError, not TypeError
118
+ try:
119
+ return Raw(value)
120
+ except ValidationError as exc:
121
+ raise ValueError(str(exc)) from exc
122
+
123
+ return cs.json_or_python_schema(
124
+ json_schema=cs.chain_schema([cs.str_schema(), cs.no_info_plain_validator_function(make)]),
125
+ python_schema=cs.no_info_plain_validator_function(make),
126
+ serialization=cs.plain_serializer_function_ser_schema(lambda raw: raw.tex),
127
+ )
128
+
129
+
130
+ class Choice(enum.Enum):
131
+ """A closed set a field takes (T1): subclass it, decorate the subclass with `@enum.unique`, and give each member the
132
+ TeX it stands for, a `Raw` (passes through) or a `str` (escaped as text). Data names a member by its name."""
133
+
134
+ def __repr__(self) -> str:
135
+ return f"{type(self).__name__}.{self.name}"
136
+
137
+ @classmethod
138
+ def __get_pydantic_core_schema__(cls, source: object, handler: object) -> CoreSchema:
139
+ from pydantic_core import core_schema as cs
140
+
141
+ names = list(cls.__members__)
142
+
143
+ def make(value: object) -> Choice:
144
+ if isinstance(value, cls):
145
+ return value
146
+ if isinstance(value, str) and value in names:
147
+ return cls[value]
148
+ raise ValueError(f"expected one of {names}")
149
+
150
+ return cs.json_or_python_schema(
151
+ json_schema=cs.chain_schema([cs.literal_schema(names), cs.no_info_plain_validator_function(make)]),
152
+ python_schema=cs.no_info_plain_validator_function(make),
153
+ serialization=cs.plain_serializer_function_ser_schema(lambda member: member.name),
154
+ )
155
+
156
+
157
+ # --- Values (T2, T4, T5): what a `Value[T]` holds. Immutable, validated on construction, stable to format. ---
158
+
159
+ _NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)"
160
+ _LENGTH = re.compile(rf"(?P<n>{_NUMBER})(?P<u>[a-z]+)")
161
+ _INCHES_PER = { # None: relative to the font, no fixed size
162
+ "in": Fraction(1),
163
+ "mm": Fraction(10, 254),
164
+ "cm": Fraction(100, 254),
165
+ "pt": Fraction(100, 7227), # TeX points: 72.27 to the inch
166
+ "bp": Fraction(1, 72), # big (PostScript) points: 72 to the inch
167
+ "pc": Fraction(1200, 7227),
168
+ "em": None,
169
+ "ex": None,
170
+ }
171
+
172
+
173
+ def _plain(d: Decimal) -> str:
174
+ """No exponent, no trailing zeros, no `-0`: `2.0` -> `2`, `1e-05` -> `0.00001`."""
175
+ s = format(d, "f")
176
+ if "." in s:
177
+ s = s.rstrip("0").rstrip(".")
178
+ return "0" if s in ("-0", "") else s
179
+
180
+
181
+ def _decimal(x: float) -> Decimal:
182
+ return Decimal(repr(float(x)))
183
+
184
+
185
+ @dataclasses.dataclass(frozen=True, init=False)
186
+ class Length:
187
+ """A TeX dimension: `Length("2mm")`, `Length(1.5, "pt")`. Units are kept as written, never converted."""
188
+
189
+ value: Decimal
190
+ unit: str
191
+
192
+ def __init__(self, value: str | float, unit: str | None = None) -> None:
193
+ hint = 'write a number and a unit (mm cm in pt bp pc em ex), e.g. Length("2mm")'
194
+ if unit is None:
195
+ m = _LENGTH.fullmatch(value) if isinstance(value, str) else None
196
+ if not m:
197
+ raise DeclarationError(f"Length({value!r}): not a number with a unit", code="TM-M016", hint=hint)
198
+ value, unit = m["n"], m["u"]
199
+ if unit not in _INCHES_PER:
200
+ raise DeclarationError(f"Length: unknown unit {unit!r}", code="TM-M016", hint=hint)
201
+ if isinstance(value, str):
202
+ if not re.fullmatch(_NUMBER, value):
203
+ raise DeclarationError(f"Length({value!r}): not a number", code="TM-M016", hint=hint)
204
+ number = Decimal(value)
205
+ elif math.isfinite(value):
206
+ number = _decimal(value)
207
+ else:
208
+ raise DeclarationError(f"Length({value!r}): not finite", code="TM-M016", hint=hint)
209
+ object.__setattr__(self, "value", number)
210
+ object.__setattr__(self, "unit", unit)
211
+
212
+ def __str__(self) -> str:
213
+ return f"{_plain(self.value)}{self.unit}"
214
+
215
+ @property
216
+ def inches(self) -> Fraction:
217
+ """Exact size in inches (T4); `em` and `ex` depend on the font and have none."""
218
+ per = _INCHES_PER[self.unit]
219
+ if per is None:
220
+ raise DeclarationError(f"{self}: {self.unit} is relative to the font", code="TM-M016")
221
+ return Fraction(self.value) * per
222
+
223
+
224
+ class _Colour:
225
+ """Components in [0, 1], refused outside it; `str()` is what `\\definecolor` takes."""
226
+
227
+ model: ClassVar[str]
228
+
229
+ def __post_init__(self) -> None:
230
+ for name, v in vars(self).items():
231
+ if not 0 <= v <= 1: # also false for nan
232
+ raise DeclarationError(
233
+ f"{type(self).__name__}.{name}={v!r}: components are in [0, 1]", code="TM-M017", path=name
234
+ )
235
+
236
+ def __str__(self) -> str:
237
+ return ",".join(_plain(_decimal(v)) for v in vars(self).values())
238
+
239
+
240
+ @dataclasses.dataclass(frozen=True)
241
+ class CMYK(_Colour):
242
+ c: float
243
+ m: float
244
+ y: float
245
+ k: float
246
+ model: ClassVar[str] = "cmyk"
247
+
248
+
249
+ @dataclasses.dataclass(frozen=True)
250
+ class RGB(_Colour):
251
+ r: float
252
+ g: float
253
+ b: float
254
+ model: ClassVar[str] = "rgb"
255
+
256
+
257
+ @dataclasses.dataclass(frozen=True)
258
+ class Gray(_Colour):
259
+ g: float
260
+ model: ClassVar[str] = "gray"
261
+
262
+
263
+ Colour = CMYK | RGB | Gray
264
+ """One colour model each; there is no conversion between them (T5)."""