From 6aa8eaa2ea36174f1724a0e3da37861e653e92f1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 21 Oct 2026 00:18:19 +0100 Subject: [PATCH 1/2] fix(docx): keep a band of layers whole on the page the layout moves it to The layout moves a layer stack or a shape container to a new page whole, but only a stack of one layer was kept so in Word. Measured in Word and LibreOffice, a band of two layers the page moved to the next page stood with its first layer at the foot of the page above, and a shape container of three lines with two of them there. Every layer stack and shape container in the flow is now kept on its page as a block kept together is. Set on the new page whole, a band then lost the space above its first block, as Word drops it at the top of a page: its first paragraph, picture or spacer now holds that space in a line kept with it, as one in the flow does, also for a band in a stack of one layer and after a backdrop the band draws first. --- CHANGELOG.md | 27 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 10 +- .../semantic/docx/DocxSemanticBackend.java | 84 ++++-- .../semantic/docx/DocxBandOnANewPageTest.java | 278 ++++++++++++++++++ .../semantic/docx/DocxKeepTogetherTest.java | 57 +++- .../docx/DocxPictureOnANewPageTest.java | 19 -- .../DocxStackOfOneLayerOnANewPageTest.java | 29 -- 8 files changed, 421 insertions(+), 85 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c2571ae..3e4b6096a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps a band of layers and a shape container on the page the layout puts them + on.** The layout moves a layer stack or a shape container to a new page whole. A stack of more + than one layer is written as a band, its layers one after the other, and Word was told nothing + for it or for a shape container. + - Measured in Word and LibreOffice on Windows, with nothing named: + - a band of two layers the page moved to the next page stood with its first layer at the foot + of the page above, a band with a picture as its second layer too; + - a shape container of three lines stood with two of them there. + - Each is now kept on its page as a stack of one layer is: each paragraph keeps its lines, and + all but the last keep with the next. Each measured one the layout moves to a new page now + stands whole there, in both editors. + - The first paragraph, picture or spacer of a band the layout moves to a new page now 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. That holds where a backdrop is drawn before it too. Set on that page whole, a band with a + 14pt margin stood 14pt high, as did one with a backdrop laid first and one nested in a stack of + one layer with that margin; one in a section with a 10pt margin stood 10pt high. Held, each + stands where the page sets it, within 1.2pt, and the backdrop where the page draws it. + - A band whose first block is a list or a row still loses that space, measured; nor is it held + for a table, a panel or a line pair written first. + - A later layer whose own margin, or a picture's room in its box, reaches above where the layers + before it end stands lower than the page sets it by as much as it reaches above them, 13pt in + the measured case. Such a band near the foot of a page, which the page keeps there, now moves + to the next page whole in Word rather than split. + - 10 of the corpus's 62 DOCX change, in these keeps alone (`w:keepLines`, `w:keepNext`). Each + renders word for word as before in Word and LibreOffice. Its 1246 notes are unchanged. - **A DOCX export names a block Word may break inside a panel the layout moves it down in.** The layout moves a block kept together, a layer stack or a shape container whole, or a block kept with the next with it, to the next page as the page requires. Inside a panel the layout runs on @@ -31,7 +56,7 @@ follow semantic versioning; release dates are ISO 8601. - A stack of one layer in the flow is now kept on its page as a block kept together is: each paragraph keeps its lines, and all but the last keep with the next. Each measured stack now stands whole on the page the layout sets it on, in both editors. - - A stack in a table's or a panel's cell is not kept here, nor a stack of more than one layer. + - A stack in a table's or a panel's cell is not kept here. - No document of the 62 in the corpus has a stack of one layer in the flow, and none changes. - **A DOCX export keeps the space above a spacer opening a stack of one layer on a new page.** A stack of one layer that opens with a spacer, moved to a new page, lost the space above its text: diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 482a21e92..3e7fd1b00 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 of one layer in the flow is kept so too, as the layout moves it to a new page whole. 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 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 | | 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. 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 the page starts above where those before it end — a caption over its photo, a label in a card's corner — 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 a4251066c..4c5212fd7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,16 +67,16 @@ 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. A paragraph in a table cell, any other overlay or a list is left to its container | +| 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 | | 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, 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 | +| 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 | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel | -| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses. A layer stack of one layer in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of one the page moved down at the foot of the page above | -| Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. So does a spacer opening a stack of one layer, which the layout moves to a new page whole: the space the layout leaves above it there, less its layer's offset, which is named, is held in its line. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | +| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms | +| Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there, less its layer's offset, which is named, is held in its line. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | | Page breaks | Explicit Word page breaks | Page geometry (size, margins and orientation — a page wider than it is tall is stated as @@ -197,7 +197,7 @@ it cannot work out for itself: | What | Where it lands | |---|---| -| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow opens its page, is not raised, as a paragraph's there is not; a band in a stack of one layer the layout moves to a new page is not yet told apart). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or under its layer stack's column, or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | +| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow, a stack of one layer's included, opens its page, is not raised, as a paragraph's there is not). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or under its layer stack's column, or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them; read off the table's rows, so a table with an `auto` column and a column edge no row of it shows has none, and its columns are the editor's (measured on one such table: Word set them as the page did, LibreOffice on Windows otherwise), and the report names it (`TableNode`) | | Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed | 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 02a0f3315..ef3e6ef85 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 @@ -3590,11 +3590,12 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio } private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Exception { - // A stack of one layer is written in the flow, and the layout moves it to a new page whole, - // as it does a block kept together: told nothing, Word set a stack of three lines the page - // moves down with two of them at the foot of the page above, in Word and LibreOffice alike. - // In a cell its paragraphs are its table's, which a row keeps or breaks on its own terms. - boolean keepTogether = (node.keepTogether() || currentCell == null && isOneLayer(node)) + // The layout moves a layer stack or a shape container to a new page whole, as it does a + // block kept together. Told nothing, Word and LibreOffice split them in the flow: a stack + // of one layer or a shape container, three lines the page moves down, with two at the foot + // of the page above, and a band of two layers with its first layer there. + // In a cell their paragraphs are the table's, which a row keeps or breaks on its own terms. + boolean keepTogether = (node.keepTogether() || currentCell == null && movedWhole(node)) && layout.onOnePage(node); PanelBreak panelBreak = keepThePanelMayBreak(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); @@ -3659,9 +3660,7 @@ private PanelBreak keepThePanelMayBreak(DocumentNode node) { if (panelCell == null || currentCell != panelCell || panelLastEndPage < 0 || !layout.onOnePage(node)) { return null; } - boolean whole = node.keepTogether() - || node instanceof com.demcha.compose.document.node.LayerStackNode - || node instanceof ShapeContainerNode; + boolean whole = node.keepTogether() || movedWhole(node); if (!whole && !node.keepWithNext()) { return null; } @@ -4342,6 +4341,15 @@ private static boolean isOverlay(DocumentNode node) { || node instanceof ShapeContainerNode; } + /** + * Whether a node is a layer stack or a shape container, which the layout moves to a new page + * whole, never breaking it over one. A canvas and an alignment wrapper are atomic there too and + * are not counted here: Word was not measured splitting them. + */ + private static boolean movedWhole(DocumentNode node) { + return node instanceof com.demcha.compose.document.node.LayerStackNode || node instanceof ShapeContainerNode; + } + /** Whether a node is a layer stack of one layer, which lays nothing over anything. */ private static boolean isOneLayer(DocumentNode node) { return node instanceof com.demcha.compose.document.node.LayerStackNode stack && stack.layers().size() == 1; @@ -10416,15 +10424,17 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { writeTextOverTheFlow(document, node); return; } - if (currentCell == null && startsAPageOfItsOwn(node)) { + boolean holdsItsEdge = opensItsPageInTheFlow(node); + if (holdsItsEdge || currentCell == null && startsAPageOfItsOwn(node)) { // The layout moves a stack of one layer, written in the flow, to a new page whole: its // first paragraph opens that page as one in the flow does, and its line is not moved // into the space above it there either. A band's first, where the band opens its page, // is not either (paragraphOpeningABandsPage); in any other overlay it is not moved anyway. - // Its top edge is held in the flow, a stack of one layer's included: there Word dropped - // it as at the top of any page, and a paragraph with a 20pt margin, or the first in a - // stack with a 14pt margin, stood that much high in Word and LibreOffice. - if (overlayDepth == oneLayerDepth) { + // Its top edge is held in the flow, a stack of one layer's included, and as a band's + // first where the band opens its page: there Word dropped it as at the top of any page, + // and a paragraph with a 20pt margin, the first in a stack with a 14pt margin, or in a + // band with one, stood that much high in Word and LibreOffice. + if (holdsItsEdge) { holdAParagraphsTopEdgeOnItsPage(document, node, 0); } paragraphOpeningItsPage = node; @@ -13368,9 +13378,9 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception } double[] room = fitMode == DocumentImageFitMode.CONTAIN ? roomRound(resolved, box) : new double[2]; - if (currentCell == null && overlayDepth == oneLayerDepth && startsAPageOfItsOwn(node)) { + if (opensItsPageInTheFlow(node)) { // Its top edge is held on the page the layout moves it to, as a text paragraph's is, in - // the flow and in a stack of one layer. + // the flow, in a stack of one layer and first in a band opening its page. holdAParagraphsTopEdgeOnItsPage(document, node, room[0]); } XWPFParagraph para = newBodyParagraph(document); @@ -16740,18 +16750,20 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay throws Exception { // A band inside a band that has written nothing yet: the outer band measured its space // to the first block written, which is this one's first too, so that space stands. - if (Double.isNaN(resumeSpacing)) { + boolean opensItsSpace = Double.isNaN(resumeSpacing); + if (opensItsSpace) { double above = carriedSpacingBefore + pendingSpacingAfter + stack.margin().top() + band.above(); // Text hanging below the block before — a band's last line past its foot, a row's // icon line — takes its place out of that space, as out of the gap above a paragraph // (newBodyParagraph); the band's first block forgets it (resumeHere). ConsultingInvoice's // address runs 2.3pt past its band, and the contact bands under it stood 2.9pt low. // A band the layout starts on a new page leaves the hang on the page above, as a - // paragraph or a table there does. - boolean opensItsPage = currentCell == null && overlayDepth == 0 && startsAPageOfItsOwn(stack); + // paragraph or a table there does: in the flow, a stack of one layer's included. + boolean opensItsPage = currentCell == null && overlayDepth == oneLayerDepth && startsAPageOfItsOwn(stack); double hang = opensItsPage ? 0 : hangingBelow; - // Its first paragraph opens the page, and Word drops the space above it there: its line - // is not raised into it (writeParagraph). + // Its first paragraph opens the page, and Word drops the space above it there: a line + // holds the band's top edge above its first block (holdAParagraphsTopEdgeOnItsPage), and + // the block's line is not raised into it (writeParagraph). bandOpensItsPage = opensItsPage; 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 @@ -16802,6 +16814,11 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay insetRight = outerRight; standIns.removeAll(band.standIns()); closingBlocks.removeAll(band.closing()); + // A band that wrote no body paragraph — drawings alone, or text in a table's cells — + // opens nothing after it: what follows starts a page, or does not, of its own. + if (opensItsSpace) { + bandOpensItsPage = false; + } } // Nothing was written after all: the space the stack opened with is still owed. double unwritten = Double.isNaN(resumeSpacing) ? 0 : resumeSpacing; @@ -18694,6 +18711,18 @@ private boolean startsAPageOfItsOwn(DocumentNode node) { return placed != null && lastEndPage >= 0 && placed.startPage() > lastEndPage; } + /** + * Whether a block opens a page the layout moves it to in the flow, where Word drops the space + * above it: in the flow or a stack of one layer, a block the layout starts on a new page, and + * a band's first block where the band opens its page. The band decides that one, not the + * block: a backdrop drawn first in the band is on that page already, and the block after it + * starts no page of its own, though nothing above it is written there. + */ + private boolean opensItsPageInTheFlow(DocumentNode node) { + return currentCell == null + && (bandOpensItsPage || overlayDepth == oneLayerDepth && startsAPageOfItsOwn(node)); + } + /** * Holds the space owed above a table the layout starts on a new page, on that page. * @@ -18764,7 +18793,13 @@ private static void holdInALineKeptWithTheNext(XWPFParagraph line, long twips) { private void 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); - double edges = Math.max(0, carriedSpacingBefore); + // A band's first block: everything owed above it — the gap before the band, the band's + // margin and its room above the block — is the space the band resumes there + // (writeOverlayBand). It is owed above the line, which takes from it what the page leaves + // above the block, as from the edges of containers in the flow; without a canvas to say how + // much, it stands for those edges. + double edges = Math.max(0, Double.isNaN(resumeSpacing) ? carriedSpacingBefore : resumeSpacing); + resumeHere(); // A layer's offset down its stack of one layer is named, not written (reportTheLayerMoved), // and the line holds none of it either. double above = spaceAboveOnItsPage(node) - layerOffsetNotWritten; @@ -19158,9 +19193,10 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio writeInPlaceOf(document, node); return; } - // In the flow, a stack of one layer's included: opening one on a new page, a stack with a - // 14pt margin and an 8pt spacer before its text stood 22pt high in Word and LibreOffice. - boolean opensAPage = currentCell == null && overlayDepth == oneLayerDepth && startsAPageOfItsOwn(node); + // In the flow, a stack of one layer's included, and first in a band opening its page: + // opening a stack of one layer on a new page, a stack with a 14pt margin and an 8pt spacer + // before its text stood 22pt high in Word and LibreOffice. + boolean opensAPage = opensItsPageInTheFlow(node); if (opensAPage) { // Text hanging below the block before — a row's last line held to its icon — a pull // out of it and a card's border below it stay on the page above with it, as at a diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java new file mode 100644 index 000000000..f20fd9a1b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java @@ -0,0 +1,278 @@ +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.ImageBuilder; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +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 java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A band of layers the layout moves to a new page keeps the space the layout leaves above it + * there, in Word. + * + *

A stack of more than one layer is written as a band, its layers one after the other, and Word + * drops the space above its first paragraph at the top of a page as any other's. Measured in Word + * 16 and LibreOffice on Windows, a band with a 14pt margin that Word set on a new page whole stood + * 14pt high in both; so did one nested in a stack of one layer with that margin, and one in a + * section with a 10pt margin, 10pt. Held in a line kept with its first block — a paragraph, a + * picture, or a spacer's own line — each stood where the page sets it, within 1.2pt.

+ * + * @author Artem Demchyshyn + */ +class DocxBandOnANewPageTest { + + 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 theBandsMarginIsHeldInALineKeptWithItsFirstParagraph() throws Exception { + Consumer content = movedToANewPage(band(stack -> stack.margin(DocumentInsets.top(EDGE)))); + assertThat(startPage(content, "Opening")).as("the premise: the band opens the second page").isEqualTo(1); + 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 paragraph").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 theBandsPaddingIsHeldWithItsFirstParagraphsMargin() throws Exception { + // 9pt of the band's padding and 6pt of the paragraph's margin: the band owes the first above + // the paragraph, which owes the second itself. + try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + .padding(DocumentInsets.top(9)) + .topLeft(new ParagraphBuilder().text("Opening").margin(DocumentInsets.top(6)).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + XWPFParagraph opening = paragraphWith(document, "Opening"); + CTSpacing line = spacing(paragraphBefore(document, opening)); + + assertThat(DocxTwips.of(line.getLine())).as("9pt and 6pt").isEqualTo(300); + assertThat(before(line)).as("the filler's height stays above the line, whole").isEqualTo(owed(CONTENT - 10)); + assertThat(before(spacing(opening))).isZero(); + } + } + + @Test + void aBackdropDrawnFirstLeavesTheEdgeToTheFirstParagraph() throws Exception { + // Measured: a band with a 14pt margin and a backdrop laid first, moved whole to a new page, + // stood 14pt high in Word and LibreOffice. The backdrop is drawn on that page first, and + // the paragraph after it starts no page of its own, though nothing above it is written there. + Consumer content = movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)) + .back(new com.demcha.compose.document.dsl.ShapeBuilder().size(200, 30) + .fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY).build()) + .topLeft(new ParagraphBuilder().name("Opening").text("Opening").build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertThat(startPage(content, "Opening")).as("the premise: the band opens the second page").isEqualTo(1); + try (XWPFDocument document = export(content)) { + XWPFParagraph opening = paragraphWith(document, "Opening"); + + assertThat(lineBefore(document, opening)).as("the band's margin").isEqualTo(Math.round(EDGE * 20)); + assertThat(before(spacing(opening))).isZero(); + } + } + + @Test + void aPictureOpeningTheBandIsHeld() throws Exception { + try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)) + .topLeft(new ImageBuilder().source(picture()).size(60, 30).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + XWPFParagraph picture = pictureIn(document); + + assertThat(lineBefore(document, picture)).isEqualTo(Math.round(EDGE * 20)); + assertThat(before(spacing(picture))).isZero(); + } + } + + @Test + void aPicturesOwnMarginIsHeldWithTheBands() throws Exception { + try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)) + .topLeft(new ImageBuilder().source(picture()).size(60, 30).margin(DocumentInsets.top(5)).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + XWPFParagraph picture = pictureIn(document); + + assertThat(lineBefore(document, picture)).as("12pt and 5pt").isEqualTo(340); + assertThat(before(spacing(picture))).isZero(); + } + } + + @Test + void aSpacerOpeningTheBandHoldsItsEdgeAndGoesWithWhatFollows() throws Exception { + // Measured: held so, a band with a 14pt margin and an 8pt spacer before its text stood 1.2pt + // high in Word and LibreOffice, as a stack of one layer opening with one does. + Consumer content = movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)) + .topLeft(new SectionBuilder().spacer(0, 8).addParagraph(p -> p.name("Opening").text("Opening")).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertThat(startPage(content, "Opening")).as("the premise: the band opens the second page").isEqualTo(1); + try (XWPFDocument document = export(content)) { + XWPFParagraph spacer = paragraphBefore(document, paragraphWith(document, "Opening")); + + assertThat(DocxTwips.of(spacing(spacer).getLine())).as("the band's margin, and the spacer's hairline") + .isEqualTo(Math.round(EDGE * 20) + HAIRLINE); + assertThat(spacer.getCTP().getPPr().isSetKeepNext()).as("kept with the text under it").isTrue(); + } + } + + @Test + void aBandInAStackOfOneLayerHoldsTheStacksMargin() throws Exception { + try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)).topLeft(band(stack -> stack)).build()))) { + assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(Math.round(EDGE * 20)); + } + } + + @Test + void aBandInASectionHoldsTheSectionsMargin() throws Exception { + try (XWPFDocument document = export(page -> page.spacer(0, CONTENT - 10) + .addSection(section -> section.margin(DocumentInsets.top(10)).add(band(stack -> stack))))) { + assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(200); + } + } + + @Test + void onlyTheBandsFirstBlockOpensThePage() throws Exception { + try (XWPFDocument document = export(movedToANewPage(band(stack -> stack.margin(DocumentInsets.top(EDGE)))))) { + XWPFParagraph caption = paragraphWith(document, "Caption"); + + assertThat(paragraphBefore(document, caption).getText()).as("no line before the second layer") + .isEqualTo("Opening"); + } + } + + @Test + void aBandOnItsPageHoldsNoLine() throws Exception { + // The same band, the page tall enough to hold it under the filler. + try (XWPFDocument document = DocxExports.withLayout(300, 600, 20, + movedToANewPage(band(stack -> stack.margin(DocumentInsets.top(EDGE)))))) { + XWPFParagraph before = paragraphBefore(document, paragraphWith(document, "Opening")); + + assertThat(DocxTwips.of(spacing(before).getLine())).as("the filler's hairline, no line held") + .isEqualTo(HAIRLINE); + } + } + + @Test + void aBandInARowsCellHoldsNoLine() throws Exception { + // The row's table opens the page; the band's paragraphs are its cell's. + Consumer content = page -> page.spacer(0, CONTENT - 10) + .addRow(row -> row.add(band(stack -> stack.margin(DocumentInsets.top(EDGE)))).addParagraph("Beside")); + assertThat(startPage(content, "Opening")).as("the premise: the row opens the second page").isEqualTo(1); + try (XWPFDocument document = export(content)) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + + assertThat(cell.getParagraphs().get(0).getText()).as("the band's first paragraph opens its cell") + .isEqualTo("Opening"); + } + } + + /** A band of two layers: a paragraph at its top, a caption at its foot. */ + private static LayerStackNode band(UnaryOperator stack) { + return stack.apply(new LayerStackBuilder()) + .topLeft(new ParagraphBuilder().name("Opening").text("Opening").build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()) + .build(); + } + + /** A band under a 150pt filler, which the layout moves to the second page. */ + private static Consumer movedToANewPage(LayerStackNode band) { + return page -> page.spacer(0, CONTENT - 10).add(band); + } + + private static XWPFDocument export(Consumer content) throws Exception { + return DocxExports.withLayout(300, 200, 20, content); + } + + 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) { + CTSpacing spacing = spacing(paragraphBefore(document, paragraph)); + assertThat(spacing.getLineRule()).as("a line holding its edge").isEqualTo(STLineSpacingRule.EXACT); + 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 pictureIn(XWPFDocument document) { + return document.getParagraphs().stream() + .filter(paragraph -> paragraph.getRuns().stream().anyMatch(run -> !run.getEmbeddedPictures().isEmpty())) + .findFirst().orElseThrow(); + } + + private static XWPFParagraph paragraphBefore(XWPFDocument document, XWPFParagraph paragraph) { + List paragraphs = document.getParagraphs(); + return paragraphs.get(paragraphs.indexOf(paragraph) - 1); + } + + 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 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/DocxKeepTogetherTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java index d774bc59d..74d5e05ee 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxKeepTogetherTest.java @@ -157,14 +157,59 @@ void aStackOfOneLayerInARowsCellIsLeftToItsRow() throws Exception { } @Test - void aBandIsNotChainedHere() throws Exception { - // A stack of more than one layer is written as a band, whose own keeps are another step. + void aBandIsChainedIntoOneUnit() throws Exception { + // A stack of two layers is written as a band, one layer after the other. Measured: one the + // page moved to a new page stood with its first layer at the foot of the page above in + // Word and LibreOffice. try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page .addLayerStack(stack -> stack - .topLeft(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Opening").build()) - .bottomRight(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Caption").build())))) { - assertThat(keepsNext(paragraph(document, "Opening")) || keepsLines(paragraph(document, "Opening"))) - .isFalse(); + .topLeft(new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Opening").addParagraph("Body").build()) + .bottomRight(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Caption").build())) + .addParagraph(p -> p.text("After")))) { + assertThat(keepsNext(paragraph(document, "Opening")) && keepsNext(paragraph(document, "Body"))) + .as("the first layer kept with the second").isTrue(); + assertThat(keepsNext(paragraph(document, "Caption"))) + .as("the band ends here, and is not tied to what follows it").isFalse(); + assertThat(keepsLines(paragraph(document, "Opening")) && keepsLines(paragraph(document, "Caption"))) + .isTrue(); + assertThat(keepsNext(paragraph(document, "After")) || keepsLines(paragraph(document, "After"))).isFalse(); + } + } + + @Test + void aShapeContainerIsChainedIntoOneUnit() throws Exception { + // The layout moves a shape container whole too. Measured: one of three lines the page moved + // to a new page stood with two of them at the foot of the page above in Word and LibreOffice. + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .add(new com.demcha.compose.document.dsl.ShapeContainerBuilder().rectangle(200, 45) + .topLeft(new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Opening").addParagraph("Body").addParagraph("Tail").build()) + .build()) + .addParagraph(p -> p.text("After")))) { + assertThat(keepsNext(paragraph(document, "Opening")) && keepsNext(paragraph(document, "Body"))).isTrue(); + assertThat(keepsNext(paragraph(document, "Tail"))).isFalse(); + assertThat(keepsLines(paragraph(document, "Tail"))).isTrue(); + } + } + + @Test + void aBandInARowsCellIsLeftToItsRow() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addRow(row -> row + .add(new com.demcha.compose.document.dsl.LayerStackBuilder() + .topLeft(new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Opening").addParagraph("Body").build()) + .bottomRight(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Caption").build()) + .build()) + .addParagraph("Beside")))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + + assertThat(cell.getParagraphs()).extracting(XWPFParagraph::getText).contains("Opening", "Caption"); + for (XWPFParagraph paragraph : cell.getParagraphs()) { + assertThat(keepsNext(paragraph) || keepsLines(paragraph)) + .as("a cell of a row Word keeps on its own terms").isFalse(); + } } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureOnANewPageTest.java index 4ab164554..596144331 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureOnANewPageTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureOnANewPageTest.java @@ -162,25 +162,6 @@ void aPictureInARowsCellIsLeftToItsTable() throws Exception { } } - @Test - void aPictureOpeningABandIsLeftToTheBand() throws Exception { - // The band, moved to the second page, sets its layers from its own top, and holds what - // opens its page itself (raiseIntoTheSpaceAbove). - java.util.function.Consumer content = page -> page - .spacer(0, CONTENT - 10) - .addLayerStack(stack -> stack - .topLeft(new ImageBuilder().source(picture(60, 60)).size(60, 60) - .margin(DocumentInsets.top(EDGE)).build()) - .bottomRight(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Caption").build())); - try (XWPFDocument moved = DocxExports.withLayout(300, 200, 20, content); - XWPFDocument onOnePage = DocxExports.withLayout(300, 600, 20, content)) { - List paragraphs = moved.getParagraphs(); - - assertThat(paragraphs.indexOf(pictureIn(moved))).as("no line of the flow's held before it") - .isEqualTo(onOnePage.getParagraphs().indexOf(pictureIn(onOnePage))); - } - } - /** A 60pt picture under a 150pt filler, which the layout moves to the second page, and a line after it. */ private static XWPFDocument movedToANewPage(UnaryOperator image) throws Exception { return DocxExports.withLayout(300, 200, 20, page -> page diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerOnANewPageTest.java index baa50b357..77cee314f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerOnANewPageTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerOnANewPageTest.java @@ -238,20 +238,6 @@ void aSpacerUnderAnOffsetHoldsNoneOfIt() throws Exception { .as("the offset named").isNotNull(); } - @Test - void aSpacerOpeningABandIsLeftToTheBand() throws Exception { - Consumer content = movedToANewPage(stack -> stack.margin(DocumentInsets.top(EDGE)) - .topLeft(new SectionBuilder().spacer(0, 8).addParagraph(p -> p.name("Opening").text("Opening")).build()) - .bottomRight(new ParagraphBuilder().text("Caption").build())); - assertThat(startPage(content, "Opening")).as("the premise: the band opens the second page").isEqualTo(1); - try (XWPFDocument document = export(content)) { - XWPFParagraph spacer = paragraphBefore(document, paragraphWith(document, "Opening")); - - assertThat(DocxTwips.of(spacing(spacer).getLine())).as("its hairline alone").isEqualTo(HAIRLINE); - assertThat(spacer.getCTP().getPPr().isSetKeepNext()).isFalse(); - } - } - @Test void aPullOutOfTheBlockOnThePageAboveStaysThere() throws Exception { // The paragraph above pulls what follows it 5pt up. On its page, Word sets no block over @@ -270,21 +256,6 @@ void aPullOutOfTheBlockOnThePageAboveStaysThere() throws Exception { .as("on the next page, nothing is").isNull(); } - @Test - void aParagraphOpeningABandIsLeftToTheBand() throws Exception { - // A stack of two layers is a band, which sets its layers from its own top. - Consumer moved = movedToANewPage(stack -> stack - .topLeft(new ParagraphBuilder().name("Opening").text("Opening").margin(DocumentInsets.top(EDGE)).build()) - .bottomRight(new ParagraphBuilder().text("Caption").build())); - assertThat(startPage(moved, "Opening")).as("the premise: the band opens the second page").isEqualTo(1); - try (XWPFDocument document = export(moved); - XWPFDocument onOnePage = DocxExports.withLayout(300, 600, 20, moved)) { - assertThat(document.getParagraphs().indexOf(paragraphWith(document, "Opening"))) - .as("no line of the flow's held before it") - .isEqualTo(onOnePage.getParagraphs().indexOf(paragraphWith(onOnePage, "Opening"))); - } - } - /** A stack under a 150pt filler, which the layout moves to the second page. */ private static Consumer movedToANewPage( java.util.function.UnaryOperator stack) { From 5bc4fe2d7f6ba88ee2d7a5aa3e7eccc1cc6981ce Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 21 Oct 2026 04:50:19 +0100 Subject: [PATCH 2/2] fix(docx): let a table opening a band open its page for the band A band opening its page held the top edge of its first paragraph, picture or spacer until it wrote a body paragraph. A table of text cells writes none, so the caption under a table that opens the band was held as if it opened the page, an empty line above it in the middle of the page. A table written in the body now ends the band's opening as a paragraph does, and every test of a block opening the page first checks the page the layout starts it on. --- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 13 ++-- .../semantic/docx/DocxBandOnANewPageTest.java | 64 +++++++++++++++---- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 4c5212fd7..b94af2767 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -76,7 +76,7 @@ creation date is real metadata. | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel | | Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms | -| Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there, less its layer's offset, which is named, is held in its line. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | +| Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there is held in its line, less, in a stack of one layer, its layer's offset, which is named. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | | Page breaks | Explicit Word page breaks | Page geometry (size, margins and orientation — a page wider than it is tall is stated as 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 ef3e6ef85..294c33a74 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 @@ -16750,8 +16750,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay throws Exception { // A band inside a band that has written nothing yet: the outer band measured its space // to the first block written, which is this one's first too, so that space stands. - boolean opensItsSpace = Double.isNaN(resumeSpacing); - if (opensItsSpace) { + if (Double.isNaN(resumeSpacing)) { double above = carriedSpacingBefore + pendingSpacingAfter + stack.margin().top() + band.above(); // Text hanging below the block before — a band's last line past its foot, a row's // icon line — takes its place out of that space, as out of the gap above a paragraph @@ -16814,11 +16813,6 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay insetRight = outerRight; standIns.removeAll(band.standIns()); closingBlocks.removeAll(band.closing()); - // A band that wrote no body paragraph — drawings alone, or text in a table's cells — - // opens nothing after it: what follows starts a page, or does not, of its own. - if (opensItsSpace) { - bandOpensItsPage = false; - } } // Nothing was written after all: the space the stack opened with is still owed. double unwritten = Double.isNaN(resumeSpacing) ? 0 : resumeSpacing; @@ -18534,6 +18528,11 @@ private double nestedTableWidth() { private XWPFTable newTable(XWPFDocument document, int rows, int columns) { resumeHere(); nameAHangLeftOver(); + // A table in the body is a block, as a paragraph is: one a band opens its page with is + // that band's first, and the block after it no longer opens the page (opensItsPageInTheFlow). + if (currentCell == null) { + bandOpensItsPage = false; + } // A container edge still waiting for a paragraph stood above this table, and a table // carries no space above itself: it is owed with the rest of the space above, which // the paragraph before the table holds below itself. Left waiting, it landed on the diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java index f20fd9a1b..d44eb881f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandOnANewPageTest.java @@ -70,10 +70,12 @@ void theBandsMarginIsHeldInALineKeptWithItsFirstParagraph() throws Exception { void theBandsPaddingIsHeldWithItsFirstParagraphsMargin() throws Exception { // 9pt of the band's padding and 6pt of the paragraph's margin: the band owes the first above // the paragraph, which owes the second itself. - try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + Consumer content = movedToANewPage(new LayerStackBuilder() .padding(DocumentInsets.top(9)) - .topLeft(new ParagraphBuilder().text("Opening").margin(DocumentInsets.top(6)).build()) - .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + .topLeft(new ParagraphBuilder().name("Opening").text("Opening").margin(DocumentInsets.top(6)).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertOpensTheSecondPage(content, "Opening"); + try (XWPFDocument document = export(content)) { XWPFParagraph opening = paragraphWith(document, "Opening"); CTSpacing line = spacing(paragraphBefore(document, opening)); @@ -105,10 +107,12 @@ void aBackdropDrawnFirstLeavesTheEdgeToTheFirstParagraph() throws Exception { @Test void aPictureOpeningTheBandIsHeld() throws Exception { - try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + Consumer content = movedToANewPage(new LayerStackBuilder() .margin(DocumentInsets.top(EDGE)) - .topLeft(new ImageBuilder().source(picture()).size(60, 30).build()) - .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + .topLeft(new ImageBuilder().name("Picture").source(picture()).size(60, 30).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertOpensTheSecondPage(content, "Picture"); + try (XWPFDocument document = export(content)) { XWPFParagraph picture = pictureIn(document); assertThat(lineBefore(document, picture)).isEqualTo(Math.round(EDGE * 20)); @@ -118,10 +122,13 @@ void aPictureOpeningTheBandIsHeld() throws Exception { @Test void aPicturesOwnMarginIsHeldWithTheBands() throws Exception { - try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() + Consumer content = movedToANewPage(new LayerStackBuilder() .margin(DocumentInsets.top(EDGE)) - .topLeft(new ImageBuilder().source(picture()).size(60, 30).margin(DocumentInsets.top(5)).build()) - .bottomRight(new ParagraphBuilder().text("Caption").build()).build()))) { + .topLeft(new ImageBuilder().name("Picture").source(picture()).size(60, 30) + .margin(DocumentInsets.top(5)).build()) + .bottomRight(new ParagraphBuilder().text("Caption").build()).build()); + assertOpensTheSecondPage(content, "Picture"); + try (XWPFDocument document = export(content)) { XWPFParagraph picture = pictureIn(document); assertThat(lineBefore(document, picture)).as("12pt and 5pt").isEqualTo(340); @@ -149,23 +156,29 @@ void aSpacerOpeningTheBandHoldsItsEdgeAndGoesWithWhatFollows() throws Exception @Test void aBandInAStackOfOneLayerHoldsTheStacksMargin() throws Exception { - try (XWPFDocument document = export(movedToANewPage(new LayerStackBuilder() - .margin(DocumentInsets.top(EDGE)).topLeft(band(stack -> stack)).build()))) { + Consumer content = movedToANewPage(new LayerStackBuilder() + .margin(DocumentInsets.top(EDGE)).topLeft(band(stack -> stack)).build()); + assertOpensTheSecondPage(content, "Opening"); + try (XWPFDocument document = export(content)) { assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(Math.round(EDGE * 20)); } } @Test void aBandInASectionHoldsTheSectionsMargin() throws Exception { - try (XWPFDocument document = export(page -> page.spacer(0, CONTENT - 10) - .addSection(section -> section.margin(DocumentInsets.top(10)).add(band(stack -> stack))))) { + Consumer content = page -> page.spacer(0, CONTENT - 10) + .addSection(section -> section.margin(DocumentInsets.top(10)).add(band(stack -> stack))); + assertOpensTheSecondPage(content, "Opening"); + try (XWPFDocument document = export(content)) { assertThat(lineBefore(document, paragraphWith(document, "Opening"))).isEqualTo(200); } } @Test void onlyTheBandsFirstBlockOpensThePage() throws Exception { - try (XWPFDocument document = export(movedToANewPage(band(stack -> stack.margin(DocumentInsets.top(EDGE)))))) { + Consumer content = movedToANewPage(band(stack -> stack.margin(DocumentInsets.top(EDGE)))); + assertOpensTheSecondPage(content, "Opening"); + try (XWPFDocument document = export(content)) { XWPFParagraph caption = paragraphWith(document, "Caption"); assertThat(paragraphBefore(document, caption).getText()).as("no line before the second layer") @@ -173,6 +186,24 @@ void onlyTheBandsFirstBlockOpensThePage() throws Exception { } } + @Test + void aTableOpeningTheBandLeavesTheLayerAfterItUnheld() throws Exception { + // The table is the band's first block, and opens the page; the caption under it does not. + Consumer content = movedToANewPage(new LayerStackBuilder() + .topLeft(new com.demcha.compose.document.dsl.TableBuilder().name("Table").columns( + com.demcha.compose.document.table.DocumentTableColumn.auto()) + .row("Cell").build()) + .bottomRight(new ParagraphBuilder().text("Caption").margin(DocumentInsets.top(EDGE)).build()).build()); + assertOpensTheSecondPage(content, "Table"); + try (XWPFDocument document = export(content)) { + List body = document.getBodyElements(); + int caption = body.indexOf(paragraphWith(document, "Caption")); + + assertThat(body.get(caption - 1)).as("the table, and no line held between them") + .isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class); + } + } + @Test void aBandOnItsPageHoldsNoLine() throws Exception { // The same band, the page tall enough to hold it under the filler. @@ -216,6 +247,11 @@ private static XWPFDocument export(Consumer content) throws Exc 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);