From db3cbb82723cd3c3b3ef7652151ce6710a44a3c6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 21 Oct 2026 12:53:14 +0100 Subject: [PATCH] fix(docx): hold the space above a list, a line pair and a band's table on a new page Word drops the space above a paragraph at the top of a page. A paragraph, a picture and a spacer the layout moves to a new page already hold theirs in a line kept with them; a list, a title and its dates set as one line, and a table, a row or a panel opening a band did not. Measured in Word and LibreOffice, a list with a 14pt margin stood 14pt high, one padded 10pt or opening a section padded 10pt stood 10pt high, and so did one opening a stack of one layer or a band with a 14pt margin, and such a line pair. A row, a table or a panel opening a band with a 14pt margin stood 14pt high: the band's margin was left at the foot of the page above. A list and a line pair now hold their top edge as a paragraph does, in the flow, in a stack of one layer and first in a band opening its page, and a list's wrapped first item takes the gap between its lines out of that line. A pair whose text starts above its overlay's top is left as it was. A table, a row or a panel opening such a band carries the band's own top edge down and holds it with its own, as one in the flow holds the edges of the containers opening with it. The page above keeps what the block before the band owes below itself, and only that. Each now stands within 0.1pt of the same block, or a paragraph, moved to a new page in the flow. --- CHANGELOG.md | 20 + .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 6 +- .../semantic/docx/DocxSemanticBackend.java | 130 ++++- .../semantic/docx/DocxBandRaiseTest.java | 23 + .../docx/DocxBlockOpeningAPageTest.java | 443 ++++++++++++++++++ .../semantic/docx/DocxLinePairRaiseTest.java | 35 +- .../semantic/docx/DocxListItemLowerTest.java | 25 +- 8 files changed, 660 insertions(+), 24 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockOpeningAPageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7ab8c5c..70274f3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps the space above a list, a line pair, and a table, a row or a panel + opening a band, on the page the layout moves them to.** Word drops the space above a paragraph + at the top of a page. A paragraph, a picture and a spacer there already hold theirs in a line + kept with them; these did not. + - Measured in Word and LibreOffice on Windows, each moved to a new page: + - a list with a 14pt margin stood 14pt high, and so did one opening a stack of one layer or a + band with a 14pt margin; a list padded 10pt, or opening a section padded 10pt, stood 10pt + high; + - a title and its dates set as one line in a stack with a 14pt margin stood 14pt high, in the + flow and opening a band; + - a row, a table or a panel opening a band with a 14pt margin stood 14pt high: the band's + margin was left at the foot of the page above. + - Each now holds that space in a line kept with it, as a paragraph does. A list and a line pair + stand within 0.1pt of a paragraph moved so; a row, a table and a panel within 0.1pt of the same + block moved to a new page in the flow. A list's wrapped first item takes the gap between its + lines out of that line, as a paragraph does there, and stands where such a paragraph does. A + row, a table or a panel opening a band holds the band's margin above it with its own, and the + gap before the band stays at the foot of the page above, as one in the flow leaves it. A line + pair whose text the page sets above its overlay's top is left as it was. + - None of the corpus's 62 DOCX changes, and none of its 1246 notes. - **A DOCX export sets a later layer where the page does when its first block's edge reaches above the layers before it.** A layer written after another starts at the page's gap below them, less what its first block writes above its content itself. Where the block held more diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 55edd51d4..726fd5e11 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -122,7 +122,7 @@ honour an option ignores it (documented contract). | Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) | | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | -| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing | +| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list or such a line the layout moves to a new page in the flow holds its space above there as a paragraph does. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing | | Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured | ## Output surface and lifecycle diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 9915e00b6..86fd007e7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,9 +67,9 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), 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 character the page draws nothing for — a control such as a tab or a bell, a soft hyphen, a zero-width space, one of private use — is not written either, where the file held a question mark for most controls, the direction marks and joiners kept; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, as far as the cell's text and no further, and the report names what is held back; a row's first child the page sets out past the row's left edge starts at it, named; 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, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A stack of one layer is in the flow: the paragraph opening one does the same, with the stack's own edges, though not the layer's offset in the stack, up or down, which is named and not written. So does the first paragraph of a band of layers the layout moves to a new page, in the flow, in a stack of one layer or in a section, a backdrop drawn before it or not: the band's own edges, its room above that paragraph and the edges of the containers opening with it (measured: a band with a 14pt margin, one with a backdrop laid first, one nested in a stack of one layer with that margin, and one in a section with a 10pt margin each stood that much high in Word and LibreOffice; held, each stands where the page sets it, within a point, the backdrop where the page draws it). A paragraph in a table cell, any other overlay or a list is left to its container | -| 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. An item the page reads as markdown is written as the page sets it, as a paragraph is. 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). Padding thinner than its share gives what it has; Word sets each cell at its content and, on each side, its padding or its share where that is more, and the row at its tallest cell, so a row stands taller where a cell of text then needs more than the page's height — by that much, which the report names, added up for the table (`cell rule`; measured in Word and LibreOffice on Windows against the same table unruled, to a tenth of a point: four rows with no padding, 3pt rules 15pt and 1pt rules 5pt; a two-line cell with no padding beside a padded one-line cell, each counted on its own). Cells built from a node, cells spanning rows and empty cells in rows held at the page's height are not counted; where the layout does not tell a row's height — a table composed in a cell, or no layout — the report names the most it can be. Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). 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 row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). 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`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `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 gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs, spacers and pictures: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | +| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), 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 character the page draws nothing for — a control such as a tab or a bell, a soft hyphen, a zero-width space, one of private use — is not written either, where the file held a question mark for most controls, the direction marks and joiners kept; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, as far as the cell's text and no further, and the report names what is held back; a row's first child the page sets out past the row's left edge starts at it, named; 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, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A stack of one layer is in the flow: the paragraph opening one does the same, with the stack's own edges, though not the layer's offset in the stack, up or down, which is named and not written. So does the first paragraph of a band of layers the layout moves to a new page, in the flow, in a stack of one layer or in a section, a backdrop drawn before it or not: the band's own edges, its room above that paragraph and the edges of the containers opening with it (measured: a band with a 14pt margin, one with a backdrop laid first, one nested in a stack of one layer with that margin, and one in a section with a 10pt margin each stood that much high in Word and LibreOffice; held, each stands where the page sets it, within a point, the backdrop where the page draws it). A title and its dates an overlay sets as one line do the same, the room above the line inside the overlay held with its edges (measured: such a pair in a stack with a 14pt margin, in the flow or opening a band, stood 14pt high in Word and LibreOffice; held, within 0.1pt of a paragraph moved so); one whose text the page sets above its overlay's top is left as it was. A paragraph in a table cell or any other overlay is left to its container, and a list's items to their list (see Lists) | +| 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. An item the page reads as markdown is written as the page sets it, as a paragraph is. A list the layout moves to a new page — in the flow, a stack of one layer's included, or first in a band of layers opening its page — keeps the space the layout leaves above its first item there, its margin, its padding and the edges of the containers opening with it, as an empty line that tall before it, kept with it, as a body paragraph does; a wrapped first item takes the gap between its lines out of that line (measured: a list with a 14pt margin stood 14pt high in Word and LibreOffice, one padded 10pt or opening a section padded 10pt 10pt, one opening a stack of one layer or a band with a 14pt margin 14pt; held, each within 0.1pt of a paragraph moved so). 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). Padding thinner than its share gives what it has; Word sets each cell at its content and, on each side, its padding or its share where that is more, and the row at its tallest cell, so a row stands taller where a cell of text then needs more than the page's height — by that much, which the report names, added up for the table (`cell rule`; measured in Word and LibreOffice on Windows against the same table unruled, to a tenth of a point: four rows with no padding, 3pt rules 15pt and 1pt rules 5pt; a two-line cell with no padding beside a padded one-line cell, each counted on its own). Cells built from a node, cells spanning rows and empty cells in rows held at the page's height are not counted; where the layout does not tell a row's height — a table composed in a cell, or no layout — the report names the most it can be. Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). 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 row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). 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`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `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 gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table in the flow (body paragraphs, spacers and pictures: see their rows). A table, a row or a panel opening a band of layers the layout moves to a new page holds the band's own top edge there with its own — the band's margin and the room it leaves above the block — while the gap before the band stays at the foot of the page above (measured: each, opening a band with a 14pt margin, stood 14pt high in Word and LibreOffice, the margin left on the page above; held, within 0.1pt of the same block moved to a new page in the flow). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | 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, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) | | 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. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture in the flow, a stack of one layer's included, or first in a band of layers there, that the layout moves to a new page keeps the space the layout leaves above it there — its margin, its padding, the room above it in its box, the edges of the containers opening with it and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, as a body paragraph does. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | 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 6922fc9f7..e94858e4a 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 @@ -259,6 +259,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // its first written in the body: it opens that page in Word, though a layer of room went before // it, and Word drops the space above it there (raiseIntoTheSpaceAbove). private boolean bandOpensItsPage; + // What the block before that band owes below itself, which the layout leaves at the foot of the + // page above (leaveThePageAboveTheBand). + private double bandGapBefore; private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP paragraphOpeningABandsPage; // How many paragraphs have been written: what a raised line owes below it is paid by the next // one, as its space above, or by flushSpacingAfter. @@ -664,6 +667,12 @@ private record LineToLower(XWPFParagraph paragraph, long twips, int depth, int p */ private boolean listOpensItsPage; + /** + * The line holding the top edge of the list being written on the page the layout opens with it, + * until its first item takes the gap between its lines from it ({@link #applyItemLineGap}). + */ + private XWPFParagraph listEdgeLine; + /** Whether the layout placed the list being written all on one page. */ private boolean listOnOnePage; @@ -1117,6 +1126,7 @@ private byte[] write(List sections, Path outputFile) throws Exc cellTextShift = 0; paragraphOpeningItsPage = null; bandOpensItsPage = false; + bandGapBefore = 0; paragraphOpeningABandsPage = null; paragraphsWritten = 0; panelsFirstLineKeeps = 0; @@ -1140,6 +1150,7 @@ private byte[] write(List sections, Path outputFile) throws Exc topEdgeHeld = 0; topEdgeLine = null; topEdgeLineOver = null; + listEdgeLine = null; clipContainer = null; cellDrawing = CellDrawing.NONE; tableDrawings = null; @@ -5439,8 +5450,19 @@ private void writeList(XWPFDocument document, // the layer resumes, and its own edges come after, as a table's do. Left to its first // item's paragraph, the resume took their place, and a list with a 20pt margin in a later // layer stood 7pt high in Word and LibreOffice (measured). - resumeHere(); - carriedSpacingBefore += list.margin().top() + list.padding().top(); + // One the layout opens its page with has its top edge held there, as a paragraph has: in the + // flow, a stack of one layer's included, and first in a band opening its page. Word drops the + // space above its first item at the top of a page, and a list with a 14pt margin stood 14pt + // high in Word and LibreOffice, one opening a section padded 10pt, 10pt high. + double edge = list.margin().top() + list.padding().top(); + listEdgeLine = null; + if (opensItsPageInTheFlow(list)) { + edge -= holdTheTopEdgeAboveItsLine(document, list, 0); + listEdgeLine = topEdgeLine; + } else { + resumeHere(); + } + carriedSpacingBefore += edge; double previousItemSpacing = pendingItemSpacing; pendingItemSpacing = list.itemSpacing(); boolean previousItemWritten = anItemWasWritten; @@ -5875,11 +5897,25 @@ private boolean indentAsTheItemIs(XWPFParagraph para, DocxLayoutMetrics.ItemText * @return how the layout set the item, or {@code null} where its items are not matched */ private DocxLayoutMetrics.ItemText applyItemLineGap(XWPFParagraph item) { + // The first item's top edge is its list's, which a line holds where the layout opens a page + // with the list (writeList): the gap between the item's lines comes out of that line, as a + // paragraph's does there. Left in the item's lines, a wrapped first item with a 4pt gap + // stood 2.2pt low in Word and LibreOffice. + boolean underTheEdgeLine = listEdgeLine != null; + if (underTheEdgeLine) { + topEdgeLine = listEdgeLine; + topEdgeLineOver = item; + listEdgeLine = null; + } DocxLayoutMetrics.ItemText laidOut = listItemLines.poll(); if (laidOut != null) { applyLineGap(item, listLineGap, laidOut.lines().size()); settleTheItemsLines(item, laidOut); } + if (underTheEdgeLine) { + topEdgeLine = null; + topEdgeLineOver = null; + } return laidOut; } @@ -6847,11 +6883,16 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint boolean onANewPage = false; if (index > 0) { writePageBreak(document); - } else if (currentCell == null && startsAPageOfItsOwn(node)) { + } else if (currentCell == null && (bandOpensItsPage || startsAPageOfItsOwn(node))) { // What the block above leaves below itself stays on its page, and the space above // the panel is held on its own, as at a table the layout moves to a new page - // (writeTableWithItsOwnSpacing). - leaveThePageAbove(); + // (writeTableWithItsOwnSpacing): a band's first, where the band opens its page, the + // band's top edge with it (leaveThePageAboveTheBand). + if (bandOpensItsPage) { + leaveThePageAboveTheBand(node); + } else { + leaveThePageAbove(); + } onANewPage = true; } List piece = pieces.get(index); @@ -9837,10 +9878,18 @@ private void applyInset(XWPFParagraph para) { */ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLinePair.Pair pair) throws Exception { + // Its top edge is held on the page the layout opens with it, as a paragraph's is: in the flow, + // a stack of one layer's included, and first in a band opening its page. Word drops the space + // above the line there, and a pair in a stack with a 14pt margin stood 14pt high in Word and + // LibreOffice. A pair whose text the page sets above its overlay's top is left as it was. boolean opensItsPage = currentCell == null && startsAPageOfItsOwn(overlay); - double text = pair.line(); double above = overlay.margin().top() + pair.above(); - if (above >= 0) { + boolean holdsItsEdge = above >= 0 && pair.above() >= 0 && opensItsPageInTheFlow(overlay); + double text = pair.line(); + if (holdsItsEdge) { + carriedSpacingBefore += above + - holdTheTopEdgeAboveItsLine(document, overlay, pair.above() - overlay.padding().top()); + } else if (above >= 0) { carriedSpacingBefore += above; } else { standsAboveItsCell(takeBackSpaceAbove(-above)); @@ -14088,6 +14137,32 @@ private void leaveThePageAbove() { forgetTheHang(); } + /** + * Leaves on the page above what a band the layout moves to a new page leaves there, where the + * band opens with a block Word puts no space of its own above — a table, a row or a panel: the + * space the block before the band owes below itself ({@link #bandGapBefore}), as above such a + * block moved in the flow ({@link #leaveThePageAbove}). The band's own top edge there, its + * margin and the room it leaves above the block, is carried down as the edge of a container + * opening with the block, which the block holds above itself on its page with its own edge, as + * in the flow ({@link #holdTheSpaceAboveOnItsPage}). Left on the page above with the gap, a row, + * a table or a panel opening a band with a 14pt margin stood 14pt high in Word and LibreOffice. + * + *

The edge is the space the page leaves above the block's own margin on that page. The band + * measures its resume to the first leaf it writes, past a row's or a panel's own edges, which + * the block writes itself: what the resume holds besides the gap and the edge is not owed + * again. Without a canvas to say how much, the edge is all the band resumes past the gap.

+ */ + private void leaveThePageAboveTheBand(DocumentNode block) { + double onItsPage = spaceAboveOnItsPage(block) - block.margin().top() - layerOffsetNotWritten; + resumeHere(); + double owed = pendingSpacingAfter; + double gap = Math.max(0, Math.min(owed, bandGapBefore)); + double edge = Double.isNaN(onItsPage) ? owed - gap : Math.max(0, onItsPage); + pendingSpacingAfter = gap; + leaveThePageAbove(); + carriedSpacingBefore += edge; + } + private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { if (node instanceof TableNode table && !table.rows().isEmpty()) { @@ -14095,8 +14170,13 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod } // 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 first block, where the band opens its page, opens that page too, the band's top + // edge above it (leaveThePageAboveTheBand). + boolean opensItsBand = currentCell == null && bandOpensItsPage; + boolean onANewPage = currentCell == null && (opensItsBand || startsAPageOfItsOwn(node)); + if (opensItsBand) { + leaveThePageAboveTheBand(node); + } else if (onANewPage) { leaveThePageAbove(); } // A table a band's or a column's layer opens is that layer's first block, so it starts @@ -16804,6 +16884,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay // holds the band's top edge above its first block (holdAParagraphsTopEdgeOnItsPage), and // the block's line is not raised into it (writeParagraph). bandOpensItsPage = opensItsPage; + bandGapBefore = opensItsPage ? pendingSpacingAfter : 0; resumeSpacing = above - Math.min(Math.max(0, above), hang); // A list's items lowered into it are lowered as far as that space takes it, and the // rest taken back, as all of it is where the band opens a page (lowerIntoTheSpaceAbove). @@ -18827,8 +18908,10 @@ private static void holdInALineKeptWithTheNext(XWPFParagraph line, long twips) { * * @param room the space above the block's content inside its box, past its padding: a * contained picture's room ({@link #roomRound}), none for text + * @return whether a line holds the edge: none where the layout leaves no space above the + * block's content on that page */ - private void holdAParagraphsTopEdgeOnItsPage(XWPFDocument document, DocumentNode node, double room) { + private boolean holdAParagraphsTopEdgeOnItsPage(XWPFDocument document, DocumentNode node, double room) { double inside = Math.max(0, node.padding().top() + room); double own = Math.max(0, node.margin().top() + node.padding().top() + room); // A band's first block: everything owed above it — the gap before the band, the band's @@ -18851,7 +18934,7 @@ private void holdAParagraphsTopEdgeOnItsPage(XWPFDocument document, DocumentNode pullBelow = 0; borderBelow = 0; if (!(kept > 0.01)) { - return; + return false; } carriedSpacingBefore += own; XWPFParagraph line = newBodyParagraph(document); @@ -18866,6 +18949,31 @@ private void holdAParagraphsTopEdgeOnItsPage(XWPFDocument document, DocumentNode topEdgeHeldAbove = node; topEdgeHeld = own; topEdgeLine = line; + return true; + } + + /** + * Holds the top edge of a list or a line pair the layout opens its page with in the flow, as a + * paragraph's is ({@link #holdAParagraphsTopEdgeOnItsPage}): each is written as paragraphs, the + * first a line whose space above Word drops there. Each writes its own edge above that line, + * with none of a paragraph's spacing for the hold to take it back from. The hold stays + * {@link #topEdgeLine}, for a list's first item to take the gap between its lines from + * ({@link #applyItemLineGap}); none where no line is written. + * + * @param room the space above the first line inside the block's box, past its padding: a + * pair's line stands that far below its overlay's padding, a list's at it + * @return how much of the block's own top edge the hold takes, which the block no longer owes + * above its line: its margin, padding and room above, where they are not below zero + */ + private double holdTheTopEdgeAboveItsLine(XWPFDocument document, DocumentNode node, double room) { + topEdgeLine = null; + if (!holdAParagraphsTopEdgeOnItsPage(document, node, room)) { + return 0; + } + double held = topEdgeHeld; + topEdgeHeldAbove = null; + topEdgeHeld = 0; + return held; } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java index cab4ac800..9ceb96f65 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java @@ -177,6 +177,29 @@ void aListOpeningABandsPageLeavesItsFirstItemWhereWordSetsItAndRaisesTheNext() t XWPFParagraph two = export.paragraph("Two"); assertThat(export.placed("Band").startPage()).as("the premise: the band opens the second page").isOne(); + assertThat(before(one)).as("the band's padding is held in a line above it, which Word keeps").isZero(); + assertThat(position(one)).as("raised by its position, not into the space Word drops") + .isCloseTo((int) Math.round(raiseOf(one) * 2), within(1)); + assertThat(position(two)).as("the next item moves").isZero(); + } + } + + @Test + void aListOpeningABandsPageWithNoEdgeToHoldIsNotRaisedIntoTheGapAboveIt() throws Exception { + // A band with no edge of its own: the gap before it, left at the foot of the page above by + // the layout, is written above its first item, and Word drops it at the top of the page. + try (Export export = export(page -> page + .spacer(0, 480) + .addParagraph(p -> p.name("Above").text("Above").margin(DocumentInsets.bottom(10))) + .addLayerStack(stack -> stack.name("Band") + .back(new ShapeBuilder().name("Backdrop").size(200, 140).build()) + .layer(new com.demcha.compose.document.dsl.ListBuilder().noMarker().textStyle(TITLE) + .itemSpacing(10).items("One", "Two").build(), LayerAlign.TOP_LEFT)))) { + XWPFParagraph one = export.paragraph("One"); + XWPFParagraph two = export.paragraph("Two"); + + assertThat(export.placed("Band").startPage()).as("the premise: the band opens the second page").isOne(); + assertThat(export.placed("Above").startPage()).as("the premise: the paragraph above stays on the first").isZero(); assertThat(before(one)).as("the premise: space above it to raise it into").isPositive(); assertThat(position(one)).as("raised by its position, not into the space Word drops") .isCloseTo((int) Math.round(raiseOf(one) * 2), within(1)); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockOpeningAPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockOpeningAPageTest.java new file mode 100644 index 000000000..3656038ac --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockOpeningAPageTest.java @@ -0,0 +1,443 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +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.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A list, a line pair, and a table, a row or a panel opening a band keep the space the layout + * leaves above them on the page it moves them to, in Word. + * + *

Word drops the space above a paragraph at the top of a page. Measured in Word 16 and + * LibreOffice on Windows: a list with a 14pt margin the layout moved to a new page stood 14pt high + * in both, one padded 10pt or opening a section padded 10pt, 10pt; so did one opening a stack of + * one layer or a band with a 14pt margin, and a title and its dates set as one line in such a + * stack. A row, a table or a panel opening a band with a 14pt margin stood 14pt high too: the + * band's margin was left on the page above. Each held in a line kept with it, as a paragraph's + * edge is, stood where the page sets it, within 0.1pt of the paragraphs round it.

+ * + * @author Artem Demchyshyn + */ +class DocxBlockOpeningAPageTest { + + private static final double EDGE = 12; + + /** + * The page's content is 160pt tall: 300 x 200 with a 20pt margin. A spacer fills 150pt of it, + * and nothing taller than 10pt fits under it. + */ + private static final double CONTENT = 160; + + @Test + void aListsMarginIsHeldInALineKeptWithItsFirstItem() throws Exception { + Consumer content = movedToANewPage(list(DocumentInsets.top(EDGE), DocumentInsets.zero())); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + XWPFParagraph opening = paragraphWith(document, "Opening"); + XWPFParagraph line = paragraphBefore(document, opening); + CTSpacing spacing = spacing(line); + + assertThat(line.getText()).isEmpty(); + assertThat(spacing.getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + assertThat(DocxTwips.of(spacing.getLine())).as("the margin, a line's height").isEqualTo(Math.round(EDGE * 20)); + assertThat(line.getCTP().getPPr().isSetKeepNext()).as("kept with the item").isTrue(); + assertThat(before(spacing)).as("the filler's height stays above the line").isEqualTo(owed(CONTENT - 10)); + assertThat(before(spacing(opening))).as("its edge is the line's").isZero(); + } + } + + @Test + void aListsPaddingIsHeld() throws Exception { + Consumer content = movedToANewPage(list(DocumentInsets.zero(), DocumentInsets.top(9))); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + XWPFParagraph opening = paragraphWith(document, "Opening"); + + assertThat(lineBefore(document, opening)).isEqualTo(180); + assertThat(before(spacing(opening))).isZero(); + } + } + + @Test + void aListOpeningAPaddedSectionHoldsTheSectionsPadding() throws Exception { + Consumer content = page -> page.spacer(0, CONTENT - 10) + .addSection(section -> section.padding(DocumentInsets.top(10)) + .add(list(DocumentInsets.zero(), DocumentInsets.zero()))); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(200); + } + } + + @Test + void aListOpeningABandHoldsTheBandsMargin() throws Exception { + Consumer content = movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)) + .topLeft(list(DocumentInsets.zero(), DocumentInsets.zero())) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(Math.round(EDGE * 20)); + assertThat(paragraphBefore(document, paragraphWith(document, "Caption")).getText()) + .as("no line before the second layer").isEqualTo("Opening"); + } + } + + @Test + void aWrappedFirstItemTakesTheGapBetweenItsLinesOutOfTheLine() throws Exception { + // As a paragraph's does where a line holds its edge: Word drops the space above the item, so + // what the gap took from it would set the item's first line that much low — 2.2pt, measured. + Consumer content = movedToANewPage(new ListBuilder().name("List") + .items("Opening words that run on past the width of this page and wrap onto a second line") + .lineSpacing(4).margin(DocumentInsets.top(EDGE)).build()); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + XWPFParagraph opening = document.getParagraphs().stream() + .filter(p -> p.getText().startsWith("Opening")).findFirst().orElseThrow(); + + assertThat(lineBefore(document, opening)).as("the margin less the 4pt gap").isEqualTo(Math.round(EDGE * 20) - 80); + assertThat(before(spacing(opening))).isZero(); + } + } + + @Test + void aListOnItsPageHoldsNoLine() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(300, 600, 20, + movedToANewPage(list(DocumentInsets.top(EDGE), DocumentInsets.zero())))) { + XWPFParagraph opening = paragraphWith(document, "Opening"); + + assertThat(DocxTwips.of(spacing(paragraphBefore(document, opening)).getLine())) + .as("the filler's hairline, no line held").isEqualTo(HAIRLINE); + assertThat(before(spacing(opening))).as("its margin above it").isPositive(); + } + } + + @Test + void aListInARowsCellHoldsNoLine() throws Exception { + // The row's table opens the page; the list's items are its cell's. + Consumer content = page -> page.spacer(0, CONTENT - 10) + .addRow(row -> row.add(new SectionBuilder().add(list(DocumentInsets.top(EDGE), DocumentInsets.zero())).build()) + .addParagraph("Beside")); + assertOpensTheSecondPage(content, "List"); + try (XWPFDocument document = export(content)) { + assertThat(document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0).getText()) + .as("the item opens its cell").isEqualTo("Opening"); + } + } + + @Test + void aTableOpeningABandHoldsTheBandsMargin() throws Exception { + Consumer content = movedToANewPage(bandOpeningWith(new TableBuilder().name("Block") + .autoColumns(2).row("Opening", "Beside").build())); + assertOpensTheSecondPage(content, "Block"); + try (XWPFDocument document = export(content)) { + XWPFParagraph line = paragraphBefore(document, document.getTables().get(0)); + + assertThat(line.getText()).isEmpty(); + assertThat(spacing(line).getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + assertThat(DocxTwips.of(spacing(line).getLine())).as("the band's margin").isEqualTo(Math.round(EDGE * 20)); + assertThat(line.getCTP().getPPr().isSetKeepNext()).as("kept with the table").isTrue(); + } + } + + @Test + void aRowOpeningABandHoldsTheBandsMargin() throws Exception { + Consumer content = movedToANewPage(bandOpeningWith(new RowBuilder().name("Block") + .addParagraph("Opening").addParagraph("Beside").build())); + assertOpensTheSecondPage(content, "Block"); + try (XWPFDocument document = export(content)) { + assertThat(lineHeight(paragraphBefore(document, document.getTables().get(0)))) + .isEqualTo(Math.round(EDGE * 20)); + } + } + + @Test + void aPanelOpeningABandHoldsTheBandsMargin() throws Exception { + Consumer content = movedToANewPage(bandOpeningWith(new SectionBuilder() + .fillColor(DocumentColor.LIGHT_GRAY).padding(DocumentInsets.of(6)) + .addParagraph(p -> p.name("Block").text("Opening")).build())); + assertOpensTheSecondPage(content, "Block"); + try (XWPFDocument document = export(content)) { + assertThat(lineHeight(paragraphBefore(document, document.getTables().get(0)))) + .isEqualTo(Math.round(EDGE * 20)); + } + } + + @Test + void theGapAboveABandOpeningWithATableStaysOnThePageAbove() throws Exception { + // The layout starts the band at its own top edge on the new page: the paragraph's 8pt below + // it is left at the foot of the page above, and the line holds the band's margin alone. + assertTheGapStaysAbove(new TableBuilder().name("Block").autoColumns(2).row("Opening", "Beside").build(), 0); + } + + @Test + void aRowOpeningABandLeavesOnlyTheGapOnThePageAbove() throws Exception { + // The band measures its space to the row's first paragraph, past the row's 4pt padding, + // which the row writes above itself: the line holds it with the band's margin, and the page + // above keeps the gap alone. + assertTheGapStaysAbove(new RowBuilder().name("Block").padding(DocumentInsets.top(4)) + .addParagraph("Opening").addParagraph("Beside").build(), 4); + } + + @Test + void aPanelOpeningABandLeavesOnlyTheGapOnThePageAbove() throws Exception { + // The band measures its space to the panel's paragraph, past the panel's 4pt margin. + assertTheGapStaysAbove(new SectionBuilder().fillColor(DocumentColor.LIGHT_GRAY) + .padding(DocumentInsets.of(6)).margin(DocumentInsets.top(4)) + .addParagraph(p -> p.name("Block").text("Opening")).build(), 4); + } + + @Test + void aTableOpeningABandAfterItsBackdropHoldsTheBandsMargin() throws Exception { + // A backdrop drawn first is on the new page already, and the table after it starts no page + // of its own: the band opens the page, and the table is its first block. + Consumer content = movedToANewPage(new LayerStackBuilder().margin(DocumentInsets.top(EDGE)) + .back(new com.demcha.compose.document.dsl.ShapeBuilder().size(200, 30) + .fillColor(DocumentColor.LIGHT_GRAY).build()) + .topLeft(new TableBuilder().name("Block").autoColumns(2).row("Opening", "Beside").build()) + .bottomRight(new ParagraphBuilder().text("Caption").margin(DocumentInsets.top(30)).build()) + .build()); + assertOpensTheSecondPage(content, "Block"); + try (XWPFDocument document = export(content)) { + assertThat(lineHeight(paragraphBefore(document, document.getTables().get(0)))) + .isEqualTo(Math.round(EDGE * 20)); + } + } + + /** + * {@code block} opening a band the layout moves to a new page, under a paragraph whose 8pt below + * it the layout leaves at the foot of the page above; {@code own} is the block's own space above + * its table, which the line holds with the band's margin. + */ + private static void assertTheGapStaysAbove(DocumentNode block, double own) throws Exception { + Consumer content = page -> page.spacer(0, CONTENT - 30) + .addParagraph(p -> p.name("Above").text("Above").margin(DocumentInsets.bottom(8))) + .add(bandOpeningWith(block)); + assertOpensTheSecondPage(content, "Block"); + assertThat(startPage(content, "Above")).as("the premise: the paragraph stays on the first page").isZero(); + try (XWPFDocument document = export(content)) { + assertThat(lineHeight(paragraphBefore(document, document.getTables().get(0)))) + .as("the band's margin and the block's own edge").isEqualTo(Math.round((EDGE + own) * 20)); + assertThat(after(spacing(paragraphWith(document, "Above")))) + .as("the paragraph's margin, and the raise its line owes below it") + .isEqualTo(160 + DocxExports.DEFAULT_LINE_RAISE); + } + } + + @Test + void aLinePairOpeningItsPageHoldsItsStacksMargin() throws Exception { + Consumer content = movedToANewPage(pair(new LayerStackBuilder().name("Pair") + .margin(DocumentInsets.top(EDGE)))); + assertOpensTheSecondPage(content, "Pair"); + try (XWPFDocument document = export(content)) { + XWPFParagraph line = paragraphWith(document, "Opening\tBeside"); + + assertThat(lineBefore(document, line)).isEqualTo(Math.round(EDGE * 20)); + assertThat(before(spacing(line))).isZero(); + } + } + + @Test + void aLinePairOpeningABandHoldsTheBandsMargin() throws Exception { + Consumer content = movedToANewPage(bandOpeningWith(pair(new LayerStackBuilder().name("Block")))); + assertOpensTheSecondPage(content, "Block"); + try (XWPFDocument document = export(content)) { + assertThat(lineBefore(document, paragraphWith(document, "Opening\tBeside"))).isEqualTo(Math.round(EDGE * 20)); + } + } + + @Test + void aLinePairSetDownItsStackHoldsThatRoomToo() throws Exception { + // Both halves 4pt down the stack: the line starts 4pt below its top, inside it, and the + // space above the line on the new page is the margin and those 4pt. + Consumer content = movedToANewPage(new LayerStackBuilder().name("Pair") + .margin(DocumentInsets.top(EDGE)) + .topLeft(new ParagraphBuilder().text("Opening").margin(DocumentInsets.top(4)).build()) + .topRight(new ParagraphBuilder().text("Beside").align(TextAlign.RIGHT) + .margin(DocumentInsets.top(4)).build()) + .build()); + assertOpensTheSecondPage(content, "Pair"); + try (XWPFDocument document = export(content)) { + XWPFParagraph line = paragraphWith(document, "Opening\tBeside"); + + assertThat(lineBefore(document, line)).as("12pt and 4pt").isEqualTo(320); + assertThat(before(spacing(line))).isZero(); + } + } + + @Test + void aListHoldingNoLineTakesItsGapFromNoEarlierOne() throws Exception { + // A picture opening the second page holds its margin in a line; a second picture fills the + // page under it, and the list opening the third, with no edge to hold and nothing written + // above its first item, takes the gap between that item's lines from no line. + Consumer content = page -> page.spacer(0, CONTENT - 10) + .addImage(image -> image.name("Picture").source(picture()).size(60, 30).margin(DocumentInsets.top(EDGE))) + .addImage(image -> image.source(picture()).size(200, CONTENT - EDGE - 30 - 5)) + .add(new ListBuilder().name("List") + .items("Opening words that run on past the width of this page and wrap onto a second line") + .lineSpacing(4).build()); + assertThat(startPage(content, "Picture")).as("the premise: the picture opens the second page").isEqualTo(1); + assertThat(startPage(content, "List")).as("the premise: the list opens the third page").isEqualTo(2); + try (XWPFDocument document = export(content)) { + XWPFParagraph picture = document.getParagraphs().stream() + .filter(p -> p.getRuns().stream().anyMatch(run -> !run.getEmbeddedPictures().isEmpty())) + .findFirst().orElseThrow(); + + assertThat(lineBefore(document, picture)).as("the picture's margin, whole").isEqualTo(Math.round(EDGE * 20)); + } + } + + @Test + void aLinePairSetAboveItsOverlayIsLeftAsItWas() throws Exception { + // Its halves offset 3pt up, its text starts above the shape's top: the line would hold the + // margin, not the space above the text, and is not written. + Consumer content = movedToANewPage(new com.demcha.compose.document.dsl.ShapeContainerBuilder() + .name("Pair").rectangle(260, 20).margin(DocumentInsets.top(EDGE)) + .clipPolicy(com.demcha.compose.document.style.ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().text("Opening").build(), 0, -3, + com.demcha.compose.document.node.LayerAlign.TOP_LEFT) + .position(new ParagraphBuilder().text("Beside").build(), 0, -3, + com.demcha.compose.document.node.LayerAlign.TOP_RIGHT) + .build()); + assertOpensTheSecondPage(content, "Pair"); + try (XWPFDocument document = export(content)) { + XWPFParagraph line = paragraphWith(document, "Opening\tBeside"); + + assertThat(DocxTwips.of(spacing(paragraphBefore(document, line)).getLine())) + .as("the filler's hairline, no line held").isEqualTo(HAIRLINE); + assertThat(before(spacing(line))).as("its space above, as before").isPositive(); + } + } + + /** A list of one item, named for the layout's placement of it. */ + private static DocumentNode list(DocumentInsets margin, DocumentInsets padding) { + return new ListBuilder().name("List").items("Opening").margin(margin).padding(padding).build(); + } + + /** A title at the left and its dates at the right of one stack, which the export sets as one line. */ + private static DocumentNode pair(LayerStackBuilder stack) { + return stack.topLeft(new ParagraphBuilder().text("Opening").build()) + .topRight(new ParagraphBuilder().text("Beside").align(TextAlign.RIGHT).build()) + .build(); + } + + /** A band with a 12pt margin opening with {@code first}, a caption at its foot. */ + private static DocumentNode bandOpeningWith(DocumentNode first) { + return new LayerStackBuilder().margin(DocumentInsets.top(EDGE)) + .topLeft(first) + .bottomRight(new ParagraphBuilder().text("Caption").margin(DocumentInsets.top(30)).build()) + .build(); + } + + /** A block under a 150pt filler, which the layout moves to the second page. */ + private static Consumer movedToANewPage(DocumentNode block) { + return page -> page.spacer(0, CONTENT - 10).add(block); + } + + private static XWPFDocument export(Consumer content) throws Exception { + return DocxExports.withLayout(300, 200, 20, content); + } + + /** The premise of a test of a block opening a page: the layout starts it on the second. */ + private static void assertOpensTheSecondPage(Consumer content, String name) { + assertThat(startPage(content, name)).as("the premise: %s opens the second page", name).isEqualTo(1); + } + + private static int startPage(Consumer content, String name) { + try (DocumentSession session = GraphCompose.document().pageSize(300, 200).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content::accept); + return session.layoutGraph().nodes().stream() + .filter(node -> name.equals(node.semanticName())).findFirst().orElseThrow().startPage(); + } + } + + /** The height of the exact line holding a block's top edge, in twips. */ + private static long lineBefore(XWPFDocument document, XWPFParagraph paragraph) { + return lineHeight(paragraphBefore(document, paragraph)); + } + + /** The height of an empty exact line kept with what follows it, in twips. */ + private static long lineHeight(XWPFParagraph line) { + CTSpacing spacing = spacing(line); + assertThat(line.getText()).as("a line holding no text").isEmpty(); + assertThat(spacing.getLineRule()).as("a line holding its edge").isEqualTo(STLineSpacingRule.EXACT); + assertThat(line.getCTP().getPPr().isSetKeepNext()).as("kept with what follows").isTrue(); + return DocxTwips.of(spacing.getLine()); + } + + /** A spacer's paragraph is a tenth of a point tall, in twips (see holdToHairline). */ + private static final long HAIRLINE = 2; + + /** + * The space owed above the block after the filler, in twips: {@code points} of it, less the + * filler's own hairline, which is part of its height. + */ + private static long owed(double points) { + return Math.round(points * 20) - HAIRLINE; + } + + private static XWPFParagraph paragraphWith(XWPFDocument document, String text) { + return document.getParagraphs().stream().filter(p -> p.getText().equals(text)).findFirst().orElseThrow(); + } + + private static XWPFParagraph paragraphBefore(XWPFDocument document, XWPFParagraph paragraph) { + List paragraphs = document.getParagraphs(); + return paragraphs.get(paragraphs.indexOf(paragraph) - 1); + } + + /** The body paragraph just before a table. */ + private static XWPFParagraph paragraphBefore(XWPFDocument document, XWPFTable table) { + List body = document.getBodyElements(); + IBodyElement before = body.get(body.indexOf(table) - 1); + assertThat(before).as("a paragraph before the table").isInstanceOf(XWPFParagraph.class); + return (XWPFParagraph) before; + } + + private static CTSpacing spacing(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr().getSpacing(); + } + + private static long before(CTSpacing spacing) { + return spacing != null && spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0; + } + + private static long after(CTSpacing spacing) { + return spacing != null && spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0; + } + + private static DocumentImageData picture() { + BufferedImage image = new BufferedImage(60, 30, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(image, "png", png); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException(failure); + } + return DocumentImageData.fromBytes(png.toByteArray()); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairRaiseTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairRaiseTest.java index 4e6ab42f5..aa464586c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairRaiseTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairRaiseTest.java @@ -140,8 +140,9 @@ private static long raiseOf(DocumentTextStyle left, DocumentTextStyle right) thr @Test void aPairOpeningAPageKeepsTheSpaceAboveIt() throws Exception { - // Word drops the space above the first paragraph on a page: raised into it, the line would - // stand where Word sets it, and what it owed would stand all below it lower. + // Word drops the space above the first paragraph on a page: the pair's is held in a line above + // it, and raised into that line, the pair would stand where Word sets it and what it owed + // would stand all below it lower. try (Export export = export(page -> page .spacer(0, 540) .add(pair(new ParagraphBuilder().name("Label").text("Bank name:").margin(DocumentInsets.top(10)).build(), @@ -153,7 +154,35 @@ void aPairOpeningAPageKeepsTheSpaceAboveIt() throws Exception { - (export.placed("Below").placementY() + export.placed("Below").placementHeight()); assertThat(export.placed("Pair").startPage()).as("the premise: the pair opens the second page").isOne(); - assertThat(before(pair)).as("the premise: space above it, which Word drops").isPositive(); + assertThat(before(pair)).as("its space above is held in a line above it, which Word keeps").isZero(); + assertThat(after(pair) + before(below)).as("the page's gap, less the next line's own raise: nothing owed") + .isCloseTo(Math.round(gapBelow * 20) - RAISE, within(2L)); + } + } + + @Test + void aPairOpeningAPageWithNoEdgeToHoldIsNotRaisedIntoTheGapAboveIt() throws Exception { + // Set at its overlay's top with no margin, the pair has no edge of its own for a line to + // hold: the gap before it, left at the foot of the page above by the layout, is written + // above it, and Word drops it at the top of the page. + try (Export export = export(page -> page + .spacer(0, 520) + .addParagraph(p -> p.name("Above").text("Above").margin(DocumentInsets.bottom(20))) + .add(new ShapeContainerBuilder().name("Pair").rectangle(360, 40) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Label").text("Bank name:").build(), 0, 0, LayerAlign.TOP_LEFT) + .position(new ParagraphBuilder().name("Value").text("Example Bank").build(), 0, 0, + LayerAlign.TOP_RIGHT) + .build()) + .addParagraph(p -> p.name("Below").text("Below")))) { + XWPFParagraph pair = export.paragraph("Example Bank"); + XWPFParagraph below = export.paragraph("Below"); + double gapBelow = export.placed("Label").placementY() + - (export.placed("Below").placementY() + export.placed("Below").placementHeight()); + + assertThat(export.placed("Pair").startPage()).as("the premise: the pair opens the second page").isOne(); + assertThat(export.placed("Above").startPage()).as("the premise: the paragraph above stays on the first").isZero(); + assertThat(before(pair)).as("the premise: the gap above it, which Word drops").isPositive(); assertThat(after(pair) + before(below)).as("the page's gap, less the next line's own raise: nothing owed") .isCloseTo(Math.round(gapBelow * 20) - RAISE, within(2L)); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java index 52a2bf920..38789f9ee 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java @@ -414,14 +414,17 @@ void anItemLoweredAboveAListTheLayoutOpensAPageWithIsTakenBack() throws Exceptio @Test void aBlockOnALaterPageTakesNothingOfTheHangTheListAboveLeft() throws Exception { - // The list under it opens the next page: its space above is written whole, and nothing of - // a hang taken back is named. + // The list under it opens the next page: the space the layout leaves above it there is held + // whole, in the line holding its top edge, and nothing of a hang taken back is named. Exported exported = export(page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)).spacer(1, 460) .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading")) .addList(list -> list.textStyle(lato(10)).margin(DocumentInsets.top(50)).items("Next item"))); + XWPFParagraph next = paragraph(exported, "Next item"); - assertThat(before(paragraph(exported, "Next item"))).as("its 50pt, whole").isEqualTo(50 * 20); + assertThat(Math.round(line(paragraphBefore(exported.document(), next)) * 20)).as("its 50pt, whole") + .isEqualTo(50 * 20); + assertThat(before(next)).as("its edge is the line's").isZero(); assertThat(exported.report().bySubject()).doesNotContainKey("space above"); } @@ -624,10 +627,14 @@ void theFirstItemOfAStackOfOneLayerTheLayoutMovesToANewPageIsNotLowered() throws XWPFParagraph two = paragraph(document, "Two"); long lower = Math.round(-(0.8 * line(two) - LATO_ASCENT * 24) * 20); + XWPFParagraph one = paragraph(document, "One"); + XWPFParagraph held = paragraphBefore(document, one); + assertThat(pages).as("the premise: the stack's list opens the next page").containsExactly(1, 1); - assertThat(before(paragraph(document, "One"))) - .as("the spacer's height past its hairline and the list's 10pt, as written") - .isCloseTo(Math.round((500 - 0.1 + 10) * 20), within(1L)); + assertThat(before(one)).as("not lowered: its edge is the line's").isZero(); + assertThat(Math.round(line(held) * 20)).as("the list's 10pt, held in a line on its page").isEqualTo(200); + assertThat(before(held)).as("the spacer's height past its hairline, above that line") + .isCloseTo(Math.round((500 - 0.1) * 20), within(1L)); assertThat(before(two)).as("the gap and its own lowering, nothing owed above it").isEqualTo(200 + lower); } } @@ -728,6 +735,12 @@ private static XWPFParagraph inACell(XWPFDocument document, String text) { throw new AssertionError("no cell holds " + text); } + /** The body paragraph just before {@code paragraph}. */ + private static XWPFParagraph paragraphBefore(XWPFDocument document, XWPFParagraph paragraph) { + List paragraphs = document.getParagraphs(); + return paragraphs.get(paragraphs.indexOf(paragraph) - 1); + } + private static double line(XWPFParagraph paragraph) { return DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()) / 20.0; }