Show HN: I built a zero-browser, pure-JS typesetting engine for bit-perfect PDFs

2026-03-0112:257857github.com

A pure-JS, zero-dependency typesetting engine that yields bit-perfect PDF output across any runtime—from Cloudflare Workers to the browser. Stop using Headless Chrome to print text. - cosmiciron/vm...

:: Deterministic typesetting for the programmable web.

If you generate PDFs with headless browsers or HTML-to-PDF tools, you've accepted a compromise: heavy dependencies, memory leaks, and "approximate" layout that shifts across environments. VMPrint offers a stable, high-performance alternative. It composes documents from a versioned JSON instruction stream and guarantees identical layout given identical input, down to the sub-point position of every glyph.

Open-source documentation deserves better than the "crude" look of standard Markdown-to-PDF exports. Read the beautifully typeset PDF version of this README — generated from this source file using draft2final and the opensource flavor (a gift to help the community move past boring documents, and a gentle nod to a director's benevolent insistence on aesthetic standards).

VMPrint manifesto

Publication-grade layout rendered directly by the VMPrint engine. The above image -- including all annotations, measurement guides, legends, and script direction markers -- are rendered entirely by VMPrint. The source documents are available in the repository under /documents/readme/.

  • Deterministic layout engine with a versioned instruction schema
  • Zero browser or Node.js dependencies — runs anywhere
  • Multilingual text shaping: Latin, CJK, RTL scripts
  • Pluggable font and rendering backends
  • Layout output is JSON — snapshot it, diff it, inspect it
  • Identical PDF output across machines and runtimes
  • 88 KiB core, renders complex documents in milliseconds

VMPrint manifesto

One engine. Every script. Baselines, shaping, and directionality remain stable across mixed-language content. The above image -- including all annotations, measurement guides, legends, and script direction markers -- are rendered entirely by VMPrint. The source documents are available in the repository under /documents/readme/.

In the 1980s and 90s, serious software thought seriously about pages. TeX got hyphenation and justification right. Display PostScript gave NeXT workstations a real imaging model — every application had access to typographically precise, device-independent layout at the OS level. Desktop publishing software understood widows, orphans, and the subtle difference between a line break and a paragraph break.

Then the web happened. Mostly great. But somewhere along the way, "generate a PDF" became either "run a headless Chromium instance" or "write your own pagination loop against a low-level drawing API." Neither of these is good. The thinking that went into document composition — the kind that made TeX and PostScript genuinely good — largely disappeared from the toolkit of the working developer.

VMPrint is an attempt to recover some of what was lost.

VMPrint works in two stages, and keeping them separate is the whole point.

Stage 1 — Layout. You give it a document: structured JSON, or Markdown via draft2final. It measures glyphs, wraps lines, handles hyphenation, paginates tables, controls orphans and widows, places floats. It produces a Page[] stream — an array of pages, each containing a flat list of absolutely-positioned boxes.

Stage 2 — Rendering. A renderer walks those boxes and paints them to a context. Today that context is a PDF. Tomorrow it could be canvas, SVG, or a test spy.

The Page[] stream is the thing that makes this different. It's serializable JSON. You can diff it between versions. You can snapshot it for regression tests. You can inspect it to understand why something ended up where it did. Layout bugs become reproducible. This is not how PDF generation usually works.

Layout is based on real font metrics. VMPrint loads actual font files, reads glyph advance widths and kerning data, and measures text the way a typesetting system does — not the way a browser estimates it from computed styles. There is no CSS box model underneath. Same font files, same input, same config: identical output, down to the sub-point position of every glyph.

const engine = new LayoutEngine(config, runtime);
await engine.waitForFonts(); // pages is a plain Page[] — inspect it, snapshot it, diff it
const pages = engine.paginate(document.elements); const renderer = new Renderer(config, false, runtime);
await renderer.render(pages, context);

The core engine has no dependency on Node.js, the browser, or any specific JavaScript runtime. It doesn't call fs. It doesn't touch the DOM. It doesn't assume Buffer exists. Font loading and rendering are injected through well-defined interfaces — the engine itself is pure, environment-agnostic JavaScript.

In practice: run VMPrint in a browser extension, a Cloudflare Worker, a Lambda, and a Node.js server. The layout output is identical. Same page breaks. Same line wraps. Same glyph positions. The rendering context changes; the layout does not.

This is not a promise about "should work in theory." It's an architectural constraint that was enforced from the beginning.

Headless Chrome / Puppeteer: Works great until it doesn't. Cold starts are slow. Output drifts across browser versions. Edge runtimes typically can't run it at all. You're maintaining a Chromium dependency to produce text in a box — and Chromium is ~170 MB on disk. VMPrint's full dependency tree, including the font engine that makes real glyph measurement possible, is ~2 MiB packed and ~8.7 MiB unpacked.

PDFKit / pdf-lib / react-pdf: You're writing pagination. "If this paragraph doesn't fit, cut here, carry the rest to the next page" — by hand, for every element type, including tables that span pages and headings that must stay with what follows them.

LaTeX: Genuinely excellent at what it does. Also requires a TeX installation, a 1970s input format, and an afternoon of fighting package conflicts.

VMPrint handles the pagination. You describe your document. It figures out where things break.

I'm a film director. I hated writing in screenplay software, so I started writing in plain text. Then I wrote a book in Markdown and wanted industry-standard manuscript output — and found no tool I trusted to get there without pain.

Low-level PDF libraries made me implement my own pagination. Headless browser pipelines were heavy and unpredictable. So I took a detour and built a layout engine first.

The manuscript is still waiting. The engine shipped instead.

Prerequisites: Node.js 18+, npm 9+

git clone https://github.com/cosmiciron/vmprint.git
cd vmprint
npm install

Render a JSON document to PDF:

npm run dev --prefix cli -- --input engine/tests/fixtures/regression/00-all-capabilities.json --output out.pdf

Markdown to PDF (screenplay format):

npm run dev --prefix draft2final -- build draft2final/tests/fixtures/screenplay-sample.md -o screenplay.pdf --format screenplay
import fs from 'fs';
import { LayoutEngine, Renderer, toLayoutConfig, createEngineRuntime } from '@vmprint/engine';
import { PdfContext } from '@vmprint/context-pdf';
import { LocalFontManager } from '@vmprint/local-fonts'; const runtime = createEngineRuntime({ fontManager: new LocalFontManager() });
const config = toLayoutConfig(documentInput);
const engine = new LayoutEngine(config, runtime); await engine.waitForFonts();
const pages = engine.paginate(documentInput.elements); const output = fs.createWriteStream('output.pdf');
const context = new PdfContext(output, { size: [612, 792], margins: { top: 0, right: 0, bottom: 0, left: 0 }, autoFirstPage: false, bufferPages: false
}); const renderer = new Renderer(config, false, runtime);
await renderer.render(pages, context);

Pagination

  • keepWithNext, pageBreakBefore, orphan and widow controls
  • Tables that span pages: colspan, rowspan, row splitting, repeated header rows
  • Drop caps
  • Story zones with float-aware text wrapping
  • Inline images and rich objects on text baselines
  • Continuation markers when content splits across pages

Typography and Multilingual

Most libraries treat international text as an optional concern — get ASCII layout working first, bolt on Unicode support later. VMPrint's text pipeline was built correctly from the start, because the alternative produces subtly wrong output for most of the world's writing systems.

  • Text segmentation uses Intl.Segmenter for grapheme-accurate line breaking. A grapheme cluster spanning multiple Unicode code points is always treated as a single unit.
  • CJK text breaks correctly between characters, without needing spaces.
  • Indic scripts are measured and broken as grapheme units, not codepoints.
  • Language-aware hyphenation applies per text segment, so a document mixing English and French body text hyphenates each according to its own rules.
  • Mixed-script runs — Latin with embedded CJK, inline code within prose — share the same baseline and are measured correctly across font boundaries.
  • Two justification modes: space-based (standard for Latin) and inter-character (standard for CJK and some print conventions).

RTL/bidi support is partial today. Full UAX #9-grade bidirectional behavior is a v1.x item.

VMPrint manifesto

VMPrint manifesto

VMPrint manifesto

Multilingual Rendering. The images above — including all annotations, measurement guides, legends, and script direction markers — are rendered entirely by VMPrint. Source document can be found in the repository under engine\tests\fixtures\regression.

Architecture

  • Core engine is pure TypeScript with zero runtime environment dependencies — no Node.js APIs, no DOM, no native modules
  • One codebase runs in-browser, Node.js, serverless, and edge runtimes with identical layout output
  • Swappable font managers and rendering contexts via clean interfaces
  • Overlay hooks for watermarks, debug grids, and print marks
  • Input immutability and snapshot-friendly output for regression testing

VMPrint is built for sustained throughput. The measurement cache, font cache, and glyph metrics cache are all shared across LayoutEngine instances that use the same EngineRuntime — so batch pipelines get faster as the runtime warms up, not slower.

On a 9-watt low-power i7, the engine's most complex regression fixture — 8 pages of mixed-script typography, floated images, and multi-page tables — completes in:

Scenario font load layout total
Warm (shared runtime, batch pipeline) ~10 ms ~66 ms ~87 ms
Cold (fresh process, first invocation) ~53 ms ~239 ms ~292 ms

The warm figure is what batch PDF generation looks like after the first document has been processed: fonts are already parsed, text measurements are cached, and paginate() spends its time on composition rather than measurement. The cold figure is what a fresh CLI invocation sees — fonts parsed from disk, measurement cache empty, JIT compilation running through the hot paths for the first time.

Run the full benchmark suite yourself:

cd engine && npm run test:perf -- --repeat=5

Or profile a specific document with the CLI's --profile-layout flag, which runs the document cold then twice more warm and reports both:

[vmprint] cold  fontMs: 53.07 | layoutMs: 239.21 | total: 292.28 (8 pages)
[vmprint] warm  fontMs: 0.21  | layoutMs: 68.44  | total: 68.65  (avg ×2)

Footprint: The core engine is 88 KiB packed. The full dependency tree, including fontkit for OpenType parsing, is ~2 MiB packed and ~8.7 MiB unpacked — versus Chromium's ~170 MB. The largest single dependency is fontkit (~1.1 MiB packed), which is the cost of reading real glyph metrics rather than approximating them from computed styles. Among headless PDF tools, that's not bloat — it's the price of correctness.

Because the pipeline is synchronous and the footprint is minimal, VMPrint can run directly in edge environments (Cloudflare Workers, Vercel Edge, AWS Lambda) where other solutions often exceed memory or cold-start limits. It is fast enough to serve PDFs synchronously in response to user requests, without background job queues.

This is a monorepo:

Package Purpose
@vmprint/contracts Shared interfaces
@vmprint/engine Deterministic typesetting core
@vmprint/context-pdf PDF output context
@vmprint/local-fonts Filesystem font loading
@vmprint/cli vmprint JSON → bit-perfect PDF CLI
@draft2final/cli Markdown → bit-perfect PDF compiler

The monorepo is layered so that getting involved at any depth is straightforward.

Engine (engine/): Layout algorithms, pagination, text shaping, the packager system. This is where the hard problems live — and where a well-placed contribution has the most leverage. Regression snapshot tests make it possible to verify that changes haven't broken existing behavior.

Contexts and Font Managers (contexts/, font-managers/): Concrete implementations of well-defined interfaces. A new context for canvas or SVG output. A font manager that loads from a CDN or a bundled asset. The contracts are clear, the surface area is contained, and a working implementation is immediately useful to anyone on that platform.

Draft2Final format flavors: New document formats are purely declarative — a flavor module specifies fonts, margins, heading styles, and how Markdown constructs map to VMPrint elements. No pagination code. No layout code. If you know what a correctly formatted academic paper, legal brief, or technical report should look like, you can write a flavor.

Draft2Final format scripts: A single TypeScript file that maps a normalized Markdown AST to DocumentInput. The system hands you a structured, source-annotated document; you decide what it becomes. Screenplay, novel, contract, invoice — each is its own file.

npm run test --prefix engine
npm run test:update-layout-snapshots --prefix engine
npm run test --prefix draft2final
npm run test:packaged-integration

Version 0.1.0. The core layout pipeline is working and covered by regression fixtures. PDF output is the production-ready path. RTL/bidi support is partial — full Unicode bidirectional behavior is on the roadmap for v1.x.

This is pre-1.0 software. The API may change.

Architecture · Quickstart · Contributing · Testing · Roadmap

Apache 2.0. See LICENSE.


Read the original article

Comments

  • By TimTheTinker 2026-03-025:572 reply

    > If you generate PDFs with headless browsers or HTML-to-PDF tools, you've accepted a compromise: heavy dependencies, memory leaks, and "approximate" layout that shifts across environments

    Absolutely not true with Prince[0]. It's an HTML/CSS-based typesetter built by the creator of CSS (Håkon Wium Lie [1]) that is lightweight, cross-platform, requires no dependencies, has no memory leaks, is 100% consistent in its output, is fully compliant with the relevant standards, and has a lot of really great print-oriented features (like using CSS to control things like page headers/footers, numbering, etc.). Prince has been used to typeset a lot of different print output types, from posters to books to scientific papers. It's even a viable alternative to LaTex. I've used it in the past, and can attest that it is outstanding.

    [0] https://www.princexml.com/

    [1] https://en.wikipedia.org/wiki/H%C3%A5kon_Wium_Lie

    • By cosmiciron 2026-03-026:271 reply

      Thanks for the correction. I'm actually not familiar with Prince, so I really can't tell.

      To be clear, VMPrint isn't meant to compete with established engines like that. It’s just a genuinely helpful tool I built from scratch for the specific tasks I needed to accomplish because I couldn't find an alternative.

      Prince looks powerful, but I have a feeling it probably wouldn't have been the right fit for my use case anyway.

      • By irrationalfab 2026-03-026:364 reply

        Prince starts at 2k. This is OSS

        • By Semaphor 2026-03-027:252 reply

          Weasyprint [0] is OSS and supports CSS Paged media. I’m not actually sure why you’d ever use something like this project (or headless chrome for that matter, maybe you need some automated login as well?) which doesn’t.

          print-css.rock [1] has a good overview of available tools and their features.

          [0]: https://github.com/Kozea/WeasyPrint

          [1]: https://print-css.rocks/

          • By cosmiciron 2026-03-028:101 reply

            Because CSS is exactly what I wanted to avoid. I just needed predictable pager layout, and I didn't want to wrestle with CSS. Besides, this thing's tiny size allows it to run in a serverless function on the Edge, and that can be useful sometimes.

            • By Semaphor 2026-03-028:201 reply

              Why use HTML if you want to avoid CSS? HTML default styles only?

              • By cosmiciron 2026-03-029:141 reply

                Who said I used HTML? It was impossible to write screenplay/manuscript in HTML and receive industry compliant outputs when printed/converted to PDF.

                • By TimTheTinker 2026-03-0412:411 reply

                  Sounds like you need Scrivener. It outputs industry standard formats for all kinds of manuscripts.

                  • By cosmiciron 2026-03-0515:51

                    Its UI is too complicated for my taste. Besides, its screenplay support isn't perfect.

          • By fmajid 2026-03-029:02

            And Weasyprint does not have browser dependencies either, which is great.

            Fun fact: I had to write a routine administrative letter for my parents in another country, I asked Claude to do so in PDF form so I could email it to them they would print it and mail it. The way it did so was to write a Python program using Weasyprint to generate the PDF...

        • By TimTheTinker 2026-03-0215:25

          For a personal desktop license, it's $495.

          If you need something specific for a hosted service and aren't able to pay the full license fee, I can attest from personal experience that Håkon Wium Lie is very friendly and can probably work something out with you.

        • By Koshkin 2026-03-0221:53

          What does price have to do with anything.

        • By JOHN34567 2026-03-0212:20

          [dead]

  • By raphlinus 2026-03-024:472 reply

    Unfortunately, your complex script shaping for Arabic and Devanagari is wrong. The Arabic is missing the joining (all forms are isolated), and the Devanagari doesn't have the vowels combining (so you see those dotted circles).

    To fix this you'll need Harfbuzz or something similar. Taking a quick look at the code, it seems like you're just doing a glyph at a time through the cmap. That, uh, won't do.

    • By alfiedotwtf 2026-03-027:411 reply

      As the person who implemented GSUB support for Arabic in Prince (via the Allsorts Rust crate), this post highly intrigued me… especially because I wanted to see how they implemented GSUB for Opentype while being a film director and possibly stunt double on the side.

      After seeing your comment, I’m saddened to see that OP and their comments in this threat are just bots.

      • By cosmiciron 2026-03-029:16

        I was an animation director, so I didn't do stunts. Sorry.

    • By cosmiciron 2026-03-025:412 reply

      You are completely right on all fronts. Thank you for taking a look at the code!

      You hit the exact architectural bottleneck. Right now, the engine uses Intl.Segmenter to find the grapheme boundaries, but then it just does a direct cmap lookup to get the advance widths. It currently lacks a parser for the OpenType GSUB (Glyph Substitution) and GPOS (Glyph Positioning) tables, which is why Arabic defaults to isolated forms and Indic matras don't fuse.

      The standard advice is exactly what you suggested: "just drop in HarfBuzz." But that creates an existential problem for this specific project. HarfBuzz is a massive C++ library. To run it in an Edge worker or pure V8 environment, I'd have to ship a WebAssembly binary that is often upwards of 1MB. That entirely defeats the purpose of building an 88 KiB, pure-JS, zero-dependency layout VM.

      Doing complex text layout (CTL) and shaping purely in JavaScript without exploding the bundle size is essentially the final boss of this project. The roadmap is to either implement a highly tree-shakeable, pure-JS parser for the most critical GSUB/GPOS rules, or find a way to pre-compile shaping instructions.

      For right now, it's a known trade-off: lightning-fast, edge-native pure JS layout, at the cost of failing on complex cursive ligatures. If you know of any micro-footprint pure-JS shaping libraries that don't rely on WASM, I am all ears!

      • By Yiin 2026-03-025:592 reply

        Not sure what's the point of it being so fast and so small if it's also wrong.

        • By attila-lendvai 2026-03-027:38

          what's the point of black and white statements that are wrong for a major subset of problems that the tool set out to solve?

        • By cosmiciron 2026-03-026:04

          And what's the point of being right when it's slow and bloated? Come on, it works for a lot of use cases, and it doesn't work for some. And it's still evolving.

      • By whosegotit 2026-03-026:05

        All your comments here appear to be run through an ai engine, while you might think it makes you sounds better if English isn’t your native tongue, it just comes off as insincere, I’d rather read your bad grammar than feel like I’m communicating with a clanker.

  • By LastTrain 2026-03-026:001 reply

    So this is what it has come to? AI bots writing code and fake origin stories of said code and AI bots commenting on it any other bots responding? This is front page content now? HN: please require all AI generated content to be flagged as such. Ban offenders. This just blows.

    • By wmf 2026-03-026:211 reply

      I think I'm more tired of AInvestigations than of AI now.

      • By 0815beck 2026-03-026:571 reply

        so why does every reply start with "good eye, you are right"

        • By goodmythical 2026-03-0216:56

          cuz peeps have tics

          How come so few of your questions end with question marks?

          Why is there so little capitalization?

          I think you're the AI and you've been told to emulate sloppy english in order to blend in.

HackerNews