IR passes and fragment contracts¶
TeXSmith no longer reads HTML and no longer emits LaTeX from Python. The parser, the intermediate representation, the resolver and the writers are tmark's; what remains on the Python side is the part that needs the filesystem, the network and a process — and the part that decides what a construct looks like.
tmark.parse ─▶ IR ─▶ TeXSmith passes ─▶ tmark.resolve ─▶ tmark.write ─▶ Body { text, map, requires }
There are therefore two extension points, and the @reads / @writes
decorators of 0.6 map onto them:
| 0.6 hook | 0.7 replacement |
|---|---|
@reads lowering (HTML → IR) |
a TMark container (name) plus, when it computes something, an IR pass |
@writes emitter (IR → LaTeX) |
a contract macro provided by a fragment, redefined by the template |
[latex.template] readers |
[latex.template] passes = ["pkg.module:pass"] |
[latex.template] writer |
redefine the contract macro in the template's .tex |
fragment partials, required_partials |
the fragment's provides list |
All five — readers, writer, latex.template.override, fragment
partials and required_partials — were deprecated in 0.7.0 and are gone in
0.8.0: neither the template manifest nor fragment.toml reads them any more, so
a package that still declares one converts as if it did not.
IR passes¶
A pass is a pure function (Document, PassContext) -> Document over the
generated IR models. It never mutates its input, returns the same object when it
has nothing to do, and otherwise rebuilds the tree with document.evolve(...).
Failures never raise: a node is replaced by a visible literal and a diagnostic
is emitted at its span. An exception escaping a pass is a bug.
Two stages surround the one Rust step in the middle:
pre- before
tmark.resolve. Aprepass may add, remove or rewrite blocks — this is where includes are spliced, diagrams converted, DOIs fetched, moustaches expanded. post- after
tmark.resolve. Apostpass only slices the block list or computes per-body options, so that theResolvedof the whole document stays valid.
from dataclasses import replace
from tmark.ir import model
from texsmith.core.documents import Document
from texsmith.passes import PassContext, spec
@spec("exam-questions", stage="pre", after=("include",))
def run(document: Document, ctx: PassContext) -> Document:
ir = document.ir
if ir is None:
return document
blocks = tuple(_number(block, solution=ctx.attribute("solution", False)) for block in ir.blocks)
if blocks == ir.blocks:
return document # nothing to do: hand back the same object
return document.evolve(ir=replace(ir, blocks=blocks))
Order is declared, never implicit: a PassSpec names the passes it must run
after, and build_pipeline performs a stable topological sort that raises
PassOrderError on a cycle. Declare a template's passes in its manifest:
[latex.template]
name = "exam"
version = "1.0.0"
entrypoint = "template/template.tex"
engine = "lualatex"
passes = ["my_exam_pkg.questions:run"]
They are resolved with the same import machinery as attribute normalisers and
fragment entrypoints, so a typo fails early with an actionable TemplateError,
and they apply only while the declaring template renders — exam-style rules
would corrupt unrelated documents, so there is no global entry point.
[typst.template] reads the same key, and a template that declares both
sections lists its passes in both.
Each entry names an attribute of a module ("package.module:attribute"). It
may be a PassSpec, used as declared; a callable registered by @spec(...),
whose registered spec is reused, so the name, stage and after of the
decorator hold; or a plain (Document, PassContext) -> Document callable,
wrapped in a pre pass named after the reference. An after naming a bundled
pass orders the template's pass among them, and a stage="post" pass runs
after tmark.resolve with the bundled post ones.
A template pass reads its options from the PassContext:
ctx.attribute("solution", False) walks ctx.contexts — the template
overrides first (the document's front matter merged with the CLI's
-a key=value, so -a solution=true is True here), then the front matter —
and a dotted name reaches into a nested mapping (ctx.attribute("press.title")).
The attribute defaults declared in the manifest are resolved by the template
renderer and never reach the contexts, so the pass supplies its own default.
Keep passes semantic and backend-neutral: a pass rewrites IR nodes and their
attributes, never LaTeX strings. The one exception is deliberate and narrow —
the bundled highlight pass turns code blocks into RawBlock{format=latex}
holding a Pygments payload, because that work is Python-only and reuses the
latex raw path rather than making the writer Pygments-aware.
Custom constructs¶
The container names TMark knows are a closed registry: the callout types
(built-in and declared), aside, figure, tabs, tab, multicolumn,
landscape and div. A new kind of thing is a declaration under
press.declare.admonitions, not a new container name; a wrapper the template
styles is div with a class.
::: div {#s1 .sidebar}
Content.
:::
On the paged backends every layout container is rendered by one contract,
\begin{tsdiv}{name}[attrs] (#ts-div("name", ..) in Typst), dispatched on the
name with the attributes forwarded as keys: #id becomes id, classes become
class={a,b}, key=val is forwarded as is (lang and media never are). A
template styles a class through the pgfkeys family, or redefines the
environment for a name it knows:
\tcbset{/ts/div/class/sidebar/.style={grow to left by=2cm}}
\RenewDocumentEnvironment{tsdiv@multicolumn}{O{}}{\begin{multicols}{3}}{\end{multicols}}
A container name the registry does not know raises container-unknown and
renders its content transparently.
A template whose own IR pass reads such a name declares it, next to the pass that claims it, and the warning stops for that name alone:
[latex.template]
passes = ["my_exam_pkg.questions:run"]
containers = ["solution"]
The names are read from the manifest before the documents are parsed — tmark
reports an unknown container while it parses, and no pass can take a record
back — so solution is a known container for the whole run, on both the
LaTeX and the Typst path, and every undeclared name still warns. Bad entries
(not a list, not a string, empty) raise TemplateError when the manifest
loads.
Contract macros¶
The writers emit a fixed macro or environment per construct, and a fragment must
define every macro the writer can name. Naming is regular: macros are
\ts<name>, environments ts<name>, lowercase, one word per construct. At most
one optional keyval group comes first, then the mandatory arguments, content
last.
\tslead{…}
\tsaside[side=left]{…}
\tsprogress[thin]{0.45}{Launch}
\tskeys{Ctrl,Alt,Del}
\tsindex[registry=physics, main]{sort@formatted!sub}
\begin{tscallout}[kind=warning, title={…}, id=w1, collapsed] … \end{tscallout}
\begin{tscode}[lang=py, title={bubble\_sort.py}, linenums=1, hl_lines={2-3}] … \end{tscode}
\tscodeinline[lang=py]{xs.sort()}
Options are pgfkeys keyvals under /ts/<name>/, and every family ignores
unknown keys, so an older fragment survives a newer writer. Only keys with a
value are serialised; booleans are bare, text is escaped and braced, lists are
braced with commas. Typst mirrors each contract with one hyphenated function
(#ts-callout, #ts-keys) whose named arguments match the keys.
Which fragment provides which macro is the writer's Requires.fragments: a body
that emits \tscallout activates ts-callouts, one that emits \tsindex
activates ts-index. Activation is by construction, not by sniffing the
rendered LaTeX.
Overriding a construct¶
Three levels, in increasing order of violence. All of them go in the template's
.tex, after \VAR{extra_packages} (that is where fragments are
\usepackaged).
\VAR{extra_packages}
% (a) restyle through the pgfkeys family
\tcbset{/ts/code/.append style={frame hidden, boxrule=0pt}}
% (b) redefine the macro, same signature
\RenewDocumentCommand{\tscodeinline}{O{}m}{\texttt{#2}}
\RenewDocumentEnvironment{tsdiv@multicolumn}{O{}}{\begin{multicols}{3}}{\end{multicols}}
# (c) replace the fragment outright
press:
fragments:
disable: [ts-code]
append: [./my-code.sty]
A replacement fragment must define every entry of the fragment's provides
list; the check runs at load time against tmark.fragments(). See
Contract macros for the full table mapping
each construct to its macro.
Reference¶
The IR pass framework (specs/migration/python-ir-and-passes.md §3).
A pass is a pure function (Document, PassContext) -> Document over the
generated models: it never mutates its input, returns the same object when it
has nothing to do, and otherwise document.evolve(...) with a rebuilt tree.
Failures never raise — a node is replaced by a visible literal and a
diagnostic is emitted at its span; an exception escaping a pass is a bug.
Two stages surround the one Rust step of the pipeline: pre passes run
before tmark.resolve (they may add, remove or rewrite blocks), post
passes after it (they only slice the block list or compute per-body options,
so the Resolved of the whole document stays valid — decision X1).
:data:DEFAULT_PIPELINE is the explicit order; a template or plugin
registers a :class:PassSpec with after= and :func:build_pipeline
performs a stable topological sort, raising :class:PassOrderError on a
cycle. A template declares its own passes in its manifest
([latex.template] passes = ["pkg.module:run"], and the same under
[typst.template]); they are resolved when the manifest loads and handed
to :func:build_pipeline as extra while that template renders — never
globally.
Ids and spans. Three passes cite "span rule 1" and "span rule 2" in their docstrings; here is what they are citing, written down at last.
Rule 1 — a node rewritten in place keeps its id and its span. The
construct is the one the author wrote; only its content changed. highlight
turning a CodeBlock into a Div is the case: a diagnostic about that
code still points at the fence.
Rule 2 — a node synthesised from another takes the source's span and a fresh
id. The text is TeXSmith's, the location is the author's: a moustache
resolved by var, a cluster split out by emoji. Reusing the source's id
would put two nodes at one address; inventing a span would put a diagnostic
somewhere the author never wrote.
Rule 3 — id and span take no part in equality or hashing, so a
rebuilt subtree compares equal to its source when only identity changed
(field(compare=False) on the generated models).
Ids are unique per file but not dense; the invariant that makes rule 2 safe is
"every id of every file is below :attr:IdAllocator.floor", maintained by
:meth:IdAllocator.observe after each parse.
tests/passes/test_pass_framework.py checks rules 2 and 3 over the corpus:
no two nodes share an id, and no span names a file the build never registered.
IdAllocator ¶
IdAllocator(floor: int = 1)
Fresh node ids above every id of every file of the build.
Source code in src/texsmith/passes/__init__.py
110 111 | |
next ¶
next() -> int
One fresh id.
Source code in src/texsmith/passes/__init__.py
117 118 119 120 121 | |
observe ¶
observe(root: Any) -> None
Raise the floor above the ids of root (a document, node or record).
Source code in src/texsmith/passes/__init__.py
113 114 115 | |
reserve ¶
reserve(count: int) -> int
Reserve count consecutive ids; returns the first one.
Source code in src/texsmith/passes/__init__.py
123 124 125 126 127 | |
PassContext
dataclass
¶
PassContext(
files: FileTable = FileTable(),
ids: IdAllocator = IdAllocator(),
diagnostics: DiagnosticSink = DiagnosticSink(),
loader: Any = MemoryLoader(),
output_dir: Path = Path.cwd(),
include_paths: tuple[Path, ...] = (),
root_dir: Path | None = None,
request: ConversionRequest | None = None,
contexts: tuple[Mapping[str, Any], ...] = (),
emitter: DiagnosticEmitter = NullEmitter(),
template: SlotTemplate = SlotTemplate(),
backend: str = "latex",
code: Mapping[str, Any] = dict(),
bibliography: list[Path] = list(),
pygments_styles: dict[str, str] = dict(),
doi_fetcher: Any = None,
copy_assets: bool = True,
convert_assets: bool = False,
hash_assets: bool = False,
values: dict[str, Any] = dict(),
)
Everything a pass may read besides the document; request is read-only.
attribute ¶
attribute(name: str, default: Any = None) -> Any
The template attribute name as the conversion resolved it, else default.
The lookup walks :attr:contexts in order — template overrides (front
matter merged with the CLI's -a key=value), then the front matter,
then the mustache defaults — and a dotted name reaches into a nested
mapping (ctx.attribute("press.title")). A template pass reads its
options here: ctx.attribute("solution", False) is True under
-a solution=true and falls back to the pass's own default when
nothing overrides it, since manifest defaults never reach the contexts.
Source code in src/texsmith/passes/__init__.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | |
PassOrderError ¶
Bases: ValueError
A pipeline cannot be ordered: a cycle in after or an unknown pass name.
PassSpec
dataclass
¶
PassSpec(
name: str,
run: Pass,
after: tuple[str, ...] = (),
needs_io: bool = False,
stage: Stage = "pre",
)
One registered pass.
SlotTemplate
dataclass
¶
SlotTemplate(
default_slot: str = "mainmatter",
requests: Mapping[str, str] = dict(),
levels: Mapping[str, int] = dict(),
strip_heading: frozenset[str] = frozenset(),
base_level: int = 0,
)
What the slots and headings passes know of the template binding.
build_pipeline ¶
build_pipeline(
names: Iterable[str] = DEFAULT_PIPELINE,
*,
extra: Iterable[PassSpec] = (),
) -> tuple[PassSpec, ...]
The passes to run, ordered.
names gives the base order (registered passes, looked up by name);
extra adds specs placed by their after constraints. The sort is
stable: without constraints the listed order stands, and every pre
pass precedes every post pass.
Source code in src/texsmith/passes/__init__.py
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | |
highest_id ¶
highest_id(root: Any) -> int
The largest node id under root (a document, node, record or tuple), 0 when none.
Source code in src/texsmith/passes/__init__.py
130 131 132 | |
register ¶
register(pass_spec: PassSpec) -> PassSpec
Register a pass by name (a template or plugin adds its own here).
Source code in src/texsmith/passes/__init__.py
244 245 246 247 | |
run_pipeline ¶
run_pipeline(
document: Document,
ctx: PassContext,
pipeline: Iterable[PassSpec] = (),
*,
resolve: Callable[[Document, PassContext], Document]
| None = None,
) -> Document
Run pipeline on document: the pre passes, resolve, the post passes.
Source code in src/texsmith/passes/__init__.py
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | |
spec ¶
spec(
name: str,
*,
after: Iterable[str] = (),
needs_io: bool = False,
stage: Stage = "pre",
) -> Callable[[Pass], Pass]
Decorator registering run under name.
Source code in src/texsmith/passes/__init__.py
250 251 252 253 254 255 256 257 258 259 260 261 262 263 | |
tmark.write on the Python side: bodies, Requires and WriterOptions.
writers-and-passes.md §1 (Body/Requires as Python sees them) and
python-ir-and-passes.md §4: one write call per slot body, each
receiving the full document with blocks replaced by the body's slice (the
footnotes and abbreviations stay), the shared Resolved handle, and the
WriterOptions derived from the template context plus the per-body
heading options of the headings pass. Python unions the Requires of
every body; :mod:texsmith.core.fragments.activation turns the union into
fragment activation.
Body
dataclass
¶
Body(
text: str,
map: list[tuple[int, int, int]] = list(),
requires: Requires = Requires(),
)
One tmark.write result.
Requires
dataclass
¶
Requires(
packages: list[str] = list(),
fragments: list[str] = list(),
shell_escape: bool = False,
assets: list[dict[str, Any]] = list(),
bibliography: bool = False,
citations: list[str] = list(),
acronyms: list[str] = list(),
index: list[str] = list(),
counters: list[str] = list(),
)
What the bodies need from the template (tmark_writers::Requires).
merge ¶
merge(other: Requires) -> None
Union other into this record, keeping first-seen order of the lists.
Source code in src/texsmith/core/conversion/bodies.py
83 84 85 86 87 88 89 90 91 92 93 | |
build_writer_options ¶
build_writer_options(
*,
backend: str,
language: str | None = None,
code_options: Mapping[str, Any] | None = None,
legacy_accents: bool = False,
media: str | None = None,
base_level: int = 1,
numbered: bool = True,
refs: Mapping[str, str] | None = None,
numbering: Mapping[str, str] | None = None,
source_map: bool = False,
) -> dict[str, Any]
The options dict of tmark.write, one key per WriterOptions field.
code_options is the merged code section of the template context
(_resolve_code_options): engine, inline.plain, inline.breaks.
Source code in src/texsmith/core/conversion/bodies.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | |
write_body ¶
write_body(
ir_document: Document,
backend: str,
options: Mapping[str, Any],
*,
blocks: tuple[Block, ...] | None = None,
loader: Any = None,
resolved: Any = None,
resolve_options: ResolveOptions | None = None,
sink: DiagnosticSink | None = None,
) -> Body
Write ir_document (or the slice blocks of it) with backend.
resolved is the tmark.resolve result (or its handle) shared by
every body of the document; without it tmark resolves the slice itself
through loader with resolve_options.
Source code in src/texsmith/core/conversion/bodies.py
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | |