Typst-backed document generation for RustCFML,
shipped as a dynamically loaded .rcx extension. Read this before changing
anything; most of it is a trap that has already cost time once.
src/model.rs the document model the builder accumulates — inert, no Typst types
src/emit.rs model -> Typst markup. THE injection boundary. Unit-tested.
src/document.rs the CFML class: argument parsing, validation, the .table() logic
src/world.rs typst::World, the template sandbox, PDF options, fonts
src/lib.rs module! declaration and the raw typst* BIFs
examples/invoice/ main.cfm (demo), selftest.cfm (108 end-to-end checks), templates/
docs/ document-api.md (reference), typst-coverage.md (gap audit), PLAN.md
This crate needs a RustCFML checkout beside it — rustcfml-module is a path
dependency, not a crates.io one:
some-dir/
RustCFML/ # v0.635.2 or later
RustCFML-Extension-Typst/ # this repo
Only the RustCFML/ sibling's name matters — the path dependency is
../RustCFML/crates/rustcfml-module, so this directory can be called anything.
CI checks it out as rustcfml-typst.
cargo test # 24 Rust unit tests — emit/validation/date maths
cargo clippy --all-targets # must be clean; CI runs -D warnings
cargo fmt --check # must be clean
# The end-to-end suite. Everything in it compiles REAL Typst, so a pass means
# the markup was valid, not merely that it was generated.
rustcfml ext build .
rustcfml ext install typst-0.1.0.rcx --dir examples/invoice/extensions
rustcfml examples/invoice/selftest.cfm # expect "all checks passed" (108 ok)
rustcfml examples/invoice/main.cfm # the demo, both builder and template🚨
cargo testpassing is not evidence. Every bug found in the last two sessions was invisible to the Rust tests, and three of them were invisible to the 108 CFML checks as well. See "Verification that actually works" below.
1. Caller text is a Typst STRING LITERAL. Always.
emit::lit() produces the literal; every verb emits Typst code mode
(#heading(level: 1)[#"…"]). A paragraph containing #set page(fill: black),
$x^2$ or a stray ] must render as those characters. Tests
caller_text_cannot_become_typst_code and
hostile_text_in_every_inline_position_stays_a_literal enforce it — if you add a
place where caller text lands, extend the second one.
2. The few bare expressions are WHITELISTS, checked in document.rs.
Measurements, colours, labels, paper names, PDF standard names and link URLs have
no string-literal form, so they are validated rather than escaped. Never widen one
without a test. Link URLs are restricted to http/https/mailto/tel/same-document
because a PDF viewer acts on javascript:.
3. emit.rs assumes its input is already valid. Validation lives in
document.rs. Do not add checks to the emitter; do not skip them in the parser.
4. Nothing may call back into CFML while the document lock is held.
CfmlDocument::with() holds a Mutex, and ctx.call() re-enters the engine.
Every arm resolves its arguments first and takes the lock last. Breaking this is a
self-deadlock, not an error.
5. Read arguments BY NAME. One const P_* string per method answers
method_params and resolves arguments (a.val("stripe")), so they cannot drift.
.table() has 24 parameters; a mis-numbered index is a silent wrong answer.
6. Formatting is the ENGINE's job. numberFormat/dateFormat masks call the
engine's own BIFs over the ABI (ctx.call, needs host tier 3), and .data() uses
the engine's serializeJSON. Never reimplement a CFML mask here — a second
implementation of that language would diverge in ways users would have to debug.
7. Template data crosses as a virtual data.json, never as generated #let
source. That is what keeps caller data data. If you ever emit #let data = …,
you have handed every value in the caller's database a route into code mode.
Three techniques, each of which caught something nothing else did:
Rasterise and look at it. pdfToImage( pdfRead( path ), 1, 1400 ) then read
the PNG. This is the only thing that found the caption bug: #figure centres its
content and Typst's table align defaults to auto (= inherit), so adding
caption= silently centre-aligned every cell. Markup fine, compile fine, all 106
checks green at the time. If a change affects layout, render it.
Measure memory with vmmap -summary, not ps. ps RSS showed 5.8 MB for
processes Activity Monitor reported at 3.26 GB — macOS charges swapped-out dirty
pages, and ps counts only resident ones. vmmap -summary $PID gives the
Physical footprint (the Activity Monitor number) and a region table saying
where it went. That is how the font loader's 2.9 GB of MALLOC_LARGE was found.
On Apple Silicon this is unified memory, so a large footprint is real RAM.
Recipe for "does it leak?": start --serve, probe, then N requests per phase,
probing between phases. Font data plateaus at ~220 MB and stays flat over 600
requests — a plateau is not a leak.
Test in Docker, on a fontless image. rust:1-slim has zero fonts, and
Typst does not fail on that — it compiles happily and writes a PDF with zero
text-showing operators and zero embedded fonts. A blank page, successfully.
world::check_fonts now refuses instead. Any change near fonts or World gets a
container run.
Assert on the reason, not just the failure. A bare try/catch "the sandbox
refused it" check also passes when the extension failed to load. Check the
message. And prove data inertness by page count — an executed #pagebreak()
compiles perfectly well.
- ⭐⭐⭐**
#in CFML.** Typst markup is built out of#; CFML interpolates it. Raw Typst in a CFML string doubles every one (##set). Writing tests about Typst is where this bites..typfiles on disk are unaffected. - ⭐⭐CFML forbids mixing positional and named arguments in one call.
.spacer( 1, unit="cm" )is an engine error. Fluent APIs read as if it were allowed. - ⭐⭐**
r#"…"#cannot contain"#.** User##"…"##. Bites in any test asserting onrgb("#f00"). - ⭐Typst refuses
#refto an unnumbered heading. Hence.headingNumbering(). - ⭐A PDF page range cannot combine with tagged PDF, and tagging is ON by
default, so
pages=alone always failed. Refused at.pdf()now, naming the fix. Do not silently disable tagging — dropping accessibility is the caller's call. - ⭐⭐⭐**
fontdb::with_face_datahands you the whole FILE, not the face.** Never build aFontper face from it: a.ttcis one file with many faces, so that copies the file once per face. 904 faces = 3.4 GB at start-up. UseFontInfo::new(data, index), which borrows, and load theFontlazily.world::startup_holds_font_metadata_onlyis the regression test. - The ABI type constant is
ty::BOOL, notty::BOOLEAN. - ⭐⭐macOS and Windows filesystems are CASE-INSENSITIVE, and the engine is
checked out as
RustCFML/. Never name a filerustcfmlbeside it — Docker Desktop bind mounts resolve it to the directory, and on a macOS/Windows CI runnergh release download --output rustcfmlrefuses with "rustcfml already exists". The Linux jobs pass, so it looks like a platform-specific build failure when it is a filename collision. The workflows download the engine asenginefor this reason. Cost: one red macOS job after the same trap had already been hit in Docker and written down here.
model.rs— aBlockvariant, or fields on an existing one. Keep it inert.Block::Tableis boxed; the enum is size-sensitive.emit.rs— one arm. Caller text throughbody()/inline(), nothing else.document.rs— aconst P_YOURVERBparameter list, an entry inparams_for, an arm incall, and an alias if there is an obvious one. MutatorsOk(ctx.this()); terminals return a value.unknown_method()— add the name to the list in the error message.- Tests: a unit test in
emit.rsfor the markup, and a check inexamples/invoice/selftest.cfmthat compiles it. docs/document-api.md— the reference table. And move it out ofdocs/typst-coverage.md§3 if it was listed as missing.- If it affects layout, render it and look.
Dual MIT/Apache-2.0 for this crate's own code; see COPYRIGHT. A .rcx statically
links Typst and ~90 crates, so notices must travel with it: THIRD-PARTY.txt is
generated (./scripts/gen-licenses.sh, needs cargo-about 0.9.1), committed, and
packaged inside the archive along with NOTICE and the licence files. Regenerate
and commit after any dependency change.
gen-licenses.sh squeezes blank lines.
This broke the engine repo's byte-exact CI gate once.
No fonts are bundled: typst-assets is 6.7 MB of OFL-1.1 faces, and Typst is
given the host OS fonts at run time instead.
.github/workflows/release.yml, on a v* tag: four platforms, one
single-platform .rcx each — never the fat multi-platform archive, which would
make every download carry four libraries so one could be used. Each job downloads
the released engine binary and packages with that, because ext build bakes the
host's own target triple and ABI major into the manifest. RUSTCFML_REF is pinned
deliberately: the engine tree is a real build input.
- Comments explain why, and especially why-not. A comment recording a trap that cost an hour is worth more than one restating the code.
- Match the surrounding density. This codebase comments decisions, not syntax.
- Never no-op or stub to get past a blocker without flagging it.
- Lucee/ACF conventions are the reference for anything CFML-visible. The
Spreadsheet()builder in the engine is the reference for this API's shape — format structs, polymorphicdata, masks alongside formatting.