From 5a05a180389db3cfe9df543d1b77a45378f89d9b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 14:58:38 +0100
Subject: [PATCH 1/4] fix(docx): anchor a timeline's dot in its own cell of the
entry's row, and let a dot rise above its line
A drawing in a table cell went into that cell only when the cell held it
across, and a dot set in a column of its own beside its entry's text had no
paragraph to go to, so it stayed on the page. It now goes into the first
paragraph of the cell of the same row that holds it, placed from the row's
top: Word and LibreOffice start each top-aligned cell's first paragraph
there, less its top margin. A drawing may also rise above its paragraph's top
by its own height, 24pt at most, as a dot centred on the first line does;
both editors place a negative offset exactly and draw the shape whole.
---
CHANGELOG.md | 33 ++-
docs/recipes/docx-export.md | 14 +-
.../semantic/docx/DocxDrawingAnchors.java | 246 ++++++++++++++----
.../semantic/docx/DocxDrawingAnchorsTest.java | 198 +++++++++++++-
4 files changed, 416 insertions(+), 75 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a28e0926..76c69deb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,22 +8,35 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX timeline's dots, and drawings centred on a line, move with their text too.** A
+ drawing in a table cell went into that cell only when the cell held it across, and a dot
+ set in a column of its own beside its entry's text — `CharcoalGold`'s timeline, a row of
+ date, gap, dot, gap and text — had no paragraph to go to, so it stayed on the page. It now goes
+ into the first paragraph of the cell of the same row that holds it, placed from the row's top.
+ Measured in Word 16.0.20430 and LibreOffice, both start each cell's first paragraph there,
+ less its top margin, where the cells set their content from the top. A drawing may now also
+ rise above its paragraph's top by its own height, 24pt at most, as a dot centred on the
+ paragraph's first line does; both editors place it there, out of a cell's top too, and draw
+ it whole. Word's editing protocol finds 132 of the 231 shapes beside text that moved moving
+ with it, against 104, and 20 of the 35 documents with drawings passing, against 19. Unedited,
+ no shape stands more than 1.5pt from where it stood in Word; no line of text moves in Word or
+ LibreOffice.
- **A DOCX's drawings move with the text they stand beside.** Most shapes the page paints — a
timeline's dot, an icon by a heading, a skill's bar, a badge — were placed from the page's
edges, so when a reader edited the text above one, the text moved and the shape stayed. A shape
standing beside a paragraph's text is now anchored in that paragraph and placed down from its
- top: the paragraph whose text is nearest, within 48pt, when the shape hangs from it rather than
- rising above it and, in a table cell, when the cell holds it across, its line is set from the
- left and its row is not a repeated header. When the nearest paragraph cannot hold the shape, it
- stays on the page rather than going to one further off. The paragraph's top is where
- Word places from, the space above its first line included and its line where Word stands it,
- measured in Word 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph holding
- text that a paragraph node is written as offers its place yet; beside a table's own cell text or a list's
+ top: the paragraph whose text is nearest, within 48pt, when the shape does not rise above its
+ top and, in a table cell, when the cell holds it across, its line is set from the left and its
+ row is not a repeated header. When the nearest paragraph cannot hold the shape, it stays on the
+ page rather than going to one further off. The paragraph's top is where Word places from, the
+ space above its first line included and its line where Word stands it, measured in Word
+ 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph holding text that a
+ paragraph node is written as offers its place yet; beside a table's own cell text or a list's
items, in the gap between two columns or far from any text, a shape stays on the page. Word's
editing protocol, with a new `drawings-follow-text` scenario that lengthens a paragraph, finds
- 104 of the 228 shapes
- beside text that moved moving with it, against 12, and 19 of the 35 documents with drawings
- passing, against 15. Unedited, no shape stands more than 1.5pt from where it stood in Word.
+ 104 of the 228 shapes beside text that moved moving with it, against 12, and 19 of the 35
+ documents with drawings passing, against 15. Unedited, no shape stands more than 1.5pt from
+ where it stood in Word.
In LibreOffice the same holds but for `OrangeOps`'s header separator, which follows its line
of text where LibreOffice already sets that line 78pt low.
- **Restyling Normal reaches a DOCX's body text when the body is in runs or tables.** Normal is
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 1f5436bee..47a125931 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -838,11 +838,15 @@ it as approximated. A shape standing beside a paragraph's text — a timeline's
entry, an icon by its heading, a skill's bar by its label — is anchored in that paragraph
and placed down from its top, so it moves with the paragraph when a reader edits the text
above it. The paragraph is the one whose text is nearest the shape, within 48pt across
-and down together, when the shape hangs from it rather than rising above it; one in a table
-cell carries the shape only when its cell holds it across, its line is set from the left and
-its row is not a repeated header, and places it from the cell's text column. When the
-nearest paragraph cannot carry the shape, it stays on the page rather than going to a
-paragraph further off, which is not the text it stands by. The paragraph's top is where Word places from:
+and down together, when the shape rises above its top by its own height at most, as a dot
+centred on its first line does. In a table cell, the cell that holds the shape across takes
+it, from its text column: the paragraph's own cell, or another of its row from that cell's
+first paragraph — a timeline's dot in a column of its own beside its entry's text. Both
+editors start each cell's first paragraph at the row's top, less its top margin, where the
+cells set their content from the top; a row whose top is not known that way, a line not set
+from the left and a repeated header row carry none. When the nearest paragraph cannot carry
+the shape, it stays on the page rather than going to a paragraph further off, which is not
+the text it stands by. The paragraph's top is where Word places from:
the space written above its first line included, and the line where Word stands it — an
exact line's baseline four fifths of the way down it, raised by the run's position —
measured in Word 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph
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 2454f4a01..9c6ad1ed4 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
@@ -1,5 +1,6 @@
package com.demcha.compose.document.backend.semantic.docx;
+import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
@@ -29,11 +30,13 @@
* heading, a skill's bar by its label — is anchored in that paragraph and placed down from its
* top ({@link #seat}): Word moves it with the paragraph, wherever an edit above takes it. Placed
* from the page's edges, it stayed where the page had put it while the text moved away from it.
- * The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape hangs from
- * it rather than rising above it and, for a paragraph in a table cell, when its cell holds the
- * shape across; when the nearest cannot hold it, the shape is left to the page rather than given
- * to one further off. The shapes wait for the section's end, since the paragraph a shape stands
- * beside may be written after it.
+ * The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape rises
+ * above its top by the shape's own height at most, as a dot centred on its first line does. In a
+ * table cell, the cell that holds the shape across takes it — the paragraph's own, or another of
+ * its row, as a timeline's dot in a column of its own beside its entry's text — placed from that
+ * cell's first paragraph (see {@link Seat#place}). When the nearest paragraph cannot place the
+ * shape, it is left to the page rather than given to one further off. The shapes wait for the
+ * section's end, since the paragraph a shape stands beside may be written after it.
*
* A shape beside no paragraph is positioned from the page's edges, so any paragraph on its
* page carries it to the right place — but only one on that page: an anchor takes its shape to
@@ -218,13 +221,14 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier inCells = new TreeMap<>();
Map dropped = new TreeMap<>();
takeTheSeats();
- Map> beside = new java.util.LinkedHashMap<>();
+ // By the paragraph each is anchored in, which keeps no equality but its own.
+ Map> beside = new java.util.LinkedHashMap<>();
pending.forEach((page, all) -> {
List shapes = new ArrayList<>();
for (Ordered shape : all) {
- Seat seat = seatBeside(shape.shape());
- if (seat != null) {
- beside.computeIfAbsent(seat, ignored -> new ArrayList<>()).add(shape);
+ Place place = placeBeside(shape.shape());
+ if (place != null) {
+ beside.computeIfAbsent(place.paragraph(), ignored -> new ArrayList<>()).add(new Placed(shape, place));
} else {
shapes.add(shape);
}
@@ -296,7 +300,7 @@ void anchorInCell(XWPFParagraph carrier, List shapes, DocxDrawings.Cell
* cell's row, and the Linux LibreOffice the build checks with set the row's 'Total due' 2pt
* and 4pt up in two invoices.
*/
- private Seat seatBeside(DocxDrawings.Shape shape) {
+ private Place placeBeside(DocxDrawings.Shape shape) {
Seat nearest = null;
double nearestBy = Double.POSITIVE_INFINITY;
for (Seat seat : seats.getOrDefault(shape.page(), List.of())) {
@@ -308,26 +312,27 @@ private Seat seatBeside(DocxDrawings.Shape shape) {
nearestBy = by;
}
}
- // A shape hangs from its paragraph's top, never above it: LibreOffice keeps a shape laid
- // out in a cell from rising out of it, and set one 45pt above its paragraph's top at the
- // head of the next page.
- return nearest != null && shape.top() >= nearest.paragraphTop() - SLACK
- && !Double.isNaN(nearest.column(shape)) ? nearest : null;
+ // A shape rises above its paragraph's top by its own height at most, as a dot centred on
+ // the paragraph's first line does: Word and LibreOffice place it there, out of a cell's
+ // top too, and draw it whole. One rising further stands by other text: LibreOffice set an
+ // 11pt rule 45pt above its paragraph's top at the head of the next page.
+ return nearest == null ? null : nearest.place(shape);
}
/**
* Anchors shapes in the paragraph whose text they stand nearest, placed down from its top,
* in the order they are drawn: they move with the paragraph wherever an edit takes it.
*/
- private void anchorBeside(Seat seat, List shapes) {
- double paragraphTop = seat.paragraphTop();
- XWPFRun run = seat.paragraph().insertNewRun(0);
- for (Ordered ordered : shapes) {
- double column = seat.column(ordered.shape());
- // A shape reaching up to a stroke's width past the paragraph's top is placed at its top:
- // for a skill bar 0.13pt above its paragraph in a cell, Word 16.0.20430 reported no
- // offset at all, but a Top of -999997.
- double top = Math.min(paragraphTop, ordered.shape().top());
+ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
+ XWPFRun run = paragraph.insertNewRun(0);
+ for (Placed placed : shapes) {
+ Ordered ordered = placed.ordered();
+ double column = placed.place().column();
+ // A shape less than a stroke's width above the paragraph's top is placed at its top:
+ // Word 16.0.20430 draws an offset of -0.13pt where it says, but reports it to a reader
+ // as a Top of -999997. A shape higher still keeps its own offset, which Word reports.
+ double rise = placed.place().top() - ordered.shape().top();
+ double top = rise > 0 && rise <= SLACK ? ordered.shape().top() : placed.place().top();
run.getCTR().addNewDrawing().set(Double.isInfinite(column)
? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), top)
: DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(),
@@ -345,6 +350,14 @@ private void anchorBeside(Seat seat, List shapes) {
/** A stroke's width either side a shape may reach past its paragraph's top or its cell, in points. */
private static final double SLACK = 1;
+ /**
+ * The furthest a shape rises above its paragraph's top, in points: as far as a dot or an
+ * icon's disc centred on a line — the corpus's largest disc is 22.6pt — where Word and
+ * LibreOffice were measured placing 6pt and 10pt dots up to 8pt above their paragraphs. A
+ * taller shape — a rail, an accent bar — rising further stands by other text.
+ */
+ private static final double FURTHEST_RISE = 24;
+
/** Word's room for a cell's text either side, where neither the cell nor its table states one. */
private static final double DEFAULT_CELL_MARGIN = 5.4;
@@ -432,39 +445,149 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
}
/**
- * Where Word places a shape across from: infinite for a paragraph of the body, placed
- * from the page's edge; for one in a table cell, the cell's text column, where the page
- * puts it; NaN for a cell that does not hold the shape across — one in the gap between
- * two columns belongs to neither —, for a line not set from the cell's left, and for a
- * repeated header row, which Word repeats on every page with what is anchored in it.
+ * Where Word places a shape beside this paragraph's text from, or {@code null} where it
+ * cannot. The shape may rise above the paragraph's top by its own height at most.
+ *
+ * A paragraph of the body places it across from the page's edge. One in a table cell
+ * places it from the cell's text column; a cell that does not hold the shape across hands
+ * it to the cell of the same row that does, from that cell's first paragraph — a
+ * timeline's dot stands in a column of its own beside its entry's text, in a cell holding
+ * nothing else. None takes it for a line not set from the cell's left, whose indent is
+ * written short of the page's, or in a repeated header row, which Word repeats on every
+ * page with what is anchored in it.
*/
- double column(DocxDrawings.Shape shape) {
+ Place place(DocxDrawings.Shape shape) {
+ double top = paragraphTop();
+ if (!risesNoFurther(shape, top)) {
+ return null;
+ }
if (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
- return Double.POSITIVE_INFINITY;
+ return new Place(paragraph, Double.POSITIVE_INFINITY, top);
}
- CTTcPr cellProperties = cell.getCTTc().getTcPr();
- // A line centred or set right in a cell is written with the editor's slack taken off
- // its indent, so its text's left edge is not where Word's column starts.
- if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph)
- || cellProperties == null || !cellProperties.isSetTcW()
- || cellProperties.getTcW().getType() != STTblWidth.DXA
- || !(cellProperties.getTcW().getW() instanceof Number width)) {
- return Double.NaN;
+ if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph)) {
+ return null;
+ }
+ double width = widthOf(cell);
+ double cellLeft = textLeft() - leftMargin(cell);
+ if (Double.isNaN(width) || Double.isNaN(cellLeft)) {
+ return null;
+ }
+ if (holdsAcross(shape, cellLeft, width)) {
+ return new Place(paragraph, cellLeft + leftMargin(cell), top);
+ }
+ Place inTheRow = inTheRow(shape, cell, cellLeft, width, top);
+ return inTheRow != null && risesNoFurther(shape, inTheRow.top()) ? inTheRow : null;
+ }
+
+ /**
+ * Whether a shape rises above a paragraph's top by no more than its own height, as a dot
+ * centred on the paragraph's first line does, nor more than {@link #FURTHEST_RISE}. NaN
+ * for a top not known is no.
+ */
+ private static boolean risesNoFurther(DocxDrawings.Shape shape, double top) {
+ return shape.top() >= top - Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE));
+ }
+
+ /**
+ * The place in the cell of this paragraph's row that holds a shape across, from that
+ * cell's first paragraph; {@code null} when none does or its top is not known.
+ *
+ * Measured in Word 16.0.20430 and LibreOffice: in a row whose cells set their content
+ * from the top, the first paragraph of each starts at the row's top, less its cell's top
+ * margin; so a shape is placed from a neighbour's first paragraph as from this one's.
+ */
+ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cellLeft, double width,
+ double top) {
+ List opening = cell.getBodyElements();
+ if (opening.isEmpty() || opening.get(0) != paragraph || !alignedToTheTop(cell)) {
+ return null;
+ }
+ double rowTop = top - topMargin(cell);
+ List cells = cell.getTableRow().getTableCells();
+ int at = -1;
+ for (int index = 0; index < cells.size(); index++) {
+ if (cells.get(index).getCTTc() == cell.getCTTc()) {
+ at = index;
+ }
+ }
+ if (at < 0) {
+ return null;
+ }
+ double edge = cellLeft;
+ for (int index = at - 1; index >= 0; index--) {
+ double neighbour = widthOf(cells.get(index));
+ if (Double.isNaN(neighbour)) {
+ return null;
+ }
+ edge -= neighbour;
+ if (holdsAcross(shape, edge, neighbour)) {
+ return openingPlace(cells.get(index), edge, rowTop);
+ }
+ }
+ edge = cellLeft + width;
+ for (int index = at + 1; index < cells.size(); index++) {
+ double neighbour = widthOf(cells.get(index));
+ if (Double.isNaN(neighbour)) {
+ return null;
+ }
+ if (holdsAcross(shape, edge, neighbour)) {
+ return openingPlace(cells.get(index), edge, rowTop);
+ }
+ edge += neighbour;
}
- double margin = leftMargin(cell);
+ return null;
+ }
+
+ /** The place at the start of a cell whose left edge stands where given; {@code null} when unknown. */
+ private static Place openingPlace(XWPFTableCell cell, double cellLeft, double rowTop) {
+ CTTcPr properties = cell.getCTTc().getTcPr();
+ List opening = cell.getBodyElements();
+ if (properties != null && properties.isSetVMerge() || !alignedToTheTop(cell) || opening.isEmpty()
+ || !(opening.get(0) instanceof XWPFParagraph first)) {
+ return null;
+ }
+ return new Place(first, cellLeft + leftMargin(cell), rowTop + topMargin(cell));
+ }
+
+ /** Where the paragraph's text's left edge stands without its indent; NaN when not known. */
+ private double textLeft() {
CTPPr properties = paragraph.getCTP().getPPr();
- double indent = 0;
if (properties != null && properties.isSetInd() && properties.getInd().isSetLeft()) {
- if (!(properties.getInd().getLeft() instanceof Number twips)) {
- return Double.NaN;
- }
- indent = twips.doubleValue() / 20;
+ return properties.getInd().getLeft() instanceof Number twips ? left - twips.doubleValue() / 20
+ : Double.NaN;
}
- double column = left - indent;
- double cellLeft = column - margin;
- boolean holds = shape.x() >= cellLeft - SLACK
- && shape.x() + shape.width() <= cellLeft + width.doubleValue() / 20 + SLACK;
- return holds ? column : Double.NaN;
+ return left;
+ }
+
+ private static boolean holdsAcross(DocxDrawings.Shape shape, double cellLeft, double width) {
+ return shape.x() >= cellLeft - SLACK && shape.x() + shape.width() <= cellLeft + width + SLACK;
+ }
+
+ /** A cell's width as written, in points; NaN where it is not written in twips. */
+ private static double widthOf(XWPFTableCell cell) {
+ CTTcPr properties = cell.getCTTc().getTcPr();
+ return properties != null && properties.isSetTcW() && properties.getTcW().getType() == STTblWidth.DXA
+ && properties.getTcW().getW() instanceof Number width ? width.doubleValue() / 20 : Double.NaN;
+ }
+
+ /** Whether a cell sets its content from its top. */
+ private static boolean alignedToTheTop(XWPFTableCell cell) {
+ CTTcPr properties = cell.getCTTc().getTcPr();
+ return properties == null || !properties.isSetVAlign()
+ || properties.getVAlign().getVal() == org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc.TOP;
+ }
+
+ /** A cell's top margin as written, in points: Word's own is none. */
+ private static double topMargin(XWPFTableCell cell) {
+ CTTcPr cellProperties = cell.getCTTc().getTcPr();
+ if (cellProperties != null && cellProperties.isSetTcMar() && cellProperties.getTcMar().isSetTop()) {
+ return points(cellProperties.getTcMar().getTop(), 0);
+ }
+ CTTblPr table = cell.getTableRow().getTable().getCTTbl().getTblPr();
+ if (table != null && table.isSetTblCellMar() && table.getTblCellMar().isSetTop()) {
+ return points(table.getTblCellMar().getTop(), 0);
+ }
+ return 0;
}
/** Whether a paragraph's lines start at its left indent: not centred, set right or right to left. */
@@ -486,20 +609,35 @@ private static boolean leftAligned(XWPFParagraph paragraph) {
private static double leftMargin(XWPFTableCell cell) {
CTTcPr cellProperties = cell.getCTTc().getTcPr();
if (cellProperties != null && cellProperties.isSetTcMar() && cellProperties.getTcMar().isSetLeft()) {
- return points(cellProperties.getTcMar().getLeft());
+ return points(cellProperties.getTcMar().getLeft(), DEFAULT_CELL_MARGIN);
}
CTTblPr table = cell.getTableRow().getTable().getCTTbl().getTblPr();
if (table != null && table.isSetTblCellMar() && table.getTblCellMar().isSetLeft()) {
- return points(table.getTblCellMar().getLeft());
+ return points(table.getTblCellMar().getLeft(), DEFAULT_CELL_MARGIN);
}
return DEFAULT_CELL_MARGIN;
}
- private static double points(CTTblWidth width) {
- return width.getW() instanceof Number twips ? twips.doubleValue() / 20 : DEFAULT_CELL_MARGIN;
+ private static double points(CTTblWidth width, double otherwise) {
+ return width.getW() instanceof Number twips ? twips.doubleValue() / 20 : otherwise;
}
}
+ /**
+ * Where a shape is anchored and placed from.
+ *
+ * @param paragraph the paragraph it is anchored in
+ * @param column where Word's column starts, from the page's left edge; infinite for a
+ * paragraph of the body, whose shapes are placed from the page's edge
+ * @param top where the paragraph's top stands, from the page's top edge
+ */
+ record Place(XWPFParagraph paragraph, double column, double top) {
+ }
+
+ /** A shape and where it is placed from. */
+ private record Placed(Ordered ordered, Place place) {
+ }
+
private void anchor(XWPFParagraph carrier, List shapes) {
XWPFRun run = carrier.insertNewRun(0);
for (Ordered shape : shapes) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
index f2e815e4e..7fe42bd03 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
@@ -120,9 +120,9 @@ void aShapeOutOfReachOfEveryParagraphStaysWhereThePagePutsIt() {
}
@Test
- void aShapeIsNeverPlacedAboveTheTopOfItsParagraph() {
- // The ring stands 20pt above the only paragraph near it. Placed from that paragraph, it
- // would hang above it, which LibreOffice does not keep in a table cell.
+ void aShapeRisingAboveItsParagraphByMoreThanItsHeightIsNotPlacedFromIt() {
+ // The 6pt ring stands 20pt above the only paragraph near it: it stands by other text.
+ // LibreOffice set a rule placed 45pt above its paragraph at the head of the next page.
XWPFDocument document = new XWPFDocument();
XWPFParagraph first = text(document, "First");
DocxDrawingAnchors anchors = anchors();
@@ -136,6 +136,22 @@ void aShapeIsNeverPlacedAboveTheTopOfItsParagraph() {
.contains("");
}
+ @Test
+ void aDotCentredOnTheFirstLineRisesAboveItsParagraphsTopByItsOffset() {
+ // A 6pt dot 4pt above the paragraph's top, as one centred on a line with no space above:
+ // both editors place it 4pt above, out of a cell's top too, and draw it whole.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph first = text(document, "Date");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, first, true);
+ anchors.seat(0, first, first, 40, 300, 100, 110);
+
+ anchors.queue(List.of(dot(30, 96)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(offsetDown(drawingsIn(first).get(0))).isCloseTo(-4, within(0.01));
+ }
+
@Test
void aShapeReachingAStrokePastItsParagraphsTopIsPlacedAtThatTop() {
XWPFDocument document = new XWPFDocument();
@@ -243,8 +259,9 @@ void aCellStatingNoMarginTakesWordsOwn() {
@Test
void aShapeItsNearestParagraphCannotHoldIsNotGivenToOneFurtherOff() {
- // An icon centred on a note's line stands a little above the note's top; the totals
- // table's last cell above is within reach too, but it is not the text the icon is by.
+ // The 6pt icon stands 8pt above the note nearest it, more than its height: the note does
+ // not take it. The totals table's last cell above is within reach and could, but it is not
+ // the text the icon is by.
XWPFDocument document = new XWPFDocument();
XWPFTable table = document.createTable(1, 1);
XWPFTableCell cell = table.getRow(0).getCell(0);
@@ -258,13 +275,182 @@ void aShapeItsNearestParagraphCannotHoldIsNotGivenToOneFurtherOff() {
anchors.seat(0, total, total, 110, 290, 60, 70);
anchors.seat(0, note, note, 110, 290, 106, 116);
- anchors.queue(List.of(dot(120, 104)));
+ anchors.queue(List.of(dot(120, 98)));
anchors.endSection(0, null, document::createParagraph);
assertThat(drawingsIn(total)).isEmpty();
assertThat(drawingsIn(note)).singleElement().asString().contains("");
}
+ @Test
+ void aDotInAColumnOfItsOwnIsAnchoredInItsCellFromTheRowsTop() {
+ // A timeline's entry: a 10pt column holding the dot and nothing else, beside a 200pt
+ // column holding the text, from 100. The text's cell does not hold the dot; the dot's own
+ // cell does, and its first paragraph starts where the text's does, at the row's top.
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 10);
+ width(textCell, 200);
+ noLeftMargin(dotCell);
+ noLeftMargin(textCell);
+ XWPFParagraph dotParagraph = dotCell.getParagraphs().get(0);
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, entry, false);
+ anchors.seat(0, entry, entry, 100, 300, 60, 70);
+
+ anchors.queue(List.of(dot(92, 62)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).isEmpty();
+ String drawing = drawingsIn(dotParagraph).get(0);
+ assertThat(drawing).contains("layoutInCell=\"1\"").contains("");
+ assertThat(offsetAcross(drawing)).as("from the dot's cell, at 90").isCloseTo(2, within(0.01));
+ assertThat(offsetDown(drawing)).as("from the row's top, the text's paragraph top").isCloseTo(2, within(0.01));
+ }
+
+ @Test
+ void aNeighbourCellsTopMarginMovesItsFirstParagraphDown() {
+ // The text's cell states no top margin, the dot's 3pt: its first paragraph starts 3pt
+ // below the text's, and the dot is placed from there.
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 10);
+ width(textCell, 200);
+ noLeftMargin(dotCell);
+ noLeftMargin(textCell);
+ dotCell.getCTTc().getTcPr().getTcMar().addNewTop().setW(BigInteger.valueOf(60));
+ XWPFParagraph dotParagraph = dotCell.getParagraphs().get(0);
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, entry, false);
+ anchors.seat(0, entry, entry, 100, 300, 60, 70);
+
+ anchors.queue(List.of(dot(92, 66)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(offsetDown(drawingsIn(dotParagraph).get(0))).isCloseTo(66 - 63, within(0.01));
+ }
+
+ @Test
+ void aNeighbourCellIsNotUsedWhereARowsTopIsNotKnown() {
+ // The text is not the first thing in its cell, or the neighbour centres its content:
+ // neither tells where the neighbour's first paragraph starts.
+ for (boolean centred : new boolean[]{false, true}) {
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 10);
+ width(textCell, 200);
+ noLeftMargin(dotCell);
+ noLeftMargin(textCell);
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ if (centred) {
+ dotCell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
+ } else {
+ entry = textCell.addParagraph();
+ }
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ XWPFParagraph body = text(document, "Body");
+ anchors.paragraphOn(0, body, true);
+ anchors.seat(0, entry, entry, 100, 300, 60, 70);
+
+ anchors.queue(List.of(dot(92, 62)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(dotCell.getParagraphs().get(0))).as("centred=" + centred).isEmpty();
+ assertThat(drawingsIn(body)).singleElement().asString().contains("");
+ }
+ }
+
+ @Test
+ void aNeighbourCellToTheRightTakesAShapeTooButNotOneMergedFromAbove() {
+ // The text's 200pt cell from 100, then a 10pt cell at 300 holding a mark.
+ for (boolean merged : new boolean[]{false, true}) {
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell textCell = table.getRow(0).getCell(0);
+ XWPFTableCell markCell = table.getRow(0).getCell(1);
+ width(textCell, 200);
+ width(markCell, 10);
+ noLeftMargin(textCell);
+ noLeftMargin(markCell);
+ if (merged) {
+ markCell.getCTTc().getTcPr().addNewVMerge();
+ }
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, entry, 100, 290, 60, 70);
+
+ anchors.queue(List.of(dot(302, 62)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ List drawings = drawingsIn(markCell.getParagraphs().get(0));
+ if (merged) {
+ assertThat(drawings).as("a cell merged from the row above starts elsewhere").isEmpty();
+ } else {
+ assertThat(offsetAcross(drawings.get(0))).isCloseTo(2, within(0.01));
+ }
+ }
+ }
+
+ @Test
+ void aShapeRisesNoFurtherAboveTheNeighbourCellsParagraphThanAboveItsOwn() {
+ // The dot's cell holds its content 10pt down: a 6pt dot 4pt above the text's top stands
+ // 14pt above that cell's first paragraph, more than its height.
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 10);
+ width(textCell, 200);
+ noLeftMargin(dotCell);
+ noLeftMargin(textCell);
+ dotCell.getCTTc().getTcPr().getTcMar().addNewTop().setW(BigInteger.valueOf(200));
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, entry, 100, 300, 60, 70);
+
+ anchors.queue(List.of(dot(92, 56)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(dotCell.getParagraphs().get(0))).isEmpty();
+ }
+
+ @Test
+ void aTallShapeRisesNoFurtherThanADisc() {
+ // A 40pt bar 30pt above its paragraph's top, within its own height but further than any
+ // dot or disc rises: it stands by other text.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph first = text(document, "First");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, first, true);
+ anchors.seat(0, first, first, 40, 300, 100, 110);
+
+ anchors.queue(List.of(new DocxDrawings.Shape(DocxDrawings.Kind.RECT, 30, 70, 2, 40, Color.BLUE, null,
+ 0, 0, false, 0)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(first)).singleElement().asString().contains("");
+ }
+
+ private static void noLeftMargin(XWPFTableCell cell) {
+ CTTcPr properties = cell.getCTTc().getTcPr();
+ (properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar()).addNewLeft().setW(BigInteger.ZERO);
+ }
+
@Test
void aCellOfARepeatedHeaderRowDoesNotCarryAShape() {
// Word repeats the row on every page, and what is anchored in it with it.
From 758d05ff07d49491a555b6e2b5c30ee36ecaa06d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 15:35:28 +0100
Subject: [PATCH 2/4] test(docx): take the Linux LibreOffice baseline from CI
for dots anchored in their own cell of the row
---
qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index 0854e2111..312f9b034 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -1485,7 +1485,7 @@ cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore
cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse
cv-teal_pulse 0:613c7b77c405 1.08 0.17 manchester,unitedkingdom
cv-teal_pulse 0:4ddba42ce115 0.08 0.14 isabella.moore@email.com
-cv-teal_pulse 0:a8acc3a90a7b 0.68 0.17 +447700900123
+cv-teal_pulse 0:a8acc3a90a7b -1.12 0.17 +447700900123
cv-teal_pulse 0:84308bea4540 0.83 0.16 linkedin
cv-teal_pulse 0:32c1cf49a2fe -0.25 0.10 skills
cv-teal_pulse 0:9836c1fde1ab -0.09 0.08 patientassessment
From a288679446eefcae48bcc074b79373e04c524834 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 16:20:23 +0100
Subject: [PATCH 3/4] fix(docx): keep a drawing in a table cell from rising
above its paragraph, and start a neighbour cell at the row's top
A rule placed 1pt above its paragraph in a contact row's cell moved that row's
text 1.8pt in LibreOffice on Linux, though not in Word or LibreOffice on
Windows. A drawing in a cell rises above its paragraph's top by a stroke's
width at most again; the body keeps the rise by the drawing's height.
A neighbour cell's first paragraph is placed at the paragraph's own top: Word
and LibreOffice give every cell of a row the row's largest top margin, so the
first paragraphs of a row start together. One with a border above it is not
used. Tests cover the paragraph's own cell centred, a neighbour opening with a
table, a cell with no width in twips stopping the walk, and a cell in a
vertical merge leaving the drawing on the page.
---
CHANGELOG.md | 24 ++---
docs/recipes/docx-export.md | 11 ++-
.../semantic/docx/DocxDrawingAnchors.java | 74 ++++++++--------
.../semantic/docx/DocxDrawingAnchorsTest.java | 87 ++++++++++++++-----
4 files changed, 120 insertions(+), 76 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76c69deb6..d276b4e67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,21 +12,23 @@ follow semantic versioning; release dates are ISO 8601.
drawing in a table cell went into that cell only when the cell held it across, and a dot
set in a column of its own beside its entry's text — `CharcoalGold`'s timeline, a row of
date, gap, dot, gap and text — had no paragraph to go to, so it stayed on the page. It now goes
- into the first paragraph of the cell of the same row that holds it, placed from the row's top.
- Measured in Word 16.0.20430 and LibreOffice, both start each cell's first paragraph there,
- less its top margin, where the cells set their content from the top. A drawing may now also
- rise above its paragraph's top by its own height, 24pt at most, as a dot centred on the
- paragraph's first line does; both editors place it there, out of a cell's top too, and draw
- it whole. Word's editing protocol finds 132 of the 231 shapes beside text that moved moving
- with it, against 104, and 20 of the 35 documents with drawings passing, against 19. Unedited,
- no shape stands more than 1.5pt from where it stood in Word; no line of text moves in Word or
- LibreOffice.
+ into the first paragraph of the cell of the same row that holds it, placed from the row's top:
+ measured in Word 16.0.20430 and LibreOffice, both start each cell's first paragraph there
+ where the cells set their content from the top, every cell of a row taking the row's largest
+ top margin. A drawing in the body may now also rise above its paragraph's top by its own height,
+ 24pt at most, as a dot centred on the paragraph's first line does; both editors place it
+ there and draw it whole. In a table cell it still rises by a stroke's width at most: a rule
+ placed 1pt above its paragraph in a contact row's cell moved that row's text 1.8pt in
+ LibreOffice on Linux. Word's editing protocol finds __FOLLOWED__ shapes beside text that
+ moved moving with it, against 104 of 228, and __PASSING__ of the 35 documents with drawings
+ passing, against 19. Unedited, no shape stands more than __WORDMAX__pt from where it stood in
+ Word, and no line of text moves in Word or LibreOffice on Windows.
- **A DOCX's drawings move with the text they stand beside.** Most shapes the page paints — a
timeline's dot, an icon by a heading, a skill's bar, a badge — were placed from the page's
edges, so when a reader edited the text above one, the text moved and the shape stayed. A shape
standing beside a paragraph's text is now anchored in that paragraph and placed down from its
- top: the paragraph whose text is nearest, within 48pt, when the shape does not rise above its
- top and, in a table cell, when the cell holds it across, its line is set from the left and its
+ top: the paragraph whose text is nearest, within 48pt, when the shape rises above its top by a
+ stroke's width at most (the entry above widens this) and, in a table cell, when the cell holds it across, its line is set from the left and its
row is not a repeated header. When the nearest paragraph cannot hold the shape, it stays on the
page rather than going to one further off. The paragraph's top is where Word places from, the
space above its first line included and its line where Word stands it, measured in Word
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 47a125931..040ddcbd1 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -838,12 +838,15 @@ it as approximated. A shape standing beside a paragraph's text — a timeline's
entry, an icon by its heading, a skill's bar by its label — is anchored in that paragraph
and placed down from its top, so it moves with the paragraph when a reader edits the text
above it. The paragraph is the one whose text is nearest the shape, within 48pt across
-and down together, when the shape rises above its top by its own height at most, as a dot
-centred on its first line does. In a table cell, the cell that holds the shape across takes
+and down together, when the shape rises above its top by its own height at most, 24pt at
+most, as a dot centred on its first line does — in a table cell by a stroke's width at most:
+a rule placed 1pt above its paragraph in a cell moved its row's text in LibreOffice on Linux.
+In a table cell, the cell that holds the shape across takes
it, from its text column: the paragraph's own cell, or another of its row from that cell's
first paragraph — a timeline's dot in a column of its own beside its entry's text. Both
-editors start each cell's first paragraph at the row's top, less its top margin, where the
-cells set their content from the top; a row whose top is not known that way, a line not set
+editors start each cell's first paragraph at the row's top where the cells set their content
+from the top, and give every cell of a row the row's largest top margin, so the other cell's
+first paragraph starts where the text's does; one with a border above it is not used; a row whose top is not known that way, a line not set
from the left and a repeated header row carry none. When the nearest paragraph cannot carry
the shape, it stays on the page rather than going to a paragraph further off, which is not
the text it stands by. The paragraph's top is where Word places from:
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 9c6ad1ed4..00f580759 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
@@ -31,7 +31,8 @@
* top ({@link #seat}): Word moves it with the paragraph, wherever an edit above takes it. Placed
* from the page's edges, it stayed where the page had put it while the text moved away from it.
* The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape rises
- * above its top by the shape's own height at most, as a dot centred on its first line does. In a
+ * above its top by the shape's own height at most, as a dot centred on its first line does — in a
+ * table cell by a stroke's width at most. In a
* table cell, the cell that holds the shape across takes it — the paragraph's own, or another of
* its row, as a timeline's dot in a column of its own beside its entry's text — placed from that
* cell's first paragraph (see {@link Seat#place}). When the nearest paragraph cannot place the
@@ -351,10 +352,11 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
private static final double SLACK = 1;
/**
- * The furthest a shape rises above its paragraph's top, in points: as far as a dot or an
- * icon's disc centred on a line — the corpus's largest disc is 22.6pt — where Word and
- * LibreOffice were measured placing 6pt and 10pt dots up to 8pt above their paragraphs. A
- * taller shape — a rail, an accent bar — rising further stands by other text.
+ * The furthest a shape rises above its paragraph's top in the body, in points: as far as a
+ * dot or an icon's disc centred on a line of text does — NavySidebar's 22.6pt badge is the
+ * corpus's largest — where Word and LibreOffice were measured placing 6pt and 10pt dots up
+ * to 8pt above their paragraphs. A taller shape — a rail, an accent bar — rising further
+ * stands by other text.
*/
private static final double FURTHEST_RISE = 24;
@@ -446,7 +448,8 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
/**
* Where Word places a shape beside this paragraph's text from, or {@code null} where it
- * cannot. The shape may rise above the paragraph's top by its own height at most.
+ * cannot. The shape may rise above the paragraph's top as {@code risesNoFurther} allows:
+ * by its own height in the body, by a stroke's width in a table cell.
*
* A paragraph of the body places it across from the page's edge. One in a table cell
* places it from the cell's text column; a cell that does not hold the shape across hands
@@ -458,13 +461,10 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
*/
Place place(DocxDrawings.Shape shape) {
double top = paragraphTop();
- if (!risesNoFurther(shape, top)) {
- return null;
- }
if (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
- return new Place(paragraph, Double.POSITIVE_INFINITY, top);
+ return risesNoFurther(shape, top, false) ? new Place(paragraph, Double.POSITIVE_INFINITY, top) : null;
}
- if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph)) {
+ if (!risesNoFurther(shape, top, true) || cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph)) {
return null;
}
double width = widthOf(cell);
@@ -475,26 +475,32 @@ Place place(DocxDrawings.Shape shape) {
if (holdsAcross(shape, cellLeft, width)) {
return new Place(paragraph, cellLeft + leftMargin(cell), top);
}
- Place inTheRow = inTheRow(shape, cell, cellLeft, width, top);
- return inTheRow != null && risesNoFurther(shape, inTheRow.top()) ? inTheRow : null;
+ return inTheRow(shape, cell, cellLeft, width, top);
}
/**
- * Whether a shape rises above a paragraph's top by no more than its own height, as a dot
- * centred on the paragraph's first line does, nor more than {@link #FURTHEST_RISE}. NaN
- * for a top not known is no.
+ * Whether a shape rises above a paragraph's top no further than it may: in the body, by
+ * its own height, as a dot centred on the paragraph's first line does, and
+ * {@link #FURTHEST_RISE} at most; in a table cell, by a stroke's width. A 20.5pt rule
+ * placed 1pt above its paragraph in a cell of a contact row moved that row's text 1.8pt up
+ * in the Linux LibreOffice the build checks with, though not in Word or LibreOffice on
+ * Windows. NaN for a top not known is no.
*/
- private static boolean risesNoFurther(DocxDrawings.Shape shape, double top) {
- return shape.top() >= top - Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE));
+ private static boolean risesNoFurther(DocxDrawings.Shape shape, double top, boolean inCell) {
+ double rise = inCell ? SLACK : Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE));
+ return shape.top() >= top - rise;
}
/**
* The place in the cell of this paragraph's row that holds a shape across, from that
- * cell's first paragraph; {@code null} when none does or its top is not known.
+ * cell's first paragraph; {@code null} when none does, its top is not known, or a cell
+ * crossed on the way has no width in twips — the walk stops there rather than guess.
*
*
Measured in Word 16.0.20430 and LibreOffice: in a row whose cells set their content
- * from the top, the first paragraph of each starts at the row's top, less its cell's top
- * margin; so a shape is placed from a neighbour's first paragraph as from this one's.
+ * from the top, the first paragraph of each starts at the row's top. Both give every cell
+ * of a row the row's largest top margin (see {@code DocxSemanticBackend.evenTheRowsMargins}),
+ * so a neighbour's first paragraph starts where this one does, whatever margin it states.
+ * One with a border above it is not taken: its border stands between its top and its line.
*/
private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cellLeft, double width,
double top) {
@@ -502,7 +508,6 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell
if (opening.isEmpty() || opening.get(0) != paragraph || !alignedToTheTop(cell)) {
return null;
}
- double rowTop = top - topMargin(cell);
List cells = cell.getTableRow().getTableCells();
int at = -1;
for (int index = 0; index < cells.size(); index++) {
@@ -521,7 +526,7 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell
}
edge -= neighbour;
if (holdsAcross(shape, edge, neighbour)) {
- return openingPlace(cells.get(index), edge, rowTop);
+ return openingPlace(cells.get(index), edge, top);
}
}
edge = cellLeft + width;
@@ -531,7 +536,7 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell
return null;
}
if (holdsAcross(shape, edge, neighbour)) {
- return openingPlace(cells.get(index), edge, rowTop);
+ return openingPlace(cells.get(index), edge, top);
}
edge += neighbour;
}
@@ -539,14 +544,14 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell
}
/** The place at the start of a cell whose left edge stands where given; {@code null} when unknown. */
- private static Place openingPlace(XWPFTableCell cell, double cellLeft, double rowTop) {
+ private static Place openingPlace(XWPFTableCell cell, double cellLeft, double top) {
CTTcPr properties = cell.getCTTc().getTcPr();
List opening = cell.getBodyElements();
if (properties != null && properties.isSetVMerge() || !alignedToTheTop(cell) || opening.isEmpty()
- || !(opening.get(0) instanceof XWPFParagraph first)) {
+ || !(opening.get(0) instanceof XWPFParagraph first) || borderedAbove(first)) {
return null;
}
- return new Place(first, cellLeft + leftMargin(cell), rowTop + topMargin(cell));
+ return new Place(first, cellLeft + leftMargin(cell), top);
}
/** Where the paragraph's text's left edge stands without its indent; NaN when not known. */
@@ -577,17 +582,10 @@ private static boolean alignedToTheTop(XWPFTableCell cell) {
|| properties.getVAlign().getVal() == org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc.TOP;
}
- /** A cell's top margin as written, in points: Word's own is none. */
- private static double topMargin(XWPFTableCell cell) {
- CTTcPr cellProperties = cell.getCTTc().getTcPr();
- if (cellProperties != null && cellProperties.isSetTcMar() && cellProperties.getTcMar().isSetTop()) {
- return points(cellProperties.getTcMar().getTop(), 0);
- }
- CTTblPr table = cell.getTableRow().getTable().getCTTbl().getTblPr();
- if (table != null && table.isSetTblCellMar() && table.getTblCellMar().isSetTop()) {
- return points(table.getTblCellMar().getTop(), 0);
- }
- return 0;
+ /** Whether a paragraph has a border above it. */
+ private static boolean borderedAbove(XWPFParagraph paragraph) {
+ CTPPr properties = paragraph.getCTP().getPPr();
+ return properties != null && properties.isSetPBdr() && properties.getPBdr().isSetTop();
}
/** Whether a paragraph's lines start at its left indent: not centred, set right or right to left. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
index 7fe42bd03..f64ba8900 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
@@ -139,7 +139,7 @@ void aShapeRisingAboveItsParagraphByMoreThanItsHeightIsNotPlacedFromIt() {
@Test
void aDotCentredOnTheFirstLineRisesAboveItsParagraphsTopByItsOffset() {
// A 6pt dot 4pt above the paragraph's top, as one centred on a line with no space above:
- // both editors place it 4pt above, out of a cell's top too, and draw it whole.
+ // both editors place it 4pt above and draw it whole.
XWPFDocument document = new XWPFDocument();
XWPFParagraph first = text(document, "Date");
DocxDrawingAnchors anchors = anchors();
@@ -313,9 +313,9 @@ void aDotInAColumnOfItsOwnIsAnchoredInItsCellFromTheRowsTop() {
}
@Test
- void aNeighbourCellsTopMarginMovesItsFirstParagraphDown() {
- // The text's cell states no top margin, the dot's 3pt: its first paragraph starts 3pt
- // below the text's, and the dot is placed from there.
+ void aNeighbourCellsFirstParagraphStartsWhereTheParagraphDoesWhateverItsMargin() {
+ // The dot's cell states a 3pt top margin, the text's none: Word and LibreOffice give every
+ // cell of a row its largest, so both first paragraphs start together.
XWPFDocument document = new XWPFDocument();
XWPFTable table = document.createTable(1, 2);
XWPFTableCell dotCell = table.getRow(0).getCell(0);
@@ -335,7 +335,7 @@ void aNeighbourCellsTopMarginMovesItsFirstParagraphDown() {
anchors.queue(List.of(dot(92, 66)));
anchors.endSection(0, null, document::createParagraph);
- assertThat(offsetDown(drawingsIn(dotParagraph).get(0))).isCloseTo(66 - 63, within(0.01));
+ assertThat(offsetDown(drawingsIn(dotParagraph).get(0))).isCloseTo(66 - 60, within(0.01));
}
@Test
@@ -389,7 +389,8 @@ void aNeighbourCellToTheRightTakesAShapeTooButNotOneMergedFromAbove() {
XWPFParagraph entry = textCell.getParagraphs().get(0);
entry.createRun().setText("Senior Engineer");
DocxDrawingAnchors anchors = anchors();
- anchors.paragraphOn(0, text(document, "Body"), true);
+ XWPFParagraph body = text(document, "Body");
+ anchors.paragraphOn(0, body, true);
anchors.seat(0, entry, entry, 100, 290, 60, 70);
anchors.queue(List.of(dot(302, 62)));
@@ -397,36 +398,36 @@ void aNeighbourCellToTheRightTakesAShapeTooButNotOneMergedFromAbove() {
List drawings = drawingsIn(markCell.getParagraphs().get(0));
if (merged) {
- assertThat(drawings).as("a cell merged from the row above starts elsewhere").isEmpty();
+ assertThat(drawings).as("a cell in a vertical merge may start elsewhere").isEmpty();
+ assertThat(drawingsIn(body)).singleElement().asString()
+ .contains("");
} else {
assertThat(offsetAcross(drawings.get(0))).isCloseTo(2, within(0.01));
+ assertThat(offsetDown(drawings.get(0))).isCloseTo(2, within(0.01));
}
}
}
@Test
- void aShapeRisesNoFurtherAboveTheNeighbourCellsParagraphThanAboveItsOwn() {
- // The dot's cell holds its content 10pt down: a 6pt dot 4pt above the text's top stands
- // 14pt above that cell's first paragraph, more than its height.
+ void inACellAShapeRisesAboveItsParagraphByAStrokeAtMost() {
+ // The same 6pt dot 4pt above its paragraph that the body takes: in a cell it stays on
+ // the page. LibreOffice on Linux moved a contact row's text for a rule placed 1pt above
+ // its paragraph in a cell.
XWPFDocument document = new XWPFDocument();
- XWPFTable table = document.createTable(1, 2);
- XWPFTableCell dotCell = table.getRow(0).getCell(0);
- XWPFTableCell textCell = table.getRow(0).getCell(1);
- width(dotCell, 10);
- width(textCell, 200);
- noLeftMargin(dotCell);
- noLeftMargin(textCell);
- dotCell.getCTTc().getTcPr().getTcMar().addNewTop().setW(BigInteger.valueOf(200));
- XWPFParagraph entry = textCell.getParagraphs().get(0);
- entry.createRun().setText("Senior Engineer");
+ XWPFTable table = document.createTable(1, 1);
+ XWPFTableCell cell = table.getRow(0).getCell(0);
+ width(cell, 200);
+ noLeftMargin(cell);
+ XWPFParagraph entry = cell.getParagraphs().get(0);
+ entry.createRun().setText("Date");
DocxDrawingAnchors anchors = anchors();
anchors.paragraphOn(0, text(document, "Body"), true);
- anchors.seat(0, entry, entry, 100, 300, 60, 70);
+ anchors.seat(0, entry, entry, 110, 290, 100, 110);
- anchors.queue(List.of(dot(92, 56)));
+ anchors.queue(List.of(dot(120, 96)));
anchors.endSection(0, null, document::createParagraph);
- assertThat(drawingsIn(dotCell.getParagraphs().get(0))).isEmpty();
+ assertThat(drawingsIn(entry)).isEmpty();
}
@Test
@@ -446,6 +447,46 @@ void aTallShapeRisesNoFurtherThanADisc() {
assertThat(drawingsIn(first)).singleElement().asString().contains("");
}
+ @Test
+ void noNeighbourIsUsedWhereTheRowCannotBeWalked() {
+ // The text's own cell centres its content; the dot's cell opens with a table, or its first
+ // paragraph has a border above it; a cell between them has no width in twips. Each leaves
+ // the dot on the page.
+ for (String why : List.of("own cell centred", "neighbour opens with a table", "width unknown",
+ "neighbour bordered above")) {
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 3);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell between = table.getRow(0).getCell(1);
+ XWPFTableCell textCell = table.getRow(0).getCell(2);
+ width(dotCell, 10);
+ width(between, 10);
+ width(textCell, 200);
+ noLeftMargin(dotCell);
+ noLeftMargin(between);
+ noLeftMargin(textCell);
+ switch (why) {
+ case "own cell centred" -> textCell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER);
+ case "neighbour bordered above" -> dotCell.getParagraphs().get(0).getCTP().addNewPPr().addNewPBdr()
+ .addNewTop().setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder.SINGLE);
+ case "neighbour opens with a table" -> dotCell.insertNewTbl(dotCell.getParagraphs().get(0).getCTP().newCursor());
+ default -> between.getCTTc().getTcPr().getTcW().setType(STTblWidth.PCT);
+ }
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Senior Engineer");
+ DocxDrawingAnchors anchors = anchors();
+ XWPFParagraph body = text(document, "Body");
+ anchors.paragraphOn(0, body, true);
+ anchors.seat(0, entry, entry, 100, 300, 60, 70);
+
+ anchors.queue(List.of(dot(82, 62)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(body)).as(why).singleElement().asString()
+ .contains("");
+ }
+ }
+
private static void noLeftMargin(XWPFTableCell cell) {
CTTcPr properties = cell.getCTTc().getTcPr();
(properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar()).addNewLeft().setW(BigInteger.ZERO);
From 4aa66b9f40270af1c53f199ea8c7bf62570b53be Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 16:50:53 +0100
Subject: [PATCH 4/4] test(docx): restore the Linux LibreOffice baseline, and
record the protocol's count for dots in their own cell
---
CHANGELOG.md | 8 ++++----
.../resources/docx-fidelity/libreoffice-linux-lines.tsv | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d276b4e67..cb8e7d721 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,10 +19,10 @@ follow semantic versioning; release dates are ISO 8601.
24pt at most, as a dot centred on the paragraph's first line does; both editors place it
there and draw it whole. In a table cell it still rises by a stroke's width at most: a rule
placed 1pt above its paragraph in a contact row's cell moved that row's text 1.8pt in
- LibreOffice on Linux. Word's editing protocol finds __FOLLOWED__ shapes beside text that
- moved moving with it, against 104 of 228, and __PASSING__ of the 35 documents with drawings
- passing, against 19. Unedited, no shape stands more than __WORDMAX__pt from where it stood in
- Word, and no line of text moves in Word or LibreOffice on Windows.
+ LibreOffice on Linux. Word's editing protocol finds 132 of the 231 shapes beside text
+ that moved moving with it, against 104 of 228, and 20 of the 35 documents with drawings
+ passing, against 19. Unedited, no shape stands more than 1.5pt from where it stood in Word,
+ and no line of text moves in Word or in LibreOffice on Windows or Linux.
- **A DOCX's drawings move with the text they stand beside.** Most shapes the page paints — a
timeline's dot, an icon by a heading, a skill's bar, a badge — were placed from the page's
edges, so when a reader edited the text above one, the text moved and the shape stayed. A shape
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index 312f9b034..0854e2111 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -1485,7 +1485,7 @@ cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore
cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse
cv-teal_pulse 0:613c7b77c405 1.08 0.17 manchester,unitedkingdom
cv-teal_pulse 0:4ddba42ce115 0.08 0.14 isabella.moore@email.com
-cv-teal_pulse 0:a8acc3a90a7b -1.12 0.17 +447700900123
+cv-teal_pulse 0:a8acc3a90a7b 0.68 0.17 +447700900123
cv-teal_pulse 0:84308bea4540 0.83 0.16 linkedin
cv-teal_pulse 0:32c1cf49a2fe -0.25 0.10 skills
cv-teal_pulse 0:9836c1fde1ab -0.09 0.08 patientassessment