diff --git a/CHANGELOG.md b/CHANGELOG.md index 258dd7eeb..243a4918b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A table or row the layout moves to a new page keeps its top edge there in DOCX.** + The page starts a block a page break moves down at its own top edge, and leaves the gap + before it at the foot of the page above where it fits there. Word has no space above a table, so the edge was + written below the paragraph before it, on the page above, and the long `LumaStudioInvoice`'s + closing pair stood 12pt high at the top of its third page. The edge is now a line of that + height kept with the table: Word drops a paragraph's space above at the top of a page, and + keeps a line's height. `LumaStudioInvoice`'s lines more than 2pt off fall from 20 to none + in Word, and from 22 to 2 in LibreOffice. - **A paragraph opening a table cell keeps its lines the page's distance apart in DOCX.** Word holds a paragraph's line gap inside its lines, and a paragraph of `n` lines then has a gap too many, which comes off the space above it. A paragraph opening a cell has none, so diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index d2014d11c..ce81ad3e3 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,7 +69,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, or in a header or footer | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a paragraph moved to a new page does not yet keep its own | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | 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 | diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index aee0ba34c..ecd00458d 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -2604,26 +2604,26 @@ invoice-luma_studio_long 1:a894ec54e7f3 0.32 £1,700.00 invoice-luma_studio_long 1:57a66d21eb1b -0.92 totaldue invoice-luma_studio_long 1:b4cb2a644826 0.51 £10,200.00 invoice-luma_studio_long 1:60fd7eb93483 0.01 page2of3 -invoice-luma_studio_long 2:ab5aa97074c4 -11.83 notes -invoice-luma_studio_long 2:7c6ab37d83ef -12.15 thankyouforyourbusiness. -invoice-luma_studio_long 2:3d717dfb25d8 -12.20 ifyouhaveanyquestionsregarding -invoice-luma_studio_long 2:479a74142336 -12.24 thisinvoice,pleasegetintouch. -invoice-luma_studio_long 2:1960744391ed -12.28 allworkremainstheintellectualpro -invoice-luma_studio_long 2:eeb53247f32c -12.33 ofluma&co.studiountilpayment -invoice-luma_studio_long 2:9eef22d7d34e -12.37 hasbeenreceivedinfull. -invoice-luma_studio_long 2:3c7ade679fdf -11.83 paymentdetails -invoice-luma_studio_long 2:b1819c9cd5bf -12.10 pleasemakepaymentbybanktransfert -invoice-luma_studio_long 2:8c1e4a24f6a8 -11.86 luma&co.studioltd -invoice-luma_studio_long 2:4381dc2ab142 -11.90 bank -invoice-luma_studio_long 2:447827e26127 -11.90 starlingbank -invoice-luma_studio_long 2:10aeacde20ff -11.90 sortcode -invoice-luma_studio_long 2:c14b2f2f32e4 -11.90 60-83-71 -invoice-luma_studio_long 2:f5bd52edd4c2 -11.90 accountno. -invoice-luma_studio_long 2:4e20613ec3a6 -11.90 98765432 -invoice-luma_studio_long 2:d53cd2cae33d -11.89 iban -invoice-luma_studio_long 2:279d55d978a1 -11.89 gb36srlg60837198765432 -invoice-luma_studio_long 2:e398486c0acf -11.89 bic -invoice-luma_studio_long 2:ddc998160ae0 -11.89 srlggb2l +invoice-luma_studio_long 2:ab5aa97074c4 0.17 notes +invoice-luma_studio_long 2:7c6ab37d83ef -0.15 thankyouforyourbusiness. +invoice-luma_studio_long 2:3d717dfb25d8 -0.20 ifyouhaveanyquestionsregarding +invoice-luma_studio_long 2:479a74142336 -0.24 thisinvoice,pleasegetintouch. +invoice-luma_studio_long 2:1960744391ed -0.28 allworkremainstheintellectualpro +invoice-luma_studio_long 2:eeb53247f32c -0.33 ofluma&co.studiountilpayment +invoice-luma_studio_long 2:9eef22d7d34e -0.37 hasbeenreceivedinfull. +invoice-luma_studio_long 2:3c7ade679fdf 0.17 paymentdetails +invoice-luma_studio_long 2:b1819c9cd5bf -0.10 pleasemakepaymentbybanktransfert +invoice-luma_studio_long 2:8c1e4a24f6a8 0.14 luma&co.studioltd +invoice-luma_studio_long 2:4381dc2ab142 0.10 bank +invoice-luma_studio_long 2:447827e26127 0.10 starlingbank +invoice-luma_studio_long 2:10aeacde20ff 0.10 sortcode +invoice-luma_studio_long 2:c14b2f2f32e4 0.10 60-83-71 +invoice-luma_studio_long 2:f5bd52edd4c2 0.10 accountno. +invoice-luma_studio_long 2:4e20613ec3a6 0.10 98765432 +invoice-luma_studio_long 2:d53cd2cae33d 0.11 iban +invoice-luma_studio_long 2:279d55d978a1 0.11 gb36srlg60837198765432 +invoice-luma_studio_long 2:e398486c0acf 0.11 bic +invoice-luma_studio_long 2:ddc998160ae0 0.11 srlggb2l invoice-luma_studio_long 2:543a2e86433f 0.23 thankyouforchoosingluma&co.studi invoice-luma_studio_long 2:7111b3fcbf69 -0.08 paymentisdueby19june2024. invoice-luma_studio_long 2:bc6d215b63ee 0.01 page3of3 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 91c840f57..0ff38aac0 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -31,7 +31,7 @@ invoice-classic_long 2 2 166 166 0.20 0.27 0 invoice-consulting 1 1 92 92 0.87 1.16 3 invoice-consulting_long 2 2 236 236 0.79 1.26 3 invoice-luma_studio 1 1 97 97 0.12 0.52 2 -invoice-luma_studio_long 3 3 192 192 0.24 11.83 22 +invoice-luma_studio_long 3 3 192 192 0.20 0.41 2 invoice-merchant 1 1 94 94 0.73 1.72 5 invoice-metered 1 1 99 99 0.91 10.00 48 invoice-modern 1 1 54 54 0.07 0.20 0 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 736769c34..b92a1a2f0 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -2509,26 +2509,26 @@ invoice-luma_studio_long 1:a894ec54e7f3 0.32 £1,700.00 invoice-luma_studio_long 1:57a66d21eb1b -0.92 totaldue invoice-luma_studio_long 1:b4cb2a644826 0.51 £10,200.00 invoice-luma_studio_long 1:60fd7eb93483 0.01 page2of3 -invoice-luma_studio_long 2:ab5aa97074c4 -11.83 notes -invoice-luma_studio_long 2:7c6ab37d83ef -12.15 thankyouforyourbusiness. -invoice-luma_studio_long 2:3d717dfb25d8 -12.20 ifyouhaveanyquestionsregarding -invoice-luma_studio_long 2:479a74142336 -12.24 thisinvoice,pleasegetintouch. -invoice-luma_studio_long 2:1960744391ed -12.28 allworkremainstheintellectualpro -invoice-luma_studio_long 2:eeb53247f32c -12.33 ofluma&co.studiountilpayment -invoice-luma_studio_long 2:9eef22d7d34e -12.37 hasbeenreceivedinfull. -invoice-luma_studio_long 2:3c7ade679fdf -11.83 paymentdetails -invoice-luma_studio_long 2:b1819c9cd5bf -12.10 pleasemakepaymentbybanktransfert -invoice-luma_studio_long 2:8c1e4a24f6a8 -11.86 luma&co.studioltd -invoice-luma_studio_long 2:4381dc2ab142 -11.90 bank -invoice-luma_studio_long 2:447827e26127 -11.90 starlingbank -invoice-luma_studio_long 2:10aeacde20ff -11.90 sortcode -invoice-luma_studio_long 2:c14b2f2f32e4 -11.90 60-83-71 -invoice-luma_studio_long 2:f5bd52edd4c2 -11.90 accountno. -invoice-luma_studio_long 2:4e20613ec3a6 -11.90 98765432 -invoice-luma_studio_long 2:d53cd2cae33d -11.89 iban -invoice-luma_studio_long 2:279d55d978a1 -11.89 gb36srlg60837198765432 -invoice-luma_studio_long 2:e398486c0acf -11.89 bic -invoice-luma_studio_long 2:ddc998160ae0 -11.89 srlggb2l +invoice-luma_studio_long 2:ab5aa97074c4 0.17 notes +invoice-luma_studio_long 2:7c6ab37d83ef -0.15 thankyouforyourbusiness. +invoice-luma_studio_long 2:3d717dfb25d8 -0.20 ifyouhaveanyquestionsregarding +invoice-luma_studio_long 2:479a74142336 -0.24 thisinvoice,pleasegetintouch. +invoice-luma_studio_long 2:1960744391ed -0.28 allworkremainstheintellectualpro +invoice-luma_studio_long 2:eeb53247f32c -0.33 ofluma&co.studiountilpayment +invoice-luma_studio_long 2:9eef22d7d34e -0.37 hasbeenreceivedinfull. +invoice-luma_studio_long 2:3c7ade679fdf 0.17 paymentdetails +invoice-luma_studio_long 2:b1819c9cd5bf -0.10 pleasemakepaymentbybanktransfert +invoice-luma_studio_long 2:8c1e4a24f6a8 0.14 luma&co.studioltd +invoice-luma_studio_long 2:4381dc2ab142 0.10 bank +invoice-luma_studio_long 2:447827e26127 0.10 starlingbank +invoice-luma_studio_long 2:10aeacde20ff 0.10 sortcode +invoice-luma_studio_long 2:c14b2f2f32e4 0.10 60-83-71 +invoice-luma_studio_long 2:f5bd52edd4c2 0.10 accountno. +invoice-luma_studio_long 2:4e20613ec3a6 0.10 98765432 +invoice-luma_studio_long 2:d53cd2cae33d 0.11 iban +invoice-luma_studio_long 2:279d55d978a1 0.11 gb36srlg60837198765432 +invoice-luma_studio_long 2:e398486c0acf 0.11 bic +invoice-luma_studio_long 2:ddc998160ae0 0.11 srlggb2l invoice-luma_studio_long 2:543a2e86433f 0.23 thankyouforchoosingluma&co.studi invoice-luma_studio_long 2:7111b3fcbf69 -0.08 paymentisdueby19june2024. invoice-luma_studio_long 2:bc6d215b63ee 0.01 page3of3 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index d0812ab42..c88046b0e 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -31,7 +31,7 @@ invoice-classic_long 2 2 166 166 0.47 0.87 0 invoice-consulting 1 1 92 92 0.99 1.61 3 invoice-consulting_long 2 2 236 236 0.96 1.41 3 invoice-luma_studio 1 1 97 97 0.12 0.52 2 -invoice-luma_studio_long 3 3 192 192 0.24 11.83 22 +invoice-luma_studio_long 3 3 192 192 0.20 0.41 2 invoice-merchant 1 2 94 90 0.21 0.67 0 invoice-metered 1 1 99 99 0.25 0.69 0 invoice-modern 1 1 54 54 0.32 0.52 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 54fd60e46..ca1e36a8a 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -2603,26 +2603,26 @@ invoice-luma_studio_long 1:a894ec54e7f3 0.52 £1,700.00 invoice-luma_studio_long 1:57a66d21eb1b -0.09 totaldue invoice-luma_studio_long 1:b4cb2a644826 0.57 £10,200.00 invoice-luma_studio_long 1:60fd7eb93483 0.17 page2of3 -invoice-luma_studio_long 2:ab5aa97074c4 -11.75 notes -invoice-luma_studio_long 2:7c6ab37d83ef -12.19 thankyouforyourbusiness. -invoice-luma_studio_long 2:3d717dfb25d8 -12.25 ifyouhaveanyquestionsregarding -invoice-luma_studio_long 2:479a74142336 -12.32 thisinvoice,pleasegetintouch. -invoice-luma_studio_long 2:1960744391ed -12.33 allworkremainstheintellectualpro -invoice-luma_studio_long 2:eeb53247f32c -12.41 ofluma&co.studiountilpayment -invoice-luma_studio_long 2:9eef22d7d34e -12.36 hasbeenreceivedinfull. -invoice-luma_studio_long 2:3c7ade679fdf -11.75 paymentdetails -invoice-luma_studio_long 2:b1819c9cd5bf -12.07 pleasemakepaymentbybanktransfert -invoice-luma_studio_long 2:8c1e4a24f6a8 -11.82 luma&co.studioltd -invoice-luma_studio_long 2:4381dc2ab142 -11.91 bank -invoice-luma_studio_long 2:447827e26127 -11.91 starlingbank -invoice-luma_studio_long 2:10aeacde20ff -11.91 sortcode -invoice-luma_studio_long 2:c14b2f2f32e4 -11.91 60-83-71 -invoice-luma_studio_long 2:f5bd52edd4c2 -11.91 accountno. -invoice-luma_studio_long 2:4e20613ec3a6 -11.91 98765432 -invoice-luma_studio_long 2:d53cd2cae33d -11.90 iban -invoice-luma_studio_long 2:279d55d978a1 -11.90 gb36srlg60837198765432 -invoice-luma_studio_long 2:e398486c0acf -11.90 bic -invoice-luma_studio_long 2:ddc998160ae0 -11.90 srlggb2l +invoice-luma_studio_long 2:ab5aa97074c4 0.25 notes +invoice-luma_studio_long 2:7c6ab37d83ef -0.17 thankyouforyourbusiness. +invoice-luma_studio_long 2:3d717dfb25d8 -0.25 ifyouhaveanyquestionsregarding +invoice-luma_studio_long 2:479a74142336 -0.32 thisinvoice,pleasegetintouch. +invoice-luma_studio_long 2:1960744391ed -0.33 allworkremainstheintellectualpro +invoice-luma_studio_long 2:eeb53247f32c -0.41 ofluma&co.studiountilpayment +invoice-luma_studio_long 2:9eef22d7d34e -0.36 hasbeenreceivedinfull. +invoice-luma_studio_long 2:3c7ade679fdf 0.25 paymentdetails +invoice-luma_studio_long 2:b1819c9cd5bf -0.05 pleasemakepaymentbybanktransfert +invoice-luma_studio_long 2:8c1e4a24f6a8 0.18 luma&co.studioltd +invoice-luma_studio_long 2:4381dc2ab142 0.09 bank +invoice-luma_studio_long 2:447827e26127 0.09 starlingbank +invoice-luma_studio_long 2:10aeacde20ff 0.09 sortcode +invoice-luma_studio_long 2:c14b2f2f32e4 0.09 60-83-71 +invoice-luma_studio_long 2:f5bd52edd4c2 0.09 accountno. +invoice-luma_studio_long 2:4e20613ec3a6 0.09 98765432 +invoice-luma_studio_long 2:d53cd2cae33d 0.10 iban +invoice-luma_studio_long 2:279d55d978a1 0.10 gb36srlg60837198765432 +invoice-luma_studio_long 2:e398486c0acf 0.10 bic +invoice-luma_studio_long 2:ddc998160ae0 0.10 srlggb2l invoice-luma_studio_long 2:543a2e86433f 0.35 thankyouforchoosingluma&co.studi invoice-luma_studio_long 2:7111b3fcbf69 -0.06 paymentisdueby19june2024. invoice-luma_studio_long 2:bc6d215b63ee 0.17 page3of3 diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 9b68ba86b..f96f6ed0f 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -31,7 +31,7 @@ invoice-classic_long 2 2 166 166 0.51 0.88 0 invoice-consulting 1 1 92 92 1.10 1.65 3 invoice-consulting_long 2 2 236 236 0.99 1.40 3 invoice-luma_studio 1 1 97 97 0.13 0.42 0 -invoice-luma_studio_long 3 3 192 192 0.23 11.75 20 +invoice-luma_studio_long 3 3 192 192 0.22 0.38 0 invoice-merchant 1 1 94 94 0.30 0.75 0 invoice-metered 1 1 99 99 0.37 0.77 0 invoice-modern 1 1 54 54 0.31 0.57 0 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 6ef4c9191..b7a46aa69 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 @@ -276,6 +276,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The page the node being written starts on, as the layout placed it, counted within the // section being written. private int currentPage; + + /** The page the last block written in the flow ended on, -1 before the first. */ + private int lastEndPage = -1; private int sectionIndex; // Where the section being written starts among the body's elements. private int sectionFirstElement; @@ -655,6 +658,7 @@ private byte[] write(List sections, Path outputFile) throws Exc nextDrawingId = 100_000; anchors.reset(); currentPage = 0; + lastEndPage = -1; clipContainer = null; cellDrawing = CellDrawing.NONE; tableDrawings = null; @@ -777,6 +781,8 @@ private void beginSection(SemanticSection section, int index) { pendingItemSpacing = 0; anItemWasWritten = false; forgetTheHang(); + // The layout counts a section's pages from its own first. + lastEndPage = -1; lastBodyParagraph = null; lastWrittenNode = null; lastWrittenParagraph = null; @@ -1662,6 +1668,17 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio if (placed != null) { currentPage = placed.startPage(); } + boolean inTheBody = currentCell == null; + try { + writePlacedNode(document, node); + } finally { + if (placed != null && inTheBody) { + lastEndPage = Math.max(lastEndPage, placed.endPage()); + } + } + } + + private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Exception { boolean keepTogether = node.keepTogether() && layout.onOnePage(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); String anchor = blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); @@ -7242,7 +7259,23 @@ private static boolean startsWith(byte[] bytes, int... signature) { */ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { + // The layout starts a block it moves to a new page at the block's own top edge: what + // the page above holds below its last block, and the gap between the two, stay there. + boolean onANewPage = currentCell == null && startsAPageOfItsOwn(node); + if (onANewPage) { + // A band's resumed gap is the page above's too: made the one owed first, so it is + // written there rather than taken into the line below. + resumeHere(); + flushSpacingAfter(); + // A border or a line hanging below the last block stands on the page above, as a + // page break leaves it (writePageBreak). + borderBelow = 0; + forgetTheHang(); + } owePendingSpacingAfter(node.margin().top() + node.padding().top()); + if (onANewPage) { + holdTheSpaceAboveOnItsPage(document); + } 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 @@ -9947,6 +9980,46 @@ private void separateFromTheTableAbove(XWPFDocument document) { } } + /** + * Whether the layout starts a block on a page after the one the block before it ended on: + * the page broke between them, and the block's space above went with it. + */ + private boolean startsAPageOfItsOwn(DocumentNode node) { + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); + return placed != null && lastEndPage >= 0 && placed.startPage() > lastEndPage; + } + + /** + * Holds the space owed above a table the layout starts on a new page, on that page. + * + *

The page keeps a block's space above it where a page break moves the block down: the + * closing pair of the long {@code LumaStudioInvoice} starts its third page 12pt below the + * margin. Word has no space above a table, so the space was written below the paragraph + * before it, at the foot of the page above, and the pair stood 12pt high. A paragraph's + * space above at the top of a page Word drops too, measured; the height of a line it keeps. + * So the space is the height of a line of its own, kept with the table.

+ */ + private void holdTheSpaceAboveOnItsPage(XWPFDocument document) { + if (!(carriedSpacingBefore + pendingSpacingAfter - borderBelow - pullBelow > 0.01)) { + return; + } + XWPFParagraph spacer = newBodyParagraph(document); + CTPPr properties = spacer.getCTP().isSetPPr() ? spacer.getCTP().getPPr() : spacer.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + long held = twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null); + spacing.setBefore(BigInteger.ZERO); + spacing.setLineRule(STLineSpacingRule.EXACT); + spacing.setLine(BigInteger.valueOf(Math.max(1, held))); + if (!properties.isSetKeepNext()) { + properties.addNewKeepNext(); + } + // The mark of a line this tall in the document's size would not grow it; a point keeps + // it from standing out of the line in an editor that measures the mark. + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTParaRPr mark = + properties.isSetRPr() ? properties.getRPr() : properties.addNewRPr(); + (mark.sizeOfSzArray() > 0 ? mark.getSzArray(0) : mark.addNewSz()).setVal(BigInteger.valueOf(ONE_POINT_IN_HALF_POINTS)); + } + /** Makes a paragraph that holds no text as short as a separator between tables. */ private static void holdToHairline(XWPFParagraph para) { CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java new file mode 100644 index 000000000..6b75c4d1b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java @@ -0,0 +1,128 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.style.DocumentInsets; +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.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A table the layout moves to a new page keeps its own space above it there in Word. + * + *

The page keeps a block's top edge where a page break moves the block down; the gap + * between it and the block before stays at the foot of the page above. Word has no space + * above a table, so the edge was written below the paragraph before it, on the page above: + * the long {@code LumaStudioInvoice}'s closing pair stood 12pt high at the top of its third + * page. Word drops a paragraph's space above at the top of a page, and keeps a line's height.

+ * + * @author Artem Demchyshyn + */ +class DocxSpaceOnANewPageTest { + + private static final double EDGE = 12; + private static final double GAP = 4; + + @Test + void aRowMovedToANewPageHoldsItsTopEdgeInALineKeptWithIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, content(8))) { + XWPFParagraph spacer = paragraphBefore(document, onlyTable(document)); + CTSpacing spacing = spacer.getCTP().getPPr().getSpacing(); + + assertThat(spacing.getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + assertThat(DocxTwips.of(spacing.getLine())).as("the row's top edge, a line's height") + .isEqualTo(Math.round(EDGE * 20)); + assertThat(spacer.getCTP().getPPr().isSetKeepNext()).as("kept with the row").isTrue(); + assertThat(spacingAfter(paragraphBefore(document, spacer))) + .as("the gap stays at the foot of the page above, the edge does not") + .isEqualTo(Math.round(GAP * 20)); + } + } + + @Test + void aTableMovedToANewPageHoldsItsTopEdgeTheSameWay() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, page -> { + page.spacing(GAP); + for (int i = 0; i < 8; i++) { + page.addParagraph("Line " + i); + } + page.addTable(t -> t.name("Totals").margin(new DocumentInsets(EDGE, 0, 0, 0)) + .autoColumns(2).row("Subtotal", "8,500.00").row("VAT", "1,700.00")); + })) { + XWPFParagraph spacer = paragraphBefore(document, onlyTable(document)); + + assertThat(DocxTwips.of(spacer.getCTP().getPPr().getSpacing().getLine())).isEqualTo(Math.round(EDGE * 20)); + assertThat(spacer.getCTP().getPPr().isSetKeepNext()).isTrue(); + } + } + + @Test + void aRowMovedToANewPageAfterATableHoldsItsTopEdgeBetweenThem() throws Exception { + // Seven rows fill the first page, and the row moves to the second (with five or six it + // stays on the first). Word joins two tables with nothing between them; the line holding + // the edge is what keeps them apart, on the row's page. + try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, page -> { + page.spacing(GAP); + page.addTable(t -> { + t.name("Lines").autoColumns(2); + for (int i = 0; i < 7; i++) { + t.row("Item " + i, "100.00"); + } + }); + page.addRow(row -> row.name("Closing").margin(new DocumentInsets(EDGE, 0, 0, 0)) + .addParagraph("Notes").addParagraph("Payment")); + })) { + XWPFTable closing = document.getTables().get(document.getTables().size() - 1); + XWPFParagraph spacer = paragraphBefore(document, closing); + + assertThat(document.getTables()).hasSize(2); + assertThat(DocxTwips.of(spacer.getCTP().getPPr().getSpacing().getLine())).isEqualTo(Math.round(EDGE * 20)); + assertThat(spacer.getCTP().getPPr().isSetKeepNext()).isTrue(); + } + } + + @Test + void aRowOnThePageOfTheBlockBeforeItIsWrittenAsBefore() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 600, 20, content(2))) { + XWPFParagraph above = paragraphBefore(document, onlyTable(document)); + + assertThat(above.getText()).as("no line between them").isNotEmpty(); + assertThat(spacingAfter(above)).isEqualTo(Math.round((GAP + EDGE) * 20)); + } + } + + /** Lines of text, then a row with a top edge (a row is never broken), the flow's gap between each. */ + private static Consumer content(int lines) { + return page -> { + page.spacing(GAP); + for (int i = 0; i < lines; i++) { + page.addParagraph("Line " + i); + } + page.addRow(row -> row.name("Closing") + .margin(new DocumentInsets(EDGE, 0, 0, 0)) + .addParagraph("Notes").addParagraph("Payment")); + }; + } + + private static XWPFTable onlyTable(XWPFDocument document) { + return document.getTables().get(0); + } + + private static XWPFParagraph paragraphBefore(XWPFDocument document, IBodyElement element) { + List body = document.getBodyElements(); + return (XWPFParagraph) body.get(body.indexOf(element) - 1); + } + + private static long spacingAfter(XWPFParagraph paragraph) { + CTSpacing spacing = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing(); + return spacing == null || !spacing.isSetAfter() ? 0 : DocxTwips.of(spacing.getAfter()); + } +}