From 0f19a8d4cbc52a4d9db6a684a044f03bcf8dec32 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 00:10:39 +0100 Subject: [PATCH 1/2] fix(docx): name in the report the geometry a block is written without A picture's, a barcode's and a table's left margin or padding, a page reference's sides, a spacer's and a chart's insets above and below, and a fixedWidth narrower than the column of an unpainted box or of a panel composed in a table cell were left out of the Word file in silence. Each block's note now names them, and a page reference whose anchor has no bookmark says its number is written as text. In a band, where a spacer's or a chart's insets are written as the space below, no note is made. Nothing written changes. --- CHANGELOG.md | 20 ++ .../architecture/backend-capability-matrix.md | 6 +- docs/recipes/docx-export.md | 11 +- .../semantic/docx/DocxSemanticBackend.java | 79 ++++- .../docx/DocxBlockGeometryReportTest.java | 285 ++++++++++++++++++ .../docx/DocxNodeFieldLedgerTest.java | 21 +- 6 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f94d5dc01..8cc634a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names the geometry a block is written without.** A logo given a + left margin stood at the column's edge, a spacer with a margin held only its height and + everything under it rose, and a section fixed to half the column ran its text the column's + width — and the report listed no loss. Each block's note now names: + - on a picture or a barcode: its left margin and padding, which are not written (its + paragraph sets it from the left, where its right side moves nothing); a picture drawn + beside its text or over its badge names its padding, which it is fitted to its box with; + - on a page reference: its side margins and padding, and, where its anchor has no bookmark, + its number written as text — said on the reference, where a table of contents' entry is; + - on a table: its left padding (its margin is its indent); + - on a spacer: its margin and padding above and below, as it is written as its height alone; + - on a chart: its margin and padding, which are not written round its data table; + - on an unpainted section or container, and a panel composed in a table cell: a + `fixedWidth` narrower than its column, which its paragraphs and lists run the width of. + + In a band, whose space below is measured from the page, a spacer's and a chart's insets are + written, and no note is made. None changes what is written. In `DocxNodeFieldLedgerTest` 11 + node fields move from a gap to `REPORTED`; 23 node-field gaps remain, each named — among + them a table's padding, since a padded table's rows are not matched to the layout's, and the + fixed width of a layer stack's column. - **A DOCX export's report names what each written or drawn node goes without.** The export wrote these nodes and said nothing of what it left behind: a linked logo became an unlinked picture, a rotated photo stood upright, a dashed line was drawn solid, and a link to an anchor diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 57a8b0616..147ccecfa 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -78,8 +78,8 @@ Payload records live in `core` under | Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ | | Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ⚠️ `PptxGradientFill` (`circle` path shade — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ | | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | -| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) | -| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | +| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) | +| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | @@ -101,7 +101,7 @@ Payload records live in `core` under |---|---|---|---| | External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ❌ | | Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ✅ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's or image's around everything the block wrote (`bookmarkAround`). An anchor on a node the export drops (a shape, a barcode) has nothing to mark | -| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is its placeholder text, since Word turns a `PAGEREF` to a missing bookmark into an error. `w:updateFields` is not set | +| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is its placeholder text, since Word turns a `PAGEREF` to a missing bookmark into an error, and the report names it, as it does the reference's side margin and padding, which are not written. `w:updateFields` is not set | | Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | ⚠️ `PptxNavigationWriter` (no PPTX outline concept — slide names where 1:1, extra bookmarks dropped with a note) | ❌ | ## Document chrome and output options diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index bc38f5257..81df5840e 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,14 +69,14 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, or in a header or footer. 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 paragraph in a table cell, an 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. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A 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 cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a 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 and spacers: see their rows) | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A 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 cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a 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 and spacers: see their rows). A table's margin is its indent and the space round it; its left padding is not written, and the report names it | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | -| Images | Embedded pictures at the node's declared size | +| Images | Embedded pictures at the node's declared size. 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 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. 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. 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 | -| 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) | -| Spacers | An empty paragraph a tenth of a point tall, which Word keeps (a shorter spacer stands that tenth); the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. 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 | +| 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 box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written — except under an alignment wrapper or in a band, which hold the box in to where the page placed it: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it | +| Spacers | An empty paragraph a tenth of a point tall, which Word keeps (a shorter spacer stands that tenth); the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. 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. A spacer's own margin and padding are not written, and the report names them — except in a band, where the space below its lowest block is measured from the page | | Page breaks | Explicit Word page breaks | Page geometry (size, margins and orientation — a page wider than it is tall is stated as @@ -650,7 +650,8 @@ tint it was flattened to. That is recorded with the rest. export does not draw. Its *semantic* content is its data, so the backend writes a categories-by-series table (values formatted with the chart's own axis format) and logs **one - capability warning per export**. See [charts.md](charts.md). + capability warning per export**. The chart's margin and padding are not + written round the table; its report note says so. See [charts.md](charts.md). - **Columns drawn as layers → one table row.** A two-column page can lay its columns out as the layers of one stack, each inset to its band, so the name is drawn before the sidebar. Word has no layers. When every layer is a plain 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 f4816e0a5..d4a629252 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 @@ -2550,6 +2550,38 @@ private void reportWrittenWithout(DocumentNode node, String writtenAs, ListOnly the left side moves a block its paragraph sets from the left — a picture, a + * barcode, a table on the grid the layout resolved; a page reference's alignment can set it + * from either side, and both count.

+ * + * @param node the block written + * @param marginWritten whether its side margins are written, so only its padding can be lost + * @param right whether its right side counts as well as its left + * @return the phrases, empty when every side that counts is written + */ + private List sidesLost(DocumentNode node, boolean marginWritten, boolean right) { + List lost = new ArrayList<>(2); + double marginLeft = node == leftMarginInCell ? 0 : node.margin().left(); + if (!marginWritten && (marginLeft != 0 || right && node.margin().right() != 0)) { + lost.add(right ? "its side margins are not in the file" : "its left margin is not in the file"); + } + if (node.padding().left() != 0 || right && node.padding().right() != 0) { + lost.add(right ? "its side padding is not in the file" : "its left padding is not in the file"); + } + return lost; + } + + /** Whether any side of an inset is set. */ + private static boolean isInset(DocumentInsets insets) { + return insets.top() != 0 || insets.right() != 0 || insets.bottom() != 0 || insets.left() != 0; + } + /** Why a node the export neither writes nor draws is dropped, as the report says it. */ private static String droppedBecause(DocumentNode node) { if (node instanceof PageFieldNode) { @@ -3724,9 +3756,13 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s * fixed-layout backend, where charts compile into ordinary primitives. */ private void writeChartFallback(XWPFDocument document, ChartNode node) throws Exception { + // The table is written as it is, with nothing owed round it for the chart's own insets + // — except in a band, whose space below its lowest block is measured from the page. + boolean inset = bandDepth == 0 && (isInset(node.margin()) || isInset(node.padding())); report.add(DocxExportReport.Severity.APPROXIMATED, "chart", layout.pathOf(node), "exported as its data table — a categories-by-series table in the chart's own " - + "value format — because the drawn chart is layout geometry"); + + "value format — because the drawn chart is layout geometry" + + (inset ? "; its margin and padding are not written round the table" : "")); if (chartWarned.compareAndSet(false, true)) { LOG.warn("docx.export.chart-fallback kind={} — the semantic DOCX export has no " + "layout pass, so charts are exported as their data table. " @@ -3782,7 +3818,18 @@ private void writeContainerChildren(XWPFDocument document, DocumentNode node) th // hangBelowItsBox does a shape container's, so that line keeps its own height. holdStackedLines(node.children(), Double.NaN); ContainerPaint paint = paintOf(node); - reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel"); + // A panel the layout placed is a table that wide; an unpainted box is only its contents, + // and its paragraphs and lists run the width its margins leave them, as does a panel + // composed in a table cell, which takes the cell's. A cell of a row the layout placed + // starts past its left margin; an auto column is a point wider than its content. + double room = availableWidth() - (node == leftMarginInCell ? 0 : node.margin().left()) + - node.margin().right(); + boolean narrowed = (paint.isEmpty() || layout.placedWidth(node).isEmpty()) && node.flowWidth().isFixed() + && Double.isFinite(room) + && node.flowWidth().points() < room - EDITOR_COLUMN_SLACK_POINTS - 0.5; + reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", + narrowed ? List.of("its fixed width is not in the file, so its paragraphs and lists run " + + "the width of the column it stands in") : List.of()); if (paint.isEmpty()) { writeContainerBody(document, node); return; @@ -6226,15 +6273,21 @@ private void writePageReference(XWPFDocument document, String bookmark = bookmarkedAnchors.contains(node.anchor()) ? bookmarkNames.nameFor(node.anchor()) : null; + List lost = new ArrayList<>(); if (bookmark == null) { XWPFRun run = para.createRun(); applyStyle(run, node.textStyle()); run.setText(shown); + lost.add("its anchor has no bookmark in the Word file, so its page number is written as " + + "text that an edit does not update"); } else { // A complex field, as a page field is (see appendField): a simple one's number is // repainted without its style when the field updates. appendField(para, " PAGEREF " + bookmark + " \\h ", shown, node.textStyle()); } + // Unlike a paragraph's (writeParagraph), its own sides do not hold its line in. + lost.addAll(sidesLost(node, false, true)); + reportWrittenWithout(node, "written as a paragraph", lost); } private void writeParagraph(XWPFDocument document, ParagraphNode node) { @@ -8101,7 +8154,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception .setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE); } } - reportWrittenWithout(node, "written as an inline picture"); + reportWrittenWithout(node, "written as an inline picture", sidesLost(node, false, false)); } /** @@ -8339,6 +8392,11 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte for (String lost : carriedWithout(image, false)) { message.append("; ").append(lost); } + if (isInset(image.padding())) { + // Its placement holds its padding, and the picture is fitted to the placement. + message.append("; its padding is not in the file: the picture is fitted to its box with " + + "the padding in it"); + } report.add(DocxExportReport.Severity.APPROXIMATED, image.nodeKind(), layout.pathOf(image), message.toString()); } @@ -8381,6 +8439,9 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod for (String lost : carriedWithout(node, false)) { message.append("; ").append(lost); } + for (String lost : sidesLost(node, false, false)) { + message.append("; ").append(lost); + } report.add(DocxExportReport.Severity.APPROXIMATED, "barcode", layout.pathOf(node), message.toString()); } @@ -8582,7 +8643,7 @@ private static boolean startsWith(byte[] bytes, int... signature) { private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { if (node instanceof TableNode table && !table.rows().isEmpty()) { - reportWrittenWithout(node, "written as a Word table"); + reportWrittenWithout(node, "written as a Word table", sidesLost(node, true, false)); } // The layout starts a block it moves to a new page at the block's own top edge: what // the page above holds below its last block, and the gap between the two, stay there. @@ -11942,6 +12003,16 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio pullLeft = 0; } owePendingSpacingAfter(height); + // The page gives a spacer its margin and padding above and below as well; here it is + // its height alone. In a band the space below its lowest block is measured from the + // page, a spacer's insets in it. + DocumentInsets margin = node.margin(); + DocumentInsets padding = node.padding(); + if (bandDepth == 0 + && (margin.top() != 0 || margin.bottom() != 0 || padding.top() != 0 || padding.bottom() != 0)) { + reportWrittenWithout(node, "written as its height", + List.of("its margin and padding above and below are not written with it")); + } } /** Clears the text hanging below a band: it reaches the next block only (see {@link #writeLinePair}). */ diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java new file mode 100644 index 000000000..90c5c3f0e --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java @@ -0,0 +1,285 @@ +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.chart.ChartData; +import com.demcha.compose.document.chart.ChartSpec; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.ChartNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.PageReferenceNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What of a block's own geometry the export does not write is in its report note: the sides of + * a picture, a barcode or a page reference, a table's padding, the room a spacer holds past its + * height, the space round a chart's table, the fixed width of an unpainted box. + * + *

Each was left out in silence: a logo given a left margin stood at the column's edge, a + * spacer with a margin held only its height and everything under it rose, and a section fixed + * to half the column ran its text the column's width — and the report listed no loss.

+ */ +class DocxBlockGeometryReportTest { + + private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148); + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + + @Test + void anInlinePictureNamesTheLeftSideItIsWrittenWithout() throws Exception { + DocxExportReport report = reportOf(page -> page + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40) + .margin(new DocumentInsets(0, 0, 0, 30)).padding(new DocumentInsets(0, 0, 0, 10)))); + + assertThat(detailOf(report, "ImageNode")).isEqualTo("written as an inline picture; " + + "its left margin is not in the file; its left padding is not in the file"); + assertThat(bodyOf(page -> page.addImage(image -> image.source(DocumentImageData.fromBytes(png())) + .size(80, 40).margin(new DocumentInsets(0, 0, 0, 30)).padding(new DocumentInsets(0, 0, 0, 10))))) + .as("the file carries neither").isEqualTo(bodyOf(page -> page + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40)))); + } + + @Test + void aPictureWithInsetsAboveBelowAndRightLosesNothing() throws Exception { + // Its paragraph sets it from the left, where its right side moves nothing. + DocxExportReport report = reportOf(page -> page + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40) + .margin(new DocumentInsets(12, 20, 12, 0)).padding(new DocumentInsets(4, 6, 4, 0)))); + + assertThat(report.bySubject()).doesNotContainKey("ImageNode"); + } + + @Test + void aPictureDrawnBesideItsLabelNamesThePaddingItFills() throws Exception { + DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack + .layer(new com.demcha.compose.document.dsl.ImageBuilder() + .source(DocumentImageData.fromBytes(png())).size(12, 12) + .padding(DocumentInsets.of(2)).build(), LayerAlign.CENTER_LEFT) + .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Label") + .margin(new DocumentInsets(0, 0, 0, 22)).build(), LayerAlign.CENTER_LEFT))); + + assertThat(detailOf(report, "ImageNode")).startsWith("drawn beside the text it labels") + .endsWith("its padding is not in the file: the picture is fitted to its box with the " + + "padding in it"); + } + + @Test + void aBarcodeNamesItsSides() throws Exception { + DocxExportReport report = reportOf(page -> page + .addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60) + .margin(new DocumentInsets(0, 0, 0, 30)))); + + assertThat(detailOf(report, "barcode")).startsWith("written as a picture of the symbol") + .endsWith("its left margin is not in the file"); + assertThat(bodyOf(page -> page.addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60) + .margin(new DocumentInsets(0, 0, 0, 30))))) + .as("the file does not carry it").isEqualTo(bodyOf(page -> page + .addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60)))); + } + + @Test + void aTableNamesItsSidePaddingAndHoldsItsMargin() throws Exception { + DocxExportReport padded = reportOf(page -> page + .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell") + .margin(new DocumentInsets(0, 0, 0, 10)).padding(new DocumentInsets(0, 0, 0, 30)))); + DocxExportReport indented = reportOf(page -> page + .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell") + .margin(new DocumentInsets(0, 0, 0, 30)))); + + assertThat(detailOf(padded, "TableNode")) + .isEqualTo("written as a Word table; its left padding is not in the file"); + assertThat(indented.bySubject()).as("a table's margin is its indent").doesNotContainKey("TableNode"); + String marginOnly = indentOf(bodyOf(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(200)).row("Cell").margin(new DocumentInsets(0, 0, 0, 10))))); + assertThat(marginOnly).as("its margin is its indent").isNotEmpty(); + assertThat(indentOf(bodyOf(page -> page.addTable(table -> table.columns(DocumentTableColumn.fixed(200)) + .row("Cell").margin(new DocumentInsets(0, 0, 0, 10)).padding(new DocumentInsets(0, 0, 0, 30)))))) + .as("its indent does not carry its padding").isEqualTo(marginOnly); + } + + @Test + void aPageReferenceNamesItsSides() throws Exception { + DocxExportReport report = reportOf(page -> page + .addParagraph(p -> p.text("Target").anchor("target")) + .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.LEFT, "", + DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 30)))); + + assertThat(detailOf(report, "PageReferenceNode")) + .isEqualTo("written as a paragraph; its side margins are not in the file"); + assertThat(bodyOf(page -> page.addParagraph(p -> p.text("Target").anchor("target")) + .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.LEFT, "", + DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 30))))) + .as("the file does not carry it").isEqualTo(bodyOf(page -> page + .addParagraph(p -> p.text("Target").anchor("target")).addPageReference("target"))); + } + + @Test + void aPageReferenceToAnAnchorNoBookmarkMarksNamesItsNumberAsText() throws Exception { + // A drawn shape's anchor has no bookmark, so the reference to it is its number alone. + DocxExportReport report = reportOf(page -> page + .addShape(shape -> shape.size(40, 30).fillColor(INK).anchor("box")) + .addPageReference("box")); + + assertThat(detailOf(report, "PageReferenceNode")).isEqualTo("written as a paragraph; its anchor has " + + "no bookmark in the Word file, so its page number is written as text that an edit does " + + "not update"); + } + + @Test + void aSpacerNamesTheRoomItHoldsPastItsHeight() throws Exception { + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .addSpacer(spacer -> spacer.height(10).margin(new DocumentInsets(20, 0, 0, 0))) + .addParagraph("Below")); + + assertThat(detailOf(report, "SpacerNode")).isEqualTo("written as its height; its margin and padding " + + "above and below are not written with it"); + assertThat(bodyOf(page -> page.addParagraph("Above") + .addSpacer(spacer -> spacer.height(10).margin(new DocumentInsets(20, 0, 0, 0))) + .addParagraph("Below"))) + .as("the file does not carry it").isEqualTo(bodyOf(page -> page.addParagraph("Above") + .addSpacer(spacer -> spacer.height(10)).addParagraph("Below"))); + } + + @Test + void aChartNamesTheSpaceRoundItsTable() throws Exception { + ChartData data = ChartData.builder().categories("Q1", "Q2").series("2025", 12.4, 15.1).build(); + DocxExportReport report = reportOf(page -> page + .add(new ChartNode("", ChartSpec.bar().data(data).build(), null, + new DocumentInsets(20, 0, 20, 30), DocumentInsets.zero()))); + + assertThat(detailOf(report, "chart")).startsWith("exported as its data table") + .endsWith("; its margin and padding are not written round the table"); + assertThat(bodyOf(page -> page.add(new ChartNode("", ChartSpec.bar().data(data).build(), null, + new DocumentInsets(20, 0, 20, 30), DocumentInsets.zero())))) + .as("the file does not carry them").isEqualTo(bodyOf(page -> page + .add(new ChartNode(ChartSpec.bar().data(data).build())))); + } + + @Test + void anUnpaintedSectionNamesTheFixedWidthItsTextRunsPast() throws Exception { + DocxExportReport report = reportOf(page -> page + .addSection(section -> section.fixedWidth(150).addParagraph("Text the page wraps at 150pt."))); + + assertThat(detailOf(report, "SectionNode")).isEqualTo("written as its contents; its fixed width is " + + "not in the file, so its paragraphs and lists run the width of the column it stands in"); + assertThat(bodyOf(page -> page.addSection(section -> section.fixedWidth(150) + .addParagraph("Text the page wraps at 150pt.")))) + .as("the file does not carry it").isEqualTo(bodyOf(page -> page + .addSection(section -> section.addParagraph("Text the page wraps at 150pt.")))); + } + + @Test + void aFixedWidthThePanelOrTheColumnHoldsLosesNothing() throws Exception { + // A panel is a table the width the layout placed it at; a width the column cannot give + // is the column's anyway. + DocxExportReport report = reportOf(page -> page + .addSection(panel -> panel.fixedWidth(150).fillColor(SURFACE).addParagraph("In a panel")) + .addSection(section -> section.fixedWidth(900).addParagraph("Wider than the column"))); + + assertThat(report.bySubject()).doesNotContainKey("SectionNode"); + } + + @Test + void aPictureInARowColumnTheLayoutPlacedIsPastItsLeftMarginThere() throws Exception { + // The column's cell starts where the layout placed the picture, past its margin. + DocxExportReport report = reportOf(page -> page.addRow(row -> row.weights(1, 1) + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(60, 30) + .margin(new DocumentInsets(0, 0, 0, 20))) + .addParagraph("Beside it"))); + + assertThat(report.bySubject()).doesNotContainKey("ImageNode"); + } + + @Test + void aFixedWidthSectionInAnAutoRowColumnLosesNothing() throws Exception { + // An auto column is the section's own width, and a point of editor slack. + DocxExportReport report = reportOf(page -> page.addRow(row -> row + .columns(com.demcha.compose.document.style.DocumentRowColumn.auto(), + com.demcha.compose.document.style.DocumentRowColumn.weight(1)) + .addSection(section -> section.fixedWidth(120).addParagraph("Fixed")) + .addParagraph("The rest of the row"))); + + assertThat(report.bySubject()).doesNotContainKey("SectionNode"); + } + + @Test + void aSpacerClosingABandHasItsInsetsWrittenAsTheSpaceBelow() throws Exception { + // A title and its date at either end of a band: the space below the band is measured + // from its lowest block, the spacer's margin included. + DocxExportReport report = reportOf(page -> page + .addLayerStack(stack -> stack + .layer(new com.demcha.compose.document.dsl.SectionBuilder().addParagraph("Title") + .addSpacer(spacer -> spacer.height(4).margin(new DocumentInsets(0, 0, 10, 0))) + .build(), LayerAlign.TOP_LEFT) + .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("May 2026") + .align(TextAlign.RIGHT).build(), LayerAlign.TOP_RIGHT)) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("SpacerNode"); + } + + /** The exported body's XML, to compare a document with and without what is not written. */ + private static String bodyOf(Consumer content) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 600) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + try (org.apache.poi.xwpf.usermodel.XWPFDocument document = new org.apache.poi.xwpf.usermodel.XWPFDocument( + new java.io.ByteArrayInputStream(session.export(new DocxSemanticBackend())))) { + return document.getDocument().getBody().xmlText(); + } + } + } + + /** A table's {@code w:tblInd} in the body's XML, or none. */ + private static String indentOf(String body) { + java.util.regex.Matcher indent = java.util.regex.Pattern.compile("]*/>").matcher(body); + return indent.find() ? indent.group() : ""; + } + + private static String detailOf(DocxExportReport report, String subject) { + List notes = report.bySubject().get(subject); + assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1); + return notes.get(0).detail(); + } + + private static DocxExportReport reportOf(Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 600) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + session.export(new DocxSemanticBackend(captured::set)); + } + assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull(); + return captured.get(); + } + + private static byte[] png() { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(new BufferedImage(40, 20, BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 097227b80..e9cc1d043 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -102,7 +102,7 @@ private record Entry(Fate fate, String note) { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN"); node(BarcodeNode.class, "name:INERT", "barcodeOptions:WRITTEN", "width:WRITTEN", "height:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:GAP:its left and right sides", "margin:GAP:its left and right sides", + "padding:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(CanvasLayerNode.class, "name:INERT", "width:GAP:the room the canvas holds in the flow", @@ -110,19 +110,19 @@ private record Entry(Fate fate, String note) { "placements:GAP:where its text, pictures and tables stand; they are written one after another", "clipPolicy:GAP:the clip", "padding:WRITTEN", "margin:WRITTEN"); node(ChartNode.class, "name:INERT", "spec:REPORTED", "style:REPORTED:in the chart's note", - "margin:GAP:the space round the chart's table", "padding:GAP:the space round the chart's table"); + "margin:REPORTED:in the chart's note; in a band they are the space below it", "padding:REPORTED:in the chart's note; in a band they are the space below it"); node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", - "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of an unpainted container"); + "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of a container written as a layer stack's column; an unpainted one's, and a panel's in a table cell, are reported"); node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", "anchor:REPORTED"); node(ImageNode.class, "name:INERT", "imageData:WRITTEN", "width:WRITTEN", "height:WRITTEN", "scale:WRITTEN", "fitMode:WRITTEN", "linkTarget:REPORTED", - "bookmarkOptions:REPORTED", "padding:GAP:its left and right sides", - "margin:GAP:its left and right sides", "transform:REPORTED", + "bookmarkOptions:REPORTED", "padding:REPORTED:its left side; its right moves nothing in a paragraph set from the left; drawn beside its text or over its badge, all of it", + "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", "clipToBounds:GAP:the clip"); @@ -145,9 +145,9 @@ private record Entry(Fate fate, String note) { "align:GAP:its alignment in a page zone", "padding:GAP:its sides in a page zone", "margin:GAP:its sides in a page zone"); node(PageReferenceNode.class, "name:INERT", - "anchor:GAP:the live field, where the anchor has no bookmark; the number is written as text", + "anchor:REPORTED:where the anchor has no bookmark, its number is written as text", "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", - "padding:GAP:its left and right sides", "margin:GAP:its left and right sides"); + "padding:REPORTED:its sides", "margin:REPORTED:its sides"); node(ParagraphNode.class, "name:INERT", "text:WRITTEN", "inlineRuns:WRITTEN", "textStyle:WRITTEN", "align:WRITTEN", "lineSpacing:WRITTEN", "bulletOffset:GAP:the letters of a prefix that has any", "indentStrategy:WRITTEN", "linkTarget:WRITTEN", "bookmarkOptions:GAP:the outline entry's own title", @@ -175,7 +175,7 @@ private record Entry(Fate fate, String note) { "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", "bleed:GAP:the paint's bleed past the section", "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", - "flowWidth:GAP:the width of an unpainted section"); + "flowWidth:GAP:the width of a section written as a layer stack's column; an unpainted one's, and a panel's in a table cell, are reported"); node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN", "clipPolicy:GAP:the clip of a container written as a badge, a line pair or over the flow", "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", @@ -186,11 +186,12 @@ private record Entry(Fate fate, String note) { "margin:WRITTEN", "transform:REPORTED", "fillPaint:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked"); node(SpacerNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", - "padding:GAP:its padding in the body flow", "margin:GAP:its margin in the body flow", + "padding:REPORTED:above and below; in a band they are the space below it", "margin:REPORTED:above and below; in a band they are the space below it", "grow:WRITTEN"); node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN", "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED", - "bookmarkOptions:REPORTED", "padding:GAP:its left and right sides", + "bookmarkOptions:REPORTED", + "padding:GAP:a padded table's rows, not matched to the layout's, lose its grid, row heights and unbroken rows; its left padding is reported", "margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN"); } From ce80112d3d67d7c86074dcacf7f9bfb4cf85e04f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 07:45:12 +0100 Subject: [PATCH 2/2] fix(docx): name a spacer's and a chart's insets by the side the page measures A band measures the space below its lowest block from the page, and a column the space before a layer it resumes after the one above, each taking that block's margin and padding below it; nothing else in them writes a spacer's or a chart's insets. DocxLayerColumns now hands back where those blocks are placed, and only their side below goes unnamed: the sides above, below a block in the middle and a chart's left are named in a band too. A page reference counts the sides its alignment sets it from, and a fixed width is named only where the column's width is known and the box holds text. --- CHANGELOG.md | 30 +++-- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 15 ++- .../semantic/docx/DocxLayerColumns.java | 56 +++++--- .../semantic/docx/DocxSemanticBackend.java | 95 ++++++++++---- .../docx/DocxBlockGeometryReportTest.java | 122 +++++++++++++++--- .../docx/DocxNodeFieldLedgerTest.java | 21 ++- 7 files changed, 252 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc634a63..abe907a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,20 +14,24 @@ follow semantic versioning; release dates are ISO 8601. width — and the report listed no loss. Each block's note now names: - on a picture or a barcode: its left margin and padding, which are not written (its paragraph sets it from the left, where its right side moves nothing); a picture drawn - beside its text or over its badge names its padding, which it is fitted to its box with; - - on a page reference: its side margins and padding, and, where its anchor has no bookmark, - its number written as text — said on the reference, where a table of contents' entry is; + beside its text or over its badge names its padding, since it is fitted to a box that + holds it; + - on a page reference: the margin and padding on the side or sides its alignment sets it + from, and, where the export writes no bookmark for its anchor, that its number is written + as text — the shape the anchor is on says so too, and a table of contents' entry now does; - on a table: its left padding (its margin is its indent); - - on a spacer: its margin and padding above and below, as it is written as its height alone; - - on a chart: its margin and padding, which are not written round its data table; - - on an unpainted section or container, and a panel composed in a table cell: a - `fixedWidth` narrower than its column, which its paragraphs and lists run the width of. - - In a band, whose space below is measured from the page, a spacer's and a chart's insets are - written, and no note is made. None changes what is written. In `DocxNodeFieldLedgerTest` 11 - node fields move from a gap to `REPORTED`; 23 node-field gaps remain, each named — among - them a table's padding, since a padded table's rows are not matched to the layout's, and the - fixed width of a layer stack's column. + - on a spacer: its margin and padding above and below; on a chart, those and the ones on its + left, which are not written round its data table. Below the lowest block of a band, or of + a layer another resumes after in its column, the space is measured from the page, the + block's own margin and padding included, so that side is written and not named; + - on an unpainted section or container holding text, and on a panel composed in a table + cell: a `fixedWidth` narrower than its column, which its paragraphs and lists run the + width of. + + None changes what is written. In `DocxNodeFieldLedgerTest` 11 node fields move from a gap to + `REPORTED`; 23 node-field gaps remain, each named — among them a table's padding, since a + table with side padding is not matched to the rows the layout placed, and the fixed width of + a layer stack's column. - **A DOCX export's report names what each written or drawn node goes without.** The export wrote these nodes and said nothing of what it left behind: a linked logo became an unlinked picture, a rotated photo stood upright, a dashed line was drawn solid, and a link to an anchor diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 147ccecfa..4319010eb 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -101,7 +101,7 @@ Payload records live in `core` under |---|---|---|---| | External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ❌ | | Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ✅ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's or image's around everything the block wrote (`bookmarkAround`). An anchor on a node the export drops (a shape, a barcode) has nothing to mark | -| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is its placeholder text, since Word turns a `PAGEREF` to a missing bookmark into an error, and the report names it, as it does the reference's side margin and padding, which are not written. `w:updateFields` is not set | +| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is the text the page prints, since Word turns a `PAGEREF` to a missing bookmark into an error, and the report names it, as it does the margins and padding on the sides its alignment sets it from, which are not written. `w:updateFields` is not set | | Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | ⚠️ `PptxNavigationWriter` (no PPTX outline concept — slide names where 1:1, extra bookmarks dropped with a note) | ❌ | ## Document chrome and output options diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 81df5840e..64e7127e8 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -75,8 +75,8 @@ creation date is real metadata. | Images | Embedded pictures at the node's declared size. 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 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. 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. 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 | -| 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 box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written — except under an alignment wrapper or in a band, which hold the box in to where the page placed it: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it | -| Spacers | An empty paragraph a tenth of a point tall, which Word keeps (a shorter spacer stands that tenth); the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. 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. A spacer's own margin and padding are not written, and the report names them — except in a band, where the space below its lowest block is measured from the page | +| 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 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 | +| Spacers | An empty paragraph a tenth of a point tall, which Word keeps (a shorter spacer stands that tenth); the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. 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. 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 @@ -123,9 +123,10 @@ number of pages it laid out. A file therefore opens reading the same numbers as The export does not set `w:updateFields`. It would make Word ask, on every open, whether to update fields — to recompute numbers that already read correctly. -A page reference to an anchor the document does not bookmark is written as its text, the -placeholder the page prints: Word turns a `PAGEREF` to a missing bookmark into "Error! -Bookmark not defined." the first time it updates. +A page reference to an anchor the export writes no bookmark for — a drawn shape's — is +written as the text the page prints, a number no edit updates, and its report note says so: +Word turns a `PAGEREF` to a missing bookmark into "Error! Bookmark not defined." the first +time it updates. ## Several sections in one document @@ -650,8 +651,8 @@ tint it was flattened to. That is recorded with the rest. export does not draw. Its *semantic* content is its data, so the backend writes a categories-by-series table (values formatted with the chart's own axis format) and logs **one - capability warning per export**. The chart's margin and padding are not - written round the table; its report note says so. See [charts.md](charts.md). + capability warning per export**. The chart's margin and padding above, below + and on its left are not written round the table; its report note says so. See [charts.md](charts.md). - **Columns drawn as layers → one table row.** A two-column page can lay its columns out as the layers of one stack, each inset to its band, so the name is drawn before the sidebar. Word has no layers. When every layer is a plain diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java index dc61003d3..bafda5c53 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java @@ -65,9 +65,12 @@ record Column(double left, double right, List layers) { * @param moves what is written in place of a stand-in instead (see {@link Moves}) * @param drawn the layers holding only what is drawn where the page puts it — a column's * mark, the rule between two columns — which belong to no column + * @param closing where the blocks a resume is measured from are placed: the space below each + * is the page's, its own margin and padding below it included */ record Plan(double width, List columns, Set standIns, - Map resumes, Moves moves, List drawn) { + Map resumes, Moves moves, List drawn, + Set closing) { /** * The space above a layer's first block when it follows another layer in its cell. @@ -160,11 +163,12 @@ private static Plan columnsOf(LayerStackNode stack, DocxLayoutMetrics layout, Pr columns.sort(Comparator.comparingDouble(Column::left)); Set standIns = Collections.newSetFromMap(new IdentityHashMap<>()); Map resumes = new IdentityHashMap<>(); + Set closing = Collections.newSetFromMap(new IdentityHashMap<>()); Moves moves = new Moves(); for (Column column : columns) { - flow(column.layers(), layout, node -> false, painted, standIns, resumes, moves); + flow(column.layers(), layout, node -> false, painted, standIns, resumes, closing, moves); } - return new Plan(width, List.copyOf(columns), standIns, resumes, moves, List.copyOf(drawnLayers)); + return new Plan(width, List.copyOf(columns), standIns, resumes, moves, List.copyOf(drawnLayers), closing); } /** @@ -215,9 +219,11 @@ private boolean filled(DocumentNode standIn) { * aside * @param below from the text of its lowest written block to the stack's bottom; below zero * when that text runs past the bottom, by as much as it hangs below it + * @param closing where the blocks {@code below} and a resume are measured from are placed: the + * space below each is the page's, its own margin and padding below it included */ record Band(List layers, Set standIns, Map resumes, - double above, double below) { + double above, double below, Set closing) { /** @see Plan#resume(DocumentNode) */ double resume(DocumentNode layer) { @@ -252,10 +258,11 @@ static Band band(DocumentNode stack, DocxLayoutMetrics layout, Predicate layers = stack.children(); Set standIns = Collections.newSetFromMap(new IdentityHashMap<>()); Map resumes = new IdentityHashMap<>(); + Set closing = Collections.newSetFromMap(new IdentityHashMap<>()); // A band's layers are written over each other in one run, where a stand-in's place // is where the later layer writes its content anyway: nothing moves. Moves none = new Moves(); - flow(layers, layout, drawing, node -> false, standIns, resumes, none); + flow(layers, layout, drawing, node -> false, standIns, resumes, closing, none); // A stack nested in a layer is a band of its own, and its stand-ins are not written // either: the band's first and lowest blocks are measured past them too, or a badge's // place-holding spacer would set where the initials over it start. @@ -279,7 +286,8 @@ static Band band(DocumentNode stack, DocxLayoutMetrics layout, Predicate written(Set standIns, Moves private static void nestedStandIns(DocumentNode node, DocxLayoutMetrics layout, Predicate drawing, Set standIns) { if (node instanceof LayerStackNode nested) { - flow(nested.children(), layout, drawing, candidate -> false, standIns, new IdentityHashMap<>(), new Moves()); + flow(nested.children(), layout, drawing, candidate -> false, standIns, new IdentityHashMap<>(), + Collections.newSetFromMap(new IdentityHashMap<>()), new Moves()); } for (DocumentNode child : node.children()) { nestedStandIns(child, layout, drawing, standIns); } } - /** The stand-ins among layers that share a band, and each later layer's resume. */ + /** + * The stand-ins among layers that share a band, each later layer's resume, and where the + * block each resume is measured from is placed. + */ private static void flow(List layers, DocxLayoutMetrics layout, Predicate drawing, Predicate painted, - Set standIns, Map resumes, Moves moves) { + Set standIns, Map resumes, + Set closing, Moves moves) { if (layers.size() < 2) { return; } @@ -352,10 +365,14 @@ private static void flow(List layers, DocxLayoutMetrics layout, } } for (int index = 1; index < layers.size(); index++) { + PlacedNode[] from = new PlacedNode[1]; double resume = resume(layers.subList(0, index), layers.get(index), layout, standIns, drawing, - painted, moves); + painted, moves, from); if (!Double.isNaN(resume)) { resumes.put(layers.get(index), resume); + if (from[0] != null) { + closing.add(from[0]); + } } } } @@ -430,10 +447,11 @@ private static boolean inside(PlacedNode box, PlacedNode place, double[] across) */ private static double resume(List above, DocumentNode layer, DocxLayoutMetrics layout, Set standIns, - Predicate drawing, Predicate painted, Moves moves) { + Predicate drawing, Predicate painted, Moves moves, + PlacedNode[] from) { double bottom = Double.NaN; for (DocumentNode earlier : above) { - bottom = lowestEdge(earlier, layout, written(standIns, moves, true), drawing, painted, null, bottom); + bottom = lowestEdge(earlier, layout, written(standIns, moves, true), drawing, painted, null, bottom, from); } // A filled stand-in is where what fills it is written, so it counts as a first block. DocumentNode first = firstLeaf(layer, layout, written(standIns, moves, true), drawing); @@ -449,10 +467,14 @@ private static double resume(List above, DocumentNode layer, * The lowest edge on the page a written leaf under {@code node} ends at: its text's bottom, * or the bottom of the outermost painted panel it sits in. Measured up from the page's foot, * so lower is smaller; {@code NaN} while none is found. + * + * @param from set to where the leaf whose text ends at the edge is placed, or to null where + * the edge is a panel's */ private static double lowestEdge(DocumentNode node, DocxLayoutMetrics layout, Predicate written, Predicate drawing, - Predicate painted, PlacedNode panel, double lowest) { + Predicate painted, PlacedNode panel, double lowest, + PlacedNode[] from) { if (!written.test(node)) { return lowest; } @@ -466,10 +488,14 @@ private static double lowestEdge(DocumentNode node, DocxLayoutMetrics layout, boolean onePage = outer != null && outer.startPage() == outer.endPage() && outer.startPage() == placed.startPage(); double edge = onePage ? outer.placementY() : placed.placementY() + placed.padding().bottom(); - return Double.isNaN(lowest) || edge < lowest ? edge : lowest; + if (Double.isNaN(lowest) || edge < lowest) { + from[0] = onePage ? null : placed; + return edge; + } + return lowest; } for (DocumentNode child : node.children()) { - lowest = lowestEdge(child, layout, written, drawing, painted, outer, lowest); + lowest = lowestEdge(child, layout, written, drawing, painted, outer, lowest, from); } return lowest; } 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 d4a629252..1c15c8855 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 @@ -248,6 +248,10 @@ public final class DocxSemanticBackend implements SemanticBackend { // layer stack they sit in is written as columns (DocxLayerColumns). private final java.util.Set standIns = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // Where the blocks are placed whose space below a band or a column measures from the page, + // their own margin and padding below them included (DocxLayerColumns.Band#closing). + private final java.util.Set closingBlocks = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // What the stacks being written write in a stand-in's place instead (DocxLayerColumns.Moves), // and the blocks being written there now rather than skipped in their own layer. private final List moves = new ArrayList<>(); @@ -688,6 +692,7 @@ private byte[] write(List sections, Path outputFile) throws Exc containerRadiusWarned.set(false); warnedNodeKinds.clear(); zonePartsReported.clear(); + closingBlocks.clear(); surfaceBehind = null; overlayDepth = 0; bandDepth = 0; @@ -2556,23 +2561,25 @@ private void reportWrittenWithout(DocumentNode node, String writtenAs, ListOnly the left side moves a block its paragraph sets from the left — a picture, a - * barcode, a table on the grid the layout resolved; a page reference's alignment can set it - * from either side, and both count.

+ *

A side counts where it moves the block: only the left for a picture, a barcode or a + * table, which their paragraph or indent sets from the left; for a page reference, the side + * or sides its alignment sets it from.

* * @param node the block written * @param marginWritten whether its side margins are written, so only its padding can be lost - * @param right whether its right side counts as well as its left + * @param left whether its left side counts + * @param right whether its right side counts * @return the phrases, empty when every side that counts is written */ - private List sidesLost(DocumentNode node, boolean marginWritten, boolean right) { + private List sidesLost(DocumentNode node, boolean marginWritten, boolean left, boolean right) { List lost = new ArrayList<>(2); + String side = left && right ? "side" : left ? "left" : "right"; double marginLeft = node == leftMarginInCell ? 0 : node.margin().left(); - if (!marginWritten && (marginLeft != 0 || right && node.margin().right() != 0)) { - lost.add(right ? "its side margins are not in the file" : "its left margin is not in the file"); + if (!marginWritten && (left && marginLeft != 0 || right && node.margin().right() != 0)) { + lost.add("its " + side + (left && right ? " margins are" : " margin is") + " not in the file"); } - if (node.padding().left() != 0 || right && node.padding().right() != 0) { - lost.add(right ? "its side padding is not in the file" : "its left padding is not in the file"); + if (left && node.padding().left() != 0 || right && node.padding().right() != 0) { + lost.add("its " + side + " padding is not in the file"); } return lost; } @@ -2582,6 +2589,34 @@ private static boolean isInset(DocumentInsets insets) { return insets.top() != 0 || insets.right() != 0 || insets.bottom() != 0 || insets.left() != 0; } + /** + * The sides a block written with none of its own insets — a spacer, a chart's table — loses + * them on: above, below and, for a block set from the left, on the left. Below is written + * where the block closes a band, or a layer another resumes after in its column: that space + * is measured from the page, the block's own margin and padding below it included. A cell + * of a row the layout placed starts past its left margin. + * + * @param node the block + * @param left whether its left side counts + * @return "above", "below", "on the left", those it sets and loses, in that order + */ + private List insetSidesLost(DocumentNode node, boolean left) { + DocumentInsets margin = node.margin(); + DocumentInsets padding = node.padding(); + List sides = new ArrayList<>(3); + if (margin.top() != 0 || padding.top() != 0) { + sides.add("above"); + } + if ((margin.bottom() != 0 || padding.bottom() != 0) && !closingBlocks.contains(layout.placement(node))) { + sides.add("below"); + } + double marginLeft = node == leftMarginInCell ? 0 : margin.left(); + if (left && (marginLeft != 0 || padding.left() != 0)) { + sides.add("on the left"); + } + return sides; + } + /** Why a node the export neither writes nor draws is dropped, as the report says it. */ private static String droppedBecause(DocumentNode node) { if (node instanceof PageFieldNode) { @@ -3756,13 +3791,14 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s * fixed-layout backend, where charts compile into ordinary primitives. */ private void writeChartFallback(XWPFDocument document, ChartNode node) throws Exception { - // The table is written as it is, with nothing owed round it for the chart's own insets - // — except in a band, whose space below its lowest block is measured from the page. - boolean inset = bandDepth == 0 && (isInset(node.margin()) || isInset(node.padding())); + // The table is written as it is, set from the left with nothing owed round it for the + // chart's own insets, but where the page measures the space below it (insetSidesLost). + List sides = insetSidesLost(node, true); report.add(DocxExportReport.Severity.APPROXIMATED, "chart", layout.pathOf(node), "exported as its data table — a categories-by-series table in the chart's own " + "value format — because the drawn chart is layout geometry" - + (inset ? "; its margin and padding are not written round the table" : "")); + + (sides.isEmpty() ? "" : "; its margin and padding " + listed(sides) + + " are not written round the table")); if (chartWarned.compareAndSet(false, true)) { LOG.warn("docx.export.chart-fallback kind={} — the semantic DOCX export has no " + "layout pass, so charts are exported as their data table. " @@ -3822,10 +3858,11 @@ private void writeContainerChildren(XWPFDocument document, DocumentNode node) th // and its paragraphs and lists run the width its margins leave them, as does a panel // composed in a table cell, which takes the cell's. A cell of a row the layout placed // starts past its left margin; an auto column is a point wider than its content. + boolean known = currentCell != null ? Double.isFinite(currentCellWidth) : contentWidth < Double.MAX_VALUE; double room = availableWidth() - (node == leftMarginInCell ? 0 : node.margin().left()) - node.margin().right(); boolean narrowed = (paint.isEmpty() || layout.placedWidth(node).isEmpty()) && node.flowWidth().isFixed() - && Double.isFinite(room) + && known && holdsWrappedText(node) && node.flowWidth().points() < room - EDITOR_COLUMN_SLACK_POINTS - 0.5; reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", narrowed ? List.of("its fixed width is not in the file, so its paragraphs and lists run " @@ -6286,7 +6323,7 @@ private void writePageReference(XWPFDocument document, appendField(para, " PAGEREF " + bookmark + " \\h ", shown, node.textStyle()); } // Unlike a paragraph's (writeParagraph), its own sides do not hold its line in. - lost.addAll(sidesLost(node, false, true)); + lost.addAll(sidesLost(node, false, node.align() != TextAlign.RIGHT, node.align() != TextAlign.LEFT)); reportWrittenWithout(node, "written as a paragraph", lost); } @@ -8154,7 +8191,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception .setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE); } } - reportWrittenWithout(node, "written as an inline picture", sidesLost(node, false, false)); + reportWrittenWithout(node, "written as an inline picture", sidesLost(node, false, true, false)); } /** @@ -8439,7 +8476,7 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod for (String lost : carriedWithout(node, false)) { message.append("; ").append(lost); } - for (String lost : sidesLost(node, false, false)) { + for (String lost : sidesLost(node, false, true, false)) { message.append("; ").append(lost); } report.add(DocxExportReport.Severity.APPROXIMATED, "barcode", layout.pathOf(node), message.toString()); @@ -8643,7 +8680,7 @@ private static boolean startsWith(byte[] bytes, int... signature) { private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { if (node instanceof TableNode table && !table.rows().isEmpty()) { - reportWrittenWithout(node, "written as a Word table", sidesLost(node, true, false)); + reportWrittenWithout(node, "written as a Word table", sidesLost(node, true, true, false)); } // The layout starts a block it moves to a new page at the block's own top edge: what // the page above holds below its last block, and the gap between the two, stay there. @@ -10274,6 +10311,7 @@ private void writeLayerColumns(XWPFDocument document, } writeRowColumns(table, cells); standIns.addAll(plan.standIns()); + closingBlocks.addAll(plan.closing()); moves.add(plan.moves()); try { XWPFTableRow row = table.getRow(0); @@ -10330,6 +10368,7 @@ private void writeLayerColumns(XWPFDocument document, } } finally { standIns.removeAll(plan.standIns()); + closingBlocks.removeAll(plan.closing()); moves.remove(plan.moves()); } // After the columns, as the stack lays them over the columns: a card's mark stands over @@ -10383,6 +10422,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay carriedSpacingBefore = 0; pendingSpacingAfter = 0; standIns.addAll(band.standIns()); + closingBlocks.addAll(band.closing()); double outerLeft = insetLeft; double outerRight = insetRight; insetLeft += stack.margin().left() + stack.padding().left(); @@ -10415,6 +10455,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay insetLeft = outerLeft; insetRight = outerRight; standIns.removeAll(band.standIns()); + closingBlocks.removeAll(band.closing()); } // Nothing was written after all: the space the stack opened with is still owed. double unwritten = Double.isNaN(resumeSpacing) ? 0 : resumeSpacing; @@ -10552,6 +10593,12 @@ private void collectDrawnPictures(DocumentNode node, java.util.Set } } + /** Whether a node is or holds what wraps at its box's width: a paragraph or a list. */ + private static boolean holdsWrappedText(DocumentNode node) { + return node instanceof ParagraphNode || node instanceof com.demcha.compose.document.node.ListNode + || node.children().stream().anyMatch(DocxSemanticBackend::holdsWrappedText); + } + /** Whether a node is a paragraph of text or holds one. */ private static boolean holdsText(DocumentNode node) { return node instanceof ParagraphNode || node.children().stream().anyMatch(DocxSemanticBackend::holdsText); @@ -12004,14 +12051,12 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio } owePendingSpacingAfter(height); // The page gives a spacer its margin and padding above and below as well; here it is - // its height alone. In a band the space below its lowest block is measured from the - // page, a spacer's insets in it. - DocumentInsets margin = node.margin(); - DocumentInsets padding = node.padding(); - if (bandDepth == 0 - && (margin.top() != 0 || margin.bottom() != 0 || padding.top() != 0 || padding.bottom() != 0)) { + // its height alone. The space below a band, or below a layer another resumes after in + // its column, is measured from the page, the margin and padding below its block in it. + List sides = insetSidesLost(node, false); + if (!sides.isEmpty()) { reportWrittenWithout(node, "written as its height", - List.of("its margin and padding above and below are not written with it")); + List.of("its margin and padding " + listed(sides) + " are not written with it")); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java index 90c5c3f0e..711a71838 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBlockGeometryReportTest.java @@ -83,10 +83,10 @@ void aPictureDrawnBesideItsLabelNamesThePaddingItFills() throws Exception { void aBarcodeNamesItsSides() throws Exception { DocxExportReport report = reportOf(page -> page .addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60) - .margin(new DocumentInsets(0, 0, 0, 30)))); + .margin(new DocumentInsets(0, 0, 0, 30)).padding(new DocumentInsets(0, 0, 0, 4)))); assertThat(detailOf(report, "barcode")).startsWith("written as a picture of the symbol") - .endsWith("its left margin is not in the file"); + .endsWith("; its left margin is not in the file; its left padding is not in the file"); assertThat(bodyOf(page -> page.addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60) .margin(new DocumentInsets(0, 0, 0, 30))))) .as("the file does not carry it").isEqualTo(bodyOf(page -> page @@ -114,14 +114,26 @@ void aTableNamesItsSidePaddingAndHoldsItsMargin() throws Exception { } @Test - void aPageReferenceNamesItsSides() throws Exception { - DocxExportReport report = reportOf(page -> page + void aPageReferenceNamesTheSidesItsAlignmentSetsItFrom() throws Exception { + DocxExportReport left = reportOf(page -> page .addParagraph(p -> p.text("Target").anchor("target")) .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.LEFT, "", - DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 30)))); - - assertThat(detailOf(report, "PageReferenceNode")) - .isEqualTo("written as a paragraph; its side margins are not in the file"); + new DocumentInsets(0, 6, 0, 4), new DocumentInsets(0, 12, 0, 30)))); + DocxExportReport centred = reportOf(page -> page + .addParagraph(p -> p.text("Target").anchor("target")) + .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.CENTER, "", + new DocumentInsets(0, 6, 0, 4), DocumentInsets.zero()))); + DocxExportReport right = reportOf(page -> page + .addParagraph(p -> p.text("Target").anchor("target")) + .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.RIGHT, "", + new DocumentInsets(0, 0, 0, 4), new DocumentInsets(0, 0, 0, 30)))); + + assertThat(detailOf(left, "PageReferenceNode")).isEqualTo( + "written as a paragraph; its left margin is not in the file; its left padding is not in the file"); + assertThat(detailOf(centred, "PageReferenceNode")) + .isEqualTo("written as a paragraph; its side padding is not in the file"); + assertThat(right.bySubject()).as("set from the right, its left side moves nothing") + .doesNotContainKey("PageReferenceNode"); assertThat(bodyOf(page -> page.addParagraph(p -> p.text("Target").anchor("target")) .add(new PageReferenceNode("", "target", DocumentTextStyle.DEFAULT, TextAlign.LEFT, "", DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 30))))) @@ -148,8 +160,8 @@ void aSpacerNamesTheRoomItHoldsPastItsHeight() throws Exception { .addSpacer(spacer -> spacer.height(10).margin(new DocumentInsets(20, 0, 0, 0))) .addParagraph("Below")); - assertThat(detailOf(report, "SpacerNode")).isEqualTo("written as its height; its margin and padding " - + "above and below are not written with it"); + assertThat(detailOf(report, "SpacerNode")) + .isEqualTo("written as its height; its margin and padding above are not written with it"); assertThat(bodyOf(page -> page.addParagraph("Above") .addSpacer(spacer -> spacer.height(10).margin(new DocumentInsets(20, 0, 0, 0))) .addParagraph("Below"))) @@ -165,7 +177,7 @@ void aChartNamesTheSpaceRoundItsTable() throws Exception { new DocumentInsets(20, 0, 20, 30), DocumentInsets.zero()))); assertThat(detailOf(report, "chart")).startsWith("exported as its data table") - .endsWith("; its margin and padding are not written round the table"); + .endsWith("; its margin and padding above, below and on the left are not written round the table"); assertThat(bodyOf(page -> page.add(new ChartNode("", ChartSpec.bar().data(data).build(), null, new DocumentInsets(20, 0, 20, 30), DocumentInsets.zero())))) .as("the file does not carry them").isEqualTo(bodyOf(page -> page @@ -220,19 +232,87 @@ void aFixedWidthSectionInAnAutoRowColumnLosesNothing() throws Exception { } @Test - void aSpacerClosingABandHasItsInsetsWrittenAsTheSpaceBelow() throws Exception { + void aSpacerClosingABandHasItsInsetsBelowWrittenAsTheSpaceBelow() throws Exception { // A title and its date at either end of a band: the space below the band is measured - // from its lowest block, the spacer's margin included. - DocxExportReport report = reportOf(page -> page - .addLayerStack(stack -> stack - .layer(new com.demcha.compose.document.dsl.SectionBuilder().addParagraph("Title") - .addSpacer(spacer -> spacer.height(4).margin(new DocumentInsets(0, 0, 10, 0))) - .build(), LayerAlign.TOP_LEFT) - .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("May 2026") - .align(TextAlign.RIGHT).build(), LayerAlign.TOP_RIGHT)) - .addParagraph("Below")); + // from its lowest block, the spacer's margin below it included. + DocxExportReport report = reportOf(band(new DocumentInsets(0, 0, 10, 0), false)); assertThat(report.bySubject()).doesNotContainKey("SpacerNode"); + assertThat(bodyOf(band(new DocumentInsets(0, 0, 10, 0), false))) + .as("its margin below is the band's space below").isNotEqualTo(bodyOf(band(DocumentInsets.zero(), false))); + } + + @Test + void aSpacerInABandNamesTheInsetsNoMeasureTakesIn() throws Exception { + // Its margin above is no band's measure; nor is any inset of a spacer between two of + // a layer's blocks. + DocxExportReport closing = reportOf(band(new DocumentInsets(10, 0, 10, 0), false)); + DocxExportReport between = reportOf(band(new DocumentInsets(10, 0, 6, 0), true)); + + assertThat(detailOf(closing, "SpacerNode")) + .isEqualTo("written as its height; its margin and padding above are not written with it"); + assertThat(detailOf(between, "SpacerNode")) + .isEqualTo("written as its height; its margin and padding above and below are not written with it"); + } + + @Test + void aSpacerClosingALayerAnotherResumesAfterInItsColumnLosesNothingBelow() throws Exception { + // Two layers share the left column; the second resumes at the page's distance below the + // first's lowest block, the spacer's margin below it included. + DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack + .layer(new com.demcha.compose.document.dsl.SectionBuilder().bookmark( + new com.demcha.compose.document.node.DocumentBookmarkOptions("Left")) + .margin(new DocumentInsets(0, 200, 0, 0)).addParagraph("Left, first") + .addSpacer(spacer -> spacer.height(4).margin(new DocumentInsets(0, 0, 10, 0))).build(), + LayerAlign.TOP_LEFT) + .layer(new com.demcha.compose.document.dsl.SectionBuilder() + .margin(new DocumentInsets(60, 200, 0, 0)).addParagraph("Left, second").build(), + LayerAlign.TOP_LEFT) + .layer(new com.demcha.compose.document.dsl.SectionBuilder() + .margin(new DocumentInsets(0, 0, 0, 220)).addParagraph("Right").build(), + LayerAlign.TOP_LEFT))); + + assertThat(detailOf(report, "SectionNode")).as("the stack is written as columns") + .startsWith("written as a column of its layer stack"); + assertThat(report.bySubject()).doesNotContainKey("SpacerNode"); + } + + @Test + void aPanelComposedInATableCellNamesTheFixedWidthItRunsPast() throws Exception { + DocxExportReport report = reportOf(page -> page + .addTable(table -> table.columns(DocumentTableColumn.fixed(240)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node( + new com.demcha.compose.document.dsl.SectionBuilder().fixedWidth(120) + .fillColor(SURFACE).addParagraph("In a panel in a cell").build())))); + + assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a panel; its fixed width is not in the " + + "file, so its paragraphs and lists run the width of the column it stands in"); + } + + @Test + void aFixedWidthUnderAnAlignmentWrapperIsHeldIn() throws Exception { + DocxExportReport report = reportOf(page -> page + .addAligned(com.demcha.compose.document.node.HorizontalAlign.CENTER, + new com.demcha.compose.document.dsl.SectionBuilder().fixedWidth(150) + .addParagraph("Held in to where the page places it").build())); + + assertThat(report.bySubject()).doesNotContainKey("SectionNode"); + } + + /** A band: a title, a spacer with the given margin and, when asked, a subtitle, beside a date. */ + private static Consumer band(DocumentInsets spacerMargin, boolean subtitle) { + return page -> { + com.demcha.compose.document.dsl.SectionBuilder title = new com.demcha.compose.document.dsl.SectionBuilder() + .addParagraph("Title").addSpacer(spacer -> spacer.height(4).margin(spacerMargin)); + if (subtitle) { + title.addParagraph("Subtitle"); + } + page.addLayerStack(stack -> stack + .layer(title.build(), LayerAlign.TOP_LEFT) + .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("May 2026") + .align(TextAlign.RIGHT).build(), LayerAlign.TOP_RIGHT)) + .addParagraph("Below"); + }; } /** The exported body's XML, to compare a document with and without what is not written. */ diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index e9cc1d043..0c3d1dcca 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -102,7 +102,8 @@ private record Entry(Fate fate, String note) { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN"); node(BarcodeNode.class, "name:INERT", "barcodeOptions:WRITTEN", "width:WRITTEN", "height:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", + "padding:REPORTED:its left side; its right moves nothing in a paragraph set from the left", + "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(CanvasLayerNode.class, "name:INERT", "width:GAP:the room the canvas holds in the flow", @@ -110,11 +111,13 @@ private record Entry(Fate fate, String note) { "placements:GAP:where its text, pictures and tables stand; they are written one after another", "clipPolicy:GAP:the clip", "padding:WRITTEN", "margin:WRITTEN"); node(ChartNode.class, "name:INERT", "spec:REPORTED", "style:REPORTED:in the chart's note", - "margin:REPORTED:in the chart's note; in a band they are the space below it", "padding:REPORTED:in the chart's note; in a band they are the space below it"); + "margin:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written", + "padding:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written"); node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", - "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of a container written as a layer stack's column; an unpainted one's, and a panel's in a table cell, are reported"); + "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of a container written as a layer stack's column; " + + "an unpainted one's, and a panel's in a table cell, are reported"); node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", @@ -147,7 +150,8 @@ private record Entry(Fate fate, String note) { node(PageReferenceNode.class, "name:INERT", "anchor:REPORTED:where the anchor has no bookmark, its number is written as text", "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", - "padding:REPORTED:its sides", "margin:REPORTED:its sides"); + "padding:REPORTED:the sides its alignment sets it from", + "margin:REPORTED:the sides its alignment sets it from"); node(ParagraphNode.class, "name:INERT", "text:WRITTEN", "inlineRuns:WRITTEN", "textStyle:WRITTEN", "align:WRITTEN", "lineSpacing:WRITTEN", "bulletOffset:GAP:the letters of a prefix that has any", "indentStrategy:WRITTEN", "linkTarget:WRITTEN", "bookmarkOptions:GAP:the outline entry's own title", @@ -175,7 +179,8 @@ private record Entry(Fate fate, String note) { "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", "bleed:GAP:the paint's bleed past the section", "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", - "flowWidth:GAP:the width of a section written as a layer stack's column; an unpainted one's, and a panel's in a table cell, are reported"); + "flowWidth:GAP:the width of a section written as a layer stack's column; " + + "an unpainted one's, and a panel's in a table cell, are reported"); node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN", "clipPolicy:GAP:the clip of a container written as a badge, a line pair or over the flow", "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", @@ -186,12 +191,14 @@ private record Entry(Fate fate, String note) { "margin:WRITTEN", "transform:REPORTED", "fillPaint:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked"); node(SpacerNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", - "padding:REPORTED:above and below; in a band they are the space below it", "margin:REPORTED:above and below; in a band they are the space below it", + "padding:REPORTED:above and below; below a block a band or a column measures from, written", + "margin:REPORTED:above and below; below a block a band or a column measures from, written", "grow:WRITTEN"); node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN", "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:GAP:a padded table's rows, not matched to the layout's, lose its grid, row heights and unbroken rows; its left padding is reported", + "padding:GAP:with side padding, its rows are not matched to the layout's and lose its grid, " + + "row heights, unbroken rows and the anchoring of drawings in them; its left padding is reported", "margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN"); }