The pipeline
odf-kit reads the .odt file and emits Typst markup. The Typst CLI compiles it to PDF. odf-kit handles the hard part — Typst handles the rendering.
Try it online — no CLI needed
Convert your ODT file to Typst markup in your browser, then compile to PDF at typst.app. Free, private — your file never leaves your device.
Open the ODT to PDF tool →Quick start
Install odf-kit and the Typst CLI:
# Install odf-kit npm install odf-kit # Install the Typst CLI (choose one) npm install -g typst # via npm brew install typst # macOS winget install --id Typst.Typst # Windows
Convert a .odt file to PDF:
import { odtToTypst } from "odf-kit/typst"; import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; const bytes = new Uint8Array(readFileSync("document.odt")); // Step 1 — convert .odt to Typst markup const typ = odtToTypst(bytes); writeFileSync("document.typ", typ); // Step 2 — compile to PDF execSync("typst compile document.typ document.pdf"); // document.pdf is ready
Parse once, emit to multiple formats
If you already have a parsed OdtDocumentModel — for example from readOdt() — use modelToTypst() directly to avoid re-parsing the file. This is the preferred approach when generating both HTML and PDF from the same document:
import { readOdt } from "odf-kit/reader"; import { modelToTypst } from "odf-kit/typst"; import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; const bytes = new Uint8Array(readFileSync("document.odt")); // Parse once const model = readOdt(bytes); // Emit to HTML const html = model.toHtml({ fragment: true }); writeFileSync("document.html", html); // Emit to Typst, compile to PDF const typ = modelToTypst(model); writeFileSync("document.typ", typ); execSync("typst compile document.typ document.pdf");
What gets converted
| ODT element | Typst output | Status |
|---|---|---|
| Headings (levels 1–6) | = H1, == H2, … | ✓ |
| Paragraphs | Block text with blank-line separation | ✓ |
| Text alignment | #align(center)[…] | ✓ |
| Bold | *text* | ✓ |
| Italic | _text_ | ✓ |
| Underline | #underline[text] | ✓ |
| Strikethrough | #strike[text] | ✓ |
| Superscript / subscript | #super[] / #sub[] | ✓ |
| Hyperlinks | #link("url")[text] | ✓ |
| Font color | #text(fill: rgb("…"))[…] | ✓ |
| Font size | #text(size: 14pt)[…] | ✓ |
| Font family | #text(font: "…")[…] | ✓ |
| Highlight color | #highlight(fill: rgb("…"))[…] | ✓ |
| Unordered lists | - item | ✓ |
| Ordered lists | + item | ✓ |
| Nested lists | Two-space indent per level | ✓ |
| Tables | #table(columns: N, …) | ✓ |
| Table column widths | columns: (5cm, 10cm) | ✓ |
| Footnotes / endnotes | #footnote[…] | ✓ |
| Bookmarks | <label> | ✓ |
| Page number field | #counter(page).display() | ✓ |
| Page count field | #counter(page).final().first() | ✓ |
| Named sections | Comment header + body | ✓ |
| Tracked changes | #underline[] / #strike[] / transparent | ✓ |
| Page geometry | #set page(width:, height:, margin: (…)) | ✓ |
| Images | Comment placeholder (see below) | Placeholder |
Images in the PDF
Typst does not support inline base64 image data without filesystem access. Each image in the source .odt is emitted as a comment placeholder at its document position:
/* [image: logo 10cm × 6cm] */
To include images in the final PDF, extract the image data from the document model and write the files alongside the .typ output before compiling:
import { readOdt } from "odf-kit/reader"; import { modelToTypst } from "odf-kit/typst"; import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; const bytes = new Uint8Array(readFileSync("document.odt")); const model = readOdt(bytes); // Extract images from the model and write them to disk let typ = modelToTypst(model); for (const node of model.body) { if (node.kind === "paragraph") { for (const span of node.spans) { if ("kind" in span && span.kind === "image" && span.name) { const ext = (span.mediaType ?? "image/png").split("/")[1]; const filename = `${span.name}.${ext}`; writeFileSync(filename, Buffer.from(span.data, "base64")); // Substitute the placeholder with a real #image() call typ = typ.replace( `/* [image: ${span.name}`, `#image("${filename}") /* was:` ); } } } } writeFileSync("document.typ", typ); execSync("typst compile document.typ document.pdf");
Why not shell out to LibreOffice?
LibreOffice headless (libreoffice --headless --convert-to pdf) is the traditional approach. It works, but comes with real costs in production:
- Startup time — LibreOffice takes 2–5 seconds per conversion even for small documents
- Deployment size — LibreOffice is several hundred megabytes and dominates Docker image size
- Serverless incompatibility — LibreOffice doesn't run on AWS Lambda, Vercel, Cloudflare Workers, or similar environments without significant workarounds
- Security surface — spawning a process with filesystem access from your application is a meaningful attack surface
- Fragility — headless LibreOffice is notoriously unreliable in containerized environments; fonts, locale, and display settings all affect output
The Typst CLI is a single small binary that starts in milliseconds, produces deterministic output, and runs cleanly in containers. odf-kit's Typst emitter adds zero new JavaScript dependencies.
Comparison with other approaches
| Approach | No LibreOffice | No subprocess | Serverless | npm install |
|---|---|---|---|---|
| odf-kit + Typst CLI | ✓ | Typst only | ✓ (container) | ✓ |
| LibreOffice headless | ✗ | ✗ | ✗ | ✗ OS install |
| docxtemplater + LibreOffice | ✗ | ✗ | ✗ | Partial |
| Pandoc (ODT → PDF) | ✗ Needs LaTeX | ✗ | ✗ | ✗ OS install |
Typst CLI installation options
# via npm (works on all platforms) npm install -g typst # macOS (Homebrew) brew install typst # Windows (winget) winget install --id Typst.Typst # Linux (many distros — check repology.org/project/typst) sudo apt install typst # Debian/Ubuntu (if available) sudo pacman -S typst # Arch Linux # Verify installation typst --version
ESM setup
odf-kit is ESM-only. Your script must use import syntax. Either add "type": "module" to your package.json, or use a .mjs file extension:
// package.json { "type": "module" } // convert.mjs — or convert.js with "type": "module" above import { odtToTypst } from "odf-kit/typst"; import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; const [,, input, output] = process.argv; const bytes = new Uint8Array(readFileSync(input)); writeFileSync("out.typ", odtToTypst(bytes)); execSync(`typst compile out.typ ${output}`);
odf-kit does more than convert
The same library that converts .odt files to PDF can also create .odt files from scratch, fill existing templates with data, and read .odt files as structured HTML. One install, one dependency.
- Convert .odt to HTML in JavaScript — extract content for web display
- Fill .odt templates with JavaScript — design in LibreOffice, fill from code
- Generate .odt files in Node.js — build documents from scratch
- Generate .odt without LibreOffice — lightweight alternative overview