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.