GraphCompose: как я приволок ECS из геймдева и снапшот-тесты из фронта в PDF-генерацию на Java

Страницы:  1

Ответить
 

Professor Seleznov


С чего всё началось: проблема, которая бесила
В мире 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 или 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: одна сессия рендера на проход, одинPDPageContentStreamper 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:

    com.github.DemchaAV
    GraphCompose
    v1.4.1
  • Документация в репо: 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». Можно по-другому.
Спасибо, что дочитали. Вопросы и критику — в комменты.-Источник
 
Loading...
Error