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
20 changes: 15 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ follow semantic versioning; release dates are ISO 8601.
plain family-name reference with a one-time warning, and the run baseline
compensates the PDF-vs-viewer ascent difference so text sits on the
measured baseline either way.
- The fixed PPTX backend renders table rows: cell fills, border edge lines
(including the half-stroke internal join extension and the page-start top
border), and anchored cell text as positioned frames — never native
PowerPoint tables, which re-lay-out their content. Contiguous table rows
paint all fills before any border or text, the PDF backend's two-pass
discipline, and row-spanning cells grow downward through their merged rows.
- `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.

### Fixed

Expand All @@ -65,15 +76,14 @@ 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
shared text-fidelity demo with a backend-neutral layout snapshot.
- PPTX table tests re-derive cell fills, border edge lines, and text frames
from the resolved layout graph across a page break with a repeated header
and a row-spanning cell, and pin the fills-before-ink paint order plus the
page-start top-border branch at the handler level.

## v2.0.0 — 2026-07-13

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | 🚧 | ❌ |
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | 🚧 | ✅ semantic images (`DocxSemanticBackend`) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | 🚧 | ❌ |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | 🚧 | ✅ semantic tables (`DocxSemanticBackend`) |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ✅ semantic tables (`DocxSemanticBackend`) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | 🚧 (rect crops for pictures; other content degrades with a one-time warning — PPTX has no graphics-state clipping) | ⚠️ inline fallback + one-time capability warning |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | 🚧 (group shape with `xfrm`) | ⚠️ inline fallback + one-time capability warning |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | 🚧 | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
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.pptx.handlers.PptxTableRowFragmentRenderHandler;
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 com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
import org.apache.poi.sl.usermodel.PictureData;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFPictureData;
Expand Down Expand Up @@ -48,7 +50,7 @@
* built-in defaults per payload type via {@link Builder#addHandler}.</p>
*
* <p>The backend currently renders paragraphs (including rich runs, chips and
* inline graphics) plus vector shape, line, and ellipse fragments;
* inline graphics), table rows, and vector shape, line, and ellipse fragments;
* the remaining payload types arrive incrementally — see
* {@code docs/architecture/backend-capability-matrix.md} for the live
* per-capability status. Multi-section rendering and render-to-images are not
Expand Down Expand Up @@ -104,7 +106,8 @@ private static List<PptxFragmentRenderHandler<?>> defaultHandlers() {
new PptxShapeFragmentRenderHandler(),
new PptxLineFragmentRenderHandler(),
new PptxEllipseFragmentRenderHandler(),
new PptxParagraphFragmentRenderHandler());
new PptxParagraphFragmentRenderHandler(),
new PptxTableRowFragmentRenderHandler());
}

@Override
Expand Down Expand Up @@ -208,9 +211,7 @@ private void renderToOutput(LayoutGraph graph,
PptxRenderEnvironment environment =
new PptxRenderEnvironment(show, session, 0, graph.canvas().height(),
measurement.fontLibrary(), context.customFontFamilies());
for (PlacedFragment fragment : graph.fragments()) {
renderFragment(fragment, environment);
}
renderGraph(graph, environment);
show.write(output);
}
}
Expand Down Expand Up @@ -246,6 +247,58 @@ private static byte[] encodePng(BufferedImage image) throws IOException {
}
}

/**
* Paints every fragment in graph order, grouping contiguous table-row
* fragments so every cell fill of a table lands beneath its borders and
* text — the PDF backend's two-pass discipline.
*/
private void renderGraph(LayoutGraph graph, PptxRenderEnvironment environment) throws Exception {
PptxFragmentRenderHandler<?> tableRowHandler =
handlers.get(TableRowFragmentPayload.class);
List<PlacedFragment> fragments = graph.fragments();
for (int index = 0; index < fragments.size(); index++) {
PlacedFragment fragment = fragments.get(index);
if (fragment.payload() instanceof TableRowFragmentPayload
&& tableRowHandler instanceof PptxTableRowFragmentRenderHandler tableHandler) {
index = renderTableRowGroup(fragments, index, tableHandler, environment);
continue;
}
renderFragment(fragment, environment);
}
}

/**
* Renders one contiguous run of same-table row fragments: all fills
* first, then all borders and text. Returns the last consumed index.
*/
private int renderTableRowGroup(List<PlacedFragment> fragments,
int startIndex,
PptxTableRowFragmentRenderHandler handler,
PptxRenderEnvironment environment) {
String tablePath = fragments.get(startIndex).path();
int endExclusive = startIndex;
while (endExclusive < fragments.size()
&& Objects.equals(fragments.get(endExclusive).path(), tablePath)
&& fragments.get(endExclusive).payload()
instanceof TableRowFragmentPayload) {
endExclusive++;
}
for (int index = startIndex; index < endExclusive; index++) {
PlacedFragment fragment = fragments.get(index);
handler.renderFills(fragment,
(TableRowFragmentPayload) fragment.payload(),
environment);
}
for (int index = startIndex; index < endExclusive; index++) {
PlacedFragment fragment = fragments.get(index);
TableRowFragmentPayload payload =
(TableRowFragmentPayload) fragment.payload();
handler.renderBordersAndText(fragment, payload, environment);
finishRenderedFragment(fragment, payload, environment);
}
return endExclusive - 1;
}

private void renderFragment(PlacedFragment fragment, PptxRenderEnvironment environment) throws Exception {
Object payload = fragment.payload();
PptxFragmentRenderHandler<Object> handler = handlerFor(payload);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,10 @@
import com.demcha.compose.document.node.TextVerticalAlign;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.InlineBackground;
import com.demcha.compose.engine.components.content.text.TextStyle;
import com.demcha.compose.engine.render.pdf.PdfFont;
import com.demcha.compose.font.FontLibrary;
import org.apache.poi.sl.usermodel.ShapeType;
import org.apache.poi.sl.usermodel.TextShape.TextAutofit;
import org.apache.poi.sl.usermodel.VerticalAlignment;
import org.apache.poi.xslf.usermodel.*;
import org.openxmlformats.schemas.drawingml.x2006.main.CTRegularTextRun;
import org.openxmlformats.schemas.drawingml.x2006.main.CTTextCharacterProperties;
import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;

import java.awt.Color;
import java.awt.geom.Rectangle2D;
Expand All @@ -32,8 +26,6 @@
public final class PptxParagraphFragmentRenderHandler
implements PptxFragmentRenderHandler<ParagraphFragmentPayload> {

private static final double FRAME_EPSILON = 0.1;

/** Creates the paragraph renderer. */
public PptxParagraphFragmentRenderHandler() {
}
Expand Down Expand Up @@ -90,14 +82,12 @@ private static void renderLine(int pageIndex,
if (!usesAbsoluteSpans) {
double top = environment.canvasHeight() - baselineY
- sharedBoxViewerAscent(line, environment);
XSLFTextBox lineBox = newTextBox(slide,
XSLFTextBox lineBox = PptxTextFrames.newTextBox(slide,
new Rectangle2D.Double(lineX, top,
Math.max(FRAME_EPSILON, line.width() + FRAME_EPSILON),
Math.max(FRAME_EPSILON, line.textLineHeight())));
setShapeName(lineBox, "GraphCompose Text Line");
paragraph = lineBox.getTextParagraphs().get(0);
removePlaceholderRuns(paragraph);
paragraph.setTextAlign(org.apache.poi.sl.usermodel.TextParagraph.TextAlign.LEFT);
Math.max(PptxTextFrames.FRAME_EPSILON, line.width() + PptxTextFrames.FRAME_EPSILON),
Math.max(PptxTextFrames.FRAME_EPSILON, line.textLineHeight())));
PptxTextFrames.setShapeName(lineBox, "GraphCompose Text Line");
paragraph = PptxTextFrames.preparedParagraph(lineBox);
paragraph.setLineSpacing(100.0);
paragraph.setSpaceBefore(0.0);
paragraph.setSpaceAfter(0.0);
Expand All @@ -112,7 +102,7 @@ private static void renderLine(int pageIndex,
renderTextSpan(slide, textSpan, text, cursorX, baselineY, fonts,
environment.canvasHeight(), environment);
} else {
addRun(paragraph, text, textSpan.textStyle(), environment);
PptxTextFrames.addRun(paragraph, text, textSpan.textStyle(), environment);
}
} else {
renderChip(slide, textSpan, text, cursorX, baselineY, line, fonts, environment);
Expand Down Expand Up @@ -186,58 +176,6 @@ private static com.demcha.compose.document.node.DocumentLinkTarget spanLinkTarge
return null;
}

private static XSLFTextBox newTextBox(XSLFSlide slide, Rectangle2D anchor) {
XSLFTextBox box = slide.createTextBox();
box.setAnchor(anchor);
box.setWordWrap(false);
box.setTextAutofit(TextAutofit.NONE);
box.setVerticalAlignment(VerticalAlignment.TOP);
box.setTopInset(0);
box.setRightInset(0);
box.setBottomInset(0);
box.setLeftInset(0);
return box;
}

private static void addRun(XSLFTextParagraph paragraph,
String text,
TextStyle style,
PptxRenderEnvironment environment) {
if (text.isEmpty()) {
return;
}
XSLFTextRun run = paragraph.addNewTextRun();
run.setText(text);
applyStyle(run, style, environment);
}

private static void applyStyle(XSLFTextRun run,
TextStyle style,
PptxRenderEnvironment environment) {
run.setFontFamily(environment.fontFamily(style.fontName()));
run.setFontSize(style.size());
run.setFontColor(style.color() == null ? Color.BLACK : style.color());
run.setBold(PptxFontMapping.isBold(style));
run.setItalic(PptxFontMapping.isItalic(style));
run.setUnderlined(PptxFontMapping.isUnderline(style));
run.setStrikethrough(PptxFontMapping.isStrikethrough(style));
disableKerning(run);
}

/**
* PowerPoint auto-kerns text above ~12pt, but the engine measures and the
* PDF backend draws plain unkerned advances — kerned glyphs would drift off
* the measured grid and change the visual rhythm of large text. An explicit
* {@code kern="0"} keeps the viewer on the measured advances.
*/
private static void disableKerning(XSLFTextRun run) {
if (run.getXmlObject() instanceof CTRegularTextRun ctRun) {
CTTextCharacterProperties properties =
ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr();
properties.setKern(0);
}
}

private static void renderTextSpan(XSLFSlide slide,
ParagraphTextSpan span,
String text,
Expand All @@ -249,14 +187,11 @@ private static void renderTextSpan(XSLFSlide slide,
PdfFont.VerticalMetrics metrics = verticalMetrics(fonts, span);
double top = canvasHeight - baselineY
- environment.viewerAscent(span.textStyle(), metrics.ascent());
XSLFTextBox textBox = newTextBox(slide, new Rectangle2D.Double(
cursorX, top, Math.max(FRAME_EPSILON, span.width() + FRAME_EPSILON),
Math.max(FRAME_EPSILON, metrics.lineHeight())));
setShapeName(textBox, "GraphCompose Inline Text Span");
XSLFTextParagraph paragraph = textBox.getTextParagraphs().get(0);
removePlaceholderRuns(paragraph);
paragraph.setTextAlign(org.apache.poi.sl.usermodel.TextParagraph.TextAlign.LEFT);
addRun(paragraph, text, span.textStyle(), environment);
XSLFTextBox textBox = PptxTextFrames.newTextBox(slide, new Rectangle2D.Double(
cursorX, top, Math.max(PptxTextFrames.FRAME_EPSILON, span.width() + PptxTextFrames.FRAME_EPSILON),
Math.max(PptxTextFrames.FRAME_EPSILON, metrics.lineHeight())));
PptxTextFrames.setShapeName(textBox, "GraphCompose Inline Text Span");
PptxTextFrames.addRun(PptxTextFrames.preparedParagraph(textBox), text, span.textStyle(), environment);
}

private static void renderChip(XSLFSlide slide,
Expand Down Expand Up @@ -289,15 +224,12 @@ private static void renderChip(XSLFSlide slide,
PdfFont.VerticalMetrics metrics = verticalMetrics(fonts, span);
double textTop = canvasHeight - baselineY
- environment.viewerAscent(span.textStyle(), metrics.ascent());
double textWidth = Math.max(FRAME_EPSILON, span.width() - padding.horizontal());
XSLFTextBox textBox = newTextBox(slide, new Rectangle2D.Double(
double textWidth = Math.max(PptxTextFrames.FRAME_EPSILON, span.width() - padding.horizontal());
XSLFTextBox textBox = PptxTextFrames.newTextBox(slide, new Rectangle2D.Double(
cursorX + padding.left(), textTop, textWidth,
Math.max(FRAME_EPSILON, metrics.lineHeight())));
setShapeName(textBox, "GraphCompose Inline Chip Text");
XSLFTextParagraph paragraph = textBox.getTextParagraphs().get(0);
removePlaceholderRuns(paragraph);
paragraph.setTextAlign(org.apache.poi.sl.usermodel.TextParagraph.TextAlign.LEFT);
addRun(paragraph, text, span.textStyle(), environment);
Math.max(PptxTextFrames.FRAME_EPSILON, metrics.lineHeight())));
PptxTextFrames.setShapeName(textBox, "GraphCompose Inline Chip Text");
PptxTextFrames.addRun(PptxTextFrames.preparedParagraph(textBox), text, span.textStyle(), environment);
}

private static void renderExternalLinkOverlay(XSLFSlide slide,
Expand All @@ -318,22 +250,10 @@ private static void renderExternalLinkOverlay(XSLFSlide slide,
// in slide-show mode. noFill shapes only hit-test their outline.
hotspot.setFillColor(new Color(0, 0, 0, 0));
hotspot.setLineColor(null);
setShapeName(hotspot, "GraphCompose Text Link Hotspot");
PptxTextFrames.setShapeName(hotspot, "GraphCompose Text Link Hotspot");
hotspot.createHyperlink().linkToUrl(external.options().uri());
}

private static void removePlaceholderRuns(XSLFTextParagraph paragraph) {
for (XSLFTextRun run : java.util.List.copyOf(paragraph.getTextRuns())) {
if (run.getRawText() == null || run.getRawText().isEmpty()) {
paragraph.removeTextRun(run);
}
}
}

private static void setShapeName(XSLFSimpleShape shape, String name) {
((CTShape) shape.getXmlObject()).getNvSpPr().getCNvPr().setName(name);
}

private static void renderImage(XSLFSlide slide,
ParagraphImageSpan span,
double cursorX,
Expand Down
Loading
Loading