diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37880a70f..fd91acc1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,45 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export writes a row's fill, outline and side borders, as a panel holding its
+ columns.** A row paints its box as a container does — `RowBuilder.fillColor`, `stroke`,
+ `borders` — and the export wrote its columns as a table with no shading or borders, naming the
+ paint as lost (`row paint`).
+ - **A row that paints is written as a panel**, as a painted container is: a table of one cell
+ carrying the fill as its shading and the outline and side borders as its borders, its
+ margins the row's padding, as a panel's are. The columns are a table nested in the cell,
+ laid out as the row's own less the padding the cell's margins hold; the panel holds the
+ row's height. The panel is kept whole across a page break, as the row is, and a row the
+ layout moves to a new page keeps the space above it there.
+ - **A corner radius squares**, and is named (`corner radius`), as a panel's is. A translucent
+ fill or border is flattened against what the page paints under it, and named.
+ - **What stands on the row is set on its fill.** A chip, a rule or a translucent panel in one
+ of its columns is flattened against the panel's shading as written, where it was flattened
+ against the page under the row.
+ - **Borders above and below are named.** Word draws them outside the panel's row, where the
+ page strokes them on the box's edge: where no space round the row takes them, what follows
+ stands up to their width lower (`RowNode`, `APPROXIMATED`). Measured, five rows with a 1pt
+ bottom border one under the other set what follows 5.4pt low in Word and LibreOffice: their
+ borders' 5pt, and a tenth of a point for each hairline paragraph Word needs between two
+ tables, as five containers with that border do. A top border wider than the row's padding
+ stands its columns that much lower too, where the space above does not take it, and the
+ note says so.
+ - **Still named as `row paint`**, its columns written alone:
+ - composed in a table cell, whose table draws its box as a shape where it frames no text;
+ - in a page zone's line;
+ - in a band of overlapping layers, or where a stack's column measures the space above or
+ below it to the text inside it, which the panel's margins would hold again;
+ - with a padding below zero, or a first or last column whose margin hangs into it;
+ - with a margin below zero above or below it, which its columns' table nets against its
+ padding and a panel does not, or at a side in a cell, where Word starts a table no further
+ left than the cell's text. In the body a row bleeding past the margin is its panel.
+
+ Measured in Word 16 and LibreOffice on a page of painted rows, a filled row's shading covers
+ the box the page fills, to a tenth of a point, and its text stands where the page sets it. No
+ row in the DOCX fidelity corpus paints its box, and the 62 documents are byte-identical. In
+ `DocxNodeFieldLedgerTest` a row's `fillColor`, `stroke` and `borders` stay `REPORTED`, for
+ the cases above.
+
- **A DOCX export writes an auto-sized paragraph's text at the size the page fits it to.** The
page fits a paragraph with `autoSize(...)` to the largest size its line holds, smaller or
larger than its style's. The export wrote the text at its style's size: Word broke a shrunk
@@ -245,7 +284,9 @@ follow semantic versioning; release dates are ISO 8601.
- **Where Word holds an opaque colour only** — a cell's shading, a border, a rule — the colour
is flattened against what Word paints under it. Inside a panel or cell the export shaded, that
is the shading as written; on the page, it is read from the layout: the fills drawn before the
- block at its centre, a page background included, a row's own fill (which is not written) left
+ block at its centre, a page background included, a row's own fill (which is not written; since
+ written in the flow, as a panel: see "A DOCX export writes a row's fill, outline and side
+ borders, as a panel holding its columns") left
out. Each is named in the report as `translucency`:
- a panel's fill and borders, once per panel;
- a table cell's fill and rules, once per table;
@@ -525,7 +566,8 @@ follow semantic versioning; release dates are ISO 8601.
row is written as a table with no shading or borders, and a page zone's row as one line.
Now `DROPPED`, `row paint`, naming which of them, and `rounded` where a corner radius
rounds them; a row of no columns is named where the layout gives it height, as the page
- paints only then. A row composed in a table cell is `DROPPED` where the cell drew none of
+ paints only then (since written in the flow, as a panel: see "A DOCX export writes a row's
+ fill, outline and side borders, as a panel holding its columns"). A row composed in a table cell is `DROPPED` where the cell drew none of
its paint — round text it never draws any — and `APPROXIMATED` where the table drew
shapes, since a cell's paint is drawn where it frames no text and the layout does not say
which box is the row's;
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 91870f258..cb0231a56 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -70,7 +70,7 @@ Payload records live in `core` under
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) |
| Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) |
| Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches |
-| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill, which is not written, left out). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` |
+| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` |
| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row |
| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too). A vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported |
| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored as a rectangle is |
@@ -86,7 +86,7 @@ Payload records live in `core` under
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size |
-| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's unwritten fill left out, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer |
+| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's own fill left out, what stands on it being written in the row's panel, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) |
| Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. The indents that carry a container's margin and padding, and a rail timeline body's column, are mapped the same way (`applyDirection` swaps `w:ind` `left` and `right`): Word and LibreOffice both read them as start/end in a `w:bidi` paragraph, and `w:start`/`w:end` read the same — measured, a Hebrew paragraph in a section padded on the left ended short of the right margin by the padding in both. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten |
| Arabic contextual shaping — joined letter forms (`ArabicShaper`) | ✅ shaped into Presentation Forms-B before measurement, because `showText` walks the font `cmap` and never runs `GSUB`; a font carrying the letters but not the forms degrades to unjoined base letters rather than `?` | ✅ base letters restored (`PptxParagraphFragmentRenderHandler` → `ArabicShaper.toBaseLetters`, joining controls kept) — PowerPoint shapes Arabic itself, and frozen forms would land in a file users search and copy from | ✅ never shaped — Word receives the letters and shapes them itself |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 842cdc9ab..06261a5d2 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -74,7 +74,7 @@ creation date is real metadata.
| 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. 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 |
+| 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. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and so are borders above and below, which Word draws outside the panel's row, and a top border its padding does not take, which stands the columns that much lower. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel |
| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses |
| 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 |
@@ -187,7 +187,7 @@ it cannot work out for itself:
|---|---|
| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it (not yet a list item's, or text set over the flow). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell opening with a table, whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
-| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
+| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
The space a block holds above and below itself needs no measuring and is written from the
document: a paragraph's `margin` and `padding` become `w:spacing`, and a container hands
@@ -523,10 +523,10 @@ How it lands:
is written even when it is 0 — a panel bled to the paper's edge by a negative margin its
padding takes back. Unwritten, Word 16 puts the table's edge on the margin and the text a
padding further in (measured).
-- **Nesting.** A panel inside a panel is a table inside its cell. So is a row, with no fill
- of its own, so the panel shows through it. A row's own fill, outline and side borders
- (`RowBuilder.fillColor`, `stroke`, `borders`) are not written yet, in or out of a panel,
- and the export report names them (`row paint`). A table keeps its own cell fills, and a cell
+- **Nesting.** A panel inside a panel is a table inside its cell. So is a row, its cells
+ unshaded, so the panel shows through them. A row that paints its own box
+ (`RowBuilder.fillColor`, `stroke`, `borders`) is a panel of its own, in or out of another
+ outside a table cell, holding its columns as a nested table without its padding. A table keeps its own cell fills, and a cell
no style fills is written white, as the page draws it on the card. Word draws a table's
right border outside its right edge and, on screen, cuts off what passes its cell's edge
and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its
@@ -700,8 +700,8 @@ wherever Word holds an alpha, and is flattened where it does not:
flattened at its centre is one colour wherever its content stands. On the page, it is the
colour the page paints there — the fills the layout draws before the block, composited at
its centre: a white rule at half strength over a navy sidebar is a pale navy, not white. A
- page background counts; a row's own fill does not, as the export does not write it (`row
- paint`). A panel's borders and a cell's rules are flattened against the block's own fill
+ page background counts; a row's own fill does not: what stands on it is written in the row's
+ panel, on its shading as written. A panel's borders and a cell's rules are flattened against the block's own fill
where it has one, since the page draws them over it. A wholly transparent fill is no
shading; a wholly transparent border is drawn in the colour under it, so it keeps its room
in the row.
diff --git a/render-docx/README.md b/render-docx/README.md
index 7b4d51fe8..08c5e7245 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -81,8 +81,9 @@ What maps:
each page and every row is kept whole. A composed cell is written by the same writers as
anywhere else, so it can hold an image, a list or a nested table.
- **Rows** are a one-row table whose columns are where the layout placed each child.
-- **Panels.** A container with a fill or a border is a one-cell table carrying them; rounded
- corners come out square, and the report says so.
+- **Panels.** A container with a fill or a border is a one-cell table carrying them, as is a row
+ that paints its box, holding its columns; rounded corners come out square, and the report says
+ so.
- **Images.** A block image keeps its size and fit mode. Pictures, SVG icons, emoji and
shapes — dots, arrows, chevrons, checkboxes — in a line are inline pictures, placed where
the page's alignment puts them; an icon's text is the picture's description. Code and badge chips keep their fill as run shading, without
@@ -110,7 +111,13 @@ What is not written — each one is named in the export report
not carried. A layer stack whose layers are side-by-side columns is the exception: it is
written as one table row, a cell per column.
- **Watermarks, protection and viewer preferences**, each named in the export report.
-- **A row's own fill, outline and side borders**, named in the export report as `row paint`.
+- **The fill, outline and side borders of a row** composed in a table cell (drawn as a shape where
+ they frame no text), in a page zone's line, in a band of overlapping layers or where a stack's
+ column measures the space round it to its text, with a padding below zero or a column
+ hanging into it, or with a margin below zero above or below it or at a side in a cell, named
+ in the export report as `row paint`. Elsewhere a row that paints is written as a panel holding
+ its columns, its borders above and below named where Word draws them outside the panel's
+ row, and its columns where its padding does not take its top border.
- **Translucency where Word holds an opaque colour only** — a cell's shading, a border, a rule,
a chip's run shading: the colour is flattened against what Word paints under it — the panel or
cell the export shaded, or what the page paints there — and a text header's separator against
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 27c2fc5e5..0999093c7 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -1370,7 +1370,10 @@ java.util.Optional colourUnderCell(DocumentNode table, int row,
/**
* What the page painted at a point before a fragment, over its white, as Word shows it:
* rectangles, ellipses, polygons, paths and table cells in their fill colours, a row's own fill
- * left out, as the export does not write it ({@code row paint}). A picture, a barcode, a
+ * left out. Written, that fill is its panel's shading, and only what the row holds stands on
+ * it, read off the panel as written; what the page lays over the row from outside it Word
+ * writes before or after the panel, over what is under the row. Kept from its panel, the fill
+ * is not written at all. A picture, a barcode, a
* gradient, a fill drawn under a transform, or what the layout paints in a payload this does
* not know, covering the point, leaves the colour unknown.
*/
@@ -1420,7 +1423,10 @@ private java.util.Optional colourUnder(PlacedFragment above, dou
com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload.class,
com.demcha.compose.document.layout.payloads.LayoutAnchorPayload.class);
- /** Whether a fragment is a row's own fill, which the export does not write. */
+ /**
+ * Whether a fragment is a row's own fill, which nothing written outside the row's panel stands
+ * on in Word (see {@link #colourUnder(PlacedFragment, double, double)}).
+ */
private boolean aRowsOwnFill(PlacedFragment fragment) {
if (!(fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload)) {
return false;
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 b9c6b0c4d..61684c74c 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
@@ -1869,10 +1869,10 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
tab.setPos(java.math.BigInteger.valueOf(Math.round(zoneRightTab() * TWIPS_PER_POINT)));
List parts = DocxZoneParts.of(content);
- // A zone's row is its children on one line; its paint is lost as a body row's is, and
- // said once, though the line is written into each kind of header the zone is given.
+ // A zone's row is its children on one line; its paint is lost, and said once, though the
+ // line is written into each kind of header the zone is given.
if (content instanceof RowNode row && zonePartsReported.add(row)) {
- reportUnwrittenRowPaint(row, true);
+ reportUnwrittenRowPaint(row, true, null);
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
@@ -3099,7 +3099,15 @@ private void dispatchNode(XWPFDocument document, DocumentNode node) throws Excep
} else if (node instanceof PageBreakNode) {
writePageBreak(document);
} else if (node instanceof RowNode row) {
- writeTableWithItsOwnSpacing(document, row);
+ // Decided, and its paint named where it is not written, before anything of it is: what
+ // keeps it from its panel is read off where the writing stands now.
+ String kept = paintsItsBox(row) ? keptFromItsPanel(row) : null;
+ if (kept == null && paintsAPanelOfItsOwn(row)) {
+ writePaintedRow(document, row);
+ } else {
+ reportUnwrittenRowPaint(row, false, kept);
+ writeTableWithItsOwnSpacing(document, row);
+ }
} else if (node instanceof ShapeContainerNode shapeContainer) {
writeShapeContainer(document, shapeContainer, null);
} else if (node instanceof ChartNode chart) {
@@ -5003,7 +5011,9 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint
if (index > 0) {
writePageBreak(document);
}
- writePanelPiece(document, node, paint, pieces.get(index), index == 0, index == pieces.size() - 1);
+ List piece = pieces.get(index);
+ writePanelPiece(document, node, paint, () -> writeChildren(document, piece, spacingOf(node)),
+ index == 0, index == pieces.size() - 1, false);
}
}
@@ -5013,9 +5023,17 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint
*/
private static final double CLEAR_OF_THE_GRIDLINE_POINTS = 0.5;
- /** Writes one table of a panel: the whole panel, or the part of it between page breaks. */
+ /**
+ * Writes one table of a panel: the whole panel, or the part of it between page breaks.
+ *
+ * @param content what the panel's cell holds: a container's children, or a row's columns
+ * ({@link #writePaintedRow})
+ * @param onANewPage whether the layout moves the panel to a new page, where the space above it
+ * is held by a line of its own ({@link #holdTheSpaceAboveOnItsPage}); a row's
+ * panel says so, as the row's table did
+ */
private void writePanelPiece(XWPFDocument document, DocumentNode node, ContainerPaint paint,
- List children, boolean first, boolean last) throws Exception {
+ CellContent content, boolean first, boolean last, boolean onANewPage) throws Exception {
DocumentInsets margin = node.margin();
DocumentInsets padding = node.padding();
DocumentBorders borders = paint.borders() == null ? DocumentBorders.NONE : paint.borders();
@@ -5035,6 +5053,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
pendingSpacingAfter = Math.max(0, pendingSpacingAfter - strokeWidth(borders.top()) - borderBelow);
borderBelow = 0;
}
+ if (onANewPage) {
+ holdTheSpaceAboveOnItsPage(document);
+ }
holdTheSpaceAboveATable(document);
double width = panelWidth(node);
@@ -5106,7 +5127,8 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
DocumentInsets margins = insideTheBorders(padding, borders);
applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, margins.right() - shortOfTheEdge),
margins.bottom(), inTheBody ? padding.left() : margins.left()));
- if (node.keepTogether() && layout.onOnePage(node)) {
+ // A row is laid out as one piece, never across a page break (writeRow): so is its panel.
+ if ((node.keepTogether() || node instanceof RowNode) && layout.onOnePage(node)) {
table.getRow(0).setCantSplitRow(true);
}
if (node instanceof ShapeContainerNode) {
@@ -5127,7 +5149,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
cutTheLabelToItsOutline(node, borders, cell);
}
try {
- writeInCell(cell, () -> writeChildren(cell.getXWPFDocument(), children, spacingOf(node)));
+ writeInCell(cell, content);
} finally {
surfaceBehind = outerSurface;
currentCellWidth = outerCellWidth;
@@ -6060,6 +6082,10 @@ private static ContainerPaint paintOf(DocumentNode node) {
return new ContainerPaint(container.fillColor(),
bordersOf(container.borders(), container.stroke()));
}
+ if (node instanceof RowNode row) {
+ // The page paints a row's box as it paints a container's: one decoration.
+ return new ContainerPaint(row.fillColor(), bordersOf(row.borders(), row.stroke()));
+ }
return new ContainerPaint(null, null);
}
@@ -6083,6 +6109,8 @@ private void warnContainerRadiusDropped(DocumentNode node) {
? hasRadius(section.cornerRadius())
: node instanceof ContainerNode container
? hasRadius(container.cornerRadius())
+ : node instanceof RowNode row
+ ? hasRadius(row.cornerRadius())
: node instanceof ShapeContainerNode shape
&& (shape.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectangle round
&& round.cornerRadius() > 0
@@ -10477,6 +10505,20 @@ private static boolean startsWith(byte[] bytes, int... signature) {
* resumes — a paragraph a tenth of a point tall holds that edge
* ({@link #holdTheSpaceAboveATable}); any other table opening a cell still loses it.
*/
+ /**
+ * Leaves on the page above what the layout leaves there when it moves a block to a new page,
+ * starting the block at its own top edge: a band's resumed gap, made the one owed first so it
+ * is written there rather than taken into the line below, the space owed below the last
+ * block, and a border or a line hanging below it, as a page break leaves them
+ * ({@link #writePageBreak}).
+ */
+ private void leaveThePageAbove() {
+ resumeHere();
+ flushSpacingAfter();
+ borderBelow = 0;
+ forgetTheHang();
+ }
+
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
throws Exception {
if (node instanceof TableNode table && !table.rows().isEmpty()) {
@@ -10486,14 +10528,7 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
// the page above holds below its last block, and the gap between the two, stay there.
boolean onANewPage = currentCell == null && startsAPageOfItsOwn(node);
if (onANewPage) {
- // A band's resumed gap is the page above's too: made the one owed first, so it is
- // written there rather than taken into the line below.
- resumeHere();
- flushSpacingAfter();
- // A border or a line hanging below the last block stands on the page above, as a
- // page break leaves it (writePageBreak).
- borderBelow = 0;
- forgetTheHang();
+ leaveThePageAbove();
}
// A table a band's or a column's layer opens is that layer's first block, so it starts
// where the layer resumes, as a paragraph does, and its own top margin comes after:
@@ -10532,7 +10567,7 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
insetRight += node.margin().right() + (table ? node.padding().right() : 0);
try {
if (node instanceof RowNode row) {
- writeRow(document, row);
+ writeRow(document, row, false);
} else {
writeTable(document, (TableNode) node);
}
@@ -11774,16 +11809,20 @@ private static DocumentTableStyle orEmpty(DocumentTableStyle style) {
}
/**
- * Reports the paint a row asks for that its table is not given: the page paints a row's
- * fill, outline and side borders round its columns (RowDefinition), and the row's table is
- * written without them. A corner radius on its own paints nothing, so it is not reported.
+ * Reports the paint a row asks for that is not written as its panel ({@link #writePaintedRow}):
+ * the page paints a row's fill, outline and side borders round its columns (RowDefinition), and
+ * such a row's table is written without them — in a page zone's line, composed in a table cell,
+ * or in the flow where something keeps it from its panel ({@link #keptFromItsPanel}). A corner
+ * radius on its own paints nothing, so it is not reported.
+ *
+ * A row composed in a table cell says which way its paint went: what the cell paints is
+ * drawn as shapes where it frames no text (drawCellDrawing), and the layout does not say which
+ * of those boxes is the row's.
*
- * A row composed in a table cell is the exception: what the cell paints is drawn as
- * shapes where it frames no text (drawCellDrawing), and the layout does not say which of
- * those boxes is the row's, so the note says which way it went rather than that it was
- * lost.
+ * @param kept why a row in the flow is kept from its panel ({@link #keptFromItsPanel});
+ * {@code null} in a zone, and not read for a row composed in a table cell
*/
- private void reportUnwrittenRowPaint(RowNode node, boolean inAZone) {
+ private void reportUnwrittenRowPaint(RowNode node, boolean inAZone, String kept) {
List paint = new ArrayList<>();
if (node.fillColor() != null) {
paint.add("fill");
@@ -11811,13 +11850,153 @@ private void reportUnwrittenRowPaint(RowNode node, boolean inAZone) {
what + (cellDrawing.skippedBoxes()
? " drawn as a shape where it frames no text, and not written where it does"
: " drawn as a shape where the layout puts it in its cell"));
+ } else if (composedInACell(node)) {
+ report.add(DocxExportReport.Severity.DROPPED, "row paint", layout.pathOf(node),
+ what + " not written: a table cell's paint is drawn only where it frames no text, "
+ + "and this table's cells drew none");
} else {
report.add(DocxExportReport.Severity.DROPPED, "row paint", layout.pathOf(node),
- what + (composedInACell(node)
- ? " not written: a table cell's paint is drawn only where it frames no text, "
- + "and this table's cells drew none"
- : " not written: its columns are, as a table with no shading or borders"));
+ what + " not written: " + (kept != null ? kept
+ : "its columns are, as a table with no shading or borders"));
+ }
+ }
+
+ /** Whether a row paints its box: a fill, an outline, or a side's border of some width. */
+ private static boolean paintsItsBox(RowNode node) {
+ return node.fillColor() != null || node.stroke() != null && node.stroke().width() > 0
+ || paintsASide(node.borders());
+ }
+
+ /**
+ * Whether a row in the flow paints a panel of its own, holding its columns
+ * ({@link #writePaintedRow}), where nothing keeps it from one ({@link #keptFromItsPanel}): it
+ * paints its box, stands tall enough to be painted, and has a place of its own in the layout,
+ * or no layout at all. Composed in a table cell, where its table draws what the cell paints
+ * ({@link #drawCellDrawing}), it does not. A page zone's line writes its row apart, and does
+ * not ask.
+ */
+ private boolean paintsAPanelOfItsOwn(RowNode node) {
+ return paintsItsBox(node) && standsTall(node) && !composedInACell(node);
+ }
+
+ /**
+ * Why a row in the flow that paints its box is written as its columns alone, its paint named,
+ * rather than as its panel; {@code null} where nothing keeps it. Asked once, before anything of
+ * the row is written.
+ *
+ *
+ * - Its padding below zero, or a first column hanging left into it or a last one right: a
+ * panel's cell holds the padding as margins, which go no further than its edge.
+ * - Its margin below zero above or below, or at a side in a cell: a panel owes the space
+ * round it on its own, where its columns' table nets the margin against the padding, and
+ * Word starts a table no further left than its cell's text.
+ * - In a band of overlapping layers, or where a stack's column measures the space above it
+ * or below it to the text inside it — the first block of a later layer, or the last a
+ * layer below resumes after: the space would hold the row's padding, and so would its
+ * panel's margins.
+ *
+ */
+ private String keptFromItsPanel(RowNode node) {
+ DocumentInsets padding = node.padding();
+ List children = node.children();
+ if (padding.top() < 0 || padding.right() < 0 || padding.bottom() < 0 || padding.left() < 0
+ || !children.isEmpty() && (children.get(0).margin().left() < 0
+ || children.get(children.size() - 1).margin().right() < 0)) {
+ return "its padding, or a column's margin into it, is below zero, which a Word table cell's "
+ + "margins do not hold";
+ }
+ DocumentInsets margin = node.margin();
+ if (margin.top() < 0 || margin.bottom() < 0
+ || currentCell != null && (margin.left() < 0 || margin.right() < 0)) {
+ return "its margin is below zero above or below it, or at a side in a cell, which its panel's "
+ + "place in Word does not take";
}
+ if (bandDepth > 0 || !Double.isNaN(resumeSpacing) || closesAMeasure(node)) {
+ return "a band of layers or a stack's column measures the space round it to its text, which "
+ + "its panel's margins would hold again";
+ }
+ return null;
+ }
+
+ /** Whether something under a node closes what a band or a stack's column measures its space from. */
+ private boolean closesAMeasure(DocumentNode node) {
+ if (closingBlocks.isEmpty()) {
+ return false;
+ }
+ com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node);
+ if (placed != null && closingBlocks.contains(placed)) {
+ return true;
+ }
+ for (DocumentNode child : node.children()) {
+ if (closesAMeasure(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Writes a row that paints its box as a panel holding its columns.
+ *
+ * The page paints a row's fill, outline and side borders round its columns as it paints a
+ * container's — {@code RowDefinition} emits the same decoration — and Word holds a box's fill
+ * and borders on a table cell ({@link #writePanel}): its shading behind the row, its borders
+ * at the row's full height, its margins the row's padding, as a panel's are. The columns are a
+ * table nested in that cell, laid out as the row's own less the padding the cell's margins hold
+ * ({@link #withoutTheRowsPadding}). A corner radius, which a cell does not round, is named; a
+ * translucent fill or border is flattened against what the page paints under it, and named,
+ * as a panel's is. A row the layout moves to a new page keeps the space the page leaves above
+ * it there, as the row's table does ({@link #writeTableWithItsOwnSpacing}).
+ */
+ private void writePaintedRow(XWPFDocument document, RowNode row) throws Exception {
+ ContainerPaint paint = paintOf(row);
+ // Word draws a cell's borders above and below outside its row, where the page strokes
+ // them on the box's edge, taking no room: a panel's row stands that much taller, and what
+ // follows that much lower, where no space round it takes them.
+ DocumentBorders borders = paint.borders() == null ? DocumentBorders.NONE : paint.borders();
+ double outside = strokeWidth(borders.top()) + strokeWidth(borders.bottom());
+ List lost = new ArrayList<>(2);
+ if (outside > 0) {
+ lost.add("its borders above and below are drawn outside the panel's row in Word: where no space "
+ + "round the row takes them, what follows stands up to " + pointsOf(outside)
+ + "pt lower than the page sets it");
+ }
+ // Word starts the cell's content below its top border where its top margin is narrower;
+ // a panel's first paragraph takes that out of the space above it (takeTheTopBorderInside),
+ // and the columns' table a row's panel opens with has none.
+ double unheld = strokeWidth(borders.top()) - Math.max(0, row.padding().top());
+ if (unheld > 0) {
+ lost.add("its columns stand up to " + pointsOf(unheld) + "pt lower than the page sets them, where "
+ + "neither its padding nor the space above it takes its top border");
+ }
+ reportWrittenWithout(row, "written as a panel", lost);
+ warnContainerRadiusDropped(row);
+ boolean onANewPage = currentCell == null && startsAPageOfItsOwn(row);
+ if (onANewPage) {
+ leaveThePageAbove();
+ }
+ writePanelPiece(document, row, paint, () -> writeRow(document, row, true), true, true, onANewPage);
+ }
+
+ /**
+ * Takes a row's padding out of its first and last columns, where the cell of its own panel
+ * holds it as margins ({@link #writePaintedRow}): the first column's leading and the last's
+ * trailing keep what is a child's margin or the gap, and the columns are that much narrower.
+ *
+ * @return how much narrower the columns are together, in points
+ */
+ private static double withoutTheRowsPadding(RowNode node, List columns) {
+ if (columns.isEmpty()) {
+ return 0;
+ }
+ CellColumn first = columns.get(0);
+ double left = Math.min(Math.max(0, node.padding().left()), first.leading());
+ columns.set(0, new CellColumn(first.width() - left, first.leading() - left, first.trailing()));
+ int lastIndex = columns.size() - 1;
+ CellColumn last = columns.get(lastIndex);
+ double right = Math.min(Math.max(0, node.padding().right()), last.trailing());
+ columns.set(lastIndex, new CellColumn(last.width() - right, last.leading(), last.trailing() - right));
+ return left + right;
}
/**
@@ -11844,12 +12023,16 @@ private static boolean paintsASide(com.demcha.compose.document.style.DocumentBor
.anyMatch(side -> side != null && side.width() > 0);
}
- private void writeRow(XWPFDocument document, RowNode node) throws Exception {
+ /**
+ * Writes a row's columns as a one-row table.
+ *
+ * @param inItsPanel whether the row is written in the cell of its own panel
+ * ({@link #writePaintedRow}), which holds its padding and its height
+ */
+ private void writeRow(XWPFDocument document, RowNode node, boolean inItsPanel) throws Exception {
// Represent rows as a single one-row table so downstream editors get a
// visual side-by-side layout; each cell holds its child as it is written
// anywhere else (writeCellBody).
- // Before the empty row returns: a row of no columns the page still paints.
- reportUnwrittenRowPaint(node, false);
if (node.children().isEmpty()) {
return;
}
@@ -11865,7 +12048,7 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
// heading left by its dash, and in Word the titles stood that far right of the page's,
// 17pt in the main column.
double hang = currentCell != null ? Math.max(0, -insetLeft) : 0;
- RowGeometry geometry = applyRowGeometry(table, node, hang);
+ RowGeometry geometry = applyRowGeometry(table, node, hang, inItsPanel);
boolean placedColumns = geometry.placed();
hang -= geometry.hangTaken();
XWPFTableRow row = table.getRow(0);
@@ -11880,8 +12063,8 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
boolean inAPanel = surfaceBehind != null && panelCell != null;
com.demcha.compose.document.layout.PlacedNode placedRow = layout.placement(node);
double rowOverhang = 0;
- // A row has no fill of its own: inside a panel it is a table nested in the panel's
- // cell, and a cell with no shading shows the panel's through it.
+ // A row's cells are unshaded: inside a panel — its own, where it paints (writePaintedRow), or
+ // a container's — it is a table nested in the panel's cell, and shows the panel's through them.
for (int i = 0; i < node.children().size(); i++) {
XWPFTableCell cell = row.getCell(i);
cell.removeParagraph(0);
@@ -11911,7 +12094,9 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
// address under it stood 10pt high in Word. Held to the page's height, whose margins are
// written around the table. A drawing no taller than its neighbours' text — a timeline's rail
// beside its entry — changes nothing and is left to Word.
- if (placedRow != null && placedRow.startPage() == placedRow.endPage()
+ // In the cell of its own panel the panel holds the row's height, its borders and margins
+ // taken off, and the columns, held to the height less only the padding, would grow it.
+ if (!inItsPanel && placedRow != null && placedRow.startPage() == placedRow.endPage()
&& (inAPanel || aDrawingMakesTheRow(node, row))) {
holdRowAtLeast(row, placedRow.placementHeight() - node.padding().top() - node.padding().bottom());
}
@@ -12799,36 +12984,43 @@ private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Ex
* containers around it leave of the page ({@link #availableWidth}); the row is moved in
* by the same containers ({@link #indentTable}), so it starts where their text does.
*
- * @param hang how far the row hangs left out of the cell holding it, in points; the first
- * column gives up what it can of it ({@link #takeHang})
+ * @param hang how far the row hangs left out of the cell holding it, in points; the first
+ * column gives up what it can of it ({@link #takeHang})
+ * @param inItsPanel whether the row is written in the cell of its own panel, whose margins
+ * hold its padding ({@link #withoutTheRowsPadding})
* @return whether the columns are the layout's, each cell's text starting where its child
* does — past the child's left margin — and how much of the hang they took
*/
- private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang) {
+ private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang, boolean inItsPanel) {
+ // In the cell of its own panel the row is its columns alone: the cell's margins hold its
+ // padding (writePaintedRow).
+ double padded = inItsPanel ? node.padding().left() + node.padding().right() : 0;
double[] starts = layout.rowChildStarts(node);
if (starts != null) {
// The layout placed each child, so every way a row can divide — the two that
// measure their children included — is already answered.
List columns = new ArrayList<>(withRowEditorSlack(node, placedColumns(node, starts)));
holdTheLastFixedColumn(node, columns);
+ double width = starts[0] - (inItsPanel ? withoutTheRowsPadding(node, columns) : 0);
double taken = takeHang(node, columns, hang);
- setTableWidth(table, Math.min(starts[0], widthOf(columns) + taken) - taken);
+ setTableWidth(table, Math.min(width, widthOf(columns) + taken) - taken);
writeRowColumns(table, columns);
return new RowGeometry(true, taken);
}
- double available = availableWidth();
+ double available = availableWidth() + padded;
if (!Double.isFinite(available) || available <= 0) {
return new RowGeometry(false, 0);
}
double[] slots = resolveRowSlots(node, available);
if (slots == null) {
- setTableWidth(table, available);
+ setTableWidth(table, available - padded);
return new RowGeometry(false, 0);
}
List columns = new ArrayList<>(statedColumns(node, slots));
+ double width = available - (inItsPanel ? withoutTheRowsPadding(node, columns) : 0);
double taken = takeHang(node, columns, hang);
- setTableWidth(table, Math.min(available, widthOf(columns) + taken) - taken);
+ setTableWidth(table, Math.min(width, widthOf(columns) + taken) - taken);
writeRowColumns(table, columns);
return new RowGeometry(false, taken);
}
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 d1c3096b6..dc88cae7b 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
@@ -99,6 +99,16 @@ private record Entry(Fate fate, String note) {
+ "under the cell, and of its rules, against its fill where it has "
+ "one; any other is written";
+ // A row in the flow is written as a panel holding its columns, its paint the panel's.
+ private static final String ROW_PAINT = "composed in a table cell, drawn as a shape where it frames no text "
+ + "and not written where it does; in a page zone's line, in a band of "
+ + "layers or where a stack's column measures the space round it to its "
+ + "text, with a padding below zero or a column hanging into it, and "
+ + "with a margin below zero above or below it or at a side in a cell; "
+ + "its translucency, as a panel's, borders above and below, which Word "
+ + "draws outside the panel's row, and a top border its padding does not "
+ + "take; any other is written, as a panel";
+
private static final Map, Map> NODES = new LinkedHashMap<>();
private static final Map OUTPUT_OPTIONS = fields(
"metadata:WRITTEN",
@@ -220,9 +230,11 @@ private record Entry(Fate fate, String note) {
node(PolygonNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "points:WRITTEN",
"fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN");
node(RowNode.class, "name:INERT", "children:WRITTEN", "weights:WRITTEN", "gap:WRITTEN", "padding:WRITTEN",
- "margin:WRITTEN", "fillColor:REPORTED", "stroke:REPORTED",
- "cornerRadius:REPORTED:with the paint it rounds; with none it paints nothing",
- "borders:REPORTED", "columns:WRITTEN", "verticalAlign:WRITTEN", "arrangement:WRITTEN");
+ "margin:WRITTEN",
+ "fillColor:REPORTED:" + ROW_PAINT, "stroke:REPORTED:" + ROW_PAINT,
+ "cornerRadius:REPORTED:a panel's corners, which a Word table cell does not round; with no paint "
+ + "it paints nothing",
+ "borders:REPORTED:" + ROW_PAINT, "columns:WRITTEN", "verticalAlign:WRITTEN", "arrangement:WRITTEN");
// The wrappers the layout adds round a timeline's parts, public records the export writes
// through to their child.
node(com.demcha.compose.document.layout.LayoutAnchorNode.class, "name:INERT", "id:INERT", "child:WRITTEN");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
index e615f9e7e..18a1b4ef9 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
@@ -42,10 +42,11 @@
* What a document asks for that the Word file is not given is in the report, not only in the
* file's absence.
*
- * A row's fill, a logo in a page header, a watermark, a protection and a node kind the
- * export does not know were each left out of the Word file with no more than a log line, or
- * nothing: a caller reading the report was told the document lost nothing. Each is now a note
- * naming what the file does not carry.
+ * A row's fill in a page zone or a table cell, a logo in a page header, a watermark, a
+ * protection and a node kind the export does not know were each left out of the Word file with no
+ * more than a log line, or nothing: a caller reading the report was told the document lost nothing.
+ * Each is now a note naming what the file does not carry. A row's paint in the flow is written
+ * ({@link DocxRowPaintTest}).
*/
class DocxReportedLossesTest {
@@ -53,25 +54,13 @@ class DocxReportedLossesTest {
private static final DocumentStroke RULE = DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1);
@Test
- void aRowsFillOutlineAndBordersAreReportedAsNotWritten() throws Exception {
+ void aRowsFillOutlineAndBordersInTheFlowAreWrittenNotReported() throws Exception {
+ // Written as a panel holding its columns (DocxRowPaintTest), they are no loss.
DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row
.name("Totals").fillColor(SURFACE).stroke(RULE).borders(DocumentBorders.bottom(RULE))
.addParagraph("Subtotal").addParagraph("120.00"))));
- List notes = report.bySubject().get("row paint");
- assertThat(notes).as("one note for the row").hasSize(1);
- assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED);
- assertThat(notes.get(0).path()).contains("Totals");
- assertThat(notes.get(0).detail()).contains("the row's fill, outline and borders are not written");
- }
-
- @Test
- void aRowWithAFillAloneSaysSo() throws Exception {
- DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row
- .fillColor(SURFACE).addParagraph("Left").addParagraph("Right"))));
-
- assertThat(report.bySubject().get("row paint")).singleElement()
- .extracting(DocxExportReport.Note::detail).asString().contains("the row's fill is not written");
+ assertThat(report.bySubject()).doesNotContainKey("row paint");
}
@Test
@@ -143,14 +132,6 @@ void aPaintedRowInAPageZoneIsReportedOnceThoughWrittenIntoTwoFooters() throws Ex
.contains("the row's fill is not written: a page zone's row is written as one line");
}
- @Test
- void aRowOfNoColumnsThePagePaintsIsReported() throws Exception {
- DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row
- .fillColor(SURFACE).padding(DocumentInsets.of(8)))));
-
- assertThat(report.bySubject()).containsKey("row paint");
- }
-
@Test
void aRowOfNoColumnsAndNoHeightIsNotReported() throws Exception {
// The page paints a row's box only where the layout gives it some height.
@@ -160,15 +141,6 @@ void aRowOfNoColumnsAndNoHeightIsNotReported() throws Exception {
assertThat(report.bySubject()).doesNotContainKey("row paint");
}
- @Test
- void aRoundedRowSaysItsPaintWasRounded() throws Exception {
- DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row
- .fillColor(SURFACE).cornerRadius(6).addParagraph("Left").addParagraph("Right"))));
-
- assertThat(report.bySubject().get("row paint")).singleElement()
- .extracting(DocxExportReport.Note::detail).asString().contains("the row's rounded fill is not written");
- }
-
@Test
void aFilledRowRoundTextInATableCellIsDroppedNotApproximated() throws Exception {
// A cell's paint is drawn as a shape only where it frames no text; round a label and a
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowPaintTest.java
new file mode 100644
index 000000000..84db7aa57
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowPaintTest.java
@@ -0,0 +1,395 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.DocumentBorders;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A row that paints its box — a fill, an outline, a side's border — is written as a panel holding
+ * its columns: Word holds the paint on a table cell, the row's padding in the cell's margins, and
+ * the columns in a table nested in the cell, as a container's panel holds its children. The page
+ * paints a row's box as it paints a container's.
+ */
+class DocxRowPaintTest {
+
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+ private static final DocumentStroke RULE = DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1);
+ private static final double CONTENT = 360;
+
+ @Test
+ void aRowsFillIsItsPanelsShadingRoundItsColumns() throws Exception {
+ Exported exported = export(page -> page.addRow(row -> row.name("Totals").fillColor(SURFACE)
+ .padding(DocumentInsets.of(8)).spacing(12).addParagraph("Subtotal").addParagraph("120.00")));
+
+ XWPFTable panel = exported.document().getTables().get(0);
+ assertThat(panel.getRows()).hasSize(1);
+ XWPFTableCell cell = panel.getRow(0).getCell(0);
+ assertThat(panel.getRow(0).getTableCells()).as("one cell, the panel's").hasSize(1);
+ assertThat(cell.getColor()).isEqualToIgnoringCase("EEF3F9");
+ CTTcMar margins = cell.getCTTc().getTcPr().getTcMar();
+ assertThat(twips(margins.getTop().getW())).as("the row's padding, as the cell's margins").isEqualTo(160);
+ assertThat(twips(margins.getBottom().getW())).isEqualTo(160);
+ assertThat(twips(margins.getLeft().getW())).isEqualTo(160);
+ assertThat(twips(margins.getRight().getW())).isEqualTo(160);
+
+ // The columns, nested in the cell, unshaded and without the padding the margins hold.
+ assertThat(cell.getTables()).hasSize(1);
+ XWPFTable columns = cell.getTables().get(0);
+ assertThat(columns.getRow(0).getTableCells()).hasSize(2)
+ .allSatisfy(column -> assertThat(column.getColor()).isNull());
+ assertThat(columns.getRow(0).getCell(0).getText()).isEqualTo("Subtotal");
+ assertThat(columns.getRow(0).getCell(1).getText()).isEqualTo("120.00");
+ long grid = columns.getCTTbl().getTblGrid().getGridColList().stream().mapToLong(col -> twips(col.getW())).sum();
+ assertThat(grid).as("the row's width less its padding").isEqualTo(Math.round((CONTENT - 16) * 20));
+ assertThat(twips(columns.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getLeft().getW()))
+ .as("the first column starts at the cell's text").isZero();
+ assertThat(twips(columns.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getRight().getW()))
+ .as("the gap after it").isEqualTo(240);
+ assertThat(twips(columns.getRow(0).getCell(1).getCTTc().getTcPr().getTcMar().getRight().getW()))
+ .as("the last column ends at the cell's text").isZero();
+
+ assertThat(exported.report().bySubject()).doesNotContainKey("row paint").doesNotContainKey("RowNode");
+ // Laid out as one piece, never across a page break: its panel is kept whole too.
+ assertThat(panel.getRow(0).isCantSplitRow()).isTrue();
+ // The panel holds the row's height; the columns, held as well, would grow it.
+ var columnsRow = columns.getRow(0).getCtRow();
+ assertThat(columnsRow.isSetTrPr() && columnsRow.getTrPr().sizeOfTrHeightArray() > 0)
+ .as("the columns are not held").isFalse();
+ }
+
+ @Test
+ void withNoLayoutTheColumnsAreTheRowsOwnArithmeticLessItsPadding() throws Exception {
+ XWPFDocument document = DocxExports.withoutLayout(400, 600, 20, page -> page.addRow(row -> row
+ .fillColor(SURFACE).padding(DocumentInsets.of(8)).spacing(12).addParagraph("Left").addParagraph("Right")));
+
+ XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
+ assertThat(cell.getColor()).isEqualToIgnoringCase("EEF3F9");
+ XWPFTable columns = cell.getTables().get(0);
+ long grid = columns.getCTTbl().getTblGrid().getGridColList().stream().mapToLong(col -> twips(col.getW())).sum();
+ assertThat(grid).as("the room the panel's margins leave").isEqualTo(Math.round((CONTENT - 16) * 20));
+ assertThat(twips(columns.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getLeft().getW())).isZero();
+ }
+
+ @Test
+ void aRowThePageGivesNoHeightIsNotWritten() throws Exception {
+ // The page paints a row's box only where the layout gives it height.
+ Exported exported = export(page -> page.addParagraph("Before").addRow(row -> row.fillColor(SURFACE))
+ .addParagraph("After"));
+
+ assertThat(exported.document().getTables()).isEmpty();
+ assertThat(exported.report().bySubject()).doesNotContainKey("row paint");
+ }
+
+ @Test
+ void anOutlineAndASidesBorderAreItsPanelsBorders() throws Exception {
+ Exported outlinedRow = export(page -> page.addRow(row -> row.name("Outlined").stroke(RULE)
+ .addParagraph("Left").addParagraph("Right")));
+ CTTcBorders outlined = panelCell(outlinedRow).getCTTc().getTcPr().getTcBorders();
+ assertThat(List.of(outlined.getTop(), outlined.getLeft(), outlined.getBottom(), outlined.getRight()))
+ .allSatisfy(side -> assertThat(side.xgetColor().getStringValue()).isEqualToIgnoringCase("1A5694"));
+ // Word draws the borders above and below outside the row, where the page takes no room,
+ // and with no padding to take the top one, starts the columns below it.
+ assertThat(outlinedRow.report().bySubject().get("RowNode")).singleElement().satisfies(note -> {
+ assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED);
+ assertThat(note.path()).contains("Outlined");
+ assertThat(note.detail()).isEqualTo("written as a panel; its borders above and below are drawn outside "
+ + "the panel's row in Word: where no space round the row takes them, "
+ + "what follows stands up to 2pt lower than the page sets it; its "
+ + "columns stand up to 1pt lower than the page sets them, where neither "
+ + "its padding nor the space above it takes its top border");
+ });
+ Exported padded = export(page -> page.addRow(row -> row.stroke(RULE).padding(DocumentInsets.of(4))
+ .addParagraph("Left").addParagraph("Right")));
+ assertThat(padded.report().bySubject().get("RowNode")).extracting(DocxExportReport.Note::detail)
+ .as("its padding takes its top border").singleElement().asString().doesNotContain("its columns");
+
+ Exported underlined = export(page -> page.addRow(row -> row.borders(DocumentBorders.bottom(RULE))
+ .addParagraph("Left").addParagraph("Right")));
+ CTTcBorders bottom = panelCell(underlined).getCTTc().getTcPr().getTcBorders();
+ assertThat(bottom.getBottom().xgetColor().getStringValue()).isEqualToIgnoringCase("1A5694");
+ assertThat(bottom.isSetTop() && bottom.getTop().getVal() != STBorder.NONE && bottom.getTop().getVal() != STBorder.NIL)
+ .as("no top border").isFalse();
+ assertThat(underlined.report().bySubject()).doesNotContainKey("row paint");
+ assertThat(underlined.report().bySubject().get("RowNode")).extracting(DocxExportReport.Note::detail)
+ .singleElement().asString().endsWith("up to 1pt lower than the page sets it");
+ }
+
+ @Test
+ void aRoundedRowKeepsItsFillAndNamesItsSquaredCorners() throws Exception {
+ Exported exported = export(page -> page.addRow(row -> row.fillColor(SURFACE).cornerRadius(6)
+ .addParagraph("Left").addParagraph("Right")));
+
+ assertThat(panelCell(exported).getColor()).isEqualToIgnoringCase("EEF3F9");
+ assertThat(exported.report().bySubject().get("corner radius")).singleElement().satisfies(note -> {
+ assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED);
+ assertThat(note.detail()).isEqualTo("a Word table cell is rectangular, so the panel keeps its fill and "
+ + "loses its rounded corners");
+ });
+ assertThat(exported.report().bySubject()).doesNotContainKey("row paint");
+ }
+
+ @Test
+ void aTranslucentFillIsFlattenedAgainstThePageAndNamed() throws Exception {
+ Exported exported = export(page -> page.addRow(row -> row.name("Tinted")
+ .fillColor(DocumentColor.rgba(26, 86, 148, 128)).addParagraph("Left").addParagraph("Right")));
+
+ // Half of 1A5694 over the white page.
+ assertThat(panelCell(exported).getColor()).isEqualToIgnoringCase("8CAAC9");
+ assertThat(exported.report().notes()).anySatisfy(note -> {
+ assertThat(note.path()).contains("Tinted");
+ assertThat(note.detail()).contains("its fill").contains("flattened");
+ });
+ }
+
+ @Test
+ void aTranslucentPanelInAFilledRowIsFlattenedAgainstTheRowsFill() throws Exception {
+ // Word shows the row's fill under the panel, as the page does: white at half over navy.
+ Exported exported = export(page -> page.addRow(row -> row.fillColor(DocumentColor.rgb(30, 50, 90))
+ .padding(DocumentInsets.of(6)).addSection("Tile", tile -> tile
+ .fillColor(DocumentColor.rgba(255, 255, 255, 128)).addParagraph("Inside"))
+ .addParagraph("Beside")));
+
+ XWPFTableCell rowCell = panelCell(exported);
+ assertThat(rowCell.getColor()).isEqualToIgnoringCase("1E325A");
+ XWPFTableCell tile = rowCell.getTables().get(0).getRow(0).getCell(0).getTables().get(0).getRow(0).getCell(0);
+ assertThat(tile.getColor()).isEqualToIgnoringCase("8F99AD");
+ }
+
+ @Test
+ void aFilledRowInABandOfLayersKeepsItsNoteAndNothingIsSetOnItsFill() throws Exception {
+ // A band measures the space round its first block to the text inside it, which a panel's
+ // margins would hold again: the row is written as its columns alone, its paint named, and
+ // the panel the page lays over it, written after it, stands on what Word shows there.
+ Exported exported = export(page -> page.addLayerStack(stack -> stack
+ .back(new com.demcha.compose.document.dsl.RowBuilder().name("Under").fillColor(DocumentColor.rgb(30, 50, 90))
+ .padding(DocumentInsets.of(20)).addParagraph("Under").addParagraph("Row").build())
+ .center(new com.demcha.compose.document.dsl.SectionBuilder().name("Over")
+ .fillColor(DocumentColor.rgba(255, 255, 255, 128)).addParagraph("Over").build())));
+
+ assertThat(cellsShaded(exported.document())).as("the row's navy is not written; white over white")
+ .containsOnly("FFFFFF");
+ assertThat(exported.report().bySubject().get("row paint")).singleElement().satisfies(note -> {
+ assertThat(note.path()).contains("Under");
+ assertThat(note.detail()).isEqualTo("the row's fill is not written: a band of layers or a stack's "
+ + "column measures the space round it to its text, which its "
+ + "panel's margins would hold again");
+ });
+
+ // So in the middle of a band's layer, between blocks the band measures.
+ Exported middle = export(page -> page.addLayerStack(stack -> stack
+ .back(new com.demcha.compose.document.dsl.SectionBuilder().addParagraph("Top")
+ .addRow(row -> row.name("Middle").fillColor(SURFACE).padding(DocumentInsets.of(6))
+ .addParagraph("Left").addParagraph("Right"))
+ .addParagraph("Bottom").build())
+ .center(new com.demcha.compose.document.dsl.SectionBuilder().addParagraph("Tag").build())));
+ assertThat(middle.report().bySubject().get("row paint")).extracting(DocxExportReport.Note::path)
+ .singleElement().asString().contains("Middle");
+ }
+
+ @Test
+ void aRowWhosePaddingOrColumnHangsPastItsEdgeKeepsItsNote() throws Exception {
+ String kept = "the row's fill is not written: its padding, or a column's margin into it, is below zero, "
+ + "which a Word table cell's margins do not hold";
+ Exported hanging = export(page -> page.addRow(row -> row.fillColor(SURFACE).padding(DocumentInsets.of(8))
+ .addParagraph(p -> p.text("Left").margin(new DocumentInsets(0, 0, 0, -4))).addParagraph("Right")));
+ assertThat(hanging.report().bySubject().get("row paint")).extracting(DocxExportReport.Note::detail)
+ .containsExactly(kept);
+ assertThat(hanging.document().getTables().get(0).getRow(0).getTableCells()).as("its columns alone").hasSize(2);
+
+ Exported negative = export(page -> page.addRow(row -> row.fillColor(SURFACE)
+ .padding(new DocumentInsets(0, 0, 0, -10)).addParagraph("Left").addParagraph("Right")));
+ assertThat(negative.report().bySubject().get("row paint")).extracting(DocxExportReport.Note::detail)
+ .containsExactly(kept);
+ }
+
+ @Test
+ void aRowWhoseMarginIsBelowZeroAboveBelowOrAtASideInACellKeepsItsNote() throws Exception {
+ String kept = "the row's fill is not written: its margin is below zero above or below it, or at a side in a "
+ + "cell, which its panel's place in Word does not take";
+ // Its columns' table nets a margin pulling it up against its padding; a panel owes the
+ // space round it on its own, where a pull is nothing.
+ Exported pulled = export(page -> page.addParagraph(p -> p.text("Before").margin(DocumentInsets.bottom(10)))
+ .addRow(row -> row.fillColor(SURFACE).margin(new DocumentInsets(-8, 0, -8, 0))
+ .padding(DocumentInsets.of(8)).addParagraph("Left").addParagraph("Right"))
+ .addParagraph("After"));
+ assertThat(pulled.report().bySubject().get("row paint")).extracting(DocxExportReport.Note::detail)
+ .containsExactly(kept);
+ assertThat(cellsShaded(pulled.document())).as("its columns alone").isEmpty();
+
+ // Word starts a table no further left than its cell's text.
+ Exported bled = export(page -> page.addSection("Card", card -> card.fillColor(SURFACE)
+ .padding(DocumentInsets.of(12)).addRow(row -> row.fillColor(DocumentColor.rgb(30, 50, 90))
+ .margin(new DocumentInsets(0, -12, 0, -12)).padding(DocumentInsets.of(12))
+ .addParagraph("Left").addParagraph("Right"))));
+ assertThat(bled.report().bySubject().get("row paint")).extracting(DocxExportReport.Note::detail)
+ .containsExactly(kept);
+
+ // In the body Word holds a table's edge past the margin: the row bleeds as its panel.
+ Exported bleeding = export(page -> page.addRow(row -> row.fillColor(SURFACE)
+ .margin(new DocumentInsets(0, -20, 0, -20)).padding(DocumentInsets.of(20))
+ .addParagraph("Left").addParagraph("Right")));
+ assertThat(bleeding.report().bySubject()).doesNotContainKey("row paint");
+ assertThat(panelCell(bleeding).getColor()).isEqualToIgnoringCase("EEF3F9");
+ }
+
+ @Test
+ void aFilledRowAStacksColumnMeasuresRoundIsWrittenAsItsColumns() throws Exception {
+ String kept = "the row's fill is not written: a band of layers or a stack's column measures the space round "
+ + "it to its text, which its panel's margins would hold again";
+ // The main layer goes on below the name layer after a stand-in: its first block opens
+ // where the layer resumes, measured to the text inside it.
+ Exported opening = export(page -> page.addLayerStack(stack -> stack
+ .layer(column("NameLayer", 120, 0, name -> name.addParagraph("Ada Lovelace")), LayerAlign.TOP_LEFT)
+ .layer(column("Sidebar", 0, 240, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT)
+ .layer(column("MainLayer", 120, 0, main -> main
+ .addSpacer(spacer -> spacer.width(100).height(20))
+ .addRow(row -> row.name("Opening").fillColor(SURFACE).padding(DocumentInsets.of(6))
+ .addParagraph("Engineer").addParagraph("2020")))
+ , LayerAlign.TOP_LEFT)));
+ assertThat(opening.report().bySubject().get("row paint")).singleElement().satisfies(note -> {
+ assertThat(note.path()).contains("Opening");
+ assertThat(note.detail()).isEqualTo(kept);
+ });
+
+ // The name layer ends with a row the main layer resumes after, measured from its text.
+ Exported closing = export(page -> page.addLayerStack(stack -> stack
+ .layer(column("NameLayer", 120, 0, name -> name.addParagraph("Ada Lovelace")
+ .addRow(row -> row.name("Closing").fillColor(SURFACE).padding(DocumentInsets.of(6))
+ .addParagraph("Engineer").addParagraph("2020"))), LayerAlign.TOP_LEFT)
+ .layer(column("Sidebar", 0, 240, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT)
+ .layer(column("MainLayer", 120, 0, main -> main
+ .addSpacer(spacer -> spacer.width(100).height(40))
+ .addParagraph("Experience")), LayerAlign.TOP_LEFT)));
+ assertThat(closing.report().bySubject().get("row paint")).singleElement().satisfies(note -> {
+ assertThat(note.path()).contains("Closing");
+ assertThat(note.detail()).isEqualTo(kept);
+ });
+ }
+
+ /** One column as a full-width layer, inset to its band, as a two-column CV lays it out. */
+ private static com.demcha.compose.document.node.DocumentNode column(
+ String name, double insetLeft, double insetRight,
+ Consumer content) {
+ com.demcha.compose.document.dsl.SectionBuilder layer = new com.demcha.compose.document.dsl.SectionBuilder();
+ layer.name(name).spacing(0).padding(new DocumentInsets(0, insetRight, 0, insetLeft));
+ layer.addSection(name + "Content", section -> content.accept(section.spacing(0)));
+ return layer.build();
+ }
+
+ @Test
+ void aPaintedRowTheLayoutMovesToANewPageKeepsTheSpaceAboveItThere() throws Exception {
+ // The layout keeps the row's top margin on the new page; Word drops a paragraph's space
+ // above at a page's top and keeps a line's height, so a line kept with the panel holds it.
+ // The space below the paragraph before stays on the page above, below that paragraph.
+ Exported exported = export(page -> page.addSpacer(spacer -> spacer.height(500))
+ .addParagraph(p -> p.text("Before").margin(DocumentInsets.bottom(10)))
+ .addRow(row -> row.name("Moved").fillColor(SURFACE).margin(new DocumentInsets(24, 0, 0, 0))
+ .padding(DocumentInsets.of(6)).addParagraph("Left").addParagraph("Right")));
+
+ List body = exported.document().getBodyElements();
+ int panel = body.indexOf(exported.document().getTables().get(0));
+ assertThat(body.get(panel - 1)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFParagraph.class)
+ .satisfies(element -> {
+ var line = ((org.apache.poi.xwpf.usermodel.XWPFParagraph) element).getCTP().getPPr();
+ assertThat(line.isSetKeepNext()).as("kept with the panel").isTrue();
+ assertThat(twips(line.getSpacing().getLine())).as("the margin, 24pt").isEqualTo(480);
+ });
+ assertThat(body.get(panel - 2)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFParagraph.class)
+ .satisfies(element -> {
+ var before = (org.apache.poi.xwpf.usermodel.XWPFParagraph) element;
+ assertThat(before.getText()).isEqualTo("Before");
+ assertThat(twips(before.getCTP().getPPr().getSpacing().getAfter()))
+ .as("its own 10pt, on the page above").isEqualTo(200);
+ });
+ assertThat(exported.report().bySubject()).doesNotContainKey("row paint");
+ }
+
+ /** Every cell shading in the document, nested tables included. */
+ private static List cellsShaded(XWPFDocument document) {
+ List fills = new java.util.ArrayList<>();
+ for (XWPFTable table : document.getTables()) {
+ collectShading(table, fills);
+ }
+ return fills;
+ }
+
+ private static void collectShading(XWPFTable table, List into) {
+ for (var row : table.getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ if (cell.getColor() != null) {
+ into.add(cell.getColor().toUpperCase(java.util.Locale.ROOT));
+ }
+ for (XWPFTable nested : cell.getTables()) {
+ collectShading(nested, into);
+ }
+ }
+ }
+ }
+
+ @Test
+ void aRowOfNoColumnsItPaintsIsAPanelAsTallAsThePageMakesIt() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addRow(row -> row.fillColor(SURFACE).padding(DocumentInsets.of(8))).addParagraph("After"));
+
+ // Its padding is the cell's margins, 16pt together, round a paragraph a hairline tall.
+ XWPFTableCell cell = exported.document().getTables().get(0).getRow(0).getCell(0);
+ assertThat(cell.getColor()).isEqualToIgnoringCase("EEF3F9");
+ assertThat(twips(cell.getCTTc().getTcPr().getTcMar().getTop().getW())).isEqualTo(160);
+ assertThat(twips(cell.getCTTc().getTcPr().getTcMar().getBottom().getW())).isEqualTo(160);
+ assertThat(cell.getText()).isEmpty();
+ assertThat(cell.getTables()).isEmpty();
+ assertThat(exported.report().bySubject()).doesNotContainKey("row paint");
+ }
+
+ @Test
+ void aRowThatPaintsNothingIsItsColumnsAlone() throws Exception {
+ Exported exported = export(page -> page.addRow(row -> row.padding(DocumentInsets.of(8))
+ .addParagraph("Left").addParagraph("Right")));
+
+ XWPFTable columns = exported.document().getTables().get(0);
+ assertThat(columns.getRow(0).getTableCells()).hasSize(2)
+ .allSatisfy(cell -> assertThat(cell.getTables()).isEmpty());
+ assertThat(twips(columns.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getLeft().getW()))
+ .as("its padding rides in its first column's margin").isEqualTo(160);
+ }
+
+ private static XWPFTableCell panelCell(Exported exported) {
+ return exported.document().getTables().get(0).getRow(0).getCell(0);
+ }
+
+ private static long twips(Object value) {
+ return DocxTwips.of(value);
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report) {
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend(report::set));
+ }
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get());
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
index 8916ba6c6..328ca3080 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
@@ -245,13 +245,13 @@ void aPanelsBordersAreFlattenedAgainstItsOwnFill() throws Exception {
}
@Test
- void aRowsOwnFillIsNotWhatIsUnderItsChip() throws Exception {
- // The export does not write a row's own fill — the report names it as row paint — so what
- // Word shows under the chip is the page's white, and the chip is flattened against that.
+ void aRowsOwnFillIsWhatIsUnderItsChip() throws Exception {
+ // The row's fill is written, as its panel's shading: what Word shows under the chip is the
+ // row's navy, and the chip is flattened against that, not against the page's white.
try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY)
.addParagraph(p -> p.inlineText("Call ").inlineCode("render()")).addParagraph("Beside")))) {
- assertThat(exported.report().bySubject()).as("the row's fill is not written").containsKey("row paint");
- assertThat(runShading(run(exported.document(), "render("))).isEqualTo("EFF1F3");
+ assertThat(exported.report().bySubject()).as("the row's fill is written").doesNotContainKey("row paint");
+ assertThat(runShading(run(exported.document(), "render("))).isEqualTo("39445A");
}
}
@@ -426,15 +426,15 @@ void aMergedTranslucentCellIsFlattenedAsOne() throws Exception {
@Test
void aRuleInAFilledRowIsFlattenedAgainstWhatWordShows() throws Exception {
- // The export does not write a row's own fill, so what Word shows under the rule is the page.
+ // The row's fill is written, as its panel's shading: what Word shows under the rule is navy.
try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY)
.addLine(line -> line.horizontal(100).stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1)))
.addParagraph("Beside")))) {
CTBorder bottom = allParagraphs(exported.document()).stream()
.filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr())
.findFirst().orElseThrow().getCTP().getPPr().getPBdr().getBottom();
- // Black at 128/255 over white, not over the row's navy.
- assertThat(hex(bottom.getColor())).isEqualTo("7F7F7F");
+ // Black at 128/255 over the row's navy, not over white.
+ assertThat(hex(bottom.getColor())).isEqualTo("0E1320");
}
}