Envisia.Typst
0.2.1
dotnet add package Envisia.Typst --version 0.2.1
NuGet\Install-Package Envisia.Typst -Version 0.2.1
<PackageReference Include="Envisia.Typst" Version="0.2.1" />
<PackageVersion Include="Envisia.Typst" Version="0.2.1" />
<PackageReference Include="Envisia.Typst" />
paket add Envisia.Typst --version 0.2.1
#r "nuget: Envisia.Typst, 0.2.1"
#:package Envisia.Typst@0.2.1
#addin nuget:?package=Envisia.Typst&version=0.2.1
#tool nuget:?package=Envisia.Typst&version=0.2.1
Envisia.Typst
Compiles Typst markup to PDF from .NET, in process. The package bundles the Typst compiler (0.15.1) as a native library, so there is no CLI to install and no process to spawn.
dotnet add package Envisia.Typst
Supported runtimes: linux-x64, linux-arm64 (glibc 2.28 or newer), osx-x64, osx-arm64, win-x64 and
win-arm64, on .NET 10. Each native library is about 35 MB. Publish with a runtime identifier
(dotnet publish -r linux-x64, or a container build) so only the matching one ends up in the output; a publish
without one carries all six.
Usage
var pdf = TypstCompiler.CompilePdf(
new TypstCompileRequest
{
Markup = "#set text(font: \"Open Sans\")\n#text(\"Hallo\")",
Fonts = [new TypstFont(openSansBytes)],
Files = [new TypstFile("logo.svg", svgBytes)],
Today = new DateOnly(2024, 5, 17),
});
TypstCompiler.CompilePdfreturns the PDF bytes or throwsTypstCompileException, which carries Typst's own diagnostics (with source positions and hints) inDiagnostics.- Everything the document needs is passed in. The compiler reads no fonts from the machine, opens no files and makes no network calls, so a rendered document depends only on its inputs. No font is bundled either: pass at least one.
Todayfeeds Typst'sdatetime.today(). Left unset that call fails to compile rather than silently reading a wall clock inside the renderer.- A document is expected to be a fixed template that reads its data from a file handed in through
Files, for example#let d = json("data.json"). A value read that way is data: Typst shows a string as text instead of parsing it, so untrusted input can never turn into executable markup.Filesalso serves#import, so a template can be split across several documents. Creatorsets the application the PDF names as its creator (/Creatorand XMPCreatorTool). Left unset, Typst names itself (Typst 0.15.1); an empty string leaves the entry out. Typst writes no/Producer, and typst-pdf offers no way to set one.Standardsmakes Typst enforce PDF standards, for example[TypstPdfStandard.PdfA3b]for an archivable PDF/A-3b. Typst checks conformance while it writes the file and throwsTypstCompileExceptionwhen the document violates one (PDF/A needs a creation date,#set document(date: datetime.today())withTodayset). At most one PDF/A and one PDF/UA standard can be combined, plus a PDF version both allow. Left empty the output is PDF 1.7.Tagged(on by default, as in Typst) writes a structure tree for screen readers. PDF/UA and the PDF/A "a" levels need it; a long report nobody reads with assistive technology renders faster and smaller without it.CompilePdfblocks the calling thread while Typst compiles.
Characters the fonts cannot show
Typst only has the fonts it is handed. A character none of them has is drawn as the font's missing glyph box, and
under a PDF/A standard Typst refuses the whole document (the text ... could not be displayed). User supplied text
can go through TypstFontCoverage first:
var coverage = TypstFontCoverage.Of(fonts); // once per set of fonts, safe to share
var text = coverage.Displayable(userText);
Of reads the cmap tables of the fonts (every face of a collection). Displayable keeps what they can show and
replaces the rest where there is an obvious substitute: other spaces become a space, line and paragraph separators a
line break, the hyphens that only differ in their break behaviour a hyphen-minus, ←/→/⇒ become <-/->/=>
and characters with a compatibility decomposition (₂, full width forms) their decomposition. Anything else, such as
emoji, symbol font code points or scripts the fonts lack, is dropped. Text that needs no change is returned as is.
Thread safety
CompilePdf can be called from any number of threads at once. The binding keeps no state between calls: every
call builds its own Typst world from the request's markup, fonts, files, date and creator, and nothing of it
outlives the call. A test compiles eight different documents that share a file name on twelve threads and checks
every PDF is byte for byte what the same request produces on its own.
Typst itself keeps three things process wide, which a binding cannot scope to one call:
- comemo's memoization cache. It is lock protected, and a cached result is only reused when every input Typst tracked is identical, so it can make a call faster but never hand it another call's data. The native layer evicts it after every document without taking what running calls still use (see below), so calls running in parallel keep their cache hits.
- The interner for file names. Each distinct name stays allocated for the life of the process, so use stable
names such as
data.jsonrather than a new name per call. - rayon's global thread pool, which Typst lays out and exports on in parallel.
Native layer
src/Envisia.Typst/native/ is a Rust cdylib that implements Typst's World over the caller's fonts and files.
The C ABI is deliberately small:
| Symbol | Purpose |
|---|---|
envisia_typst_abi_version |
Version handshake, checked on every call. |
envisia_typst_compile_pdf |
Compiles markup into an EvTypstResult. |
envisia_typst_result_free |
Releases everything the result owns. |
Memory ownership: the caller owns the input buffers and only has to keep them alive for the duration of the call
(the managed side pins them). Everything in the result is allocated and freed by Rust; the managed side copies
the bytes out and always calls envisia_typst_result_free, including on the error paths. A Rust panic is caught
at the boundary and reported as a status rather than unwinding into the CLR.
Typst memoizes layout in comemo's process wide cache. Each document here is rendered once, and a large one would
otherwise stay resident until later calls aged it out, so the native layer evicts the cache whenever a call ends,
also when it panics. The call that ends last clears it (comemo::evict(0)). A call that ends while others still
run only evicts entries older than the oldest of them: every eviction ages each entry by one and a cache hit
resets it, so what a running call has used since it started stays. Clearing everything instead makes a call lose
the layout its next pass reuses whenever another one ends next to it. Measured on macOS arm64: a 2,000 entry report
rendered next to a stream of small ones took 1.8 s instead of 1.1 s (1.0 s on its own), and four threads
rendering 1,000 entry reports back to back finished 120 of them in 40 s instead of 25 s. The price is memory
while calls overlap, up to about 1.3 GB of native memory in use in that run instead of 0.5 GB; once no call runs
the cache is empty either way.
The crate must depend on the comemo version Typst itself uses; with another one comemo::evict clears a cache
Typst never fills and the memory grows with every distinct document. same_comemo_as_typst in lib.rs turns
such a mismatch into a build error.
Building
A local dotnet build or dotnet test compiles the native library for the host platform with
cargo build --release (through Envisia.Typst.Native.targets) and copies it next to the assembly.
- A Rust toolchain is required.
native/rust-toolchain.tomlpins the channel. EnvisiaTypstSkipNativeBuild=truereuses whatever is innative/target.EnvisiaTypstLibraryPath=<path>uses a prebuilt library instead of building one.
A cold cargo build --release compiles roughly 300 crates. Subsequent builds are incremental and cost well under
a second when nothing changed.
dotnet pack does not compile Rust. It takes one prebuilt library per runtime from
EnvisiaTypstRuntimesDirectory (laid out as <rid>/<library>) and fails when one is missing;
EnvisiaTypstAllowMissingRuntimes=true packs whatever is there, for a local test package.
CI and releases
.github/workflows/build.ymlbuilds the native library on each platform's own runner (Linux inside themanylinux_2_28images, so the.soruns on glibc 2.28 and newer), packs them into one package and runs the tests on every platform against that package.ci.ymlruns it for pushes tomainand pull requests.publish.ymlruns it for every pushed tag and pushes the package to nuget.org through trusted publishing (policy forpublish.yml, environmentrelease). The tag is the version:v1.2.3,1.2.3or a prerelease such asv1.2.3-beta.1; any other tag fails the workflow before anything is built.
To release, tag the commit and push the tag:
git tag v0.1.0
git push origin v0.1.0
Licenses
Envisia.Typst is MIT licensed, see LICENSE. The native library contains Typst (Apache-2.0) and about
290 Rust crates under permissive licenses; THIRD-PARTY-NOTICES.md lists them and ships in
the package. CI regenerates it with cargo about and fails when it is out of date. The test font is Open Sans under
the SIL Open Font License, see tests/Envisia.Typst.Tests/Fonts/OFL.txt.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.