From dfa4dbea61b08e3a930db5c5e19e3f9efb17e7f6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 11:59:26 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(pptx):=20table=20rows=20=E2=80=94=20fi?= =?UTF-8?q?lls,=20borders,=20and=20cell=20text=20at=20graph=20coordinates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table rows render 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 groups contiguous same-table row fragments and paints every cell fill before any border or text, the PDF backend's two-pass discipline, so fills always land beneath strokes. Border lines reuse the PDF handler's half-stroke-width extension on internal horizontal joins (outer table edges stay clamped), a fragment that starts a page forces its TOP border, and row-spanning cells grow downward through their merged rows via the negative yOffset. Cell text mirrors the PDF handler line for line: control-sanitized lines measured with PdfFont.getTextWidth, anchored nine ways inside the padded cell box, drawn through sanitizeForRender into per-line frames seated at baseline − viewer ascent. The frame discipline (wrap/autofit off, zero insets, LEFT align, kern=0) moved into a shared PptxTextFrames helper that the paragraph handler now delegates to — no behavior change there. Tests (32 in render-pptx): PptxTableGeometryTest re-derives fills, border segments, and text frames from the captured LayoutGraph across a page break with a repeated header, pins the resolved rowSpan (negative yOffset), the fills-before-ink paint order per slide, and the page-start TOP-border branch at the handler level with a synthetic fragment; PptxTableParityDemoTest writes the reviewable PDF/PPTX pair plus per-page PNGs under target/visual-tests/pptx-parity/tables/. Full 10-module gate green. --- CHANGELOG.md | 20 +- .../architecture/backend-capability-matrix.md | 2 +- .../fixed/pptx/PptxFixedLayoutBackend.java | 63 +++- .../PptxParagraphFragmentRenderHandler.java | 114 +----- .../PptxTableRowFragmentRenderHandler.java | 257 +++++++++++++ .../fixed/pptx/handlers/PptxTextFrames.java | 138 +++++++ .../fixed/pptx/PptxTableGeometryTest.java | 348 ++++++++++++++++++ .../fixed/pptx/PptxTableParityDemoTest.java | 115 ++++++ 8 files changed, 949 insertions(+), 108 deletions(-) create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableGeometryTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableParityDemoTest.java 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..c2f631fb --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxTableParityDemoTest.java @@ -0,0 +1,115 @@ +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 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; + + @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() + .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().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() + .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; + } +} From a4cbe3726f38d0d9e42cf100d178f516f2613661 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 12:02:30 +0100 Subject: [PATCH 2/2] test(pptx): table parity demo uses an embedded Lato family With the standard-14 default the two formats legitimately differ in letterforms (PDF draws Helvetica, PPTX declares metric-compatible Arial); a registered binary family embeds the same TTF into both outputs, so the demo now proves glyph-identical rendering in real viewers as well as geometry identity. --- .../backend/fixed/pptx/PptxTableParityDemoTest.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index c2f631fb..d61ea242 100644 --- 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 @@ -8,6 +8,8 @@ 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; @@ -32,6 +34,12 @@ 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 { @@ -60,13 +68,14 @@ void writesTheTableParityDemoPair() throws Exception { 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().size(10).build()) + .textStyle(DocumentTextStyle.builder().fontName(DEMO_FONT).size(10).build()) .build(); session.pageFlow(page -> page.module("engines", module -> module.table(table -> { table.autoColumns(3) @@ -75,6 +84,7 @@ private static DocumentSession composeDemo() { .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)