Ни в коей мере не пытаясь причислиться к пантеону богов, создавших словесный свет и отделивших его от бинарной тьмы, я все же оказался способен, благодаря выразительности языка Kotlin, в одиночку насыпать сахара поверх древнего могучего колосса.

Речь о библиотеке DocxKtm, собранной буквально «на коленке». Она завернула мощь и силу Java-либы для автоматизации MS Office docx4j в лаконичность и удобство котлиновского DSL.

Но не только


Предыстория и мотивация

Началось все, как часто бывает, с лени. Мне было лень писать руками горы документов, особенно если в них нужно подставлять названия, реквизиты, цифры, суммы, номера, даты, в общем, все то, что и так уже хранилось где-то в таблицах или базе данных. Тем более написание вручную всегда влечет опечатки и ошибки, даже у тех, кто в упор отрицает эту реальность. Бонусом все эти данные постоянно меняются во времени и пространстве.

Соединять таблицу Excel с документом Word было можно, но влекло чрезвычайные неудобства, особенно если речь шла о форматировании данных и подстановке не ячейки, а, допустим, диапазона, который неплохо было бы представить в виде списка или таблицы.

На тот момент я на износ эксплуатировал свой проект, созданный в Lazarus + FreePascal, и перепробовал все от встроенных, но при этом богатых средств создания отчетов LazReports (полный клон Fast Reports в мире Delphi) до попыток использования каких-то редких академических либ, позволяющих автоматизировать Word через OLE или понимающих OpenOffice XML. В итоге плюнул на все, поскольку ничего не работало, и остановился на ручной генерации html страниц буквально из строк.

Однако html - тоже не самый приятный формат представления документа, который обычно принято печатать, а еще форматировать так, как это делают именно в Word'е жители офисов, навравшие в свое время про «уверенный пользователь office». Поэтому html было решено открывать в LibreOffice и в нем уже сохранять в родную xml-кашу формата Word.

Шли годы, случился ковид, после которого я буквально пропитался Котлином, влюбился в Compose, а потом и в Compose multiplatform. Ну и не смог удержаться и сорвался в программирование снова. С приобретенным за все прошедшие годы багажом знаний в чистой архитектуре, чистом коде, чистом белье и чистых мыслях я полностью переписал весь тот офисный проект с нуля. Переложил данные из mysql в mariadb, кнопочки перекрасил в приятные цвета палитры Material Design, а вот с генерацией docx документов разных мастей опять не вышло. И это не смотря на богатейшую экосистему мира Java. Был момент (как раз после второго ковида), когда я уж было начал мигрировать на Python, поскольку там была библиотека docxtpl, где однострочником можно было написать четвертый том войны и мира, распарсив какой-нибудь dataframe. Однако быстроразвивающийся мир Compose и желание проникнуть еще и в мир мобильной разработки вернули меня в Kotlin.

Итак, в 2023 году я открыл гугл и попросил его перечислить мне первый десяток Java-библиотек, с помощью которых я на Котлине щелчком пальцев смогу сделать что-то похожее на docxtpl. К удивлению гугл не принес мне десять страниц с ассортиментом на любой вкус, а предложил пройти на три буквы POI, за которыми скрывалась библиотека из экосистемы Apache. И еще аналогичную ей библиотеку docx4j. Сейчас уже не помню, почему я выбрал последнюю, возможно из-за того, что все-таки сумел кое-как разобраться в ее документации и примерах, чтобы начать творить. Но еще возможно роль сыграло и то, что POI не умела соединять данные с шаблонами, то есть наподобие docxtpl в заранее подготовленный шаблон вставлять данные программно в нужные места. Docx4j при этом кое-какой элементарный инструментарий для такого все же имела.

Напомню всем, что речь сейчас идет о тех древних временах, когда еще нельзя было попросить сгенерировать код первую попавшуюся LLM, поэтому чтобы создать простейший документ Word с парой абзацев, табличкой без изысканного форматирования и нумерацией страничек внизу, следовало бы сначала изучить документацию (включая описание формата docx и структуры всех входящих в него xml частей, их атрибутов и прочего), понять как, а потом сесть и написать примерно вот это:

й fun main() {
    val wordMLPackage = WordprocessingMLPackage.createPackage()
    val mainDocumentPart = wordMLPackage.mainDocumentPart
    val factory = Context.getWmlObjectFactory()

    // --- Footer with page numbers ---
    val footerPart = FooterPart(PartName("/word/footer1.xml"))
    val footer = factory.createFooter()

    val p = factory.createP()
    val r = factory.createR()
    val fldCharBegin = factory.createFldChar().apply { fldCharType = STFldCharType.BEGIN }
    val fldCharSeparate = factory.createFldChar().apply { fldCharType = STFldCharType.SEPARATE }
    val fldCharEnd = factory.createFldChar().apply { fldCharType = STFldCharType.END }

    fun createInstrText(value: String): R {
        val run = factory.createR()
        val instr = factory.createText().apply {
            this.value = value
            this.space = "preserve"
        }
        run.content.add(factory.createRInstrText(instr))
        return run
    }

    r.content.add(factory.createRFldChar(fldCharBegin))
    r.content.add(createInstrText(" PAGE "))
    r.content.add(factory.createRFldChar(fldCharSeparate))
    r.content.add(factory.createRFldChar(fldCharEnd))

    val r2 = factory.createR()
    r2.content.add(factory.createText(" of "))

    val r3 = factory.createR()
    r3.content.add(factory.createRFldChar(factory.createFldChar().apply { fldCharType = STFldCharType.BEGIN }))
    r3.content.add(createInstrText(" NUMPAGES "))
    r3.content.add(factory.createRFldChar(factory.createFldChar().apply { fldCharType = STFldCharType.SEPARATE }))
    r3.content.add(factory.createRFldChar(factory.createFldChar().apply { fldCharType = STFldCharType.END }))

    p.content.add(r)
    p.content.add(r2)
    p.content.add(r3)

    // Center paragraph
    val pPr = factory.createPPr()
    val jc = factory.createJc()
    jc.`val` = JcEnumeration.CENTER
    pPr.jc = jc
    p.pPr = pPr

    footer.content.add(p)
    footerPart.jaxbElement = footer
    wordMLPackage.mainDocumentPart.addTargetPart(footerPart)

    val sectPr = factory.createSectPr()
    val footerRef = factory.createFooterReference()
    footerRef.id = footerPart.relationshipsPart.relationships[0].id
    footerRef.type = HdrFtrRef.DEFAULT
    sectPr.footerReference.add(footerRef)
    mainDocumentPart.document.body.sectPr = sectPr

    // --- Random paragraphs ---
    repeat(5) {
        val para = factory.createP()
        val run = factory.createR()
        val text = factory.createText()
        text.value = "This is random text paragraph #${it + 1}. Lorem ipsum dolor sit amet."
        run.content.add(text)
        para.content.add(run)
        mainDocumentPart.addObject(para)
    }

    // --- Table with random numbers ---
    val tbl = factory.createTbl()
    val borders = factory.createCTTblBorders().apply {
        top = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
        bottom = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
        left = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
        right = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
        insideH = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
        insideV = factory.createCTBorder().apply { `val` = STBorder.SINGLE }
    }
    val tblPr = factory.createTblPr()
    tblPr.tblBorders = borders
    tbl.tblPr = tblPr

    repeat(3) {
        val tr = factory.createTr()
        repeat(5) {
            val tc = factory.createTc()
            val para = factory.createP()
            val run = factory.createR()
            val text = factory.createText()
            text.value = Random.nextInt(1, 100).toString()
            run.content.add(text)
            para.content.add(run)
            tc.content.add(para)
            tr.content.add(tc)
        }
        tbl.content.add(tr)
    }
    mainDocumentPart.addObject(tbl)

    // --- Save the file ---
    wordMLPackage.save(File("example.docx"))
}

Однако времена хоть и были древние, но все же не настолько, и в Котлине хотелось бы писать вот так:

DocxNew("example.docx") {
    body {
        repeat(5) { num ->
            paragraph { text("This is random text paragraph #${num + 1}. Lorem ipsum dolor sit amet.") }
        }
        table {
            repeat(3) {
                row {
                  listInCells(List(5) { Random.nextInt(100) })
                }
            }
        }
    }
    footer(ParagraphStyle(alignment = Alignment.CENTER)) {
        pageNumber("page #p of #t")
    }
}

И вот чтобы писать так, сначала мне пришлось написать собственную библиотеку, DSL-обертку поверх docx4j.

Как устроен этот ваш docx

По старой традиции писать библиотеку для использования чего-то нового я стал вместе с изучением этого нового, а именно структуры формата docx. Именно тогда я выяснил, что docx — это просто переименованный zip-архив, в котором десяток файлов и весьма сложная структура папок, а весь контент документа и его форматирование представлены в виде xml.

Если создать новый документ, написать в нем строчку текста, сохранить, переименовать расширение и открыть как архив, то внутри обнаружится примерно такая структура:

содержимое простейшего текстового документа
содержимое простейшего текстового документа

Безусловно только так и можно было создать столь мощный и богатый по своему арсеналу формат представления документа со всеми возможными и невозможными потребностями как с точки зрения рядового сотрудника офиса, так и писателя текста, редактора, издателя, иллюстратора, верстальщика. Об автоматизаторах в Microsoft тоже подумали, но как они это сделали — отдельная страшная история на десерт.

Текстовое содержимое всего документа находится внутри маленького файла document.xml, в котором даже при этом 99% всего содержимого - это управляющие xml тэги и атрибуты, не имеющие никакого прямого отношения к тексту документа:

Содержимое основного файла с текстом документа.
Содержимое основного файла с текстом документа.

Самый простой текстовый документ условно будет иметь вот такую иерархическую структуру (и это я максимально упрощаю):

логическая структура блоков текста внутри документа
логическая структура блоков текста внутри документа

Document — понятно, сам документ целиком.

Section — раздел, он может иметь собственный формат бумаги, ориентацию, собственную нумерацию страниц и собственные колонтитулы.

Paragraph — условно параграф, то есть блок контента, имеющий собственные свойства верстки — отступы, интервалы, выравнивание относительно раздела.

Run — кусок контента, имеющий одинаковое форматирование, но не только. Это «но не только» — самое противное, что могли изобрести. Это и есть то, что останавливает от желания работать с форматом и делать автоматизацию. Все заключается в том, что именно блоки run создатели формата решили наполнить всяческими управляющими тэгами, относящимися к проверке грамматики, закладкам, и прочему, не имеющему непосредственного отношения к самому контенту, то есть тексту.

Text — просто элементарный блок текста. Ни больше, ни меньше. Со своими подводными камнями. Например, пробелами Шредингера между собой, а также переносами строк, которые не создают новый абзац, как вы могли бы подумать. Нет, нет.

Показываю на кошках, как выглядит ад. Давайте в слове Hello допустим ошибку, напишем Helllo, дождемся это вредное красное подчеркивание и сохраним документ. Вот как будет выглядеть он изнутри теперь:

Увы, простое слово теперь разделено на три (3!) разных run-блока. Теперь скажу страшное: это я все открываю в LibreOffice, который не слишком сильно гадит внутрь xml. Родной Word превращает разметку простого текста в Санта‑Барбару. Но про эту боль — дальше.

В целом, если всего лишь изучить основы стандарта ECMA-376 (то есть, всего‑то 5076 страниц документа под названием Office Open XML File Formats — Fundamentals and Markup Language Reference, я не наврал, буквально основы), документацию к библиотеке docx4j, от которой есть «основы» в привычном смысле, а вся конкретика сводится к отсылкам в исходники примеров и тестов, то уже можно начать писать осмысленный код наподобие того первого фолианта, что я разместил в начале статьи.

Всего то лишь надо без конца повторять рутинную бесконечную последовательность действий:

  • создать на фабрике объект каждого отдельного свойства (атрибута)

  • создать на фабрике объект контейнера свойств

  • сложить в контейнер свойства

  • создать на фабрике объект для каждого отдельного свойства контейнера свойств

  • сложить в контейнер свойств свойства контейнера свойств

  • создать

  • сложить

  • создать

  • сложить

  • внимание, наконец-то добавить текст в контейнер текста

  • добавить контейнер текста в контейнер рана

  • добавить контейнер рана в контейнер параграфа

  • добавить контейнер параграфа в контейнер документа

  • собрать все в кучку, сохранить.

Так мы собственными руками сгенерируем самый настоящий docx с целой строкой текста!

К сожалению, создатели библиотеки docx4j решили, что и так сойдет. Джависты привычны к портянкам кода и не будут возмущаться. Люди вон целый день пишут километр когда на openGL, чтобы ночью нарисовать один статичный треугольник и ничего. Но мы, зумеры от кодеров, то бишь котлинисты, в ужасе от такого подхода. Завернуть весь шаблонный код в DSL — первая мысль, которая пришла в голову мне.

К моему еще большему сожалению, другие, к кому эта мысль просто обязана была прийти, отмахнулись от нее, и писать все эти DSL обертки пришлось мне самому.

Самое удивительное, что моего терпения на большую часть задуманного таки хватило! Я не покрыл на сегодняшний день никак структурную единицу разделов, поскольку для своих целей это не было нужно, не обернул все возможности форматирования текста и абзацев, хотя при остром желании добраться до них сквозь мой DSL напрямую к docx4j вполне возможно.

В целом уже реализованного функционала достаточно для того, чтобы библиотека DocxKtm умела:

  • генерировать документы с нуля;

  • управлять форматами страниц, колонтитулами;

  • вставлять текст с базовым форматированием;

  • добавлять изображения;

  • создавать таблицы (с объединением ячеек и вложенными таблицами);

  • нумеровать страницы.

Этого достаточно для большинства рабочих сценариев.

А потом мне захотелось повторить подвиг docxtpl.

Генерация документов по шаблонам

Всю мощь docxtpl осознаешь, когда одной строчкой кода можешь на основе обычного документа Word, в котором простым текстом разбросаны поля для подстановки, сгенерировать сотню готовых документов, соединив шаблон с данными, взятыми из базы или, к примеру, Json, преобразованного в питоновский словарь.

Docxtpl это умеет делать с помощью встроенного процессора текста jinja2. И разумеется в самом начале библиотека честно предупреждает об ограничениях, поскольку это все ж таки Word:

Restrictions The usual jinja2 tags, are only to be used inside the same run of a same paragraph, it can not be used across several paragraphs, table rows, runs. If you want to manage paragraphs, table rows and a whole run with its style, you must use special tag syntax as explained in next chapter. Note: a ‘run’ for Microsoft Word is a sequence of characters with the same style. For example, if you create a paragraph with all characters of the same style, MS Word will create internally only one ‘run’ in the paragraph. Now, if you put in bold a text in the middle of this paragraph, word will transform the previous ‘run’ into 3 different ‘runs’ (normal - bold - normal).

А что предлагает docx4j с точки зрения процессинга шаблонов?

Сразу скажу, что сложного процессинга наподобие буквально встроенного скриптового языка, который распознает синтаксис jinja2, в библиотеке docx4j нет. Но есть простейший парсер полей подстановок, оформленных в тексте вот таким синтаксисом: ${ field_name }.

При этом в документации к функции библиотеки, которая заменяет поля подстановки данными, буквально написано, что библиотека не рекомендует этот подход:

NB: There are at least 3 approaches for replacing variables in a docx.

1. as shows in this example

2. using Merge Fields (see org.docx4j.model.fields.merge.MailMerger)

3. binding content controls to an XML Part (via XPath)

and this is not the recommended approach!

А не безопасен он как раз с точки зрения закона неопределенности Гейзенберга в приложении к квантованию блоковrun Word'ом: стоит лишь допустить опечатку в имени поля подстановки или даже и не допускать, но позволить Word'у решить, что там опечатка есть, и все — ваша переменная разбита на мешок кода xml, как минимум на три различных run‑блока. В таких условиях невозможно гарантировать то, что все поля подстановки будут внутри единых блоков, которые сможет распарсить простой поиск по строке.

К счастью, создатели библиотеки предоставили кое‑какой вспомогательный функционал, который в целом может собрать воедино разбитые вероломным редактором блоки. Гарантии при этом повышаются, но никогда не становятся полными. Странно, что при этом нужны два отдельных вызова функций, хотя очевидно, что лучше вызвать, чем не вызвать подготовительную прокладку, и можно было бы упростить жизнь пользователям, но оставим это на совести компании Plutext.

Второй подход, о котором речь, — merge fields. Это отдельная боль Word'а, по моему мнению, работоспособная только в тех случаях, когда соединение делается в самом редакторе, а не программно. Просто нет никакого очевидного и интуитивного способа рядовому пользователю редактора в тексте разместить merge fields без установки соединения с источником данных.

Третий подход, гордо названный рекомендуемым разработчиками библиотеки, буквально заставит рядового пользователя текстового редактора стать квалифицированным программистом, не выходя из редактора. Ведь речь идет о так называемых Content controls! Не имея под рукой родного редактора Word, я не смогу показать как это выглядит, но описание из документации Microsoft даст некоторое представление. В общих словах, надо открыть вкладку разработчика, на них создавать отдельные элементы для управления контентом, например, plain text, которая по сути заменяет ту же переменную для подстановки типа ${ var }.

Затем, эти элементы контроля контента также вручную должны быть сопоставлены тэгам xml, по которым в дальнейшем уже силами библиотеки docx4j будет производиться маппинг данных. Ну и обещанный десерт — данные будут приходить из файлов xml и только из них. Благодаря способности chatGPT получать удовольствие от того, что никто в здравом уме делать не будет, мы теперь наконец-то можем увидеть как бы это могло выглядеть в коде:

Invoice

Customer: [ContentControl with tag "customerName"]
Date: [ContentControl with tag "invoiceDate"]

Items:
- [ContentControl with tag "item1"]
- [ContentControl with tag "item2"]

Total: [ContentControl with tag "totalAmount"]
fun main() {
    // Load template docx
    val templatePath = "template.docx"
    val wordMLPackage = WordprocessingMLPackage.load(File(templatePath))

    // Prepare XML data as string
    val xml =
        """
        <root>
            <customerName>John Smith</customerName>
            <invoiceDate>2025-09-20</invoiceDate>
            <item1>Apples - 10kg</item1>
            <item2>Bananas - 5kg</item2>
            <totalAmount>150.00 USD</totalAmount>
        </root>
        """.trimIndent()

    // Add custom XML part to the docx
    val xmlDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
        .parse(xml.byteInputStream())

    val customXmlDataStoragePart = CustomXmlDataStoragePart(PartName("/customXml/item1.xml"))
    val customXmlDataStorage = org.docx4j.model.datastorage.CustomXmlDataStorageImpl()
    customXmlDataStorage.setDocument(xmlDoc)
    customXmlDataStoragePart.data = customXmlDataStorage
    wordMLPackage.mainDocumentPart.addTargetPart(customXmlDataStoragePart)

    // Perform binding (populate content controls)
    BindingHandler.getInstance().applyBindings(wordMLPackage)

    // Save result
    val outFile = File("invoice_filled.docx")
    wordMLPackage.save(outFile)

    println("Generated: ${outFile.absolutePath}")
}

Увы, это все, на что была способна библиотека из коробки. Поговаривают, что ее платное расширение привносит кое-какие дополнительные возможности, однако они не затрагивают генерацию шаблонов.

Выход на сцену MVEL

У docxtpl был ninja, а у меня только variable replaces, который буквально парсил строки внутри документа Word, отягощенного xml разметкой. В поисках аналога для ninja я каким-то удивительным образом набрел на библиотеку MVEL2, которая представляет собой практически аналогичный встраиваемый скриптовый язык, максимально подходящий для создания сложных ветвящихся и динамически заполняемых шаблонов.

Описание синтаксиса MVEL2 не всегда однозначно и понятно, а также весьма скупо, однако благодаря наступившей эре LLM я все же сумел интегрировать движок шаблонизатора в свою библиотеку, таким образом совместив всю мощь предоставляемых возможностей манипулирования docx со стороны docx4j и богатейшие возможности написания динамических сценариев простым текстом внутри документа.

Отмечу, что как я ни старался писать промты, LLM'ки не выдали мне готовый и работающий код сами, однако повернули мою голову в нужном направлении. До некоторых приемов я бы, честно, сам не додумался, а к тому же LLM нужно было пару секунд, чтоб продраться сквозь все исходники MVEL и сразу же начать соображать какие классы с какими параметрами нужно использовать, а я бы этим занимался неделю или месяц, пока не бросил.

То, в чем LLM мне действительно помогли руками, так это в написании мелких рутинных кусков кода под мои желания. И вот это реально весело — видеть, как твое сформулированное в паре абзацев желание новой фичи выдается готовым куском шаблонного кода спустя секунду.

Совместными силами был написан движок шаблонизатора, по моему мнению, с выразительностью и возможностями побогаче, чем в самом docxtpl, на который я равнялся. Ну и без DSL средств языка Kotlin это было бы невозможно.

Вот пример кода, в котором показана работа с шаблонизатором (работающий пример из тестов):

val sampleJson = """
        {
          "orderId": "ORD-2025-09-15-001",
          "total": 299.99,
          "orderedAt": "2025-09-15T14:30:00Z",
          "customer": {
            "name": "Alice Smith",
            "age": 30,
            "vip": true
          },
          "items": [
            {
              "sku": "KB-2025-B",
              "desc": "Mechanical keyboard",
              "qty": 1,
              "unitPrice": 119.99
            },
            {
              "sku": "MS-2025-X",
              "desc": "Wireless mouse",
              "qty": 2,
              "unitPrice": 15.00
            },
            {
              "sku": "WB-2021-11",
              "desc": "Monitor FullHD",
              "qty": 1,
              "unitPrice": 150.00
            }
          ]
        }
    """.trimIndent()

    DocxTemplate("template.docx", "output.docx") {
        fromJsonString(sampleJson)
    }

В шаблоне Word при этом поля подстановок имеют различное форматирование, сохраняющееся после обработки движка, а Json-массив превращается в заполненную таблицу по шаблону, для чего нужно только заполнить одну строку с расстановкой полей в ячейках:

кстати, тэг @end{} не обязателен, но сохранен для верности синтаксису MVEL
кстати, тэг @end{} не обязателен, но сохранен для верности синтаксису MVEL

DSL шаблонизатора позволяет красиво создавать пары из полей подстановки и данных таким способом:

DocxTemplate(
    templateFilename = "template.docx",
    outputFilename  = "output.docx",
    filler          = "n/a"  // филлер для отсутствующих полей
) {
  "name" to "John Doe"  // обычная строка, а также число, дата или время

  fromMap(  // словарь с примитивными типами, массивами или вложенными словарями
      mapOf(
        "customer" to "Alice",
        "age" to 30,
        "items" to listOf(1, 2, 3)
      )
  )

  val user = User("Alex", 41, Gender.MALE)
  "user" to user    // Data-класс Котлина с поддержкой Enum'ов

  "total" to 9876.5 with CurrencyFormat.EUR  // встроенные возможности форматирования
}

Заключение

История DocxKtm — это пример того, как личная боль и лень могут породить библиотеку, которой могут пользоваться и другие. Мне хотелось избавиться от рутины — и вместо этого пришлось вгрызаться в XML-структуру Word, знакомиться с docx4j, разбираться в MVEL и писать свой DSL.

Сегодня библиотека умеет генерировать документы «с нуля», работать с таблицами, картинками, колонтитулами и — главное — создавать документы по шаблонам, практически как docxtpl в Python.

Я не ставлю себе цель конкурировать с больши��и проектами. Но если хоть кто-то ещё сможет сэкономить на рутине и не тратить время на копипасту в Word — значит, труд был не зря.

Возвращаясь к эксцентричному заголовку, который, надеюсь привлек ваше внимание, хочу сказать, что памятники библиотекам наподобие docx4j, POI или docxtpl, действительно, стоит ставить хотя бы потому, что без уже созданной экосистемы сама идея автоматизированной генерации документов выглядела бы фантастикой. Именно на их основе появляются новые удобные инструменты, и именно благодаря им программисты сегодня могут воспринимать то, что раньше казалось невыполнимым, как повседневную рутину.

Спасибо всем, кто дочитал до конца. Документация и примеры — в README

Комментарии (0)