texmodel 0.2.0__tar.gz

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 (167) hide show
  1. texmodel-0.2.0/LICENSE +21 -0
  2. texmodel-0.2.0/PKG-INFO +525 -0
  3. texmodel-0.2.0/README.md +506 -0
  4. texmodel-0.2.0/pyproject.toml +217 -0
  5. texmodel-0.2.0/src/texmodel/__init__.py +6 -0
  6. texmodel-0.2.0/src/texmodel/_internal/__init__.py +1 -0
  7. texmodel-0.2.0/src/texmodel/_internal/render.py +323 -0
  8. texmodel-0.2.0/src/texmodel/_internal/scan.py +23 -0
  9. texmodel-0.2.0/src/texmodel/body.py +364 -0
  10. texmodel-0.2.0/src/texmodel/bundle.py +69 -0
  11. texmodel-0.2.0/src/texmodel/cli.py +211 -0
  12. texmodel-0.2.0/src/texmodel/components.py +757 -0
  13. texmodel-0.2.0/src/texmodel/config.py +32 -0
  14. texmodel-0.2.0/src/texmodel/drift.py +55 -0
  15. texmodel-0.2.0/src/texmodel/errors.py +38 -0
  16. texmodel-0.2.0/src/texmodel/package.py +482 -0
  17. texmodel-0.2.0/src/texmodel/plan.py +92 -0
  18. texmodel-0.2.0/src/texmodel/pointer.py +70 -0
  19. texmodel-0.2.0/src/texmodel/preamble.py +110 -0
  20. texmodel-0.2.0/src/texmodel/py.typed +0 -0
  21. texmodel-0.2.0/src/texmodel/spec.py +235 -0
  22. texmodel-0.2.0/src/texmodel/std/__init__.py +37 -0
  23. texmodel-0.2.0/src/texmodel/std/adjustbox.py +19 -0
  24. texmodel-0.2.0/src/texmodel/std/anyfontsize.py +7 -0
  25. texmodel-0.2.0/src/texmodel/std/calc.py +7 -0
  26. texmodel-0.2.0/src/texmodel/std/enumitem.py +18 -0
  27. texmodel-0.2.0/src/texmodel/std/eso_pic.py +49 -0
  28. texmodel-0.2.0/src/texmodel/std/etoolbox.py +127 -0
  29. texmodel-0.2.0/src/texmodel/std/fontspec.py +36 -0
  30. texmodel-0.2.0/src/texmodel/std/geometry.py +17 -0
  31. texmodel-0.2.0/src/texmodel/std/graphicx.py +26 -0
  32. texmodel-0.2.0/src/texmodel/std/hyphenat.py +7 -0
  33. texmodel-0.2.0/src/texmodel/std/latex.py +516 -0
  34. texmodel-0.2.0/src/texmodel/std/microtype.py +27 -0
  35. texmodel-0.2.0/src/texmodel/std/pdfrender.py +17 -0
  36. texmodel-0.2.0/src/texmodel/std/tcolorbox.py +43 -0
  37. texmodel-0.2.0/src/texmodel/std/tikz.py +36 -0
  38. texmodel-0.2.0/src/texmodel/std/xcolor.py +47 -0
  39. texmodel-0.2.0/src/texmodel/testing.py +297 -0
  40. texmodel-0.2.0/src/texmodel/types.py +264 -0
  41. texmodel-0.2.0/src/texmodel_build/__init__.py +10 -0
  42. texmodel-0.2.0/src/texmodel_build/_internal/__init__.py +1 -0
  43. texmodel-0.2.0/src/texmodel_build/_internal/converge.py +19 -0
  44. texmodel-0.2.0/src/texmodel_build/_internal/env.py +42 -0
  45. texmodel-0.2.0/src/texmodel_build/_internal/logparse.py +170 -0
  46. texmodel-0.2.0/src/texmodel_build/_internal/proofpdf.py +52 -0
  47. texmodel-0.2.0/src/texmodel_build/_internal/spawn.py +36 -0
  48. texmodel-0.2.0/src/texmodel_build/_internal/styscan.py +37 -0
  49. texmodel-0.2.0/src/texmodel_build/cli.py +349 -0
  50. texmodel-0.2.0/src/texmodel_build/compiler.py +341 -0
  51. texmodel-0.2.0/src/texmodel_build/config.py +58 -0
  52. texmodel-0.2.0/src/texmodel_build/diagnostics.py +225 -0
  53. texmodel-0.2.0/src/texmodel_build/engines.py +35 -0
  54. texmodel-0.2.0/src/texmodel_build/errors.py +17 -0
  55. texmodel-0.2.0/src/texmodel_build/py.typed +0 -0
  56. texmodel-0.2.0/src/texmodel_build/result.py +65 -0
  57. texmodel-0.2.0/src/texmodel_build/roots.py +41 -0
  58. texmodel-0.2.0/src/texmodel_build/targets.py +43 -0
  59. texmodel-0.2.0/src/texmodel_build/testkit.py +59 -0
  60. texmodel-0.2.0/src/texmodel_build/verify.py +158 -0
  61. texmodel-0.2.0/tests/consumers/card_deck/__init__.py +1 -0
  62. texmodel-0.2.0/tests/consumers/card_deck/cards.toml +77 -0
  63. texmodel-0.2.0/tests/consumers/card_deck/convert.py +74 -0
  64. texmodel-0.2.0/tests/consumers/card_deck/fit.py +50 -0
  65. texmodel-0.2.0/tests/consumers/card_deck/legacy.py +83 -0
  66. texmodel-0.2.0/tests/consumers/card_deck/model.py +97 -0
  67. texmodel-0.2.0/tests/consumers/card_deck/old/card-deck.sty +31 -0
  68. texmodel-0.2.0/tests/consumers/card_deck/project.toml +3 -0
  69. texmodel-0.2.0/tests/consumers/card_deck/res/card-deck.sty +30 -0
  70. texmodel-0.2.0/tests/examples/README.md +16 -0
  71. texmodel-0.2.0/tests/examples/business_card/__init__.py +105 -0
  72. texmodel-0.2.0/tests/examples/business_card/card.begin.tex +1 -0
  73. texmodel-0.2.0/tests/examples/business_card/card.end.tex +1 -0
  74. texmodel-0.2.0/tests/examples/business_card/contact.tex +2 -0
  75. texmodel-0.2.0/tests/examples/business_card/expected/ada.tex +11 -0
  76. texmodel-0.2.0/tests/examples/business_card/expected/business-card.sty +55 -0
  77. texmodel-0.2.0/tests/examples/business_card/expected/compact.tex +12 -0
  78. texmodel-0.2.0/tests/examples/business_card/name.tex +1 -0
  79. texmodel-0.2.0/tests/examples/composed/__init__.py +67 -0
  80. texmodel-0.2.0/tests/examples/composed/expected/base-kit.sty +17 -0
  81. texmodel-0.2.0/tests/examples/composed/expected/composed-kit.sty +21 -0
  82. texmodel-0.2.0/tests/examples/composed/expected/composed.tex +9 -0
  83. texmodel-0.2.0/tests/examples/expl3/__init__.py +49 -0
  84. texmodel-0.2.0/tests/examples/expl3/expected/expl3-kit.sty +20 -0
  85. texmodel-0.2.0/tests/examples/expl3/expected/expl3.tex +6 -0
  86. texmodel-0.2.0/tests/examples/hello/__init__.py +20 -0
  87. texmodel-0.2.0/tests/examples/hello/expected/hello.sty +9 -0
  88. texmodel-0.2.0/tests/examples/hello/expected/hello.tex +7 -0
  89. texmodel-0.2.0/tests/examples/letter/__init__.py +97 -0
  90. texmodel-0.2.0/tests/examples/letter/expected/invitation.tex +15 -0
  91. texmodel-0.2.0/tests/examples/letter/expected/letter-kit.sty +56 -0
  92. texmodel-0.2.0/tests/examples/letter/expected/terse.tex +9 -0
  93. texmodel-0.2.0/tests/examples/letter/letter.begin.tex +3 -0
  94. texmodel-0.2.0/tests/examples/letter/letter.end.tex +1 -0
  95. texmodel-0.2.0/tests/fixtures/acme_cards/__init__.py +74 -0
  96. texmodel-0.2.0/tests/fixtures/acme_cards/card_back.begin.tex +1 -0
  97. texmodel-0.2.0/tests/fixtures/acme_cards/card_back.end.tex +1 -0
  98. texmodel-0.2.0/tests/fixtures/acme_cards/fact.tex +2 -0
  99. texmodel-0.2.0/tests/fixtures/acme_cards/generated/acme-cards.sty +54 -0
  100. texmodel-0.2.0/tests/fixtures/acme_cards/name.tex +1 -0
  101. texmodel-0.2.0/tests/fixtures/acme_cards/slot.tex +3 -0
  102. texmodel-0.2.0/tests/fixtures/logs/diagnostics.latexmk.txt +83 -0
  103. texmodel-0.2.0/tests/fixtures/logs/diagnostics.log +246 -0
  104. texmodel-0.2.0/tests/fixtures/logs/miss.log +100 -0
  105. texmodel-0.2.0/tests/fixtures/logs/ok.log +182 -0
  106. texmodel-0.2.0/tests/fixtures/logs/overset.log +3 -0
  107. texmodel-0.2.0/tests/fixtures/logs/pkg.log +309 -0
  108. texmodel-0.2.0/tests/fixtures/logs/sty.log +78 -0
  109. texmodel-0.2.0/tests/fixtures/logs/undefined_in_argument.log +4 -0
  110. texmodel-0.2.0/tests/fixtures/tex/bad.tex +9 -0
  111. texmodel-0.2.0/tests/fixtures/tex/bad_font.tex +4 -0
  112. texmodel-0.2.0/tests/fixtures/tex/missing_file.tex +4 -0
  113. texmodel-0.2.0/tests/fixtures/tex/missing_package.tex +3 -0
  114. texmodel-0.2.0/tests/fixtures/tex/ok.tex +5 -0
  115. texmodel-0.2.0/tests/fixtures/tex/shell_escape.tex +5 -0
  116. texmodel-0.2.0/tests/fixtures/tex/unstable.tex +12 -0
  117. texmodel-0.2.0/tests/fixtures/tex/uses_acme.tex +9 -0
  118. texmodel-0.2.0/tests/fixtures/tex/uses_values.tex +8 -0
  119. texmodel-0.2.0/tests/test_consumers/__init__.py +0 -0
  120. texmodel-0.2.0/tests/test_consumers/test_card_deck.py +192 -0
  121. texmodel-0.2.0/tests/test_consumers/test_card_deck_fit.py +42 -0
  122. texmodel-0.2.0/tests/test_consumers/test_card_deck_tex.py +75 -0
  123. texmodel-0.2.0/tests/test_std/__init__.py +0 -0
  124. texmodel-0.2.0/tests/test_std/test_headers.py +384 -0
  125. texmodel-0.2.0/tests/test_texmodel/__init__.py +0 -0
  126. texmodel-0.2.0/tests/test_texmodel/block.tex +1 -0
  127. texmodel-0.2.0/tests/test_texmodel/conftest.py +14 -0
  128. texmodel-0.2.0/tests/test_texmodel/test_adopted.py +201 -0
  129. texmodel-0.2.0/tests/test_texmodel/test_body.py +96 -0
  130. texmodel-0.2.0/tests/test_texmodel/test_build_check.py +183 -0
  131. texmodel-0.2.0/tests/test_texmodel/test_bundle_drift.py +72 -0
  132. texmodel-0.2.0/tests/test_texmodel/test_check_documents.py +115 -0
  133. texmodel-0.2.0/tests/test_texmodel/test_choice.py +96 -0
  134. texmodel-0.2.0/tests/test_texmodel/test_comment.py +207 -0
  135. texmodel-0.2.0/tests/test_texmodel/test_cs.py +251 -0
  136. texmodel-0.2.0/tests/test_texmodel/test_document.py +385 -0
  137. texmodel-0.2.0/tests/test_texmodel/test_emitter.py +166 -0
  138. texmodel-0.2.0/tests/test_texmodel/test_errors_config_cli.py +49 -0
  139. texmodel-0.2.0/tests/test_texmodel/test_examples.py +43 -0
  140. texmodel-0.2.0/tests/test_texmodel/test_expl3.py +482 -0
  141. texmodel-0.2.0/tests/test_texmodel/test_expl3_tex.py +152 -0
  142. texmodel-0.2.0/tests/test_texmodel/test_grammar_hatches.py +109 -0
  143. texmodel-0.2.0/tests/test_texmodel/test_import.py +11 -0
  144. texmodel-0.2.0/tests/test_texmodel/test_pdf_string.py +220 -0
  145. texmodel-0.2.0/tests/test_texmodel/test_plan.py +91 -0
  146. texmodel-0.2.0/tests/test_texmodel/test_preamble.py +69 -0
  147. texmodel-0.2.0/tests/test_texmodel/test_public_api.py +30 -0
  148. texmodel-0.2.0/tests/test_texmodel/test_refs.py +255 -0
  149. texmodel-0.2.0/tests/test_texmodel/test_render.py +375 -0
  150. texmodel-0.2.0/tests/test_texmodel/test_schema.py +111 -0
  151. texmodel-0.2.0/tests/test_texmodel/test_spec.py +132 -0
  152. texmodel-0.2.0/tests/test_texmodel/test_stubs.py +242 -0
  153. texmodel-0.2.0/tests/test_texmodel/test_text_values.py +115 -0
  154. texmodel-0.2.0/tests/test_texmodel/test_types.py +94 -0
  155. texmodel-0.2.0/tests/test_texmodel/test_values.py +266 -0
  156. texmodel-0.2.0/tests/test_texmodel_build/__init__.py +0 -0
  157. texmodel-0.2.0/tests/test_texmodel_build/conftest.py +83 -0
  158. texmodel-0.2.0/tests/test_texmodel_build/test_cli.py +198 -0
  159. texmodel-0.2.0/tests/test_texmodel_build/test_compile_tex.py +204 -0
  160. texmodel-0.2.0/tests/test_texmodel_build/test_compiler.py +298 -0
  161. texmodel-0.2.0/tests/test_texmodel_build/test_config_doctor.py +95 -0
  162. texmodel-0.2.0/tests/test_texmodel_build/test_diagnostics_logparse.py +158 -0
  163. texmodel-0.2.0/tests/test_texmodel_build/test_doctor_sty.py +104 -0
  164. texmodel-0.2.0/tests/test_texmodel_build/test_proofpdf.py +67 -0
  165. texmodel-0.2.0/tests/test_texmodel_build/test_public_api.py +14 -0
  166. texmodel-0.2.0/tests/test_texmodel_build/test_support.py +55 -0
  167. texmodel-0.2.0/tests/test_texmodel_build/test_verify.py +159 -0
texmodel-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ilja Orlovs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,525 @@
1
+ Metadata-Version: 2.4
2
+ Name: texmodel
3
+ Version: 0.2.0
4
+ Summary: Typed document-family models rendered to TeX, with an optional latexmk driver and verifier plugins.
5
+ Author-Email: Ilja Orlovs <ilja@release.art>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3.15
10
+ Classifier: Typing :: Typed
11
+ Project-URL: Homepage, https://github.com/release-art/texmodel
12
+ Project-URL: Repository, https://github.com/release-art/texmodel
13
+ Requires-Python: <3.16,>=3.15
14
+ Requires-Dist: typer>=0.27.3
15
+ Requires-Dist: pydantic>=2.14.1
16
+ Provides-Extra: proof-pdf
17
+ Requires-Dist: proof-pdf>=0.3.3; extra == "proof-pdf"
18
+ Description-Content-Type: text/markdown
19
+
20
+ # texmodel
21
+
22
+ [![CI](https://github.com/release-art/texmodel/actions/workflows/ci.yml/badge.svg)](https://github.com/release-art/texmodel/actions/workflows/ci.yml)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
24
+
25
+ Declare a LaTeX document family once in Python types; get plain, readable, git-trackable `.sty` source out of it. The output
26
+ is ordinary TeX: commit it, diff it, compile it with whatever LaTeX setup you already have.
27
+
28
+ > **Status: 0.1.0, alpha.** The public API may change before 1.0. Release migrations, `texmodel-print` and the trust
29
+ > and sandbox layers are planned, not built (see [the plan](docs/texmodel-plan.md)).
30
+
31
+ ```zsh
32
+ pip install texmodel # the library and the texmodel-build command
33
+ pip install 'texmodel[proof-pdf]' # plus the proof-pdf verifier
34
+ ```
35
+
36
+ One distribution, two packages:
37
+
38
+ | Package | What it is |
39
+ |---|---|
40
+ | **`texmodel`** (`src/`) | The library: declare components, render deterministic TeX, check that committed output is current. No TeX needed, no processes spawned. |
41
+ | **`texmodel_build`** (command `texmodel-build`) | Optional: a thin `latexmk` driver with typed log diagnostics, and verifier plugins for built PDFs. Use it if you want compile results as data; your own `latexmk` or image works just as well. |
42
+
43
+ ## texmodel
44
+
45
+ ```python
46
+ # acme_cards/__init__.py
47
+ from texmodel.body import TemplateRef
48
+ from texmodel.components import Command, Environment, Value
49
+ from texmodel.package import Package
50
+ from texmodel.types import CMYK, Colour, Length, Text
51
+
52
+ pkg = Package("acme-cards", prefix="ac", version="1.4.0", date="2026/10/21")
53
+
54
+ @pkg.value
55
+ class FactGap(Value[Length]):
56
+ """Space under each fact."""
57
+ default = Length("2mm")
58
+
59
+ @pkg.value
60
+ class Ink(Value[Colour]):
61
+ """The text colour."""
62
+ default = CMYK(0, 0, 0, 0.95)
63
+
64
+ @pkg.value
65
+ class Compact(Value[bool]):
66
+ """Tighten the layout."""
67
+ default = False
68
+
69
+ @pkg.command
70
+ class Fact(Command):
71
+ """One short sentence under the title."""
72
+ text: Text
73
+ body = TemplateRef("fact.tex") # explicit: a file next to this module; nothing is found by name
74
+
75
+ @pkg.command
76
+ class Slot(Command):
77
+ """A labelled paragraph; the label prints as its heading."""
78
+ text: Text
79
+ label: Text | None = None # optional: keyword-only, TeX's one optional argument
80
+
81
+ @pkg.environment
82
+ class CardBack(Environment):
83
+ """The back face of a card."""
84
+ children = [Name, Fact.times(2, 3), Slot.times(3)] # in order: Name once, Fact 2 to 3, Slot 0 to 3; also X.times.any(), X.times.at_least_one()
85
+ begin = r"\begin{minipage}{\linewidth}"
86
+ end = r"\end{minipage}"
87
+ ```
88
+
89
+ A body is TeX you point at: a `TemplateRef("fact.tex")` (relative to the defining module), a string, or a `body`
90
+ method returning either (dedented, so a raw triple-quoted string reads as it renders). `#text` is the argument named `text`:
91
+
92
+ ```latex
93
+ \par\noindent{\color{ac-ink}#text}\par\vspace{\acFactGap}
94
+ \ifacCompact\vspace{-\acFactGap}\fi
95
+ ```
96
+
97
+ A `Value[T]` needs no body: its type decides the declaration (`Length` -> `\newlength` + `\setlength`, `Colour` ->
98
+ `\definecolor` named `ac-ink`, `Bool` -> `\newif`), and its `default` is an instance of `T`. Packages a value needs
99
+ (`xcolor` for colours) are `\RequirePackage`d once, sorted. A body that names an undeclared `\acThing` is refused when
100
+ `render()` / `texmodel check` runs (not at registration, since a body may name a component declared after it).
101
+
102
+ A body may also be a `TexRaw` or a sequence or generator of pieces (strings, `TemplateRef`s, `TexRaw`s), each with its own
103
+ `Whitespace` policy: `TRIM` writes the `%` at line ends for you, `KEEP` (the default, set per package with
104
+ `Package(whitespace=)`) writes lines as given.
105
+
106
+ ```python
107
+ from texmodel.body import TexRaw, Whitespace
108
+
109
+ def body(self):
110
+ yield TexRaw(r"""
111
+ \par\noindent
112
+ {\color{ac-ink}#text}
113
+ """, whitespace=Whitespace.TRIM) # \par\noindent% {\color{ac-ink}#1}%
114
+ yield r"\par"
115
+ ```
116
+
117
+ A body can call another component by Python binding. The call is checked against the target's arguments, written with the
118
+ target's prefix, and a target in another package makes that package a `\RequirePackage` of the generated `.sty`:
119
+
120
+ ```python
121
+ from texmodel.body import Hole
122
+
123
+ class Entry(Command):
124
+ """A fact with a rule above it."""
125
+ text: Text
126
+
127
+ def body(self):
128
+ yield r"\par\hrule"
129
+ yield Fact.call(Hole("text")) # \bkFact{#1}; Fact belongs to base-kit, so \RequirePackage{base-kit}
130
+
131
+ class Section(Environment):
132
+ """A titled panel."""
133
+ begin = Panel.open(title=Hole("title")) # \begin{bkPanel}{#1}
134
+ end = Panel.close() # \end{bkPanel}
135
+ title: Text
136
+ ```
137
+
138
+ `Fact.call(...)` takes the required arguments positionally and the optional one by keyword (`Slot.call(Hole("t"), label="x")`);
139
+ an environment's arguments are all keywords. A target must be registered before the body that calls it.
140
+
141
+ An environment has `begin` and `end` in the same way. A document is plain
142
+ Python; `Text` fields are escaped, a `Raw` field passes TeX through, and everything wrong is reported at once:
143
+
144
+ ```python
145
+ tex = pkg.document(
146
+ CardBack(Name("Rex"), Fact("Friendly & loyal."), Fact("50% of $5")),
147
+ settings=[FactGap(Length("3mm"))], # values for the whole document
148
+ options=["a4paper"],
149
+ )
150
+ CardBack(Fact("x")).validate() # [Violation(TM-C002 ...), Violation(TM-C001 path='CardBack/Fact[0]' ...)]
151
+ ```
152
+
153
+ ```toml
154
+ # pyproject.toml
155
+ [tool.texmodel]
156
+ package = "acme_cards:pkg"
157
+ output = "generated"
158
+ ```
159
+
160
+ A pointer (`package =`, and the argument of `texmodel render`) is `dotted.module:object`, `dotted.module:namespace.object`
161
+ (attributes are followed) or `dotted.module:getter()` (called with no arguments; its result is used):
162
+
163
+ ```toml
164
+ package = "acme_cards.decks:Decks.birds" # or "acme_cards.decks:make_package()"
165
+ ```
166
+
167
+ ```zsh
168
+ texmodel build # writes generated/acme-cards.sty
169
+ texmodel render docs:card # writes card.tex from `card = pkg.document(...)` (or a function returning one); -o to choose
170
+ texmodel check # exit 1 if a committed generated file is stale, missing or orphaned; a stale line names its component
171
+ # (`changed generated/acme-cards.sty Fact: ...`)
172
+ ```
173
+
174
+ ```latex
175
+ % generated/acme-cards.sty (excerpt)
176
+ \RequirePackage{xcolor}
177
+
178
+ % Space under each fact.
179
+ % from acme_cards.FactGap (value: Length)
180
+ \newlength{\acFactGap}
181
+ \setlength{\acFactGap}{2mm}
182
+
183
+ % The text colour.
184
+ % from acme_cards.Ink (value: Colour)
185
+ \definecolor{ac-ink}{cmyk}{0,0,0,0.95}
186
+ ```
187
+
188
+ - **Deterministic**: no timestamps or hashes; the header records the generator version (`generated with texmodel X.Y.Z`),
189
+ so `check` can say "written by 0.1.0, current is 0.2.0".
190
+ - **Checked early**: a bad declaration is refused when the class is defined, with a stable code (`TM-M0xx`) and the class
191
+ named: more than one optional argument, a name that is not a TeX control word, a missing or ambiguous body, a `#name` that
192
+ is not a field, an argument the body never uses, unbalanced braces, a name collision.
193
+ - **Typed**: `Fact("A soft woof.")` type-checks; fields are `Text`, `Raw` or either `| None`; values are `Length`, `CMYK`/`RGB`/`Gray` (components in [0, 1], no conversion between models) or `bool`.
194
+
195
+ ### Generating a body
196
+
197
+ A body can also be built from pieces that are well-formed by construction, so a generator cannot write an unbalanced brace or
198
+ a bad control word. `Group(...)` is `{...}`, `Seq(...)` the same without braces, `Cs("par")` a control word (letters and `@`
199
+ only, else `TM-M027`; `_` and `:` too in `Syntax.EXPL3`), `Lit("50% & co")` text escaped as a `Text` field is, and `Hole`, `Ref` and `Raw` go inside. A space
200
+ follows a control word when the next text could continue its name. Loops and conditions are plain Python:
201
+
202
+ ```python
203
+ class Rating(Command):
204
+ '''Says which level it has.'''
205
+ level: Level # a Choice whose members are macros: \lvLow, \lvHigh
206
+
207
+ def body(self):
208
+ for member in Level: # one branch per member, chosen when TeX expands the call
209
+ yield Seq(Cs("ifx"), Hole("level"), member.value, Group(Lit(member.name)), Cs("fi"))
210
+ # \ifx #1\lvLow{LOW}\fi
211
+ # \ifx #1\lvHigh{HIGH}\fi
212
+ ```
213
+
214
+ texmodel writes this TeX and never reads TeX it did not write. The pieces are lines in the package's `Whitespace` policy;
215
+ a `Ref` inside a `Group` still makes its package a requirement.
216
+
217
+ ### expl3 and library blocks
218
+
219
+ expl3 is a syntax mode and a source kind, not a typed language: texmodel has no vocabulary of `\fp_` or `\seq_` functions, and the
220
+ compile tells you when one is wrong. `Syntax` (`texmodel.body`) is `LATEX2E` (the default) or `EXPL3`. A body is in one syntax
221
+ (`TM-M031` if its pieces differ): give each `TexRaw`, `TemplateRef` and `Cs` that is expl3 `syntax=Syntax.EXPL3`. The generated
222
+ definition is then wrapped in `\ExplSyntaxOn` / `\ExplSyntaxOff`, no `%` is added at line ends (a piece's `Whitespace` is ignored),
223
+ and a `Lit` or a text argument of a `Ref` writes `~` for a space. A component whose `cs` has `_` or `:` says
224
+ `syntax = Syntax.EXPL3`.
225
+
226
+ `Package.library(name, source, syntax=Syntax.EXPL3)` carries TeX into the `.sty` verbatim: dedented, blank edges removed, no `%`
227
+ added, `#1` and `##1` as written, in registration order among the package's items, under a provenance comment. Its only static
228
+ checks are balanced braces (`TM-M011`) and no `\ExplSyntaxOn` / `\ExplSyntaxOff` inside an `EXPL3` block (`TM-M034`). Every
229
+ line of the block has its name as its plan path, and a name is used once (`TM-M007`). `source` is a string or a `TemplateRef`.
230
+
231
+ ```python
232
+ sizing = pkg.library("sizing", r"""
233
+ \fp_new:N \l__cd_size_fp
234
+ \newcommand{\cdFitSize}[2]{ \fp_set:Nn \l__cd_size_fp { min ( 14 , max ( 8 , 12 * #2 / #1 ) ) } }
235
+ """, syntax=Syntax.EXPL3)
236
+
237
+ @pkg.command
238
+ class FitSize(Command):
239
+ '''Work out the title size.'''
240
+ defined_by = sizing # declared and callable (FitSize.call(...)), but no \newcommand is rendered
241
+ natural: Raw
242
+ available: Raw
243
+ ```
244
+
245
+ A component with `defined_by` has no body (`TM-M032`) and its block belongs to the same package (`TM-M033`). Whether TeX
246
+ really defines it, with that many arguments, is checked by compiling: `texmodel_build.testkit.check_library(compiler, pkg)`
247
+ builds `pkg.test_document(...)` with `export_probes(pkg)` (it writes `texmodel:probe:def.<cs>=yes|no` and
248
+ `arity.<cs>=<n>` for each such component), compiles it, and returns `TM-C010` for a name the block does not define and
249
+ `TM-C011` for another number of arguments (`?` is checked for existence only). The arity is the number of `#` in the macro's
250
+ `\meaning`, so a command defined with `\NewDocumentCommand` or with an optional first argument reports `0`, which
251
+ `check_exports` flags as `TM-C011` when more arguments are declared. A block that TeX refuses fails the compile with the block's name as the diagnostic's `path`.
252
+
253
+ The PDF Info strings need no expl3 in a document: `PdfInfo(Title="...", Author="...")` in `before_package` or `after_package`
254
+ writes each value PDF-escaped through `\csname pdfmanagement_add:nnn\endcsname`, and needs `RequireMetadata` in `before_class`
255
+ (`TM-C009`). A key is letters only (`TM-M035`).
256
+
257
+ ### Standard libraries
258
+
259
+ `texmodel.std` has a header for each TeX library a deck loads: `latex` (the kernel), `graphicx`, `xcolor`, `geometry`,
260
+ `etoolbox`, `eso_pic`, `microtype`, `hyphenat`, `anyfontsize`, `calc`, `pdfrender`, `enumitem`, `adjustbox`, `tikz`,
261
+ `tcolorbox`, `fontspec`. A header is an adopted package with no prefix whose components name their TeX command with `cs`:
262
+
263
+ ```python
264
+ class IncludeGraphics(Command):
265
+ r"""Place a picture: `\includegraphics[width=3cm]{a.png}`."""
266
+ cs = "includegraphics" # the TeX name, stated outright; without `cs` it is prefix + class name
267
+ path: Raw
268
+ options: Raw | None = None # TeX's one optional argument, written first
269
+ ```
270
+
271
+ A body that calls one (`IncludeGraphics.call(Hole("path"), options=Raw(r"width=\linewidth"))`) makes the generated `.sty`
272
+ load the package; the kernel header (`builtin=True`) never does. A library whose commands no body calls, or that takes
273
+ options, is loaded with `pkg.require(microtype, options=["protrusion=true"])`: `\RequirePackage[protrusion=true]{microtype}`,
274
+ in that order, before the packages the bodies need. `tests/test_std` compiles a call of every declared name against the
275
+ reference image, so a header that names a command TeX does not have fails, with the path of the call. A header covers
276
+ required `{}` arguments and one leading `[]`; a library's own syntax (TikZ paths such as `\draw (0,0) -- (1,1);`, key-value
277
+ declarations) is written as `Passthrough` or `Raw`.
278
+
279
+ ### A package that is already written in TeX
280
+
281
+ When the `.sty` is hand-written, declare what it defines instead of generating it: `adopted=True` (M9). The components
282
+ have no body, `build` writes no file, documents are still checked and escaped, and `texmodel check` compares each
283
+ declaration with the TeX it names (`\newcommand`, `\providecommand` and `\newenvironment` are read; a name defined any
284
+ other way counts as missing):
285
+
286
+ ```python
287
+ pkg = Package("card-deck", prefix="cd", adopted=True) # the package name is the hand-written one
288
+
289
+ @pkg.command
290
+ class TalkAboutMe(Command):
291
+ """A question, with an optional answer."""
292
+ question: Text
293
+ answer: Text | None = None # declared [2][]: \newcommand{\cdTalkAboutMe}[2][]{...}
294
+
295
+ @pkg.command
296
+ class DeckName(Command): # no fields: a setting the deck sets with \renewcommand
297
+ """The deck name printed on every card."""
298
+ ```
299
+
300
+ ```toml
301
+ [tool.texmodel]
302
+ package = "deck.model:pkg"
303
+ sources = ["res/brand/*.sty"] # globs, relative to pyproject.toml
304
+ ```
305
+
306
+ ```zsh
307
+ texmodel check # TM-C010: declared, not defined; TM-C011: defined with another signature
308
+ ```
309
+
310
+ `Package.document(..., preamble=PreambleSlots(before_class=, before_package=, after_package=))` places `Raw` TeX where the
311
+ document needs it: `before_class` for `\DocumentMetadata`, `before_package` for `\input@path`, `after_package` for
312
+ `\graphicspath` and per-document `\renewcommand`s. A public name of the package used there (`\cdSafeInset`) that no
313
+ component declares is refused with its path (`TM-M015`, `preamble.after_package[0]`). Typed entries render to one `Raw` and know
314
+ their slot: `PassOption("xcolor", "table")`, `InputPath("res/")` (`before_package`), `GraphicsPath("images/")` (`after_package`,
315
+ the current directory first unless `include_cwd=False`) and `RequireMetadata(lang="en-GB")` (`before_class`); one in another
316
+ slot is `TM-C008`. `tests/consumers/card_deck/` is a
317
+ complete example: a hand-written package, the generator it replaces and a 3-card deck.
318
+
319
+ ### A text setting
320
+
321
+ ```python
322
+ @pkg.value
323
+ class DeckName(Value[Text]):
324
+ """The deck name printed on every card."""
325
+ default = "Untitled" # an adopted package may leave it out: the hand-written TeX has it
326
+
327
+ pkg.document(card, settings=[DeckName("R&D")]) # \renewcommand{\acDeckName}{R\&D}, after \usepackage
328
+ ```
329
+
330
+ A generated package defines `\newcommand{\acDeckName}{Untitled}`; for an adopted one `texmodel check` expects the hand-written
331
+ package to define the macro with no arguments. Length, colour and flag settings are written in the document body as before.
332
+
333
+ A `Value[Raw]` is the same for a setting that is TeX (`\newcommand{\acFooter}{\textit{draft}}`, renewed by `settings=[Footer(Raw(...))]`).
334
+ A `PdfString` field holds text for the inside of a PDF literal string you write yourself (`\pdfextension info{/Subject (#text)}`):
335
+ it is written as `\detokenize{...}` with `( ) \` and every TeX special as an octal escape (`\050`), and text with a non-ASCII
336
+ character as UTF-16BE, so a parenthesis cannot cut the string short, `%` or `_` cannot break the TeX, and a reader gets the
337
+ text back. It is not for `\hypersetup{pdftitle=...}`: hyperref escapes its own input.
338
+
339
+ ### Layout that is not a component
340
+
341
+ ```python
342
+ class Page(Environment):
343
+ '''A page.'''
344
+ children = [Name, Anywhere(Heading), Fact.times(2, 3), Passthrough.times.any()]
345
+
346
+ Page(Name("Rex"), Heading("Facts"), Fact("a"), Passthrough(Raw(r"\vspace{1mm}")), Fact("b"))
347
+ ```
348
+
349
+ `Anywhere(Heading)` allows zero or more of that component anywhere among the other children; it is not counted against the
350
+ ordered terms and the kind cannot also be an ordered term (`TM-M029`). `Passthrough(Raw(...))` writes its TeX as it is and is
351
+ accepted only where a grammar lists it.
352
+
353
+ ### A closed set of values
354
+
355
+ A `Choice` is an enum whose members carry their TeX: a `Raw` passes through, a `str` is escaped. Use it as a field type
356
+ (optionally `| None`); data names a member by its name.
357
+
358
+ ```python
359
+ @enum.unique
360
+ class SlotLabel(Choice):
361
+ SOUND = Raw(r"\deckParamSound")
362
+ WHERE = Raw(r"\deckParamWhere")
363
+
364
+ @pkg.command
365
+ class LabelledSlot(Command):
366
+ """A fact with a label."""
367
+ label: SlotLabel
368
+ text: Text
369
+
370
+ LabelledSlot(SlotLabel.SOUND, "A soft woof.") # \cdLabelledSlot{\deckParamSound}{A soft woof.}
371
+ ```
372
+
373
+ ### A document from data
374
+
375
+ `texmodel.spec` loads a document from plain data (what TOML or JSON gives), using pydantic: a child names its component with
376
+ `"type"`, an environment's children are its `content`, a `Raw` field takes a string.
377
+
378
+ ```python
379
+ from texmodel.spec import from_dict, json_schema, to_dict
380
+
381
+ card = from_dict(Card, {"content": [
382
+ {"type": "CardFront", "content": [{"type": "CardImage", "path": "a.jpg"}]},
383
+ {"type": "CardBack", "content": [{"type": "Name", "text": "Fox"}, {"type": "LeadFact", "text": "..."}]},
384
+ ]})
385
+ card.validate() # the grammar (counts, order) is checked here, as for any tree
386
+ to_dict(card) # the same shape back
387
+ json_schema(Card) # JSON Schema of what from_dict accepts
388
+ ```
389
+
390
+ Everything wrong comes at once as an `ExceptionGroup` of `ValidationError` (`TM-C020`), each with a path such as
391
+ `Card/CardBack[1]/LeadFact[2].text`.
392
+
393
+ ### A schema for the package's data
394
+
395
+ `texmodel schema` writes `json_schema(pkg)` to `[tool.texmodel] schema_output`: one JSON Schema with a `$defs` entry for every
396
+ command and environment (a child is told apart by its `"type"` constant) and `Value.<Name>` for every value, for editors and
397
+ validators of the TOML or JSON that `from_dict` reads. A renamed field keeps loading under its old key:
398
+
399
+ ```python
400
+ class Fact(Command):
401
+ '''One fact.'''
402
+ text: Text = field(renamed_from="body") # {"body": "..."} loads; to_dict writes "text"; the schema has x-renamed-from
403
+ ```
404
+
405
+ ### Checking committed documents
406
+
407
+ ```toml
408
+ [tool.texmodel.documents]
409
+ "deck.tex" = "deck.build:plan()" # committed file = pointer to a Plan (or a str) the model renders
410
+ ```
411
+
412
+ `texmodel check` renders each pointer from today's model and compares it with the committed file, like `terraform plan`:
413
+ for a `Plan` every difference names the component that writes the line (`changed deck.tex Card[1]/CardBack[1]/LabelledSlot[3]: ...`),
414
+ so a hand edit and a stale render read the same; a `str` is compared whole. Exit 1 on any difference; `texmodel render` with no
415
+ pointer rewrites every configured document.
416
+
417
+ ### Comments
418
+
419
+ ```python
420
+ from texmodel.body import Comment
421
+
422
+ class CardHeader(Comment):
423
+ """The audit block above a card."""
424
+ template = """
425
+ Card: #name
426
+ id: #id
427
+ """
428
+ name: str
429
+ id: str
430
+
431
+ pkg.document(CardHeader("Red Fox", "11111111-aaaa"), Card(...)) # % Card: Red Fox / % id: 11111111-aaaa
432
+ ```
433
+
434
+ Every line of a `Comment` is prefixed `% `, so a value cannot leave it; nothing is escaped (`##` is a `#` in a template). It
435
+ is a body piece (`yield Comment("why this is 3mm")`) and a document node: a child of an environment or a top-level node, and
436
+ transparent to the grammar (not counted, never listed, paths keep the real index: `Card[3]/CardHeader[0]`). It needs no
437
+ package registration. `plan.edits(text, ignore_comments=True)` skips lines that are comments on both sides;
438
+ `from_dict(cls, data, comments=[CardHeader])` and `json_schema(cls, comments=[...])` know it as a child (`"type": "CardHeader"`).
439
+
440
+ ### Testing generated code with stubs
441
+
442
+ A component's body decides what it passes on to other components. `expand(node, stubs=[Fact])` (`texmodel.testing`) walks the
443
+ bodies with the node's values and returns the body text and a `Call` for every stubbed component, in order, with no TeX:
444
+
445
+ ```python
446
+ from texmodel.testing import Call, expand
447
+
448
+ section = Section(Entry("First"), Entry("R&D 50%"), title="Notes")
449
+ assert [c for c in expand(section, stubs=[Fact]) if isinstance(c, Call)] == [
450
+ Call("bkFact", ("First",)), Call("bkFact", (r"R\&D 50\%",))]
451
+ ```
452
+
453
+ The TeX tier checks the real macros do the same: `pkg.test_document(section, stub=[Fact])` loads the real packages and replaces
454
+ each stub with a macro that writes `texmodel:probe:callN=bkFact{...}` to the log; `texmodel_build.testkit.run_probes(compiler,
455
+ tex, files={"base-kit.sty": ...})` compiles it and returns the probes, which `Call.parse` reads. Arguments are shown as
456
+ `\detokenize` shows them (a space after each control word), so both tiers agree. Layout is not covered: this tests what a
457
+ component passes on, not how it looks.
458
+
459
+ ### Where a line came from
460
+
461
+ `pkg.plan(...)` takes the arguments of `pkg.document(...)` and returns a `Plan`: every output line with the path of what wrote it
462
+ (`Card[1]/CardBack[1]/LabelledSlot[3]`, `preamble.after_package[0]`). `plan.text` is the document. Store `plan.to_json()` beside
463
+ the committed file; `Plan.from_json(saved).edits(text)` then names each hand edit by the path it had when it was written, even
464
+ if the model has changed since, and `plan.path_of(39)` names the component behind a log's `input line 39`. `Compiler.compile(src, out, plan=plan)` does
465
+ that for every diagnostic in the source: `diagnostic.path` is the component behind its line; `plans={"acme.sty": pkg.render_plan()}` does the
466
+ same for diagnostics in other files, by file name.
467
+
468
+ `texmodel.bundle` copies a manifest of files and refuses if any is missing (`stale` compares bytes, not timestamps);
469
+ `texmodel.drift` is what `check` uses; `texmodel.pointer` resolves `module:object` pointers (a package, a document, a `Plan`).
470
+
471
+ ## texmodel-build
472
+
473
+ ```zsh
474
+ texmodel-build doctor # tools on this machine, and what the configured image has
475
+ texmodel-build doctor --sty 'res/*.sty' # which files those packages load that the toolchain lacks (kpsewhich), with a tlmgr line; exit 3
476
+ texmodel-build compile doc.tex # exit 0 ok, 1 errors in the document, 3 tool or image missing
477
+ texmodel-build compile doc.tex --json --epoch 1700000000 --root generated
478
+ texmodel-build compile deck.tex --plan "deck.build:plan()" # diagnostics name the component: [Card[1]/CardBack[1]/Name[0]]
479
+ texmodel-build build -j 4 # every [tool.texmodel-build.targets.*]
480
+ texmodel-build verify out/*.pdf # verifier plugins; anything unconfirmable fails
481
+ ```
482
+
483
+ ```toml
484
+ [tool.texmodel-build]
485
+ image = "releaseart/lualatex-python:latest" # runs `docker run` over it; omit to run on this machine
486
+ ```
487
+
488
+ - The compile log becomes typed diagnostics with stable codes: undefined control sequences (`TM-X010`), missing glyphs
489
+ (`TM-X011`), overfull boxes (`TM-X012`), missing files (`TM-X013`), package errors (`TM-X014`), undefined references
490
+ (`TM-X018`), package warnings with their continuation lines (`TM-X019`, how a hand-written package says a card does
491
+ not fit), non-convergence with the `.aux` entries that keep moving (`TM-X030`), and a fallback so no failure is silent.
492
+ - A package's "does not fit" warning becomes a typed `Overset` (`TM-X031`, an error: item, needs, has, over, in pt) when a
493
+ rule says how to read it; the warning is kept too. A package that prints `texmodel:probe:fit.<item>=<needs>/<has>` needs
494
+ no rule. Lengths take `pt`, `mm`, `cm`, `in` (a bare number is pt); an item that fits gives none.
495
+
496
+ ```toml
497
+ [tool.texmodel-build.measures.card-fit]
498
+ package = "card-deck"
499
+ pattern = "Card '(?P<item>[^']+)' does not fit. Its body needs (?P<needs>\\S+) and the card has (?P<has>\\S+)"
500
+ ```
501
+ - Reproducibility is the image's job (`--epoch` pins `SOURCE_DATE_EPOCH`); there is no sandbox or cache here.
502
+ - `texmodel-build verify` runs plugins from the `texmodel_build.verifiers` entry-point group. `pip install
503
+ 'texmodel[proof-pdf]'` adds [proof-pdf](https://github.com/release-art/proofpress). A verifier that cannot run, a
504
+ file that cannot be opened and a profile that selects nothing are failures; the only excuse is a declared `exempt` glob.
505
+ - The image must contain `latexmk` (`releaseart/lualatex-python` has it since proofpress v0.3.4); `doctor` reports it if not.
506
+
507
+ ## Where it is going
508
+
509
+ Open work is in [the plan](docs/texmodel-plan.md) (§19, §21a) and [the roadmap](docs/briefs/roadmap.md).
510
+
511
+ ## Development
512
+
513
+ See [CONTRIBUTING.md](CONTRIBUTING.md). `bin/autoformat.sh` runs ruff, strict pyright and import-linter over both packages;
514
+ `bin/test.sh` runs pytest with a 100% coverage gate. The container compile suite is `pytest -m tex`; it skips without an
515
+ image that has `latexmk`, and `TEXMODEL_REQUIRE_TEX=1` turns that skip into a failure (`TEXMODEL_TEST_IMAGE` sets the image).
516
+ `bin/test-image.sh` builds `texmodel-test` from `docker/test/Dockerfile`: the published reference image plus the few TeX
517
+ packages the suite needs that it does not carry yet (`TEXMODEL_TEST_IMAGE=texmodel-test pdm run pytest -m tex`); CI uses it.
518
+
519
+ Public API is the top-level modules of each package; everything under `_internal` may change. Report vulnerabilities as
520
+ described in [SECURITY.md](SECURITY.md).
521
+
522
+ ## Licence
523
+
524
+ [MIT](LICENSE). The reference images carry their own licences (Ghostscript is AGPL); `texmodel-build` runs them as a
525
+ separate process and ships none of their code.