С чего всё началось: проблема, которая бесила
В мире Java для генерации PDF исторически есть три лагеря:
Низкоуровневые рисовалки — iText, PDFBox. Быстро, мощно, но ты буквально пишешь на бумаге пиксели координатами. Любой инвойс превращается в 200 строк
contentStream.beginText() / setFont() / newLineAtOffset(...). А потом приходит дизайнер и говорит: «отступ должен быть 14, а не 12».Шаблонные движки — JasperReports, OpenPDF. Удобно для отчётов, но XML-шаблон — это отдельный язык, отдельный инструментарий, отдельная боль на ревью. Изменения логики растекаются между Java-кодом, JRXML и DTO.
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, в нём абзац».
Дальше движок:
собирает семантические узлы (
DocumentNode— модули, секции, параграфы, таблицы, строки, изображения, дивайдеры, page-break-ы, слои-стеки),компилирует их в layout-граф (
DocumentSession.layoutGraph()),пагинирует по правилам, заданным в определении узла (
NodeDefinition),отдаёт результат активному бэкенду (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-тесты. Скучно, но надёжно.
Уровень 2 — LayoutSnapshotAssertions. Сравнивает дерево layout-граф со схранённым JSON. Если внесли структурное изменение (добавилась колонка, поехал отступ), снапшот меняется, тест падает, ты смотришь diff в JSON и понимаешь, что произошло. Не нужно открывать PDF.
Уровень 3 — PdfVisualRegression. Это та часть, которая закрывает «диф структурно нормальный, но просто выглядит уродливо». Рендерим 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/.
Итого: задумка
Я хотел библиотеку, которая:
Описывает документ через семантику, а не через рисование. Код приложения должен читаться как структура контента.
Тестирует layout до рендера. Снапшоты, как в фронте. Регрессии ловятся в JSON-диффе, а не в визуальном код-ревью.
Один документ — много бэкендов. PDF сегодня, DOCX вчера-сегодня (уже работает), PPTX/HTML завтра. Без переписывания пользовательского кода.
Производит PDF, который не стыдно показать дизайнеру. Layer-стеки, фоны, темы, rich-text — first-class, не workaround-ы.
Не тормозит. ~2 мс на инвойс, 11k+ doc/sec на 16 потоках, ноль падений в стресс-тесте.
И — главное — построена на инженерных идеях, которые в Java-PDF-мире не очень популярны: ECS из геймдева, declarative-DSL из Compose/SwiftUI, snapshot-tests из фронта.
Эти три идеи, собранные в одну точку, дают довольно нетривиальный профиль возможностей. Поэтому я и написал эту статью: показать, что генерация документов — это не обязательно «либо рисуй пиксели, либо пиши XML». Можно по-другому.
Спасибо, что дочитали. Вопросы и критику — в комменты.
grizzli106
а зачем все это, если были/есть и будут Enterprise решения уровня Analytics Publisher, Crystal Reports, TIBCO Jaspersoft и другие 100500 решений, которые уже проверены годами и решают быстро и качественно при должном опыте поставленные задачи? а софткодеры по-прежнему изобретают велосипед и получают квадратные колеса с уймой проблем, которые уже давно решены в enterprise решениях. тратится больше времени, а значит и денег в подобных гравицапах
Demcha Автор
Я бы не стал спорить с тем, что 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 и назвать это фреймворком было бы куда более квадратным велосипедом.