dxpdf превращает .docx в PDF. Без Microsoft Office, без headless-режима LibreOffice, без облачных API — один бинарник на Rust, который читает OOXML напрямую и рисует результат через от 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-sample3334 КБ170 мс55 МБ
sample-docx-files-sample-4710 КБ170 мс52 МБ
sample-docx-files-sample191,3 МБ55 мс42 МБ
sample-docx-files-sample417114 МБ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

Вклад в проект нужен по-настоящему

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.

Частые вопросы

0.5.0 — релиз про интернационализацию: переносы строк по UAX #14 через ICU4X (включая тайский, лаосский, кхмерский и бирманский), двунаправленный текст по UAX #9 с зеркалированием по правилу L4, шейпинг через HarfBuzz для письменностей с курсивным соединением, десятичные разделители с учётом региона и локализованные маски полей DATE/TIME по атрибуту w:lang документа, а также числительные прописью для английского, немецкого, французского и испанского. Кроме того, исправлено несколько краевых случаев пагинации, добавлен синтез жирного и наклонного начертаний, Windows добавлена в матрицу CI и появилась сборка .deb для Debian и Ubuntu.
LibreOffice в режиме --headless — стандартный ответ на эту задачу, и конвертирует DOCX он точно, но это десктопный пакет, запущенный на сервере. Он сериализуется вокруг каталога своего профиля, так что каждому параллельному воркеру нужен свой; он умеет зависать на необычных документах, и вокруг него приходится строить таймаут, сторож и сборщик осиротевших процессов; он занимает сотни мегабайт резидентной памяти и требует соответствующего образа контейнера; а его вывод меняется при смене версии LibreOffice. dxpdf — один бинарник на Rust с библиотечным API, предсказуемым профилем памяти и выводом, который меняется только вместе с самим конвертером.
Нет. dxpdf — самостоятельный бинарник на Rust, который читает DOCX напрямую и отрисовывает PDF через Skia. Не нужны ни установленный Office, ни headless-процесс LibreOffice, ни внешний сервис — а значит, документы не покидают машину, на которой идёт конвертация.
На Apple M3 Max закоммиченные фикстуры конвертируются за 55–170 мс, а документ на 171 страницу и 14 МБ — примерно за 420 мс. Размер документа значит меньше, чем то, как разрешаются шрифты: документ, чьи шрифты встроены или уже есть в системе, тратит на это около 4 мс, а тот, что падает в системный индекс метаданных, платит 120–185 мс один раз.
Начиная с 0.5.0: переносы по UAX #14 для всех письменностей, включая те, что пишутся без пробелов (тайская, лаосская, кхмерская, бирманская), двунаправленный текст по UAX #9 для арабского и иврита с зеркалированием, шейпинг через HarfBuzz для письменностей с позиционными формами — арабской, сирийской, нко, монгольской, адлам. Переупорядочивание индийских письменностей пока не поддерживается, и нет подстановки шрифта для отдельных глифов: документ должен называть шрифт, покрывающий используемые символы, — так, как это делает Word.
Да. Установите его командой pip install dxpdf и вызывайте dxpdf.convert(bytes) или dxpdf.convert_file("input.docx", "output.pdf"). Wheel-пакеты публикуются для macOS, Linux и Windows под Python 3.8 и новее, так что тулчейн Rust для использования не нужен.
Заведите issue с DOCX, который рендерится неправильно: проект живёт на фикстурах, поэтому воспроизводящий документ полезен не меньше патча. Если хочется писать код — неподдерживаемые пункты в матрице возможностей из README и есть дорожная карта, у каждого указан раздел ISO 29500. Перед большим PR заведите issue, а перед пушем прогоните cargo fmt, cargo clippy и cargo test.

Конвертируете документы в больших объёмах?

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


Илья Никсан — основатель и ведущий разработчик Nerdy Production, Flutter-агентства, которое заодно пишет и поддерживает инфраструктурные инструменты вроде dxpdf и Orosu, на которых держится его собственная разработка.