Задача звучит безобидно: «нам нужно на бэкенде превращать .docx в .html (а иногда .html в .pdf)». В тикете это одна строчка. На практике конвертация офисных документов — одна из тех тем, где красивый прототип собирается за час, а потом полгода латается по краям: то таблица разъехалась, то картинки пропали, то сервис лёг под нагрузкой, то .doc из 2007-го отказывается открываться.

Я подходил к этой задаче двумя принципиально разными способами и в итоге держу два отдельных репозитория:

  • converter-apache-poi — чистая Java, конвертация «руками» через Apache POI (+ xdocreport, iText, PDFBox, pdf2dom и ещё десяток библиотек).

  • converter-libreoffice — Kotlin + Spring Boot, который дёргает headless LibreOffice через UNO API и просто просит его сохранить файл в другом формате.

Это не туториал «как повторить». Это разбор двух архитектурных подходов: как они устроены внутри, где именно ломается каждый, сколько это стоит по памяти и поддержке, и как в итоге выбрать, что вам нужно. Если вы сейчас гуглите «java docx to html» и смотрите на первые ответы со Stack Overflow — эта статья сэкономит вам пару недель.

TL;DR для тех, кто спешит

  • Apache POI — это парсер формата, а не рендерер. Он прекрасно читает структуру документа, но «конвертация в HTML/PDF» через него — это всегда сборка из нескольких библиотек, и точность верстки будет средней. Плюс: никаких внешних процессов, всё в JVM, полный контроль. Минус: сложную вёрстку он воспроизводит приблизительно, а .doc (старый бинарный формат) — совсем приблизительно.

  • LibreOffice headless — это готовый офисный движок. Он рендерит документ практически так же, как это сделал бы Word. Плюс: качество результата вне конкуренции, один вызов. Минус: тяжёлый внешний процесс, который надо ставить на сервер, он однопоточный по своей природе и умеет падать/зависать в самый неподходящий момент.

Грубое правило: нужна точность вёрстки — LibreOffice; нужен контроль, лёгкость и работа с содержимым, а не с пикселями — POI.

Дальше — подробно, с кодом из обоих проектов.

Подход №1. Apache POI: собираем конвертер из кубиков

Первое заблуждение, из-за которого люди выбирают POI для конвертации: «Apache POI же умеет работать с Word, значит умеет и конвертировать». POI действительно умеет работать с Word — на уровне модели документа: параграфы, таблицы, стили, картинки. Но у него нет собственного движка вёрстки. Он не знает, как ваш документ выглядит на странице. Поэтому «конвертация» превращается в задачу «прочитать структуру и самому построить HTML/PDF».

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

Стратегия 1: .docx → HTML напрямую (xdocreport)

Самый прямой путь для нового формата (.docx — это по сути ZIP с XML внутри). Берём XWPFDocument, отдаём его в XHTML-конвертер из xdocreport:

XWPFDocument document = new XWPFDocument(fis);
XHTMLOptions options = XHTMLOptions.create();
options.setImageManager(new ImageManager(new File("./"), "images"));

XHTMLConverter.getInstance().convert(document, fos, options);

Выглядит чисто. Но вот первые грабли, ради которых я и оставил этот код в репозитории:

for (XWPFTable table : document.getTables()) {
    for (XWPFTableRow row : table.getRows()) {
        for (XWPFTableCell cell : row.getTableCells()) {
            if (cell.getText().trim().isEmpty()) {
                cell.setText(" ");   // <-- костыль
            }
        }
    }
}

Проблема №1: пустые ячейки таблиц схлопываются. Пустая ячейка в HTML-выводе может «сложиться» и сломать всю таблицу визуально. Лечится тем, что ты вручную обходишь все таблицы и в пустые ячейки насильно вставляешь пробел. Это не баг конкретной библиотеки — это фундаментальное следствие того, что ты руками пересобираешь вёрстку. Как только начинаешь чинить один такой edge-case, понимаешь, что их сотни.

Стратегия 2: постобработка HTML через jsoup

Когда HTML уже получен, его почти всегда надо ещё «допилить» — POI-конвертеры выдают довольно наивную разметку. Здесь в дело идёт jsoup:

Document doc = Jsoup.parse(html);
Elements imgs = doc.select("img");
for (Element img : imgs) {
    img.attr("style", "float: left; margin-right: 10px;");
}

Проблема №2: результат почти всегда требует ручной постобработки. Ты не получаешь готовый HTML — ты получаешь заготовку, которую доводишь до ума парсером HTML поверх результата другого парсера. Это нормальный рабочий приём, но он показывает уровень: ты не «конвертируешь документ», ты «программируешь конкретный вид документов, которые ожидаешь на входе».

Стратегия 3: старый .doc через HWPF

Для бинарного формата .doc (Word 97–2003) в POI отдельный мир — poi-scratchpad и HWPFDocument:

HWPFDocument document = new HWPFDocument(fis);
WordToHtmlConverter converter = new WordToHtmlConverter(
        DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument());
converter.setPicturesManager((bytes, type, name, w, h) -> {
    // картинки надо доставать и сохранять руками
    File imageFile = new File(name);
    try (FileOutputStream fos = new FileOutputStream(imageFile)) {
        fos.write(bytes);
    }
    return imageFile.getAbsolutePath();
});
converter.processDocument(document);

Проблема №3: .doc и .docx — это два разных API и два разных уровня качества. WordToHtmlConverter для старого формата помечен как «в разработке» уже много лет и корректно переносит только базовую структуру. Картинки надо доставать самому через PicturesManager. Если у вас на входе смесь .doc и .docx (а в enterprise это норма), вы поддерживаете фактически два конвертера. Для POI старый .doc — самое слабое место.

Стратегия 4: цепочка отчаяния .docx → PDF → HTML

Когда прямой HTML не устроил по качеству, я попробовал зайти через PDF: сначала рендерим .docx в PDF (xdocreport PdfConverter), потом PDF разбираем в HTML (pdf2dom / PDFDomTree):

// docx -> pdf
XWPFDocument document = new XWPFDocument(in);
PdfConverter.getInstance().convert(document, out, PdfOptions.create());

// pdf -> html
PDDocument pdf = PDDocument.load(new File(pdfPath));
new PDFDomTree().writeText(pdf, new PrintWriter("output.html", "utf-8"));

Проблема №4: каждое звено конвертации теряет информацию, и потери накапливаются. PDF — формат про пиксели и абсолютное позиционирование, а не про семантику. После docx → pdf → html ты получаешь HTML, где текст разложен по абсолютным координатам, без нормальных абзацев, списков и таблиц как сущностей. Это годится, чтобы «показать похоже», но не годится, чтобы дальше с этим HTML работать. Сам факт, что этот путь вообще появился в проекте, — сигнал: одной библиотекой красиво не вышло.

Что POI делает хорошо, а что — плохо

Посмотрите на pom.xml проекта. Чтобы «просто конвертировать docx», в зависимостях собрались: poi, poi-ooxml, poi-scratchpad, ooxml-schemas, xmlbeans, три пакета xdocreport, jsoup, три пакета iText, pdfbox-tools, pdf2dom и даже jodconverter-core (обёртка над тем самым LibreOffice — то есть в какой-то момент я и внутри «чистого POI-проекта» потянулся за офисным движком).

Это и есть главная характеристика подхода POI: у него нет одной точки входа «сконвертируй мне файл». Есть россыпь библиотек, из которых ты собираешь пайплайн под свой конкретный тип документов.

Сильные стороны, за которые его всё-таки стоит любить:

  • Всё в JVM, никаких внешних процессов. Ничего не надо ставить на сервер, деплой — это просто jar. Для контейнеров и serverless это огромный плюс.

  • Полный контроль над содержимым. Если ваша реальная задача — не «показать документ пиксель-в-пиксель», а извлечь текст, таблицы, поля, пройтись по структуре, что-то подставить в шаблон — POI здесь король. Это его родная задача.

  • Предсказуемость по ресурсам. Никакой отдельный процесс не съест внезапно 2 ГБ и не зависнет — вы работаете в своей JVM со своими лимитами.

Слабые стороны:

  • Точность вёрстки — средняя, сложные документы (плавающие объекты, хитрые таблицы, колонки, колонтитулы) воспроизводятся приблизительно.

  • Старый .doc — совсем слабо.

  • «Конвертер» — это на самом деле ваш код на сотни строк edge-case’ов, а не библиотечный вызов.

Подход №2. LibreOffice headless: отдаём работу настоящему офису

Логика прямо противоположная. Вместо того чтобы самому строить вёрстку, мы берём настоящий офисный движок — LibreOffice в headless-режиме — и просим его: «открой этот файл и сохрани в другом формате». Тот же движок, что рисует документ на экране, отрендерит его и в файл. Отсюда качество, недостижимое для ручной сборки.

Технически это делается через UNO API (Universal Network Objects) — мост в потроха LibreOffice. В моём Kotlin-проекте он выглядит так:

val context = Bootstrap.bootstrap()
val loader = UnoRuntime.queryInterface(
    XComponentLoader::class.java,
    context.serviceManager.createInstanceWithContext(
        "com.sun.star.frame.Desktop", context)
) as XComponentLoader

val document = loader.loadComponentFromURL(
    file.toURI().toString(), "_blank", 0, emptyArray()
)

val storable = UnoRuntime.queryInterface(XStorable::class.java, document) as XStorable

val props = arrayOf(
    PropertyValue().apply { Name = "FilterName"; Value = "HTML (StarWriter)" },
    PropertyValue().apply { Name = "Overwrite"; Value = true }
)

storable.storeAsURL(outputHtmlFile.toURI().toString(), props)
document.dispose()

Весь конвертер — по сути, вот эти двадцать строк. Для html → pdf меняется буквально одна строка — имя фильтра. И вот здесь начинаются свои грабли, совершенно другого класса.

Проблема №5: FilterName — недокументированная магия строк

Обратите внимание на "HTML (StarWriter)" и "writer_web_pdf_Export". Это внутренние имена фильтров LibreOffice, и они:

  • нигде не лежат единым удобным списком в стиле «вот все допустимые значения»;

  • зависят от того, каким модулем открыт документ (Writer, Calc, Impress — у каждого свои);

  • при опечатке не дают внятной ошибки — ты просто получаешь пустой или неправильный файл.

// docx -> html
Name = "FilterName"; Value = "HTML (StarWriter)"

// html -> pdf (writer web!)
Name = "FilterName"; Value = "writer_web_pdf_Export"

Я потратил заметное время просто на то, чтобы подобрать правильную строку фильтра под каждую пару форматов. Это классическая боль UNO: API мощный, но документация — археология по форумам и исходникам.

Проблема №6: хардкод пути к LibreOffice и его JAR’ам

Загляните в build.gradle:

//LibreOffice -----------------------------------------------------------
implementation fileTree(dir: 'libs', include: '*.jar')
//TODO specify the path on the server
implementation files('C:\\Program Files\\LibreOffice\\program\\classes\\unoil.jar')

Вот он, честный //TODO, который переживёт нас всех. Чтобы собрать проект, нужны JAR’ы из установленной LibreOffice (unoil.jar, juh.jar, jurt.jar, ridl.jar), а путь к ним — это путь конкретной установки на конкретной машине. На Windows он один, в Docker-контейнере с Linux — совсем другой.

Отсюда главный операционный вывод: LibreOffice — это внешняя зависимость уровня инфраструктуры, а не Maven/Gradle-артефакт. Ваш Dockerfile теперь ставит офисный пакет, ваш деплой зависит от версии LibreOffice, а «работает у меня локально» и «работает на сервере» — это два очень разных утверждения.

Проблема №7: один Desktop-процесс — узкое место и точка отказа

Bootstrap.bootstrap() поднимает (или подключается к) единственный процесс soffice. И здесь два фундаментальных ограничения, о которых в туториалах молчат:

  1. Конкурентность. LibreOffice по своей природе не рассчитан на то, чтобы десятки потоков одновременно гнали через него документы. Если каждый HTTP-запрос дёргает Bootstrap.bootstrap() и открывает документ в общем Desktop — под нагрузкой вы получите гонки, зависания и падения процесса. В продакшене это лечится пулом процессов soffice (ровно для этого существует JODConverter) или очередью с ограничением параллелизма.

  2. Живучесть. Внешний процесс может зависнуть на «битом» документе и не отдать управление. Нужны таймауты, healthcheck и авто-рестарт soffice. Внутри JVM (как у POI) такой проблемы просто нет.

Проблема №8: GET с телом и ручная уборка мусора

Пара штрихов из REST-слоя, которые я оставил как есть и честно показываю:

@GetMapping("convertDocxToHtml")
fun convertDocxToHtml(@RequestBody fileJson: FileJson): String { ... }

@GetMapping с @RequestBody — так делать не стоит: многие клиенты, прокси и спецификация HTTP тело у GET не жалуют. Для загрузки файла это должен быть POST (а лучше multipart/form-data, а не массив байт в JSON).

И ещё: конвертер физически пишет файлы на диск в структуру files/год/месяц/день/, а чистится это отдельным ручным эндпоинтом:

@PostMapping("deleteDirectory")
fun deleteDirectory(): String {
    val directory = File("files/")
    directory.deleteRecursively()
    ...
}

Проблема №9: LibreOffice-подход почти всегда работает через файловую систему. Движок открывает файл по URL и сохраняет файл по URL — ему нужны реальные пути. Значит, у вас появляется временное файловое хранилище, а с ним — вопросы уборки, дискового места, конкурентного доступа и очистки чувствительных данных. У POI, работающего со стримами в памяти, этого класса проблем нет.

Что LibreOffice делает хорошо, а что — плохо

Сильные стороны:

  • Качество рендеринга. Это тот же движок, что показывает документ на экране. Сложные таблицы, колонтитулы, стили, шрифты, плавающие объекты — воспроизводятся так, как задумывал автор. Для «покажи пользователю документ как в Word» альтернатив по качеству практически нет.

  • Один вызов на любой формат. docx, doc, odt, xlsx, pptxhtml, pdf, что угодно — меняется только имя фильтра. Не нужно поддерживать отдельный код под каждый входной формат.

  • Старый .doc работает так же хорошо, как и .docx — движку всё равно, он их открывает нативно. Это ровно то место, где POI проваливается.

Слабые стороны:

  • Тяжёлая инфраструктурная зависимость (установка, версии, Docker).

  • Однопоточность и хрупкость внешнего процесса — нужен пул/очередь/таймауты/рестарты.

  • Работа через файловую систему, а не потоки.

  • «Магические» фильтры и археологическая документация UNO.

  • Потребление памяти: полноценный офисный процесс — это сотни мегабайт RSS даже в покое.

Лоб в лоб

Критерий

Apache POI (+ обвязка)

LibreOffice headless (UNO)

Что это по сути

Парсер/модель формата

Настоящий офисный рендер-движок

Точность вёрстки

Средняя, edge-case’ы руками

Высокая, «как в Word»

Старый .doc

Слабо (HWPF «в разработке»)

Наравне с .docx

Внешние зависимости

Нет, всё в JVM

LibreOffice на сервере/в образе

Деплой

jar и всё

Установка офиса, версии, Docker

Конкурентность

Ограничена только JVM

Однопоточный процесс, нужен пул

Память

Предсказуемая, лёгкая

Сотни МБ на процесс

Работа с данными

Стримы в памяти

Через файловую систему

Контроль над результатом

Полный (это ваш код)

Ограничен опциями фильтра

Объём вашего кода

Сотни строк edge-case’ов

~20 строк на конвертацию

Документация

Нормальная (POI), хуже у xdocreport

UNO — археология

Как выбирать: короткий чеклист

Берите LibreOffice, если:

  • главное требование — визуальная точность («документ должен выглядеть как в Word»);

  • на входе зоопарк форматов, включая старые .doc, .xls, .ppt;

  • вы готовы держать это как инфраструктурный сервис: отдельный контейнер с офисом, пул процессов (смотрите в сторону JODConverter — он берёт на себя пул, таймауты и рестарты soffice, и снимает половину граблей из этой статьи), очередь, мониторинг.

Берите Apache POI, если:

  • вам важнее содержимое, чем пиксели: извлечь текст/таблицы/поля, подставить данные в шаблон, что-то проверить;

  • вы не можете или не хотите тащить на сервер внешний офисный пакет (serverless, жёсткие ограничения окружения);

  • нужна предсказуемость по памяти и параллелизму;

  • вы готовы писать и поддерживать конвертацию под свой конкретный набор документов, а не «любой файл вообще».

Практический гибрид, к которому я в итоге склоняюсь: POI — для чтения и работы с содержимым документов, LibreOffice (через JODConverter, а не голый UNO) — для случаев, когда действительно нужен точный визуальный рендер. Ровно поэтому jodconverter-core в какой-то момент оказался даже в зависимостях «чистого POI-проекта»: как только от тебя требуют настоящей вёрстки, ты всё равно приходишь к офисному движку.

Оба проекта лежат у меня на GitHub: converter-apache-poi и converter-libreoffice. Если гоняете конвертацию под нагрузкой — расскажите в комментариях, как решали проблему пула процессов LibreOffice, это отдельная большая тема.