diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a28e0926..cb8e7d721 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,22 +8,37 @@ 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
+ 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 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
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 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
+ 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..040ddcbd1 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -838,11 +838,18 @@ 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, 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 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:
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..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
@@ -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,14 @@
* 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 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
+ * 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 +222,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 +301,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 +313,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 +351,15 @@ 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 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;
+
/** 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 +447,145 @@ 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 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
+ * 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 (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
- return Double.POSITIVE_INFINITY;
+ return risesNoFurther(shape, top, false) ? new Place(paragraph, Double.POSITIVE_INFINITY, top) : null;
}
- 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 (!risesNoFurther(shape, top, true) || 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);
+ }
+ return inTheRow(shape, cell, cellLeft, width, top);
+ }
+
+ /**
+ * 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, 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, 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. 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) {
+ List opening = cell.getBodyElements();
+ if (opening.isEmpty() || opening.get(0) != paragraph || !alignedToTheTop(cell)) {
+ return null;
+ }
+ 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, top);
+ }
+ }
+ 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, top);
+ }
+ 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 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) || borderedAbove(first)) {
+ return null;
+ }
+ return new Place(first, cellLeft + leftMargin(cell), top);
+ }
+
+ /** 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;
+ }
+
+ /** 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. */
@@ -486,20 +607,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..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
@@ -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 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,223 @@ 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 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);
+ 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 - 60, 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();
+ 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)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ List drawings = drawingsIn(markCell.getParagraphs().get(0));
+ if (merged) {
+ 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 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, 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, 110, 290, 100, 110);
+
+ anchors.queue(List.of(dot(120, 96)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).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("");
+ }
+
+ @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);
+ }
+
@Test
void aCellOfARepeatedHeaderRowDoesNotCarryAShape() {
// Word repeats the row on every page, and what is anchored in it with it.