From f9bd7778b045c1dcde05b5d4f54b37a6a11cb6cd Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 10:00:07 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(pptx):=20raster-slide=20mode=20?= =?UTF-8?q?=E2=80=94=20pixel-exact=20full-slide=20pictures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PptxFixedLayoutBackend.Builder.rasterSlides(int dpi) renders every page through the PDF backend at the given DPI and places it as one full-slide picture, so the deck is a pixel-exact copy of the PDF/PNG output — for decks that must look identical on any machine regardless of fonts. The trade-off is a picture per slide: text is not selectable or editable and files grow with resolution, so the editable vector mode stays the default and the provider path is untouched. PptxRasterSlidesTest pins the contract: one picture per page anchored to the full slide, picture bytes equal to the independently encoded session.toImages output at the same DPI, and the default mode still emitting vector shapes. --- CHANGELOG.md | 5 + .../architecture/backend-capability-matrix.md | 3 +- .../fixed/pptx/PptxFixedLayoutBackend.java | 67 +++++++++++++ .../fixed/pptx/PptxRasterSlidesTest.java | 95 +++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxRasterSlidesTest.java 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..6777f76e 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,6 +7,7 @@ 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; @@ -57,6 +58,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 +80,7 @@ private PptxFixedLayoutBackend(Builder builder) { merged.put(handler.payloadType(), handler); } this.handlers = Map.copyOf(merged); + this.rasterSlidesDpi = builder.rasterSlidesDpi; } /** @@ -181,6 +189,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 +208,39 @@ 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++) { + org.apache.poi.xslf.usermodel.XSLFPictureData data = show.addPicture( + encodePng(pages.get(pageIndex)), + org.apache.poi.sl.usermodel.PictureData.PictureType.PNG); + org.apache.poi.xslf.usermodel.XSLFPictureShape picture = + session.slide(pageIndex).createPicture(data); + picture.setAnchor(new java.awt.geom.Rectangle2D.Double( + 0, 0, graph.canvas().width(), graph.canvas().height())); + } + show.write(output); + } + } + + private static byte[] encodePng(java.awt.image.BufferedImage image) throws java.io.IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + javax.imageio.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 +298,32 @@ 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. + * + * @param dpi raster resolution in dots per inch (72 = native size) + * @return this builder + * @throws IllegalArgumentException if {@code dpi} is not positive + */ + public Builder rasterSlides(int dpi) { + if (dpi <= 0) { + throw new IllegalArgumentException("Raster DPI must be positive: " + 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/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; + } +} From 46ce7270c791ea9eefcd3ccb0e367a0bf5cd9f92 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 10:49:58 +0100 Subject: [PATCH 2/2] refactor(pptx): single ascent resolver, raster DPI cap, raster javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer-ascent formula lived twice — in the render environment and, line for line, in the geometry-test mirror — so the geometry gate only proved the two copies agreed. PptxViewerMetrics.ascentPoints is now the single resolver both sides call, and the anti-tautology check is a hand-computed constant pinned in PptxFontMappingTest (Arial hhea ascent: 22pt x 1854/2048 = 19.916015625pt exactly). Raster-slide mode caps the resolution at 600 DPI — every page is held in memory during the render, so an unbounded value died later as an opaque OutOfMemoryError inside PDFBox — and its javadoc now states the memory profile and that hyperlinks, bookmarks, and custom fragment handlers do not apply to raster slides. Fully-qualified names in the raster path replaced with imports. --- .../fixed/pptx/PptxFixedLayoutBackend.java | 37 ++++++++++++------- .../fixed/pptx/PptxRenderEnvironment.java | 10 +---- .../backend/fixed/pptx/PptxViewerMetrics.java | 18 +++++++++ .../fixed/pptx/PptxGeometryAssertions.java | 15 +++----- .../pptx/handlers/PptxFontMappingTest.java | 15 ++++++++ 5 files changed, 65 insertions(+), 30 deletions(-) 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 6777f76e..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 @@ -13,12 +13,19 @@ 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; @@ -216,27 +223,25 @@ private void renderToOutput(LayoutGraph graph, private void renderRasterSlides(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { - List pages = new PdfFixedLayoutBackend() + 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++) { - org.apache.poi.xslf.usermodel.XSLFPictureData data = show.addPicture( - encodePng(pages.get(pageIndex)), - org.apache.poi.sl.usermodel.PictureData.PictureType.PNG); - org.apache.poi.xslf.usermodel.XSLFPictureShape picture = - session.slide(pageIndex).createPicture(data); - picture.setAnchor(new java.awt.geom.Rectangle2D.Double( + 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(java.awt.image.BufferedImage image) throws java.io.IOException { + private static byte[] encodePng(BufferedImage image) throws IOException { try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - javax.imageio.ImageIO.write(image, "png", buffer); + ImageIO.write(image, "png", buffer); return buffer.toByteArray(); } } @@ -312,13 +317,19 @@ private Builder() { * files grow with resolution. Leave unset for the default editable * vector mode. * - * @param dpi raster resolution in dots per inch (72 = native size) + *

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 positive + * @throws IllegalArgumentException if {@code dpi} is not in [1, 600] */ public Builder rasterSlides(int dpi) { - if (dpi <= 0) { - throw new IllegalArgumentException("Raster DPI must be positive: " + dpi); + if (dpi <= 0 || dpi > 600) { + throw new IllegalArgumentException( + "Raster DPI must be between 1 and 600: " + dpi); } this.rasterSlidesDpi = dpi; return this; 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/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");