diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb0dc5dd..bfe8302e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export anchors a drawing in a paragraph written by any writer of text — a list's item, + the one line two halves of a band share — so a card whose only text is written so moves its + drawings with it when the text above is edited.** + - A paragraph was offered to the drawings standing beside it only while a paragraph node was + being written, so a block whose text another writer writes had no paragraph to offer: a card + whose only text is a list's items, and a CV entry's head band written as one Word line. Their + drawings went to the text nearest them — in the body, the paragraph above the card, whose top + does not move when that paragraph grows a line and the card does. + - Measured in Word 16.0.20527 and LibreOffice 24.2, with a paragraph above lengthened by one + line: the card's own text moved 9.72pt in Word and 19.40pt in LibreOffice, and the drawing + moved 0.00 in both. Carried by the item or by the shared line, it moves with them — 9.70 and + 19.35pt — and stands on the card's corner where the page draws it, 0.09pt from it in Word + against 13.76pt before. + - Each item of a list is offered with its own text's box, so a drawing standing beside the third + item is anchored in that item — where the export matched the layout's items to the list's one + by one, which the items' line gap and their own seating need as well; a list with an item split + across a page is laid out as more pieces of text than it has items, and offers none. A band's + shared line is offered as the half that starts it, from its text's own start and reaching + across to the end of the other's box: both halves are written in that one paragraph, and that + half's text begins where the line does. Its text's start is the inked one, not the edge of the + box the half is set in, because a drawing anchored in a cell is placed from the cell's text + column and that column is read from the place's left edge less the indent written on the + paragraph: stated as the box, the column came out the band's width to the left, and a card + carried its drawing 292pt across its cell where the page draws it at 150. The line's reach + across is the whole line's, so that a drawing beside the dates at its right end, or in the gap + between the two texts, stands beside the line. A line the layout starts left of its cell's text + column offers no place at all — a band pulled out by a margin below zero, into the cell's + padding or past it: Word draws no text past a cell's left edge, so the indent written for such + a line is held at nought and its text does not start where the layout puts it. + - A table's own cell text offers no place yet, and a drawing whose block's text is written only + so keeps the nearest-text rule: a table's cells state no width of their own — their table's + grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which + cannot be read without that width; a text cell is set to the middle by default besides, and such a + cell carries no drawing. - **A DOCX export anchors what a table's composed cell draws in that cell, so it moves with the cell's row when the text above is edited.** - Such a drawing has no node of its own — a composed cell's content is laid out under its diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f5c74a5ba..a16f66eb5 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -47,7 +47,16 @@ nodes are a different case: `ShapeNode`, `LineNode`, `EllipseNode`, `ShapeNode` is written as a rule; any other rectangle, rounded rectangle, ellipse, line, polygon or path is drawn from the fragments the layout placed it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`, -polygons and paths as custom geometry). Inside a filled panel a shape stands in +polygons and paths as custom geometry). Three writers offer a paragraph to be +anchored in: a paragraph node's own, an item of a list the layout's items are +matched to, and the one line two halves of a band share. A table's own cell text +offers none — its cell states no width for a shape to be placed from — so a +shape whose block's text is written only so keeps to the text nearest it. A +shared line is offered from its text's own start, which its indent is written +from, and reaches across to the end of the box its other half is set in; one +the layout starts left of its cell's text column offers none, its indent being +held at nought there. +Inside a filled panel a shape stands in front of the text unless it frames text; anchored in a paragraph of the panel's cell, it is placed from the cell's text column, as Word lays a shape anchored in a table cell out inside the cell. A filled or outlined diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 4dfa29e8d..f9ace63fb 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1517,10 +1517,32 @@ The paragraph's top is where Word places from: the space written above its first 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; a paragraph with a border above its line, contextual spacing or space reckoned in -lines carries none. Only the first paragraph holding text that a paragraph node is written as -offers its place, in a column's cell too; a table's own cell text, a list's items and a pair -of lines do not yet, so a shape whose block's text is written only so goes to the paragraph -nearest it. +lines carries none. Three writers of text offer their paragraphs, and the first +paragraph of each piece of text that holds text takes the place: a paragraph node's own +paragraph, in a column's cell too; each item of a list, so that a card whose only text is a +list's items carries the drawings laid over it, and a shape standing beside the third item takes +that item; and the one line two halves of a band share — a title at the left, its dates at the +right — offered as the half that starts the line, since both are written in that one paragraph. +That line's text starts where the line's own indent puts it, at that half's inked start rather +than at the edge of the box it is laid out in, which is as wide as the band where the half is set +in it by its alignment: a shape anchored in a cell is placed from the cell's text column, and the +column is read from the place's left edge less the indent written on the paragraph, so the two +must be the one edge — stated as the box, the column comes out the band's width to the left and +the cell carries the shape from there. How far the line reaches across is the whole line's, to the +end of the box the other half is laid out in, so that a shape beside the dates at its right end, +or in the gap between the two texts, stands beside the line and not beside some other paragraph. +A line the layout starts left of its cell's text column offers no place — a band pulled out by a +margin below zero, into the cell's padding or past it: Word draws no text past a cell's left edge, +so the indent written for such a line is held at nought and its text does not start where the +layout puts it, and the shapes beside it keep to the text nearest them. +An item offers its place only where the export matched the layout's items to the list's one by +one, which its line gap and its own seating need as well: a list with an item split across a +page is laid out as more pieces of text than it has items, and none of its items offers a place. +A table's own cell text offers none yet either: a table's cells state no width of their own, +their table's grid holding it, and a shape anchored in a cell is placed from the cell's text +column, which cannot be read without that width; a text cell is also set to the middle by +default, and such a cell carries no drawing (above). So a shape whose block's text is written +only as a table's cells goes to the paragraph nearest it. What a table's composed cell draws has no node of its own: a composed cell's content is laid out under its table, and its drawings stand among the table's own fragments. The cell whose box the diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index e69cda22e..7e492901d 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -589,7 +589,7 @@ List itemLines(DocumentNode list) { } boolean sameItem = previous != null && level(previous, fragment); ItemText text = new ItemText(paragraph.lines(), fragment.x() + paragraph.padding().left(), - fragment.pageIndex(), Math.max(0, paragraph.lineGap())); + fragment.pageIndex(), Math.max(0, paragraph.lineGap()), textBoxOf(fragment, paragraph)); if (!sameItem) { items.add(text); } else if (text.x() >= items.get(items.size() - 1).x()) { @@ -604,11 +604,15 @@ List itemLines(DocumentNode list) { * An item's text as a list laid it out. * * @param lines its lines - * @param x where its text starts across the page, in points + * @param x where its text starts across the page, in points — the box's left edge as + * well, held apart from it because an item the page laid no line of has no box * @param page the page it was set on * @param lineGap the gap the page sets between its lines, in points + * @param box where the page sets that same text, which the item's paragraph is offered to + * the shapes beside with ({@code DocxSemanticBackend.offerASeat}); {@code null} + * for an item the page laid no line of, which has no text to stand beside */ - record ItemText(List lines, double x, int page, double lineGap) { + record ItemText(List lines, double x, int page, double lineGap, TextBox box) { /** * How tall the page sets the item's lines together, as {@link #linesHeight(DocumentNode)} @@ -1188,18 +1192,33 @@ OptionalDouble firstLineTop(DocumentNode node) { */ java.util.Optional firstTextBox(DocumentNode node) { for (PlacedFragment fragment : textFragmentsOf(node)) { - if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) { - double top = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), paragraph.padding().top()); - ParagraphLine first = paragraph.lines().get(0); - return java.util.Optional.of(new TextBox(fragment.pageIndex(), - fragment.x() + paragraph.padding().left(), - fragment.x() + fragment.width() - paragraph.padding().right(), - top, top - first.lineHeight() + first.baselineOffsetFromBottom())); + if (fragment.payload() instanceof ParagraphFragmentPayload paragraph) { + TextBox box = textBoxOf(fragment, paragraph); + if (box != null) { + return java.util.Optional.of(box); + } } } return java.util.Optional.empty(); } + /** + * Where the page sets the lines of one fragment of paragraph text: its content box, inside the + * paragraph's padding, and its first line's baseline. {@code null} for a fragment holding no + * line. + */ + private static TextBox textBoxOf(PlacedFragment fragment, ParagraphFragmentPayload paragraph) { + if (paragraph.lines().isEmpty()) { + return null; + } + double top = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), paragraph.padding().top()); + ParagraphLine first = paragraph.lines().get(0); + return new TextBox(fragment.pageIndex(), + fragment.x() + paragraph.padding().left(), + fragment.x() + fragment.width() - paragraph.padding().right(), + top, top - first.lineHeight() + first.baselineOffsetFromBottom()); + } + /** * The content box of a node's first fragment of text, and its first line's baseline. * diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index d4599a2e7..da4fedce4 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -877,6 +877,11 @@ private int startPageOf(DocumentNode node) { /** Where the list being written starts its items on the page, in points; NaN unknown. */ private double listTextLeft = Double.NaN; + /** + * The path of the list being written, which each of its items is offered to the shapes beside + * it with ({@link #seatTheItem}); {@code null} outside a list. + */ + private String listPath; /** Whether the layout's items are matched to the list's, one by one: each then has its place. */ private boolean listItemsMatched; /** @@ -5760,6 +5765,9 @@ private void writeList(XWPFDocument document, double previousTextLeft = listTextLeft; com.demcha.compose.document.layout.PlacedNode listBox = layout.placement(list); listTextLeft = listBox == null ? Double.NaN : listBox.placementX() + list.padding().left(); + // Whose items the shapes beside them are offered (seatTheItem). + String previousPath = listPath; + listPath = layout.pathOf(list); // Its own sides hold its items in, as a paragraph's hold its text (see writeParagraph): // NavySidebar indents its closing lists to clear the badge beside their heading, and // without it their markers stood under the badge, the text a line short in Word. @@ -5802,6 +5810,7 @@ private void writeList(XWPFDocument document, itemsLowered = previousLowered; listItemLines = previousItemLines; listTextLeft = previousTextLeft; + listPath = previousPath; listItemsMatched = previousMatched; listItemsWritten = previousWritten; } @@ -6379,6 +6388,14 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut, if (laidOut == null) { return; } + // Each item is a piece of text of its own, offered to the shapes beside it: a shape the + // list or a block round it holds takes the first item written on its page. Only a list + // whose items the layout's are matched to one by one gets this far: the list keeps the + // layout's items for the writers only then (listItemsMatched), and an item with + // none of them is seated in no way at all (the guard above). + if (laidOut.box() != null && listPath != null) { + offerASeat(para, laidOut, listPath, laidOut.box(), 0); + } boolean opensItsPage = itemPage < 0 ? listOpensItsPage : laidOut.page() > itemPage; itemPage = laidOut.page(); boolean staysOnItsPage = !opensItsPage && (currentCell == null || listOnOnePage || inARowKeptWhole()); @@ -9871,13 +9888,102 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) { * first of them that holds text takes the place. */ private void offerASeat(XWPFParagraph para) { - if (!(writing instanceof ParagraphNode node) || Double.isNaN(canvasHeight)) { + if (!(writing instanceof ParagraphNode node)) { return; } // The baseline the page draws the first line on, seated as the page seats it. - layout.firstTextBox(node).ifPresent(box -> - anchors.seat(box.page(), node, layout.pathOf(node), para, box.left(), box.right(), - canvasHeight - box.top(), canvasHeight - box.baseline() - seatShift(node))); + layout.firstTextBox(node).ifPresent(box -> offerASeat(para, node, layout.pathOf(node), box, seatShift(node))); + } + + /** + * Offers the one line two halves of a band share to the shapes beside it, as the text of the + * half that starts it, reaching across to the end of the other's box. + * + *

One place is enough for the line, since both halves are written in this one paragraph and + * a place stands for a paragraph. The half that starts it is the one whose text begins where + * the line begins, and whose raise Word reads back from the line's first run + * ({@link DocxDrawingAnchors.Seat#paragraphTop}); the line itself is raised by the lesser of + * what its two halves call for ({@link #raiseTheSharedLine}), so a shape placed from it stands + * as far below the page's place as the line does — 0.2 to 0.33pt, measured there.

+ * + *

Where its text starts is where the line's own indent puts it: the half's inked start, not + * the box it is laid out in, which is as wide as the band where the half is set in it by its + * alignment. A shape anchored in a table cell is placed from the cell's text column, and a + * place states that column by its text's left edge less the indent written on it + * ({@link DocxDrawingAnchors.Seat#textLeft}) — the two must be the one edge, or the column + * comes out the band's width to the left and the cell carries the shape from there: measured + * on the test's card, 292pt across its cell instead of 150.

+ * + *

A line the layout starts left of its cell's text column offers no place at all — a band + * pulled out by a margin below zero, whether into the cell's padding or past it. Word draws no + * text past a cell's left edge, so the indent written for such a line is held at nought + * ({@link #leftIndentTwips}) and its text does not start where the layout puts it; the cell's + * text column cannot be read back from the place, and the shapes beside the line keep to the + * text nearest them, as they did before any of this.

+ * + *

How far it reaches across is the whole line's, not that half's: a shape standing beside + * the dates at the line's right end, or in the gap between the two texts, stands beside the + * line, and the text nearest a shape is reckoned from its edges + * ({@code DocxDrawingAnchors.distance}). The half that starts the line is laid out in a box of + * its own width — the title of the test's band is 60pt of a line 360pt wide — and offered as + * that box the line was reckoned 250pt from a shape beside the dates, which a paragraph of + * body text above it then took.

+ * + * @param para the one paragraph the two halves are written in + * @param band where the layout placed the overlay, the box both halves are set in + * @param pair the two halves, as {@link DocxLinePair} read them + * @param lineInset where the line's text is written from, inside its cell + */ + private void offerTheSharedLine(XWPFParagraph para, com.demcha.compose.document.layout.PlacedNode band, + DocxLinePair.Pair pair, double lineInset) { + if (currentCell != null && lineInset + cellTextShift < 0) { + return; + } + ParagraphNode starts = pair.left(); + java.util.Optional box = layout.firstTextBox(starts); + if (box.isEmpty()) { + return; + } + double ends = layout.firstTextBox(pair.right()).map(DocxLayoutMetrics.TextBox::right) + .orElse(box.get().right()); + offerASeat(para, starts, layout.pathOf(starts), + new DocxLayoutMetrics.TextBox(box.get().page(), band.placementX() + pair.leftOffset(), + Math.max(box.get().right(), ends), box.get().top(), box.get().baseline()), + seatShift(starts)); + } + + /** + * Offers a paragraph written for any piece of text to the shapes standing beside it: a + * paragraph node's text, an item of a list whose items the layout's are matched to + * ({@link #listItemsMatched}), the line two halves of a band share. + * + *

A shape belongs to the node drawing it and to the blocks holding that node, and is + * anchored in the first paragraph the nearest of them writes on its page + * ({@link DocxDrawingAnchors}). A block whose text another writer writes had no paragraph to + * offer: a card whose only text is a list's items, a CV entry's head band written as one Word + * line. Their shapes went to the text nearest them instead — in the body, the paragraph above + * the card, whose top does not move when that paragraph grows a line and the card does. + * Measured in Word 16.0.20527 and LibreOffice 24.2 on a probe of each, with the paragraph above + * lengthened by a line: the card's own text moved 9.72pt in Word and 19.40pt in LibreOffice, + * and the shape 0.00 in both; carried by the item or by the shared line it moves with them, + * 9.70 and 19.35pt, and stands on the card's corner, 0.09pt from it in Word against 13.76pt + * before.

+ * + * @param owner the piece of text the paragraph was written for: the first paragraph of it that + * holds text takes the place, so each item and each shared line is its own + * @param path the path of the node whose text it is, which a shape's holders are matched + * against; a list's own for each of its items + * @param box where the page sets that text, y up from the foot of the page + * @param seated how far the page moves it off its baseline to seat it ({@link #seatShift}), 0 + * where the page sets it on its baseline + */ + private void offerASeat(XWPFParagraph para, Object owner, String path, + DocxLayoutMetrics.TextBox box, double seated) { + if (Double.isNaN(canvasHeight)) { + return; + } + anchors.seat(box.page(), owner, path, para, box.left(), box.right(), + canvasHeight - box.top(), canvasHeight - box.baseline() - seated); } /** @@ -11151,6 +11257,7 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine int rightAnchor = openAnchor(para, pair.right().anchor()); writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false); closeAnchor(para, rightAnchor); + offerTheSharedLine(para, box, pair, lineStart + pair.leftOffset()); raiseTheSharedLine(para, pair, lineTop, opensItsPage); // A Word paragraph has one outline level, and its outline lists the whole line by it: the // left side's level where it has one, the right side's where not; the other's is lost. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java new file mode 100644 index 000000000..116956ec5 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java @@ -0,0 +1,388 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.xmlbeans.XmlObject; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.STRelFromV; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A block whose text a writer of its own writes — a list's items, two halves sharing one line — + * carries the drawings of the blocks holding it. + * + *

A paragraph was offered to the shapes beside it only when a paragraph node was being + * written, so a card whose only text was a list's items, and a CV entry's head band written as one + * line, had no paragraph to offer: their shapes went to the text nearest them, in the body the + * paragraph above the card, and stayed where that paragraph's top was while the card moved. + * Measured in Word 16.0.20527 and LibreOffice 24.2: a paragraph above lengthened by a line moved + * the card's text 9.72pt in Word and 19.40pt in LibreOffice and left the shape where it was; with + * the item carrying it, the shape moves with the card (9.70 and 19.35pt) and stands on its corner, + * 0.09pt from it in Word against 13.76pt before.

+ */ +class DocxTextWriterAnchorTest { + + private static final DocumentColor INK = DocumentColor.rgb(20, 30, 40); + private static final DocumentColor SURFACE = DocumentColor.rgb(230, 240, 250); + private static final double DOT = 12; + private static final double CARD_PADDING = 10; + /** Where a band's dot is laid across it, from the band's left edge, in points. */ + private static final double DOT_ACROSS = 150; + + @Test + void aCardWhoseTextIsAListsItemsCarriesTheDrawingLaidOverIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the card")); + page.addLayerStack(stack -> stack + .layer(new SectionBuilder().softPanel(SURFACE, 0, 10) + .addList(list -> list.bullet().items("First item", "Second item")).build()) + .layer(dot(), LayerAlign.TOP_RIGHT)); + })) { + XWPFParagraph item = firstItem(document); + + assertThat(placedFrom(item)).as("in the first item's paragraph, not on the page") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(placedFrom(document.getParagraphs().get(0))).as("not in the paragraph above") + .isEmpty(); + assertThat(onThePage(document)).isZero(); + // The card is a table cell, so the dot is placed from the cell's text column, which + // the item states by where its own text starts: the page lays the dot at the card's + // right edge, so it stands the cell's text width less its own width into that column. + XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0); + assertThat(offsetAcross(item)).as("from the cell's text column") + .isCloseTo(widthOf(card) - marginOf(card) - DOT, within(1.0)); + } + } + + @Test + void theFirstItemCarriesOneStandingBesideTheLast() throws Exception { + // The holder's first paragraph on the page takes it, however far from its text it stands: + // where it stands gives only its offset. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the card")); + page.addLayerStack(stack -> stack + .layer(new SectionBuilder().softPanel(SURFACE, 0, 10) + .addList(list -> list.bullet().items("First item", "Second item")).build()) + .layer(dot(), LayerAlign.BOTTOM_RIGHT)); + })) { + XWPFParagraph item = firstItem(document); + + assertThat(placedFrom(item)).containsExactly(STRelFromV.PARAGRAPH); + // The card holds two items of one line and 10pt of padding either side: the dot at its + // foot stands below the first item's top by about the second item's line and the + // padding under it, and no further than the card is tall. + assertThat(offsetDown(item)).as("below the first item's top, where it stands") + .isBetween(9.0, 60.0); + } + } + + @Test + void theItemWhoseTextItStandsBesideCarriesOneNoBlockHolds() throws Exception { + // Each item is offered with its own text's box: a dot nothing holds, standing level with + // the third item and clear of the items' text across the page, is anchored in that item. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addList(list -> list.bullet().items("First item", "Second item", "Third item", + "Fourth item")); + page.addEllipse(dot -> dot.name("Dot").circle(10).fillColor(INK) + .margin(new DocumentInsets(-22, 0, 0, 250))); + })) { + List items = document.getParagraphs().stream() + .filter(para -> para.getCTP().getPPr() != null && para.getCTP().getPPr().isSetNumPr()) + .toList(); + + assertThat(items).hasSize(4); + assertThat(placedFrom(items.get(2))).as("the third item, whose line it stands on") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(placedFrom(items.get(0))).as("not the first").isEmpty(); + assertThat(onThePage(document)).isZero(); + } + } + + @Test + void theLineTwoHalvesShareCarriesTheDrawingsOfTheirBand() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the band")); + page.addContainer(band -> band.name("EntryHead").rectangle(360, 16) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("Delivery engineer") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_RIGHT) + .position(dot(), 160, 0, LayerAlign.CENTER_LEFT)); + })) { + XWPFParagraph shared = shared(document); + + assertThat(shared.getText()).isEqualTo("Delivery engineer\t2019 - 2024"); + assertThat(placedFrom(shared)).as("in the line the halves share") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(onThePage(document)).isZero(); + } + } + + @Test + void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception { + // A dot no block carries — nothing holding it writes text — goes to the paragraph whose + // text it stands nearest, counted from the text's edges. This one stands level with the + // band at its right end, beside the dates, with a paragraph of body text just above it: + // the line reaches across to the dates, so it is the nearer text. Offered as the box of + // the half that starts the line — a title 60pt of a line 360pt wide — the line would be + // reckoned 250pt away and the paragraph above would take the dot. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the band")); + page.addContainer(band -> band.name("EntryHead").rectangle(360, 16) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("Engineer") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_RIGHT)); + page.addEllipse(dot -> dot.name("Dot").circle(12).fillColor(INK) + .margin(new DocumentInsets(-14, 0, 0, 290))); + })) { + XWPFParagraph shared = shared(document); + + assertThat(placedFrom(shared)).as("the dot, beside the dates") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(placedFrom(document.getParagraphs().get(0))).as("not the paragraph above") + .isEmpty(); + assertThat(onThePage(document)).isZero(); + } + } + + @Test + void anItemWhoseMarkerIsDrawnCarriesThemToo() throws Exception { + // A list whose marker is drawn has no Word list definition to be given, so its items go + // through the other item writer (writeRichListLine), which offers their place by the same + // call and tabs their text past the marker. + java.util.function.Consumer card = page -> { + page.addParagraph(p -> p.text("Above the card")); + page.addLayerStack(stack -> stack + .layer(new SectionBuilder().softPanel(SURFACE, 0, CARD_PADDING) + .addList(list -> list.name("Skills").marker(m -> m.dot(4, INK)) + .markerGap(6).hangingIndent(true).normalizeMarkers(false) + .addItem("First item").addItem("Second item")).build()) + .layer(dot(), LayerAlign.TOP_RIGHT)); + }; + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, card)) { + XWPFTableCell panel = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph item = panel.getParagraphs().stream() + .filter(para -> para.getText().contains("First item")).findFirst().orElseThrow(); + + assertThat(DocxExports.report(400, 600, 20, card).bySubject()) + .as("laid out — without a layout nothing is drawn and the rest proves nothing") + .doesNotContainKey("measured geometry"); + assertThat(placedFrom(item)).as("in the first item, marker and all") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(offsetAcross(item)).as("from the cell's text column") + .isCloseTo(widthOf(panel) - marginOf(panel) - DOT, within(1.0)); + assertThat(onThePage(document)).isZero(); + } + } + + @Test + void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception { + // The line's indent is written from the inked start of the half that starts it, and a + // shape anchored in a cell is placed from the cell's text column, which the place states + // by its left edge less that indent. Here the band's title is centred in a box as wide as + // the band, so its box starts a long way left of its text: stated as the box, the column + // comes out that much to the left and the cell carries the shape from there. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection( + card -> card.softPanel(SURFACE, 0, 10).addContainer(band -> band.name("EntryHead") + .rectangle(320, 16).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("Engineer") + .align(TextAlign.CENTER) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2019 - 2024") + .align(TextAlign.RIGHT) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_RIGHT) + .position(dot(), DOT_ACROSS, 0, LayerAlign.CENTER_LEFT))))) { + XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph shared = card.getParagraphs().stream() + .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow(); + + assertThat(placedFrom(shared)).as("the cell carries it") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(offsetAcross(shared)).as("from the cell's text column, where the page draws it") + .isCloseTo(DOT_ACROSS, within(1.0)); + // The line's own top, from which it is placed down: the dot is centred on the band, a + // little below where the line starts, and well inside the band's height. + assertThat(offsetDown(shared)).as("down from the line's top, where it stands") + .isBetween(0.0, 16.0); + } + } + + @Test + void aSharedLineInACellPlacesItFromTheCellsTextColumn() throws Exception { + // A shape anchored in a cell's paragraph is placed from the cell's text column, which the + // seat states by where its own text starts. A band inside a card is such a line, and the + // dot laid on it stands where the band places it, measured from that column. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection( + card -> card.softPanel(SURFACE, 0, 10).addContainer(band -> band.name("EntryHead") + .rectangle(320, 16).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("Engineer") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_RIGHT) + .position(dot(), 140, 0, LayerAlign.CENTER_LEFT))))) { + XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph shared = card.getParagraphs().stream() + .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow(); + + assertThat(placedFrom(shared)).containsExactly(STRelFromV.PARAGRAPH); + assertThat(offsetAcross(shared)).as("from the cell's text column, where the line starts") + .isCloseTo(140, within(1.0)); + } + } + + @Test + void aSharedLineStartedLeftOfItsCellsTextColumnOffersNone() throws Exception { + // Word draws no text past a cell's left edge, so the indent written for a line the layout + // starts left of its cell's text column is held at nought and its text does not start + // where the layout puts it. The column cannot be read back from such a place, so the line + // offers none and the drawing keeps to the text nearest it, as before this rule. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the card")); + page.addSection(card -> card.softPanel(SURFACE, 0, CARD_PADDING) + .addContainer(band -> band.name("EntryHead").rectangle(320, 16) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .margin(new DocumentInsets(0, 0, 0, -(CARD_PADDING + 2))) + .position(new ParagraphBuilder().name("Title").text("Engineer") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2019 - 2024") + .align(TextAlign.RIGHT) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, + LayerAlign.CENTER_RIGHT) + .position(dot(), DOT_ACROSS, 0, LayerAlign.CENTER_LEFT))); + })) { + XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph shared = card.getParagraphs().stream() + .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow(); + + assertThat(indentOf(shared)).as("its indent held at nought, hung out as it is").isZero(); + assertThat(placedFrom(shared)).as("the line takes none").isEmpty(); + assertThat(placedFrom(document.getParagraphs().get(0))) + .as("the paragraph above the card keeps it, as before this rule") + .containsExactly(STRelFromV.PARAGRAPH); + assertThat(onThePage(document)).as("and it is not left to the page").isZero(); + } + } + + @Test + void aCardWhoseTextATablesCellsHoldLeavesItWhereItWas() throws Exception { + // A table's own cells state no width of their own — a table's grid holds it — and such a + // cell is set to the middle, whose text LibreOffice 24.2 moves for a drawing it carries + // (measured for #977). So the cell is offered no drawing to carry, and the shape keeps to + // the text nearest it, as before. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> { + page.addParagraph(p -> p.text("Above the card")); + page.addLayerStack(stack -> stack + .layer(new SectionBuilder().softPanel(SURFACE, 0, 10) + .addTable(table -> table.columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.text("A cell"))).build()) + .layer(dot(), LayerAlign.TOP_RIGHT)); + })) { + XWPFTableCell cellOfTheTable = document.getTables().get(0).getRow(0).getCell(0) + .getTables().get(0).getRow(0).getCell(0); + + assertThat(placedFrom(cellOfTheTable.getParagraphs().get(0))).as("the cell takes none") + .isEmpty(); + assertThat(placedFrom(document.getParagraphs().get(0))) + .as("the paragraph above the card keeps it, as before") + .containsExactly(STRelFromV.PARAGRAPH); + } + } + + private static DocumentNode dot() { + return new EllipseNode("dot", DOT, DOT, INK, null, null, null, null, null); + } + + /** The one Word paragraph two halves of a band share, found by the tab between them. */ + private static XWPFParagraph shared(XWPFDocument document) { + return document.getParagraphs().stream() + .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow(); + } + + private static XWPFParagraph firstItem(XWPFDocument document) { + return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(para -> para.getCTP().getPPr() != null && para.getCTP().getPPr().isSetNumPr()) + .findFirst().orElseThrow(); + } + + private static List placedFrom(XWPFParagraph paragraph) { + List from = new ArrayList<>(); + for (CTAnchor anchor : anchorsIn(paragraph.getCTP())) { + from.add(anchor.getPositionV().getRelativeFrom()); + } + return from; + } + + private static double offsetDown(XWPFParagraph paragraph) { + return anchorsIn(paragraph.getCTP()).get(0).getPositionV().getPosOffset() / 12700.0; + } + + private static double offsetAcross(XWPFParagraph paragraph) { + return anchorsIn(paragraph.getCTP()).get(0).getPositionH().getPosOffset() / 12700.0; + } + + /** The left indent written on a paragraph, in points; 0 where none is written. */ + private static double indentOf(XWPFParagraph paragraph) { + var properties = paragraph.getCTP().getPPr(); + if (properties == null || !properties.isSetInd() || !properties.getInd().isSetLeft()) { + return 0; + } + return ((Number) properties.getInd().getLeft()).doubleValue() / 20; + } + + /** A cell's width as written, in points. */ + private static double widthOf(XWPFTableCell cell) { + return ((Number) cell.getCTTc().getTcPr().getTcW().getW()).doubleValue() / 20; + } + + /** The room a cell leaves its text at the left, as written, in points. */ + private static double marginOf(XWPFTableCell cell) { + return ((Number) cell.getCTTc().getTcPr().getTcMar().getLeft().getW()).doubleValue() / 20; + } + + private static long onThePage(XWPFDocument document) { + return anchorsIn(document.getDocument()).stream() + .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE) + .count(); + } + + private static List anchorsIn(XmlObject root) { + List anchors = new ArrayList<>(); + try (org.apache.xmlbeans.XmlCursor cursor = root.newCursor()) { + cursor.selectPath("declare namespace wp='http://schemas.openxmlformats.org/drawingml/2006/" + + "wordprocessingDrawing' .//wp:anchor"); + while (cursor.toNextSelection()) { + if (cursor.getObject() instanceof CTAnchor anchor) { + anchors.add(anchor); + } + } + } + return anchors; + } +}