Задача звучит безобидно: «нам нужно на бэкенде превращать .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. И здесь два фундаментальных ограничения, о которых в туториалах молчат:
Конкурентность. LibreOffice по своей природе не рассчитан на то, чтобы десятки потоков одновременно гнали через него документы. Если каждый HTTP-запрос дёргает
Bootstrap.bootstrap()и открывает документ в общем Desktop — под нагрузкой вы получите гонки, зависания и падения процесса. В продакшене это лечится пулом процессов soffice (ровно для этого существует JODConverter) или очередью с ограничением параллелизма.Живучесть. Внешний процесс может зависнуть на «битом» документе и не отдать управление. Нужны таймауты, 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,pptx→html,pdf, что угодно — меняется только имя фильтра. Не нужно поддерживать отдельный код под каждый входной формат.Старый
.docработает так же хорошо, как и.docx— движку всё равно, он их открывает нативно. Это ровно то место, где POI проваливается.
Слабые стороны:
Тяжёлая инфраструктурная зависимость (установка, версии, Docker).
Однопоточность и хрупкость внешнего процесса — нужен пул/очередь/таймауты/рестарты.
Работа через файловую систему, а не потоки.
«Магические» фильтры и археологическая документация UNO.
Потребление памяти: полноценный офисный процесс — это сотни мегабайт RSS даже в покое.
Лоб в лоб
Критерий | Apache POI (+ обвязка) | LibreOffice headless (UNO) |
|---|---|---|
Что это по сути | Парсер/модель формата | Настоящий офисный рендер-движок |
Точность вёрстки | Средняя, edge-case’ы руками | Высокая, «как в Word» |
Старый | Слабо (HWPF «в разработке») | Наравне с |
Внешние зависимости | Нет, всё в 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, это отдельная большая тема.
