С чего всё началось: проблема, которая бесила

В мире Java для генерации PDF исторически есть три лагеря:

  1. Низкоуровневые рисовалки — iText, PDFBox. Быстро, мощно, но ты буквально пишешь на бумаге пиксели координатами. Любой инвойс превращается в 200 строк contentStream.beginText() / setFont() / newLineAtOffset(...). А потом приходит дизайнер и говорит: «отступ должен быть 14, а не 12».

  2. Шаблонные движки — JasperReports, OpenPDF. Удобно для отчётов, но XML-шаблон — это отдельный язык, отдельный инструментарий, отдельная боль на ревью. Изменения логики растекаются между Java-кодом, JRXML и DTO.

  3. HTML→PDF — Flying Saucer, OpenHtmlToPdf. Внешне просто, но любой нетривиальный layout превращается в борьбу с CSS-движком, который про печатные документы знает мало.

Меня бесило, что в каждом из этих подходов исчезает семантика документа. PDF — это плоский поток операторов рисования. Шаблон — это просто XML. HTML — это вёрстка под экран.

А ведь документ — это семантика: «вот заголовок, вот секция, вот строка таблицы, вот итоговая ячейка». Эту семантику хочется писать прямо в Java, без отдельного шаблонного слоя, без XML, без CSS, и хочется чтобы её можно было тестировать, переиспользовать и рендерить во что угодно — сегодня PDF, завтра DOCX, послезавтра PPTX.

Так появился GraphCompose.


Идея: «author intent, not coordinates»

Главный принцип GraphCompose: код приложения описывает намерение автора, движок занимается геометрией.

Простой пример из README:

try (DocumentSession document = GraphCompose.document(Path.of("output.pdf"))
        .pageSize(DocumentPageSize.A4)
        .margin(24, 24, 24, 24)
        .create()) {

    document.pageFlow(page -> page
            .module("Summary", module -> module.paragraph("Hello GraphCompose")));

    document.buildPdf();
}

Здесь нет ни одной координаты. Я не считаю, где у меня кончается заголовок и начинается параграф. Я не пагинирую вручную. Я просто говорю: «вот модуль с заголовком Summary, в нём абзац».

Дальше движок:

  1. собирает семантические узлы (DocumentNode — модули, секции, параграфы, таблицы, строки, изображения, дивайдеры, page-break-ы, слои-стеки),

  2. компилирует их в layout-граф (DocumentSession.layoutGraph()),

  3. пагинирует по правилам, заданным в определении узла (NodeDefinition),

  4. отдаёт результат активному бэкенду (PDF через PDFBox, или DOCX через Apache POI).

Это тот же подход, который используют декларативные UI-фреймворки: Jetpack Compose, SwiftUI, React. Ты описываешь дерево, фреймворк его измеряет, размещает и рисует. Только применённый к документам, а не к экранам.


Прикол №1: ECS под капотом

Самая необычная часть архитектуры — внутри GraphCompose работает Entity-Component-System в стиле игровых движков.

src/main/java/com/demcha/compose/engine/
├── core/
│   ├── EntityManager.java        // менеджер сущностей
│   ├── SystemECS.java            // базовый класс системы
│   ├── SystemRegistry.java       // регистрация систем
│   ├── Canvas.java               // координатная система
│   └── LayoutTraversalContext.java
├── components/                   // компоненты (Placement, ContentSize, Padding…)
├── layout/                       // системы layout
├── pagination/                   // системы пагинации
└── render/                       // системы рендера

Когда семантический узел приходит в движок, он превращается в Entity — голый ID. К этому ID привязываются компоненты: Placement (где?), ContentSize (сколько занимает?), Padding, Margin, render-маркеры. Над компонентами работают системы: LayoutSystem считает геометрию, PaginationSystem режет на страницы, RenderingSystem дёргает бэкенд для отрисовки.

Зачем это нужно для документов? Несколько причин:

  • Композиция вместо наследования. Параграф с границей — это не подкласс параграфа, это сущность с компонентами ParagraphContent + Border + Padding. Добавить новый кросс-режущий аспект — это новый компонент, а не новая ветка в иерархии классов.

  • Дешёвые проверки. «Есть ли у этой сущности рендер-маркер?» — entity.hasRender(), O(1).

  • Чистые системы. EntityRenderOrder сначала собирает лёгкие sort-entries для каждого слоя, потом сортирует — без обращения к компонентам в горячей точке компаратора. Это критично, потому что render-order пересчитывается при каждой пагинации.

  • Расширяемость. Хотите добавить, скажем, эффект тени? Добавляете компонент Shadow и систему, которая его обрабатывает на render-этапе. Бэкенд рисует тень, если у сущности есть этот компонент. Никакой движок переделывать не надо.

Пример, который я сам долго переваривал. Ваше любимое «слой-стек» (overlay-примитив):

document.add(new LayerStackBuilder()
        .name("Hero")
        .back(heroBackgroundShape)              // фон, top-left
        .center(heroContent)                    // контент по центру
        .layer(badge, LayerAlign.TOP_RIGHT)     // бэйдж в углу
        .build());

Внутри это — отдельная ось Axis.STACK в CompositeLayoutSpec, наряду с VERTICAL и HORIZONTAL. Layout-компилятор для STACK позиционирует каждого ребёнка внутри box-а через offset для конкретного LayerAlign-а, переиспользуя ту же compileNodeInFixedSlot плюмбинг, которой пользуются строки.

То есть никакого нового рендер-кода не было написано вообще. Layer-стек — это просто новая раскладка над существующими сущностями. Это и есть преимущество ECS: новый layout-режим стоит дёшево, потому что все компоненты и системы уже есть.


Прикол №2: Layout и рендер — это два прохода

Вторая ключевая идея — два независимых прохода:

GraphCompose.document(...)
  → DocumentSession (мутабельная, не thread-safe, одна на запрос)
  → DocumentDsl / template compose
  → semantic nodes
  → layout graph              ← ПРОХОД 1
  → layout snapshot or render ← ПРОХОД 2
  → PDF stream/bytes/file

DocumentSession.layoutGraph() компилирует семантические узлы в детерминированный layout-граф: измеряет, пагинирует, размещает. На выходе — resolved fragments с уже посчитанными координатами.

DocumentSession.writePdf(...) берёт эти resolved fragments и просто их рисует через PdfFixedLayoutBackend.

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

a) Снапшот-тесты документа

DocumentSession.layoutSnapshot() извлекает геометрию из того же layout-графа, до рендера. Снапшоты стабильны между запусками и машинами — потому что в layout-проходе нет ничего, что зависит от состояния PDFBox.

LayoutSnapshotAssertions.assertThat(document.layoutSnapshot())
        .matchesGoldenFile("invoice-overview-layout.json");

Это то же самое, что снапшот-тесты в React/Jest или Compose UI: ты сравниваешь дерево с ранее сохранённым «золотым» состоянием и ловишь регрессии до того, как кто-то увидит баг визуально.

b) Бэкенд-агностичность

PDF-бэкенд через PDFBox — основной путь. DOCX-бэкенд через Apache POI — рабочий, отдаёт настоящий редактируемый файл (а не «PDF, переименованный в .docx»). Для будущих PPTX и других форматов нужно реализовать FixedLayoutBackend<R> или SemanticBackend и потреблять тот же LayoutGraph. Пользовательский код не меняется.

c) Page-background, который никто не заметил

Когда я добавлял в v1.4 фон страницы и бэкграунды секций, я ожидал, что придётся править PDF-рендерер. Не пришлось.

DocumentSession.layoutGraph() оборачивает результат compiler.compile(...) в withPageBackgrounds(...). Эта обёртка инжектит дополнительный ShapeFragmentPayload в начало каждой страницы — обычный шейп, как если бы вы добавили прямоугольник руками. PDF-рендерер просто итерирует фрагменты, его это не касается.

Это и есть «бэкенды никогда не должны знать про опции документа» в действии.


Прикол №3: Атомарная пагинация без боли

Если вы когда-нибудь делали PDF-таблицы вручную — вы знаете, что пагинация таблиц это отдельный круг ада. Заголовок повторяется или нет? Где режется строка? Что с границами на разрыве страницы?

В GraphCompose таблица описывается через DocumentTableNode. На layout-проходе она материализуется в «логические ячейки»: каждая авторская ячейка — это LogicalCell(startColumn, colSpan, content), разрешённый по stylesGrid[row][col]. Строки превращаются в атомарные leaf-сущности с предвычисленным cell payload.

С точки зрения пагинатора, строка таблицы — это атомарный блок. Не разрезается. Если не помещается — едет на следующую страницу целиком. Если на странице много строк — режется между строками, и каждый край страницы знает, чьё это право рисовать границу (чтобы не было двойной линии и не было пропуска линии).

Layer-стек атомарен. Hero-блок «фон + контент + бэйдж» либо помещается на странице, либо едет целиком. Никакой страницы с фоном без контента.

И ещё одно правило, которое я сначала недооценил: дети должны пагинироваться раньше родителей. Когда дочерняя сущность не помещается на странице, она сдвигается на следующую — и это поднимает ContentSize родителя. Если родитель уже зафиксирован — Placement.height остаётся старым, контейнер не дотягивается до сдвинутого ребёнка. Визуально это выглядит как «полоска фона почему-то заканчивается раньше последнего элемента».

Реализация в LayoutTraversalContext:

  • ParentComponent даёт авторитетную parent-связь,

  • Entity.children — канонический порядок сиблингов,

  • PageBreaker гоняет приоритетный топологический обход: в очередь готовых попадают только узлы без необработанных детей,

  • внутри ready-queue — сортировка по ComputedPosition.y, потом по глубине, потом по UUID.

Без pairwise ancestor-компаратора. Быстро, детерминированно, и устойчиво к «подвинул отступ — рендер сломался».


Прикол №4: Трёхуровневый regression-pyramid

Я к этому шёл несколько месяцев и считаю это самой ценной частью проекта.

1. Layout math unit tests           — проверяют отдельные расчёты
2. Layout snapshot tests            — проверяют детерминированную геометрию
                                      всего документа до рендера
3. PDF visual regression (PNG-diff) — проверяют, что рендер выглядит как раньше

Уровень 1 — обычные unit-тесты. Скучно, но надёжно.

Уровень 2LayoutSnapshotAssertions. Сравнивает дерево layout-граф со схранённым JSON. Если внесли структурное изменение (добавилась колонка, поехал отступ), снапшот меняется, тест падает, ты смотришь diff в JSON и понимаешь, что произошло. Не нужно открывать PDF.

Уровень 3PdfVisualRegression. Это та часть, которая закрывает «диф структурно нормальный, но просто выглядит уродливо». Рендерим PDF в PNG через PDFRenderer, сравниваем попиксельно с baseline-ом из src/test/resources/visual-baselines/. Падение теста кладёт рядом actual.png и diff.png — открыл, посмотрел, понял.

PdfVisualRegression visual = PdfVisualRegression.standard()
        .perPixelTolerance(6)
        .mismatchedPixelBudget(0);

byte[] pdf = session.toPdfBytes();
visual.assertMatchesBaseline("invoice-overview", pdf);

Чтобы благословить новый baseline:

mvn test -Dgraphcompose.visual.approve=true

Это даёт мне возможность рефакторить агрессивно. Снапшоты ловят, что геометрия не изменилась. Visual-regression ловит, что рендер не изменился. На main-ветке сейчас 525 зелёных тестов, из которых 41 — это «cinematic feature tests» из v1.4 (фоны, слои, rich-text, темы).


Прикол №5: Производительность

Честные цифры. Все они получены из scripts/run-benchmarks.ps1 на ноутбуке разработчика; CI-машины обычно в 1.5–2 раза медленнее.

End-to-end latency (полный профиль current-speed, 12 warmup + 40 measurement)

Сценарий

Avg ms

p50 ms

p95 ms

Docs/sec

engine-simple

3.00

2.73

4.86

333.83

invoice-template

17.74

17.44

25.13

56.38

cv-template

10.16

9.91

14.08

98.46

proposal-template

18.21

16.93

23.57

54.91

feature-rich

36.02

34.18

41.79

27.76

Per-stage breakdown (median ms на стадию):

Сценарий

Compose

Layout

Render

Total

invoice-template

0.33

2.55

5.76

8.63

cv-template

0.27

2.77

1.60

4.72

proposal-template

0.34

9.54

5.66

15.65

Видно интересное: рендер съедает 36–67% времени. Это сериализация PDFBox-ом, и тут моих ускорений нет — это работа по сжатию байтов. Layout — мой движок — занимает 2–10 мс на средних шаблонах.

Параллелизм (invoice template, 12 docs на поток)

Threads

Total docs

Throughput

Avg doc ms

1

12

89.56/s

11.17

2

24

143.53/s

6.97

4

48

245.26/s

4.08

8

96

328.78/s

3.04

Почти линейный рост до 4 ядер.

Linear scalability (scalability suite, простые документы)

Threads

Total docs

Throughput

1

100

807.41/s

2

200

1,960.75/s

4

400

3,839.64/s

8

800

7,394.56/s

16

1,600

11,164.76/s

13.8× ускорение на 16 потоках. В горячем пути нет глобальных синхронизаций — EntityManager создаётся per-session, текстовые кеши request-local.

Stress test: 50 потоков, 5000 документов, один прогон

Successful: 5000
Errors:     0
Time:       2499 ms

~2000 doc/sec под контеншном, ноль падений.

Сравнение с другими (простой инвойс-документ, 100 итераций)

Library

Avg ms

Avg heap MB

Заметки

iText 5

1.57

0.16

низкоуровневые примитивы

GraphCompose v1.4

2.45

0.16

семантический DSL + пагинация

JasperReports

4.45

0.19

XML-шаблонный движок

Я нахожусь между низкоуровневой рисовалкой и шаблонным движком: в 1.5× медленнее iText (но получаешь полноценный семантический DSL и автопагинацию), в 1.8× быстрее JasperReports (без XML-шаблонного слоя вообще).

Что важно: engine-only без рендераGraphComposeBenchmark — это avg 1.04 ms, p50 0.97 ms, p95 1.64 ms. То есть мой layout-движок сам по себе очень быстрый, бутылочное горлышко — это PDFBox-сериализация, и это уже не моя зона ответственности.


Дизайнерский слой: «cinematic» в v1.4

В v1.3 у меня был отстроен тидиC-PDF: ровный текст, ровные таблицы, всё работает. Но визуально это было «бухгалтерский отчёт». Дизайнер бы плюнул.

В v1.4 я закрыл этот гэп шестью фичами:

1. Column spans

Одна ячейка может занимать несколько колонок:

.rowCells(
    DocumentTableCell.text("Total").colSpan(3)
            .withStyle(DocumentTableStyle.builder()
                    .fillColor(DocumentColor.LIGHT_GRAY)
                    .build()),
    DocumentTableCell.text("$200.00"));

TableLayoutSupport валидирует, что sum(colSpan) == columnCount в строке, распределяет лишнюю ширину по auto-колонкам внутри спана, и сохраняет согласованность border-ownership. Спан-ячейка эмитит один TableResolvedCell — рендереру ничего менять не надо.

2. Layer stacks (overlay primitive)

Описано выше. Девять LayerAlign — четыре угла, четыре стороны, центр. Hero-блоки, водяные знаки, бэйджи в углу — всё на этом примитиве.

3. Page и section backgrounds

GraphCompose.document(Path.of("proposal.pdf"))
        .pageBackground(new Color(252, 248, 240))  // кремовая бумага
        .create();

Один сеттер. Внутри — инжект фрагмента в начало каждой страницы. PDF-бэкенд не тронут.

Для секций — пресеты в AbstractFlowBuilder:

section
    .band(navy)                    // полноширинный цветной баннер
    .softPanel(palePink)           // fill + 8pt corner radius + 12pt padding
    .accentLeft(navy, 4)           // акцентная полоса слева
    .accentBottom(navy, 2);        // линейка снизу под заголовком

4. Rich-text DSL

Смешанные стили в одной цепочке:

section.addRich(t -> t
    .plain("Status: ")
    .bold("Pending")
    .plain(" — last review on ")
    .accent("Mar 14", brandBlue));

Без необходимости разбивать параграф или прятать текст в табличную ячейку.

5. Business themes

Один BusinessTheme — это DocumentPalette + SpacingScale + TextScale + TablePreset + опциональный фон страницы. Три встроенных пресета: classic(), modern() (кремовая бумага + тил/золото), executive().

Инвойс / предложение / отчёт, рендерящиеся через одну тему, выглядят как один продукт, а не как три независимо стилизованных документа.

6. Visual regression

Описана в трёхуровневом пиaмiде выше.


Что было сложно: грабли, на которых я постоял

Раз уж это статья на Хабр, то без раздела «грабли» — несерьёзно.

Грабля 1: пагинация и ContentSize родителя. Уже описана выше. Когда я в первый раз увидел «контейнер обрывается, не доходя до последнего ребёнка» — два дня дебажил рендерер. Оказалось — porder обхода. Зафиксил через LayoutTraversalContext и приоритетный топологический walk.

Грабля 2: PDFBox держит за горло PDPageContentStream. Каждое его открытие/закрытие — дорогая операция. Изначально я открывал stream для каждой сущности на странице — на сложных шаблонах это убивало производительность. Решение — RenderPassSession: одна сессия рендера на проход, один PDPageContentStream per page на всё время прохода. Хэндлеры могут менять graphics/text state, но обязаны его восстанавливать перед возвратом.

Грабля 3: Entity.getComponent и isDebugEnabled. Замерял через JMH-подобный профилировщик — на горячем пути компонент-лукапов было 5–7% времени, и это были… логи. Даже guarded if (logger.isDebugEnabled()) стоит volatile-чтения на Logback. Убрал per-call логирование с getComponent / require — получил +6% в среднем.

Грабля 4: comparator allocations. В PageBreaker.paginationPriority старый компаратор использовал UUID.toString() для tie-break. Это 36-символьная строка на каждое сравнение в priority queue. Заменил на UUID.compareTo() + предвычисленные (y, depth) ключи — приоритет очереди стала ощутимо быстрее.

Грабля 5: «структурно ок, но визуально дно». Когда я делал v1.4, я ловил себя на том, что layout-снапшоты зелёные, а рендер выглядит фигово (отступ внутри softPanel пиксельно отъехал, тон фона оказался не тот). Это и стало мотивацией для PdfVisualRegression. Теперь у меня в CI крутятся PNG-диффы на ключевых шаблонах, и я могу рефакторить без страха.


Куда дальше

Текущий релиз — v1.4.1. Roadmap:

  • [ ] table row spans (vertical merging)

  • [ ] header repeat on page break + zebra rows + total row пресеты в TablePreset

  • [ ] anchored overlay позиции (position(x, y) внутри слоя)

  • [ ] Maven Central релиз (сейчас живёт через JitPack)

  • [ ] настоящий PPTX export (v1.3 уже даёт manifest skeleton)

Ещё хочу написать отдельную статью про снапшот-тестирование конкретно: как я выбирал формат золотого файла (JSON vs YAML vs custom DSL), как обходил проблемы с порядком ключей и floating-point дрейфом в координатах, как сделал approve-mode так, чтобы он не превращался в «git add всех baselines, что-то поменялось».


Где посмотреть

  • Исходники: https://github.com/DemchaAV/GraphCompose

  • Maven через JitPack:

    <dependency>
        <groupId>com.github.DemchaAV</groupId>
        <artifactId>GraphCompose</artifactId>
        <version>v1.4.1</version>
    </dependency>
    
  • Документация в репо: docs/architecture.md, docs/lifecycle.md, docs/pagination-ordering.md, docs/benchmarks.md.

  • Runnable примеры: в репозитории есть модуль examples/ — там CV, cover letter, инвойс, обычное предложение, cinematic предложение, недельное расписание, module-first документ. Один Java-файл на пример, никакого XML.

    ./mvnw -f examples/pom.xml clean package
    ./mvnw -f examples/pom.xml exec:java \
        -Dexec.mainClass=com.demcha.examples.GenerateAllExamples
    

    Каждый пример пишет PDF в examples/build/.


Итого: задумка

Я хотел библиотеку, которая:

  1. Описывает документ через семантику, а не через рисование. Код приложения должен читаться как структура контента.

  2. Тестирует layout до рендера. Снапшоты, как в фронте. Регрессии ловятся в JSON-диффе, а не в визуальном код-ревью.

  3. Один документ — много бэкендов. PDF сегодня, DOCX вчера-сегодня (уже работает), PPTX/HTML завтра. Без переписывания пользовательского кода.

  4. Производит PDF, который не стыдно показать дизайнеру. Layer-стеки, фоны, темы, rich-text — first-class, не workaround-ы.

  5. Не тормозит. ~2 мс на инвойс, 11k+ doc/sec на 16 потоках, ноль падений в стресс-тесте.

И — главное — построена на инженерных идеях, которые в Java-PDF-мире не очень популярны: ECS из геймдева, declarative-DSL из Compose/SwiftUI, snapshot-tests из фронта.

Эти три идеи, собранные в одну точку, дают довольно нетривиальный профиль возможностей. Поэтому я и написал эту статью: показать, что генерация документов — это не обязательно «либо рисуй пиксели, либо пиши XML». Можно по-другому.

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

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


  1. grizzli106
    03.05.2026 20:23

    а зачем все это, если были/есть и будут Enterprise решения уровня Analytics Publisher, Crystal Reports, TIBCO Jaspersoft и другие 100500 решений, которые уже проверены годами и решают быстро и качественно при должном опыте поставленные задачи? а софткодеры по-прежнему изобретают велосипед и получают квадратные колеса с уймой проблем, которые уже давно решены в enterprise решениях. тратится больше времени, а значит и денег в подобных гравицапах


    1. Demcha Автор
      03.05.2026 20:23

      Я бы не стал спорить с тем, что Crystal Reports, JasperReports и подобные решения существуют не просто так. Если задача хорошо решается enterprise-репортингом - надо брать enterprise-репортинг и не героически страдать.

      GraphCompose не пытается быть “ещё одним JasperReports”. Это скорее code-first layout engine для случаев, когда PDF- часть приложения, а не отдельная отчётная платформа.

      Мне была интересна другая модель, документ описывается кодом, layout версионируется в Git, геометрию можно тестировать до генерации PDF, pipeline можно гонять в CI, pagination/rendering не спрятаны в чёрный ящик, нет отдельного report server/designer/runtime ради пары кастомных документов.

      То есть это не “давайте заменим 20 лет enterprise-отчётности за выходные”. Это скорее попытка сделать встраиваемый layout-инструмент для тех задач, где enterprise-комбайн выглядит тяжелее самой проблемы.

      А квадратные колёса - возможно. Но именно поэтому я и вынес layout, pagination и snapshot testing в отдельные концепции. Просто рисовать текст через PDFBox и назвать это фреймворком было бы куда более квадратным велосипедом.