dxpdf превращает .docx в PDF. Без Microsoft Office, без headless-режима LibreOffice, без облачных API — один бинарник на Rust, который читает OOXML напрямую и рисует результат через Skia от Google. Мы написали его потому, что все альтернативы заставляли выбирать между точностью вёрстки, скоростью и возможностью не отправлять документы клиентов на чужие серверы, — а та, на которой мы в итоге и сидели, headless LibreOffice, оказалась десктопным приложением, которое приходится нянчить в продакшене.
Версия 0.5.0 вышла 11 августа. Это релиз, в котором конвертер перестал — сразу в десятке мелочей — считать, что входящий документ написан по-английски.
Ключевые выводы
- 0.5.0 — релиз про интернационализацию. Переносы строк по UAX #14 (включая тайский, лаосский, кхмерский и бирманский), двунаправленный текст по UAX #9 с зеркалированием по правилу L4, числа и даты из CLDR по атрибуту
w:langсамого документа. - Появился первый внешний контрибьютор — @ikashapov добавил русские форматы нумерации, разбор
w:commentReferenceи исправил три дефекта в метках списков. - Данные локалей поставляются одним урезанным блобом через
icu_provider_blob, а не черезcompiled_dataкаждого крейта ICU4X: «корректно во всех локалях» не должно означать «и бинарник теперь огромный». - Стоимость конвертации определяет не размер документа, а то, как разрешаются его шрифты — документ на 9 страниц и 1,3 МБ конвертируется за 55 мс, а на 3 страницы и 34 КБ — за 170 мс, и вся разница именно в шрифтах.
- Покрытие ISO 29500: 74 возможности реализованы полностью, 11 частично, 12 пока нет — и последняя колонка и есть та самая честная дорожная карта, которую мы можем опубликовать.
- Он заменил пайплайн на headless LibreOffice — десктопный пакет за очередью, сторожем и отдельным каталогом профиля на каждый воркер. Во что это обходилось — ниже.
- dxpdf распространяется под лицензией MIT, опубликован на crates.io и PyPI, а теперь ещё и собирается в
.deb.
Спасибо первому внешнему контрибьютору
В 0.5.0 впервые появился раздел New Contributors, и открывает его на редкость удачная работа. @ikashapov сделал #118: разбор w:commentReference, форматы нумерации russianUpper и russianLower и исправления трёх разных дефектов вёрстки и нумерации в метках списков.
Это не правка опечатки мимоходом. Форматы нумерации — ровно тот случай, когда проблему находит только тот, у кого такие документы есть на руках. Договор со списком а)/б)/в) не попадёт в набор фикстур, собранный на английском, а три найденных дефекта меток лежали в общем коде вёрстки и портили результат всем, у кого документ устроен похожим образом. Спасибо.
Если вы это читаете и dxpdf ломает вёрстку уже ваших документов — это ровно тот вклад, которого нам не хватает больше всего. Об этом в конце.
Что у нас работало раньше: headless LibreOffice
До dxpdf был soffice --headless --convert-to pdf, обёрнутый в очередь и ретрай, — как в большинстве документных пайплайнов. Это работает, и долгое время это был правильный выбор: ничто другое не конвертирует DOCX с такой точностью за цену apt install. Чем это точно не является, так это компонентом, который можно поставить в путь запроса и перестать о нём думать:
- Это десктопное приложение в костюме сервера. Нет ни библиотечного API, ни внутрипроцессного вызова: вы запускаете бинарник, читаете код возврата и надеетесь на лучшее — снаружи упавшая конвертация и упавший процесс выглядят почти одинаково. Первая конвертация в свежем процессе вдобавок оплачивает старт целого офисного пакета.
- Один процесс — один профиль.
sofficeсериализуется вокруг своего каталога профиля, поэтому каждому параллельному воркеру нужен собственный, иначе они конкурируют за одни и те же файлы блокировок. Масштабирование превращается в управление процессами, а не в пул потоков. - Он зависает. Необычный или битый документ может оставить
sofficeждать вечно, поэтому вокруг продакшен-установки вырастают таймаут, сторож и сборщик осиротевших процессов. Этот код нянчения рано или поздно пишет каждая команда, которая на такое подписалась. - Память и размер образа. Сотни мегабайт резидентной памяти на экземпляр и контейнер, несущий в себе целый офисный пакет, — плюс шрифты, которые надо туда положить, иначе метрики подменятся молча и вёрстка поедет.
- Точность зависит от версии. Один и тот же документ на другом релизе LibreOffice может разбиться на страницы иначе, и обновление базового образа превращается в изменение того, что получает клиент. Узнавать об этом от клиента — худший из способов.
Это не претензия к LibreOffice: это офисный пакет, и он очень хорош в том, чтобы им быть. Это утверждение о том, что происходит, когда GUI-приложение оказывается несущей конструкцией серверного пайплайна. dxpdf вырос из желания получить один вызов библиотеки, предсказуемый профиль памяти и вывод, который меняется только тогда, когда мы сами его меняем.
Проблема: движок вёрстки, знавший один алфавит
Смысл dxpdf всегда был в точности вёрстки. Конвертер DOCX в PDF полезен ровно настолько, насколько разрывы страниц совпадают с тем, что показывает Word: счёт, у которого строка «Итого» уехала на вторую страницу, хуже, чем отсутствие PDF вообще. Вся архитектура проекта существует ради этого: разобрать OOXML в неизменяемую модель, развернуть каскад стилей, посчитать и разместить всё до того, как что-то будет нарисовано.
К версии 0.4.0 у нас был движок, который делал это очень хорошо — для текста из пробелов и латиницы. Каждое допущение, лежавшее в его основе, оставалось невидимым, пока не ломалось:
- Строки переносились по пробелам. Это не алгоритм переноса, а эвристика, которая просто случайно работает для английского. В тайском, лаосском, кхмерском и бирманском пробелов между словами нет — по такому правилу целый абзац становится одним неразрывным токеном и уезжает за край страницы.
- Текст всегда верстался слева направо. Арабский или иврит превращались в визуальную бессмыслицу: глифы правильные, порядок неправильный, скобки не зеркалированы.
- Числа и даты форматировались по-американски. Десятичная табуляция выравнивалась по
.там, где немецкий документ имел в виду,. ПолеDATEс локализованной маской —ДД.ММ.ГГГГвместоdd.MM.yyyy— не понималось вовсе. - Числительные прописью знали только английский.
cardinalTextв немецком документе давалOne, а неEins. - Межбуквенный интервал работал по кодовым точкам. Добавьте 2 pt разрядки к строке с комбинируемым диакритическим знаком — и знак уедет от своей буквы.
Ничего экзотического здесь нет. Так выглядит первая встреча конвертера, написанного англоговорящими, с документом, который написан не на их языке.
Решение: настоящие спецификации вместо новых эвристик
Сквозная идея всего 0.5.0 в том, что каждое из этих мест заменено настоящим алгоритмом из Unicode или OOXML, а не более удачной догадкой.
Переносы строк теперь считаются по UAX #14 через ICU4X, причём по абзацу, а не по отдельному фрагменту (run): слово, разрезанное границей <w:r> из-за случайной смены форматирования, всё равно переносится там, где говорит алгоритм, а не там, где разрезан XML. Для четырёх письменностей, которые UAX #14 явно передаёт «сложному контекстному анализу», границы слов ищет LSTM-модель. Токен, который ни одно правило не разрешает разорвать, обрезается по краю контейнера, а не вылезает за него, — так делает Word, и именно это нужно узкой ячейке таблицы.
Двунаправленный текст теперь обрабатывается по UAX #9: уровни встраивания считаются по абзацу, перестановка выполняется по строке, зеркалирование — по правилу L4, чтобы скобки смотрели в нужную сторону. Выравнивание w:jc и отступы w:ind разрешаются относительно базового направления абзаца, а не относительно «левого края».
Письменности с позиционными формами проходят шейпинг через HarfBuzz. Арабский, сирийский, нко, монгольский, адлам и другие подобные письменности без курсивного соединения просто нечитаемы, поэтому фрагмент с такой письменностью идёт через HarfBuzz внутри Skia, а всё остальное остаётся на прежнем, более дешёвом пути через cmap.
Числа и даты следуют за w:lang. Десятичные разделители берутся из CLDR с учётом региона — de-CH и de-DE расходятся между собой, и теперь dxpdf воспроизводит это расхождение правильно. Поля DATE и TIME вычисляются с локализованными именами масок из §17.16.4.2. Числительные прописью работают для английского, немецкого, французского и испанского (Eins, Vingt et un, Veintiuno, 1.º), для остальных языков остаются цифры.
Разрядка и выключка работают по графемным кластерам согласно UAX #29: разрядка по §17.3.2.35 больше не отрывает комбинируемый знак от базовой буквы, а выравнивание distribute по §17.3.1.13 распределяет лишнюю ширину между кластерами, а не внутри одного.
Помимо интернационализации в 0.5.0 закрыты те дефекты пагинации, которые замечаешь сразу: непрерывный разрыв раздела повышается до разрыва страницы, если параметры страницы действительно различаются; абзац с «не отрывать от следующего» остаётся внизу своей страницы перед явным разрывом; абзацы, состоящие из одного разрыва, получают положенную им высоту строки. Плюс синтез жирного и наклонного начертаний для шрифтов, у которых настоящих начертаний нет, Windows в матрице CI и сборка .deb.
Три решения, которые стоит объяснить
Единицы измерения — это типы, а не числа
OOXML измеряет всё в twip'ах, EMU, полупунктах, восьмых долях пункта и тысячных долях процента, иногда по три штуки в одном элементе. Очевидный подход — привести всё к f64 на границе парсера и жить дальше. В dxpdf сделано наоборот: каждая единица OOXML — отдельный тип поверх i64 в model::dimension, они проходят разбор и разрешение стилей без преобразований и потому переводятся туда-обратно без потерь, вёрстка работает исключительно в Pt, а голый f32 появляется только на границе со Skia.
DOCX (ZIP) → Parse → Document Model → Resolve → Layout → Subset → Paint → PDF
Twips/Emu/HalfPoints ←──── Pt throughout ────→ Skia
Выигрыш в том, что сложение twip'ов с полупунктами становится ошибкой компиляции, а не документом, у которого один отступ отличается в десять раз. Такие баги мучительно ловить глазами — результат выглядит как правдоподобный документ, просто слегка неправильный, — и они исчезают полностью, если компилятор не даёт единицам смешаться. Геометрические типы обобщены по единице измерения по той же причине, а их Pt-специализации живут в render::geometry, чтобы слой модели вообще не зависел от Skia.
Данные локалей — один урезанный блоб, а не всё сразу
У крейтов ICU4X есть фича compiled_data, которая зашивает полный набор данных CLDR прямо в бинарник. Это простой путь — и очень дорогой по размеру: вы получаете все локали, все календари и все валюты независимо от того, упоминает их документ или нет.
Вместо этого dxpdf собирает один урезанный блоб под те локали, которые действительно поддерживает, и загружает его через icu_provider_blob. Больше инфраструктуры сборки, ещё один артефакт, который надо держать в актуальном состоянии, — и бинарник, который пользователь CLI действительно захочет поставить. Тот же инстинкт виден в соседнем решении: unicode-joining-type используется как предикат, единственная задача которого — не пускать HarfBuzz на латиницу. Дорогой путь включается для тех письменностей, которым он нужен, и больше ни для каких. В обоих случаях сделка одна и та же: платить за корректность там, где она требуется, а не равномерно везде.
У вёрстки появился спекулятивный участок
Вопрос §17.6.22 — останется ли непрерывный разрыв раздела на текущей странице — нельзя решить, двигаясь только вперёд: ответ зависит от того, что идёт после разрыва. Значит, вёрстка обязана попробовать размещение, заглянуть вперёд и откатить попытку, если ответ окажется отрицательным.
Это невозможно, если состояние вёрстки — это &mut, который вы мутируете по всей цепочке вызовов. В #111 появилась спекулятивная область BuildState: участок работы вёрстки, который можно целиком зафиксировать или целиком выбросить. Хорошая иллюстрация повторяющегося в проекте паттерна: спецификация говорит не только о том, что реализовать, но и о том, какой формы должна быть архитектура. В вёрстке Word есть заглядывание вперёд — значит, конвертеру, которому нужны разрывы страниц как в Word, нужно место, куда складывать отменённую попытку.
Цифры
Замерено на Apple M3 Max с помощью hyperfine (30 прогонов, 5 прогревочных) на v0.5.0, на фикстурах, закоммиченных в репозиторий, — так что числа воспроизводимы:
| Фикстура | Страниц | Вход | Время конвертации | Пик RSS |
|---|---|---|---|---|
sample-docx-files-sample3 | 3 | 34 КБ | 170 мс | 55 МБ |
sample-docx-files-sample-4 | 7 | 10 КБ | 170 мс | 52 МБ |
sample-docx-files-sample1 | 9 | 1,3 МБ | 55 мс | 42 МБ |
sample-docx-files-sample4 | 171 | 14 МБ | 420 мс | 159 МБ |
Интересна третья строка. Документ на 9 страниц, который несёт в сорок раз больше данных, конвертируется втрое быстрее трёхстраничного — потому что стоимость конвертации определяет то, как разрешаются шрифты, а не размер документа. Реестр шрифтов строится по уровням и лениво: документ, чьи шрифты встроены или уже есть в системе, до дорогого уровня не доходит и тратит там около 4 мс, а тот, которому приходится падать в системный индекс метаданных и сопоставлять по PostScript- и стилевым именам, платит 120–185 мс — один раз. На трёхстраничной фикстуре этот поиск примерно в пять раз дороже, чем разбор, вёрстка, сабсеттинг и отрисовка вместе взятые.
Для оценки пакетной нагрузки это меняет сам вопрос. Важно не насколько большие документы, а называют ли они шрифты, которые уже есть на хосте. Документы, написанные в Word, обычно называют.
Покрытие и распространение на момент публикации:
- 74 возможности OOXML реализованы полностью, 11 частично, 12 пока нет, проверка по ISO 29500. Полная матрица — в README, вместе с пропусками.
- Около 6000 загрузок на crates.io за 38 опубликованных версий, плюс wheel-пакеты на PyPI для macOS, Linux и Windows под Python 3.8+.
- 29 звёзд, 6 форков, лицензия MIT, проекту пять месяцев.
- В продакшене используется в nerdy.pro и formtastic.de.
И честная обратная сторона: нет переупорядочивания индийских письменностей, нет подстановки шрифта для отдельных глифов, нет автоматических переносов по слогам, нет отслеживания исправлений и комментариев, нет SmartArt и диаграмм, а обтекание изображений в режимах tight и through аппроксимируется описывающим прямоугольником, а не полигоном. Всё это перечислено с пометкой ❌ в той же таблице, что и достижения.
Установка
cargo install dxpdf # CLI
pip install dxpdf # Python
curl -LO https://github.com/nerdy-pro/dxpdf/releases/download/v0.5.0/dxpdf_0.5.0-1_amd64.deb
sudo apt install ./dxpdf_0.5.0-1_amd64.deb
- Исходники и issues — github.com/nerdy-pro/dxpdf
- Заметки о релизе — v0.5.0
- Крейт — crates.io/crates/dxpdf · Документация API — docs.rs/dxpdf
- Пакет для Python — pypi.org/project/dxpdf
- Страница проекта — dxpdf на nerdy.pro
- Для контекста — как мы генерируем PDF на Rust с помощью Skia, фундамент, поверх которого нарисовано всё описанное выше
Вклад в проект нужен по-настоящему
dxpdf написан на Rust, и внести вклад здесь несложно. Две вещи стоят больше, чем кажется:
DOCX, который рендерится неправильно, ценен не меньше патча. Проект живёт на фикстурах: документ, воспроизводящий дефект и закоммиченный вместе с исправлением, — это то, как в таблице покрытия закрепилась каждая строка. Если dxpdf ломает ваш документ — заведите issue с файлом или с минимальной его версией, которую можно показать.
Колонка ❌ — это дорожная карта. Автоматические переносы, подстановка шрифта для отдельных глифов, зеркалированные табуляции при w:bidi, chineseCounting и остальные счётные форматы, SmartArt: каждая позиция — понятная по объёму задача с привязанным разделом спецификации. Первый PR от @ikashapov начался ровно оттуда.
Пожалуйста, заводите issue перед большим PR, а перед пушем запускайте то же, что запускает CI:
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all
Соглашения проекта описаны в AGENTS.md.
Частые вопросы
Конвертируете документы в больших объёмах?
dxpdf распространяется под лицензией MIT и с открытым исходным кодом — пользуйтесь, форкайте или расскажите, где он не справляется с вашими документами. Если обработка документов нужна не сбоку, а внутри продукта, напишите нам: этот конвертер появился потому, что он всё время требовался нам в клиентских проектах, и поддерживать его открыто нам приятнее, чем каждый раз втихую собирать заново.
Илья Никсан — основатель и ведущий разработчик Nerdy Production, Flutter-агентства, которое заодно пишет и поддерживает инфраструктурные инструменты вроде dxpdf и Orosu, на которых держится его собственная разработка.

