diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac7b580f..e5dcc0715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A block pulled up by a negative top edge, and a row padded down its column, stand where the + page puts them in Word.** Word has no negative space above a paragraph, so a paragraph's, a + page reference's or a rule's negative top `margin` or `padding` was dropped: + `PaymentsInvoice`'s header rule is pulled 16.4pt up out of the space its header stack leaves + below its content. The pull now comes out of the space owed above the block, with any pull of + the containers around it. Word has no space above a table either, and a row opening a row's + column lost the padding above it: `PaymentsInvoice`'s metadata grid, padded 6.2pt down its + column, stood 6.3pt above the issuer beside it, as `MerchantInvoice`'s stood 6pt, and + `PaymentsInvoice`'s rule and the page under it stood 10pt low, the pull less that lost + padding. A paragraph a tenth of a point tall now carries that space, the containers' edges + included, in any cell but a table's — as it already carried the space a row owed above itself + at the top of a panel; a row opening a table's cell is left to the row height the table + holds. In Word `PaymentsInvoice`'s median drift falls from 10.3pt to 0.5, and across the 62 + templates lines more than 2pt off fall from 705 to 572; in LibreOffice from 1066 to 933. - **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 @@ -19,7 +33,7 @@ follow semantic versioning; release dates are ISO 8601. 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 + top edge is not counted in the rise. 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 diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 99ae1f70d..27abfe708 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -193,8 +193,16 @@ paragraph is held to a tenth of a point, so it adds no line of text to the gap i A table holds its own space the same way. Word has no space above a table and none below one, so a table's or a row's `margin` and `padding` travel to the paragraphs around it — -the space above a table is the space below the paragraph before it. A table with no -paragraph above it loses that edge, which is the one gap Word has nowhere to put. +the space above a table is the space below the paragraph before it. Where no paragraph +stands above it — a table opening the body, or a row opening any cell but a table's, padded +down it — a paragraph a tenth of a point tall carries the space instead. A row opening a +table's cell is left to the row height the table holds, at least as tall as the page makes it; +a table (not a row) opening a cell still loses that edge. + +An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top +`margin` or `padding` — comes out of the space owed above it, as the page takes it from +there: Word has no negative space above a paragraph. What that space cannot give is not +written. Text laid over the flow in text boxes owes no space, so its edges move nothing. A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines — goes into the line: Word has one line height for a paragraph and no gap between its lines, 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 f3a268849..fbaaf5c5a 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 @@ -236,6 +236,10 @@ public final class DocxSemanticBackend implements SemanticBackend { // 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<>(); + // The cells of a table's grid that hold a composed node, whose row the table holds at the + // page's height: see writeTableWithItsOwnSpacing. + private final java.util.Set tablesCells = + java.util.Collections.newSetFromMap(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 = @@ -608,6 +612,7 @@ private byte[] write(List sections, Path outputFile) throws Exc stackedLineHeights.clear(); lineTopsTakenIn.clear(); risenLines.clear(); + tablesCells.clear(); picturesDrawnBeside.clear(); listNumbering.clear(); report = new DocxExportReport.Builder(); @@ -2040,6 +2045,10 @@ private DocxRules.Rule ruleOf(DocumentNode node) { private void dispatchNode(XWPFDocument document, DocumentNode node) throws Exception { DocxRules.Rule rule = ruleOf(node); + if (overTheFlowDepth == 0 && (node instanceof ParagraphNode + || node instanceof com.demcha.compose.document.node.PageReferenceNode || rule != null)) { + standsIntoTheSpaceAbove(node); + } if (node instanceof ParagraphNode paragraph) { writeParagraph(document, paragraph); } else if (node instanceof com.demcha.compose.document.node.PageReferenceNode reference) { @@ -2979,7 +2988,9 @@ private void holdTheSpaceAboveATable(XWPFDocument document) { boolean tableAbove = currentCell == null ? endsWithATable(document.getBodyElements()) : cellEndsWithItsTableCloser(); - if (pendingSpacingAfter > 0 && lastBodyParagraph == null && !tableAbove) { + // The space owed is the block's own edge and the edges of the containers opened + // around it, which are carried down to it rather than owed. + if (pendingSpacingAfter + carriedSpacingBefore - borderBelow > 0 && lastBodyParagraph == null && !tableAbove) { holdToHairline(newBodyParagraph(document)); } } @@ -3172,9 +3183,10 @@ private void riseIntoItsLine(DocumentNode node) { 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. + // (applyVerticalSpacing, or standsIntoTheSpaceAbove for a negative one, after this): 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; @@ -3719,6 +3731,28 @@ private static boolean drawn(CTBorder border) { return border != null && border.getVal() != STBorder.NONE && border.getVal() != STBorder.NIL; } + /** + * Takes a paragraph's, page reference's or rule's negative top edge out of the space owed + * above it. + * + *

Word has no negative space above a paragraph, and {@link #applyVerticalSpacing} + * writes an edge only where it is positive, so an edge pulling the block up was dropped + * and the block stood that much low, with everything after it. {@code PaymentsInvoice}'s + * header rule is pulled 16.4pt up from the foot of the stack above it, whose band runs + * past its content: the pull was lost, and the rule and the page under it stood 10pt low, + * the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added + * to the edges carried down to the block, as a container's negative edge already is, so + * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it; + * what that cannot give stays unwritten, as before. Text laid over the flow owes no + * space ({@link #writeOverTheFlow}), so its edges move nothing.

+ */ + private void standsIntoTheSpaceAbove(DocumentNode node) { + double edge = node.margin().top() + node.padding().top(); + if (edge < 0) { + carriedSpacingBefore += edge; + } + } + /** * Gives back space already owed above the next block, most recent first: text standing * above its band took it on the page. @@ -5982,18 +6016,21 @@ private static boolean startsWith(byte[] bytes, int... signature) { * below the paragraph before it, and the space below one is the space above the * paragraph after. Both go through the debt every other gap goes through * ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as - * two paragraphs with a gap between them — and a table with nothing above it loses that - * edge, which is the one thing Word genuinely cannot hold.

+ * two paragraphs with a gap between them. With nothing above it — opening the body, or a + * row opening any cell but a table's — a paragraph a tenth of a point tall holds that edge + * ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.

*/ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { owePendingSpacingAfter(node.margin().top() + node.padding().top()); - if (node instanceof RowNode && currentCell != null && currentCell == panelCell) { - // At the top of a panel nothing above holds that space: MerchantInvoice's due-date + if (node instanceof RowNode && currentCell != null && !tablesCells.contains(currentCell.getCTTc())) { + // At the top of a cell nothing above holds that space: MerchantInvoice's due-date // row lost its 16.7pt of top padding and stood against the card's top edge, once the - // card held the page's height. A row in a table's cell keeps to the row height the - // table holds (holdRowHeight). A table is left as it was: holding its space moved - // ObsidianInvoice's line items 6pt below the page's. + // card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its + // column is padded down by, standing 6.3pt above the issuer beside it. A row in a + // table's cell keeps to the row height the table holds (holdRowHeight), and a table + // is left as it was: holding the space of either moved ObsidianInvoice's line items + // 6pt below the page's. holdTheSpaceAboveATable(document); } if (node instanceof RowNode row) { @@ -6592,6 +6629,7 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, // exported it as an empty cell. double previous = currentCellWidth; currentCellWidth = usableWidthOf(cell, placement); + tablesCells.add(cell.getCTTc()); try { writeCellBody(cell, source.content()); } finally { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java index bedc1bc7a..4ffbd56b3 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java @@ -224,14 +224,20 @@ void aTableAfterAPageBreakKeepsTheSpaceAboveIt() throws Exception { } @Test - void aTableOpeningACellIsWrittenAsBefore() throws Exception { - // Only the body takes the paragraph: in a cell it was measured to set content lower - // than the page does — each of ObsidianInvoice's line items 8.5pt lower. + void aRowOpeningATablesCellIsWrittenAsBefore() throws Exception { + // A table's cell takes no paragraph: the table holds its row at least as tall as the + // page makes it, and one there was measured to set content lower than the page does — + // each of ObsidianInvoice's line items 6pt lower. A row's column does (DocxSpaceAboveTest). + com.demcha.compose.document.dsl.SectionBuilder composed = new com.demcha.compose.document.dsl.SectionBuilder(); + composed.padding(new DocumentInsets(12, 0, 0, 0)) + .addRow(inner -> inner.addParagraph("Qty").addParagraph("1")); + com.demcha.compose.document.node.DocumentNode item = composed.build(); try (XWPFDocument document = DocxExports.withLayout(400, 600, 0, page -> page - .addRow(outer -> outer - .addSection(cell -> cell.padding(new DocumentInsets(12, 0, 0, 0)) - .addRow(inner -> inner.addParagraph("Qty").addParagraph("1"))) - .addParagraph("Notes")))) { + .addTable(t -> t + .columns(com.demcha.compose.document.table.DocumentTableColumn.auto(), + com.demcha.compose.document.table.DocumentTableColumn.auto()) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(item), + com.demcha.compose.document.table.DocumentTableCell.text("Notes"))))) { XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); assertThat(cell.getBodyElements().get(0)).as("the nested table opens the cell") diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java index 8dad6feea..eeb046332 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverTheFlowTest.java @@ -59,6 +59,26 @@ void theFlowAfterItStandsAsIfItWereNotThere() throws Exception { } } + @Test + void aLinePulledUpInItTakesNothingFromTheFlowAfterIt() throws Exception { + // A negative top edge takes its pull out of the space owed above a block in the flow; + // a line in a text box owes none, and the masthead keeps the 6pt its section holds + // above it. + DocumentNode pulled = new ShapeContainerBuilder().name("Sidebar") + .rectangle(100, STUB).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .margin(new DocumentInsets(-40, 0, 40 - STUB, -40)) + .position(new ParagraphBuilder().name("Monogram").text("L") + .margin(DocumentInsets.top(-5)).build(), 10, 10, LayerAlign.TOP_LEFT, 0) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page + .add(pulled) + .addSection("Head", head -> head.padding(DocumentInsets.top(6)).addParagraph("Masthead")))) { + assertThat(document.getDocument().getBody().xmlText()).as("the line is in a text box") + .contains(""); + assertThat(before(masthead(document))).as("the section's space above it").isEqualTo(6 * 20L); + } + } + @Test void aLineIsGivenRoomOnTheSideItsTextDoesNotLeanOn() throws Exception { // A right-to-left line is set against the page's right side as a right-aligned one is, diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java new file mode 100644 index 000000000..523a70618 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java @@ -0,0 +1,129 @@ +package com.demcha.compose.document.backend.semantic.docx; + +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.IBodyElement; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * The space above a block reaches Word where Word has nowhere to write it as the page does. + * + *

{@code PaymentsInvoice}'s header rule is pulled 16.4pt up out of the space its header + * stack leaves below itself, and its metadata grid is padded 6.2pt down its column. Word has + * no negative space above a paragraph and no space above a table: the pull was dropped and the + * padding lost, and in Word the grid stood 6.3pt high and the rule and the page under it 10pt + * low.

+ */ +class DocxSpaceAboveTest { + + private static final DocumentTextStyle BODY = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + + @Test + void aRulePulledUpTakesItsPullOutOfTheSpaceOwedAboveIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 20, 0))) + .addLine(line -> line.horizontal(200).thickness(1).color(DocumentColor.BLACK) + .margin(new DocumentInsets(-8, 0, 0, 0))))) { + XWPFParagraph rule = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetPBdr()) + .findFirst().orElseThrow(); + + assertThat(DocxTwips.of(rule.getCTP().getPPr().getSpacing().getBefore()) / 20.0) + .as("20pt owed less the 8pt it is pulled up").isCloseTo(12, within(0.1)); + } + } + + @Test + void aParagraphPulledUpFurtherThanTheSpaceAboveItHasNoSpaceAbove() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 4, 0))) + .addParagraph(p -> p.text("Pulled").textStyle(BODY).margin(new DocumentInsets(-10, 0, 0, 0))))) { + XWPFParagraph pulled = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().contains("Pulled")).findFirst().orElseThrow(); + var spacing = pulled.getCTP().getPPr().getSpacing(); + + assertThat(spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0L).isZero(); + } + } + + @Test + void aPageReferencePulledUpTakesItsPullOutOfTheSpaceOwedAboveIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 20, 0))) + .add(new com.demcha.compose.document.node.PageReferenceNode("Ref", "terms", BODY, + com.demcha.compose.document.node.TextAlign.LEFT, "", DocumentInsets.zero(), + new DocumentInsets(-8, 0, 0, 0))) + .addSection(s -> s.anchor("terms").addParagraph(p -> p.text("Terms").textStyle(BODY))))) { + XWPFParagraph reference = document.getParagraphs().get(1); + + assertThat(reference.getCTP().xmlText()).as("the page reference's paragraph").contains("PAGEREF"); + assertThat(DocxTwips.of(reference.getCTP().getPPr().getSpacing().getBefore()) / 20.0) + .as("20pt owed less the 8pt it is pulled up").isCloseTo(12, within(0.1)); + } + } + + @Test + void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception { + // 10pt owed below the header, the section pulls up 4pt and its paragraph 3pt more: the + // page sets the paragraph 3pt below the header's box. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Header").textStyle(BODY).margin(new DocumentInsets(0, 0, 10, 0))) + .addSection("Pulled", section -> section + .margin(new DocumentInsets(-4, 0, 0, 0)) + .addParagraph(p -> p.text("Pulled").textStyle(BODY).margin(new DocumentInsets(-3, 0, 0, 0)))))) { + XWPFParagraph pulled = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().contains("Pulled")).findFirst().orElseThrow(); + + assertThat(DocxTwips.of(pulled.getCTP().getPPr().getSpacing().getBefore()) / 20.0) + .as("10 less 4 less 3").isCloseTo(3, within(0.1)); + } + } + + @Test + void aRowOpeningAPaddedColumnKeepsThePaddingAboveIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Split", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Issuer").textStyle(BODY)) + .addSection("Meta", cell -> cell + .padding(new DocumentInsets(6, 0, 0, 0)) + .addRow("Grid", grid -> grid + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Label").textStyle(BODY)) + .addParagraph(p -> p.text("Value").textStyle(BODY))))))) { + XWPFTableCell meta = document.getTables().get(0).getRow(0).getCell(1); + List content = meta.getBodyElements(); + int grid = indexOfTheFirstTable(content); + + assertThat(grid).as("something holds the space above the grid").isPositive(); + double above = 0; + for (int i = 0; i < grid; i++) { + var spacing = ((XWPFParagraph) content.get(i)).getCTP().getPPr().getSpacing(); + above += (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0) / 20.0 + + DocxTwips.of(spacing.getLine()) / 20.0; + } + assertThat(above).as("the column's 6pt, and a hairline").isCloseTo(6, within(0.2)); + } + } + + private static int indexOfTheFirstTable(List content) { + for (int i = 0; i < content.size(); i++) { + if (content.get(i) instanceof XWPFTable) { + return i; + } + } + return -1; + } +}