WebAssembly

Browser-Side PDF Generation with Rust, WebAssembly & Typst

9 min Lesezeit

Generating PDFs from a web application should not require a server round-trip, a headless browser, or a fragile chain of JavaScript libraries. Yet for most teams, that is exactly what it requires. The typical approach β€” spin up Puppeteer, render HTML to a virtual page, print to PDF β€” is slow, resource-hungry, and surprisingly difficult to get right when pixel-perfect layout matters.

What if you could compile a document directly in the browser, with precise typographic control, custom fonts, and structured data injection, all in under a second?

pdf-generator is an open-source Rust library that does exactly that. It compiles to WebAssembly via wasm-pack, wraps the Typst document compiler, and exposes a single function β€” render_pdf β€” that takes a template and font data and returns raw PDF bytes. It works in both the browser and Node.js, making it a genuinely cross-platform solution for programmatic document generation.

In this article, we will walk through how it works, why the technology choices are compelling, and how you can start using it in your own projects.

What Is Typst and Why Should You Care?

Typst is a modern typesetting system designed as a practical alternative to LaTeX. Where LaTeX carries decades of macro complexity and cryptic error messages, Typst offers a clean scripting language with first-class support for variables, loops, conditionals, and function definitions β€” all while producing publication-quality output.

Here is a minimal Typst template:

template.typ
#set page(paper: "a4")
#set text(font: "Inter")

= Invoice

Due date: *2026-03-01*

#table(
  columns: 4,
  table.header([Qty], [Description], [Unit Price], [Total]),
  [1], [Consulting Service],   [150], [150],
  [2], [Software Development], [800], [1600],
)

That template produces a properly typeset A4 document with a heading, bold text, and a formatted table. No XML. No verbose markup. No fighting with CSS print stylesheets.

For programmatic document generation, Typst has a critical advantage: it is implemented as a Rust library. That means it can be compiled to WebAssembly and executed directly in the browser, without any server infrastructure. This is the foundation that pdf-generator builds on.

Architecture: Rust to WASM to Your Browser

The compilation pipeline is straightforward, but each layer is doing important work.

Rust source code is compiled to a WebAssembly module using wasm-pack, which orchestrates the build process and generates JavaScript bindings via wasm-bindgen. The resulting .wasm file, along with a thin JS wrapper, can be loaded by any modern browser or Node.js runtime.

The project's Cargo.toml reveals the key dependencies:

Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
typst            = { version = "0.13.1", default-features = false }
typst-pdf        = { version = "0.13.1", default-features = false }
typst-as-lib     = { version = "0.14.4", default-features = false }
wasm-bindgen     = { version = "0.2",    default-features = false }
serde            = { version = "1.0.219", features = ["derive"] }
serde-wasm-bindgen = { version = "0.6.5",  default-features = false }

A few things to note. The crate type is cdylib, which tells the compiler to produce a C-compatible dynamic library β€” the format wasm-pack needs to generate a .wasm binary. The default-features = false flags are deliberate: they strip out functionality that either does not compile to WASM or is unnecessary, keeping the output size manageable. The release profile uses opt-level = "s" and lto = true to aggressively optimize for binary size.

The typst-as-lib crate deserves special mention. It wraps Typst's compiler into a convenient library interface with a builder pattern, handling the complexity of font registration, source file management, and compilation orchestration. Without it, setting up the Typst compiler programmatically would require significantly more boilerplate.

The Core API: render_pdf

The entire public API surface is essentially one function. Here is the Rust source, lightly annotated:

lib.rs
#[derive(Default, Deserialize)]
struct RenderOptions {
    template: String,
    font:     Vec<u8>,
}

#[wasm_bindgen]
pub fn render_pdf(
    render_options: JsValue,
    data:           JsValue,
) -> Result<Vec<u8>, JsValue> {
    // Deserialize the JS object into a Rust struct
    let options = RenderOptions::try_from(render_options)?;

    // Build the Typst engine with the template and font
    let template = TypstEngine::builder()
        .main_file(options.template.as_str())
        .fonts([options.font])
        .build();

    // Convert JSON data from JS into Typst's native Dict type
    let json_value = from_value(data).map_err(log_error)?;
    let input_data = json_to_typst(json_value);
    let dict = Dict::from_value(input_data).unwrap();

    // Compile the document and export to PDF
    let doc = template
        .compile_with_input(dict)
        .output
        .map_err(log_error)?;
    let pdf = typst_pdf::pdf(&doc, &PdfOptions::default())
        .expect("Error exporting PDF");

    Ok(pdf)
}

The function accepts two JsValue arguments β€” opaque JavaScript values that cross the WASM boundary. The first is deserialized into RenderOptions (a template string and font bytes). The second is arbitrary JSON data that gets converted into Typst's native Dict type through a recursive json_to_typst converter. This is what makes the library genuinely useful for dynamic documents: you can pass structured data from your JavaScript application and reference it inside your Typst template using #import sys: inputs.

The return type, Result<Vec<u8>, JsValue>, becomes a JavaScript Uint8Array on success or throws on failure. Clean, predictable, and easy to work with on both sides of the boundary.

Using It in the Browser

Build the WASM module for the web target:

terminal
wasm-pack build --target web

Then load and call it from JavaScript:

index.html
<!-- Required: Rust expects valid UTF-8 strings -->
<meta charset="UTF-8">

<script type="module">
  import init, { render_pdf } from './pkg/pdf_generator.js';

  await init();

  // Load a font file (e.g., from a file input or fetch)
  const fontResponse = await fetch('/fonts/Inter-Regular.ttf');
  const fontBytes = Array.from(
    new Uint8Array(await fontResponse.arrayBuffer())
  );

  const render_options = {
    template: `
      #import sys: inputs
      #set page(paper: "a4")

      = Invoice \##{inputs.invoice_number}

      *Client:* #{inputs.client_name}

      #table(
        columns: 4,
        table.header([Qty], [Item], [Price], [Total]),
        ..inputs.items.map(it => (
          [#it.at(0)], [#it.at(1)], [#it.at(2)],
          [#(it.at(0) * it.at(2))]
        )).flatten()
      )
    `,
    font: fontBytes,
  };

  const data = {
    invoice_number: "2026-0042",
    client_name:    "Acme Corp",
    items: [
      [2, "API Integration",     950],
      [5, "Support Hours",        120],
    ],
  };

  const pdfBytes = render_pdf(render_options, data);
  const blob = new Blob([pdfBytes], { type: 'application/pdf' });
  window.open(URL.createObjectURL(blob));
</script>

That is the entire integration. No server calls. No headless browser. The PDF is compiled in the user's browser tab and can be displayed immediately or downloaded.

One important detail: the HTML page must declare charset="UTF-8". Rust strings are guaranteed to be valid UTF-8, and if the browser sends strings in a different encoding across the WASM boundary, deserialization will fail with opaque errors.

Using It in Node.js

The same library compiles to a Node.js module with a single flag change:

terminal
wasm-pack build --target nodejs

The API is identical:

server.js
const fs = require('node:fs');
const { render_pdf } = require('./pkg/pdf_generator');

const font = fs.readFileSync('./fonts/Inter-Regular.ttf');

const render_options = {
  template: `
    #set page(paper: "a4")

    = Monthly Report

    Generated on #datetime.today().display()
  `,
  font: Array.from(font),
};

const pdfBytes = render_pdf(render_options, null);
fs.writeFileSync('report.pdf', pdfBytes);

This is particularly valuable for backend services that need to generate documents without installing system-level dependencies like wkhtmltopdf, LibreOffice, or a headless Chrome instance. The WASM module is self-contained: it needs only the font file you provide.

Why Rust Makes This Work

It is worth pausing to appreciate why Rust is uniquely suited for this kind of project.

WebAssembly target maturity

Rust's WASM toolchain β€” wasm-pack, wasm-bindgen, and the wasm32-unknown-unknown target β€” is arguably the most mature in any systems language. The #[wasm_bindgen] attribute handles the serialization boundary between JavaScript and Rust automatically. Types like Vec<u8> become Uint8Array. Result<T, E> becomes a value-or-throw pattern.

Memory safety without garbage collection

The Typst compiler processes complex document trees, resolves fonts, performs layout calculations, and serializes PDF structures. In a garbage-collected language, this workload would introduce unpredictable pauses. In C or C++, it would be a minefield of buffer overflows. Rust gives you deterministic performance with compile-time safety guarantees β€” exactly what you want when running a compiler inside another runtime.

Performance with small binaries

The release profile uses opt-level = "s" combined with link-time optimization. This means the WASM binary is as small as the compiler can make it while still running at near-native speed. For a library that gets downloaded to the browser on every page load, binary size matters.

The ecosystem effect

This project exists because Typst is written in Rust, typst-as-lib wraps it for library use, and wasm-pack makes cross-compilation trivial. None of these pieces were built specifically for pdf-generator, but because they all share the same language and toolchain, they compose cleanly. This is the Rust ecosystem working as intended.

Use Cases

The most obvious applications involve any document that combines a fixed layout with dynamic data:

  • Invoices and receipts β€” Generate client-specific billing documents directly in the browser after a checkout flow, with no round-trip to the server.
  • Reports and dashboards β€” Compile data summaries into formatted PDFs for download. Useful in analytics tools, admin panels, or CRM systems.
  • Certificates and credentials β€” Produce personalized certificates (course completion, event attendance) on the fly.
  • Contracts and forms β€” Pre-fill legal or administrative documents with user-supplied data.
  • Offline-capable applications β€” Since PDF generation runs entirely in the browser, it works without network access once the WASM module and fonts are cached.

Limitations and Considerations

A few things to keep in mind before adopting this approach:

  • WASM binary size. The Typst compiler is not trivial. Even with size optimizations, the .wasm file will be larger than a typical JavaScript bundle. Lazy loading and caching strategies (service workers, HTTP caching headers) can mitigate this.
  • Font management. Fonts must be passed as raw byte arrays. You need to fetch or bundle the fonts yourself. There is no system font access from within WASM.
  • Single font per call. The current API accepts one font in the render options. Documents requiring multiple font families may need API extensions.
  • Error messages. Typst compilation errors are surfaced through Rust's error chain, but messages crossing the WASM boundary can lose context. The console_error_panic_hook crate helps, but debugging template issues is easier with the standalone Typst CLI during development.

Getting Started

Clone the repository and build:

terminal
git clone https://github.com/markonyango/pdf-generator.git
cd pdf-generator

# For browser use
wasm-pack build --target web --no-opt --dev

# Start a local server and open index.html
python3 -m http.server

The included index.html provides a working demo: paste a Typst template, upload a font file, and generate a PDF in your browser. It even includes an example invoice template that demonstrates data injection via #import sys: inputs.

Final Thoughts

pdf-generator sits at an interesting intersection of technologies: Rust's safety and performance, WebAssembly's portability, and Typst's modern approach to document compilation. The result is a library that makes client-side PDF generation not just possible but practical.

If you have been fighting with jsPDF's limited layout capabilities, wrestling with Puppeteer's resource overhead, or maintaining a server-side PDF generation service that you wish did not exist, this project is worth your attention.

The codebase is compact (the core logic fits in a single lib.rs), the API is minimal, and the MIT license means you can use it however you like. Check out the project on GitHub, open an issue if you run into problems, and consider contributing if you see something you would improve.