From 3440510d87641f6c77a4602d364729f8c753e535 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 01:38:09 +0100 Subject: [PATCH 1/3] fix(docx): anchor a drawing that is all its table cell holds in that cell, so it moves with its row --- CHANGELOG.md | 16 + .../architecture/backend-capability-matrix.md | 4 +- docs/recipes/docx-export.md | 13 +- .../semantic/docx/DocxCellDrawings.java | 155 ++++++++++ .../semantic/docx/DocxDrawingAnchors.java | 65 +++- .../backend/semantic/docx/DocxDrawings.java | 37 ++- .../semantic/docx/DocxLayoutMetrics.java | 48 +++ .../semantic/docx/DocxSemanticBackend.java | 281 +++++++++++++++++- .../semantic/docx/DocxCellDrawingTest.java | 275 +++++++++++++++++ .../semantic/docx/DocxComposedCellTest.java | 2 + .../semantic/docx/DocxDrawingsTest.java | 6 +- 11 files changed, 870 insertions(+), 32 deletions(-) create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 558f33d7e..4bf0afade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **An icon alone in a table cell moves with its row in Word.** A drawing the page places in a + cell of its own — a band's icon beside its label — was drawn from the page's edges, and so + off its row wherever Word set the rows above it a little taller or shorter than the page: + `CobaltRota`'s band icons stood 4pt, 9pt and 14pt above their labels, the last out of its + strip. Such a drawing is now anchored in that cell's paragraph, held at the drawing's height + and placed from its top and the cell's text column, which is taken to start where the + drawing does. In a row of the flow that is a layer stack or a shape container that only + draws — shapes, a drawn picture, a badge's initials — with no margins, on one page, all of it + inside its box. In a table's composed cell, whose drawing belongs to the table, it is a layer + stack of shapes only: its drawing is the first of the table's waiting shapes inside that cell, + where the layout first placed it, when those are its shapes, of their kinds and sizes. A + drawing holding a line, a lone shape and a composed tile are drawn on the page as before. + Every shape is now painted in the order it was drawn, rather than the order it found a + paragraph to anchor it, so one waiting for its page's first paragraph is not painted over a + shape anchored in a cell after it. + - **A line pulled up into the line above it, with no space above to take the pull from, is no longer dropped in Word.** A paragraph's negative top edge comes out of the space owed above it, and where there is none — the two lines of a lockup in a table cell — it was dropped: diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 492a13e55..a4d14e7bd 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -69,8 +69,8 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture, followed by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | -| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws; in a composed cell, a layer stack of shapes, matched to the table's fragments by kind, size and order), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds, a badge alone beside its text, which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is reported). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | | Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index ac6714317..b57c4637d 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -572,7 +572,18 @@ tint it was flattened to. Recorded, like the other two. (`DocumentTableCell.node(...)`) has no place of its own in the layout: its drawing belongs to the table, and the table draws it — an icon, a tile, a disc under a number — anchored to the page where the page draws - it. A filled or outlined rectangle or rounded rectangle holding text there + it. A layer stack of shapes that is all its cell holds — an icon alone in + the first column of a band — is anchored in that cell instead: the cell's + paragraph is held at the drawing's height and carries it, placed from its + top and the cell's text column, which is taken to start where the drawing + does, so it moves with its row wherever Word sets the rows above it. Its + drawing is the first of the table's drawings still waiting inside that + cell, where the layout first placed it, when those are the stack's own + shapes, each of its kind and size. In a row of the flow, a layer stack or shape container + that only draws — shapes, a drawn picture, a badge's initials — and is all + its cell holds is anchored the same way, when it has no margins, stands on + one page and paints nothing outside its box. A drawing holding a line, a + lone shape and a composed tile stay on the page. A filled or outlined rectangle or rounded rectangle holding text there is written as a panel is, a table of one cell in its fill and outline, its outline's width within the cell and a point for the editor's face, its row held at least its outline's height, with its layers inside, its corners diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java new file mode 100644 index 000000000..305a261cc --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java @@ -0,0 +1,155 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; +import com.demcha.compose.document.layout.payloads.LineFragmentPayload; +import com.demcha.compose.document.layout.payloads.PathFragmentPayload; +import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.LineNode; +import com.demcha.compose.document.node.PathNode; +import com.demcha.compose.document.node.PolygonNode; +import com.demcha.compose.document.node.ShapeNode; +import com.demcha.compose.document.style.DocumentInsets; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Finds the fragments of a table that draw one drawing composed in one of its cells. + * + *

Content a table cell composes has no place of its own in the layout: its fragments are the + * table's, under the table's path, so nothing says which drawing a fragment was painted for. + * Within one of the table's cells they come in the order the cell's content is laid out, which + * is the order the export writes it in; so when the first of the fragments still waiting in the + * cell are a drawing's shapes — each of its kind and size, in the order the page paints them, + * on one page — they are its drawing.

+ */ +final class DocxCellDrawings { + + /** How far a fragment's size may part from its node's and still be its, in points. */ + private static final double SIZE_TOLERANCE = 0.01; + + private DocxCellDrawings() { + } + + /** The kind of drawing node a fragment is painted for, or {@code null} for any other. */ + static Class drawnKind(PlacedFragment fragment) { + Object payload = fragment.payload(); + if (payload instanceof ShapeFragmentPayload) { + return ShapeNode.class; + } + if (payload instanceof EllipseFragmentPayload) { + return EllipseNode.class; + } + if (payload instanceof LineFragmentPayload) { + return LineNode.class; + } + if (payload instanceof PathFragmentPayload) { + return PathNode.class; + } + if (payload instanceof PolygonFragmentPayload) { + return PolygonNode.class; + } + return null; + } + + /** + * Whether a drawing holds a line anywhere in it. A line alone in a cell, or in a stack of + * one layer, is a rule and written as one; a drawing holding a line is left where it was + * drawn before, rather than told apart from a rule here. + */ + static boolean holdsALine(DocumentNode node) { + if (node instanceof LineNode) { + return true; + } + for (DocumentNode child : node.children()) { + if (holdsALine(child)) { + return true; + } + } + return false; + } + + /** + * A layer stack's shapes in the order the page paints them, or {@code null} when it holds + * anything else — a container outline, a picture, text — or a stack in it has a margin. + */ + static List shapesOf(LayerStackNode stack) { + List shapes = new ArrayList<>(); + return collect(stack, shapes) && !shapes.isEmpty() ? shapes : null; + } + + private static boolean collect(DocumentNode node, List shapes) { + if (sizeOf(node) != null) { + shapes.add(node); + return true; + } + if (!(node instanceof LayerStackNode stack) || stack.layers().isEmpty()) { + return false; + } + DocumentInsets margin = stack.margin(); + if (margin != null && (margin.top() != 0 || margin.right() != 0 || margin.bottom() != 0 || margin.left() != 0)) { + return false; + } + List layers = stack.layers().stream() + .sorted(Comparator.comparingInt(LayerStackNode.Layer::zIndex)) + .toList(); + for (LayerStackNode.Layer layer : layers) { + if (!collect(layer.node(), shapes)) { + return false; + } + } + return true; + } + + /** + * Whether the first of the waiting fragments are these shapes, each of its kind and size, + * one after another, on one page. + * + * @param waiting the fragments not yet anchored that one cell holds, in the order the layout + * emitted them + * @param shapes a drawing's shapes, in the order the page paints them + */ + static boolean opensWith(List waiting, List shapes) { + if (waiting.size() < shapes.size()) { + return false; + } + int page = waiting.get(0).pageIndex(); + for (int i = 0; i < shapes.size(); i++) { + PlacedFragment fragment = waiting.get(i); + DocumentNode shape = shapes.get(i); + double[] size = sizeOf(shape); + if (fragment.pageIndex() != page || drawnKind(fragment) != shape.getClass() + || Math.abs(fragment.width() - size[0]) >= SIZE_TOLERANCE + || Math.abs(fragment.height() - size[1]) >= SIZE_TOLERANCE) { + return false; + } + } + return true; + } + + /** A drawing node's width and height, in points, or {@code null} for any other node. */ + private static double[] sizeOf(DocumentNode node) { + if (node instanceof ShapeNode shape) { + return new double[]{shape.width(), shape.height()}; + } + if (node instanceof EllipseNode ellipse) { + return new double[]{ellipse.width(), ellipse.height()}; + } + if (node instanceof LineNode line) { + return new double[]{line.width(), line.height()}; + } + if (node instanceof PathNode path) { + return new double[]{path.width(), path.height()}; + } + if (node instanceof PolygonNode polygon) { + return new double[]{polygon.width(), polygon.height()}; + } + return null; + } +} diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index 7374e50d2..e840f731d 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -32,8 +32,8 @@ */ final class DocxDrawingAnchors { - // Shapes waiting for a paragraph on their page, by page. - private final Map> pending = new TreeMap<>(); + // Shapes waiting for a paragraph on their page, by page, each with its place in the paint order. + private final Map> pending = new TreeMap<>(); // The first body paragraph written on each page of the section, by page. private final Map bodyParagraph = new HashMap<>(); // The first table-cell paragraph written on each page of the section, by page. @@ -55,17 +55,46 @@ void reset() { order = 0; } + /** + * A shape and its place in the paint order: a later one is drawn over an earlier one. + * + * @param shape the shape + * @param order its place, given when it was drawn, whenever it is anchored + */ + record Ordered(DocxDrawings.Shape shape, int order) { + } + + /** + * Gives shapes their places in the paint order, in the order they come, as drawn now. + * + *

A shape is painted in the order it is drawn, not the order it finds a paragraph: one + * waiting for its page's first paragraph would otherwise be painted over a shape anchored + * in a cell meanwhile, though drawn before it.

+ */ + List ordered(List shapes) { + List ordered = new ArrayList<>(shapes.size()); + for (DocxDrawings.Shape shape : shapes) { + ordered.add(new Ordered(shape, order++)); + } + return ordered; + } + /** * Anchors shapes in the body paragraph already written on their page, or keeps them for * the first one written there. */ void queue(List shapes) { - for (DocxDrawings.Shape shape : shapes) { - XWPFParagraph carrier = bodyParagraph.get(shape.page()); + queueOrdered(ordered(shapes)); + } + + /** Anchors shapes given their places in the paint order, as {@link #queue} does. */ + void queueOrdered(List shapes) { + for (Ordered shape : shapes) { + XWPFParagraph carrier = bodyParagraph.get(shape.shape().page()); if (carrier != null) { anchor(carrier, List.of(shape)); } else { - pending.computeIfAbsent(shape.page(), page -> new ArrayList<>()).add(shape); + pending.computeIfAbsent(shape.shape().page(), page -> new ArrayList<>()).add(shape); } } } @@ -89,7 +118,7 @@ void paragraphOn(int page, XWPFParagraph paragraph, boolean inBody) { if (bodyParagraph.putIfAbsent(page, paragraph) != null) { return; } - List waiting = pending.remove(page); + List waiting = pending.remove(page); if (waiting != null && !waiting.isEmpty()) { anchor(paragraph, waiting); } @@ -143,10 +172,28 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier inCells, Map dropped) { } - private void anchor(XWPFParagraph carrier, List shapes) { + /** + * Anchors shapes in the paragraph of the table cell that holds them, placed from its text + * column and the paragraph's top rather than from the page's edges: they move with the row + * wherever Word sets it. The caller hands it only shapes the cell holds whole, as Word clips + * a shape anchored in a cell to the cell. + * + * @param carrier the cell's paragraph + * @param shapes the shapes, measured from the page's edges + * @param origin where the page puts the cell's text column and the paragraph's top + */ + void anchorInCell(XWPFParagraph carrier, List shapes, DocxDrawings.CellOrigin origin) { XWPFRun run = carrier.insertNewRun(0); - for (DocxDrawings.Shape shape : shapes) { - run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape, ids.getAsLong(), order++)); + for (Ordered shape : shapes) { + run.getCTR().addNewDrawing().set( + DocxDrawings.drawingInCell(shape.shape(), ids.getAsLong(), shape.order(), origin)); + } + } + + private void anchor(XWPFParagraph carrier, List shapes) { + XWPFRun run = carrier.insertNewRun(0); + for (Ordered shape : shapes) { + run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape.shape(), ids.getAsLong(), shape.order())); } } } diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java index ab971a78e..9ef72e82e 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java @@ -280,6 +280,35 @@ static long stackHeight(int order) { * @return the drawing, to be added to a run */ static CTDrawing drawing(Shape shape, long id, int order) { + return drawing(shape, id, order, null); + } + + /** + * A shape or a picture as a drawing anchored in a table cell's paragraph, placed from the + * cell's text column and that paragraph's top, so it moves with the row wherever Word sets + * it. + * + * @param shape the shape + * @param id an identifier for the drawing, unique in the document + * @param order its place among the drawings: a later one is drawn over an earlier one + * @param origin where the page puts the cell's text column and the paragraph's top, in + * points from the page's left and top edges + * @return the drawing, to be added to a run of that paragraph + */ + static CTDrawing drawingInCell(Shape shape, long id, int order, CellOrigin origin) { + return drawing(shape, id, order, origin); + } + + /** + * Where the page puts a cell's text column and a paragraph's top in it. + * + * @param x from the page's left edge, in points + * @param top from the page's top edge, in points + */ + record CellOrigin(double x, double top) { + } + + private static CTDrawing drawing(Shape shape, long id, int order, CellOrigin origin) { long cx = Units.toEMU(shape.width()); long cy = Units.toEMU(shape.height()); boolean picture = shape.kind() == Kind.PICTURE; @@ -295,11 +324,13 @@ static CTDrawing drawing(Shape shape, long id, int order) { + "" + + " layoutInCell=\"" + (origin == null ? 0 : 1) + "\" allowOverlap=\"1\">" + "" - + "" + Units.toEMU(shape.x()) + + "" + + Units.toEMU(origin == null ? shape.x() : shape.x() - origin.x()) + "" - + "" + Units.toEMU(shape.top()) + + "" + + Units.toEMU(origin == null ? shape.top() : shape.top() - origin.top()) + "" + "" + "" diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index e68325dc3..3ebb9e1fb 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -57,6 +57,7 @@ final class DocxLayoutMetrics { private final int pageCount; // A table's measured cells by name, filled on first use — see cellLineHeightsOf. private final Map> cellLineHeights = new IdentityHashMap<>(); + private final Map> cellBoxes = new IdentityHashMap<>(); // The rows of a table the layout placed, by index, filled on first use — see placedRowsOf. private final Map> placedRows = new IdentityHashMap<>(); // The heights of a table's placed rows, by index, filled on first use — see rowHeightsOf. @@ -190,6 +191,53 @@ private Map cellLineHeightsOf(DocumentNode table) { }); } + /** + * Where the layout first placed one of a table's cells, found by its name as + * {@link #cellLineHeight} finds it: the page and the box, in the page's points, y up. + * + * @param page the page, counted within the section + * @param left the cell's left edge + * @param bottom the cell's bottom edge + * @param right the cell's right edge + * @param top the cell's top edge + */ + record CellBox(int page, double left, double bottom, double right, double top) { + + /** Whether a fragment lies in the box on its page, a point either side allowed. */ + boolean holds(PlacedFragment fragment) { + double slack = 1; + return fragment.pageIndex() == page + && fragment.x() >= left - slack && fragment.x() + fragment.width() <= right + slack + && fragment.y() >= bottom - slack && fragment.y() + fragment.height() <= top + slack; + } + } + + /** + * The box the layout first placed one of a table's cells in — the first page it stands on, + * for a header repeated on every page — or {@code null} when it placed none by that name. + * + * @param table the table node + * @param row the cell's logical row + * @param column the cell's first column + */ + CellBox cellBox(DocumentNode table, int row, int column) { + String owner = table.name() == null || table.name().isBlank() ? table.nodeKind() : table.name(); + return cellBoxes.computeIfAbsent(table, node -> { + Map byName = new HashMap<>(); + for (PlacedFragment fragment : fragmentsOf(node)) { + if (fragment.payload() instanceof TableRowFragmentPayload payload) { + for (TableResolvedCell cell : payload.cells()) { + double bottom = fragment.y() + cell.yOffset(); + double left = fragment.x() + cell.x(); + byName.putIfAbsent(cell.name(), + new CellBox(fragment.pageIndex(), left, bottom, left + cell.width(), bottom + cell.height())); + } + } + } + return byName; + }).get(owner + "__row_" + row + "__cell_" + column); + } + /** * Whether the layout placed one of a table's rows. * diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index b5e21e89b..0dba42981 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -267,9 +267,22 @@ public final class DocxSemanticBackend implements SemanticBackend { private int sectionFirstElement; // The shape container being written that clips its content to its outline, null outside one. private ShapeContainerNode clipContainer; + // The table cell a drawing is written alone in, whose paragraph carries its shapes; null + // outside one (see drawingCellFor). + private DrawingCell drawingCell; + // Whether the shapes last queued went into that cell rather than onto the page. + private boolean drewInCell; // What the tables being written drew of their composed cells (see drawCellDrawing): a drawing // node inside such a cell is then drawn, not lost. private CellDrawing cellDrawing = CellDrawing.NONE; + // The drawings the composed cells of the table being written paint, not yet anchored, in the + // order the layout emitted them; null outside such a table (see anchorComposedDrawing). + private List tableDrawings; + // Where the layout first placed the cell of that table being written; null outside one. + private DocxLayoutMetrics.CellBox composedCellBox; + // The shapes composed in a cell that anchorComposedDrawing anchored there, so not lost. + private final java.util.Set anchoredInCells = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // The paragraph a badge being drawn holds, as w:p markup (see textBadgeParagraph); null // otherwise. private String badgeText; @@ -611,6 +624,9 @@ private byte[] write(List sections, Path outputFile) throws Exc currentPage = 0; clipContainer = null; cellDrawing = CellDrawing.NONE; + tableDrawings = null; + composedCellBox = null; + anchoredInCells.clear(); panelCell = null; moves.clear(); writingInAStandIn.clear(); @@ -2136,12 +2152,13 @@ private boolean drawOwnFragments(DocumentNode node) { boolean overlayFront = drawsInFront(); boolean front = false; boolean drew = false; + drewInCell = false; for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) { if (badgeText != null && !Double.isNaN(canvasHeight)) { // A badge holding its initials: in front, the text being its own. List shapes = DocxDrawings.of(fragment, canvasHeight).stream() .map(shape -> shape.holding(badgeText).inFront()).toList(); - anchors.queue(shapes); + queueDrawings(shapes, false); drew |= !shapes.isEmpty(); front |= !shapes.isEmpty(); continue; @@ -2162,8 +2179,11 @@ private boolean drawOwnFragments(DocumentNode node) { } } if (drew) { - StringBuilder message = new StringBuilder("drawn as a shape anchored to the page where the " - + "layout puts it: it stays there when the text around it is edited"); + StringBuilder message = new StringBuilder(drewInCell + ? "drawn as a shape anchored in the table cell it fills, where the layout puts it " + + "in the cell: it moves with the row" + : "drawn as a shape anchored to the page where the " + + "layout puts it: it stays there when the text around it is edited"); if (badgeText != null) { message.append("; its text is held in the shape, which stands in front of the text"); } else if (front) { @@ -2240,10 +2260,104 @@ private boolean queueDrawings(List shapes, boolean front) { // what lies behind the text: in front of it, the shape shows. shapes = shapes.stream().map(DocxDrawings.Shape::inFront).toList(); } + DrawingCell cell = drawingCell; + if (cell != null && !shapes.isEmpty() && shapes.stream().allMatch(cell::holds)) { + anchors.anchorInCell(cell.carrier(), anchors.ordered(shapes), cell.origin()); + drewInCell = true; + return true; + } anchors.queue(shapes); return !shapes.isEmpty(); } + /** + * A table cell holding one drawing and nothing else, and where the page puts it. + * + * @param carrier the cell's paragraph, held at the drawing's height + * @param origin where the page puts the drawing's top-left corner, which the paragraph's + * top and the cell's text column stand at + * @param width the drawing's width, in points + * @param height the drawing's height, in points + * @param page the page it is laid out on, counted within the section + */ + private record DrawingCell(XWPFParagraph carrier, DocxDrawings.CellOrigin origin, double width, double height, + int page) { + + /** Whether a shape lies in the drawing's box, a point's stroke either side allowed. */ + boolean holds(DocxDrawings.Shape shape) { + double slack = 1; + return shape.page() == page + && shape.x() >= origin.x() - slack && shape.x() + shape.width() <= origin.x() + width + slack + && shape.top() >= origin.top() - slack + && shape.top() + shape.height() <= origin.top() + height + slack; + } + } + + /** + * The cell a drawing is written alone in, when its shapes can be anchored in that cell's + * paragraph rather than on the page; {@code null} when they cannot. + * + *

Every other shape is placed from the page's edges (see {@link DocxDrawingAnchors}), which + * holds it where the page puts it — and, in a table, off the row it belongs to wherever Word + * sets the rows above a little taller or shorter than the page. {@code CobaltRota}'s band + * icons, each alone in the first column of its navy strip, stood 4pt, 9pt and 14pt above + * their labels, the last out of its strip. A drawing that is all its cell holds is placed + * from that cell's paragraph instead, held at the drawing's height: the row carries it.

+ * + *

Only a drawing with no margins, laid out on one page, in a cell holding nothing else yet: + * the cell's text column then starts where the drawing does, and the paragraph's top where + * its top is.

+ */ + private DrawingCell drawingCellFor(XWPFTableCell cell, DocumentNode node) { + if (Double.isNaN(canvasHeight) + || !(node instanceof com.demcha.compose.document.node.LayerStackNode || node instanceof ShapeContainerNode) + || !onlyDrawn(node) || DocxCellDrawings.holdsALine(node)) { + return null; + } + com.demcha.compose.document.style.DocumentInsets margin = node.margin(); + if (margin != null && (margin.top() != 0 || margin.right() != 0 || margin.bottom() != 0 || margin.left() != 0)) { + return null; + } + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); + if (placed == null || placed.startPage() != placed.endPage() || !(placed.placementHeight() > 0) + || !holdsNothingYet(cell) || !paintsInsideItsBox(node, placed)) { + return null; + } + XWPFParagraph carrier = cell.getParagraphs().isEmpty() ? cell.addParagraph() : cell.getParagraphs().get(0); + CTPPr properties = carrier.getCTP().isSetPPr() ? carrier.getCTP().getPPr() : carrier.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + spacing.setBefore(BigInteger.ZERO); + spacing.setAfter(BigInteger.ZERO); + spacing.setLineRule(STLineSpacingRule.EXACT); + spacing.setLine(BigInteger.valueOf(Math.max(2, toTwips(placed.placementHeight())))); + double top = canvasHeight - placed.placementY() - placed.placementHeight(); + return new DrawingCell(carrier, new DocxDrawings.CellOrigin(placed.placementX(), top), + placed.placementWidth(), placed.placementHeight(), placed.startPage()); + } + + /** + * Whether everything a node and the nodes inside it paint lies in its box on its page, a + * point either side allowed: the cell then holds the whole drawing, or none of it. + */ + private boolean paintsInsideItsBox(DocumentNode node, com.demcha.compose.document.layout.PlacedNode box) { + for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) { + double slack = 1; + if (fragment.pageIndex() != box.startPage() + || fragment.x() < box.placementX() - slack + || fragment.x() + fragment.width() > box.placementX() + box.placementWidth() + slack + || fragment.y() < box.placementY() - slack + || fragment.y() + fragment.height() > box.placementY() + box.placementHeight() + slack) { + return false; + } + } + for (DocumentNode child : node.children()) { + if (!paintsInsideItsBox(child, box)) { + return false; + } + } + return true; + } + /** * A paragraph a hairline tall before a table in the body, holding no space of its own: * the body paragraph a page laid out entirely in a table otherwise lacks. @@ -6101,10 +6215,14 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte double top = canvasHeight - placed.placementY() - (placed.placementHeight() + height) / 2; DocxDrawings.Shape picture = DocxDrawings.Shape.picture(x, top, width, height, placed.startPage(), relationship); + drewInCell = false; queueDrawings(List.of(picture), inFront); report.add(DocxExportReport.Severity.APPROXIMATED, image.nodeKind(), layout.pathOf(image), - how + ", anchored to the page where the layout puts it: it stays " - + "there when the text around it is edited"); + drewInCell + ? how + ", anchored in the table cell it fills, where the layout puts it in the cell: " + + "it moves with the row" + : how + ", anchored to the page where the layout puts it: it stays " + + "there when the text around it is edited"); } /** @@ -6363,12 +6481,23 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception return; } CellDrawing outerCellDrawing = cellDrawing; + List outerDrawings = tableDrawings; CellDrawing drawn = drawCellDrawing(node); cellDrawing = new CellDrawing(outerCellDrawing.drew() || drawn.drew(), - outerCellDrawing.skippedBoxes() || drawn.skippedBoxes()); + outerCellDrawing.skippedBoxes() || drawn.skippedBoxes(), List.of()); + if (!drawn.pending().isEmpty()) { + tableDrawings = new ArrayList<>(drawn.pending()); + } try { writeTableRows(document, node); } finally { + // What no cell took is drawn where the page puts it, in the order the table paints it. + if (tableDrawings != outerDrawings) { + for (CellFragment waiting : tableDrawings) { + anchors.queueOrdered(waiting.shapes()); + } + } + tableDrawings = outerDrawings; cellDrawing = outerCellDrawing; } } @@ -6378,9 +6507,10 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception * * @param drew whether they drew anything * @param skippedBoxes whether they left a box framing text to the panel it is written as + * @param pending the drawings one table's cells paint, waiting to be anchored */ - private record CellDrawing(boolean drew, boolean skippedBoxes) { - static final CellDrawing NONE = new CellDrawing(false, false); + private record CellDrawing(boolean drew, boolean skippedBoxes, List pending) { + static final CellDrawing NONE = new CellDrawing(false, false, List.of()); } /** @@ -6391,6 +6521,9 @@ private record CellDrawing(boolean drew, boolean skippedBoxes) { * table skipped any, a box node is still reported, rather than one lost in silence.

*/ private boolean drawnByItsTable(DocumentNode node) { + if (anchoredInCells.contains(node)) { + return true; + } if (!cellDrawing.drew() || !composedInACell(node) || !(isDrawing(node) || node instanceof ShapeContainerNode)) { // A node kind the table's drawing does not cover is reported as ever. @@ -6445,21 +6578,121 @@ private CellDrawing drawCellDrawing(TableNode table) { .toList(); boolean drew = false; boolean skipped = false; + List pending = new ArrayList<>(); for (com.demcha.compose.document.layout.PlacedFragment fragment : fragments) { boolean frames = framesText(fragment, content); if (frames && fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload) { skipped = true; continue; } - drew |= queueDrawing(fragment, !frames); + if (Double.isNaN(canvasHeight)) { + continue; + } + List shapes = DocxDrawings.of(fragment, canvasHeight); + if (!shapes.isEmpty() || DocxCellDrawings.drawnKind(fragment) != null) { + // Its place in the paint order is the table's, wherever it is anchored. + pending.add(new CellFragment(fragment, anchors.ordered( + frames ? shapes : shapes.stream().map(DocxDrawings.Shape::inFront).toList()))); + drew |= !shapes.isEmpty(); + } } if (drew) { report.add(DocxExportReport.Severity.APPROXIMATED, "cell drawing", layout.pathOf(table), - "what its cells draw is drawn as shapes anchored to the page where the layout puts " - + "it: it stays there when the text around it is edited, and a clip, a transform, a " - + "gradient or a dash on it is not carried"); + "what its cells draw is drawn as shapes where the layout puts it — anchored in the " + + "cell a drawing is all of, and to the page otherwise, where it stays when the text " + + "around it is edited; a clip, a transform, a gradient or a dash on it is not carried"); } - return new CellDrawing(drew, skipped); + return new CellDrawing(drew, skipped, pending); + } + + /** + * A drawing a table's composed cell paints, waiting to be anchored. + * + * @param fragment the table's fragment + * @param shapes the shapes it draws, each with its place in the paint order + */ + private record CellFragment(com.demcha.compose.document.layout.PlacedFragment fragment, + List shapes) { + } + + /** + * Anchors a drawing composed alone in a table cell in that cell, where the page puts it in + * the cell, rather than on the page. + * + *

A composed cell's content has no place of its own in the layout: its drawings are the + * table's fragments (see {@link #drawCellDrawing}), placed from the page's edges, and so off + * their row wherever Word sets the rows above a little taller or shorter than the page. + * {@code CobaltRota}'s band icons, each alone in the first column of its navy strip, stood + * 4pt, 9pt and 14pt above their labels, the last out of its strip. Its drawing is the first + * of the table's waiting fragments inside the table's cell being written, where the layout + * first placed it, when those are its shapes (see {@link DocxCellDrawings}). The cell's + * paragraph is held at the height of their box and carries them, + * placed from its top and the cell's text column: the row carries them.

+ * + *

Only a layer stack of shapes — no container outline, no picture, no line, which is a + * rule — with no margins, in a cell holding nothing else yet; the cell's text column is taken + * to start where its shapes do. Anything else is left to the page, as before.

+ */ + private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { + if (tableDrawings == null || tableDrawings.isEmpty() || composedCellBox == null || Double.isNaN(canvasHeight) + || !composedInACell(node) || !holdsNothingYet(cell)) { + return; + } + List shapes = node instanceof com.demcha.compose.document.node.LayerStackNode stack + ? DocxCellDrawings.shapesOf(stack) : null; + if (shapes == null || DocxCellDrawings.holdsALine(node)) { + return; + } + // Only what the table's cell being written holds, where the layout first placed it — a + // header's copies on later pages, and other cells' drawings, are not this one's — and + // from the first of those still waiting: a drawing no cell took before this one is + // never passed over and given to the next. + List inTheCell = tableDrawings.stream() + .filter(waiting -> composedCellBox.holds(waiting.fragment())) + .toList(); + if (!DocxCellDrawings.opensWith(inTheCell.stream().map(CellFragment::fragment).toList(), shapes)) { + return; + } + List own = inTheCell.subList(0, shapes.size()); + double left = Double.POSITIVE_INFINITY; + double top = Double.POSITIVE_INFINITY; + double right = Double.NEGATIVE_INFINITY; + double bottom = Double.NEGATIVE_INFINITY; + List drawn = new ArrayList<>(); + for (CellFragment waiting : own) { + com.demcha.compose.document.layout.PlacedFragment fragment = waiting.fragment(); + double fragmentTop = canvasHeight - fragment.y() - fragment.height(); + left = Math.min(left, fragment.x()); + top = Math.min(top, fragmentTop); + right = Math.max(right, fragment.x() + fragment.width()); + bottom = Math.max(bottom, fragmentTop + fragment.height()); + drawn.addAll(waiting.shapes()); + } + if (!(bottom - top > 0)) { + return; + } + XWPFParagraph carrier = cell.getParagraphs().isEmpty() ? cell.addParagraph() : cell.getParagraphs().get(0); + CTPPr properties = carrier.getCTP().isSetPPr() ? carrier.getCTP().getPPr() : carrier.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + spacing.setBefore(BigInteger.ZERO); + spacing.setAfter(BigInteger.ZERO); + spacing.setLineRule(STLineSpacingRule.EXACT); + spacing.setLine(BigInteger.valueOf(Math.max(2, toTwips(bottom - top)))); + if (!drawn.isEmpty()) { + anchors.anchorInCell(carrier, drawn, new DocxDrawings.CellOrigin(left, top)); + } + anchoredInCells.addAll(shapes); + java.util.Set taken = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + taken.addAll(own); + tableDrawings.removeIf(taken::contains); + } + + /** Whether a cell holds nothing yet: no element, or one paragraph with nothing in it. */ + private static boolean holdsNothingYet(XWPFTableCell cell) { + List elements = cell.getBodyElements(); + return elements.isEmpty() + || elements.size() == 1 && elements.get(0) instanceof XWPFParagraph only && only.getRuns().isEmpty() + && only.getCTP().sizeOfHyperlinkArray() == 0; } /** Whether a box holds a line of text or a picture of its table: the centre of one stands inside it. */ @@ -7051,12 +7284,20 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, // A composed cell keeps its node and leaves lines() empty, so reading lines() // exported it as an empty cell. double previous = currentCellWidth; + DocxLayoutMetrics.CellBox outerBox = composedCellBox; currentCellWidth = usableWidthOf(cell, placement); tablesCells.add(cell.getCTTc()); + // A table nested in a composed cell has no rows of its own in the layout: its cells + // stand in the outer table's cell. + DocxLayoutMetrics.CellBox box = layout.cellBox(node, placement.row(), placement.column()); + if (box != null) { + composedCellBox = box; + } try { writeCellBody(cell, source.content()); } finally { currentCellWidth = previous; + composedCellBox = outerBox; } return; } @@ -8725,9 +8966,19 @@ private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Except /** Writes several nodes into a cell, one after another, as a block of the cell's own. */ private void writeCellNodes(XWPFTableCell cell, List children) throws Exception { + DrawingCell alone = children.size() == 1 ? drawingCellFor(cell, children.get(0)) : null; + if (children.size() == 1 && alone == null) { + anchorComposedDrawing(cell, children.get(0)); + } writeInCell(cell, () -> { - for (DocumentNode child : children) { - writeNode(cell.getXWPFDocument(), child); + DrawingCell outer = drawingCell; + drawingCell = alone; + try { + for (DocumentNode child : children) { + writeNode(cell.getXWPFDocument(), child); + } + } finally { + drawingCell = outer; } }); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java new file mode 100644 index 000000000..7a5b20782 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java @@ -0,0 +1,275 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.LineBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.RowVerticalAlign; +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.svg.SvgIcon; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A drawing that is all a table cell holds is anchored in that cell's paragraph, placed from its + * top and the cell's text column, so it moves with its row wherever Word sets the rows above. + * + *

Placed from the page's edges, {@code CobaltRota}'s band icons — each alone in the first + * column of its navy strip — stood 4pt, 9pt and 14pt above their labels in Word, the last out of + * its strip, as the rows above them came out a little taller than the page's.

+ */ +class DocxCellDrawingTest { + + private static final SvgIcon ICON = SvgIcon.parse("" + + ""); + + private static final SvgIcon BLUE_ICON = SvgIcon.parse("" + + ""); + + @Test + void anIconAloneInACellComposedInATableIsAnchoredInThatCell() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(band(ICON, 12)))))) { + XWPFParagraph carrier = iconCell(document.getTables().get(0).getRow(1).getCell(0)).getParagraphs().get(0); + + assertThat(anchors(carrier)).as("the icon, in its own cell") + .containsExactly(new Anchor("column", 0, "paragraph", 0, 12)); + assertThat(DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine())) + .as("its paragraph as tall as the icon").isEqualTo(240); + assertThat(pageAnchoredShapes(document)).as("none left on the page").isZero(); + } + } + + @Test + void iconsOfOneSizeInTwoCellsAreEachAnchoredInTheirOwn() throws Exception { + // The table's drawings are taken in the order its cells are written: the first band's + // icon, the red one, is the first of the two. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(band(ICON, 12))) + .rowCells(DocumentTableCell.text("Between")) + .rowCells(DocumentTableCell.node(band(BLUE_ICON, 12)))))) { + XWPFTable table = document.getTables().get(0); + String first = iconCell(table.getRow(0).getCell(0)).getParagraphs().get(0).getCTP().xmlText(); + String second = iconCell(table.getRow(2).getCell(0)).getParagraphs().get(0).getCTP().xmlText(); + + assertThat(first).as("the red icon in the first band").contains("layoutInCell=\"1\"") + .contains("FF0000").doesNotContain("0000FF"); + assertThat(second).as("the blue one in the second").contains("layoutInCell=\"1\"") + .contains("0000FF").doesNotContain("FF0000"); + assertThat(pageAnchoredShapes(document)).isZero(); + } + } + + @Test + void aHeadersIconRepeatedOnTheNextPageIsNotTakenByARowThere() throws Exception { + // The layout draws a repeated header's icon again on every page; the export writes the + // header once. On the second page its copy comes first among the table's drawings, and + // matched by kind and size alone the next row took it and passed its own on, row by row. + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page.addTable(t -> { + t.columns(DocumentTableColumn.fixed(300)).repeatHeader(1) + .headerCells(DocumentTableCell.node(band(ICON, 12))); + for (int i = 0; i < 16; i++) { + t.rowCells(DocumentTableCell.node(band(BLUE_ICON, 12))); + } + }))) { + XWPFTable table = document.getTables().get(0); + assertThat(xmlOfIcon(table, 0)).as("the header's own icon").contains("FF0000"); + for (int row = 1; row < table.getRows().size(); row++) { + assertThat(xmlOfIcon(table, row)).as("row " + row + " holds its own icon") + .contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); + } + assertThat(pageAnchoredShapes(document)).as("the header's copy on the second page, left to the page") + .isEqualTo(1); + } + } + + @Test + void anIconNoCellTookIsNotGivenToTheNextCell() throws Exception { + // The first band's mark holds a line, so it stays on the page; the second band's icon, + // of the same kind and size, takes its own fragments, not the first band's. + DocumentNode ruledMark = new LayerStackBuilder().name("RuledMark") + .layer(ICON.node(12)) + .layer(new LineBuilder().name("Underline").horizontal(12) + .stroke(DocumentStroke.of(DocumentColor.rgb(200, 0, 0), 1)).build()) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new RowBuilder().name("Ruled").verticalAlign(RowVerticalAlign.CENTER) + .weights(12, 288).add(ruledMark).addParagraph(p -> p.text("LABEL")).build())) + .rowCells(DocumentTableCell.node(band(BLUE_ICON, 12)))))) { + XWPFTable table = document.getTables().get(0); + + assertThat(xmlOfIcon(table, 0)).doesNotContain("layoutInCell=\"1\""); + assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); + } + } + + @Test + void aShapeWaitingForItsPagesParagraphIsPaintedBeforeAnIconAnchoredInACellAfterIt() throws Exception { + // The banner is drawn first and waits for a paragraph on its page, the page opening with + // a table; the icon is anchored in its cell at once. Painted in the order drawn, the + // banner stays under the icon. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Band", row -> row.weights(150, 12, 138).verticalAlign(RowVerticalAlign.CENTER) + .addShape(shape -> shape.size(150, 20).fillColor(DocumentColor.rgb(230, 230, 240))) + .add(ICON.node(12)) + .addParagraph(p -> p.text("LABEL"))))) { + String body = document.getDocument().xmlText(); + Matcher banner = Pattern.compile("relativeHeight=\"(\\d+)\"[^>]*layoutInCell=\"0\"").matcher(body); + Matcher icon = Pattern.compile("relativeHeight=\"(\\d+)\"[^>]*layoutInCell=\"1\"").matcher(body); + + assertThat(banner.find()).isTrue(); + assertThat(icon.find()).isTrue(); + assertThat(Long.parseLong(banner.group(1))).as("the banner under the icon") + .isLessThan(Long.parseLong(icon.group(1))); + } + } + + /** The markup of the paragraph holding the icon of a band in one of a table's rows. */ + private static String xmlOfIcon(XWPFTable table, int row) { + return iconCell(table.getRow(row).getCell(0)).getParagraphs().get(0).getCTP().xmlText(); + } + + @Test + void aDrawingWithAMarginStaysOnThePage() throws Exception { + // The cell's text column is taken to start where the drawing does; a margin would move it. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Above")) + .addRow("Band", row -> row.weights(16, 284).verticalAlign(RowVerticalAlign.CENTER) + .add(new ShapeContainerBuilder().name("Inset").circle(12) + .fillColor(DocumentColor.rgb(26, 86, 148)) + .center(ICON.node(8)) + .margin(DocumentInsets.of(2)) + .build()) + .addParagraph(p -> p.text("LABEL"))))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + + assertThat(anchors(cell.getParagraphs().get(0))).isEmpty(); + assertThat(pageAnchoredShapes(document)).as("the disc and its mark, on the page").isEqualTo(2); + } + } + + @Test + void aLineAloneInACellOfARowInTheFlowStaysARule() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Above")) + .addRow("Ruled", row -> row.weights(150, 150) + .add(new LayerStackBuilder().name("Divider") + .layer(new LineBuilder().name("Rule").horizontal(150) + .stroke(DocumentStroke.of(DocumentColor.rgb(200, 200, 200), 1)).build()) + .build()) + .addParagraph(p -> p.text("LABEL"))))) { + String body = document.getDocument().xmlText(); + + assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\""); + } + } + + @Test + void anIconAloneInACellOfARowInTheFlowIsAnchoredInThatCell() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Above")) + .addRow("Band", row -> row.weights(12, 288).verticalAlign(RowVerticalAlign.CENTER) + .add(ICON.node(12)) + .addParagraph(p -> p.text("LABEL"))))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + + assertThat(anchors(cell.getParagraphs().get(0))).as("the icon, in its own cell") + .containsExactly(new Anchor("column", 0, "paragraph", 0, 12)); + assertThat(pageAnchoredShapes(document)).isZero(); + } + } + + @Test + void aLineAloneInACellIsStillWrittenAsARule() throws Exception { + // A line in a stack of one layer is a rule, written as a paragraph's border. + DocumentNode divider = new LayerStackBuilder().name("Divider") + .layer(new LineBuilder().name("Rule").horizontal(300) + .stroke(DocumentStroke.of(DocumentColor.rgb(200, 200, 200), 1)).build()) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(divider)) + .rowCells(DocumentTableCell.text("Below"))))) { + XWPFParagraph rule = document.getTables().get(0).getRow(1).getCell(0).getParagraphs().get(0); + + assertThat(anchors(rule)).as("no drawing in the cell").isEmpty(); + assertThat(rule.getCTP().getPPr().isSetPBdr()).as("a paragraph border").isTrue(); + } + } + + /** A navy strip's row: a mark of the given size beside its label. */ + private static DocumentNode band(SvgIcon mark, double icon) { + return new RowBuilder().name("Band").verticalAlign(RowVerticalAlign.CENTER) + .weights(icon, 300 - icon) + .add(mark.node(icon)) + .addParagraph(p -> p.text("LABEL")) + .build(); + } + + /** The cell of a band's row holding its icon: the first cell of the table nested in the band's cell. */ + private static XWPFTableCell iconCell(XWPFTableCell band) { + return band.getTables().get(0).getRow(0).getCell(0); + } + + /** + * A drawing anchored in a paragraph. + * + * @param fromH what it is placed across from + * @param x how far across, in points + * @param fromV what it is placed down from + * @param y how far down, in points + * @param height its height, in points + */ + private record Anchor(String fromH, double x, String fromV, double y, double height) { + } + + private static final Pattern ANCHOR = Pattern.compile( + "layoutInCell=\"1\".*?positionH relativeFrom=\"(\\w+)\">(-?\\d+).*?" + + "positionV relativeFrom=\"(\\w+)\">(-?\\d+).*?" + + " anchors(XWPFParagraph paragraph) { + List anchors = new ArrayList<>(); + Matcher matcher = ANCHOR.matcher(paragraph.getCTP().xmlText()); + while (matcher.find()) { + anchors.add(new Anchor(matcher.group(1), points(matcher.group(2)), matcher.group(3), + points(matcher.group(4)), points(matcher.group(5)))); + } + return anchors; + } + + private static double points(String emu) { + return Math.round(Long.parseLong(emu) / 12700.0 * 10) / 10.0; + } + + /** How many shapes the document places from the page's edges. */ + private static int pageAnchoredShapes(XWPFDocument document) { + Matcher matcher = Pattern.compile("positionV relativeFrom=\"page\"").matcher(document.getDocument().xmlText()); + int count = 0; + while (matcher.find()) { + count++; + } + return count; + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java index bbcd19330..a9a3b0ec4 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -287,6 +287,8 @@ void aTileHoldingOnlyDrawingIsDrawnWhereThePageDrawsItNotAPanel() throws Excepti assertThat(anchors(body)).as("the tile and the dot on it, anchored where the page draws them") .anyMatch(anchor -> anchor.contains("prst=\"roundRect\"")) .anyMatch(anchor -> anchor.contains("prst=\"ellipse\"")); + assertThat(anchors(body)).as("a composed tile is not taken into its cell: it stays on the page") + .allMatch(anchor -> anchor.contains("")); assertThat(report.get().count(DocxExportReport.Severity.DROPPED)).isZero(); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java index e669de42c..306bbaed2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java @@ -567,7 +567,8 @@ void aDiscAndItsInitialsInAPaintedPanelAreOneShapeInFront() throws Exception { @Test void aBadgeOutsideAPanelHoldsItsInitialsToo() throws Exception { - // ObsidianInvoice's footer disc: its "K" stood below the disc's corner in the flow. + // ObsidianInvoice's footer disc: its "K" stood below the disc's corner in the flow. All its + // cell holds, the disc is anchored in that cell, where it moves with its row. try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page .addRow("Closing", row -> row.columns(com.demcha.compose.document.style.DocumentRowColumn.fixed(40), com.demcha.compose.document.style.DocumentRowColumn.weight(1)) @@ -577,7 +578,8 @@ void aBadgeOutsideAPanelHoldsItsInitialsToo() throws Exception { .addParagraph(p -> p.text("Thank you for your business."))))) ) { assertThat(anchors(document.getDocument().xmlText())).singleElement().asString() .contains("").contains(">K<").contains("behindDoc=\"0\"") - .contains(""); + .contains("layoutInCell=\"1\"") + .contains("0"); } } From dae37528299f78abad63c4c7eded36ec3a26b825 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 02:07:25 +0100 Subject: [PATCH 2/3] fix(docx): take a composed cell's box from the table's own rows, drop a repeated header's later copies, and leave a padded stack to the page --- CHANGELOG.md | 10 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 4 +- .../semantic/docx/DocxCellDrawings.java | 13 +- .../semantic/docx/DocxDrawingAnchors.java | 7 + .../backend/semantic/docx/DocxDrawings.java | 3 +- .../semantic/docx/DocxLayoutMetrics.java | 23 +-- .../semantic/docx/DocxSemanticBackend.java | 51 +++++-- .../semantic/docx/DocxCellDrawingTest.java | 140 ++++++++++++++---- 9 files changed, 189 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf0afade..d507e4523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,16 @@ follow semantic versioning; release dates are ISO 8601. drawing does. In a row of the flow that is a layer stack or a shape container that only draws — shapes, a drawn picture, a badge's initials — with no margins, on one page, all of it inside its box. In a table's composed cell, whose drawing belongs to the table, it is a layer - stack of shapes only: its drawing is the first of the table's waiting shapes inside that cell, - where the layout first placed it, when those are its shapes, of their kinds and sizes. A + stack of shapes only, with no margin or padding: its drawing is the first of the table's + waiting shapes inside that cell, where the layout first placed it, when those are its shapes, + of their kinds and sizes and on one page. Word repeats a repeated header row, the drawing + anchored in it with it, so the layout's copies of that drawing on later pages are not drawn + again. A drawing holding a line, a lone shape and a composed tile are drawn on the page as before. Every shape is now painted in the order it was drawn, rather than the order it found a paragraph to anchor it, so one waiting for its page's first paragraph is not painted over a - shape anchored in a cell after it. + shape anchored in a cell after it. `CobaltRota`'s icons stand beside their labels in Word and + in LibreOffice; its p90 drift in Word falls from 11.7pt to 6.4. - **A line pulled up into the line above it, with no space above to take the pull from, is no longer dropped in Word.** A paragraph's negative top edge comes out of the space owed above diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a4d14e7bd..756aa9bfc 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -70,7 +70,7 @@ Payload records live in `core` under | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | | Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws; in a composed cell, a layer stack of shapes, matched to the table's fragments by kind, size and order), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | -| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds, a badge alone beside its text, which is anchored in that cell and moves with its row | +| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is reported). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | | Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index b57c4637d..bf5c1c631 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -579,7 +579,9 @@ tint it was flattened to. Recorded, like the other two. does, so it moves with its row wherever Word sets the rows above it. Its drawing is the first of the table's drawings still waiting inside that cell, where the layout first placed it, when those are the stack's own - shapes, each of its kind and size. In a row of the flow, a layer stack or shape container + shapes, each of its kind and size; a stack with a margin or padding is + left to the page. Word repeats a repeated header row with the drawing + anchored in it, so the header's copies on later pages are not drawn again. In a row of the flow, a layer stack or shape container that only draws — shapes, a drawn picture, a badge's initials — and is all its cell holds is anchored the same way, when it has no margins, stands on one page and paints nothing outside its box. A drawing holding a line, a diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java index 305a261cc..43e223e26 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java @@ -77,7 +77,8 @@ static boolean holdsALine(DocumentNode node) { /** * A layer stack's shapes in the order the page paints them, or {@code null} when it holds - * anything else — a container outline, a picture, text — or a stack in it has a margin. + * anything else — a container outline, a picture, text — or a stack in it has a margin or + * padding. */ static List shapesOf(LayerStackNode stack) { List shapes = new ArrayList<>(); @@ -92,8 +93,9 @@ private static boolean collect(DocumentNode node, List shapes) { if (!(node instanceof LayerStackNode stack) || stack.layers().isEmpty()) { return false; } - DocumentInsets margin = stack.margin(); - if (margin != null && (margin.top() != 0 || margin.right() != 0 || margin.bottom() != 0 || margin.left() != 0)) { + // A margin or padding sets the shapes in from the box the layout keeps for the stack, + // which their own box would then stand for. + if (insets(stack.margin()) || insets(stack.padding())) { return false; } List layers = stack.layers().stream() @@ -133,6 +135,11 @@ static boolean opensWith(List waiting, List shapes return true; } + private static boolean insets(DocumentInsets insets) { + return insets != null + && (insets.top() != 0 || insets.right() != 0 || insets.bottom() != 0 || insets.left() != 0); + } + /** A drawing node's width and height, in points, or {@code null} for any other node. */ private static double[] sizeOf(DocumentNode node) { if (node instanceof ShapeNode shape) { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index e840f731d..98e5a459f 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -29,6 +29,13 @@ * the first paragraph of a cell on that page, clipped as it may be, is still better than none. * Pages are counted within a section, as the layout counts them, so a section ends with * {@link #endSection}.

+ * + *

A drawing that is all a table cell holds is the exception: {@link #anchorInCell} anchors it + * in that cell's paragraph, placed from the paragraph and the cell's text column, so it moves + * with its row; the cell holds it whole, so Word's clipping cuts nothing.

+ * + *

Every shape takes its place in the paint order when it is drawn ({@link #ordered}), not + * when it finds its paragraph.

*/ final class DocxDrawingAnchors { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java index 9ef72e82e..25ff59caa 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java @@ -33,7 +33,8 @@ * page, so it stays on the page it belongs to; within the page it stands where the layout put it, * which is where the text around it stands too as long as the text lands where the page sets it. * A reader who then edits the text moves the text, not the drawing — a drawing is decoration, - * and Word treats a floating shape the same way.

+ * and Word treats a floating shape the same way. A drawing that is all a table cell holds is + * the exception ({@link #drawingInCell}): anchored in that cell, it moves with its row.

* *

A picture a badge holds is drawn the same way, over the badge: written in the flow, as a * paragraph of its own, it stood on its own line above the title beside the badge instead of in diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 3ebb9e1fb..56b4fd06a 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -57,7 +57,7 @@ final class DocxLayoutMetrics { private final int pageCount; // A table's measured cells by name, filled on first use — see cellLineHeightsOf. private final Map> cellLineHeights = new IdentityHashMap<>(); - private final Map> cellBoxes = new IdentityHashMap<>(); + private final Map>> cellBoxes = new IdentityHashMap<>(); // The rows of a table the layout placed, by index, filled on first use — see placedRowsOf. private final Map> placedRows = new IdentityHashMap<>(); // The heights of a table's placed rows, by index, filled on first use — see rowHeightsOf. @@ -192,8 +192,8 @@ private Map cellLineHeightsOf(DocumentNode table) { } /** - * Where the layout first placed one of a table's cells, found by its name as - * {@link #cellLineHeight} finds it: the page and the box, in the page's points, y up. + * Where the layout placed one of a table's cells, found by its name among the table's own + * rows (see {@link #ownRows}): the page and the box, in the page's points, y up. * * @param page the page, counted within the section * @param left the cell's left edge @@ -213,29 +213,32 @@ boolean holds(PlacedFragment fragment) { } /** - * The box the layout first placed one of a table's cells in — the first page it stands on, - * for a header repeated on every page — or {@code null} when it placed none by that name. + * Every box the layout placed one of a table's cells in, in the order it placed them: one + * for a row placed once, one a page for a header repeated on every page; empty when it + * placed none by that name. * * @param table the table node * @param row the cell's logical row * @param column the cell's first column */ - CellBox cellBox(DocumentNode table, int row, int column) { + List cellBoxes(DocumentNode table, int row, int column) { String owner = table.name() == null || table.name().isBlank() ? table.nodeKind() : table.name(); return cellBoxes.computeIfAbsent(table, node -> { - Map byName = new HashMap<>(); - for (PlacedFragment fragment : fragmentsOf(node)) { + Map> byName = new HashMap<>(); + // Only the table's own rows: a table composed in one of its cells, unnamed as it may + // be, emits rows under the owner's path whose cells carry the owner's names. + for (PlacedFragment fragment : ownRows(node)) { if (fragment.payload() instanceof TableRowFragmentPayload payload) { for (TableResolvedCell cell : payload.cells()) { double bottom = fragment.y() + cell.yOffset(); double left = fragment.x() + cell.x(); - byName.putIfAbsent(cell.name(), + byName.computeIfAbsent(cell.name(), name -> new ArrayList<>()).add( new CellBox(fragment.pageIndex(), left, bottom, left + cell.width(), bottom + cell.height())); } } } return byName; - }).get(owner + "__row_" + row + "__cell_" + column); + }).getOrDefault(owner + "__row_" + row + "__cell_" + column, List.of()); } /** diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 0dba42981..0035621f0 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -278,8 +278,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The drawings the composed cells of the table being written paint, not yet anchored, in the // order the layout emitted them; null outside such a table (see anchorComposedDrawing). private List tableDrawings; - // Where the layout first placed the cell of that table being written; null outside one. - private DocxLayoutMetrics.CellBox composedCellBox; + // Where the layout placed the cell of that table being written, first placement first; empty + // outside one. + private List composedCellBoxes = List.of(); // The shapes composed in a cell that anchorComposedDrawing anchored there, so not lost. private final java.util.Set anchoredInCells = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); @@ -625,7 +626,7 @@ private byte[] write(List sections, Path outputFile) throws Exc clipContainer = null; cellDrawing = CellDrawing.NONE; tableDrawings = null; - composedCellBox = null; + composedCellBoxes = List.of(); anchoredInCells.clear(); panelCell = null; moves.clear(); @@ -2304,9 +2305,10 @@ boolean holds(DocxDrawings.Shape shape) { * their labels, the last out of its strip. A drawing that is all its cell holds is placed * from that cell's paragraph instead, held at the drawing's height: the row carries it.

* - *

Only a drawing with no margins, laid out on one page, in a cell holding nothing else yet: - * the cell's text column then starts where the drawing does, and the paragraph's top where - * its top is.

+ *

Only a layer stack or shape container that only draws and holds no line (a rule), with no + * margins, laid out on one page, painting nothing outside its box, in a cell holding nothing + * else yet: the cell's text column then starts where the drawing does, the paragraph's top + * where its top is, and the cell holds all of it.

*/ private DrawingCell drawingCellFor(XWPFTableCell cell, DocumentNode node) { if (Double.isNaN(canvasHeight) @@ -6634,7 +6636,7 @@ private record CellFragment(com.demcha.compose.document.layout.PlacedFragment fr * to start where its shapes do. Anything else is left to the page, as before.

*/ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { - if (tableDrawings == null || tableDrawings.isEmpty() || composedCellBox == null || Double.isNaN(canvasHeight) + if (tableDrawings == null || tableDrawings.isEmpty() || composedCellBoxes.isEmpty() || Double.isNaN(canvasHeight) || !composedInACell(node) || !holdsNothingYet(cell)) { return; } @@ -6647,9 +6649,7 @@ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { // header's copies on later pages, and other cells' drawings, are not this one's — and // from the first of those still waiting: a drawing no cell took before this one is // never passed over and given to the next. - List inTheCell = tableDrawings.stream() - .filter(waiting -> composedCellBox.holds(waiting.fragment())) - .toList(); + List inTheCell = waitingIn(composedCellBoxes.get(0)); if (!DocxCellDrawings.opensWith(inTheCell.stream().map(CellFragment::fragment).toList(), shapes)) { return; } @@ -6681,12 +6681,31 @@ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { if (!drawn.isEmpty()) { anchors.anchorInCell(carrier, drawn, new DocxDrawings.CellOrigin(left, top)); } - anchoredInCells.addAll(shapes); + // A shape whose fragment draws nothing — a path painted only with a gradient — is still + // reported as lost. + for (int i = 0; i < shapes.size(); i++) { + if (!own.get(i).shapes().isEmpty()) { + anchoredInCells.add(shapes.get(i)); + } + } java.util.Set taken = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); taken.addAll(own); + // A header repeated on later pages repeats its cell there in Word, the drawing anchored + // in it with it: the page's copies of the drawing would draw it twice. + for (DocxLayoutMetrics.CellBox repeat : composedCellBoxes.subList(1, composedCellBoxes.size())) { + List copy = waitingIn(repeat); + if (DocxCellDrawings.opensWith(copy.stream().map(CellFragment::fragment).toList(), shapes)) { + taken.addAll(copy.subList(0, shapes.size())); + } + } tableDrawings.removeIf(taken::contains); } + /** The table's drawings still waiting that lie in one of its cells' boxes, in their order. */ + private List waitingIn(DocxLayoutMetrics.CellBox box) { + return tableDrawings.stream().filter(waiting -> box.holds(waiting.fragment())).toList(); + } + /** Whether a cell holds nothing yet: no element, or one paragraph with nothing in it. */ private static boolean holdsNothingYet(XWPFTableCell cell) { List elements = cell.getBodyElements(); @@ -7284,20 +7303,20 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, // A composed cell keeps its node and leaves lines() empty, so reading lines() // exported it as an empty cell. double previous = currentCellWidth; - DocxLayoutMetrics.CellBox outerBox = composedCellBox; + List outerBoxes = composedCellBoxes; currentCellWidth = usableWidthOf(cell, placement); tablesCells.add(cell.getCTTc()); // A table nested in a composed cell has no rows of its own in the layout: its cells // stand in the outer table's cell. - DocxLayoutMetrics.CellBox box = layout.cellBox(node, placement.row(), placement.column()); - if (box != null) { - composedCellBox = box; + List boxes = layout.cellBoxes(node, placement.row(), placement.column()); + if (!boxes.isEmpty()) { + composedCellBoxes = boxes; } try { writeCellBody(cell, source.content()); } finally { currentCellWidth = previous; - composedCellBox = outerBox; + composedCellBoxes = outerBoxes; } return; } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java index 7a5b20782..663e8d113 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.dsl.RowBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; import com.demcha.compose.document.node.RowVerticalAlign; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; @@ -16,12 +17,13 @@ import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.xmlbeans.XmlObject; import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.STRelFromV; import java.util.ArrayList; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; @@ -96,8 +98,10 @@ void aHeadersIconRepeatedOnTheNextPageIsNotTakenByARowThere() throws Exception { assertThat(xmlOfIcon(table, row)).as("row " + row + " holds its own icon") .contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); } - assertThat(pageAnchoredShapes(document)).as("the header's copy on the second page, left to the page") - .isEqualTo(1); + // Word repeats the header row, the icon anchored in it with it, on every page: the + // layout's copy there is not drawn again on the page. + assertThat(table.getRow(0).getCtRow().getTrPr().sizeOfTblHeaderArray()).as("a repeated header").isPositive(); + assertThat(pageAnchoredShapes(document)).as("no second copy of the header's icon").isZero(); } } @@ -132,14 +136,51 @@ void aShapeWaitingForItsPagesParagraphIsPaintedBeforeAnIconAnchoredInACellAfterI .addShape(shape -> shape.size(150, 20).fillColor(DocumentColor.rgb(230, 230, 240))) .add(ICON.node(12)) .addParagraph(p -> p.text("LABEL"))))) { - String body = document.getDocument().xmlText(); - Matcher banner = Pattern.compile("relativeHeight=\"(\\d+)\"[^>]*layoutInCell=\"0\"").matcher(body); - Matcher icon = Pattern.compile("relativeHeight=\"(\\d+)\"[^>]*layoutInCell=\"1\"").matcher(body); + List drawn = anchorsIn(document.getDocument()); + CTAnchor banner = drawn.stream().filter(anchor -> !anchor.getLayoutInCell()).findFirst().orElseThrow(); + CTAnchor icon = drawn.stream().filter(CTAnchor::getLayoutInCell).findFirst().orElseThrow(); + + assertThat(banner.getRelativeHeight()).as("the banner under the icon") + .isLessThan(icon.getRelativeHeight()); + } + } + + @Test + void aTableNestedInACellDoesNotLendItsCellsToTheOuterTable() throws Exception { + // Neither table is named, so their cells carry the same names; the inner table's rows, + // laid out with the outer table's first row, must not stand for the outer rows below it. + DocumentNode inner = new com.demcha.compose.document.dsl.TableBuilder() + .columns(DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("a")) + .rowCells(DocumentTableCell.text("b")) + .rowCells(DocumentTableCell.text("c")) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150)) + .rowCells(DocumentTableCell.text("Nested"), DocumentTableCell.node(inner)) + .rowCells(DocumentTableCell.node(band(ICON, 12)), DocumentTableCell.text("One")) + .rowCells(DocumentTableCell.node(band(BLUE_ICON, 12)), DocumentTableCell.text("Two"))))) { + XWPFTable table = document.getTables().get(0); + + assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("FF0000"); + assertThat(xmlOfIcon(table, 2)).contains("layoutInCell=\"1\"").contains("0000FF"); + assertThat(pageAnchoredShapes(document)).isZero(); + } + } - assertThat(banner.find()).isTrue(); - assertThat(icon.find()).isTrue(); - assertThat(Long.parseLong(banner.group(1))).as("the banner under the icon") - .isLessThan(Long.parseLong(icon.group(1))); + @Test + void aPaddedStackComposedInACellStaysOnThePage() throws Exception { + // Its padding sets the icon in from the box the layout keeps for it, which the icon's own + // box cannot stand for. + DocumentNode padded = new com.demcha.compose.document.node.LayerStackNode("Padded", + List.of(new com.demcha.compose.document.node.LayerStackNode.Layer(ICON.node(12))), + DocumentInsets.of(4), DocumentInsets.zero()); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new RowBuilder().name("Band").verticalAlign(RowVerticalAlign.CENTER) + .weights(20, 280).add(padded).addParagraph(p -> p.text("LABEL")).build()))))) { + assertThat(xmlOfIcon(document.getTables().get(0), 0)).doesNotContain("layoutInCell=\"1\""); + assertThat(pageAnchoredShapes(document)).isEqualTo(1); } } @@ -183,6 +224,40 @@ void aLineAloneInACellOfARowInTheFlowStaysARule() throws Exception { } } + @Test + void aMarkHoldingALineInACellOfARowInTheFlowStaysOnThePage() throws Exception { + // Its line is drawn among the mark's layers, not a rule; the drawing is left as it was. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Above")) + .addRow("Band", row -> row.weights(12, 288).verticalAlign(RowVerticalAlign.CENTER) + .add(new LayerStackBuilder().name("RuledMark") + .layer(ICON.node(12)) + .layer(new LineBuilder().name("Underline").horizontal(12) + .stroke(DocumentStroke.of(DocumentColor.rgb(200, 0, 0), 1)).build()) + .build()) + .addParagraph(p -> p.text("LABEL"))))) { + assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); + assertThat(pageAnchoredShapes(document)).as("the mark and its line, on the page").isEqualTo(2); + } + } + + @Test + void aDrawingPaintingPastItsBoxStaysOnThePage() throws Exception { + // Word clips a drawing anchored in a cell to the cell: one reaching past its own box is + // left to the page whole, not split between the two. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Above")) + .addRow("Band", row -> row.weights(20, 280).verticalAlign(RowVerticalAlign.CENTER) + .add(new LayerStackBuilder().name("Overhanging") + .layer(ICON.node(12)) + .position(BLUE_ICON.node(12), 10, 0, LayerAlign.TOP_LEFT) + .build()) + .addParagraph(p -> p.text("LABEL"))))) { + assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); + assertThat(pageAnchoredShapes(document)).as("both marks, on the page").isEqualTo(2); + } + } + @Test void anIconAloneInACellOfARowInTheFlowIsAnchoredInThatCell() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page @@ -243,33 +318,40 @@ private static XWPFTableCell iconCell(XWPFTableCell band) { private record Anchor(String fromH, double x, String fromV, double y, double height) { } - private static final Pattern ANCHOR = Pattern.compile( - "layoutInCell=\"1\".*?positionH relativeFrom=\"(\\w+)\">(-?\\d+).*?" - + "positionV relativeFrom=\"(\\w+)\">(-?\\d+).*?" - + " anchors(XWPFParagraph paragraph) { List anchors = new ArrayList<>(); - Matcher matcher = ANCHOR.matcher(paragraph.getCTP().xmlText()); - while (matcher.find()) { - anchors.add(new Anchor(matcher.group(1), points(matcher.group(2)), matcher.group(3), - points(matcher.group(4)), points(matcher.group(5)))); + for (CTAnchor anchor : anchorsIn(paragraph.getCTP())) { + if (anchor.getLayoutInCell()) { + anchors.add(new Anchor(anchor.getPositionH().getRelativeFrom().toString(), + points(anchor.getPositionH().getPosOffset()), + anchor.getPositionV().getRelativeFrom().toString(), + points(anchor.getPositionV().getPosOffset()), + points(anchor.getExtent().getCy()))); + } } return anchors; } - private static double points(String emu) { - return Math.round(Long.parseLong(emu) / 12700.0 * 10) / 10.0; + /** Every drawing anchored under an element, in document order. */ + private static List anchorsIn(XmlObject root) { + List anchors = new ArrayList<>(); + for (XmlObject found : root.selectPath("declare namespace wp='" + + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'" + + " .//wp:anchor")) { + anchors.add((CTAnchor) found); + } + return anchors; + } + + private static double points(long emu) { + return Math.round(emu / 12700.0 * 10) / 10.0; } /** How many shapes the document places from the page's edges. */ - private static int pageAnchoredShapes(XWPFDocument document) { - Matcher matcher = Pattern.compile("positionV relativeFrom=\"page\"").matcher(document.getDocument().xmlText()); - int count = 0; - while (matcher.find()) { - count++; - } - return count; + private static long pageAnchoredShapes(XWPFDocument document) { + return anchorsIn(document.getDocument()).stream() + .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE) + .count(); } } From 3548a4324e973909a602eb3dc37c0bc60dc2513f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 08:29:19 +0100 Subject: [PATCH 3/3] fix(docx): anchor a composed drawing in its cell only when it is the cell's only drawing, and drop a header's copies only on later pages --- CHANGELOG.md | 12 +++--- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 21 +++++----- .../semantic/docx/DocxCellDrawings.java | 6 +-- .../semantic/docx/DocxLayoutMetrics.java | 4 +- .../semantic/docx/DocxSemanticBackend.java | 25 +++++++----- .../semantic/docx/DocxCellDrawingTest.java | 39 +++++++++++++++---- 7 files changed, 72 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d507e4523..49a3f17e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,12 @@ follow semantic versioning; release dates are ISO 8601. drawing does. In a row of the flow that is a layer stack or a shape container that only draws — shapes, a drawn picture, a badge's initials — with no margins, on one page, all of it inside its box. In a table's composed cell, whose drawing belongs to the table, it is a layer - stack of shapes only, with no margin or padding: its drawing is the first of the table's - waiting shapes inside that cell, where the layout first placed it, when those are its shapes, - of their kinds and sizes and on one page. Word repeats a repeated header row, the drawing - anchored in it with it, so the layout's copies of that drawing on later pages are not drawn - again. A - drawing holding a line, a lone shape and a composed tile are drawn on the page as before. + stack of shapes only, with no margin or padding and the only drawing the cell holds: its + drawing is the table's shapes waiting inside that cell, where the layout first placed it, + when those are its shapes, of their kinds and sizes and on one page. Word repeats a repeated + header row, the drawing anchored in it with it, so the layout's copies of that drawing on + later pages are not drawn again. A drawing holding a line, a lone shape, a composed tile and + a drawing sharing its table cell with another are drawn on the page as before. Every shape is now painted in the order it was drawn, rather than the order it found a paragraph to anchor it, so one waiting for its page's first paragraph is not painted over a shape anchored in a cell after it. `CobaltRota`'s icons stand beside their labels in Word and diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 756aa9bfc..41ecc785c 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -69,7 +69,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture, followed by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws; in a composed cell, a layer stack of shapes, matched to the table's fragments by kind, size and order), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is reported). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index bf5c1c631..bc49b3bc1 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -577,15 +577,18 @@ tint it was flattened to. Recorded, like the other two. paragraph is held at the drawing's height and carries it, placed from its top and the cell's text column, which is taken to start where the drawing does, so it moves with its row wherever Word sets the rows above it. Its - drawing is the first of the table's drawings still waiting inside that - cell, where the layout first placed it, when those are the stack's own - shapes, each of its kind and size; a stack with a margin or padding is - left to the page. Word repeats a repeated header row with the drawing - anchored in it, so the header's copies on later pages are not drawn again. In a row of the flow, a layer stack or shape container - that only draws — shapes, a drawn picture, a badge's initials — and is all - its cell holds is anchored the same way, when it has no margins, stands on - one page and paints nothing outside its box. A drawing holding a line, a - lone shape and a composed tile stay on the page. A filled or outlined rectangle or rounded rectangle holding text there + drawing is the table's drawings still waiting inside that cell, where the + layout first placed it, when those are the stack's own shapes, each of its + kind and size, and the only drawing the cell holds; a stack with a margin + or padding is left to the page. Word repeats a repeated header row with + the drawing anchored in it, so the header's copies on later pages are not + drawn again. In a row of the flow, a layer stack or shape container that + only draws — shapes, a drawn picture, a badge's initials — and is all its + cell holds is anchored the same way, when it has no margins, stands on one + page and paints nothing outside its box. A drawing holding a line, a lone + shape, a composed tile and a drawing sharing its table cell with another + stay on the page. + A filled or outlined rectangle or rounded rectangle holding text there is written as a panel is, a table of one cell in its fill and outline, its outline's width within the cell and a point for the editor's face, its row held at least its outline's height, with its layers inside, its corners diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java index 43e223e26..c4eb5827b 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java @@ -59,9 +59,9 @@ static Class drawnKind(PlacedFragment fragment) { } /** - * Whether a drawing holds a line anywhere in it. A line alone in a cell, or in a stack of - * one layer, is a rule and written as one; a drawing holding a line is left where it was - * drawn before, rather than told apart from a rule here. + * Whether a drawing holds a line anywhere in it. A line may be a rule, written as one, and a + * drawing holding one is left where it was drawn before rather than told apart from a rule + * here. */ static boolean holdsALine(DocumentNode node) { if (node instanceof LineNode) { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 56b4fd06a..ff03c2c20 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -214,8 +214,8 @@ boolean holds(PlacedFragment fragment) { /** * Every box the layout placed one of a table's cells in, in the order it placed them: one - * for a row placed once, one a page for a header repeated on every page; empty when it - * placed none by that name. + * for a row placed once, one a page for a header repeated on every page, as many as the + * layout placed it otherwise; empty when it placed none by that name. * * @param table the table node * @param row the cell's logical row diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 0035621f0..1faa9d789 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -6627,13 +6627,15 @@ private record CellFragment(com.demcha.compose.document.layout.PlacedFragment fr * {@code CobaltRota}'s band icons, each alone in the first column of its navy strip, stood * 4pt, 9pt and 14pt above their labels, the last out of its strip. Its drawing is the first * of the table's waiting fragments inside the table's cell being written, where the layout - * first placed it, when those are its shapes (see {@link DocxCellDrawings}). The cell's - * paragraph is held at the height of their box and carries them, - * placed from its top and the cell's text column: the row carries them.

+ * first placed it, when those are its shapes and the only drawing the cell holds (see + * {@link DocxCellDrawings}). The cell's paragraph is held at the height of their box and + * carries them, placed from its top and the cell's text column: the row carries them. Word + * repeats a repeated header row with what is anchored in it, so the matching copies in the + * cell's boxes on later pages are dropped.

* - *

Only a layer stack of shapes — no container outline, no picture, no line, which is a - * rule — with no margins, in a cell holding nothing else yet; the cell's text column is taken - * to start where its shapes do. Anything else is left to the page, as before.

+ *

Only a layer stack of shapes — no container outline, no picture, no line — with no + * margin or padding, in a cell holding nothing else yet; the cell's text column is taken to + * start where its shapes do. Anything else is left to the page, as before.

*/ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { if (tableDrawings == null || tableDrawings.isEmpty() || composedCellBoxes.isEmpty() || Double.isNaN(canvasHeight) @@ -6649,8 +6651,11 @@ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { // header's copies on later pages, and other cells' drawings, are not this one's — and // from the first of those still waiting: a drawing no cell took before this one is // never passed over and given to the next. + // The only drawing the table's cell holds: where it holds others, which of them a later + // node paints is not known, and one left to the page would be taken for the next. List inTheCell = waitingIn(composedCellBoxes.get(0)); - if (!DocxCellDrawings.opensWith(inTheCell.stream().map(CellFragment::fragment).toList(), shapes)) { + if (inTheCell.size() != shapes.size() + || !DocxCellDrawings.opensWith(inTheCell.stream().map(CellFragment::fragment).toList(), shapes)) { return; } List own = inTheCell.subList(0, shapes.size()); @@ -6692,10 +6697,12 @@ private void anchorComposedDrawing(XWPFTableCell cell, DocumentNode node) { taken.addAll(own); // A header repeated on later pages repeats its cell there in Word, the drawing anchored // in it with it: the page's copies of the drawing would draw it twice. + int firstPage = composedCellBoxes.get(0).page(); for (DocxLayoutMetrics.CellBox repeat : composedCellBoxes.subList(1, composedCellBoxes.size())) { List copy = waitingIn(repeat); - if (DocxCellDrawings.opensWith(copy.stream().map(CellFragment::fragment).toList(), shapes)) { - taken.addAll(copy.subList(0, shapes.size())); + if (repeat.page() > firstPage && copy.size() == shapes.size() + && DocxCellDrawings.opensWith(copy.stream().map(CellFragment::fragment).toList(), shapes)) { + taken.addAll(copy); } } tableDrawings.removeIf(taken::contains); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java index 663e8d113..3ef1f81f8 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java @@ -61,8 +61,8 @@ void anIconAloneInACellComposedInATableIsAnchoredInThatCell() throws Exception { @Test void iconsOfOneSizeInTwoCellsAreEachAnchoredInTheirOwn() throws Exception { - // The table's drawings are taken in the order its cells are written: the first band's - // icon, the red one, is the first of the two. + // Each band's cell takes the drawing the layout placed inside it: the red icon in the + // first, the blue one in the second. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t .columns(DocumentTableColumn.fixed(300)) .rowCells(DocumentTableCell.node(band(ICON, 12))) @@ -83,15 +83,21 @@ void iconsOfOneSizeInTwoCellsAreEachAnchoredInTheirOwn() throws Exception { @Test void aHeadersIconRepeatedOnTheNextPageIsNotTakenByARowThere() throws Exception { // The layout draws a repeated header's icon again on every page; the export writes the - // header once. On the second page its copy comes first among the table's drawings, and - // matched by kind and size alone the next row took it and passed its own on, row by row. - try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page.addTable(t -> { + // header once. A row takes only what the layout placed inside its own cell, so it never + // takes the header's copy; the copies lie in the header's later boxes and are dropped. + java.util.function.Consumer content = page -> page.addTable(t -> { t.columns(DocumentTableColumn.fixed(300)).repeatHeader(1) .headerCells(DocumentTableCell.node(band(ICON, 12))); for (int i = 0; i < 16; i++) { t.rowCells(DocumentTableCell.node(band(BLUE_ICON, 12))); } - }))) { + }); + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content::accept); + assertThat(session.layoutGraph().totalPages()).as("the table runs onto a second page").isGreaterThan(1); + } + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, content)) { XWPFTable table = document.getTables().get(0); assertThat(xmlOfIcon(table, 0)).as("the header's own icon").contains("FF0000"); for (int row = 1; row < table.getRows().size(); row++) { @@ -123,6 +129,7 @@ void anIconNoCellTookIsNotGivenToTheNextCell() throws Exception { assertThat(xmlOfIcon(table, 0)).doesNotContain("layoutInCell=\"1\""); assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); + assertThat(pageAnchoredShapes(document)).as("the first mark and its line, on the page").isEqualTo(2); } } @@ -168,6 +175,23 @@ void aTableNestedInACellDoesNotLendItsCellsToTheOuterTable() throws Exception { } } + @Test + void aCellHoldingTwoDrawingsLeavesBothOnThePage() throws Exception { + // The first mark has a margin, so it stays on the page; the second, of the same kind and + // size, would otherwise take the first's fragments, the first of those waiting in the cell. + DocumentNode inset = new com.demcha.compose.document.node.LayerStackNode("Inset", + List.of(new com.demcha.compose.document.node.LayerStackNode.Layer(ICON.node(12))), + DocumentInsets.zero(), DocumentInsets.of(2)); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new RowBuilder().name("Pair").verticalAlign(RowVerticalAlign.CENTER) + .weights(16, 12, 272).add(inset).add(ICON.node(12)).addParagraph(p -> p.text("LABEL")) + .build()))))) { + assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); + assertThat(pageAnchoredShapes(document)).as("both marks, on the page").isEqualTo(2); + } + } + @Test void aPaddedStackComposedInACellStaysOnThePage() throws Exception { // Its padding sets the icon in from the box the layout keeps for it, which the icon's own @@ -209,7 +233,7 @@ void aDrawingWithAMarginStaysOnThePage() throws Exception { } @Test - void aLineAloneInACellOfARowInTheFlowStaysARule() throws Exception { + void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Above")) .addRow("Ruled", row -> row.weights(150, 150) @@ -221,6 +245,7 @@ void aLineAloneInACellOfARowInTheFlowStaysARule() throws Exception { String body = document.getDocument().xmlText(); assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\""); + assertThat(pageAnchoredShapes(document)).as("the line, drawn on the page as before").isEqualTo(1); } }