Проблема
Конвертация документов Word в PDF — одна из самых распространённых задач в бизнес-софте. Счета, договоры, отчёты, регуляторные формы — они начинаются как .docx-файлы и должны стать PDF для обмена, архивирования или печати.
Каждое существующее решение требует серьёзных компромиссов:
- Microsoft Office / LibreOffice — требует установки полного офисного пакета на каждый сервер. Headless-режим LibreOffice медленный, потребляет много памяти и выдаёт разный результат от версии к версии. Масштабирование означает запуск нескольких экземпляров, потребляющих гигабайты оперативной памяти.
- Облачные API (Google Docs, Adobe, CloudConvert) — добавляют задержку, берут плату за каждую конвертацию и отправляют потенциально конфиденциальные документы на сторонние серверы. Неприемлемо для регулируемых отраслей или изолированных сред.
- Инструменты HTML-to-PDF (wkhtmltopdf, Puppeteer) — требуют предварительного преобразования DOCX в HTML с потерей точности форматирования. Таблицы, колонтитулы и разрывы страниц редко переживают такую цепочку преобразований.
Ни одно из этих решений не подходит, когда нужна быстрая и точная офлайн-конвертация в больших объёмах — особенно в автоматизированных пайплайнах, CI/CD-системах или встраиваемых приложениях, где установка LibreOffice невозможна.
Как dxpdf решает эту задачу
dxpdf — это автономный конвертер DOCX в PDF, написанный на Rust и использующий графическую библиотеку Google Skia. Он читает .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 страницы | 3 | 34 КБ | 170 мс | 54 МБ |
| Документ на 7 страниц | 7 | 10 КБ | 170 мс | 51 МБ |
| Документ на 9 страниц с изображениями | 9 | 1,3 МБ | 55 мс | 40 МБ |
| Отчёт на 171 страницу | 171 | 14 МБ | 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 по языку самого документа.