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/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)."""
|