diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf2f8ca..94494fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,11 @@ follow semantic versioning; release dates are ISO 8601. deterministic PDF default; `MissingBackendContractTest` (core, backend-free classpath) pins the missing-format diagnostics; `DocumentPageSizeTest` pins the slide presets. +- `PptxFixedLayoutBackend.Builder.rasterSlides(int dpi)` — raster-slide mode: + every page renders through the PDF backend and lands as one full-slide + picture, a pixel-exact copy of the PDF/PNG output for decks that must look + identical everywhere. Slides are not editable as text; the default stays + the editable vector mode. - PPTX text tests re-read line, absolute-span and inline-graphic anchors through POI; cover real wrapping, mixed font sizes, vertical seating, custom fonts, rich styles, links, chips, SVG fallback and exact inline radii; and lock the diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 52e52663..5578ba38 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -89,7 +89,8 @@ honour an option ignores it (documented contract). | Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| | Render to bytes / stream / file (`FixedLayoutRenderer`) | ✅ `PdfFixedLayoutBackend` | ✅ `PptxFixedLayoutBackend` | ✅ `DocxSemanticBackend` (`SemanticBackend`) | -| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` (in-memory, no round-trip) | 🚧 (decision pending: POI `XSLFSlide.draw` quality) | ❌ | +| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | 🚧 (decision pending: POI `XSLFSlide.draw` quality) | ❌ | +| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | ❌ | | Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | 🚧 | ❌ | | Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | 🚧 (pinned OPC timestamps + zip normalization) | ❌ | | ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) | diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java index c8dc8ba5..4390df24 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java @@ -7,17 +7,25 @@ import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxLineFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxParagraphFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxShapeFragmentRenderHandler; +import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; import com.demcha.compose.document.exceptions.UnsupportedNodeCapabilityException; import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.PdfSemanticFragmentPayload; +import org.apache.poi.sl.usermodel.PictureData; import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFPictureData; +import org.apache.poi.xslf.usermodel.XSLFPictureShape; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.imageio.ImageIO; + +import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.util.ArrayList; @@ -57,6 +65,12 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer { private final Map, PptxFragmentRenderHandler> handlers; + /** + * Raster-slide resolution in DPI; {@code 0} keeps the default editable + * vector mode. + */ + private final int rasterSlidesDpi; + /** * Creates a backend with the default handler set. */ @@ -73,6 +87,7 @@ private PptxFixedLayoutBackend(Builder builder) { merged.put(handler.payloadType(), handler); } this.handlers = Map.copyOf(merged); + this.rasterSlidesDpi = builder.rasterSlidesDpi; } /** @@ -181,6 +196,10 @@ public void writeSections(List sections, OutputStream output) { private void renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { + if (rasterSlidesDpi > 0) { + renderRasterSlides(graph, context, output); + return; + } try (XMLSlideShow show = new XMLSlideShow(); PdfMeasurementResources measurement = PdfMeasurementResources.open(context.customFontFamilies())) { @@ -196,6 +215,37 @@ private void renderToOutput(LayoutGraph graph, } } + /** + * Raster-slide mode: every page is rendered through the PDF backend at the + * configured DPI and placed as one full-slide picture — a pixel-exact copy + * of the PDF/PNG output in .pptx form. Slides are not editable as text. + */ + private void renderRasterSlides(LayoutGraph graph, + FixedLayoutRenderContext context, + OutputStream output) throws Exception { + List pages = new PdfFixedLayoutBackend() + .renderToImages(graph, context, rasterSlidesDpi, false, -1); + try (XMLSlideShow show = new XMLSlideShow()) { + PptxRenderSession session = new PptxRenderSession( + show, graph.canvas().width(), graph.canvas().height(), graph.totalPages()); + for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { + XSLFPictureData data = show.addPicture( + encodePng(pages.get(pageIndex)), PictureData.PictureType.PNG); + XSLFPictureShape picture = session.slide(pageIndex).createPicture(data); + picture.setAnchor(new Rectangle2D.Double( + 0, 0, graph.canvas().width(), graph.canvas().height())); + } + show.write(output); + } + } + + private static byte[] encodePng(BufferedImage image) throws IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", buffer); + return buffer.toByteArray(); + } + } + private void renderFragment(PlacedFragment fragment, PptxRenderEnvironment environment) throws Exception { Object payload = fragment.payload(); PptxFragmentRenderHandler handler = handlerFor(payload); @@ -253,10 +303,38 @@ private PptxFragmentRenderHandler handlerFor(Object payload) { public static final class Builder { private final List> customHandlers = new ArrayList<>(); + private int rasterSlidesDpi; private Builder() { } + /** + * Switches the backend to raster-slide mode: every page renders + * through the PDF backend at the given DPI and lands as one + * full-slide picture — a pixel-exact copy of the PDF/PNG output for + * decks that must look identical everywhere. The trade-off is that + * slide content is a picture: text is not selectable or editable and + * files grow with resolution. Leave unset for the default editable + * vector mode. + * + *

Navigation is baked into the pixels: hyperlinks, bookmarks, and + * custom fragment handlers do not apply in raster mode. Every page is + * held in memory during the render, so memory grows with page count + * and the square of the DPI; the resolution is capped at 600 DPI.

+ * + * @param dpi raster resolution in dots per inch (72 = native size, max 600) + * @return this builder + * @throws IllegalArgumentException if {@code dpi} is not in [1, 600] + */ + public Builder rasterSlides(int dpi) { + if (dpi <= 0 || dpi > 600) { + throw new IllegalArgumentException( + "Raster DPI must be between 1 and 600: " + dpi); + } + this.rasterSlidesDpi = dpi; + return this; + } + /** * Registers a custom fragment handler. A handler reporting the same * payload type as a built-in default replaces that default. diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java index 08a2bc08..2966ba23 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java @@ -186,13 +186,8 @@ public double viewerAscent(TextStyle style, double fallbackAscent) { if (font == null) { return fallbackAscent; } - double pdfAscentRatio = font.verticalMetrics(style).ascent() / style.size(); - PptxViewerMetrics.ViewerFontMetrics customMetrics = viewerFontMetrics.get(style.fontName()); - double viewerAscentRatio = customMetrics == null - ? PptxFontMapping.viewerAscentRatio(style.fontName(), pdfAscentRatio) - : customMetrics.ascent(PptxFontMapping.isBold(style), - PptxFontMapping.isItalic(style)); - return viewerAscentRatio * style.size(); + return PptxViewerMetrics.ascentPoints( + style, font, viewerFontMetrics.get(style.fontName())); } /** @@ -247,7 +242,6 @@ private static boolean embedFontFamily(XMLSlideShow show, return true; } - void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkOptions) { bookmarkRecords.add(new BookmarkRecord( bookmarkOptions.title(), diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxViewerMetrics.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxViewerMetrics.java index bc865456..36e8568d 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxViewerMetrics.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxViewerMetrics.java @@ -42,6 +42,24 @@ record ViewerFontMetrics(double regularAscent, } } + /** + * Resolves the viewer ascent for one style, in points — the single seat + * formula shared by the render environment and the geometry test mirror, + * so the two cannot drift line-by-line. + */ + static double ascentPoints(com.demcha.compose.engine.components.content.text.TextStyle style, + com.demcha.compose.engine.render.pdf.PdfFont font, + ViewerFontMetrics custom) { + double pdfRatio = font.verticalMetrics(style).ascent() / style.size(); + double ratio = custom == null + ? com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping + .viewerAscentRatio(style.fontName(), pdfRatio) + : custom.ascent( + com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping.isBold(style), + com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping.isItalic(style)); + return ratio * style.size(); + } + static ViewerFontMetrics load(FontFamilyDefinition family, FontFamilyDefinition.FontSourceSet sources) { return new ViewerFontMetrics( diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java index 33670559..3b211283 100644 --- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java @@ -421,15 +421,12 @@ private static double expectedViewerAscent( if (font == null || style.size() <= 0) { return fallbackAscent; } - double pdfRatio = font.verticalMetrics(style).ascent() / style.size(); - PptxViewerMetrics.ViewerFontMetrics custom = embedded.get(style.fontName()); - double ratio = custom == null - ? com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping - .viewerAscentRatio(style.fontName(), pdfRatio) - : custom.ascent( - com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping.isBold(style), - com.demcha.compose.document.backend.fixed.pptx.handlers.PptxFontMapping.isItalic(style)); - return ratio * style.size(); + // Same resolver as production (PptxViewerMetrics.ascentPoints); the + // anti-tautology check is the hand-computed constant pinned in + // PptxFontMappingTest. Note the mirror loads metrics for every family + // with sources while production gates on a successful embed — test + // fonts must be embeddable. + return PptxViewerMetrics.ascentPoints(style, font, embedded.get(style.fontName())); } /** Mirrors the handler's shared-frame gate: mixed plain-run sizes force per-span frames. */ diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxRasterSlidesTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxRasterSlidesTest.java new file mode 100644 index 00000000..bb660a5f --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxRasterSlidesTest.java @@ -0,0 +1,95 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.DocumentDsl; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFPictureShape; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Raster-slide mode is a pixel-exact copy of the PDF raster: one full-slide + * picture per page whose bytes equal the independently encoded + * {@code session.toImages} output at the same DPI. The default (no builder + * flag) stays the editable vector mode. + */ +class PptxRasterSlidesTest { + + private static final int DPI = 144; + + @Test + void everyPageBecomesOneFullSlidePictureWithThePdfRasterBytes() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render( + PptxFixedLayoutBackend.builder().rasterSlides(DPI).build()); + List pdfPages = session.toImages(DPI); + + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides()).hasSameSizeAs(pdfPages); + for (int pageIndex = 0; pageIndex < pdfPages.size(); pageIndex++) { + XSLFSlide slide = show.getSlides().get(pageIndex); + List shapes = slide.getShapes(); + assertThat(shapes).hasSize(1); + XSLFPictureShape picture = (XSLFPictureShape) shapes.get(0); + + Rectangle2D anchor = picture.getAnchor(); + assertThat(anchor.getX()).isZero(); + assertThat(anchor.getY()).isZero(); + assertThat(anchor.getWidth()).isEqualTo(400.0); + assertThat(anchor.getHeight()).isEqualTo(300.0); + + try (ByteArrayOutputStream expected = new ByteArrayOutputStream()) { + ImageIO.write(pdfPages.get(pageIndex), "png", expected); + assertThat(picture.getPictureData().getData()) + .as("slide %d picture must be the PDF raster", pageIndex) + .isEqualTo(expected.toByteArray()); + } + } + } + } + } + + @Test + void theDefaultModeStaysVector() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides().get(0).getShapes()) + .noneMatch(XSLFPictureShape.class::isInstance); + } + } + } + + private static DocumentSession composeTwoPages() { + DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(20)) + .create(); + DocumentDsl dsl = session.dsl(); + session.add(dsl.shape().name("Card").size(160, 60) + .fillColor(DocumentColor.ROYAL_BLUE) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 2)) + .cornerRadius(8) + .build()); + session.add(new PageBreakNode("Break", DocumentInsets.zero())); + session.add(dsl.shape().name("Second").size(120, 40) + .fillColor(DocumentColor.ORANGE) + .build()); + return session; + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMappingTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMappingTest.java index 84592947..d732e5aa 100644 --- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMappingTest.java +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxFontMappingTest.java @@ -11,6 +11,21 @@ class PptxFontMappingTest { + /** + * Anti-tautology pin: production and the geometry-test mirror share one + * ascent resolver, so this hand-computed constant is what actually proves + * the seat. Arial's hhea ascent is 1854/2048 em — at 22pt the viewer + * ascent must be 22 × 1854 / 2048 = 19.916015625 pt exactly. + */ + @Test + void helveticaViewerAscentRatioIsArialsHheaAscent() { + assertThat(PptxFontMapping.viewerAscentRatio(FontName.HELVETICA, 0.5) * 22.0) + .isEqualTo(19.916015625); + assertThat(PptxFontMapping.viewerAscentRatio(FontName.of("SomeCustom"), 0.5)) + .as("unknown families fall back to the supplied ratio") + .isEqualTo(0.5); + } + @Test void mapsStandardFamiliesToMetricCompatibleViewerFonts() { assertThat(PptxFontMapping.familyFor(FontName.HELVETICA_BOLD)).isEqualTo("Arial");