Проблема

Конвертация документов Word в PDF — одна из самых распространённых задач в бизнес-софте. Счета, договоры, отчёты, регуляторные формы — они начинаются как .docx-файлы и должны стать PDF для обмена, архивирования или печати.

Каждое существующее решение требует серьёзных компромиссов:

  • Microsoft Office / LibreOffice — требует установки полного офисного пакета на каждый сервер. Headless-режим LibreOffice медленный, потребляет много памяти и выдаёт разный результат от версии к версии. Масштабирование означает запуск нескольких экземпляров, потребляющих гигабайты оперативной памяти.
  • Облачные API (Google Docs, Adobe, CloudConvert) — добавляют задержку, берут плату за каждую конвертацию и отправляют потенциально конфиденциальные документы на сторонние серверы. Неприемлемо для регулируемых отраслей или изолированных сред.
  • Инструменты HTML-to-PDF (wkhtmltopdf, Puppeteer) — требуют предварительного преобразования DOCX в HTML с потерей точности форматирования. Таблицы, колонтитулы и разрывы страниц редко переживают такую цепочку преобразований.

Ни одно из этих решений не подходит, когда нужна быстрая и точная офлайн-конвертация в больших объёмах — особенно в автоматизированных пайплайнах, -системах или встраиваемых приложениях, где установка LibreOffice невозможна.

Как dxpdf решает эту задачу

dxpdf — это автономный конвертер DOCX в PDF, написанный на Rust и использующий графическую библиотеку Google . Он читает .docx-файлы напрямую, парсит OOXML-структуру и рендерит PDF с пиксельной точностью — всё в одном бинарнике без внешних зависимостей, кроме Skia.

Модель сначала измерить, потом разместить, заимствованная у Flutter, следит за тем, чтобы перенос текста, размеры таблиц и разрывы страниц совпадали с тем, что делает Microsoft Word:

DOCX (ZIP) → Parse → Document Model → Resolve → Layout → Subset → Paint → PDF
             Twips/Emu/HalfPoints        ←──── Pt throughout ────→      Skia

Через весь пайплайн проходят типобезопасные единицы измерения: единицы OOXML (Twips, Emu, HalfPoints) в разобранной модели хранятся как i64 и потому переводятся туда-обратно без потерь, вёрстка работает в типографских пунктах (Pt), а голый f32 появляется только на границе с рендерингом в Skia — так что смешение единиц становится ошибкой компиляции, а не документом с едва заметной ошибкой.

Результат — конвертер, который превращает 3-страничный документ с таблицами и изображениями в PDF за 170 мс, потребляя 54 МБ памяти, а документ на 171 страницу и 14 МБ — за 420 мс: достаточно быстро для запуска внутри обработчика запросов или пакетной обработки тысяч документов.

Возможности

Проверено по ISO 29500 (Office Open XML): 75 позиций реализованы полностью, 12 — частично, 11 пока не поддерживаютсяполная матрица перечисляет каждую позицию со статусом. Главное:

  • Форматирование текста — жирный, курсив, подчёркивание, выделение цветом, размер шрифта, гарнитура, цвет, межсимвольный интервал, масштабирование символов, надстрочный и подстрочный текст, заливка и границы символов
  • Абзацы — выравнивание (по левому краю, по центру, по правому краю, по ширине, распределённое), интервалы, отступы, табуляция (в том числе десятичная, с чертой и с абсолютной позицией), границы, заливка, «не отрывать от следующего», «не разрывать абзац», контроль висячих строк
  • Таблицы — ширина столбцов, отступы ячеек с 3-уровневым каскадированием, объединённые ячейки, высота строк, границы, заливка ячеек, стили таблиц с условным форматированием, вложенные и плавающие таблицы, разрыв строк между страницами
  • Изображения — встроенные (PNG, JPEG, GIF, BMP, WebP) и плавающие/привязанные с выравниванием, обтеканием, кадрированием и процентным позиционированием
  • Фигуры и надписи — фигуры DrawingML и VML, текстовые блоки фигур с внутренними полями, привязкой, автоподбором и произвольной геометрией
  • Стили — стили абзацев и символов с наследованием basedOn, настройки документа по умолчанию, шрифты темы
  • Колонтитулы — текст, изображения, номера страниц (коды полей PAGE/NUMPAGES), варианты для первой страницы и чётных/нечётных
  • Списки — многоуровневая нумерация: маркеры, десятичные, буквенные, римские, порядковые и прописью, а также нелатинские последовательности и графические маркеры
  • Навигация — кликабельные аннотации ссылок, закладки и перекрёстные ссылки как именованные назначения, оглавление PDF по уровням заголовков
  • Разделы — несколько размеров страниц и полей, разрывы разделов, многоколоночная вёрстка, книжная и альбомная ориентация
  • Формулы — встроенная математика OMML (m:oMath): математические раны, верхние индексы и дроби, набираемые математическим шрифтом Word по умолчанию; формула может нести ссылку и попадать в заголовок оглавления
  • Вёрстка — автоматическая пагинация, сноски и концевые сноски, перенос слов, режимы межстрочного интервала, обтекание плавающих изображений
  • Интернационализация — переносы по UAX #14 (включая тайский, лаосский, кхмерский и бирманский), двунаправленный текст по UAX #9 с зеркалированием, шейпинг через HarfBuzz для письменностей с курсивным соединением, а также десятичные разделители, маски дат и числительные прописью по атрибуту w:lang
  • Текст и эмодзи — сегментация по графемным кластерам и полноцветные эмодзи, включая последовательности ZWJ, модификаторы, клавиши и флаги

Четыре способа использования

Инструмент командной строки

Установите и запустите одной командой:

cargo install dxpdf
dxpdf input.docx -o output.pdf

Rust-библиотека

Один вызов функции — байты на вход, байты на выход:

let docx_bytes = std::fs::read("document.docx")?;
let pdf_bytes = dxpdf::convert(&docx_bytes)?;
std::fs::write("output.pdf", &pdf_bytes)?;

Для большего контроля можно просмотреть модель документа перед рендерингом:

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) => { /* разбор абзаца */ }
        model::Block::Table(t) => { /* разбор таблицы */ }
        model::Block::SectionBreak(props) => { /* разбор свойств секции */ }
    }
}

let pdf_bytes = render::render(document, &dxpdf::RenderOptions::default())?;

Python-пакет

Установите с PyPI и используйте в любом Python-приложении:

pip install dxpdf
import dxpdf

# Байты на вход, байты на выход
pdf_bytes = dxpdf.convert(open("input.docx", "rb").read())

# Файл в файл
dxpdf.convert_file("input.docx", "output.pdf")

Начиная с 0.8.1 оба вызова освобождают GIL на время работы Rust-ядра, поэтому пул потоков, конвертирующий несколько документов сразу, действительно выполняет их параллельно, а не выстраивает в очередь на интерпретаторе.

Go-пакет

Установите через go get и вызывайте как любой другой пакет:

go get github.com/nerdy-pro/dxpdf/go
import "github.com/nerdy-pro/dxpdf/go"

// Байты на вход, байты на выход
pdfBytes, err := dxpdf.Convert(docxBytes)

// Файл в файл
err := dxpdf.ConvertFile("input.docx", "output.pdf")

// Переопределить разрешение встроенных изображений (по умолчанию 220 DPI)
pdfBytes, err := dxpdf.ConvertWithOptions(docxBytes, 300)

Go-пакет — это тонкая cgo-прослойка над тем же Rust-ядром, которое используют CLI и Python-пакет, поэтому ему нужны CGO_ENABLED=1 и компилятор C. Собранная библиотека для каждой поддерживаемой платформы закоммичена в репозиторий, так что отдельного шага загрузки или сборки нет. Работает на linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 и — начиная с 0.8.1 — windows/amd64.

Windows единственный разворачивается иначе: остальные четыре платформы линкуют статический архив, который полностью попадает в ваш бинарник, а Windows загружает dxpdf.dll динамически, поэтому собранному исполняемому файлу нужен этот DLL рядом с ним или в PATH во время выполнения — скопируйте его из кэша модулей в каталог сборки. Кроме того, у модуля нет собственных тегов, поэтому go get .../go@v0.8.1 не разрешится; работает обычный go get, @main или коммит по SHA.

Производительность

Бенчмарк на Apple M3 Max с hyperfine (30 запусков, 5 прогревочных) на версии 0.5.1, на фикстурах, закоммиченных в репозиторий. Время округлено до 5 мс, а разброс между запусками на обычно загруженной машине — около ±10 мс, поэтому меньшие различия не значимы:

ФикстураСтраницВходВремя конвертацииПик RSS
Деловой документ на 3 страницы334 КБ170 мс54 МБ
Документ на 7 страниц710 КБ170 мс51 МБ
Документ на 9 страниц с изображениями91,3 МБ55 мс40 МБ
Отчёт на 171 страницу17114 МБ420 мс145 МБ

Стоимость конвертации определяется тем, как разрешаются шрифты, а не размером документа. Девятистраничная фикстура несёт в сорок раз больше данных, чем трёхстраничная, и конвертируется втрое быстрее, потому что её шрифты встроены или уже есть в системе: этот путь стоит около 4 мс, а откат к системному индексу метаданных — 120–185 мс, один раз. Для пакетной нагрузки важен не размер документов, а то, указывают ли они шрифты, которые на хосте уже есть.

Корректность конвертации закреплена тестами на фикстурах, включая визуальные регрессионные тесты, сравнивающие отрендеренные PDF с эталонными документами, сгенерированными Word.

Сценарии использования

Автоматизированные пайплайны документов

CI/CD-системы или пакетные процессоры, генерирующие договоры, счета или отчёты из .docx-шаблонов. dxpdf запускается как один бинарник — без установки LibreOffice, без Docker-образа с полным десктопным окружением, без платы за каждый документ.

Регулируемые среды

Приложения в здравоохранении, юриспруденции и финансах, где документы не могут покидать сеть. dxpdf работает полностью офлайн без внешних вызовов, что делает его подходящим для изолированных и локальных развёртываний.

Встраиваемые системы и edge-вычисления

IoT-устройства, киоски или лёгкие контейнеры, где установка офисного пакета на 500 МБ нецелесообразна. Потребление памяти dxpdf в десятки мегабайт и субсекундное время конвертации делают его пригодным для сред с ограниченными ресурсами.

Бэкенды на Python и Go

Сервисы на Django, Flask или FastAPI, которым нужно конвертировать загруженные DOCX-файлы на лету. Python-биндинги оборачивают Rust-ядро через PyO3, обеспечивая нативную производительность без подпроцессов и внешних сервисов.

Go-сервисы получают то же ядро через cgo: хендлер вызывает dxpdf.Convert напрямую, вместо запуска бинарника или обращения к сайдкару, — конвертация остаётся внутри собственной горутины запроса и его обработки ошибок.

О последнем релизе

Свежая версия — 0.8.1. После 0.5.0 вышло четыре релиза, и вместе они добавили ещё один язык и новый класс содержимого:

  • 0.6.0 — Go-биндинги поверх нового C ABI, а также исправления в таблицах: внешняя граница таблицы с межклеточными отступами, перенос излишка vMerge в последнюю строку объединения, зеркалирование вертикальных границ inside/outside по чётности страницы и удаление скрытых ранов w:vanish из вёрстки вместо их отрисовки.
  • 0.7.0 — рендеринг формул OMML, а вместе с ним исправления наложения колонок при непрерывном разрыве, переноса токенов по правилу LB25 из UAX #14 и таблицы PUA шрифта Symbol. Формулы несут ссылки и попадают в заголовки оглавления.
  • 0.8.0 — доработка математики: согласованная геометрия дробей, верхние индексы над дробью, шрифт номера сноски по шрифту открывающей формулы и исправленная обработка дефиса с нелатинскими цифрами. Плюс чтение универсальных мер по §22.9.2.15 и записей процентов по §22.9.2.9.
  • 0.8.1 — к Go-биндингам добавился windows/amd64, а Python-биндинги освобождают GIL на время конвертации.

Подробный разбор 0.5.0 — dxpdf 0.5.0: как мы научили конвертер DOCX читать не только по-английски — по-прежнему описывает работу по интернационализации, на которой всё это стоит: переносы строк по UAX #14, двунаправленный текст по UAX #9 и числа и даты из CLDR по языку самого документа.

Нет. dxpdf — это автономный конвертер, который читает DOCX-файлы напрямую и рендерит PDF с помощью графического движка Google Skia. Он не зависит ни от какого офисного пакета.
dxpdf написан на Rust и доступен как CLI-инструмент (через cargo install), Rust-библиотека (через crates.io), Python-пакет (через PyPI) и Go-биндинги (через go get github.com/nerdy-pro/dxpdf/go). Все четыре работают на macOS, Linux и Windows; Go-биндинги поддерживают linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 и, начиная с 0.8.1, windows/amd64.
dxpdf использует заимствованный у Flutter пайплайн «сначала измерить, потом разместить», спроектированный для пиксельной точности. Соответствие проверено по ISO 29500: 75 позиций реализованы полностью, 12 — частично, 11 пока не поддерживаются. Визуальные регрессионные тесты сравнивают вывод с эталонами, сгенерированными Word.
На Apple M3 Max закоммиченные фикстуры конвертируются за 55–170 мс, а документ на 171 страницу и 14 МБ — примерно за 420 мс. Разрешение шрифтов важнее размера документа: встроенные или уже установленные шрифты разрешаются примерно за 4 мс, а откат к системному индексу метаданных стоит 120–185 мс, один раз. Это достаточно быстро для запуска внутри обработчиков веб-запросов.
Да. Установите через pip install dxpdf. Python-пакет оборачивает Rust-ядро через PyO3, обеспечивая нативную производительность. Используйте dxpdf.convert() для работы с байтами или dxpdf.convert_file() для конвертации файл-в-файл.
Да, начиная с 0.6.0. Выполните go get github.com/nerdy-pro/dxpdf/go и вызывайте dxpdf.Convert для работы с байтами или dxpdf.ConvertFile для конвертации файл-в-файл; ConvertWithOptions и ConvertFileWithOptions принимают DPI изображений. Это cgo-прослойка над тем же Rust-ядром, поэтому нужны CGO_ENABLED=1 и компилятор C. Поддерживаются linux и macOS на amd64 и arm64, а с 0.8.1 ещё и windows/amd64 — на Windows биндинг загружает dxpdf.dll динамически, так что поставляйте этот DLL вместе с исполняемым файлом. У Go-модуля нет собственных тегов, поэтому фиксируйте версию по SHA коммита, а не по тегу.
Пока не поддерживаются: переупорядочивание индийских письменностей, автоматический перенос по слогам, исправления и комментарии, SmartArt и диаграммы, границы страницы и сетка документа, изображения WMF и SVG, эффекты текста «тень», «контур», «с приподнятым рельефом» и «с утопленным рельефом», узорная заливка ячеек, зеркалирование табуляций и меток нумерации при w:bidi, а также счётные форматы нумерации вроде chineseCounting. Частично поддерживаются: зачёркивание и малые прописные разбираются, но не отрисовываются, большинство стилей границ аппроксимируется сплошной линией, обтекание tight и through использует ограничивающий прямоугольник вместо полигона, из EMF декодируется только одиночный встроенный растр, разрывы разделов even, odd и nextColumn трактуются как nextPage, процентные и автоматические ширины ячеек откатываются к сетке таблицы, а подстановка шрифта для отдельных глифов берёт отсутствующий символ из любой покрывающей его системной гарнитуры, поэтому результат зависит от хоста, и подсказка w:lang пока не передаётся — ханьский текст может получить начертания не того языка.