В прошлой статье о GraphCompose я рассказывал, как взял идеи из разных областей разработки и попытался применить их к генерации документов: декларативный DSL, отдельный layout-проход, снапшот-тесты и даже немного ECS-мышления.
compose() создаёт PPTX, векторный PDF и PNG-превью — движок рисует схему собственной архитектуры, рекурсия пока под контролем. Открыть PDF · Посмотреть исходный код.Тогда GraphCompose в основном выглядел как PDF-движок.
Это было логично: полноценным backend’ом был PDF, внутри работал PDFBox, а поддержка других форматов находилась где-то между roadmap и классическим разработческим «да, потом обязательно сделаю».
Но изначально я не хотел строить ещё одну библиотеку, которая умеет только PDF.
Основная идея была другой:
Разработчик должен описывать документ на одном языке. Движок должен один раз рассчитать структуру, размеры, переносы и координаты. А конкретный формат должен быть только способом нарисовать уже готовый результат.
То есть PDFBox не должен быть архитектурой.
Apache POI тоже не должен быть архитектурой.
Они должны быть backend’ами.
Обычно каждый формат заставляет начинать сначала
Представим обычную задачу: нужно сформировать бизнес-отчёт.
В нём есть:
-
заголовок;
-
несколько KPI;
-
таблица;
-
диаграмма;
-
пояснительный текст;
-
footer;
-
ссылки;
-
возможно, несколько страниц.
Если мы создаём PDF через PDFBox, нам нужно изучить его модель:
contentStream.beginText();contentStream.newLineAtOffset(x, y);contentStream.showText(text);contentStream.endText();
Если затем тот же отчёт понадобится в PowerPoint, начинается новая жизнь:
XSLFTextBox textBox = slide.createTextBox();textBox.setAnchor(rectangle);textBox.setText(text);
Теперь у нас другие классы, другие единицы измерения, DrawingML, отношения внутри OPC-контейнера, особенности PowerPoint и ещё несколько вечеров, когда документация смотрит на тебя так, будто проблема очевидна только тебе.
Для SVG будет третий API.
Для изображения — четвёртый.
Для какого-нибудь будущего формата — пятый.
Но прикладная задача не изменилась.
Мы всё ещё хотим сказать:
Здесь заголовок. Ниже таблица. Рядом диаграмма. Если блок не помещается — перенеси его. Не оставляй название раздела внизу страницы в гордом одиночестве.
Низкоуровневые библиотеки хорошо умеют создавать конкретный формат. Они не обязаны решать за нас всю задачу document layout.
Проблема начинается, когда мы смешиваем эти два уровня.
Если каждый backend сам считает layout, у вас несколько движков
Допустим, я хочу поддерживать PDF и PowerPoint.
Можно сделать два отдельных renderer’а:
Document model ├── PDF renderer: измеряет, переносит, размещает └── PPTX renderer: измеряет, переносит, размещает
На первый взгляд всё нормально.
Через некоторое время PDF и PowerPoint начинают немного расходиться.
В одном формате текст перенёсся на следующую строку, в другом — нет.
В одном таблица поместилась на страницу, в другом переехала.
В PDF заголовок остался вместе с первым абзацем, а в PowerPoint оказался на одном слайде, пока его содержимое уже живёт на следующем.
Можно исправлять каждый случай отдельно.
Потом ещё один.
Потом добавить условие для конкретного шрифта.
Потом сделать PptxTableLayoutFixFinal2.
И в какой-то момент обнаружить, что у тебя не два backend’а.
У тебя два document engine, которые случайно используют похожий публичный API.
Мне хотелось избежать именно этого.
Один язык описания документа
Пользователь GraphCompose работает не с PDF-командами и не с PowerPoint shapes.
Он описывает сам документ:
document.pageFlow(page -> page .addSection("Quarterly Results", section -> section .keepWithNext() .addParagraph(p -> p .text("Revenue increased by 18%.") .textStyle(bodyStyle)) .addTable(table -> buildResultsTable(table))));
В этом коде нет:
-
PDPageContentStream; -
XSLFSlide; -
DrawingML;
-
PDF operators;
-
координат текста;
-
расчёта высоты строки;
-
ручного переноса таблицы.
Здесь есть структура документа и намерение автора.
Это и есть язык, с которым должен взаимодействовать разработчик.
Он описывает:
sectionparagraphtableimagechartshapespacingalignmentpagination rules
А движок уже решает, как всё это физически разместить.
Координаты никуда не исчезли
Иногда декларативные API описывают так, будто координаты больше не нужны.
Это, конечно, неправда.
Координаты нужны всегда.
PDF использует координаты.
PowerPoint использует координаты.
SVG использует координаты.
Изображение в конце концов тоже состоит из пикселей, которые почему-то не хотят самостоятельно догадаться, где должен находиться заголовок.
Разница только в том, кто занимается вычислениями.
В GraphCompose это делает движок:
Document DSL ↓Semantic document tree ↓Text measurement ↓Layout ↓Pagination ↓Resolved coordinates ↓LayoutGraph
После этого этапа документ уже размещён.
Движок знает:
-
размеры страниц;
-
положение каждого элемента;
-
ширину и высоту блоков;
-
text baselines;
-
границы таблиц;
-
результаты переносов;
-
порядок отрисовки;
-
clipping regions;
-
продолжения элементов после page break.
Backend не должен задавать вопрос:
Куда поставить этот абзац?
Он получает ответ:
Вот абзац. Вот его координаты. Вот размеры. Вот измеренные строки. Теперь вырази это средствами своего формата.
Именно здесь находится архитектурная граница.
Backend — это переводчик, а не второй автор документа
Упрощённо fixed-layout backend делает примерно следующее:
Resolved paragraph → native text objectResolved image → native image objectResolved line → native lineResolved path → native vector pathResolved link → native hyperlinkResolved table → fills, borders and text fragments
Он переводит промежуточное представление GraphCompose в примитивы конкретного формата.
PDF backend использует PDFBox.
PPTX backend использует Apache POI.
Будущий SVG backend может создавать XML-элементы.
Какой-нибудь backend для ещё не существующего формата PCP4X будет использовать библиотеку, которую пока не успели написать и потом переписать три раза.
Но язык документа остаётся тем же.
Движок остаётся тем же.
Layout остаётся тем же.
Меняется только последний перевод.
PowerPoint здесь не главная функция
Недавно в GraphCompose появился PPTX backend.
Но интересен он для меня не потому, что в README теперь можно поставить ещё один значок.
PowerPoint стал первой серьёзной проверкой идеи.
До него я мог говорить:
Архитектура не зависит от PDF. Теоретически можно подключить другой fixed-layout backend.
Слово «теоретически» очень удобное. Оно позволяет архитектуре быть прекрасной до первого столкновения с реальностью.
Теперь один DocumentSession может создать два результата:
Path pdf = Path.of("report.pdf");Path pptx = Path.of("report.pptx");try (DocumentSession document = GraphCompose.document(pdf) .pageSize(DocumentPageSize.SLIDE_16_9) .create()) { composeReport(document); document.buildPdf(); document.buildPptx(pptx);}
composeReport(document) вызывается один раз.
Движок один раз измеряет содержимое.
Один раз рассчитывает layout.
Один раз принимает решения о размещении.
Затем PDF backend и PPTX backend получают один resolved layout graph.
PowerPoint в данном случае не новая модель документа.
Это другая цель компиляции.
GraphCompose DSL ↓Semantic document model ↓Measurement + layout + pagination ↓Resolved LayoutGraph ├── PDF backend → PDFBox → PDF └── PPTX backend → Apache POI → editable PPTX
Всё выше LayoutGraph не знает, будет результат PDF, PowerPoint или каким-то другим fixed-layout форматом. Backend получает уже рассчитанную геометрию и переводит её в примитивы своей библиотеки.
Результат должен оставаться нативным
Самый простой способ поддержать PowerPoint — создать изображение страницы и растянуть его на весь слайд.
Внешне всё совпадает.
Задача закрыта.
Можно идти пить кофе и делать вид, что .pptx автоматически означает презентацию.
Но пользователь получает картинку:
-
текст нельзя нормально выделить;
-
текст нельзя скопировать;
-
опечатку нельзя быстро исправить;
-
блок нельзя передвинуть;
-
цвет панели нельзя поменять;
-
ссылки внутри содержимого теряются;
-
масштабирование зависит от разрешения растра.
Я хотел другой результат.
Если в GraphCompose есть paragraph, PPTX backend создаёт text frame.
Если есть panel, создаётся shape.
Если есть line, создаётся native line.
Если есть path или polygon, создаётся векторная геометрия.
Если есть hyperlink, он должен стать hyperlink’ом PowerPoint.
То есть на выходе должна быть не фотография документа, а сам документ в терминах целевого формата.
DocumentSession, один рассчитанный LayoutGraph, два нативных результата. Сверху — один и тот же документ в PDF и PPTX. Снизу видно, что текст в PowerPoint остаётся обычным редактируемым text frame, а не частью изображения. Открыть PDF · Скачать PPTX · Посмотреть исходный код. Это не означает, что каждый элемент обязательно будет на сто процентов редактируемым во всех форматах.
Но нативный результат является целью по умолчанию, а не приятным бонусом.
Что происходит с диаграммами
Диаграммы — хороший пример того, зачем нужен общий уровень примитивов.
Можно было создать отдельный PdfChartRenderer.
Потом отдельный PptxChartRenderer.
Затем отдельный renderer для каждого следующего backend’а.
Через некоторое время половина проекта занималась бы тем, что по-разному рисует одинаковые столбцы.
В GraphCompose диаграмма во время композиции превращается в обычные примитивы движка:
-
прямоугольники;
-
линии;
-
paths;
-
текст;
-
fills;
-
gradients;
-
groups.
Backend уже не видит специальный объект «bar chart».
Он видит рассчитанную векторную геометрию.
Если данные выглядят так:
Product A — 20%Product B — 35%Product C — 45%
движок рассчитывает реальные пропорции столбцов.
На момент генерации визуализация соответствует данным.
В PowerPoint пользователь получает редактируемые shapes и текст, а не PNG.
Да, это не native PowerPoint chart с embedded workbook.
Если вручную заменить подпись 45% на 70%, прямоугольник не вырастет от силы человеческой надежды.
Но исходный render корректен, остаётся векторным, масштабируется, позволяет копировать текст, менять цвета и двигать элементы.
Для меня это хороший базовый контракт:
Сначала обеспечить одинаковое и нативное представление через общие примитивы. Затем отдельный backend при необходимости может добавить более глубокую интеграцию с возможностями конкретного формата.
В будущем PPTX backend теоретически может поддержать режим NATIVE_CHART_WHEN_SUPPORTED.
Но общий vector fallback всё равно останется полезным, потому что работает одинаково для разных target formats.
Новый формат не должен требовать нового языка
Представим вымышленный формат PCP4X.
Пусть он:
-
использует fixed layout;
-
поддерживает текст, векторную графику и изображения;
-
имеет хорошую Java-библиотеку;
-
весит меньше PDF;
-
лучше работает с accessibility;
-
умеет показывать голограмму квартального отчёта прямо над столом.
Последний пункт необязателен, но инвесторам понравится.
Если модель PCP4X основана на размещении объектов в координатном пространстве, GraphCompose не должен превращаться в новый проект.
Не нужно заново создавать:
-
DSL;
-
document tree;
-
text wrapping;
-
таблицы;
-
pagination;
-
keepTogether; -
keepWithNext; -
layout dependencies;
-
chart layout;
-
систему тем.
Нужен backend:
graph-compose-render-pcp4x
Он получает готовый LayoutGraph и переводит элементы в PCP4X primitives.
После подключения dependency пользователь продолжает писать тот же document code.
Вот главный принцип:
Новый формат должен требовать нового backend’а, а не нового языка создания документов для каждого разработчика.
Зачем здесь open source
Я не смогу лично реализовать все возможные форматы.
Даже если очень захотеть, сутки почему-то продолжают содержать только 24 часа, а спецификации форматов обычно пишут люди, которые явно рассчитывают жить вечно.
Но архитектура не требует, чтобы все backend’ы находились в основном репозитории или разрабатывались одним человеком.
Представим, что разработчик работает в компании, где нужен специализированный формат.
Он уже знает низкоуровневую библиотеку этого формата.
У него есть два варианта.
Вариант первый: сделать одноразовое решение
Он пишет внутри продукта собственную генерацию:
-
считает размеры;
-
размещает текст;
-
переносит строки;
-
строит таблицы;
-
исправляет pagination;
-
добавляет несколько десятков специальных случаев;
-
через год боится открывать этот пакет.
Задача конкретной компании решена.
Никто больше этот код не увидит.
Вариант второй: реализовать GraphCompose backend
Разработчик использует готовый layout engine и сосредотачивается на переводе:
LayoutGraph text fragment → target text primitiveLayoutGraph path → target vector pathLayoutGraph image → target image objectLayoutGraph link → target hyperlink
Потом он может оформить это как независимый модуль:
<dependency> <groupId>com.example</groupId> <artifactId>graph-compose-render-special-format</artifactId> <version>1.0.0</version></dependency>
Его рабочая задача решена.
Но одновременно появляется новый backend, которым могут воспользоваться другие разработчики.
Один человек изучил конкретный формат и написал переводчик.
Остальные продолжают использовать знакомый GraphCompose DSL.
Именно так библиотека потенциально может стать экосистемой.
Не потому, что в основном репозитории будет сто модулей.
А потому, что core предоставляет стабильный язык и стабильную точку подключения.
Для этого одного интерфейса недостаточно
Конечно, нельзя просто создать:
interface Backend { void render(Object something);}
и объявить, что экосистема готова.
Чтобы сторонние backend’ы были реалистичными, проекту нужен нормальный Backend Development Kit.
Стабильный SPI
Автор backend’а должен понимать:
-
какие данные он получает;
-
какие координатные системы используются;
-
какие элементы уже измерены;
-
какие гарантии даёт
LayoutGraph; -
какие API являются стабильными;
-
какие могут измениться.
Capability model
Форматы отличаются.
Например:
Text frames — nativeVector paths — nativeInternal links — nativeClipping — raster fallbackFont embedding — partialNative charts — unsupported
Backend должен честно сообщать, что он умеет.
Не все статусы partial одинаковы.
Иногда элемент остаётся нативным, но немного упрощается стиль.
Иногда приходится растрировать только один region.
Иногда формат физически не может сохранить нужную семантику.
Это лучше показать заранее, чем обнаружить перед демонстрацией клиенту.
Conformance tests
Нужен общий набор тестов:
paragraph placementmultiline texttablesrepeated headersimagespathstransformslinksclippingfontsdeterministic output
Автор backend’а запускает suite и видит, где его реализация соответствует контракту, а где пока нет.
Без этого каждый новый модуль будет по-своему интерпретировать одну и ту же модель.
И мы снова незаметно придём к нескольким движкам.
Только теперь распределённым по Maven Central.
Не каждый формат является fixed-layout
Здесь важно не делать вид, что одна модель идеально описывает вообще всё.
PDF, PowerPoint slides, SVG и изображения хорошо соответствуют fixed-layout подходу.
Для них естественна схема:
LayoutGraph → native target primitives
Но DOCX и HTML работают иначе.
Они сами участвуют в layout.
Word может по-другому перенести текст в зависимости от версии, установленных шрифтов, настроек printer metrics и, вероятно, положения Луны.
Поэтому для flow-based форматов нужен другой тип backend’а:
Semantic document tree → semantic target structure
Такой backend переводит:
-
section в section;
-
paragraph в paragraph;
-
heading в heading;
-
table в table.
А финальное размещение выполняет сам target format.
Это означает, что в GraphCompose могут существовать два семейства backend’ов:
Fixed-layout backend
Получает рассчитанные координаты и воспроизводит geometry.
Semantic backend
Получает структуру документа и передаёт управление layout целевому формату.
DOCX export уже ближе ко второму варианту.
И это нормально.
Архитектура не обязана притворяться, что Word является PDF с более странным API.
Где идея уже сработала
Пока рано говорить, что GraphCompose стал полноценной multi-format платформой.
Сейчас это всё ещё молодой open-source проект, в котором большую часть работы делаю я.
Но важная проверка уже состоялась.
PDF backend использует PDFBox.
PPTX backend использует Apache POI.
У форматов разные объектные модели, разные возможности и разные ограничения.
При этом они получают один resolved layout graph.
PPTX не потребовал второго DSL.
Не потребовал второго table layout engine.
Не потребовал отдельного chart layout.
Не потребовал повторной реализации pagination.
Это не означает, что backend получился простым.
Перевести готовую геометрию в другой формат всё равно сложно.
Шрифты, clipping, links, transforms и DrawingML быстро объясняют разработчику, что слово «просто» было использовано преждевременно.
Но сложность осталась в правильном месте.
Backend решает, как выразить готовый layout.
Он не решает заново, каким должен быть документ.
Самый показательный баг
При реализации PPTX backend я столкнулся с расхождением в ширине текста.
Layout engine измерял один font face.
PowerPoint получал настройки, из-за которых viewer фактически рисовал более широкое начертание.
Разница составляла около нескольких процентов.
Звучит не страшно.
Пока короткий текст не перестаёт помещаться в рассчитанный frame, а два слова в заголовке не начинают выглядеть как одно длинное немецкое существительное.
При независимых layout implementations это можно было бы назвать особенностью форматов:
Ну, PDF выглядит так, а PowerPoint немного иначе.
Но при общем LayoutGraph это однозначный backend bug.
Геометрия уже была рассчитана.
Если backend рисует элемент шире выделенного пространства, он нарушает контракт.
Именно такие ошибки показывают ценность общего промежуточного представления.
Различия перестают быть «ну так получилось».
Они становятся проверяемыми дефектами.
Что я хочу построить в итоге
Цель GraphCompose не в том, чтобы лично поддержать каждый документный формат, существующий на Земле и в enterprise-системах, которые никто не решается выключить с 2008 года.
Цель в другом.
GraphCompose должен предоставить:
-
Единый язык описания документов.
-
Независимый layout engine.
-
Стабильный
LayoutGraph. -
Контракт для fixed-layout и semantic backend’ов.
-
Capability model.
-
Набор тестов для авторов новых модулей.
-
Несколько качественных reference implementations.
И всё это — в экосистеме Java.
Тогда разработчик сможет изучить один способ создания документов.
А поддержка конкретного формата станет подключаемой деталью.
GraphCompose DSL ↓Document model ↓Layout engine ↓LayoutGraph ↓PDF / PPTX / SVG / Image / PCP4X / что появится завтра
Не все backend’ы будут иметь одинаковую функциональность.
Не все форматы смогут сохранить каждую возможность.
Где-то будет native output.
Где-то approximation.
Где-то локальный raster fallback.
Где-то честное unsupported.
Но пользователю не придётся заново учиться описывать документ и повторно решать layout-задачи для каждого нового формата.
Вместо заключения
Когда я начинал GraphCompose, я просто хотел создать CV в Java.
Потом оказалось, что вручную двигать координаты не очень весело.
Потом появился layout engine.
Потом semantic DSL.
Потом отдельный core.
Потом второй fixed-layout backend, который наконец проверил, что PDF действительно является только одним из способов вывода.
Сейчас для меня основная идея проекта звучит так:
Разработчик описывает документ один раз. Движок рассчитывает layout. Backend переводит результат в нативные объекты целевого формата.
PowerPoint здесь не финальная цель.
Он первое доказательство того, что граница работает.
Если завтра появится новый формат и кто-то напишет для него backend, существующие документы не должны узнавать об этом.
Они просто получат ещё один способ быть отрисованными.
И, возможно, именно так должен выглядеть document engine: не как библиотека для одного файла, а как язык, который не обязан каждый раз начинать разговор с координат.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/1065080/