diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb98dffa..5ac7b580f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A row a drawing makes tall, and a title pulled above its row, keep the page's height in + Word.** A row whose tallest child is drawn where the page puts it — a badge beside a heading, + its cell holding nothing in Word — was only as tall as its text there: `WorkspaceInvoice`'s + bill-to heading stood beside a 20pt badge, and the address under it stood 10pt high. Such a row + is now held at least as tall as the page makes it, as a row inside a painted panel already was; + a drawing no taller than its neighbours' text, a timeline's rail beside its entry, leaves the + row to Word. A section in a cell (a row's, a table's or a panel's) that pulls its first line up + with a negative top edge — `WorkspaceInvoice`'s masthead title, set 4pt above its row — had that + line start at the cell's top in Word, and the page under it 4pt low; a one-line first paragraph + is now written as much shorter, its text seated where the page sets it — no more than the room + above its letters, as Word draws an exact line's text only inside the line. The paragraph's own + top edge is written as before. In Word `WorkspaceInvoice`'s median drift + falls from 4.8pt to 0.4 and `SubscriptionInvoice`'s from 8.8 to 0.5; across the 62 templates, + lines more than 2pt off fall from 858 to 705. `PaymentsInvoice`'s parties now stand level with + each other and its median rises from 3.7 to 10.3: the collapsed heading row had been hiding a + gap above it that runs about 16pt long in Word. - **A bordered panel in a table cell shows its right border in Word.** Word draws a table's right border outside its right edge — measured in its PDF, a table ending at 566.0pt had its right border from 566.2 to 566.9 — and on screen cuts off what passes its cell's edge and draws the diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 3ec3fa26c..99ae1f70d 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -74,7 +74,7 @@ creation date is real metadata. | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | -| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it | +| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line | | Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel) | | Spacers | An empty paragraph a tenth of a point tall; the spacer's height is the space above the next block, or below this paragraph when a table follows | | Page breaks | Explicit Word page breaks | 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 28190a583..f3a268849 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 @@ -233,6 +233,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // see holdStackedLines. private final java.util.Map stackedLineHeights = new java.util.IdentityHashMap<>(); + // How far each paragraph a container pulled above its cell rises inside its own line: see + // riseIntoItsLine. + private final java.util.Map risenLines = new java.util.IdentityHashMap<>(); // How far above the page's first line a paragraph's Word line starts, in points, where its // lines took the space between them from the space above it: see applyLineGap. private final java.util.Map lineTopsTakenIn = @@ -604,6 +607,7 @@ private byte[] write(List sections, Path outputFile) throws Exc writingInAStandIn.clear(); stackedLineHeights.clear(); lineTopsTakenIn.clear(); + risenLines.clear(); picturesDrawnBeside.clear(); listNumbering.clear(); report = new DocxExportReport.Builder(); @@ -3107,6 +3111,7 @@ private void writeContainerBody(XWPFDocument document, DocumentNode node) throws double carriedFromOutside = carriedSpacingBefore; long blocksBefore = blocksWritten; carriedSpacingBefore += node.margin().top() + node.padding().top(); + riseIntoItsLine(node); // The sides are carried the same way, as the indent of every paragraph inside: a // container's content starts inside its margin and its padding on the page, and was // written flush with the page margin, the card's text touching the card's edge. @@ -3137,6 +3142,60 @@ private void writeContainerBody(XWPFDocument document, DocumentNode node) throws carriedSpacingBefore = blocksWritten == blocksBefore ? carriedFromOutside : 0; } + /** + * Lets a container in a cell that pulls its first line up above the cell — a negative top + * edge — do so inside that line. + * + *

A Word paragraph starts no higher than its cell. {@code WorkspaceInvoice}'s title is set + * 4pt above its masthead row by the cell's padding, and in Word stood 4pt low with the whole + * page under it; the row opens the page, and there is no space above to lift it into (see + * {@link #standsAboveItsCell}). A first child of one line is written that much shorter + * instead, its text seated where the page sets it (see {@link DocxStackedLines.Line}), so + * the row is as tall as the page's and the title stands where the page puts it. That holds + * wherever the container stands in its cell: after other blocks, the space owed above it is + * written, and the line rises from there as the page's does.

+ * + *

Word draws an exact line's text on screen only inside the line, so the line gives up no + * more than the room above its letters (see {@link DocxInk}): an accent on a capital pulled + * up further would be cut. What it cannot give stays where it did, the line starting at the + * cell's top. A line whose letters cannot be read — a picture in it — is left as it was.

+ */ + private void riseIntoItsLine(DocumentNode node) { + if (currentCell == null || node.children().isEmpty() || !(node.children().get(0) instanceof ParagraphNode first)) { + return; + } + Double risen = risenLines.get(first); + if (risen != null) { + // Written again — a header on every page class: the line is already cut, and the + // edge it rises by is taken as it was the first time. + carriedSpacingBefore += risen; + return; + } + // What the paragraph would have above it before its own edge, which is written apart + // (applyVerticalSpacing): the edges carried down to it and the space the block before it + // owes, less a border standing below that block. Only what that comes short of zero is + // a rise; the rest is written as space. + double rise = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow); + if (!(rise > 0.01) || layout.lineCount(first) != 1 || stackedLineHeights.containsKey(first)) { + return; + } + java.util.OptionalDouble own = layout.lineHeight(first); + java.util.Optional line = layout.firstLine(first); + double[] ink = inkOf(first); + if (own.isEmpty() || line.isEmpty() || ink == null) { + return; + } + double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0] + - DocxStackedLines.INK_MARGIN; + double taken = Math.min(rise, aboveTheLetters); + if (!(taken > 0.01) || taken >= own.getAsDouble()) { + return; + } + stackedLineHeights.put(first, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0)); + risenLines.put(first, taken); + carriedSpacingBefore += taken; + } + private static boolean hasRadius(com.demcha.compose.document.style.DocumentCornerRadius radius) { return radius != null && !radius.isZero(); } @@ -6087,6 +6146,41 @@ private void holdRowHeight(XWPFTableRow row, TableNode node, int rowIdx) { } } + /** + * Whether a row's tallest child on the page, margins included as the row is sized by them, + * is one whose cell Word holds nothing in, taller by more than half a point than every child + * it writes. + */ + private boolean aDrawingMakesTheRow(RowNode node, XWPFTableRow row) { + double drawn = 0; + double written = 0; + for (int i = 0; i < node.children().size() && i < row.getTableCells().size(); i++) { + DocumentNode child = node.children().get(i); + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(child); + if (placed == null) { + continue; + } + double height = placed.placementHeight() + child.margin().top() + child.margin().bottom(); + if (holdsNothing(row.getCell(i))) { + drawn = Math.max(drawn, height); + } else { + written = Math.max(written, height); + } + } + return drawn > written + 0.5; + } + + /** + * Whether Word holds nothing in a cell that gives it height: no table, and no paragraph with + * a run. A rule's or a spacer's paragraph counts as nothing; the row it stands in is held no + * taller than the page makes it, which that child already sets. + */ + private static boolean holdsNothing(XWPFTableCell cell) { + return cell.getTables().isEmpty() + && cell.getParagraphs().stream().allMatch(paragraph -> paragraph.getCTP().sizeOfRArray() == 0 + && paragraph.getCTP().sizeOfHyperlinkArray() == 0); + } + /** * Writes a row at least a height, less what its cells take above and below their content * (see {@link #holdRowHeight}). @@ -6746,8 +6840,8 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { // Inside a painted panel the row is held as tall as the page made it: its padding and a // drawing standing in it are what make it taller than its text, as a panel's are, and // MerchantInvoice's due-date text stood against its card's top without it. - com.demcha.compose.document.layout.PlacedNode placedRow = - surfaceBehind != null && panelCell != null ? layout.placement(node) : null; + boolean inAPanel = surfaceBehind != null && panelCell != null; + com.demcha.compose.document.layout.PlacedNode placedRow = layout.placement(node); // A row has no fill of its own: inside a panel it is a table nested in the panel's // cell, and a cell with no shading shows the panel's through it. for (int i = 0; i < node.children().size(); i++) { @@ -6769,7 +6863,14 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { } applyRowVerticalAlign(cell, node.verticalAlign()); } - if (placedRow != null && placedRow.startPage() == placedRow.endPage()) { + // Anywhere, a row whose tallest child is one Word holds nothing of in its cell — a + // picture drawn where the page puts it, a badge beside a heading — is only as tall there + // as its text: WorkspaceInvoice's bill-to heading stood beside a 20pt badge, and the + // address under it stood 10pt high in Word. Held to the page's height, whose margins are + // written around the table. A drawing no taller than its neighbours' text — a timeline's rail + // beside its entry — changes nothing and is left to Word. + if (placedRow != null && placedRow.startPage() == placedRow.endPage() + && (inAPanel || aDrawingMakesTheRow(node, row))) { holdRowAtLeast(row, placedRow.placementHeight() - node.padding().top() - node.padding().bottom()); } indentTable(table); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowHeightTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowHeightTest.java new file mode 100644 index 000000000..3dd40155a --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowHeightTest.java @@ -0,0 +1,233 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A row keeps the page's height in Word where what makes it that tall is not written in it. + * + *

{@code WorkspaceInvoice}'s bill-to heading stands beside a 20pt badge drawn where the page + * puts it: its cell holds nothing in Word, and the row was only as tall as its 8pt label — the + * address under it stood 10pt high. Its masthead title is set 4pt above its row by the cell's + * padding, and a Word paragraph starts no higher than its cell: the title and the page under it + * stood 4pt low.

+ */ +class DocxRowHeightTest { + + private static final DocumentTextStyle LABEL = DocumentTextStyle.builder().fontName(FontName.LATO).size(8).build(); + + @Test + void aRowADrawingMakesTallIsHeldToThePagesHeight() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .columns(DocumentRowColumn.fixed(20), DocumentRowColumn.weight(1)) + .addSection("Disc", disc -> disc.add(badge(20))) + .addParagraph(p -> p.text("BILL TO").textStyle(LABEL))))) { + XWPFTableRow row = document.getTables().get(0).getRow(0); + + assertThat(row.getHeight()).as("the badge's 20pt, not the label's line").isBetween(380, 400); + } + } + + @Test + void aRowWithMarginsIsHeldToItsOwnBoxWhoseMarginsAreWrittenAroundIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .margin(new DocumentInsets(10, 0, 10, 0)) + .columns(DocumentRowColumn.fixed(20), DocumentRowColumn.weight(1)) + .addSection("Disc", disc -> disc.add(badge(20))) + .addParagraph(p -> p.text("BILL TO").textStyle(LABEL))))) { + XWPFTableRow row = document.getTables().get(0).getRow(0); + + assertThat(row.getHeight()).as("the badge's 20pt, its margins not taken off").isBetween(380, 400); + } + } + + @Test + void aDrawingNoTallerThanItsNeighboursTextLeavesTheRowToWord() throws Exception { + // A timeline's rail beside its entry: the entry's text makes the row, and Word sets it. + DocumentTextStyle entry = DocumentTextStyle.builder().fontName(FontName.LATO).size(16).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Entry", row -> row + .columns(DocumentRowColumn.fixed(20), DocumentRowColumn.weight(1)) + .addSection("Rail", rail -> rail.add(badge(8))) + .addParagraph(p -> p.text("Senior Engineer").textStyle(entry))))) { + XWPFTableRow row = document.getTables().get(0).getRow(0); + + assertThat(row.getHeight()).as("nothing held").isZero(); + } + } + + private static com.demcha.compose.document.node.DocumentNode badge(double diameter) { + return new ShapeContainerBuilder().name("Badge") + .circle(diameter).fillColor(DocumentColor.rgb(80, 40, 160)) + .center(new com.demcha.compose.document.dsl.ImageBuilder().name("Glyph") + .source(png()).size(diameter / 2, diameter / 2).build()) + .build(); + } + + private static byte[] png() { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(10, 10, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", out); + return out.toByteArray(); + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure); + } + } + + @Test + void aRowItsTextMakesTallIsLeftToWord() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .columns(DocumentRowColumn.fixed(40), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("01").textStyle(LABEL)) + .addParagraph(p -> p.text("BILL TO").textStyle(LABEL))))) { + XWPFTableRow row = document.getTables().get(0).getRow(0); + + assertThat(row.getHeight()).as("nothing held").isZero(); + } + } + + @Test + void aTitlePulledAboveItsRowRisesInsideItsOwnLine() throws Exception { + // The page's line for 36pt Lato is 43.2pt; pulled 4pt above its cell, it is written 4pt + // shorter, starting where the cell does. + DocumentTextStyle title = DocumentTextStyle.builder().fontName(FontName.LATO).size(36).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Masthead", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Logo").textStyle(LABEL)) + .addSection("Title", cell -> cell + .padding(new DocumentInsets(-4, 0, 0, 0)) + .addParagraph(p -> p.text("INVOICE").textStyle(title)))))) { + XWPFParagraph invoice = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); + var spacing = invoice.getCTP().getPPr().getSpacing(); + double line = DocxTwips.of(spacing.getLine()) / 20.0; + int position = invoice.getRuns().stream() + .map(run -> run.getCTR().getRPr()) + .filter(properties -> properties != null && properties.sizeOfPositionArray() > 0) + .map(properties -> ((Number) properties.getPositionArray(0).getVal()).intValue()) + .findFirst().orElse(0); + + assertThat(line).isCloseTo(43.2 - 4, within(0.1)); + assertThat(spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0L).as("no space above it").isZero(); + // Word's baseline four fifths down 39.2pt is 31.4pt; the page's is 4pt above the cell, + // its ascent 35.5pt down: 31.5pt. Within a half point, as it is. + assertThat(position).as("seated in half points").isBetween(-1, 1); + } + } + + @Test + void aTitlePulledUpFurtherThanItsLettersAllowRisesOnlyAsFarAsTheyDo() throws Exception { + // Pulled 10pt up, "RÉSUMÉ"'s accents stand under 2pt below its line's top: Word, drawing + // the line's text only inside it, would cut them. The line gives up no more than that room. + DocumentTextStyle title = DocumentTextStyle.builder().fontName(FontName.LATO).size(36).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Masthead", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Logo").textStyle(LABEL)) + .addSection("Title", cell -> cell + .padding(new DocumentInsets(-10, 0, 0, 0)) + .addParagraph(p -> p.text("RÉSUMÉ").textStyle(title)))))) { + XWPFParagraph resume = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); + double line = DocxTwips.of(resume.getCTP().getPPr().getSpacing().getLine()) / 20.0; + + assertThat(line).as("shortened by less than the 10pt it is pulled").isGreaterThan(43.2 - 4); + assertThat(line).as("but shortened").isLessThan(43.2); + } + } + + @Test + void aDrawingsMarginCountsTowardsTheRowItMakes() throws Exception { + // A 12pt badge 8pt down its cell makes the row 20pt, taller than the 16pt text's 19.2pt + // line beside it: the row is sized by each child's margin box. + DocumentTextStyle entry = DocumentTextStyle.builder().fontName(FontName.LATO).size(16).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .columns(DocumentRowColumn.fixed(20), DocumentRowColumn.weight(1)) + .addSection("Disc", disc -> disc.margin(new DocumentInsets(8, 0, 0, 0)).add(badge(12))) + .addParagraph(p -> p.text("Heading").textStyle(entry))))) { + XWPFTableRow row = document.getTables().get(0).getRow(0); + + assertThat(row.getHeight()).as("held to the badge's 20pt box").isBetween(380, 400); + } + } + + @Test + void aPulledTitlesOwnMarginIsWrittenAboveItAndNotTakenFromItsLine() throws Exception { + // The section pulls up 4pt, the paragraph sets itself 2pt down: its own 2pt is written as + // space above it, as for any paragraph, and the line gives up the section's 4pt. + DocumentTextStyle title = DocumentTextStyle.builder().fontName(FontName.LATO).size(36).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Masthead", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Logo").textStyle(LABEL)) + .addSection("Title", cell -> cell + .padding(new DocumentInsets(-4, 0, 0, 0)) + .addParagraph(p -> p.text("INVOICE").textStyle(title) + .margin(new DocumentInsets(2, 0, 0, 0))))))) { + XWPFParagraph invoice = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); + var spacing = invoice.getCTP().getPPr().getSpacing(); + + assertThat(DocxTwips.of(spacing.getLine()) / 20.0).isCloseTo(43.2 - 4, within(0.1)); + assertThat(DocxTwips.of(spacing.getBefore()) / 20.0).as("its own margin above it").isCloseTo(2, within(0.1)); + } + } + + @Test + void aSectionAfterABlockInItsCellWritesTheSpaceOwedBeforeItRises() throws Exception { + // 6pt owed below the label, the section pulls up 4pt: the title stands 2pt below the + // label's line, written as 2pt of space, its line whole. + DocumentTextStyle title = DocumentTextStyle.builder().fontName(FontName.LATO).size(36).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Masthead", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Logo").textStyle(LABEL)) + .addSection("Title", cell -> cell + .addParagraph(p -> p.text("ISSUED").textStyle(LABEL) + .margin(new DocumentInsets(0, 0, 6, 0))) + .addSection("Pulled", pulled -> pulled + .padding(new DocumentInsets(-4, 0, 0, 0)) + .addParagraph(p -> p.text("INVOICE").textStyle(title))))))) { + java.util.List paragraphs = document.getTables().get(0).getRow(0).getCell(1).getParagraphs(); + XWPFParagraph invoice = paragraphs.stream() + .filter(paragraph -> paragraph.getText().contains("INVOICE")).findFirst().orElseThrow(); + var spacing = invoice.getCTP().getPPr().getSpacing(); + + assertThat(DocxTwips.of(spacing.getLine()) / 20.0).as("nothing to rise into").isCloseTo(43.2, within(0.1)); + assertThat(DocxTwips.of(spacing.getBefore()) / 20.0).as("6pt owed less the 4pt pulled").isCloseTo(2, within(0.1)); + } + } + + @Test + void aPulledLineHoldingAPictureIsLeftAsItWas() throws Exception { + // Its letters cannot be read past the picture, so how far it may rise is not known. + DocumentTextStyle title = DocumentTextStyle.builder().fontName(FontName.LATO).size(36).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Masthead", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Logo").textStyle(LABEL)) + .addSection("Title", cell -> cell + .padding(new DocumentInsets(-4, 0, 0, 0)) + .addParagraph(p -> p.inlineText("INVOICE ", title) + .inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()), + 10, 10)))))) { + XWPFParagraph invoice = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); + double line = DocxTwips.of(invoice.getCTP().getPPr().getSpacing().getLine()) / 20.0; + + assertThat(line).as("not cut short").isGreaterThan(43.2 - 1); + } + } +}