From 9baf106f5e91a83ba6acda3897003c100b06fffc Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 19 Oct 2026 20:48:19 +0100
Subject: [PATCH] fix(docx): keep the room a contained picture leaves in its
box
The page draws a picture contained in its box in the box's middle. The
export embedded it at its fitted size and nothing else: measured in
Word and LibreOffice, a 200x100 picture fitted to a 100pt square stood
at the square's top, and the paragraph under it 51pt high.
Half the room it leaves above and below is now its paragraph's space,
written with its margin and padding, and half at its side its indent.
In a band, which owes the space below it from the box, the room below
is the paragraph's own space after.
---
CHANGELOG.md | 9 +
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 60 ++++++-
.../docx/DocxContainedPictureTest.java | 169 ++++++++++++++++++
5 files changed, 233 insertions(+), 9 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainedPictureTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8a2e6969..90c75a0e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export keeps the room a contained picture leaves in its box.** The page draws a picture
+ contained in its box (`fitToBounds`, `CONTAIN`) in the box's middle. The export embedded it at
+ its fitted size and nothing else. Measured in Word and LibreOffice on Windows, a 200×100 picture
+ fitted to a 100pt square stood at the square's top, and the paragraph under it 51pt high, in
+ both. Nothing was named.
+ - Half the room it leaves above and below is now the space round its paragraph, and half at its
+ side its indent. In a band, which owes the space below it from the box, the room below is the
+ paragraph's own. The wide picture, a 100×200 one held in at its side, one centred by an
+ alignment and one in a row's cell all stand where the page draws them, in both editors.
- **A DOCX export keeps a panel's padding round its outline.** The page sets a shape container's
padding round its outline, and its layers in the outline itself. Written as a panel in a table
cell, the padding was the cell's margins, inside the outline. Measured in Word and LibreOffice
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 8d8937840..fd2a9f2df 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -81,7 +81,7 @@ Payload records live in `core` under
| Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ |
| Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ⚠️ `PptxGradientFill` (`circle` path shade — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ |
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
-| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) |
+| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, the room it leaves in its box held round it as the page centres it — half above and below as its paragraph's space, half at its side as its indent — COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves, its columns the layout's widths — where one is `auto` and the layout does not show them, composed in a table cell or with a column edge no row shows, the editor's, and reported (`TableNode`): `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row) — a row where a cell of text its padding then leaves short of that room needs more than the page's height stands taller in Word, reported (`cell rule`) — each as wide as Word draws it, in eighths of a point (one outside Word's range reported, `border width`), and counted as both editors count it, two and a half twips an eighth rounded down (a rule between two rows shared in whole twips, the row above it taking half rounded down; a cell's top and bottom margins rounded to the twip together), a row held at the page's height less the most any of its cells takes, its margins and the rules it holds — between two others, the rest of its own above and half that of the cell under it; LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's, and under a row ruled heavier than it a row stands the difference lower there; a row of text cells moved by its top and bottom margins together, from Word's seat four fifths of the way down an exact line onto the page's baselines, by the least its cells' lines call for and no further than the margins hold — not a row holding a composed cell, one the layout did not measure, one in a vertical merge or whose cells call for opposite ways, nor a least call under a tenth of a point — and what the margins leave of a cell's call, half a point and more, moved by its text's position, and less, from a tenth of a point, by the height of its lines, each written shorter and the space after it taller, or the other way as far as that space holds, which keeps the cell's content as tall — where every paragraph of the cell is one line, in a face whose letters the export reads, and its letters stay clear of the line's top and foot; a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less its borders and margins, which keep inside it, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it, from the shape's top where the line is taller than its outline and Word sets it on the shape's middle, and a label it holds whole written at the line height that stands its text on the page's baseline, as far as its letters and the room allow, by a tenth of a point or more and not in a standard face the export does not embed — its row held and its label cut in whole twips rounded down, as the row the cell is in holds the page's height rounded once, and what its letters keep the label past that room taken out of the margins of the cell it is in, where it is all that cell holds and the cell all its row holds; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 2fb9b59ab..cb93d380d 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -72,7 +72,7 @@ creation date is real metadata.
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Padding thinner than its share gives what it has; Word sets each cell at its content and, on each side, its padding or its share where that is more, and the row at its tallest cell, so a row stands taller where a cell of text then needs more than the page's height — by that much, which the report names, added up for the table (`cell rule`; measured in Word and LibreOffice on Windows against the same table unruled, to a tenth of a point: four rows with no padding, 3pt rules 15pt and 1pt rules 5pt; a two-line cell with no padding beside a padded one-line cell, each counted on its own). Cells built from a node, cells spanning rows and empty cells in rows held at the page's height are not counted; where the layout does not tell a row's height — a table composed in a cell, or no layout — the report names the most it can be. Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
-| Images | Embedded pictures at the node's declared size. 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 |
+| Images | Embedded pictures at the node's declared size. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too |
| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
| Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel |
| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses |
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 92c05b087..6e15f5177 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
@@ -11526,7 +11526,15 @@ private long takeFromAboveItsRow(XWPFParagraph target, long twips, int lines) {
* {@link #owePendingSpacingAfter} says why that matters.
*/
private void applyVerticalSpacing(XWPFParagraph target, DocumentNode source) {
- double before = source.margin().top() + source.padding().top();
+ applyVerticalSpacing(target, source, 0, 0);
+ }
+
+ /**
+ * Writes the space a block holds above and below itself, with room of its own beyond its margin
+ * and padding: a contained picture's room in its box ({@link #holdTheBoxRound}).
+ */
+ private void applyVerticalSpacing(XWPFParagraph target, DocumentNode source, double roomAbove, double roomBelow) {
+ double before = source.margin().top() + source.padding().top() + roomAbove;
if (source == topEdgeHeldAbove) {
// Held by the line above it, on the page the layout starts it on.
before -= topEdgeHeld;
@@ -11549,7 +11557,7 @@ private void applyVerticalSpacing(XWPFParagraph target, DocumentNode source) {
pullLeftOn = null;
pullLeft = 0;
addSpacing(target, before, 0);
- double below = source.margin().bottom() + source.padding().bottom();
+ double below = source.margin().bottom() + source.padding().bottom() + roomBelow;
// OrangeOps' role bar is pulled 3.8pt up into its name's line; dropped, the pull set
// the bar and the whole page under it that much low in Word, and onto a second page.
if (below < 0) {
@@ -13151,7 +13159,8 @@ private static void closeAnchor(XWPFParagraph para, int id) {
*
* {@code fitMode} then decides how the image sits in that box, matching the PDF
* handler: {@code CONTAIN} scales by the smaller ratio and is embedded at that size,
- * which needs no clipping because it is inside the box already; {@code COVER} scales by
+ * which needs no clipping because it is inside the box already, the room it leaves in the
+ * box held round it as the page centres it ({@link #roomRound}); {@code COVER} scales by
* the larger and crops the overflow away in source space through {@code a:srcRect},
* centred, the way the PPTX backend expresses the same geometry; {@code STRETCH} fills
* the box.
@@ -13213,10 +13222,17 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception
}
XWPFParagraph para = newBodyParagraph(document);
+ double[] room = fitMode == DocumentImageFitMode.CONTAIN ? roomRound(resolved, box) : new double[2];
// A picture is a block: the space the node holds above and below itself is written
// on the paragraph carrying it, the same as any other block's. Without it the
// probe's image ran straight into the heading under it, 24pt short of the page.
- applyVerticalSpacing(para, node);
+ // In a band the space owed below is the band's, measured from the picture's box, so
+ // the room below it is the picture's own.
+ applyVerticalSpacing(para, node, room[0], bandDepth > 0 ? 0 : room[0]);
+ if (bandDepth > 0) {
+ addSpacing(para, 0, room[0]);
+ }
+ holdInAcross(para, room[1]);
XWPFRun run = para.createRun();
XWPFPicture picture = addFittedPicture(run, bytes, resolved, fitMode, box);
// A portrait clipped to a circle: the picture takes the circle's shape, which both
@@ -13245,9 +13261,9 @@ private XWPFPicture addFittedPicture(XWPFRun run, byte[] bytes, ImageData resolv
double drawWidth = box.width();
double drawHeight = box.height();
if (fitMode == DocumentImageFitMode.CONTAIN) {
- double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
- drawWidth = sourceWidth * scale;
- drawHeight = sourceHeight * scale;
+ double[] contained = containedIn(resolved, box);
+ drawWidth = contained[0];
+ drawHeight = contained[1];
}
XWPFPicture picture;
try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) {
@@ -13260,6 +13276,36 @@ private XWPFPicture addFittedPicture(XWPFRun run, byte[] bytes, ImageData resolv
return picture;
}
+ /** The size a picture contained in its box is drawn at, {@code {width, height}}: by the smaller ratio. */
+ private static double[] containedIn(ImageData resolved, NodeDefinitionSupport.ImageDimensions box) {
+ double sourceWidth = Math.max(1, resolved.getMetadata().width());
+ double sourceHeight = Math.max(1, resolved.getMetadata().height());
+ double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
+ return new double[]{sourceWidth * scale, sourceHeight * scale};
+ }
+
+ /**
+ * The room a contained picture leaves round it in its box, {@code {above and below, at each
+ * side}}: the page draws it in the box's middle, half the room on either side. Written at the
+ * picture's size alone, a 200×100 photo fitted to a 100pt square stood at the square's top, and
+ * everything under it 51pt high, in Word and LibreOffice. The room above and below is its
+ * paragraph's space, and the room at its side its indent ({@link #holdInAcross}).
+ */
+ private static double[] roomRound(ImageData resolved, NodeDefinitionSupport.ImageDimensions box) {
+ double[] contained = containedIn(resolved, box);
+ return new double[]{Math.max(0, box.height() - contained[1]) / 2, Math.max(0, box.width() - contained[0]) / 2};
+ }
+
+ /** Holds a paragraph in from its left by so many points more, past a twip. */
+ private static void holdInAcross(XWPFParagraph para, double points) {
+ if (toTwips(points) < 1) {
+ return;
+ }
+ CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
+ CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
+ indent.setLeft(BigInteger.valueOf(twipsOf(indent.isSetLeft() ? indent.getLeft() : null) + toTwips(points)));
+ }
+
/**
* Describes a run's picture as a screen reader reads it out: POI describes a picture by the
* file name it is handed.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainedPictureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainedPictureTest.java
new file mode 100644
index 000000000..c1459b1bc
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainedPictureTest.java
@@ -0,0 +1,169 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A picture contained in its box keeps the room it leaves round it.
+ *
+ * The page draws a contained picture in its box's middle. Measured in Word 16 and LibreOffice
+ * on Windows, a 200×100 picture fitted to a 100pt square stood at the square's top, and the
+ * paragraph under it 51pt high, in both. With the room above and below written as its paragraph's
+ * space, the picture stood where the page draws it in both, and a 100×200 one held in by the room
+ * at its side too.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxContainedPictureTest {
+
+ @Test
+ void aWidePictureKeepsTheRoomAboveAndBelowIt() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addImage(image -> image.source(picture(200, 100)).fitToBounds(100, 100)).addParagraph("After"));
+
+ XWPFParagraph picture = exported.pictureParagraph();
+ assertThat(picture.getSpacingBefore()).as("25pt above").isEqualTo(500);
+ assertThat(exported.below(picture)).as("and 25pt below, the line under it raised into it as into any space")
+ .isEqualTo(500 - DocxExports.DEFAULT_LINE_RAISE);
+ assertThat(left(picture)).as("filling the box across").isZero();
+ }
+
+ @Test
+ void aTallPictureKeepsTheRoomAtItsSide() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addImage(image -> image.source(picture(100, 200)).fitToBounds(100, 100)).addParagraph("After"));
+
+ XWPFParagraph picture = exported.pictureParagraph();
+ assertThat(left(picture)).as("25pt in, the middle of its box").isEqualTo(500);
+ assertThat(Math.max(0, picture.getSpacingBefore())).as("filling it down").isZero();
+ }
+
+ @Test
+ void aPictureThatFitsItsBoxIsWrittenAsBefore() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addImage(image -> image.source(picture(200, 100)).fitToBounds(200, 100)).addParagraph("After"));
+
+ XWPFParagraph picture = exported.pictureParagraph();
+ assertThat(Math.max(0, picture.getSpacingBefore())).isZero();
+ assertThat(left(picture)).isZero();
+ }
+
+ @Test
+ void aStretchedPictureFillsItsBox() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addImage(image -> image.source(picture(200, 100)).size(100, 100).fitMode(DocumentImageFitMode.STRETCH))
+ .addParagraph("After"));
+
+ assertThat(Math.max(0, exported.pictureParagraph().getSpacingBefore())).isZero();
+ }
+
+ @Test
+ void aContainedPictureInARowsCellKeepsItsRoomThere() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before").addRow(row -> row
+ .addImage(image -> image.source(picture(200, 100)).fitToBounds(100, 100))
+ .addParagraph("Beside"))
+ .addParagraph("After"));
+
+ XWPFParagraph picture = pictureIn(exported.document().getTables().get(0).getRow(0).getCell(0).getParagraphs());
+ assertThat(picture.getSpacingBefore()).isEqualTo(500);
+ assertThat(picture.getSpacingAfter()).as("the room below paid in the cell").isEqualTo(500);
+ }
+
+ @Test
+ void aContainedPictureComposedInATablesCellKeepsItsRoomThere() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before").addTable(table -> table
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(120),
+ com.demcha.compose.document.table.DocumentTableColumn.fixed(100))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new ImageBuilder()
+ .source(picture(200, 100)).fitToBounds(100, 100).build()),
+ com.demcha.compose.document.table.DocumentTableCell.text("Beside")))
+ .addParagraph("After"));
+
+ XWPFParagraph picture = pictureIn(exported.document().getTables().get(0).getRow(0).getCell(0).getParagraphs());
+ assertThat(picture.getSpacingBefore()).isEqualTo(500);
+ }
+
+ @Test
+ void aCentredTallPictureStandsInItsBoxsMiddle() throws Exception {
+ // The 100pt box centred in the 256pt column stands 78pt in, and the picture 25pt further.
+ Exported exported = export(page -> page.addParagraph("Before")
+ .addAligned(com.demcha.compose.document.node.HorizontalAlign.CENTER,
+ new ImageBuilder().source(picture(100, 200)).fitToBounds(100, 100).build())
+ .addParagraph("After"));
+
+ assertThat(left(exported.pictureParagraph())).isCloseTo(Math.round((78 + 25) * 20.0),
+ org.assertj.core.data.Offset.offset(2L));
+ }
+
+ @Test
+ void aContainedPictureInABandKeepsTheRoomBelowItAsItsOwn() throws Exception {
+ // A band owes the space under it from the picture's box, which holds the room below it.
+ Exported exported = export(page -> page.addParagraph("Before").addLayerStack(stack -> stack
+ .topLeft(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Heading").build())
+ .bottomLeft(new ImageBuilder().source(picture(200, 100)).fitToBounds(100, 100).build()))
+ .addParagraph("After"));
+
+ assertThat(exported.pictureParagraph().getSpacingAfter()).isEqualTo(500);
+ }
+
+ private static XWPFParagraph pictureIn(List paragraphs) {
+ return paragraphs.stream()
+ .filter(paragraph -> !paragraph.getRuns().isEmpty()
+ && !paragraph.getRuns().get(0).getEmbeddedPictures().isEmpty())
+ .findFirst().orElseThrow();
+ }
+
+ private static long left(XWPFParagraph paragraph) {
+ CTInd indent = paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr().getInd() : null;
+ return indent == null || !indent.isSetLeft() ? 0L : DocxTwips.of(indent.getLeft());
+ }
+
+ private static DocumentImageData picture(int width, int height) {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream png = new ByteArrayOutputStream();
+ try {
+ javax.imageio.ImageIO.write(image, "png", png);
+ } catch (java.io.IOException failure) {
+ throw new java.io.UncheckedIOException(failure);
+ }
+ return DocumentImageData.fromBytes(png.toByteArray());
+ }
+
+ private record Exported(XWPFDocument document) {
+
+ XWPFParagraph pictureParagraph() {
+ return pictureIn(document.getParagraphs());
+ }
+
+ /** The space under a paragraph: its own after and the next one's before, in twips. */
+ long below(XWPFParagraph paragraph) {
+ List paragraphs = document.getParagraphs();
+ XWPFParagraph next = paragraphs.get(paragraphs.indexOf(paragraph) + 1);
+ return Math.max(0, paragraph.getSpacingAfter()) + Math.max(0, next.getSpacingBefore());
+ }
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(72)).create()) {
+ session.pageFlow(content::accept);
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend()))));
+ }
+ }
+}