Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte[]>`) |
| 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`) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +65,12 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer {

private final Map<Class<?>, 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.
*/
Expand All @@ -73,6 +87,7 @@ private PptxFixedLayoutBackend(Builder builder) {
merged.put(handler.payloadType(), handler);
}
this.handlers = Map.copyOf(merged);
this.rasterSlidesDpi = builder.rasterSlidesDpi;
}

/**
Expand Down Expand Up @@ -181,6 +196,10 @@ public void writeSections(List<SectionUnit> 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())) {
Expand All @@ -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<BufferedImage> 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<Object> handler = handlerFor(payload);
Expand Down Expand Up @@ -253,10 +303,38 @@ private PptxFragmentRenderHandler<Object> handlerFor(Object payload) {
public static final class Builder {

private final List<PptxFragmentRenderHandler<?>> 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.
*
* <p>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.</p>
*
* @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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}

/**
Expand Down Expand Up @@ -247,7 +242,6 @@ private static boolean embedFontFamily(XMLSlideShow show,
return true;
}


void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkOptions) {
bookmarkRecords.add(new BookmarkRecord(
bookmarkOptions.title(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BufferedImage> 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<XSLFShape> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading