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, 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 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 basedOn inheritance, 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:

FixturePagesInputConversion timePeak RSS
3-page business document334 KB170 ms54 MB
7-page document710 KB170 ms51 MB
9-page, image-heavy document91.3 MB55 ms40 MB
171-page report17114 MB420 ms145 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, vMerge overflow going to a span's last row, vertical inside/outside borders mirroring with page parity, and w:vanish hidden 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.

No. dxpdf is a standalone converter that reads DOCX files directly and renders PDFs using Google's Skia graphics engine. It has no dependency on any office suite.
dxpdf is written in Rust and available as a CLI tool (via cargo install), a Rust library (via crates.io), a Python package (via PyPI), and Go bindings (via go get github.com/nerdy-pro/dxpdf/go). All four run on macOS, Linux and Windows; the Go bindings cover linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 and, since 0.8.1, windows/amd64.
dxpdf uses a Flutter-inspired measure-then-position pipeline designed for pixel-level fidelity, validated against ISO 29500 with 75 entries fully implemented, 12 partial and 11 not yet supported. Visual regression tests compare output against Word-generated references.
On Apple M3 Max, dxpdf converts the committed fixtures in 55-170 ms and a 171-page, 14 MB document in about 420 ms. Font resolution matters more than document size: fonts that are embedded or already on the host resolve in about 4 ms, while falling back to the host metadata index costs 120-185 ms once. It is fast enough to run inline in web request handlers.
Yes. Install with pip install dxpdf. The Python package wraps the Rust core via PyO3, providing native performance. Use dxpdf.convert() for bytes-in/bytes-out or dxpdf.convert_file() for file-to-file conversion.
Yes, since 0.6.0. Run go get github.com/nerdy-pro/dxpdf/go and call dxpdf.Convert for bytes-in/bytes-out or dxpdf.ConvertFile for file-to-file; ConvertWithOptions and ConvertFileWithOptions take an image DPI. It is a cgo layer over the same Rust engine, so it needs CGO_ENABLED=1 and a C compiler. It supports linux and macOS on amd64 and arm64, and since 0.8.1 windows/amd64 as well — on Windows the binding loads dxpdf.dll dynamically, so ship that DLL alongside your executable. The Go module has no tags of its own, so pin with a commit SHA rather than a version tag.
Not yet supported: Indic script reordering, automatic hyphenation, tracked changes and comments, SmartArt and charts, page borders and the document grid, WMF and SVG images, shadow, outline, emboss and imprint text effects, pattern cell shading, mirrored tab stops and numbering labels under w:bidi, and the counting-system numbering formats such as chineseCounting. Partially supported: strikethrough and small caps are parsed but not rendered, most border styles are approximated as solid lines, tight and through image wrapping use the bounding box rather than a polygon, EMF images decode only a single embedded bitmap, even, odd and nextColumn section breaks are treated as nextPage, percentage and auto cell widths fall back to the table grid, and per-glyph font fallback draws a missing codepoint from whichever host face covers it, so output is host-dependent and no w:lang hint is passed yet, which can give Han text the wrong language's glyph shapes.