diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94494fec..a89d6cfc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 5578ba38..b9abe0d3 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -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` | 🚧 | ❌ |
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 4390df24..a9ffc882 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,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;
@@ -48,7 +50,7 @@
* built-in defaults per payload type via {@link Builder#addHandler}.
*
* 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
@@ -104,7 +106,8 @@ private static List> defaultHandlers() {
new PptxShapeFragmentRenderHandler(),
new PptxLineFragmentRenderHandler(),
new PptxEllipseFragmentRenderHandler(),
- new PptxParagraphFragmentRenderHandler());
+ new PptxParagraphFragmentRenderHandler(),
+ new PptxTableRowFragmentRenderHandler());
}
@Override
@@ -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);
}
}
@@ -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 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 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 handler = handlerFor(payload);
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
index 1fdf946c..9031451d 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
@@ -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;
@@ -32,8 +26,6 @@
public final class PptxParagraphFragmentRenderHandler
implements PptxFragmentRenderHandler {
- private static final double FRAME_EPSILON = 0.1;
-
/** Creates the paragraph renderer. */
public PptxParagraphFragmentRenderHandler() {
}
@@ -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);
@@ -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);
@@ -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,
@@ -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,
@@ -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,
@@ -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,
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java
new file mode 100644
index 00000000..e1baab80
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java
@@ -0,0 +1,257 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
+import com.demcha.compose.engine.components.content.shape.Side;
+import com.demcha.compose.engine.components.content.table.TableResolvedCell;
+import com.demcha.compose.engine.components.layout.Anchor;
+import com.demcha.compose.engine.components.style.Padding;
+import com.demcha.compose.engine.render.pdf.PdfFont;
+import com.demcha.compose.engine.text.TextControlSanitizer;
+import com.demcha.compose.font.FontLibrary;
+import org.apache.poi.sl.usermodel.ShapeType;
+import org.apache.poi.xslf.usermodel.XSLFAutoShape;
+import org.apache.poi.xslf.usermodel.XSLFConnectorShape;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+
+import java.awt.Color;
+import java.awt.geom.Rectangle2D;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Renders atomic table-row fragments as positioned rectangles, edge lines,
+ * and text frames — never native PowerPoint tables, which re-lay-out their
+ * content and cannot hold the engine's exact row geometry.
+ *
+ * The backend batches {@link #renderFills} for all contiguous row
+ * fragments of a table before any {@link #renderBordersAndText} call, exactly
+ * like the PDF backend, so fills land beneath every border stroke. Border
+ * lines reuse the PDF handler's half-stroke-width extension on internal
+ * horizontal joins — without it, adjacent cells' perpendicular borders leave
+ * a corner notch after rasterization; outer table edges stay clamped to the
+ * cell box. Spanning cells arrive with a negative {@code yOffset} that grows
+ * the rectangle downward through the merged rows.
+ *
+ * @since 2.1.0
+ */
+public final class PptxTableRowFragmentRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ private static final double EPS = 1e-6;
+
+ /**
+ * Creates the table-row fragment renderer.
+ */
+ public PptxTableRowFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return TableRowFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ TableRowFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ renderFills(fragment, payload, environment);
+ renderBordersAndText(fragment, payload, environment);
+ }
+
+ /**
+ * Paints the cell backgrounds of one row fragment. The backend calls this
+ * for every row of a contiguous table group before any borders or text.
+ *
+ * @param fragment placed row fragment to render
+ * @param payload table-row payload carrying the resolved cells
+ * @param environment active PPTX render environment
+ */
+ public void renderFills(PlacedFragment fragment,
+ TableRowFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ XSLFSlide slide = environment.slide(fragment.pageIndex());
+ for (TableResolvedCell cell : payload.cells()) {
+ Color fill = cell.style().fillColor();
+ if (fill == null || cell.width() <= 0 || cell.height() <= 0) {
+ continue;
+ }
+ double cellX = fragment.x() + cell.x();
+ double cellY = fragment.y() + cell.yOffset();
+ XSLFAutoShape rectangle = slide.createAutoShape();
+ rectangle.setShapeType(ShapeType.RECT);
+ rectangle.setAnchor(new Rectangle2D.Double(
+ cellX,
+ PptxCoordinates.topY(environment.canvasHeight(), cellY, cell.height()),
+ cell.width(),
+ cell.height()));
+ rectangle.setFillColor(fill);
+ rectangle.setLineColor(null);
+ PptxTextFrames.setShapeName(rectangle, "GraphCompose Table Cell Fill");
+ }
+ }
+
+ /**
+ * Paints the cell borders and text of one row fragment, after every fill
+ * of the table group has landed.
+ *
+ * @param fragment placed row fragment to render
+ * @param payload table-row payload carrying the resolved cells
+ * @param environment active PPTX render environment
+ */
+ public void renderBordersAndText(PlacedFragment fragment,
+ TableRowFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ XSLFSlide slide = environment.slide(fragment.pageIndex());
+ FontLibrary fonts = environment.fonts();
+ for (TableResolvedCell cell : payload.cells()) {
+ double cellX = fragment.x() + cell.x();
+ // yOffset is 0 for single-row cells and negative for spanning
+ // cells, shifting the bottom edge downward through the merged
+ // rows — same semantics as the PDF handler.
+ double cellY = fragment.y() + cell.yOffset();
+ renderCellBorders(slide, cell, cellX, cellY,
+ fragment.width(), payload.startsPageFragment(), environment);
+ renderCellText(slide, fonts, cell, cellX, cellY, environment);
+ }
+ }
+
+ private static void renderCellBorders(XSLFSlide slide,
+ TableResolvedCell cell,
+ double cellX,
+ double cellY,
+ double rowWidth,
+ boolean startsPageFragment,
+ PptxRenderEnvironment environment) {
+ if (cell.style().stroke() == null || cell.style().stroke().width() <= 0) {
+ return;
+ }
+ Set sides = effectiveBorderSides(cell, startsPageFragment);
+ if (sides.isEmpty()) {
+ return;
+ }
+ double strokeWidth = cell.style().stroke().width();
+ Color strokeColor = cell.style().stroke().strokeColor().color();
+ double canvasHeight = environment.canvasHeight();
+ // Butt-capped edge lines meet exactly at cell corners, leaving a
+ // notch after rasterization; horizontal lines extend half a stroke
+ // width past internal joins to cover the corner pixel, while outer
+ // table edges stay clamped to the real cell box.
+ double cap = strokeWidth / 2.0;
+ double leftCap = cell.x() <= EPS ? 0.0 : cap;
+ double rightCap = Math.abs((cell.x() + cell.width()) - rowWidth) <= EPS ? 0.0 : cap;
+ double topY = PptxCoordinates.flipPoint(canvasHeight, cellY + cell.height());
+ double bottomY = PptxCoordinates.flipPoint(canvasHeight, cellY);
+ if (sides.contains(Side.TOP)) {
+ edgeLine(slide, strokeColor, strokeWidth,
+ cellX - leftCap, topY, cellX + cell.width() + rightCap, topY);
+ }
+ if (sides.contains(Side.BOTTOM)) {
+ edgeLine(slide, strokeColor, strokeWidth,
+ cellX - leftCap, bottomY, cellX + cell.width() + rightCap, bottomY);
+ }
+ if (sides.contains(Side.LEFT)) {
+ edgeLine(slide, strokeColor, strokeWidth, cellX, topY, cellX, bottomY);
+ }
+ if (sides.contains(Side.RIGHT)) {
+ edgeLine(slide, strokeColor, strokeWidth,
+ cellX + cell.width(), topY, cellX + cell.width(), bottomY);
+ }
+ }
+
+ private static void edgeLine(XSLFSlide slide,
+ Color color,
+ double width,
+ double x1, double y1, double x2, double y2) {
+ XSLFConnectorShape line = slide.createConnector();
+ line.setShapeType(ShapeType.LINE);
+ line.setAnchor(new Rectangle2D.Double(
+ Math.min(x1, x2),
+ Math.min(y1, y2),
+ Math.abs(x2 - x1),
+ Math.abs(y2 - y1)));
+ line.setLineColor(color);
+ line.setLineWidth(width);
+ PptxTextFrames.setShapeName(line, "GraphCompose Table Cell Border");
+ }
+
+ private static void renderCellText(XSLFSlide slide,
+ FontLibrary fonts,
+ TableResolvedCell cell,
+ double cellX,
+ double cellY,
+ PptxRenderEnvironment environment) {
+ PdfFont font = fonts.getFont(cell.style().textStyle().fontName(), PdfFont.class).orElseThrow();
+ double lineHeight = font.getLineHeight(cell.style().textStyle());
+ Padding padding = cell.style().padding() == null ? Padding.zero() : cell.style().padding();
+ Anchor anchor = cell.style().textAnchor() == null ? Anchor.centerLeft() : cell.style().textAnchor();
+
+ double innerX = cellX + padding.left();
+ double innerY = cellY + padding.bottom();
+ double innerWidth = Math.max(0, cell.width() - padding.horizontal());
+ double innerHeight = Math.max(0, cell.height() - padding.vertical());
+ List safeLines = sanitizeLines(cell.lines());
+ double blockHeight = lineHeight * Math.max(1, safeLines.size());
+
+ double blockY = switch (anchor.v()) {
+ case TOP -> innerY + innerHeight - blockHeight;
+ case MIDDLE -> innerY + (innerHeight - blockHeight) / 2.0;
+ case BOTTOM, DEFAULT -> innerY;
+ };
+
+ PdfFont.VerticalMetrics metrics = font.verticalMetrics(cell.style().textStyle());
+ double viewerAscent = environment.viewerAscent(cell.style().textStyle(), metrics.ascent());
+ for (int lineIndex = 0; lineIndex < safeLines.size(); lineIndex++) {
+ String line = safeLines.get(lineIndex);
+ if (line.isEmpty()) {
+ continue;
+ }
+ double lineWidth = font.getTextWidth(cell.style().textStyle(), line);
+ double lineX = switch (anchor.h()) {
+ case RIGHT -> innerX + innerWidth - lineWidth;
+ case CENTER -> innerX + (innerWidth - lineWidth) / 2.0;
+ case LEFT, DEFAULT -> innerX;
+ };
+ double lineBoxY = blockY + lineHeight * (safeLines.size() - lineIndex - 1);
+ double baselineY = lineBoxY + metrics.baselineOffsetFromBottom();
+ String safeText = font.sanitizeForRender(cell.style().textStyle(), line);
+ PptxTextFrames.singleRunBox(slide,
+ "GraphCompose Table Cell Text",
+ new Rectangle2D.Double(
+ lineX,
+ environment.canvasHeight() - baselineY - viewerAscent,
+ Math.max(PptxTextFrames.FRAME_EPSILON,
+ lineWidth + PptxTextFrames.FRAME_EPSILON),
+ Math.max(PptxTextFrames.FRAME_EPSILON, metrics.lineHeight())),
+ safeText,
+ cell.style().textStyle(),
+ environment);
+ }
+ }
+
+ private static Set effectiveBorderSides(TableResolvedCell cell, boolean startsPageFragment) {
+ EnumSet sides = cell.borderSides().isEmpty()
+ ? EnumSet.noneOf(Side.class)
+ : EnumSet.copyOf(cell.borderSides());
+ if (startsPageFragment) {
+ sides.add(Side.TOP);
+ }
+ return sides;
+ }
+
+ private static List sanitizeLines(List lines) {
+ if (lines == null || lines.isEmpty()) {
+ return List.of("");
+ }
+ List result = new ArrayList<>(lines.size());
+ for (String line : lines) {
+ result.add(TextControlSanitizer.replace(line, " ").trim());
+ }
+ return List.copyOf(result);
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
new file mode 100644
index 00000000..e2b5622f
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
@@ -0,0 +1,138 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.engine.components.content.text.TextStyle;
+import org.apache.poi.sl.usermodel.TextParagraph.TextAlign;
+import org.apache.poi.sl.usermodel.TextShape.TextAutofit;
+import org.apache.poi.sl.usermodel.VerticalAlignment;
+import org.apache.poi.xslf.usermodel.XSLFSimpleShape;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFTextBox;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTRegularTextRun;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTTextCharacterProperties;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTConnector;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;
+
+import java.awt.Color;
+import java.awt.geom.Rectangle2D;
+import java.util.List;
+
+/**
+ * Shared text-frame construction for the PPTX handlers: absolute,
+ * wrap-disabled, no-autofit, zero-inset boxes whose runs carry the engine's
+ * resolved styles with kerning off. One home for the frame discipline keeps
+ * the paragraph and table-cell paths from drifting apart.
+ */
+final class PptxTextFrames {
+
+ /** Widens frames a hair past the measured width so the last glyph never clips. */
+ static final double FRAME_EPSILON = 0.1;
+
+ private PptxTextFrames() {
+ }
+
+ /**
+ * Creates an absolute text frame that PowerPoint cannot re-lay-out: wrap
+ * off, autofit off, zero insets, top-anchored.
+ */
+ 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;
+ }
+
+ /**
+ * Returns the box's first paragraph cleared of placeholder runs and
+ * left-aligned — alignment is baked into the frame position, so the
+ * paragraph itself always lays out from the frame's left edge.
+ */
+ static XSLFTextParagraph preparedParagraph(XSLFTextBox box) {
+ XSLFTextParagraph paragraph = box.getTextParagraphs().get(0);
+ for (XSLFTextRun run : List.copyOf(paragraph.getTextRuns())) {
+ if (run.getRawText() == null || run.getRawText().isEmpty()) {
+ paragraph.removeTextRun(run);
+ }
+ }
+ paragraph.setTextAlign(TextAlign.LEFT);
+ return paragraph;
+ }
+
+ /**
+ * Creates a named single-run text frame — the common case for table cells
+ * and per-span paragraph frames.
+ */
+ static XSLFTextBox singleRunBox(XSLFSlide slide,
+ String shapeName,
+ Rectangle2D anchor,
+ String text,
+ TextStyle style,
+ PptxRenderEnvironment environment) {
+ XSLFTextBox box = newTextBox(slide, anchor);
+ setShapeName(box, shapeName);
+ addRun(preparedParagraph(box), text, style, environment);
+ return box;
+ }
+
+ /** Appends one styled run; empty text adds nothing. */
+ 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);
+ }
+
+ /**
+ * Applies family, size, color, and decoration flags, and turns kerning
+ * off — PowerPoint auto-kerns text above ~12pt while the engine measures
+ * plain unkerned advances.
+ */
+ 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);
+ }
+
+ /** Stamps the shape's non-visual name so tests and users can identify frames. */
+ static void setShapeName(XSLFSimpleShape shape, String name) {
+ if (shape.getXmlObject() instanceof CTShape ctShape) {
+ ctShape.getNvSpPr().getCNvPr().setName(name);
+ } else if (shape.getXmlObject() instanceof CTConnector ctConnector) {
+ ctConnector.getNvCxnSpPr().getCNvPr().setName(name);
+ }
+ }
+
+ /**
+ * An explicit {@code kern="0"} keeps the viewer on the measured advances;
+ * ECMA-376 defines the attribute as the minimum kerned size and the
+ * established convention (PowerPoint's own output) is that 0 disables
+ * kerning entirely.
+ */
+ private static void disableKerning(XSLFTextRun run) {
+ if (run.getXmlObject() instanceof CTRegularTextRun ctRun) {
+ CTTextCharacterProperties properties =
+ ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr();
+ properties.setKern(0);
+ }
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableGeometryTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableGeometryTest.java
new file mode 100644
index 00000000..0cf34b8c
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableGeometryTest.java
@@ -0,0 +1,348 @@
+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.backend.fixed.pdf.PdfMeasurementResources;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import com.demcha.compose.engine.components.content.shape.Side;
+import com.demcha.compose.engine.components.content.table.TableResolvedCell;
+import com.demcha.compose.engine.render.pdf.PdfFont;
+import com.demcha.compose.engine.text.TextControlSanitizer;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFTextBox;
+import org.junit.jupiter.api.Test;
+
+import java.awt.geom.Rectangle2D;
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Geometry identity for table rows: cell fills, border edge lines (including
+ * the half-stroke internal join extension and the page-start top border), and
+ * cell text frames re-derived from the captured {@link LayoutGraph} against
+ * the reopened .pptx — across a page break with a repeated header and a
+ * row-spanning cell, with every fill of the table landing before any border
+ * or text shape.
+ */
+class PptxTableGeometryTest {
+
+ private static final double EPS_PT = 0.5;
+ private static final double JOIN_EPS = 1e-6;
+
+ private static DocumentSession composeTable() {
+ DocumentSession session = GraphCompose.document()
+ .pageSize(420, 240)
+ .margin(DocumentInsets.of(20))
+ .create();
+ DocumentTableStyle plain = DocumentTableStyle.builder()
+ .stroke(DocumentStroke.of(DocumentColor.GRAY, 1))
+ .padding(3)
+ .build();
+ session.pageFlow(page -> page.module("data", module -> module.table(table -> {
+ table.autoColumns(3)
+ .headerRow("Alpha", "Beta", "Gamma")
+ .repeatHeader()
+ .headerStyle(DocumentTableStyle.builder()
+ .fillColor(DocumentColor.ROYAL_BLUE)
+ .textStyle(DocumentTextStyle.builder()
+ .color(DocumentColor.WHITE).size(10).build())
+ .stroke(DocumentStroke.of(DocumentColor.DARK_GRAY, 1))
+ .padding(3)
+ .build())
+ .defaultCellStyle(plain)
+ .zebra(DocumentColor.WHITE, DocumentColor.LIGHT_GRAY);
+ table.rowCells(
+ new DocumentTableCell(List.of("merged"), plain).rowSpan(2),
+ new DocumentTableCell(List.of("b1"), plain),
+ new DocumentTableCell(List.of("c1"), plain));
+ table.rowCells(
+ new DocumentTableCell(List.of("b2"), plain),
+ new DocumentTableCell(List.of("c2"), plain));
+ for (int row = 1; row <= 16; row++) {
+ table.row("row " + row, "value " + row, "note " + row);
+ }
+ })));
+ return session;
+ }
+
+ @Test
+ void tableRowsRenderFillsBordersAndTextAtTheGraphCoordinates() throws Exception {
+ try (DocumentSession session = composeTable()) {
+ LayoutGraph graph = session.render(new GraphCapturingBackend());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+
+ assertThat(graph.totalPages()).as("the table must cross a page break").isGreaterThan(1);
+ List rowPayloads = graph.fragments().stream()
+ .map(PlacedFragment::payload)
+ .filter(TableRowFragmentPayload.class::isInstance)
+ .map(TableRowFragmentPayload.class::cast)
+ .toList();
+ assertThat(rowPayloads)
+ .as("the row-spanning cell must resolve to a negative yOffset")
+ .anyMatch(payload -> payload.cells().stream().anyMatch(cell -> cell.yOffset() < 0));
+ assertThat(rowPayloads)
+ .as("a page-start row fragment must exist")
+ .anyMatch(TableRowFragmentPayload::startsPageFragment);
+
+ List expectedFills = new ArrayList<>();
+ List expectedBorders = new ArrayList<>();
+ List expectedTexts = new ArrayList<>();
+ double canvasHeight = graph.canvas().height();
+ try (PdfMeasurementResources measurement = PdfMeasurementResources.open(List.of())) {
+ for (PlacedFragment fragment : graph.fragments()) {
+ if (!(fragment.payload() instanceof TableRowFragmentPayload payload)) {
+ continue;
+ }
+ for (TableResolvedCell cell : payload.cells()) {
+ deriveCellExpectations(fragment, payload, cell, canvasHeight,
+ measurement, expectedFills, expectedBorders, expectedTexts);
+ }
+ }
+ }
+ assertThat(expectedFills).isNotEmpty();
+ assertThat(expectedBorders).isNotEmpty();
+ assertThat(expectedTexts).isNotEmpty();
+
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ assertThat(show.getSlides()).hasSize(graph.totalPages());
+ List fills = collect(show, "GraphCompose Table Cell Fill");
+ List borders = collect(show, "GraphCompose Table Cell Border");
+ List texts = collect(show, "GraphCompose Table Cell Text");
+
+ assertRects(fills, expectedFills, "fill");
+ assertRects(borders, expectedBorders, "border");
+
+ assertThat(texts).hasSameSizeAs(expectedTexts);
+ for (int i = 0; i < expectedTexts.size(); i++) {
+ ExpectedText want = expectedTexts.get(i);
+ ActualShape got = texts.get(i);
+ assertThat(got.pageIndex).as("text %d page", i).isEqualTo(want.pageIndex);
+ assertThat(((XSLFTextBox) got.shape).getText())
+ .as("text %d content", i).isEqualTo(want.text);
+ assertThat(got.shape.getAnchor().getX())
+ .as("text %d x", i)
+ .isCloseTo(want.x, org.assertj.core.data.Offset.offset(EPS_PT));
+ assertThat(got.shape.getAnchor().getY())
+ .as("text %d top", i)
+ .isCloseTo(want.top, org.assertj.core.data.Offset.offset(EPS_PT));
+ assertThat(got.shape.getAnchor().getWidth())
+ .as("text %d width", i).isPositive();
+ assertThat(got.shape.getAnchor().getHeight())
+ .as("text %d height", i).isPositive();
+ }
+
+ for (int page = 0; page < show.getSlides().size(); page++) {
+ int lastFill = -1;
+ int firstInk = Integer.MAX_VALUE;
+ List shapes = show.getSlides().get(page).getShapes();
+ for (int i = 0; i < shapes.size(); i++) {
+ String name = shapes.get(i).getShapeName();
+ if ("GraphCompose Table Cell Fill".equals(name)) {
+ lastFill = Math.max(lastFill, i);
+ } else if ("GraphCompose Table Cell Border".equals(name)
+ || "GraphCompose Table Cell Text".equals(name)) {
+ firstInk = Math.min(firstInk, i);
+ }
+ }
+ if (lastFill >= 0 && firstInk < Integer.MAX_VALUE) {
+ assertThat(lastFill)
+ .as("slide %d: every table fill must land before borders and text", page)
+ .isLessThan(firstInk);
+ }
+ }
+ }
+ }
+ }
+
+ private static void deriveCellExpectations(PlacedFragment fragment,
+ TableRowFragmentPayload payload,
+ TableResolvedCell cell,
+ double canvasHeight,
+ PdfMeasurementResources measurement,
+ List fills,
+ List borders,
+ List texts) {
+ double cellX = fragment.x() + cell.x();
+ double cellY = fragment.y() + cell.yOffset();
+ if (cell.style().fillColor() != null && cell.width() > 0 && cell.height() > 0) {
+ fills.add(new ExpectedRect(fragment.pageIndex(), new Rectangle2D.Double(
+ cellX, canvasHeight - cellY - cell.height(), cell.width(), cell.height())));
+ }
+
+ if (cell.style().stroke() != null && cell.style().stroke().width() > 0) {
+ EnumSet sides = cell.borderSides().isEmpty()
+ ? EnumSet.noneOf(Side.class)
+ : EnumSet.copyOf(cell.borderSides());
+ if (payload.startsPageFragment()) {
+ sides.add(Side.TOP);
+ }
+ double cap = cell.style().stroke().width() / 2.0;
+ double leftCap = cell.x() <= JOIN_EPS ? 0.0 : cap;
+ double rightCap =
+ Math.abs((cell.x() + cell.width()) - fragment.width()) <= JOIN_EPS ? 0.0 : cap;
+ double topY = canvasHeight - (cellY + cell.height());
+ double bottomY = canvasHeight - cellY;
+ if (sides.contains(Side.TOP)) {
+ borders.add(segment(fragment.pageIndex(),
+ cellX - leftCap, topY, cellX + cell.width() + rightCap, topY));
+ }
+ if (sides.contains(Side.BOTTOM)) {
+ borders.add(segment(fragment.pageIndex(),
+ cellX - leftCap, bottomY, cellX + cell.width() + rightCap, bottomY));
+ }
+ if (sides.contains(Side.LEFT)) {
+ borders.add(segment(fragment.pageIndex(), cellX, topY, cellX, bottomY));
+ }
+ if (sides.contains(Side.RIGHT)) {
+ borders.add(segment(fragment.pageIndex(),
+ cellX + cell.width(), topY, cellX + cell.width(), bottomY));
+ }
+ }
+
+ PdfFont font = measurement.fontLibrary()
+ .getFont(cell.style().textStyle().fontName(), PdfFont.class).orElseThrow();
+ double lineHeight = font.getLineHeight(cell.style().textStyle());
+ var padding = cell.style().padding() == null
+ ? com.demcha.compose.engine.components.style.Padding.zero()
+ : cell.style().padding();
+ var anchor = cell.style().textAnchor() == null
+ ? com.demcha.compose.engine.components.layout.Anchor.centerLeft()
+ : cell.style().textAnchor();
+ double innerX = cellX + padding.left();
+ double innerY = cellY + padding.bottom();
+ double innerWidth = Math.max(0, cell.width() - padding.horizontal());
+ double innerHeight = Math.max(0, cell.height() - padding.vertical());
+ List safeLines = new ArrayList<>();
+ for (String line : cell.lines().isEmpty() ? List.of("") : cell.lines()) {
+ safeLines.add(TextControlSanitizer.replace(line, " ").trim());
+ }
+ double blockHeight = lineHeight * Math.max(1, safeLines.size());
+ double blockY = switch (anchor.v()) {
+ case TOP -> innerY + innerHeight - blockHeight;
+ case MIDDLE -> innerY + (innerHeight - blockHeight) / 2.0;
+ case BOTTOM, DEFAULT -> innerY;
+ };
+ PdfFont.VerticalMetrics metrics = font.verticalMetrics(cell.style().textStyle());
+ double viewerAscent = PptxViewerMetrics.ascentPoints(cell.style().textStyle(), font, null);
+ for (int lineIndex = 0; lineIndex < safeLines.size(); lineIndex++) {
+ String line = safeLines.get(lineIndex);
+ if (line.isEmpty()) {
+ continue;
+ }
+ double lineWidth = font.getTextWidth(cell.style().textStyle(), line);
+ double lineX = switch (anchor.h()) {
+ case RIGHT -> innerX + innerWidth - lineWidth;
+ case CENTER -> innerX + (innerWidth - lineWidth) / 2.0;
+ case LEFT, DEFAULT -> innerX;
+ };
+ double lineBoxY = blockY + lineHeight * (safeLines.size() - lineIndex - 1);
+ double baselineY = lineBoxY + metrics.baselineOffsetFromBottom();
+ texts.add(new ExpectedText(fragment.pageIndex(),
+ font.sanitizeForRender(cell.style().textStyle(), line),
+ lineX,
+ canvasHeight - baselineY - viewerAscent));
+ }
+ }
+
+ private static ExpectedRect segment(int pageIndex, double x1, double y1, double x2, double y2) {
+ return new ExpectedRect(pageIndex, new Rectangle2D.Double(
+ Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)));
+ }
+
+ private static List collect(XMLSlideShow show, String shapeName) {
+ List shapes = new ArrayList<>();
+ for (int page = 0; page < show.getSlides().size(); page++) {
+ for (XSLFShape shape : show.getSlides().get(page).getShapes()) {
+ if (shapeName.equals(shape.getShapeName())) {
+ shapes.add(new ActualShape(page, shape));
+ }
+ }
+ }
+ return shapes;
+ }
+
+ private static void assertRects(List actual,
+ List expected,
+ String label) {
+ assertThat(actual).as(label + " count").hasSameSizeAs(expected);
+ for (int i = 0; i < expected.size(); i++) {
+ ExpectedRect want = expected.get(i);
+ ActualShape got = actual.get(i);
+ assertThat(got.pageIndex).as(label + " %d page", i).isEqualTo(want.pageIndex);
+ Rectangle2D anchor = got.shape.getAnchor();
+ assertThat(anchor.getX()).as(label + " %d x", i)
+ .isCloseTo(want.rect.x, org.assertj.core.data.Offset.offset(EPS_PT));
+ assertThat(anchor.getY()).as(label + " %d y", i)
+ .isCloseTo(want.rect.y, org.assertj.core.data.Offset.offset(EPS_PT));
+ assertThat(anchor.getWidth()).as(label + " %d width", i)
+ .isCloseTo(want.rect.width, org.assertj.core.data.Offset.offset(EPS_PT));
+ assertThat(anchor.getHeight()).as(label + " %d height", i)
+ .isCloseTo(want.rect.height, org.assertj.core.data.Offset.offset(EPS_PT));
+ }
+ }
+
+ /**
+ * Pins the page-start branch at the handler level, independent of what
+ * border sides the layout engine happens to emit: a continuation-row cell
+ * whose sides carry only BOTTOM must still receive a TOP border line when
+ * its fragment starts a page.
+ */
+ @Test
+ void pageStartFragmentForcesATopBorderLine() throws Exception {
+ try (org.apache.poi.xslf.usermodel.XMLSlideShow show =
+ new org.apache.poi.xslf.usermodel.XMLSlideShow();
+ PdfMeasurementResources measurement = PdfMeasurementResources.open(List.of())) {
+ PptxRenderSession session = new PptxRenderSession(show, 300, 200, 1);
+ PptxRenderEnvironment environment = new PptxRenderEnvironment(
+ show, session, 0, 200, measurement.fontLibrary(), List.of());
+ TableResolvedCell cell = new TableResolvedCell(
+ "cell", 0, 120, 30, 0, List.of(),
+ com.demcha.compose.engine.components.content.table.TableCellLayoutStyle.builder()
+ .stroke(new com.demcha.compose.engine.components.content.shape.Stroke(
+ java.awt.Color.BLACK, 1.0))
+ .textStyle(com.demcha.compose.engine.components.content.text.TextStyle.DEFAULT_STYLE)
+ .build(),
+ com.demcha.compose.engine.components.style.Padding.zero(),
+ Set.of(Side.BOTTOM));
+ TableRowFragmentPayload payload = new TableRowFragmentPayload(
+ List.of(cell), true, null, null);
+ PlacedFragment fragment = new PlacedFragment(
+ "root/table", 0, 0, 40, 100, 120, 30, null, null, payload);
+
+ new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxTableRowFragmentRenderHandler()
+ .renderBordersAndText(fragment, payload, environment);
+
+ List borders = show.getSlides().get(0).getShapes().stream()
+ .filter(shape -> "GraphCompose Table Cell Border".equals(shape.getShapeName()))
+ .toList();
+ assertThat(borders).as("BOTTOM plus the forced page-start TOP").hasSize(2);
+ assertThat(borders)
+ .anyMatch(shape -> Math.abs(shape.getAnchor().getY() - (200 - 130)) < EPS_PT)
+ .as("one border must sit on the cell's top edge (y = 200 − (100+30))");
+ }
+ }
+
+ private record ExpectedRect(int pageIndex, Rectangle2D.Double rect) {
+ }
+
+ private record ExpectedText(int pageIndex, String text, double x, double top) {
+ }
+
+ private record ActualShape(int pageIndex, XSLFShape shape) {
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableParityDemoTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableParityDemoTest.java
new file mode 100644
index 00000000..d61ea242
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableParityDemoTest.java
@@ -0,0 +1,125 @@
+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.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import com.demcha.compose.font.FontFamilyDefinition;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Produces the reviewable PDF/PPTX table parity pair: a styled multi-page
+ * table with a repeated header, zebra rows, and a row-spanning cell, plus
+ * per-page PNG previews from both formats at matching scale.
+ */
+class PptxTableParityDemoTest {
+
+ private static final double SCALE = 2.0;
+ private static final FontName DEMO_FONT = FontName.of("TableParityLato");
+ private static final FontFamilyDefinition DEMO_FONT_FAMILY = FontFamilyDefinition.classpath(
+ DEMO_FONT, "fonts/google/lato/Lato-Regular.ttf")
+ .boldResource("fonts/google/lato/Lato-Bold.ttf")
+ .wordFamily("Lato")
+ .build();
+
+ @Test
+ void writesTheTableParityDemoPair() throws Exception {
+ Path output = Path.of("target", "visual-tests", "pptx-parity", "tables");
+ Files.createDirectories(output);
+
+ try (DocumentSession session = composeDemo()) {
+ byte[] pdf = session.toPdfBytes();
+ Files.write(output.resolve("tables.pdf"), pdf);
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+ Files.write(output.resolve("tables.pptx"), pptx);
+
+ List pdfPages = session.toImages((int) Math.round(72 * SCALE));
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ assertThat(show.getSlides()).hasSameSizeAs(pdfPages);
+ for (int i = 0; i < pdfPages.size(); i++) {
+ ImageIO.write(pdfPages.get(i), "png",
+ output.resolve("tables-page-" + (i + 1) + ".pdf.png").toFile());
+ ImageIO.write(rasterize(show.getSlides().get(i),
+ show.getPageSize().width, show.getPageSize().height),
+ "png", output.resolve("tables-page-" + (i + 1) + ".pptx.png").toFile());
+ }
+ }
+ }
+ }
+
+ private static DocumentSession composeDemo() {
+ DocumentSession session = GraphCompose.document()
+ .registerFontFamily(DEMO_FONT_FAMILY)
+ .pageSize(460, 280)
+ .margin(DocumentInsets.of(24))
+ .create();
+ DocumentTableStyle body = DocumentTableStyle.builder()
+ .stroke(DocumentStroke.of(DocumentColor.rgb(203, 213, 225), 1))
+ .padding(4)
+ .textStyle(DocumentTextStyle.builder().fontName(DEMO_FONT).size(10).build())
+ .build();
+ session.pageFlow(page -> page.module("engines", module -> module.table(table -> {
+ table.autoColumns(3)
+ .headerRow("Engine", "Output", "Geometry")
+ .repeatHeader()
+ .headerStyle(DocumentTableStyle.builder()
+ .fillColor(DocumentColor.rgb(30, 64, 175))
+ .textStyle(DocumentTextStyle.builder()
+ .fontName(DEMO_FONT)
+ .color(DocumentColor.WHITE).size(10).build())
+ .stroke(DocumentStroke.of(DocumentColor.rgb(30, 64, 175), 1))
+ .padding(4)
+ .build())
+ .defaultCellStyle(body)
+ .zebra(DocumentColor.WHITE, DocumentColor.rgb(241, 245, 249));
+ table.rowCells(
+ new DocumentTableCell(List.of("Fixed layout"), body).rowSpan(2),
+ new DocumentTableCell(List.of("PDF"), body),
+ new DocumentTableCell(List.of("exact"), body));
+ table.rowCells(
+ new DocumentTableCell(List.of("PPTX"), body),
+ new DocumentTableCell(List.of("exact"), body));
+ for (int row = 1; row <= 14; row++) {
+ table.row("run " + row, "page " + (row % 3 + 1), "cell " + row);
+ }
+ })));
+ return session;
+ }
+
+ private static BufferedImage rasterize(XSLFSlide slide, int widthPt, int heightPt) {
+ BufferedImage image = new BufferedImage(
+ (int) Math.round(widthPt * SCALE),
+ (int) Math.round(heightPt * SCALE),
+ BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ graphics.setColor(Color.WHITE);
+ graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
+ graphics.scale(SCALE, SCALE);
+ slide.draw(graphics);
+ } finally {
+ graphics.dispose();
+ }
+ return image;
+ }
+}