diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 134fee62b..0723214e5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -148,7 +148,8 @@ jobs:
cache: maven
- name: Build and run tests
- # Reactor slice: the engine, the render-docx / render-pptx semantic backends,
+ # Reactor slice: the engine, the render-docx semantic backend, the
+ # render-pptx fixed-layout backend,
# the graph-compose wrapper + graph-compose-bundle (the published empty-jar
# aggregates — covered here so a packaging break is caught on every push, not
# only via the examples job), the extracted graph-compose-testing module, the
@@ -243,6 +244,14 @@ jobs:
# dependency) before the examples build resolves it.
run: ./mvnw -B -ntp -f testing/pom.xml -DskipTests install
+ - name: Install graph-compose-render-pptx (consumed by the examples module)
+ # The Engine Deck PPTX example renders the flagship deck through the
+ # fixed-layout PPTX backend, which is not on Central. Install it after
+ # the wrapper (its core + render-pdf compile dependencies) AND the
+ # testing module — -DskipTests skips execution but Maven still
+ # resolves render-pptx's test-scope graph-compose-testing dependency.
+ run: ./mvnw -B -ntp -f render-pptx/pom.xml -DskipTests install
+
- name: Install graph-compose-templates (consumed by the examples module)
# The flagship + template examples render the built-in CV/invoice/proposal
# presets, which now ship in graph-compose-templates.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8e9c6c4f..a69833b40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -129,6 +129,18 @@ follow semantic versioning; release dates are ISO 8601.
- `docs/architecture/backend-capability-matrix.md` — a per-capability matrix
of what each render backend supports and which class implements it,
maintained as part of every capability-changing PR.
+- The README's backend descriptions now present `graph-compose-render-pptx`
+ as the fixed-layout, geometry-identical PowerPoint backend (one page per
+ editable slide) instead of a semantic exporter, with its own
+ quick-start dependency row; the Engine Deck flagship example additionally
+ renders as a .pptx deck (`EngineDeckPptxExample`, part of
+ `GenerateAllExamples`).
+
+### Build
+
+- The examples CI job installs `graph-compose-render-pptx` before generating,
+ so the Engine Deck PPTX example resolves the backend the same way the DOCX
+ example resolves its own.
### Tests
@@ -158,6 +170,13 @@ follow semantic versioning; release dates are ISO 8601.
default-output-file and empty-session failure modes; the backend-free
missing-format diagnostics were already pinned by the core's
`MissingBackendContractTest`.
+- The qa module now guards the PPTX backend's boundaries at bytecode level:
+ POI stays a PPTX-backend implementation detail (the engine, the PDF backend,
+ and the templates must not touch it), the render backends stay
+ template-agnostic, and the PDF backend must not depend on the PPTX artifact
+ (the reverse dependency is by design). The `ShapeOutline` exhaustiveness
+ guard now renders every permit through both fixed-layout backends, so a new
+ outline kind cannot silently miss a PPTX dispatch branch.
## v2.0.0 — 2026-07-13
diff --git a/README.md b/README.md
index 8b1eef53e..62bb16dcf 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ Sits between **iText** (low-level page primitives) and **JasperReports** (XML-te
The **module-first** release — the single jar becomes a family of per-concern artifacts, so you install exactly what you render.
- **Lean engine** — `graph-compose-core` is the document model, DSL, themes, and deterministic layout with **no PDFBox, POI, or template code** on its dependency tree. Backends plug in through a `ServiceLoader` seam; a core-only classpath asked to render throws `MissingBackendException` naming the artifact to add.
-- **Opt-in render backends** — `graph-compose-render-pdf` (PDFBox 3.0, full DSL coverage), `graph-compose-render-docx` and `graph-compose-render-pptx` (Apache POI, semantic export).
+- **Opt-in render backends** — `graph-compose-render-pdf` (PDFBox 3.0, full DSL coverage), `graph-compose-render-pptx` (Apache POI, geometry-identical PowerPoint decks from the same resolved layout — one page per editable slide; clipped regions land as pixel-exact pictures), `graph-compose-render-docx` (Apache POI, semantic export).
- **`graph-compose` stays a drop-in** — the 1.x coordinate is now a thin wrapper over core + the PDF backend, so existing callers upgrade with **no code and no dependency change**.
- **Templates are their own artifact** — the CV / cover-letter / invoice / proposal preset families moved to `graph-compose-templates` (imports unchanged). This is the [one dependency-level break](./docs/migration/v2.0.0-modules.md#the-one-break-templates) of the split.
- **`graph-compose-bundle`** — one batteries-included coordinate: PDF stack + templates + fonts + colour emoji.
@@ -91,7 +91,8 @@ dependencies { implementation("io.github.demchaav:graph-compose:2.0.0") }
> | **Batteries-included** (PDF + templates + fonts + emoji) | `graph-compose-bundle` |
> | **Lean core, bring your own backend** | `graph-compose-core` |
> | **Built-in CV / cover-letter / invoice / proposal templates** | add `graph-compose-templates` |
-> | **DOCX / PPTX export** | add `graph-compose-render-docx` / `graph-compose-render-pptx` |
+> | **PowerPoint deck, geometry-identical to the PDF** | add `graph-compose-render-pptx` |
+> | **DOCX export (semantic)** | add `graph-compose-render-docx` |
>
> Every 2.0 coordinate shares the `graph-compose` version (the fonts and emoji companions
> keep their own lines). A bare `graph-compose-core` renders nothing until a backend is on
diff --git a/examples/README.md b/examples/README.md
index 1aadb5f4b..5451f171c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -61,7 +61,7 @@ are with the canonical DSL, then jump to its detailed section below.
| [Cover Letter](#cover-letter) | One-page `BusinessTheme.modern()` cover letter with section presets | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
| [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) |
| **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) |
-| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) |
+| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx()` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) |
### 🧱 Core DSL
diff --git a/examples/pom.xml b/examples/pom.xml
index 3dea9e92f..26c76243d 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -76,6 +76,14 @@
These complement the intra-core {@code ModuleBoundaryArchTest} in the engine * module. In particular the templates → engine boundary went unguarded when @@ -24,8 +25,8 @@ class CrossModuleBoundaryArchTest { // Every module's production classes on the qa classpath (engine + render-pdf + - // templates + testing). DO_NOT_INCLUDE_TESTS drops qa's own test classes, which - // legitimately depend on all of them. + // render-pptx + templates + testing). DO_NOT_INCLUDE_TESTS drops qa's own test + // classes, which legitimately depend on all of them. private static final JavaClasses MODULE_CLASSES = new ClassFileImporter() .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) .importPackages("com.demcha.compose"); @@ -35,6 +36,10 @@ class CrossModuleBoundaryArchTest { "com.demcha.compose.document.backend.fixed.pdf..", "com.demcha.compose.engine.render.pdf.." }; + private static final String[] PPTX_BACKEND = { + "com.demcha.compose.document.backend.fixed.pptx..", + "com.demcha.compose.document.backend.semantic.pptx.." + }; @Test void importedTheTemplatesAndEngineModuleClasses() { @@ -45,6 +50,9 @@ void importedTheTemplatesAndEngineModuleClasses() { .anyMatch(c -> c.getPackageName().startsWith("com.demcha.compose.document.templates"))) .as("the templates classes must be on the qa classpath so the boundary rules have subjects") .isTrue(); + assertThat(MODULE_CLASSES.contain("com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend")) + .as("the PPTX backend classes must be on the qa classpath so the POI rules have subjects") + .isTrue(); } @Test @@ -63,21 +71,58 @@ void templatesDoNotDependOnTheEngine() { void templatesStayBackendNeutral() { ArchRule rule = noClasses() .that().resideInAPackage(TEMPLATES) - .should().dependOnClassesThat().resideInAPackage("org.apache.pdfbox..") - .because("templates are pure authoring code over the canonical DSL; PDFBox lives " - + "behind the graph-compose-render-pdf backend seam, not in a preset"); + .should().dependOnClassesThat() + .resideInAnyPackage("org.apache.pdfbox..", "org.apache.poi..") + .because("templates are pure authoring code over the canonical DSL; PDFBox and POI " + + "live behind the render-backend seams, not in a preset"); rule.check(MODULE_CLASSES); } @Test - void thePdfBackendDoesNotDependOnTemplates() { + void theRenderBackendsDoNotDependOnTemplates() { ArchRule rule = noClasses() - .that().resideInAnyPackage(PDF_BACKEND) + .that().resideInAnyPackage(concat(PDF_BACKEND, PPTX_BACKEND)) .should().dependOnClassesThat().resideInAPackage(TEMPLATES) - .because("the render backend is template-agnostic — it renders the resolved layout " - + "graph and knows nothing about the built-in presets"); + .because("the render backends are template-agnostic — they render the resolved " + + "layout graph and know nothing about the built-in presets"); + + rule.check(MODULE_CLASSES); + } + + @Test + void onlyRenderBackendsDependOnPoi() { + // The DOCX semantic backend also legitimately uses POI; it is not on + // the qa classpath today, but the exclusion keeps this rule correct + // the day render-docx joins for its own boundary guards. + ArchRule rule = noClasses() + .that().resideOutsideOfPackages(concat(PPTX_BACKEND, + new String[]{"com.demcha.compose.document.backend.semantic.docx.."})) + .should().dependOnClassesThat().resideInAPackage("org.apache.poi..") + .because("POI is a render-backend implementation detail — the engine, the PDF " + + "backend, and the templates must stay free of it so the core artifact " + + "never drags the POI dependency in"); rule.check(MODULE_CLASSES); } + + @Test + void thePdfBackendDoesNotDependOnThePptxBackend() { + // The reverse dependency is by design: the PPTX backend reuses the PDF + // measurement stack and raster sub-renders for geometry identity. + ArchRule rule = noClasses() + .that().resideInAnyPackage(PDF_BACKEND) + .should().dependOnClassesThat().resideInAnyPackage(PPTX_BACKEND) + .because("the PDF backend is the reference implementation and must remain " + + "deployable without the PPTX artifact"); + + rule.check(MODULE_CLASSES); + } + + private static String[] concat(String[] first, String[] second) { + String[] merged = new String[first.length + second.length]; + System.arraycopy(first, 0, merged, 0, first.length); + System.arraycopy(second, 0, merged, first.length, second.length); + return merged; + } } diff --git a/qa/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java b/qa/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java index cfe01d81f..e3bd7f37f 100644 --- a/qa/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java +++ b/qa/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java @@ -24,12 +24,19 @@ * Exhaustiveness guard: every {@link ShapeOutline} permit must render through * all three outline-consuming surfaces — the shape-container outline * fill/stroke, the shape-container clip path, and the inline-shape run — - * without throwing. Each surface dispatches on the outline kind with an - * {@code instanceof} chain that ends in an {@code IllegalStateException}; this - * test reflects over {@code getPermittedSubclasses()} so the next permit added - * to the sealed type cannot silently miss a render branch (the lesson from the + * without throwing, on both fixed-layout backends. Each PDF surface + * dispatches on the outline kind with an {@code instanceof} chain that ends in + * an {@code IllegalStateException}; this test reflects over + * {@code getPermittedSubclasses()} so the next permit added to the sealed type + * cannot silently miss a render branch (the lesson from the * {@code ShapeOutline.Path} clipper, where the inline-shape switch was missed). * + *
On PPTX the discriminating chains are the fill-geometry resolution in + * core (shared with PDF) and {@code PptxInlineGeometry.drawOutline}'s own + * dispatch with its terminal throw; the clip surface renders through the PDF + * raster fallback, so its PPTX assertion re-proves the PDF chain rather than a + * PPTX-native one.
+ * *The fill surface is isolated with {@link ClipPolicy#OVERFLOW_VISIBLE}: the * clip test exercises {@code ShapeContainerDefinition}'s fill-geometry chain * transitively (the outline fragment is emitted whether or not the container @@ -82,6 +89,10 @@ void everyOutlineFillsAContainerWithoutThrowing() throws Exception { assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)) .as("fill render of " + outline.getClass().getSimpleName()) .isEqualTo("%PDF-"); + byte[] pptx = session.toPptxBytes(); + assertThat(new String(pptx, 0, 2, StandardCharsets.US_ASCII)) + .as("PPTX fill render of " + outline.getClass().getSimpleName()) + .isEqualTo("PK"); } } } @@ -104,6 +115,10 @@ void everyOutlineClipsInAContainerWithoutThrowing() throws Exception { assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)) .as("clip render of " + outline.getClass().getSimpleName()) .isEqualTo("%PDF-"); + byte[] pptx = session.toPptxBytes(); + assertThat(new String(pptx, 0, 2, StandardCharsets.US_ASCII)) + .as("PPTX clip render of " + outline.getClass().getSimpleName()) + .isEqualTo("PK"); } } } @@ -124,6 +139,10 @@ void everyOutlineRendersAsAnInlineShapeWithoutThrowing() throws Exception { assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)) .as("inline render of " + outline.getClass().getSimpleName()) .isEqualTo("%PDF-"); + byte[] pptx = session.toPptxBytes(); + assertThat(new String(pptx, 0, 2, StandardCharsets.US_ASCII)) + .as("PPTX inline render of " + outline.getClass().getSimpleName()) + .isEqualTo("PK"); } } } diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java index 752997ba2..416499355 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java @@ -42,6 +42,13 @@ static void drawOutline(XSLFShapeContainer surface, drawPath(surface, polygonPath(polygon.points(), box), fill, stroke); } else if (outline instanceof ShapeOutline.Path path) { drawPath(surface, path(path.segments(), box), fill, stroke); + } else { + // Exhaustiveness backstop: silently rendering nothing would let a + // new ShapeOutline permit ship without a PPTX branch — the qa + // coverage guard relies on this throw to catch it. + throw new IllegalStateException( + "No PPTX inline-geometry branch for outline kind " + + outline.getClass().getName() + "."); } }