diff --git a/CHANGELOG.md b/CHANGELOG.md
index 558f33d7e..49a3f17e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,26 @@ 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, 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
+ 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
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..41ecc785c 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, 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 |
| 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..bc49b3bc1 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -572,7 +572,23 @@ 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 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
new file mode 100644
index 000000000..c4eb5827b
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawings.java
@@ -0,0 +1,162 @@
+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 extends DocumentNode> 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 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) {
+ 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 or
+ * padding.
+ */
+ 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;
+ }
+ // 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()
+ .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;
+ }
+
+ 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) {
+ 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..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,11 +29,18 @@
* 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 {
- // 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 +62,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 +125,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 +179,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 (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 (DocxDrawings.Shape shape : shapes) {
- run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape, ids.getAsLong(), order++));
+ 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..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
@@ -280,6 +281,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 +325,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..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
@@ -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,56 @@ private Map cellLineHeightsOf(DocumentNode table) {
});
}
+ /**
+ * 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
+ * @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;
+ }
+ }
+
+ /**
+ * 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, 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
+ * @param column the cell's first 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<>();
+ // 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.computeIfAbsent(cell.name(), name -> new ArrayList<>()).add(
+ new CellBox(fragment.pageIndex(), left, bottom, left + cell.width(), bottom + cell.height()));
+ }
+ }
+ }
+ return byName;
+ }).getOrDefault(owner + "__row_" + row + "__cell_" + column, List.of());
+ }
+
/**
* 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..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
@@ -267,9 +267,23 @@ 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 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<>());
// The paragraph a badge being drawn holds, as w:p markup (see textBadgeParagraph); null
// otherwise.
private String badgeText;
@@ -611,6 +625,9 @@ private byte[] write(List sections, Path outputFile) throws Exc
currentPage = 0;
clipContainer = null;
cellDrawing = CellDrawing.NONE;
+ tableDrawings = null;
+ composedCellBoxes = List.of();
+ anchoredInCells.clear();
panelCell = null;
moves.clear();
writingInAStandIn.clear();
@@ -2136,12 +2153,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 +2180,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 +2261,105 @@ 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 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)
+ || !(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 +6217,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 +6483,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 +6509,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 +6523,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 +6580,145 @@ 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 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 — 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)
+ || !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.
+ // 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 (inTheCell.size() != shapes.size()
+ || !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));
+ }
+ // 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.
+ int firstPage = composedCellBoxes.get(0).page();
+ for (DocxLayoutMetrics.CellBox repeat : composedCellBoxes.subList(1, composedCellBoxes.size())) {
+ List copy = waitingIn(repeat);
+ if (repeat.page() > firstPage && copy.size() == shapes.size()
+ && DocxCellDrawings.opensWith(copy.stream().map(CellFragment::fragment).toList(), shapes)) {
+ taken.addAll(copy);
+ }
+ }
+ 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();
+ 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 +7310,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;
+ 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.
+ List boxes = layout.cellBoxes(node, placement.row(), placement.column());
+ if (!boxes.isEmpty()) {
+ composedCellBoxes = boxes;
+ }
try {
writeCellBody(cell, source.content());
} finally {
currentCellWidth = previous;
+ composedCellBoxes = outerBoxes;
}
return;
}
@@ -8725,9 +8992,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..3ef1f81f8
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java
@@ -0,0 +1,382 @@
+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.LayerAlign;
+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.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 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 {
+ // 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)))
+ .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. 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++) {
+ assertThat(xmlOfIcon(table, row)).as("row " + row + " holds its own icon")
+ .contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000");
+ }
+ // 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();
+ }
+ }
+
+ @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");
+ assertThat(pageAnchoredShapes(document)).as("the first mark and its line, on the page").isEqualTo(2);
+ }
+ }
+
+ @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"))))) {
+ 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();
+ }
+ }
+
+ @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
+ // 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);
+ }
+ }
+
+ /** 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 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)
+ .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\"");
+ assertThat(pageAnchoredShapes(document)).as("the line, drawn on the page as before").isEqualTo(1);
+ }
+ }
+
+ @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
+ .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) {
+ }
+
+ /** The drawings anchored in a paragraph's cell, to a tenth of a point. */
+ private static List anchors(XWPFParagraph paragraph) {
+ List anchors = new ArrayList<>();
+ 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;
+ }
+
+ /** 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 long pageAnchoredShapes(XWPFDocument document) {
+ return anchorsIn(document.getDocument()).stream()
+ .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE)
+ .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");
}
}