DEV Community

Cover image for We Replaced Headless LibreOffice with a Single Rust Binary for DOCX PDF
Ilya Nixan
Ilya Nixan

Posted on • Originally published at nerdy.pro

We Replaced Headless LibreOffice with a Single Rust Binary for DOCX PDF

We had a document pipeline in production that turned DOCX templates into PDFs. It ran on headless LibreOffice. It worked, most of the time, and every time it didn't, it failed in a way that was somebody's whole afternoon.

So we wrote our own: dxpdf, a single Rust binary that parses OOXML directly and renders with Skia. No Microsoft Office. No LibreOffice. No cloud API. This is the story of why we did that, what the architecture looks like, and the benchmark result that surprised us.

It's open source. Repo.

TL;DR

  • Headless LibreOffice is a great renderer and a miserable production dependency: it serializes conversions per profile, spawns processes that hang, needs per-worker profile directories, and quietly changes its output between versions.
  • We replaced it with a Rust binary that parses OOXML → lays it out in a unit-typed engine → rasterizes/draws with Skia → writes PDF.
  • On an M3 Max it converts a 3-page doc in 170 ms and a 171-page doc in 420 ms — and the counter-intuitive part is that font resolution, not document size, dominates the cost.
  • It's not a full LibreOffice replacement: 74 OOXML features are fully implemented, 11 partial, 12 not yet supported. We'll list the gaps.

What headless LibreOffice actually costs you

First, credit where it's due: LibreOffice's format coverage is enormous. It has absorbed decades of edge cases from real-world .doc/.docx/.odt files, and for a one-off "convert this file" it's genuinely hard to beat. If you run it interactively, none of the below applies.

Running it headless, in production, under load is a different animal:

  • It serializes. A soffice instance is effectively single-conversion-at-a-time. To get concurrency you run N instances, and now you're a process supervisor.
  • Per-instance profile directories. Each instance wants its own -env:UserInstallation profile dir. Share one and you get lock contention and corrupted profiles; don't share and you're managing a pool of temp dirs and cleaning them up when workers die mid-conversion.
  • It hangs. A malformed input, a missing font, a particular embedded object — and the process sits there forever instead of erroring. You end up wrapping every call in a timeout-and-kill, then reaping zombie soffice children.
  • Output drift. The same input produces a slightly different PDF across LibreOffice versions — reflowed lines, shifted tables, changed font substitution. If your output is contractual or diffed, "upgrade the base image" becomes a visual-regression event.
  • It's heavy. Dragging a full office suite into a container image for one job is hundreds of megabytes and a slow cold start.

None of these are bugs, exactly. They're what it means to run an interactive desktop application as a stateless service.

What we actually needed

Written as requirements, not complaints:

  1. Deterministic output — same bytes in, same bytes out, forever.
  2. Real concurrency — no profile-dir dance, no per-conversion process to babysit.
  3. Predictable latency — a tail we can reason about.
  4. A small artifact — one binary in a scratch-ish container.
  5. Data stays on the box — no cloud conversion API.

We looked at the alternatives before writing code, because writing a document renderer is not a decision you make lightly. Other soffice wrappers inherit all of the above. Cloud APIs mean the document leaves your infrastructure and you pay per page. The Chromium/HTML route (render DOCX → HTML → print-to-PDF) loses fidelity on exactly the DOCX features that matter — pagination, headers/footers, section breaks. If our documents were simple enough for the HTML route, we'd have taken it. They weren't.

The architecture

dxpdf is a pipeline:

DOCX (OOXML) ──▶ parser ──▶ unit-typed layout engine ──▶ Skia ──▶ PDF
                                     │
                        HarfBuzz (shaping) · ICU4X (i18n)
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out:

We parse OOXML directly. A .docx is a zip of XML (WordprocessingML). We read it and build our own document model rather than driving an office app to do it. That's the whole reason the output is deterministic — there's no interactive application state in the loop.

The layout engine is unit-typed. OOXML mixes units freely: twips (1/1440 inch) for most measurements, EMUs (1/914400 inch) for DrawingML, half-points for font sizes, plus the pixels and points you eventually render into. The single most common way a document renderer goes wrong is a silent unit mismatch — a value in twips added to a value in points, and nobody notices until a margin is off by a factor of 20. So units are types. You cannot add a Twip to a Point; the compiler stops you. This one constraint has caught more layout bugs at compile time than any test suite could.

Skia does the drawing. The same rendering engine behind Chrome and Flutter. We get battle-tested text and vector rasterization instead of reimplementing it.

HarfBuzz shapes, ICU4X internationalizes. Text shaping (turning a string + font into positioned glyphs) is HarfBuzz. Line breaking, bidi, and locale data are ICU4X, with the locale data shipped as a single trimmed blob via icu_provider_blob so there's no external ICU dependency to install.

The honest coverage gate

dxpdf is not LibreOffice. As of 0.5.0: 74 OOXML features fully implemented, 11 partial, 12 not yet supported.

The fully-supported set covers what most template-to-PDF pipelines actually use: paragraphs and runs, styles, tables, headers/footers, sections and page setup, lists and numbering, images, fields (dates, page numbers), fonts with bold/italic synthesis. The partial and unsupported set is where you should check your own documents before adopting — some advanced DrawingML, certain field types, and a handful of legacy constructs aren't there yet.

We'd rather you hit "unsupported feature: X" with a clear error than get a subtly wrong PDF. If your documents live entirely in the supported set, dxpdf is a drop-in. If they don't, the gap list tells you exactly what to test.

Two things dxpdf does that headless LibreOffice doesn't

Coverage isn't a one-way street. There are places we deliberately went past what a headless LibreOffice export gives you:

  • Colour emoji in a headless Linux container. Export a document with 🎉 or 🚀 through headless LibreOffice on a typical Linux server and you usually get monochrome outlines — or missing glyphs — because colour-emoji font formats (COLR/CPAL, CBDT/CBLC) aren't reliably handled without a desktop's font stack. dxpdf renders full-colour emoji through Skia + HarfBuzz whether or not there's a display, so the same document looks identical on your laptop and on a headless build agent.
  • Mixed page formats in a single document. OOXML lets every section carry its own w:pgSz — so one DOCX can be portrait A4 for the body and a landscape A3 for a single wide table. Headless LibreOffice tends to flatten that to one page size and orientation for the whole export. dxpdf honours per-section page setup, so a mixed-format DOCX comes out as a PDF with genuinely different page sizes and orientations, page by page.

Performance — and the surprise

Benchmarked on an Apple M3 Max, 30 runs, 5 warmup runs discarded:

Document Size Time
3 pages 34 KB 170 ms
9 pages 1.3 MB 55 ms
171 pages 14 MB 420 ms

Look at the first two rows. The 9-page, 1.3 MB document converts in 55 ms, but the 3-page, 34 KB one takes 170 ms — three times longer for a document a fraction of the size. That's not a typo, and it points at the real cost model.

Font resolution, not document size, decides what a conversion costs. Resolving a font the OS has to find and load runs 120–185 ms; a font that's embedded in the document or already cached is about 4 ms. The 3-page document referenced fonts we had to go find on the system; the 9-page one used embedded/cached fonts, so it skipped the expensive part almost entirely. The 171-page document is dominated by actual layout work, which is why it scales with content the way you'd expect.

The practical takeaway if you run this at scale: embed your fonts, or warm a font cache. Do that and small-document latency collapses from ~170 ms to tens of milliseconds. It's the highest-leverage knob, and it's invisible until you measure per-stage instead of per-document.

Operating it

The contrast with the old pipeline is the point:

  • One binary. Ships as a CLI, a Python package on PyPI, and a Debian .deb. The container is a binary, not an office suite.
  • Concurrency is just processes/threads. No profile directories, no pool, no lock file.
  • Determinism. Same input → same output bytes, across runs and across machines. Diffing generated PDFs in CI actually works.
  • Failure is an error, not a hang. Unsupported input returns a clear error instead of a wedged process you have to time out and kill.

What's new in 0.5.0

0.5.0 was our internationalization release, and it's a good illustration of the "we control the whole stack" upside:

  • UAX #14 line breaking for space-less scripts — Thai, Lao, Khmer, Burmese — where you can't just break on spaces.
  • UAX #9 bidirectional text with rule L4 mirroring for Arabic and Hebrew.
  • HarfBuzz shaping for cursive-joining scripts (Arabic, Syriac, N'Ko, Mongolian, Adlam).
  • CLDR-driven localization: region-aware decimal separators, localized DATE/TIME fields, spelled-out numbers in English, German, French, and Spanish.
  • Font synthesis for bold/oblique, pagination fixes, Windows CI, Debian packaging.
  • And our first external contribution — Russian numbering formats and list-label fixes. That one made our week.

Full notes: dxpdf 0.5.0.

FAQ

How do I convert DOCX to PDF without LibreOffice?
Use a library or binary that parses OOXML and renders directly. dxpdf is one: a standalone Rust binary (also a Python package and .deb) that turns DOCX into PDF with no Office or LibreOffice dependency.

Why is headless LibreOffice unreliable at scale?
It's an interactive desktop app running as a service. It serializes conversions per profile, needs managed profile directories, can hang on bad input, and changes its output between versions — all manageable, none free.

Is dxpdf production-ready?
For documents inside its supported feature set, yes — it's what we run. Check the coverage gate (74 full / 11 partial / 12 unsupported OOXML features) against your own templates first.

Does dxpdf need Microsoft Office installed?
No. It parses the OOXML directly. No Office, no LibreOffice, no cloud service.

How fast is it?
On an M3 Max, 170 ms for a 3-page doc and 420 ms for 171 pages. Small-document time is dominated by font resolution — embed fonts or warm a cache and it drops to tens of ms.

Can I use it from Python?
Yes, it's published on PyPI in addition to the CLI and Debian package.


dxpdf is open source — if you've ever fought a soffice pipeline at 3am, we'd love for you to try it, star it, or file the one DOCX feature you need next. And if you build systems like this — Rust, rendering, or Flutter — that's what we do at nerdy.pro.

Top comments (0)