diff --git a/CHANGELOG.md b/CHANGELOG.md index 226736bb5..03c43e3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps a stack of one layer on the page the layout puts it on.** The layout moves + a layer stack to a new page whole, but Word was told nothing and broke between its paragraphs. + Measured in Word and LibreOffice on Windows, a stack of three lines that the page moved to the + next page stood with two of them at the foot of the page above, in both. So did one opening + with a spacer or with a margin. Nothing was named. + - 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. + - 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: its own edges and the spacer's height. The spacer was written at the foot of the page above, diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f1b8351de..482a21e92 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 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 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 | | 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 6a00e5c06..fcedb2d8e 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -75,7 +75,7 @@ creation date is real metadata. | 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 | | 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 | +| 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 | | Page breaks | Explicit Word page breaks | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index f3ab1db9e..86e0293f3 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 @@ -3582,7 +3582,12 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio } private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Exception { - boolean keepTogether = node.keepTogether() && layout.onOnePage(node); + // 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)) + && layout.onOnePage(node); boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node); String anchor = blockAnchorOf(node, inTheFlow(node, overlayDepth, oneLayerDepth)); if (!keepTogether && !keepWithNext && anchor == null) { @@ -18731,22 +18736,6 @@ private void holdTheGapAboveInTheLine(XWPFParagraph para, DocumentNode node) { spacing.setLine(BigInteger.valueOf(twipsOf(spacing.getLine()) + held)); } - /** - * Whether the layout placed anything after a node in the stacks of one layer it is written in: - * after it, or after a container it closes on the way up to the outermost stack, which the - * layout moves to its page whole with everything in it, as a spacer ending a section the - * layer's text follows. - */ - private boolean followedInItsStack(DocumentNode node) { - DocumentNode stack = openOverlays.peekLast(); - for (DocumentNode at = node; at != null && at != stack; at = layout.parentOf(at)) { - if (layout.followedInItsParent(at)) { - return true; - } - } - return false; - } - /** Makes a paragraph that holds no text as short as a separator between tables. */ private static void holdToHairline(XWPFParagraph para) { CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); @@ -19058,17 +19047,10 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio // height is a line of text tall, and it stood on top of that height: between two CV // entries held apart by a 4.5pt spacer, the page shows 16pt and LibreOffice drew 23. holdToHairline(para); + // In a stack of one layer it goes with what follows it in the stack, which is kept on its + // page whole (writePlacedNode). if (opensAPage) { holdTheGapAboveInTheLine(para, node); - // A stack of one layer moves to its new page whole, the spacer with what follows it - // there: left alone, Word set the spacer at the foot of the page above and the text - // under it at the new page's top, 22pt high. - if (oneLayerDepth > 0 && followedInItsStack(node)) { - CTPPr properties = para.getCTP().getPPr(); - if (!properties.isSetKeepNext()) { - properties.addNewKeepNext(); - } - } } // The hairline is part of the height, as a separator's is of the gap it stands in: owed // whole below it, every spacer stood a tenth of a point taller than the page's, and 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 6157c960d..d774bc59d 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 @@ -98,6 +98,87 @@ void aTableInsideAKeptBlockIsChainedRowByRow() throws Exception { } } + @Test + void aStackOfOneLayerIsChainedIntoOneUnit() throws Exception { + // The layout moves a stack whole. Measured: a stack 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 + .addLayerStack(stack -> stack.topLeft(new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Opening").addParagraph("Body").addParagraph("Tail").build())) + .addParagraph(p -> p.text("After")))) { + assertThat(keepsNext(paragraph(document, "Opening")) && keepsNext(paragraph(document, "Body"))).isTrue(); + assertThat(keepsNext(paragraph(document, "Tail"))) + .as("the stack ends here, and is not tied to what follows it").isFalse(); + assertThat(keepsLines(paragraph(document, "Opening")) && keepsLines(paragraph(document, "Tail"))).isTrue(); + assertThat(keepsNext(paragraph(document, "After")) || keepsLines(paragraph(document, "After"))).isFalse(); + } + } + + @Test + void aRowInAStackOfOneLayerIsKeptAsInABlockKeptTogether() throws Exception { + // The same keeps, row by row, and the same one-row table as a section kept together holds. + java.util.function.Function> + content = inAStack -> page -> { + com.demcha.compose.document.node.DocumentNode body = new com.demcha.compose.document.dsl.SectionBuilder() + .addRow(row -> row.addParagraph("Left").addParagraph("Right")) + .addParagraph("Under").build(); + if (inAStack) { + page.addLayerStack(stack -> stack.topLeft(body)); + } else { + page.addSection(section -> section.keepTogether().add(body)); + } + page.addParagraph("After"); + }; + try (XWPFDocument stacked = DocxExports.withLayout(595, 842, 36, content.apply(true)); + XWPFDocument kept = DocxExports.withLayout(595, 842, 36, content.apply(false))) { + XWPFTableRow stackedRow = stacked.getTables().get(0).getRow(0); + XWPFTableRow keptRow = kept.getTables().get(0).getRow(0); + + assertThat(rowKeepsNext(stackedRow)).as("the row kept with what follows it in the stack").isTrue(); + assertThat(stackedRow.isCantSplitRow()).isEqualTo(keptRow.isCantSplitRow()); + assertThat(keepsNext(paragraph(stacked, "Under"))).isFalse(); + } + } + + @Test + void aStackOfOneLayerInARowsCellIsLeftToItsRow() 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()).build()) + .addParagraph("Beside")))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + + for (XWPFParagraph paragraph : cell.getParagraphs()) { + assertThat(keepsNext(paragraph)).as("a cell of a row Word keeps on its own terms").isFalse(); + } + } + } + + @Test + void aBandIsNotChainedHere() throws Exception { + // A stack of more than one layer is written as a band, whose own keeps are another step. + 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(); + } + } + + @Test + void withoutALayoutAStackIsNotKnownToBeOnOnePage() throws Exception { + try (XWPFDocument document = DocxExports.withoutLayout(595, 842, 36, page -> page + .addLayerStack(stack -> stack.topLeft(new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Opening").addParagraph("Body").build())))) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse(); + } + } + } + @Test void withoutALayoutNothingIsKnownToBeOnOnePage() throws Exception { try (XWPFDocument document = DocxExports.withoutLayout(595, 842, 36, page -> page