|
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-тесты. Скучно, но надёжно. Уровень 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: одна сессия рендера на проход, один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, что-то поменялось». - Где посмотреть
- Итого: задумка Я хотел библиотеку, которая:
- Описывает документ через семантику, а не через рисование. Код приложения должен читаться как структура контента.
- Тестирует 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». Можно по-другому. Спасибо, что дочитали. Вопросы и критику — в комменты.-Источник
|