The Problem
Converting Word documents to PDF is one of the most common tasks in business software. Invoices, contracts, reports, compliance forms — they start as .docx files and need to become PDFs for sharing, archiving, or printing.
The existing solutions all come with significant tradeoffs:
- Microsoft Office / LibreOffice — requires installing a full office suite on every server. LibreOffice's headless mode is slow, memory-hungry, and produces inconsistent results across versions. Scaling means running multiple instances that consume gigabytes of RAM.
- Cloud APIs (Google Docs, Adobe, CloudConvert) — adds latency, costs per conversion, and sends potentially sensitive documents to third-party servers. Not viable for regulated industries or air-gapped environments.
- HTML-to-PDF tools (wkhtmltopdf, Puppeteer) — requires converting DOCX to HTML first, losing formatting fidelity. Tables, headers/footers, and page breaks rarely survive the round trip.
None of these work well when you need fast, accurate, offline conversion at scale — especially in automated pipelines, CI/CD systems, or embedded applications where installing LibreOffice is not an option.
How dxpdf Solves It
dxpdf is a standalone DOCX-to-PDF converter written in Rust and powered by Google's Skia graphics library. It reads .docx files directly, parses the OOXML structure, and renders pixel-accurate PDF output — all in a single binary with no external dependencies beyond Skia.
A Flutter-inspired measure-then-position model ensures that text wrapping, table sizing, and page breaks match what Microsoft Word produces:
DOCX (ZIP) → Parse → Document Model → Resolve → Layout → Subset → Paint → PDF
Twips/Emu/HalfPoints ←──── Pt throughout ────→ Skia
Type-safe dimensions flow through the whole pipeline: OOXML units (Twips, Emu, HalfPoints) are i64-backed in the parsed model so they round-trip losslessly, layout works in typographic points (Pt), and raw f32 appears only at the Skia rendering boundary — so mixing units is a compile error rather than a document that is subtly wrong.
The result is a converter that turns a 3-page document with tables and images into a PDF in 170 ms using 54 MB of memory, and a 171-page, 14 MB document in 420 ms — fast enough to run inline in a request handler or batch-process thousands of documents.
What It Supports
Validated against ISO 29500 (Office Open XML): 75 entries fully implemented, 12 partial, 11 not yet supported — the full matrix lists every entry with its status. Highlights:
- Text formatting — bold, italic, underline, highlighting, font size, font family, color, character spacing, character scaling, superscript, subscript, run shading, run borders
- Paragraphs — alignment (left, center, right, justify, distribute), spacing, indentation, tab stops (incl. decimal, bar, and absolute-position), borders, shading, keep-with-next, keep-lines, widow/orphan control
- Tables — column widths, cell margins with 3-level cascade, merged cells, row heights, borders, cell shading, table styles with conditional formatting, nested and floating tables, row splitting across pages
- Images — inline (PNG, JPEG, GIF, BMP, WebP) and floating/anchored with alignment, wrapping, cropping, and percentage-based positioning
- Shapes and text boxes — DrawingML and VML shapes, shape text bodies with insets, anchoring, autofit, custom geometry
- Styles — paragraph and character styles with
basedOninheritance, document defaults, theme fonts - Headers and footers — text, images, page numbers (PAGE/NUMPAGES field codes), first-page and even/odd variants
- Lists — multi-level numbering: bullets, decimal, letter, roman, ordinal and spelled-out text, plus non-Latin sequences and picture bullets
- Navigation — clickable link annotations, bookmarks and cross-references as named destinations, and a PDF outline built from heading levels
- Sections — multiple page sizes and margins, section breaks, multi-column layouts, portrait and landscape orientations
- Equations — inline OMML (
m:oMath) math: math runs, superscripts and fractions, set in Word's default math face and able to carry a hyperlink or appear in an outline title - Layout — automatic pagination, footnotes and endnotes, word wrapping, line spacing modes, floating image text flow
- Internationalization — UAX #14 line breaking (incl. Thai, Lao, Khmer, Burmese), UAX #9 bidirectional text with mirroring, HarfBuzz shaping for cursive-joining scripts, and
w:lang-driven decimal separators, date pictures, and spelled-out numbers - Text and emoji — grapheme-correct segmentation and full-color emoji including ZWJ, modifier, keycap and flag sequences
Four Ways to Use It
Command-line tool
Install and run with a single command:
cargo install dxpdf
dxpdf input.docx -o output.pdf
Rust library
One function call — bytes in, bytes out:
let docx_bytes = std::fs::read("document.docx")?;
let pdf_bytes = dxpdf::convert(&docx_bytes)?;
std::fs::write("output.pdf", &pdf_bytes)?;
For more control, inspect the parsed document model before rendering:
use dxpdf::{docx, model, render};
let document = docx::parse(&std::fs::read("document.docx")?)?;
for block in &document.body {
match block {
model::Block::Paragraph(p) => { /* inspect paragraph */ }
model::Block::Table(t) => { /* inspect table */ }
model::Block::SectionBreak(props) => { /* inspect section properties */ }
}
}
let pdf_bytes = render::render(document, &dxpdf::RenderOptions::default())?;
Python package
Install from PyPI and use in any Python application:
pip install dxpdf
import dxpdf
# Bytes in, bytes out
pdf_bytes = dxpdf.convert(open("input.docx", "rb").read())
# File to file
dxpdf.convert_file("input.docx", "output.pdf")
Since 0.8.1 both calls release the GIL while the Rust engine works, so a thread pool converting several documents at once actually runs them in parallel rather than serializing on the interpreter.
Go package
Install with go get and call it like any other package:
go get github.com/nerdy-pro/dxpdf/go
import "github.com/nerdy-pro/dxpdf/go"
// Bytes in, bytes out
pdfBytes, err := dxpdf.Convert(docxBytes)
// File to file
err := dxpdf.ConvertFile("input.docx", "output.pdf")
// Override the embedded-image resolution (default 220 DPI)
pdfBytes, err := dxpdf.ConvertWithOptions(docxBytes, 300)
The Go package is a thin cgo layer over the same Rust engine the CLI and the Python package use, so it needs CGO_ENABLED=1 and a C compiler. The prebuilt library for every supported platform is committed in the repository, so there is no separate fetch or build step. It runs on linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 and — since 0.8.1 — windows/amd64.
Windows is the one platform that deploys differently: the other four link a static archive that is absorbed into your binary, while Windows loads dxpdf.dll dynamically, so the built executable needs that DLL beside it or on PATH at runtime. Copy it out of the module cache into your release output. The module also carries no tags of its own, so go get .../go@v0.8.1 will not resolve; plain go get, @main, or a commit SHA to pin does.
Performance
Benchmarked on Apple M3 Max with hyperfine (30 runs, 5 warmup) at v0.5.1, against fixtures committed in the repository. Times are rounded to 5 ms, and run-to-run spread on a normally loaded machine is around ±10 ms, so smaller differences are not meaningful:
| Fixture | Pages | Input | Conversion time | Peak RSS |
|---|---|---|---|---|
| 3-page business document | 3 | 34 KB | 170 ms | 54 MB |
| 7-page document | 7 | 10 KB | 170 ms | 51 MB |
| 9-page, image-heavy document | 9 | 1.3 MB | 55 ms | 40 MB |
| 171-page report | 171 | 14 MB | 420 ms | 145 MB |
Font resolution, not document size, decides what a conversion costs. The 9-page fixture carries forty times the input of the 3-page one and converts in a third of the time, because its fonts are embedded or already present on the host — that path costs about 4 ms, where falling back to the host metadata index costs 120–185 ms, once. For a batch workload the useful question is not how large the documents are but whether they name fonts the host has.
Conversion correctness is locked in by fixture-driven tests, including visual regression tests that compare rendered PDFs against Word-generated reference documents.
Real-World Use Cases
Automated document pipelines
CI/CD systems or batch processors that generate contracts, invoices, or reports from .docx templates. dxpdf runs as a single binary — no LibreOffice installation, no Docker image with a full desktop environment, no per-document API costs.
Regulated environments
Healthcare, legal, and financial applications where documents cannot leave the network. dxpdf runs fully offline with no external service calls, so it works in air-gapped and on-premises deployments.
Embedded and edge computing
IoT devices, kiosks, or lightweight containers where installing a 500 MB office suite is not practical. dxpdf's tens-of-megabytes memory footprint and sub-second conversion times make it viable for resource-constrained environments.
Python and Go backends
Django, Flask, or FastAPI services that need to convert uploaded DOCX files on the fly. The Python bindings wrap the Rust core via PyO3, so you get native performance without a subprocess or external service.
Go services get the same engine through cgo: a handler calls dxpdf.Convert directly instead of shelling out to a binary or calling a sidecar, which keeps conversion inside the request's own goroutine and error handling.
Release Notes
The latest release is 0.8.1. Four releases have landed since 0.5.0, and between them they added a language binding and a new class of content:
- 0.6.0 — Go bindings over a new C ABI, plus table fixes: the outer border of a spaced table,
vMergeoverflow going to a span's last row, verticalinside/outsideborders mirroring with page parity, andw:vanishhidden runs being removed from layout instead of painted. - 0.7.0 — OMML equation rendering, alongside fixes for continuous-break column overlap, UAX #14 LB25 token wrapping, and the Symbol PUA table. Equations carry hyperlinks and appear in outline titles.
- 0.8.0 — the math path refined: fraction geometry made consistent, superscripts over fraction bases, footnote number prefixes taking the opening equation's font, and hyphen handling fixed for non-ASCII digits. It also reads §22.9.2.15 universal measures and §22.9.2.9 percent spellings.
- 0.8.1 — windows/amd64 joins the Go bindings, and the Python bindings release the GIL for the duration of a conversion.
The long-form write-up of 0.5.0, dxpdf 0.5.0: Teaching a DOCX Converter to Read the Rest of the World, still covers the internationalization work that underpins all of this — UAX #14 line breaking, UAX #9 bidirectional text, and CLDR-driven numbers and dates that follow the document's own language.