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 55 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): 74 features fully implemented, 11 partial, 12 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
- 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
Three 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")
Performance
Benchmarked on Apple M3 Max with hyperfine (30 runs, 5 warmup) at v0.5.0, against fixtures committed in the repository:
| Fixture | Pages | Input | Conversion time | Peak RSS |
|---|---|---|---|---|
| 3-page business document | 3 | 34 KB | 170 ms | 55 MB |
| 7-page document | 7 | 10 KB | 170 ms | 52 MB |
| 9-page, image-heavy document | 9 | 1.3 MB | 55 ms | 42 MB |
| 171-page report | 171 | 14 MB | 420 ms | 159 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, making it suitable for 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 web applications
Django, Flask, or FastAPI backends that need to convert uploaded DOCX files on the fly. The Python bindings wrap the Rust core via PyO3, delivering native performance without a subprocess or external service.
Release Notes
The latest release, 0.5.0, is an internationalization release — UAX #14 line breaking, UAX #9 bidirectional text, and CLDR-driven numbers and dates that follow the document's own language. We wrote it up in dxpdf 0.5.0: Teaching a DOCX Converter to Read the Rest of the World, which covers the design decisions behind it, the measured numbers, and where the converter still falls short.