From b121de0b771284d9476009ef50cf1ecc774c558f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 26 Oct 2026 20:45:03 +0000 Subject: [PATCH 1/4] fix(docx): write a painted box holding text in the body as a panel A shape container shaped as a rectangle or a rounded rectangle, filled or outlined, holding text and placed in the body, was drawn at its size with its layers written in the flow: its text took the flow's width and ran past the outline, the block under it stood over the outline, and the outline kept its height when text was added in an editor. It is now written as a panel, as it already was composed in a table cell: one cell its outline's size, its fill and border on the cell, its padding round it, the row kept on one page. Its layers stand where the layout places them in the outline; one the page sets at the outline's top is set at the cell's top and takes the top border out of the space above its first line. The row is held at least the outline's height, so it grows with its text; a clip that cuts what the layers paint past the outline's foot holds it at exactly that height less one border, which Word and LibreOffice cut the content at. The report names the square corners, the clip written as an exact height, and layers running past the foot unclipped. The rows of the fidelity corpus's three body shape containers are rewritten in the Word and Windows LibreOffice baselines. --- CHANGELOG.md | 54 +++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 66 ++- .../fidelity/SyntheticDocxCorpus.java | 14 +- .../libreoffice-windows-lines.tsv | 24 +- .../docx-fidelity/libreoffice-windows.tsv | 4 +- .../docx-fidelity/word-windows-lines.tsv | 18 +- .../resources/docx-fidelity/word-windows.tsv | 4 +- .../backend/semantic/docx/DocxClipInk.java | 2 +- .../semantic/docx/DocxSemanticBackend.java | 254 +++++++++-- .../semantic/docx/DocxBodyShapePanelTest.java | 416 ++++++++++++++++++ .../docx/DocxCarriedWithoutReportTest.java | 11 +- .../semantic/docx/DocxClipReportTest.java | 3 +- .../semantic/docx/DocxDrawingsTest.java | 3 +- .../docx/DocxNodeFieldLedgerTest.java | 19 +- .../docx/DocxValueFieldLedgerTest.java | 6 +- 16 files changed, 799 insertions(+), 101 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 32975b74c..ca4eccac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export writes a painted box holding text in the body as a panel, its text inside its + outline.** + - A shape container shaped as a rectangle or a rounded rectangle, filled or outlined, holding + text, placed in the body outside any table cell and laid over nothing, was drawn as a shape at + its size with its layers written in the flow. Its text took the flow's width and ran past the + outline, the block under it stood where the flow put it, over the outline, and the outline + kept its height when text was added to it in an editor. It is now written as a panel, as it + already was composed in a table cell: a table of one cell its outline's size, its fill and + border on the cell, its padding round it, its row kept on one page, as the layout moves the + shape whole. + - Its layers stand in the cell at the height its first written layer is set — at the top where + the page starts one taller than the outline there — and across where the layout places them in + the outline; a block set at the outline's left keeps the room the page laid it out in. Word sets a cell's content below its whole top border, where the page + sets the layers from the outline's top, under the border: a shape set from its top takes the + border out of the space above its first line, and the report names one with nothing there to + take it. + - Its row is held at least the outline's height, so the panel grows with text an edit adds. A + clip that cuts what its layers paint past the outline's foot holds the row at exactly the + outline's height instead, less the one border Word and LibreOffice add to an exact height: + both cut a cell's content at an exact row's foot, as the page cuts it at the outline. The cut + is square, and text an edit adds there is hidden too; the report says so where it said the + clip was not in the file. Layers running past the foot with no clip to cut them grow the + panel, and the report says how far. + - Measured in Word 16 and LibreOffice 24.2: + - a note the page wraps over three lines inside a box 220pt wide ran over two, 95pt past the + box, with the paragraph under it 12pt high; now its three lines stand inside the box, + within 0.03pt of the page's in Word, and the paragraph under it within 0.02pt. + LibreOffice 24.2, which counts the borders inside a least height, sets the box 2pt short and + the paragraph under it 1pt high; + - a box clipped short of its text wrote all of it, 125pt past the outline, with the paragraph + under it 20pt high; now both editors show the five lines the page shows, 1pt low under the + border, and set the paragraph under it within 0.02pt; + - a card stands where the page sets it in Word, as before, within 0.1pt; with three lines + added to it in Word it grows by them, and the paragraph under it moves down with it. In + LibreOffice 24.2 the paragraph under the card stands 0.6pt high, where it stood within + 0.02pt: the card's content is shorter than its outline, and that build counts the borders + inside the row's least height. + - The rounded corners are square, and a turned box stands upright; the report names both. An + ellipse, a polygon or a star, a box holding no text — drawing alone, or a picture's frame —, + an unpainted one, one set as a band beside a drawing, one its margins give no room in the flow, + and one inside a layer stack, a row's cell or a panel are written as before. + - **A DOCX export bookmarks a block of drawings alone where its first drawing is anchored, so a page reference or a link to its anchor works.** - A section, a container or a layer stack's column holding nothing but drawings writes no @@ -4269,6 +4311,18 @@ follow semantic versioning; release dates are ISO 8601. ### Tests +- **The DOCX fidelity corpus holds its three body shape containers where the export now sets + them.** With the boxes written as panels, Word and LibreOffice set every line of + `narrow_container` and `rounded_card` at the page's words, and Word the paragraph under each box + where the page sets it. The rows of the three documents are rewritten — Word's and + LibreOffice's on Windows from local runs, LibreOffice's on Linux from CI — and every other row + is as it was. Of `narrow_container` and `clipped_container` every row moves nearer the page; of + `rounded_card`, the paragraph under the card stands 0.6pt high in LibreOffice 24.2, which counts + a cell's borders inside the row's least height. + `DocxBodyShapePanelTest` holds the panel: its size, its row's height and rule, the layers held + in across, the top border taken out of the space above, the clip written as an exact height, + and the boxes that stay drawn. + - **The DOCX fidelity corpus holds three shape containers in the body, as the export writes them today.** - Three documents join `SyntheticDocxCorpus`, each a rounded shape container holding text, diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 243ba8961..670bb8cc6 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -84,7 +84,7 @@ Payload records live in `core` under | 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; a picture in the flow, a stack of one layer's included, that the layout moves to a new page keeps the space above it there, in a line kept with it, as a paragraph's is; 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 text cell whose lines are at least as tall as its face's own written at a least height, which stands its text where the page does and grows with it, its row's margins its padding and an exact cell beside it left to its position and its lines' heights; a row of exact 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; a column sized to its content gives its text the editor's point out of its text cells' side margins, on the side their text does not lean on, so the column keeps the page's width, and what its cells do not hold — a composed cell's, padding too thin — widens it, reported `APPROXIMATED` (`right end`)) | -| 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 | +| 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, or placed in the body, outside any cell, laid over nothing and given room in the flow, where its layers written in the flow ran past its outline, 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, so it grows with text an edit adds; placed in the body, its layers held in across where the layout places them in the outline, one set from its top — or taller than the outline, from its top — set at the cell's top and taking its top border out of the space above its first line, a clip that cuts what its layers paint past the outline's foot holding the row at exactly the outline's height less one border, which Word and LibreOffice cut its content at, square, and layers running past the foot unclipped growing it, each reported; composed in a cell, 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 | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | 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, or to a name a suffix gave another, stay two bookmarks; an anchor two nodes declare — two cells of one table too — is marked on the last node the file writes a bookmark for, and reported; one in a table's header row the page repeats, which the page sends a link to on its last repeat, is marked on the one row the file holds, where the table starts, and reported | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 9d0408a70..30d28c7d0 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -309,8 +309,9 @@ paragraph is the picture's block, and a list's edges go to the paragraphs around its margin and padding — indent its items, the level's own indent added on top, so its markers start where the page starts them. A paragraph's and a list's sides hold under a layer stack or a shape container too, inside the box that places the layer — but not in a -shape container narrower than the block it stands in, whose layers' room runs past its -right edge (see "Shape containers → inline layers"). In a cell, two +shape container written as inline layers narrower than the block it stands in, whose +layers' room runs past its right edge (see "Shape containers → panels or inline layers"). +In a cell, two points of a side stay the editor's, to set a line a little wider than the page: off the side a left-to-right line does not lean on, so it keeps the edge it is set from — the right of a left-aligned paragraph and of a list, the left of a right-aligned one, both of a @@ -1107,12 +1108,14 @@ recolour what is under it in Word and it keeps the colour it was flattened to. in it, over a spacer that keeps its place in the flow — writes its initials where the ring centres them and keeps its height below them. Each layer is held in across to its box in the stack, with a few points to spare on the side its text does not - lean on. A shape container's layers are held in by as far as their box stands in - from the container's edges, but from the edges of the block the container stands - in, set in by its margin and padding: in a container narrower than that block the - right edge of their room runs that much further right, past its outline, so text - that wraps runs past the outline and centred or right-aligned text stands right of - where the page sets it (see "Shape containers → inline layers"). A block in `addAligned(...)` is held in + lean on. A shape container written as a panel holds its layers in from the cell's + edges, inside its outline. One written as inline layers holds them in by as far as + their box stands in from the container's edges, but from the edges of the block the + container stands in, set in by its margin and padding: in a container narrower than + that block the right edge of their room runs that much further right, past its + outline, so text that wraps runs past the outline and centred or right-aligned text + stands right of where the page sets it (see "Shape containers → panels or inline + layers"). A block in `addAligned(...)` is held in the same way, to where the alignment puts it — composed in a table cell, a paragraph where the lines its table laid out for it stand and a picture where the alignment sets one of its written width; any other block there, or one inside a panel or another table @@ -1166,19 +1169,50 @@ recolour what is under it in Word and it keeps the colour it was flattened to. is not that line and a right side's outline entry beside a left one's. Two texts closer than 4pt stay one after the other. Drawing in the band is drawn as shapes where the page draws it. -- **Shape containers → inline layers.** DOCX has no portable equivalent of +- **Shape containers → panels or inline layers.** A rectangle or a rounded + rectangle, filled or outlined, holding text is written as a panel, as a + section with a fill is: a table of one cell, its outline's size, the fill + and border on the cell and its padding round it, its row kept on one page + as the layout moves the shape whole. So it is composed in a table's cell, + and placed in the body, outside any cell, laid over nothing and given room + in the flow. Its corners are square, and a turned one stands upright; the + report names both. Its layers stand in the cell at the height its first + written layer is set — top, middle or foot, the top where the page starts + a layer taller than the outline there — and across where the layout places them, + a block set at the outline's left keeping the room the page laid it out + in. Word sets a cell's content below its whole top border, where the page + sets the layers from the outline's top, under the border: a shape set + from its top takes the border out of the space above its first line, and + the report names one with nothing there to take it. The row is held at + least the outline's height, so the panel grows with text added to it in + an editor. A clip that cuts what the layers paint past the outline's foot + holds it at exactly that height instead: Word and LibreOffice cut a + cell's content at an exact row's foot, as the page cuts it at the + outline, but square, and hide text an edit adds there, which the report + says. Layers running past the foot with no clip to cut them grow it, and + the report says how far. Measured in Word 16 and LibreOffice 24.2: a note + the page wraps over three lines inside a box 220pt wide stands in three, + inside the box, within 0.03pt of the page's in Word; a clipped box shows + the five lines the page shows, 1pt low under its border; the block under + either box stands within 0.02pt of the page's in Word. LibreOffice 24.2, + which counts the borders inside a least height, sets the narrow box 2pt + short and the block under it 1pt high, and the block under a card whose + content is shorter than its outline 0.6pt high. A card lengthened in Word + grows with its text, and the block under it moves down with it. + Any other container — an ellipse, a polygon or a star, one holding no + text, one set as a band beside a drawing, one inside a layer stack, a + row's cell or a panel — is written as inline layers, or drawn where it + holds only drawing. DOCX has no portable equivalent of a graphics-state path clip, so the container's layers are written inline, in source order, without clipping — again with one warning per export, and a note on each container that writes a layer in the flow. The outline is drawn at its size and does not grow with what is written in the flow, and the layers take the room of the flow, not of the - outline. Measured in Word and LibreOffice: a note the page wraps over - three lines inside a box 220pt wide runs over two, up to 95pt past the - box; a clipped box's text is written whole, past its outline; the block - under a container stands where the flow puts it, over the outline where - its layers are written shorter than the page sets them — 12pt high under - the narrow box, 20pt under the clipped one. In Word, text added to a card - runs past its foot, the outline keeping its height. A + outline: in a container narrower than the block it stands in, the right + edge of their room runs past the outline, so text that wraps runs past + it, and the block under the container stands where the flow puts it, + over the outline where its layers are written shorter than the page sets + them. A container whose layers are all drawn — a tile, an icon, a badge holding its glyph — writes none: each layer is drawn where the page draws it, in the order the page stacks them, by their z-index. Word stacks drawings by diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/SyntheticDocxCorpus.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/SyntheticDocxCorpus.java index c40fc9dc7..a6574b138 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/SyntheticDocxCorpus.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/SyntheticDocxCorpus.java @@ -30,14 +30,12 @@ * set in a face the export embeds, so both editors break at the page's words the lines they * set at the page's width.

* - *

The baselines hold the shape containers as the export writes them now, with their text - * past the outline: Word and LibreOffice set the narrow box's note and the clipped box's text - * wider than the box, at other words, which the gate therefore does not hold, and the paragraph - * under each box 12 and 20pt high. A change that writes the text inside the outline finds it, - * moves the paragraph under each box nearer the page, and rewrites their rows. The card, which - * the export sets where the page does, holds such a change to keep it there. The gate reads - * lines of text only: whether the text stays inside the outline, and whether the card grows - * when text is added to it in an editor, it does not see.

+ *

The export writes the three boxes as panels, their text inside the outline: Word sets the + * narrow box's note and the card's text at the page's words, and the paragraph under each box + * where the page sets it; of the clipped box, whose clip is written as the panel's exact height, + * both editors show the lines the page shows. The gate reads lines of text only: whether the + * text stays inside the outline, and whether the card grows when text is added to it in an + * editor, it does not see.

*/ final class SyntheticDocxCorpus { diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 4c367377a..4d2de2c02 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -5018,15 +5018,23 @@ synthetic-card_over_pages 2:f69c59b028ea 1.08 0.60 lineitem60 synthetic-card_over_pages 2:db55da3fc309 1.08 0.60 420 synthetic-card_over_pages 2:8c569b972e01 1.08 0.10 afterthecard,onitslastpage. synthetic-clipped_container 0:cbe6ee14976b -0.02 0.10 beforetheclippedbox,onalineofits -synthetic-clipped_container 0:2633ce73dc24 -20.02 0.10 aftertheclippedbox:thisparagraph -synthetic-clipped_container 0:667f8b3b3eff -20.02 0.10 noneofitslinesrunsoverthebox. +synthetic-clipped_container 0:724cbc468421 0.98 -0.40 clippedtextinaroundedboxtooshort +synthetic-clipped_container 0:08eb95ef1c44 0.98 -0.40 holdit.thepagesetsitfromthetopof +synthetic-clipped_container 0:7876cbea781b 0.98 -0.40 boxandcutsitattheoutline,sotheli +synthetic-clipped_container 0:1d37c4498256 0.98 -0.40 thatrunpastthefootoftheboxarenot +synthetic-clipped_container 0:e7653895ac38 0.98 -0.40 seen.onlythelinestheboxholdsarer +synthetic-clipped_container 0:2633ce73dc24 -0.02 0.10 aftertheclippedbox:thisparagraph +synthetic-clipped_container 0:667f8b3b3eff -0.02 0.10 noneofitslinesrunsoverthebox. synthetic-narrow_container 0:fa13cab3fe73 -0.02 0.10 beforethebox,onalineofitsown. -synthetic-narrow_container 0:697e6629574d -12.02 0.10 afterthebox:thisparagraphstandsu -synthetic-narrow_container 0:0efb534ce6c6 -12.02 0.10 linesrunsoverthebox. +synthetic-narrow_container 0:ed60c8a4c178 -0.02 -0.41 anotecentredinaboxnarrowerthanth +synthetic-narrow_container 0:d7899490a8cd -0.02 -0.41 thepagewrapsitoverthreelinesinsi +synthetic-narrow_container 0:19a2ba751318 -0.02 -0.41 eachlineheldwithinitsoutline. +synthetic-narrow_container 0:697e6629574d -0.02 0.10 afterthebox:thisparagraphstandsu +synthetic-narrow_container 0:0efb534ce6c6 -0.02 0.10 linesrunsoverthebox. synthetic-rounded_card 0:2a3c79dd9054 -0.02 0.10 beforethecard,onalineofitsown. -synthetic-rounded_card 0:b8608d77afb0 0.01 0.10 aroundedcard -synthetic-rounded_card 0:bcb0465dee5d -0.02 0.10 itstextrunsoverthreelinesfromthe -synthetic-rounded_card 0:73c347391d3b -0.02 0.10 cardisjusttallenoughtoholdthemwi -synthetic-rounded_card 0:8993a52a9026 -0.02 0.10 linemore,andthecardneedstobetall +synthetic-rounded_card 0:b8608d77afb0 0.01 -0.40 aroundedcard +synthetic-rounded_card 0:bcb0465dee5d -0.02 -0.40 itstextrunsoverthreelinesfromthe +synthetic-rounded_card 0:73c347391d3b -0.02 -0.40 cardisjusttallenoughtoholdthemwi +synthetic-rounded_card 0:8993a52a9026 -0.02 -0.40 linemore,andthecardneedstobetall synthetic-rounded_card 0:103d88aab213 -0.02 0.10 afterthecard:thisparagraphstands synthetic-rounded_card 0:66b11b97a20e -0.02 0.10 linesrunsoverthecard. diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index 3c6facd2e..ecb44428a 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -69,6 +69,6 @@ rota-cobalt 1 1 154 151 0.03 0.19 0 synthetic-break_in_column 1 1 7 7 0.02 0.02 0 synthetic-break_in_panel 3 3 8 8 3.02 5.02 5 synthetic-card_over_pages 3 3 114 114 0.98 1.08 0 -synthetic-clipped_container 1 1 10 3 20.02 20.02 2 -synthetic-narrow_container 1 1 6 3 12.02 12.02 2 +synthetic-clipped_container 1 1 10 8 0.98 0.98 0 +synthetic-narrow_container 1 1 6 6 0.02 0.02 0 synthetic-rounded_card 1 1 7 7 0.02 0.02 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 9309fe866..ce1bfa028 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -5138,11 +5138,21 @@ synthetic-card_over_pages 2:f69c59b028ea 1.16 0.58 lineitem60 synthetic-card_over_pages 2:db55da3fc309 1.16 -36.37 420 synthetic-card_over_pages 2:8c569b972e01 1.11 0.00 afterthecard,onitslastpage. synthetic-clipped_container 0:cbe6ee14976b -0.01 0.02 beforetheclippedbox,onalineofits -synthetic-clipped_container 0:2633ce73dc24 -19.97 0.02 aftertheclippedbox:thisparagraph -synthetic-clipped_container 0:667f8b3b3eff -19.97 0.02 noneofitslinesrunsoverthebox. +synthetic-clipped_container 0:724cbc468421 0.99 0.02 clippedtextinaroundedboxtooshort +synthetic-clipped_container 0:08eb95ef1c44 0.99 0.02 holdit.thepagesetsitfromthetopof +synthetic-clipped_container 0:7876cbea781b 0.99 0.02 boxandcutsitattheoutline,sotheli +synthetic-clipped_container 0:1d37c4498256 0.99 0.02 thatrunpastthefootoftheboxarenot +synthetic-clipped_container 0:e7653895ac38 0.99 0.02 seen.onlythelinestheboxholdsarer +synthetic-clipped_container 0:7365672653fc 0.99 0.02 andtheboxstaysastallasitwasmade, +synthetic-clipped_container 0:f0da0f0af799 1.02 0.02 whateveritisgiventohold. +synthetic-clipped_container 0:2633ce73dc24 -0.02 0.02 aftertheclippedbox:thisparagraph +synthetic-clipped_container 0:667f8b3b3eff -0.02 0.02 noneofitslinesrunsoverthebox. synthetic-narrow_container 0:fa13cab3fe73 -0.01 0.02 beforethebox,onalineofitsown. -synthetic-narrow_container 0:697e6629574d -12.02 0.02 afterthebox:thisparagraphstandsu -synthetic-narrow_container 0:0efb534ce6c6 -12.02 0.02 linesrunsoverthebox. +synthetic-narrow_container 0:ed60c8a4c178 0.03 0.03 anotecentredinaboxnarrowerthanth +synthetic-narrow_container 0:d7899490a8cd 0.03 0.03 thepagewrapsitoverthreelinesinsi +synthetic-narrow_container 0:19a2ba751318 0.03 0.03 eachlineheldwithinitsoutline. +synthetic-narrow_container 0:697e6629574d -0.02 0.02 afterthebox:thisparagraphstandsu +synthetic-narrow_container 0:0efb534ce6c6 -0.02 0.02 linesrunsoverthebox. synthetic-rounded_card 0:2a3c79dd9054 -0.01 0.02 beforethecard,onalineofitsown. synthetic-rounded_card 0:b8608d77afb0 0.10 0.02 aroundedcard synthetic-rounded_card 0:bcb0465dee5d -0.01 0.02 itstextrunsoverthreelinesfromthe diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 526ad56ed..a7110acb5 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -69,6 +69,6 @@ rota-cobalt 1 1 154 154 0.14 0.26 0 synthetic-break_in_column 1 1 7 7 0.03 0.03 0 synthetic-break_in_panel 3 3 8 8 0.95 0.99 0 synthetic-card_over_pages 3 3 114 114 1.03 1.15 0 -synthetic-clipped_container 1 1 10 3 19.97 19.97 2 -synthetic-narrow_container 1 1 6 3 12.02 12.02 2 +synthetic-clipped_container 1 1 10 10 0.99 0.99 0 +synthetic-narrow_container 1 1 6 6 0.03 0.03 0 synthetic-rounded_card 1 1 7 7 0.01 0.02 0 diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java index b5ac8a595..c4a78b5b6 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java @@ -63,7 +63,7 @@ final class DocxClipInk { /** How far ink may stand past a clip before the clip is taken to cut it. */ - private static final double TOLERANCE = 0.5; + static final double TOLERANCE = 0.5; /** The most bands a clip's outline is indexed in, by height. */ private static final int MAX_BANDS = 256; 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 c1617a97f..2d1118af7 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 @@ -438,6 +438,10 @@ public final class DocxSemanticBackend implements SemanticBackend { // The part a panel's table being written holds of each container a page break cuts it at // (DocxPanelPieces), by node, taken by the container's writer. private final java.util.Map panelWindows = new java.util.IdentityHashMap<>(); + // The shape containers whose clip cuts what their layers paint (reportClipCut), by node, each with + // the place of the note naming it in the report: a shape written as a panel then writes the clip + // its row can hold, and says so in that note's place (holdTheShapesRow). + private final java.util.Map clipsCutting = new java.util.IdentityHashMap<>(); // The table each table node is written as (writeTableRows). private final java.util.Map tablesWritten = new java.util.IdentityHashMap<>(); // Panels the layout breaks over pages inside the table they hold, cut there once the document is @@ -1240,6 +1244,7 @@ private byte[] write(List sections, Path outputFile) throws Exc fittedWritten.clear(); listNumbering.clear(); panelWindows.clear(); + clipsCutting.clear(); measuredColumns.clear(); prefixColumns.clear(); report = new DocxExportReport.Builder(); @@ -4209,6 +4214,9 @@ private void reportClipCut(DocumentNode node) { "a clip composed in its cells is not in the file, so what is painted past it is " + "written whole"); } else { + if (node instanceof ShapeContainerNode) { + clipsCutting.put(node, report.size()); + } reportClipLost(node, ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) clip.opening().payload()).policy(), ""); } @@ -7801,6 +7809,13 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container Math.max(0, pendingSpacingAfter - hangingBelow - borderBelow - pullBelow)); topNotTaken = reach - taken; pendingSpacingAfter -= taken; + if (node instanceof ShapeContainerNode shape && setFromItsTop(shape)) { + // The page sets a shape's layers from its outline's top, its border stroked over + // them on the edge, and Word sets a cell's content below the whole of its top + // border: a card's heading stood its 1pt border low in Word and LibreOffice. The + // space above its first paragraph takes it (below), the outline staying where it is. + topNotTaken = strokeWidth(borders.top()); + } } if (onANewPage) { holdTheSpaceAboveOnItsPage(document); @@ -7947,8 +7962,10 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // a piece after the first has none. applyCellPadding(cell, new DocumentInsets(first ? margins.top() : 0, Math.max(0, marginHeld - slackTaken), margins.bottom(), marginLeft)); - // 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)) { + // A row is laid out as one piece, never across a page break (writeRow): so is its panel, and + // a shape's, which the layout moves to a new page whole. + if ((node.keepTogether() || node instanceof RowNode || node instanceof ShapeContainerNode) + && layout.onOnePage(node)) { table.getRow(0).setCantSplitRow(true); } if (node instanceof ShapeContainerNode shape) { @@ -8061,12 +8078,20 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container double unheld = low - fromThePadding - fromTheContent; if (unheld > TAIL_CLEARANCE) { report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), - "its content stands " + pointsOf(unheld) + "pt lower than the page sets it, and what follows " - + "up to as much: Word sets it below the whole of its top border, and neither the space above it " - + "nor its padding takes what of the border reaches past the panel's box"); + heldWhole + ? "its content stands " + pointsOf(unheld) + "pt lower than the page sets it: Word sets it " + + "below the whole of its top border, where the page sets its layers from its outline's " + + "top, and nothing above its first line takes the border" + : "its content stands " + pointsOf(unheld) + "pt lower than the page sets it, and what " + + "follows up to as much: Word sets it below the whole of its top border, and neither " + + "the space above it nor its padding takes what of the border reaches past the panel's box"); } com.demcha.compose.document.layout.PlacedNode placed = first && last && layout.onOnePage(node) ? layout.placement(node) : null; - if (placed != null && placed.placementHeight() > 0) { + if (placed != null && node instanceof ShapeContainerNode shape && shape.outline().height() > 0) { + // Placed in the body, it holds its outline's height as composed in a cell, its padding + // written round it (above). + holdTheShapesRow(table.getRow(0), shape); + } else if (placed != null && placed.placementHeight() > 0) { // Its height is the page's: what makes a panel taller than its text — an icon drawn // where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's // due-date card closed from 59.4pt to its text's 26, and its calendar hung below it. @@ -8137,6 +8162,67 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container cellOverhang = 0; } + /** + * Holds the row of a shape the layout places in the body, written as a panel, to its + * outline's height. + * + *

Held at least that tall, the row grows as text is added to it in an editor, as a card's + * outline should. Where the shape's clip cuts what its layers paint ({@link #reportClipCut}) + * and they run past the outline's foot, the page shows only what the outline holds, and a box + * of clipped text stays as tall as it was made: the row is held at exactly that height, and + * Word and LibreOffice cut a cell's content at an exact row's foot. Both add the heavier border + * to an exact height — Word adds both to a least one, LibreOffice 24.2 neither — and its + * margins, none here (writePanelPiece): measured on a 60pt box with a 1pt border, held exactly + * at 58pt it stood 59pt in both, and the paragraph under it 1pt high; at 60pt, 61pt. The cut is + * square, and text added in an editor is hidden with the rest: the clip's note says so in place + * of the one that said it was not in the file.

+ * + *

Layers whose boxes run past the foot with no clip to cut them grow the row, and the + * editor sets what follows lower, which is named.

+ */ + private void holdTheShapesRow(XWPFTableRow row, ShapeContainerNode shape) { + double past = pastItsFoot(shape); + Integer noted = clipsCutting.get(shape); + long exact = Math.round(shape.outline().height() * POINT_TO_TWIP) - verticalMargins(row.getCell(0)); + if (noted == null || !(past > DocxClipInk.TOLERANCE) || exact <= 0) { + holdThePanelsHeight(row, shape.outline().height(), DocumentInsets.zero(), true, false); + if (past > DocxClipInk.TOLERANCE && noted == null) { + report.add(DocxExportReport.Severity.APPROXIMATED, "shape container", layout.pathOf(shape), + "its layers run " + pointsOf(past) + "pt past its outline's foot: written as a panel, it " + + "grows to hold them, and what follows stands up to that much lower"); + } + return; + } + row.setHeight((int) exact); + row.setHeightRule(org.apache.poi.xwpf.usermodel.TableRowHeightRule.EXACT); + report.replace(noted, DocxExportReport.Severity.APPROXIMATED, "clipped shape container", layout.pathOf(shape), + "its clip is written as its panel's exact height, its outline's: Word cuts what its layers paint " + + "past the foot of the panel, as the page cuts it at the outline" + (roundsItsCorners(shape) + ? ", but square, not round its rounded corners" : "") + + ", and text added to it in an editor is hidden too, the panel no taller"); + } + + /** + * How far below a shape's outline the layout sets the foot of its lowest written layer, its + * margin with it, in points; 0 where none runs past it, or the layout places none on its page. + */ + private double pastItsFoot(ShapeContainerNode shape) { + com.demcha.compose.document.layout.PlacedNode box = layout.placement(shape); + if (box == null) { + return 0; + } + // Measured up from the foot of the page. + double foot = box.placementY() + shape.padding().bottom(); + double past = 0; + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(layer.node()); + if (writtenInItsPanel(layer) && placed != null && placed.startPage() == box.startPage()) { + past = Math.max(past, foot - (placed.placementY() - layer.node().margin().bottom())); + } + } + return past; + } + /** * Takes what it can of a border the cell just written carries out of the padding below of the * panel ending it ({@link #carriedBorderCell}), the border then drawn that much inside that @@ -8281,12 +8367,13 @@ private PanelWidth panelWidth(DocumentNode node) { + (node instanceof ShapeContainerNode ? node.padding().horizontal() : 0); double available = known ? availableWidth() - sides : Double.NaN; java.util.OptionalDouble placed = layout.placedWidth(node); - if (placed.isEmpty() && node instanceof ShapeContainerNode shape) { + if (node instanceof ShapeContainerNode shape) { // Composed in a table cell, it has no placement; its outline states its size, within - // the cell it is composed in, and it gets the same slack below. - placed = java.util.OptionalDouble.of(Double.isFinite(available) - ? Math.min(shape.outline().width(), available) - : shape.outline().width()); + // the cell it is composed in, and it gets the same slack below. Placed, it is its + // outline's width, its padding round it. + placed = java.util.OptionalDouble.of(placed.isPresent() || !Double.isFinite(available) + ? shape.outline().width() + : Math.min(shape.outline().width(), available)); } if (placed.isEmpty()) { return new PanelWidth(available, 0); @@ -8839,6 +8926,29 @@ private static double shareDown(com.demcha.compose.document.node.LayerAlign alig }; } + /** + * Whether the page sets a shape's first written layer at its outline's top: one set from the + * top, or one taller than the outline, which the page starts there. Only a shape the layout + * places is read so; one composed in a table cell has no placements to read. + */ + private boolean setFromItsTop(ShapeContainerNode shape) { + com.demcha.compose.document.layout.PlacedNode box = layout.placement(shape); + if (box == null) { + return false; + } + double outlineTop = box.placementY() + box.placementHeight() - shape.padding().top(); + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + if (!writtenInItsPanel(layer)) { + continue; + } + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(layer.node()); + // Measured up from the foot of the page: its margin box's top against the outline's. + return placed != null && placed.startPage() == box.startPage() + && placed.placementY() + placed.placementHeight() + layer.node().margin().top() >= outlineTop - 0.5; + } + return false; + } + /** Whether a shape's layer is written: anything but drawing, which its table draws. */ private boolean writtenInItsPanel(com.demcha.compose.document.node.LayerStackNode.Layer layer) { return !onlyDrawn(layer.node()); @@ -8847,9 +8957,13 @@ private boolean writtenInItsPanel(com.demcha.compose.document.node.LayerStackNod /** * Where a shape written as a panel sets what it writes in its cell, top to bottom: where its * first written layer stands; {@code null} for the top, Word's own default. A panel writes one - * at least ({@link #writtenAsAPanel}); with none, the middle. + * at least ({@link #writtenAsAPanel}); with none, the middle. The top where the page sets it + * there, taller than the outline as it is, whatever its alignment ({@link #setFromItsTop}). */ private XWPFTableCell.XWPFVertAlign verticalOfItsLayers(ShapeContainerNode shape) { + if (setFromItsTop(shape)) { + return null; + } for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { if (writtenInItsPanel(layer)) { double share = shareDown(layer.align()); @@ -8861,24 +8975,25 @@ private XWPFTableCell.XWPFVertAlign verticalOfItsLayers(ShapeContainerNode shape } /** - * Holds a layer of a shape written as a panel in across where the shape sets it, from the - * width the lines its table laid out for it take. + * Holds a layer of a shape written as a panel in across where the shape sets it: where the + * layout places it, in a shape the layout places, or else from the width the lines its table + * laid out for it take. * *

The page sets a layer narrower than the shape at its alignment's side or middle: a label * set at the bottom-right corner stood at the left in Word and LibreOffice. A paragraph whose * own alignment leans the same way, unmoved, already stands there. Any other is held to its * box, the shape's room less its width set at its alignment's share and moved by its offset. - * What is not measured is named ({@link #reportTheLayersOfItsPanel}).

+ * In a shape composed in a table cell only a paragraph has such a box. What is not measured + * is named ({@link #reportTheLayersOfItsPanel}).

*/ private void holdInItsPanel(ShapeContainerNode shape, DocumentNode node) { com.demcha.compose.document.node.LayerStackNode.Layer layer = layerOf(shape, node); - if (layer == null || !(node instanceof ParagraphNode paragraph) || !writtenInItsPanel(layer) - || standsAsItsLayer(paragraph, layer)) { + if (layer == null || !writtenInItsPanel(layer) || standsAsItsLayer(node, layer)) { return; } - double[] slot = slotInItsPanel(shape, layer, paragraph); + double[] slot = slotInItsPanel(shape, layer, node); if (slot != null) { - holdIn(paragraph, slot[0] + paragraph.margin().left(), slot[1] - paragraph.margin().horizontal(), 0, + holdIn(node, slot[0] + node.margin().left(), slot[1] - node.margin().horizontal(), 0, roomInItsPanel(shape)); } } @@ -8892,27 +9007,47 @@ private static double roomInItsPanel(ShapeContainerNode shape) { } /** - * Whether a paragraph stands where its layer sets it without being held in: unmoved across, - * and its own alignment leaning the way its layer's does. + * Whether a layer stands where its shape sets it without being held in: unmoved across, and a + * paragraph's own alignment leaning the way its layer's does, or any other block set at the + * shape's left. + * + *

The page lays a layer out in the room the shape leaves it and reports the box it comes to, + * no wider than what it holds: a card's section set at the top left, held in to that box, + * left its text no room past the page's longest line, and the cell's right border took the + * rest — Word broke the line a word early.

*/ - private static boolean standsAsItsLayer(ParagraphNode paragraph, + private static boolean standsAsItsLayer(DocumentNode node, com.demcha.compose.document.node.LayerStackNode.Layer layer) { if (Math.abs(layer.offsetX()) > 0.5) { return false; } double share = shareAcross(layer.align()); + if (!(node instanceof ParagraphNode paragraph)) { + return share == 0; + } return share == 0 ? paragraph.align() != TextAlign.CENTER && paragraph.align() != TextAlign.RIGHT : share == 1 ? paragraph.align() == TextAlign.RIGHT : paragraph.align() == TextAlign.CENTER; } /** - * Where a paragraph layer's box stands across in the room its panel leaves it, as the page sets - * it: {@code {left, width}}, its margins in, the width read off the lines its table laid out for - * it; {@code null} where none stand in the cell. + * Where a layer's box stands across in the room its panel leaves it, as the page sets it: + * {@code {left, width}}, its margins in. A shape the layout places gives any layer the box it + * placed there, from the outline's left edge; in one composed in a table cell, a paragraph's + * width is read off the lines its table laid out for it. {@code null} where neither is found. */ private double[] slotInItsPanel(ShapeContainerNode shape, com.demcha.compose.document.node.LayerStackNode.Layer layer, - ParagraphNode paragraph) { + DocumentNode node) { + com.demcha.compose.document.layout.PlacedNode box = layout.placement(shape); + if (box != null) { + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); + return placed == null ? null + : new double[]{placed.placementX() - node.margin().left() - (box.placementX() + shape.padding().left()), + placed.placementWidth() + node.margin().horizontal()}; + } + if (!(node instanceof ParagraphNode paragraph)) { + return null; + } java.util.Optional lines = layout.firstTextBox(paragraph); if (lines.isEmpty() || !standsInTheComposedCell(lines.get())) { return null; @@ -8957,15 +9092,15 @@ private void reportTheLayersOfItsPanel(ShapeContainerNode shape) { + (layer.offsetY() > 0 ? "down" : "up") + ", which its cell does not write"); } boolean across = shareAcross(layer.align()) > 0 || Math.abs(layer.offsetX()) > 0.5; - double[] slot = layer.node() instanceof ParagraphNode paragraph && across - && !standsAsItsLayer(paragraph, layer) ? slotInItsPanel(shape, layer, paragraph) : null; + boolean standsAsIs = standsAsItsLayer(layer.node(), layer); + double[] slot = across && !standsAsIs ? slotInItsPanel(shape, layer, layer.node()) : null; if (slot != null && slot[0] + slot[1] - roomInItsPanel(shape) > 0.5) { lost.add("the page sets it " + pointsOf(slot[0] + slot[1] - roomInItsPanel(shape)) + "pt past the shape's right edge, where its cell's text ends"); - } else if (across && slot == null - && !(layer.node() instanceof ParagraphNode paragraph && standsAsItsLayer(paragraph, layer))) { - lost.add("the page sets it across by a box the layout does not give where it is composed in a " - + "table cell (" + layer.node().nodeKind() + "), and it is written from the shape's left"); + } else if (across && slot == null && !standsAsIs) { + lost.add("the page sets it across by a box the layout does not give" + + (layout.placement(shape) == null ? " where it is composed in a table cell" : "") + + " (" + layer.node().nodeKind() + "), and it is written from the shape's left"); } if (!lost.isEmpty()) { report.add(DocxExportReport.Severity.APPROXIMATED, "layer offset", layout.pathOf(layer.node()), @@ -9070,6 +9205,14 @@ private void seatTheLabelByItsLine(ParagraphNode label, com.demcha.compose.docum stackedLineHeights.put(label, new DocxStackedLines.Line(seated, (seated - lineHeight) / 2, 0)); } + /** Whether a shape's outline rounds any of its corners. */ + private static boolean roundsItsCorners(ShapeContainerNode shape) { + return shape.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectangle round + && round.cornerRadius() > 0 + || shape.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectanglePerCorner corners + && hasRadius(corners.corners()); + } + private static boolean hasRadius(com.demcha.compose.document.style.DocumentCornerRadius radius) { return radius != null && !radius.isZero(); } @@ -9465,8 +9608,20 @@ private static long textWeight(String text) { * shift chip of a rota, a filled pill with its hours inside, came out as bare text. A * rectangle or a rounded rectangle, filled or outlined, is a panel as a card is, with its * text inside a cell painted its colour, and its corners squared.

+ * + *

So is one holding text that the layout places in the body, outside any cell and over + * nothing: drawn there at its size, with its layers written in the flow, its text took the + * flow's width and ran past the outline, and the outline kept its height when text was added + * to it. Measured in Word 16 and LibreOffice 24.2, a note the page wraps over three lines inside + * a box 220pt wide ran over two, 95pt past the box, and the paragraph under it stood 12pt high, + * over the box. A panel holds its text inside the box and grows with it. One whose single + * written layer the page sets beside drawing is written as a band (see + * {@link #writeOverlayBand}); one holding no text — a picture's frame — is drawn, as it was.

+ * + * @param overlaysRound how many layer stacks, shape containers and canvases the writer is + * inside, the shape's own not counted */ - private boolean writtenAsAPanel(ShapeContainerNode node) { + private boolean writtenAsAPanel(ShapeContainerNode node, int overlaysRound) { boolean boxed = node.outline() instanceof com.demcha.compose.document.style.ShapeOutline.Rectangle || node.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectangle || node.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectanglePerCorner; @@ -9475,7 +9630,22 @@ private boolean writtenAsAPanel(ShapeContainerNode node) { // with nothing inside is a sliver, or, held to the tile's height, a row taller than the // page's on every line: an invoice ran onto a second page. It stays unwritten. One holding // an empty container or a spacer is a panel a hairline tall, its colour still there. - return boxed && painted && !onlyDrawn(node) && composedInACell(node); + return boxed && painted && !onlyDrawn(node) + && (composedInACell(node) || holdsWrappedText(node) && placedInTheBody(node, overlaysRound)); + } + + /** + * Whether a shape container is being written where the layout places it in the body, on one + * page, taking room there: outside any table cell, and inside no layer stack, shape container + * or canvas. One whose margins take back its height takes none, and a panel would take its + * outline's. + * + * @param overlaysRound how many of those the writer is inside, the shape's own not counted + */ + private boolean placedInTheBody(ShapeContainerNode node, int overlaysRound) { + com.demcha.compose.document.layout.PlacedNode box = layout.placement(node); + return currentCell == null && overlaysRound == 0 && slotDepth == 0 && layout.onOnePage(node) + && node.margin().top() + box.placementHeight() + node.margin().bottom() > 0.5; } /** @@ -9553,11 +9723,7 @@ private void warnContainerRadiusDropped(DocumentNode node) { ? 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 - || shape.outline() instanceof com.demcha.compose.document.style.ShapeOutline.RoundedRectanglePerCorner corners - && hasRadius(corners.corners())); + : node instanceof ShapeContainerNode shape && roundsItsCorners(shape); if (rounded) { report.add(DocxExportReport.Severity.APPROXIMATED, "corner radius", layout.pathOf(node), "a Word table cell is rectangular, so the panel " + keptOf(node) + "loses its rounded corners"); @@ -11059,12 +11225,16 @@ private double lineTopAbove(double lineTop, ParagraphNode paragraph) { */ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, DocxLayerColumns.Band band) throws Exception { - if (writtenAsAPanel(node)) { + // The overlays round it, its own not counted: dispatched, its own overlay is counted in + // overlayDepth already; written as a band, from writeNodeContentOf, it is not yet. A band stays + // a band all the same (the guard below), but the count is not what keeps it one. + int overlaysRound = band == null ? overlayDepth - 1 : overlayDepth; + if ((band == null || composedInACell(node)) && writtenAsAPanel(node, overlaysRound)) { warnContainerRadiusDropped(node); if (node.transform() != null && !node.transform().isIdentity()) { report.add(DocxExportReport.Severity.APPROXIMATED, "shape container", layout.pathOf(node), - "written as a panel in its table cell, which carries no transform, so it stands " - + "upright at its size"); + "written as a panel" + (composedInACell(node) ? " in its table cell" : "") + + ", which carries no transform, so it stands upright at its size"); } reportTheLayersOfItsPanel(node); writePanel(document, node, new ContainerPaint(node.fillColor(), bordersOf(null, node.stroke()))); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java new file mode 100644 index 000000000..6f7bb4dbe --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java @@ -0,0 +1,416 @@ +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.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTransform; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STHeightRule; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A painted rectangle or rounded rectangle holding text, placed in the body, is written as a + * panel: a table of one cell, its outline's size, its fill and border on the cell, its layers + * inside it where the page sets them. + * + *

Drawn at its size with its layers written in the flow, its text took the flow's width and + * ran past the outline, the block under it stood over the outline, and the outline kept its + * height when text was added to it in an editor.

+ */ +class DocxBodyShapePanelTest { + + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148); + private static final DocumentStroke BORDER = DocumentStroke.of(INK, 1); + private static final String NOTE = "A note centred in a box narrower than the page: the page wraps it over " + + "three lines inside the box, each line held within its outline."; + private static final String LONG = "Clipped text in a rounded box too short to hold it. The page sets it from " + + "the top of the box and cuts it at the outline, so the lines that run past " + + "the foot of the box are not seen. Only the lines the box holds are read, " + + "and the box stays as tall as it was made, whatever it is given to hold."; + + @Test + void aPaintedRoundedBoxHoldingTextInTheBodyIsWrittenAsAPanelItsOutlinesSize() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(narrowBox().build()) + .addParagraph("Below")); + + XWPFTable panel = onlyTable(exported.document()); + XWPFTableCell cell = panel.getRow(0).getCell(0); + assertThat(cell.getText()).contains("A note centred"); + assertThat(cell.getColor()).isEqualToIgnoringCase("EEF3F9"); + // Its outline's width, half its right border past it and the editor's point of slack. + assertThat(widthOf(cell)).isEqualTo(Math.round((220 + 0.5 + 1) * 20)); + // Its outline's height, the two borders Word adds round a least height taken off. + assertThat(heightOf(panel.getRow(0))).isEqualTo((90 - 2) * 20); + assertThat(ruleOf(panel.getRow(0))).isEqualTo(STHeightRule.AT_LEAST); + assertThat(panel.getRow(0).isCantSplitRow()).as("the layout moves a shape whole").isTrue(); + assertThat(cell.getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.CENTER); + // The panel paints the outline: nothing is drawn for it. + assertThat(exported.body()).doesNotContain(" page + .addParagraph("Above") + .add(narrowBox().build()) + .addParagraph("Below")); + + PlacedNode box = exported.placed("Box"); + PlacedNode note = exported.placed("Note"); + XWPFParagraph written = onlyTable(exported.document()).getRow(0).getCell(0).getParagraphs().get(0); + // The page centres the note's block in the box; its text is set left in it. + assertThat(note.placementX() - box.placementX()).isGreaterThan(1); + assertThat(indentOf(written, true)).isEqualTo(Math.round((note.placementX() - box.placementX()) * 20)); + // Held from the cell's edges, not the flow's: its right side ends at the outline, the room to + // spare for an editor's wider text taken from what is left of it there. + assertThat(indentOf(written, false)).isZero(); + } + + @Test + void aBlockSetAtTheOutlinesLeftKeepsTheRoomThePageLaidItOutIn() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(card(ClipPolicy.CLIP_PATH).build()) + .addParagraph("Below")); + + // Its section reports the box of its longest line; held in to it, an editor setting the text + // a little wider breaks a line a word early. Only the section's padding holds it in. + List written = onlyTable(exported.document()).getRow(0).getCell(0).getParagraphs(); + assertThat(written).hasSize(2); + for (XWPFParagraph paragraph : written) { + assertThat(indentOf(paragraph, false)).as(paragraph.getText()).isEqualTo(12 * 20); + } + } + + @Test + void aShapeSetFromItsTopTakesItsTopBorderOutOfTheSpaceAboveItsFirstLine() throws Exception { + Exported card = export(page -> page + .addParagraph("Above") + .add(card(ClipPolicy.CLIP_PATH).build()) + .addParagraph("Below")); + Exported centred = export(page -> page + .addParagraph("Above") + .add(narrowBox().build()) + .addParagraph("Below")); + + // The page sets the card's heading 12pt under the outline's top; Word sets a cell's content + // under the whole of its 1pt top border. + XWPFParagraph heading = onlyTable(card.document()).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(spacingBefore(heading)).isEqualTo((12 - 1) * 20); + assertThat(card.report().bySubject()).doesNotContainKey("ShapeContainerNode"); + // A layer the cell centres stands between both borders, as the page centres it. + XWPFParagraph note = onlyTable(centred.document()).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(spacingBefore(note)).isZero(); + } + + @Test + void aClipThatCutsItsTextHoldsThePanelExactlyAtItsOutline() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(clippedBox(ClipPolicy.CLIP_PATH).build()) + .addParagraph("Below")); + + XWPFTableRow row = onlyTable(exported.document()).getRow(0); + // Word and LibreOffice add one border to an exact height: the panel stands its outline's 60pt. + assertThat(ruleOf(row)).isEqualTo(STHeightRule.EXACT); + assertThat(heightOf(row)).isEqualTo((60 - 1) * 20); + assertThat(detailOf(exported.report(), "clipped shape container")) + .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its layers " + + "paint past the foot of the panel, as the page cuts it at the outline, but square, not " + + "round its rounded corners, and text added to it in an editor is hidden too, the panel no " + + "taller"); + // Its text is set from the outline's top, with nothing above its first line to take the border; + // the cell sets it from its top as the page does, though the layer is centred. + assertThat(detailOf(exported.report(), "ShapeContainerNode")) + .isEqualTo("its content stands 1pt lower than the page sets it: Word sets it below the whole of its " + + "top border, where the page sets its layers from its outline's top, and nothing above " + + "its first line takes the border"); + assertThat(row.getCell(0).getVerticalAlignment()).isNull(); + // The paragraph under it is measured against lines written inside the box, as the page sets them. + assertThat(exported.report().bySubject()).doesNotContainKey("space above"); + } + + @Test + void aSquareBoxsClipIsNamedWithoutRoundCorners() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").rectangle(200, 60).fillColor(SURFACE).stroke(BORDER) + .clipPolicy(ClipPolicy.CLIP_BOUNDS).center(new ParagraphBuilder().text(LONG).build()).build()) + .addParagraph("Below")); + + assertThat(ruleOf(onlyTable(exported.document()).getRow(0))).isEqualTo(STHeightRule.EXACT); + assertThat(detailOf(exported.report(), "clipped shape container")) + .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its layers " + + "paint past the foot of the panel, as the page cuts it at the outline, and text added to " + + "it in an editor is hidden too, the panel no taller"); + assertThat(exported.report().bySubject()).doesNotContainKey("corner radius"); + } + + @Test + void aClipThatCutsOnlyAboveTheFootLetsThePanelGrowAndKeepsItsNote() throws Exception { + // A line moved up past the outline's top: the page cuts its top, and nothing runs past the foot. + Exported exported = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(200, 60, 12).fillColor(SURFACE).stroke(BORDER) + .clipPolicy(ClipPolicy.CLIP_PATH) + .position(new ParagraphBuilder().text("Moved up past the top").build(), 10, -8, + LayerAlign.TOP_LEFT) + .build()) + .addParagraph("Below")); + + assertThat(ruleOf(onlyTable(exported.document()).getRow(0))).isEqualTo(STHeightRule.AT_LEAST); + assertThat(detailOf(exported.report(), "clipped shape container")) + .isEqualTo("its clip is not in the file, so what its layers paint past its outline is written whole"); + } + + @Test + void aBoxSettingItsOneWrittenLayerBesideDrawingStaysABand() throws Exception { + // A section heading beside its badge: the band writes the title on the badge's line. + Exported exported = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(300, 30, 6).fillColor(SURFACE) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .centerLeft(new ShapeBuilder().size(24, 24).fillColor(INK).build()) + .position(new ParagraphBuilder().text("EXPERIENCE").build(), 34, 0, LayerAlign.CENTER_LEFT) + .build()) + .addParagraph("Below")); + + assertThat(exported.document().getTables()).isEmpty(); + assertThat(exported.document().getParagraphs()).extracting(XWPFParagraph::getText).contains("EXPERIENCE"); + // Its outline is drawn, its corners round. + assertThat(exported.body()).contains(" page.addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(60, 60, 8).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_BOUNDS) + .center(new com.demcha.compose.document.dsl.ImageBuilder() + .source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png())) + .size(60, 70).fitMode(com.demcha.compose.document.image.DocumentImageFitMode.CONTAIN) + .build()) + .build()) + .addParagraph("Below")); + // In a painted section's cell and a row's: written in that cell, as before. + Exported inAPanel = export(page -> page.addParagraph("Above") + .addSection(section -> section.fillColor(DocumentColor.rgb(250, 250, 250)).add(narrowBox().build())) + .addParagraph("Below")); + Exported inARow = export(page -> page.addParagraph("Above") + .addRow(row -> row.add(narrowBox().build()).add(new ParagraphBuilder().text("Beside").build())) + .addParagraph("Below")); + // Its margins take back its height, its text linked, so the page lays it over the flow and + // gives it no room: not a panel, which would take its outline's. + Exported noRoom = export(page -> page.addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(220, 40, 10).fillColor(SURFACE) + .margin(new DocumentInsets(0, 0, -40, 0)) + .center(new ParagraphBuilder().text("Linked").linkTo("target").build()).build()) + .addParagraph(p -> p.text("Below").anchor("target"))); + + assertThat(framed.document().getTables()).isEmpty(); + assertThat(onlyTable(inAPanel.document()).getRow(0).getCell(0).getTables()).isEmpty(); + assertThat(onlyTable(inARow.document()).getRow(0).getCell(0).getTables()).isEmpty(); + assertThat(noRoom.document().getTables()).isEmpty(); + } + + private static byte[] png() { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(100, 20, + java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException(failure); + } + } + + @Test + void aBoxWithNoClipOrNothingToCutGrowsWithItsText() throws Exception { + Exported card = export(page -> page + .addParagraph("Above") + .add(card(ClipPolicy.CLIP_PATH).build()) + .addParagraph("Below")); + Exported overflowing = export(page -> page + .addParagraph("Above") + .add(clippedBox(ClipPolicy.OVERFLOW_VISIBLE).build()) + .addParagraph("Below")); + + assertThat(ruleOf(onlyTable(card.document()).getRow(0))).isEqualTo(STHeightRule.AT_LEAST); + assertThat(card.report().bySubject()).doesNotContainKeys("clipped shape container", "shape container"); + // Unclipped, its text runs past the outline over what follows on the page; the panel holds it. + assertThat(ruleOf(onlyTable(overflowing.document()).getRow(0))).isEqualTo(STHeightRule.AT_LEAST); + assertThat(overflowing.report().bySubject()).doesNotContainKey("clipped shape container"); + assertThat(detailOf(overflowing.report(), "shape container")) + .matches("its layers run [0-9.]+pt past its outline's foot: written as a panel, it grows to hold " + + "them, and what follows stands up to that much lower"); + } + + @Test + void itsPaddingStandsRoundTheOutline() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(narrowBox().padding(DocumentInsets.of(10)).build()) + .addParagraph("Below")); + + XWPFTable panel = onlyTable(exported.document()); + // The page pads round the outline: the table is the outline's size, set in by the padding. + assertThat(widthOf(panel.getRow(0).getCell(0))).isEqualTo(Math.round((220 + 0.5 + 1) * 20)); + assertThat(heightOf(panel.getRow(0))).isEqualTo((90 - 2) * 20); + assertThat(twipsOf(panel.getCTTbl().getTblPr().getTblInd().getW())).isEqualTo(10 * 20); + // Its layer is held in from the outline, inside the padding, not from the padded box. + PlacedNode box = exported.placed("Box"); + PlacedNode note = exported.placed("Note"); + XWPFParagraph written = panel.getRow(0).getCell(0).getParagraphs().get(0); + assertThat(indentOf(written, true)).isEqualTo(Math.round((note.placementX() - box.placementX() - 10) * 20)); + } + + @Test + void aTurnedBoxNamesTheTransformItsPanelDoesNotCarry() throws Exception { + Exported exported = export(page -> page + .addParagraph("Above") + .add(narrowBox().transform(DocumentTransform.rotate(10)).build()) + .addParagraph("Below")); + + assertThat(detailOf(exported.report(), "shape container")) + .isEqualTo("written as a panel, which carries no transform, so it stands upright at its size"); + } + + @Test + void anEllipseAnUnpaintedBoxATileAndABoxLaidOverAnotherAreNotPanels() throws Exception { + Exported ellipse = export(page -> page.addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").ellipse(220, 90).fillColor(SURFACE) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).center(note()).build()) + .addParagraph("Below")); + Exported unpainted = export(page -> page.addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(220, 90, 10) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).center(note()).build()) + .addParagraph("Below")); + Exported tile = export(page -> page.addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(40, 40, 6).fillColor(SURFACE) + .center(new ShapeBuilder().size(20, 20).fillColor(INK).build()).build()) + .addParagraph("Below")); + Exported stacked = export(page -> page.addParagraph("Above") + .addLayerStack(stack -> stack.layer(narrowBox().build(), LayerAlign.TOP_LEFT, 0) + .layer(new ParagraphBuilder().text("Over").build(), LayerAlign.TOP_RIGHT, 1)) + .addParagraph("Below")); + + assertThat(ellipse.document().getTables()).isEmpty(); + assertThat(ellipse.report().bySubject()).containsKey("shape container"); + assertThat(unpainted.document().getTables()).isEmpty(); + assertThat(tile.document().getTables()).isEmpty(); + assertThat(stacked.document().getTables()).isEmpty(); + } + + private static ShapeContainerBuilder narrowBox() { + return new ShapeContainerBuilder().name("Box").roundedRect(220, 90, 10).fillColor(SURFACE).stroke(BORDER) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).center(note()); + } + + private static ShapeContainerBuilder clippedBox(ClipPolicy clip) { + return new ShapeContainerBuilder().name("Box").roundedRect(200, 60, 12).fillColor(SURFACE).stroke(BORDER) + .clipPolicy(clip).center(new ParagraphBuilder().text(LONG).build()); + } + + private static ShapeContainerBuilder card(ClipPolicy clip) { + return new ShapeContainerBuilder().name("Box").roundedRect(328, 80, 12).fillColor(SURFACE).stroke(BORDER) + .clipPolicy(clip) + // Lines far shorter than the card: the section's box is theirs, not the card's. + .topLeft(new SectionBuilder().padding(DocumentInsets.of(12)).spacing(4) + .addParagraph(p -> p.text("A rounded card")) + .addParagraph(p -> p.text("Its text is short.")) + .build()); + } + + private static DocumentNode note() { + return new ParagraphBuilder().name("Note").text(NOTE).build(); + } + + private record Exported(XWPFDocument document, String body, DocxExportReport report, List placements) { + PlacedNode placed(String name) { + return placements.stream().filter(node -> name.equals(node.semanticName())).findFirst().orElseThrow(); + } + } + + private static Exported export(Consumer content) throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(36)).create()) { + session.pageFlow(content::accept); + List placements = session.layoutGraph().nodes(); + XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend(report::set)))); + return new Exported(document, document.getDocument().getBody().xmlText(), report.get(), placements); + } + } + + private static XWPFTable onlyTable(XWPFDocument document) { + assertThat(document.getTables()).hasSize(1); + return document.getTables().get(0); + } + + private static String detailOf(DocxExportReport report, String subject) { + List notes = report.bySubject().get(subject); + assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1); + return notes.get(0).detail(); + } + + private static long widthOf(XWPFTableCell cell) { + return twipsOf(cell.getCTTc().getTcPr().getTcW().getW()); + } + + private static long heightOf(XWPFTableRow row) { + return twipsOf(row.getCtRow().getTrPr().getTrHeightArray(0).getVal()); + } + + private static STHeightRule.Enum ruleOf(XWPFTableRow row) { + return row.getCtRow().getTrPr().getTrHeightArray(0).getHRule(); + } + + private static long indentOf(XWPFParagraph paragraph, boolean left) { + CTPPr properties = paragraph.getCTP().getPPr(); + if (properties == null || !properties.isSetInd()) { + return 0; + } + Object twips = left ? properties.getInd().getLeft() : properties.getInd().getRight(); + return twips == null ? 0 : twipsOf(twips); + } + + private static long spacingBefore(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().getPPr(); + return properties == null || !properties.isSetSpacing() || properties.getSpacing().getBefore() == null + ? 0 : twipsOf(properties.getSpacing().getBefore()); + } + + private static long twipsOf(Object value) { + return value instanceof BigInteger big ? big.longValue() : Long.parseLong(String.valueOf(value)); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java index b9bff869d..414e3f638 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java @@ -167,7 +167,8 @@ void aDrawingComposedInATableCellNamesWhatItGoesWithout() throws Exception { @Test void aDrawnContainerWithCornersOfDifferentRadiiSaysTheyAreDrawnAtTheLargest() throws Exception { - DocxExportReport report = reportOf(page -> page.add(cornered(SURFACE, null))); + // A tile holding only drawing is drawn; one holding text is a panel, its corners squared. + DocxExportReport report = reportOf(page -> page.add(cornered(SURFACE, null, dot()))); assertThat(notesOn(report, "ShapeContainerNode")) .filteredOn(detail -> detail.startsWith("drawn as a shape")) @@ -179,7 +180,7 @@ void aContainerWithEqualCornersSaysNothingOfThem() throws Exception { DocxExportReport report = reportOf(page -> page .add(new com.demcha.compose.document.dsl.ShapeContainerBuilder() .roundedRect(120, 40, DocumentCornerRadius.of(6)).fillColor(SURFACE) - .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Even").build()).build())); + .layer(dot()).build())); assertThat(notesOn(report, "ShapeContainerNode")).anyMatch(detail -> detail.startsWith("drawn as a shape")) .noneMatch(detail -> detail.contains("corners")); @@ -460,9 +461,9 @@ private static com.demcha.compose.document.node.DocumentNode laidOverTheFlow( .build(); } - /** A shape container rounding its corners by two radii, painted as given, holding a line of text. */ - private static com.demcha.compose.document.node.DocumentNode cornered(DocumentColor fill, DocumentStroke stroke) { - return cornered(fill, stroke, new com.demcha.compose.document.dsl.ParagraphBuilder().text("Cornered").build()); + /** A dot, all a tile holds: drawing, so the tile round it is drawn too. */ + private static com.demcha.compose.document.node.DocumentNode dot() { + return new ShapeBuilder().size(12, 12).fillColor(INK).build(); } /** A shape container rounding its corners by two radii, painted as given, holding one layer. */ diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java index bbca8e8d2..5924835dc 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java @@ -289,9 +289,10 @@ void aTurnedContainerWhoseOutlineDrawsNothingNamesItsTransform() throws Exceptio .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).transform(DocumentTransform.rotate(30)) .center(new ParagraphBuilder().text("Turned").build()).build()) .addParagraph("Below")); + // An ellipse's outline is drawn; a painted box holding text is a panel (DocxBodyShapePanelTest). DocxExportReport painted = reportOf(page -> page .addParagraph("Above") - .add(new ShapeContainerBuilder().name("Turned").rectangle(120, 30).fillColor(SURFACE) + .add(new ShapeContainerBuilder().name("Turned").ellipse(120, 30).fillColor(SURFACE) .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).transform(DocumentTransform.rotate(30)) .center(new ParagraphBuilder().text("Turned").build()).build()) .addParagraph("Below")); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java index 04ebcb311..7ef917df7 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java @@ -911,7 +911,8 @@ void aContainerWhoseLayersAreAllDrawnIsNotSaidToWriteThemInline() throws Excepti .addLayerStack(stack -> stack.name("Stack") .layer(tile("Tile"), LayerAlign.TOP_LEFT) .layer(new ParagraphBuilder().text("Beside the tile").build(), LayerAlign.TOP_RIGHT)) - .add(new ShapeContainerBuilder().name("Note").rectangle(160, 40).fillColor(ACCENT) + // An ellipse's outline is drawn; a painted box holding text is a panel. + .add(new ShapeContainerBuilder().name("Note").ellipse(160, 40).fillColor(ACCENT) .center(new ParagraphBuilder().text("A line too long for a badge").build()).build()) .addContainer(band -> band.name("Header").rectangle(300, 30) .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) 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 e261916b4..1b086933b 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 @@ -490,18 +490,21 @@ record Entry(Fate fate, String note) { node(ShapeContainerNode.class, "name:INERT", "outline:GAP:" + PANEL_WIDER_THAN_ITS_CELL + "; drawn as a shape, corners whose radii differ once " + "held to half its shorter side " - + "at the largest of them; a box " - + "written as a panel in a table cell, its corners square; one no shape draws, or composed in a " - + "table cell with nowhere to draw it, dropped", + + "at the largest of them; a painted box holding text " + + "written as a panel, in a table cell or placed in the body, its corners square; one no shape " + + "draws, or composed in a table cell with nowhere to draw it, dropped", "layers:GAP:" + DRAWING_OVER_TEXT + "; written inline one after another in source order where it " + "writes them in the " + "flow; drawn where the page puts them, unreported, where they are all drawing, stacked as the " + "page paints them among those behind the text and among those in front of it; a panel's in its " - + "cell, at its first written layer's height, a paragraph held across to its box, and named where " + + "cell, at its first written layer's height, held across to its box — a paragraph's, and in the " + + "body any layer set away from the shape's left — and named where " + "one follows the first, is moved down or up, is set past the shape's side, or is set across by " - + "a box the layout does not give; one set left of its content in a table cell further than the " + + "a box the layout does not give, or where a body panel's run past its outline's foot unclipped; " + + "one set left of its content in a table cell further than the " + "indents round it give", - "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table", + "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table; a " + + "body panel's cutting past its outline's foot written as the panel's exact height, square", "fillColor:REPORTED:" + RIGHT_END + "; " + LEFT_EDGE + "; its translucency, as a panel's, flattened " + "against the colour under it; drawn, its alpha is written; any other is written", "stroke:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; its translucency, as a " @@ -510,8 +513,8 @@ record Entry(Fate fate, String note) { + "panel's fill or " + "the colour under it, and as a panel's, " + BORDER_WIDTH + "; drawn, its alpha and its width " + "are written; any other is written", - "padding:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + LEFT_EDGE + "; written as a panel's in a " - + "table cell, round its outline as a margin " + "padding:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + LEFT_EDGE + "; written as a panel's, in a " + + "table cell or the body, round its outline as a margin " + "is, and below it, where its table ends its cell and the cell sets what it holds at its middle or " + "foot, as far as Word does not count it there; any other is written", "margin:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + LEFT_EDGE + "; written as a panel's in a " diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java index a320cfbe1..12822504a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java @@ -293,11 +293,13 @@ class DocxValueFieldLedgerTest { value(com.demcha.compose.document.style.DocumentTransform.class, "rotationDegrees:REPORTED", "scaleX:REPORTED", "scaleY:REPORTED"); value(com.demcha.compose.document.style.ClipPolicy.class, - "CLIP_BOUNDS:REPORTED:where it cuts what its layers paint past its box, which is written whole; " + "CLIP_BOUNDS:REPORTED:where it cuts what its layers paint past its box, which is written whole, but " + + "for a shape written as a panel in the body, cut square at its foot by its panel's exact height; " + "composed in a table cell, on its table; with no layout, wherever it is set; a canvas's, which the " + "page does not apply, cuts nothing", "CLIP_PATH:REPORTED:where it cuts what its layers paint past its outline, which is written whole, a " - + "picture filling the ellipse that clips it cropped to it in the file; composed in a table cell, on " + + "picture filling the ellipse that clips it cropped to it in the file, and a shape written as a " + + "panel in the body cut square at its foot by its panel's exact height; composed in a table cell, on " + "its table; with no layout, wherever it is set; a canvas's cuts nothing", "OVERFLOW_VISIBLE:INERT:it cuts nothing, on the page or in the Word file"); value(com.demcha.compose.document.style.DocumentColor.class, From 508ed30511f367ff0f7e696727e8e40740b71867 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 26 Oct 2026 21:23:40 +0000 Subject: [PATCH 2/4] fix(docx): hold a body panel's fixed-width layer to its width A block of a fixed width set at a body panel's left was left at the cell's whole width, as a block the page lays out in the whole room is: the page wraps it at its own width, and the inline path held it there. It is held to its width again. The first layer's height a panel's layer notes compare against is the one its cell sets it at: the top for a layer taller than the outline. Tests pin the outline's top and foot read inside a padded shape, and a fixed-width block at the left. --- CHANGELOG.md | 9 +- docs/recipes/docx-export.md | 8 +- .../semantic/docx/DocxSemanticBackend.java | 20 +++-- .../semantic/docx/DocxBodyShapePanelTest.java | 87 ++++++++++++++++--- 4 files changed, 94 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4eccac6..7cf3d91de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,11 @@ follow semantic versioning; release dates are ISO 8601. shape whole. - Its layers stand in the cell at the height its first written layer is set — at the top where the page starts one taller than the outline there — and across where the layout places them in - the outline; a block set at the outline's left keeps the room the page laid it out in. Word sets a cell's content below its whole top border, where the page - sets the layers from the outline's top, under the border: a shape set from its top takes the - border out of the space above its first line, and the report names one with nothing there to - take it. + the outline; a block set at the outline's left keeps the room the page laid it out in, the + whole room or a width it is given. Word sets a cell's content below its whole top border, where + the page sets the layers from the outline's top, under the border: a shape set from its top + takes the border out of the space above its first line, and the report names one with nothing + there to take it. - Its row is held at least the outline's height, so the panel grows with text an edit adds. A clip that cuts what its layers paint past the outline's foot holds the row at exactly the outline's height instead, less the one border Word and LibreOffice add to an exact height: diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 30d28c7d0..c42c0a1ec 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1173,14 +1173,14 @@ recolour what is under it in Word and it keeps the colour it was flattened to. rectangle, filled or outlined, holding text is written as a panel, as a section with a fill is: a table of one cell, its outline's size, the fill and border on the cell and its padding round it, its row kept on one page - as the layout moves the shape whole. So it is composed in a table's cell, - and placed in the body, outside any cell, laid over nothing and given room - in the flow. Its corners are square, and a turned one stands upright; the + as the layout moves the shape whole. So is one composed in a table's cell, + and one placed in the body, outside any cell, laid over nothing and given + room in the flow. Its corners are square, and a turned one stands upright; the report names both. Its layers stand in the cell at the height its first written layer is set — top, middle or foot, the top where the page starts a layer taller than the outline there — and across where the layout places them, a block set at the outline's left keeping the room the page laid it out - in. Word sets a cell's content below its whole top border, where the page + in — the whole room, or a width it is given. Word sets a cell's content below its whole top border, where the page sets the layers from the outline's top, under the border: a shape set from its top takes the border out of the space above its first line, and the report names one with nothing there to take it. The row is held at 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 2d1118af7..556309b8b 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 @@ -9009,12 +9009,13 @@ private static double roomInItsPanel(ShapeContainerNode shape) { /** * Whether a layer stands where its shape sets it without being held in: unmoved across, and a * paragraph's own alignment leaning the way its layer's does, or any other block set at the - * shape's left. + * shape's left at the width it comes to. * - *

The page lays a layer out in the room the shape leaves it and reports the box it comes to, - * no wider than what it holds: a card's section set at the top left, held in to that box, - * left its text no room past the page's longest line, and the cell's right border took the - * rest — Word broke the line a word early.

+ *

The page lays such a block out in the room the shape leaves it and reports the box it + * comes to, no wider than what it holds: a card's section set at the top left, held in to that + * box, left its text no room past the page's longest line, and the cell's right border took the + * rest — Word broke the line a word early. A block given a fixed width is laid out in that width, + * and is held to it.

*/ private static boolean standsAsItsLayer(DocumentNode node, com.demcha.compose.document.node.LayerStackNode.Layer layer) { @@ -9023,7 +9024,7 @@ private static boolean standsAsItsLayer(DocumentNode node, } double share = shareAcross(layer.align()); if (!(node instanceof ParagraphNode paragraph)) { - return share == 0; + return share == 0 && !node.flowWidth().isFixed(); } return share == 0 ? paragraph.align() != TextAlign.CENTER && paragraph.align() != TextAlign.RIGHT : share == 1 ? paragraph.align() == TextAlign.RIGHT @@ -9080,7 +9081,9 @@ private void reportTheLayersOfItsPanel(ShapeContainerNode shape) { List lost = new ArrayList<>(); double share = shareDown(layer.align()); if (Double.isNaN(down)) { - down = share; + // Where its cell sets what it holds (verticalOfItsLayers): the top for a first layer + // the page starts at the outline's top, taller than it, whatever its alignment. + down = setFromItsTop(shape) ? 0 : share; } else { lost.add(share == down ? "the page sets it at the height of the layers before it, and its cell writes it under them" @@ -11215,7 +11218,8 @@ private double lineTopAbove(double lineTop, ParagraphNode paragraph) { } /** - * Writes a shape container's layers, drawing its outline where the page draws it. A + * Writes a shape container: as a panel where it is one ({@link #writtenAsAPanel}), its + * layers in its cell; otherwise its layers, drawing its outline where the page draws it. A * container whose one written layer is laid beside drawing is written as a band (see * {@link #writeOverlayBand}, {@link #writesOneLayer}): a section title beside its badge * stands where the page places it in the header, with the page's space above and below it. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java index 6f7bb4dbe..6c130d458 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java @@ -2,11 +2,14 @@ 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.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.dsl.ShapeBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.image.DocumentImageFitMode; import com.demcha.compose.document.layout.PlacedNode; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerAlign; @@ -24,13 +27,19 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STHeightRule; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; import java.math.BigInteger; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; /** * A painted rectangle or rounded rectangle holding text, placed in the body, is written as a @@ -111,6 +120,25 @@ void aBlockSetAtTheOutlinesLeftKeepsTheRoomThePageLaidItOutIn() throws Exception } } + @Test + void aBlockOfAFixedWidthSetAtTheOutlinesLeftIsHeldToItsWidth() throws Exception { + // The page lays the section's text out in its 160pt, not in the card's room. + Exported exported = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(328, 80, 12).fillColor(SURFACE).stroke(BORDER) + .topLeft(new SectionBuilder().name("Column").fixedWidth(160) + .addParagraph(p -> p.text("A column of text held to a fixed width, narrower than " + + "the card it stands in.")) + .build()) + .build()) + .addParagraph("Below")); + + PlacedNode column = exported.placed("Column"); + assertThat(column.placementWidth()).isEqualTo(160, offset(0.01)); + XWPFParagraph written = onlyTable(exported.document()).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(indentOf(written, false)).isEqualTo(Math.round((328 - 160) * 20)); + } + @Test void aShapeSetFromItsTopTakesItsTopBorderOutOfTheSpaceAboveItsFirstLine() throws Exception { Exported card = export(page -> page @@ -132,6 +160,40 @@ void aShapeSetFromItsTopTakesItsTopBorderOutOfTheSpaceAboveItsFirstLine() throws assertThat(spacingBefore(note)).isZero(); } + @Test + void aPaddedShapeIsReadFromItsOutlineNotItsPaddedBox() throws Exception { + // Its padding stands round the outline: the card's heading is still set from the outline's top. + Exported card = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(300, 80, 12).fillColor(SURFACE).stroke(BORDER) + .padding(DocumentInsets.of(10)) + .topLeft(new SectionBuilder().padding(DocumentInsets.of(12)).spacing(4) + .addParagraph(p -> p.text("A padded card")) + .addParagraph(p -> p.text("Its text is short.")) + .build()) + .build()) + .addParagraph("Below")); + // Its text runs past the outline's foot by less than the padding under it: the clip still cuts it. + Exported clipped = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").rectangle(200, 47).fillColor(SURFACE).stroke(BORDER) + .padding(DocumentInsets.of(10)).clipPolicy(ClipPolicy.CLIP_BOUNDS) + .topLeft(new ParagraphBuilder().name("Note") + .text("Three short lines of text, a little taller than the box they stand in, so " + + "that the clip cuts the foot of the third.") + .build()) + .build()) + .addParagraph("Below")); + + XWPFParagraph heading = onlyTable(card.document()).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(spacingBefore(heading)).isEqualTo((12 - 1) * 20); + PlacedNode box = clipped.placed("Box"); + PlacedNode note = clipped.placed("Note"); + double past = box.placementY() + 10 - note.placementY(); + assertThat(past).as("how far the text runs past the outline's foot").isBetween(1.0, 9.0); + assertThat(ruleOf(onlyTable(clipped.document()).getRow(0))).isEqualTo(STHeightRule.EXACT); + } + @Test void aClipThatCutsItsTextHoldsThePanelExactlyAtItsOutline() throws Exception { Exported exported = export(page -> page @@ -217,10 +279,8 @@ void aBoxHoldingNoTextInsideAPanelOrARowsCellOrGivenNoRoomIsNotAPanel() throws E Exported framed = export(page -> page.addParagraph("Above") .add(new ShapeContainerBuilder().name("Box").roundedRect(60, 60, 8).fillColor(SURFACE) .clipPolicy(ClipPolicy.CLIP_BOUNDS) - .center(new com.demcha.compose.document.dsl.ImageBuilder() - .source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png())) - .size(60, 70).fitMode(com.demcha.compose.document.image.DocumentImageFitMode.CONTAIN) - .build()) + .center(new ImageBuilder().source(DocumentImageData.fromBytes(png())) + .size(60, 70).fitMode(DocumentImageFitMode.CONTAIN).build()) .build()) .addParagraph("Below")); // In a painted section's cell and a row's: written in that cell, as before. @@ -244,16 +304,6 @@ void aBoxHoldingNoTextInsideAPanelOrARowsCellOrGivenNoRoomIsNotAPanel() throws E assertThat(noRoom.document().getTables()).isEmpty(); } - private static byte[] png() { - try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { - javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(100, 20, - java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); - return out.toByteArray(); - } catch (java.io.IOException failure) { - throw new java.io.UncheckedIOException(failure); - } - } - @Test void aBoxWithNoClipOrNothingToCutGrowsWithItsText() throws Exception { Exported card = export(page -> page @@ -413,4 +463,13 @@ private static long spacingBefore(XWPFParagraph paragraph) { private static long twipsOf(Object value) { return value instanceof BigInteger big ? big.longValue() : Long.parseLong(String.valueOf(value)); } + + private static byte[] png() { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(new BufferedImage(100, 20, BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } } From 84778ca7ce58ea60f893e83ce71b95758556b48d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 26 Oct 2026 21:29:30 +0000 Subject: [PATCH 3/4] test(qa): take the body shape containers' Linux LibreOffice rows from CI The rows of narrow_container, clipped_container and rounded_card in the Linux LibreOffice baseline are taken from the first CI run of this change (artifact docx-fidelity-40398446747), which matches LibreOffice 24.2's docker build to the hundredth; every other row is as it was. The narrow and clipped boxes move nearer the page. Of rounded_card, the two lines under the card stand 0.6pt high there: LibreOffice 24.2 counts a cell's borders inside the least height its row is held at, as it counts the row's margins, where Word adds them, and the card's content is shorter than its outline. Word sets them within 0.1pt. The DOCX recipe names the difference. --- docs/recipes/docx-export.md | 13 +++++---- .../docx-fidelity/libreoffice-linux-lines.tsv | 28 ++++++++++++------- .../docx-fidelity/libreoffice-linux.tsv | 6 ++-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index c42c0a1ec..63e296da5 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1194,11 +1194,14 @@ recolour what is under it in Word and it keeps the colour it was flattened to. the page wraps over three lines inside a box 220pt wide stands in three, inside the box, within 0.03pt of the page's in Word; a clipped box shows the five lines the page shows, 1pt low under its border; the block under - either box stands within 0.02pt of the page's in Word. LibreOffice 24.2, - which counts the borders inside a least height, sets the narrow box 2pt - short and the block under it 1pt high, and the block under a card whose - content is shorter than its outline 0.6pt high. A card lengthened in Word - grows with its text, and the block under it moves down with it. + either box stands within 0.02pt of the page's in Word. LibreOffice 24.2 + counts a cell's borders inside the least height its row is held at, as it + counts the row's margins (see "Line height"), where Word and LibreOffice + 26.8 add them round it: there a panel whose content is shorter than its + outline stands its borders short — the narrow box 2pt, the block under it + 1pt high, and the block under a card 0.6pt high, where Word sets it within + 0.1pt. A card lengthened in Word grows with its text, and the block under + it moves down with it. Any other container — an ellipse, a polygon or a star, one holding no text, one set as a band beside a drawing, one inside a layer stack, a row's cell or a panel — is written as inline layers, or drawn where it diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index b9cfbdfb8..c6f584269 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -5124,15 +5124,23 @@ synthetic-card_over_pages 2:f69c59b028ea 1.08 7.10 lineitem60 synthetic-card_over_pages 2:db55da3fc309 1.08 7.10 420 synthetic-card_over_pages 2:8c569b972e01 1.08 0.10 afterthecard,onitslastpage. synthetic-clipped_container 0:cbe6ee14976b -0.02 0.10 beforetheclippedbox,onalineofits -synthetic-clipped_container 0:2633ce73dc24 -20.02 0.10 aftertheclippedbox:thisparagraph -synthetic-clipped_container 0:667f8b3b3eff -20.02 0.10 noneofitslinesrunsoverthebox. +synthetic-clipped_container 0:724cbc468421 0.98 0.60 clippedtextinaroundedboxtooshort +synthetic-clipped_container 0:08eb95ef1c44 0.98 0.60 holdit.thepagesetsitfromthetopof +synthetic-clipped_container 0:7876cbea781b 0.98 0.60 boxandcutsitattheoutline,sotheli +synthetic-clipped_container 0:1d37c4498256 0.98 0.60 thatrunpastthefootoftheboxarenot +synthetic-clipped_container 0:e7653895ac38 0.98 0.60 seen.onlythelinestheboxholdsarer +synthetic-clipped_container 0:2633ce73dc24 -0.02 0.10 aftertheclippedbox:thisparagraph +synthetic-clipped_container 0:667f8b3b3eff -0.02 0.10 noneofitslinesrunsoverthebox. synthetic-narrow_container 0:fa13cab3fe73 -0.02 0.10 beforethebox,onalineofitsown. -synthetic-narrow_container 0:697e6629574d -12.02 0.10 afterthebox:thisparagraphstandsu -synthetic-narrow_container 0:0efb534ce6c6 -12.02 0.10 linesrunsoverthebox. +synthetic-narrow_container 0:ed60c8a4c178 -0.52 0.59 anotecentredinaboxnarrowerthanth +synthetic-narrow_container 0:d7899490a8cd -0.52 0.59 thepagewrapsitoverthreelinesinsi +synthetic-narrow_container 0:19a2ba751318 -0.52 0.59 eachlineheldwithinitsoutline. +synthetic-narrow_container 0:697e6629574d -1.02 0.10 afterthebox:thisparagraphstandsu +synthetic-narrow_container 0:0efb534ce6c6 -1.02 0.10 linesrunsoverthebox. synthetic-rounded_card 0:2a3c79dd9054 -0.02 0.10 beforethecard,onalineofitsown. -synthetic-rounded_card 0:b8608d77afb0 0.01 0.10 aroundedcard -synthetic-rounded_card 0:bcb0465dee5d -0.02 0.10 itstextrunsoverthreelinesfromthe -synthetic-rounded_card 0:73c347391d3b -0.02 0.10 cardisjusttallenoughtoholdthemwi -synthetic-rounded_card 0:8993a52a9026 -0.02 0.10 linemore,andthecardneedstobetall -synthetic-rounded_card 0:103d88aab213 -0.02 0.10 afterthecard:thisparagraphstands -synthetic-rounded_card 0:66b11b97a20e -0.02 0.10 linesrunsoverthecard. +synthetic-rounded_card 0:b8608d77afb0 0.01 0.60 aroundedcard +synthetic-rounded_card 0:bcb0465dee5d -0.02 0.60 itstextrunsoverthreelinesfromthe +synthetic-rounded_card 0:73c347391d3b -0.02 0.60 cardisjusttallenoughtoholdthemwi +synthetic-rounded_card 0:8993a52a9026 -0.02 0.60 linemore,andthecardneedstobetall +synthetic-rounded_card 0:103d88aab213 -0.62 0.10 afterthecard:thisparagraphstands +synthetic-rounded_card 0:66b11b97a20e -0.62 0.10 linesrunsoverthecard. diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 7f45a925d..530d7f99f 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -69,6 +69,6 @@ rota-cobalt 1 1 154 151 0.22 0.47 0 synthetic-break_in_column 1 1 7 7 0.02 0.02 0 synthetic-break_in_panel 3 3 8 8 3.02 5.02 5 synthetic-card_over_pages 3 3 114 114 0.98 1.08 0 -synthetic-clipped_container 1 1 10 3 20.02 20.02 2 -synthetic-narrow_container 1 1 6 3 12.02 12.02 2 -synthetic-rounded_card 1 1 7 7 0.02 0.02 0 +synthetic-clipped_container 1 1 10 8 0.98 0.98 0 +synthetic-narrow_container 1 1 6 6 0.52 1.02 0 +synthetic-rounded_card 1 1 7 7 0.02 0.62 0 From a09055687e73041f74723d3493e43691bafb33d2 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 26 Oct 2026 22:05:49 +0000 Subject: [PATCH 4/4] fix(docx): hold a body panel's layer of its own width to it Only a section, a container or a list set at a body panel's left, of no fixed width, stands where it is written: the page lays it out in the whole room. A shape, a canvas or a table there has a width of its own and is held to it, as the inline path held it: a pill at a card's left centred its label in the card. The clip's note says Word cuts what the panel's cell holds, which is what an exact row cuts. The DOCX recipe and the CHANGELOG name the badge and the line pair as kept, and LibreOffice 24.2's narrow box as 1pt short. Tests read twips through DocxTwips. --- CHANGELOG.md | 14 +++--- docs/recipes/docx-export.md | 13 +++-- .../semantic/docx/DocxSemanticBackend.java | 12 +++-- .../semantic/docx/DocxBodyShapePanelTest.java | 47 ++++++++++++++++--- .../docx/DocxNodeFieldLedgerTest.java | 5 +- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf3d91de..d28df7c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ follow semantic versioning; release dates are ISO 8601. - a note the page wraps over three lines inside a box 220pt wide ran over two, 95pt past the box, with the paragraph under it 12pt high; now its three lines stand inside the box, within 0.03pt of the page's in Word, and the paragraph under it within 0.02pt. - LibreOffice 24.2, which counts the borders inside a least height, sets the box 2pt short and + LibreOffice 24.2, which counts the borders inside a least height, sets the box 1pt short and the paragraph under it 1pt high; - a box clipped short of its text wrote all of it, 125pt past the outline, with the paragraph under it 20pt high; now both editors show the five lines the page shows, 1pt low under the @@ -48,8 +48,9 @@ follow semantic versioning; release dates are ISO 8601. inside the row's least height. - The rounded corners are square, and a turned box stands upright; the report names both. An ellipse, a polygon or a star, a box holding no text — drawing alone, or a picture's frame —, - an unpainted one, one set as a band beside a drawing, one its margins give no room in the flow, - and one inside a layer stack, a row's cell or a panel are written as before. + an unpainted one, one set as a band beside a drawing, a badge centring a few letters, a pill + holding a title and its dates on one line, one its margins give no room in the flow, and one + inside a layer stack, a row's cell or a panel are written as before. - **A DOCX export bookmarks a block of drawings alone where its first drawing is anchored, so a page reference or a link to its anchor works.** @@ -4317,9 +4318,10 @@ follow semantic versioning; release dates are ISO 8601. `narrow_container` and `rounded_card` at the page's words, and Word the paragraph under each box where the page sets it. The rows of the three documents are rewritten — Word's and LibreOffice's on Windows from local runs, LibreOffice's on Linux from CI — and every other row - is as it was. Of `narrow_container` and `clipped_container` every row moves nearer the page; of - `rounded_card`, the paragraph under the card stands 0.6pt high in LibreOffice 24.2, which counts - a cell's borders inside the row's least height. + is as it was. Every line of `narrow_container` and `clipped_container` stands nearer the page or + where it stood, and the lines inside each box are found; of `rounded_card`, the paragraph under + the card stands 0.6pt high in LibreOffice 24.2, which counts a cell's borders inside the row's + least height. `DocxBodyShapePanelTest` holds the panel: its size, its row's height and rule, the layers held in across, the top border taken out of the space above, the clip written as an exact height, and the boxes that stay drawn. diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 63e296da5..d69e33431 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1198,14 +1198,17 @@ recolour what is under it in Word and it keeps the colour it was flattened to. counts a cell's borders inside the least height its row is held at, as it counts the row's margins (see "Line height"), where Word and LibreOffice 26.8 add them round it: there a panel whose content is shorter than its - outline stands its borders short — the narrow box 2pt, the block under it - 1pt high, and the block under a card 0.6pt high, where Word sets it within - 0.1pt. A card lengthened in Word grows with its text, and the block under - it moves down with it. + outline stands up to its borders short — the narrow box 1pt, the block + under it 1pt high, and the block under a card 0.6pt high, where Word sets + it within 0.1pt. A card lengthened in Word grows with its text, and the + block under it moves down with it. Any other container — an ellipse, a polygon or a star, one holding no text, one set as a band beside a drawing, one inside a layer stack, a row's cell or a panel — is written as inline layers, or drawn where it - holds only drawing. DOCX has no portable equivalent of + holds only drawing; a badge centring a few letters is drawn as one shape + holding them, and a pill holding a title and its dates is written as one + line (see "Text at the left and the right of one band → one line split by + a tab"). DOCX has no portable equivalent of a graphics-state path clip, so the container's layers are written inline, in source order, without clipping — again with one warning per export, and a note on each container that writes a layer in the flow. 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 556309b8b..4e7984280 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 @@ -8196,7 +8196,7 @@ private void holdTheShapesRow(XWPFTableRow row, ShapeContainerNode shape) { row.setHeight((int) exact); row.setHeightRule(org.apache.poi.xwpf.usermodel.TableRowHeightRule.EXACT); report.replace(noted, DocxExportReport.Severity.APPROXIMATED, "clipped shape container", layout.pathOf(shape), - "its clip is written as its panel's exact height, its outline's: Word cuts what its layers paint " + "its clip is written as its panel's exact height, its outline's: Word cuts what its cell holds " + "past the foot of the panel, as the page cuts it at the outline" + (roundsItsCorners(shape) ? ", but square, not round its rounded corners" : "") + ", and text added to it in an editor is hidden too, the panel no taller"); @@ -9008,14 +9008,14 @@ private static double roomInItsPanel(ShapeContainerNode shape) { /** * Whether a layer stands where its shape sets it without being held in: unmoved across, and a - * paragraph's own alignment leaning the way its layer's does, or any other block set at the - * shape's left at the width it comes to. + * paragraph's own alignment leaning the way its layer's does, or a section, a container or a + * list set at the shape's left at the width its content comes to. * *

The page lays such a block out in the room the shape leaves it and reports the box it * comes to, no wider than what it holds: a card's section set at the top left, held in to that * box, left its text no room past the page's longest line, and the cell's right border took the * rest — Word broke the line a word early. A block given a fixed width is laid out in that width, - * and is held to it.

+ * and any other — a shape, a canvas, a table — has a width of its own: each is held to it.

*/ private static boolean standsAsItsLayer(DocumentNode node, com.demcha.compose.document.node.LayerStackNode.Layer layer) { @@ -9024,7 +9024,9 @@ private static boolean standsAsItsLayer(DocumentNode node, } double share = shareAcross(layer.align()); if (!(node instanceof ParagraphNode paragraph)) { - return share == 0 && !node.flowWidth().isFixed(); + boolean flowsAtItsContentsWidth = node instanceof SectionNode || node instanceof ContainerNode + || node instanceof com.demcha.compose.document.node.ListNode; + return share == 0 && flowsAtItsContentsWidth && !node.flowWidth().isFixed(); } return share == 0 ? paragraph.align() != TextAlign.CENTER && paragraph.align() != TextAlign.RIGHT : share == 1 ? paragraph.align() == TextAlign.RIGHT diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java index 6c130d458..65ce5663c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java @@ -33,7 +33,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; -import java.math.BigInteger; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -139,6 +138,42 @@ void aBlockOfAFixedWidthSetAtTheOutlinesLeftIsHeldToItsWidth() throws Exception assertThat(indentOf(written, false)).isEqualTo(Math.round((328 - 160) * 20)); } + @Test + void aShapeSetAtTheOutlinesLeftIsHeldToItsOwnWidth() throws Exception { + // A pill at the card's top left: its label is centred in the pill, not in the card. + Exported exported = export(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Box").roundedRect(328, 80, 12).fillColor(SURFACE).stroke(BORDER) + .topLeft(new ShapeContainerBuilder().name("Pill").roundedRect(140, 20, 10) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .center(new ParagraphBuilder().text("Most popular plan").build()) + .build()) + .build()) + .addParagraph("Below")); + + XWPFParagraph label = onlyTable(exported.document()).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(label.getText()).isEqualTo("Most popular plan"); + // Held in to the pill's 140pt, the label's right edge stands well short of the card's. + assertThat(indentOf(label, false)).isGreaterThan(Math.round((328 - 140 - 10) * 20)); + } + + @Test + void aLaterLayerOfAShapeSetFromItsTopIsNamedAgainstTheTop() throws Exception { + // The first layer, centred but taller than the outline, is set from its top, and the cell + // with it; a second layer the page centres is named as set at the middle, its cell's at the top. + Exported exported = export(page -> page + .addParagraph("Above") + .add(clippedBox(ClipPolicy.CLIP_PATH) + .layer(new ParagraphBuilder().name("Second").text("A second layer").build(), LayerAlign.CENTER) + .build()) + .addParagraph("Below")); + + assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("layer offset")) + .extracting(DocxExportReport.Note::detail) + .contains("written as a panel's content: the page sets it at the middle of the shape, and its cell " + + "sets all it holds at the top, it under the layers before it"); + } + @Test void aShapeSetFromItsTopTakesItsTopBorderOutOfTheSpaceAboveItsFirstLine() throws Exception { Exported card = export(page -> page @@ -206,8 +241,8 @@ void aClipThatCutsItsTextHoldsThePanelExactlyAtItsOutline() throws Exception { assertThat(ruleOf(row)).isEqualTo(STHeightRule.EXACT); assertThat(heightOf(row)).isEqualTo((60 - 1) * 20); assertThat(detailOf(exported.report(), "clipped shape container")) - .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its layers " - + "paint past the foot of the panel, as the page cuts it at the outline, but square, not " + .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its cell " + + "holds past the foot of the panel, as the page cuts it at the outline, but square, not " + "round its rounded corners, and text added to it in an editor is hidden too, the panel no " + "taller"); // Its text is set from the outline's top, with nothing above its first line to take the border; @@ -231,8 +266,8 @@ void aSquareBoxsClipIsNamedWithoutRoundCorners() throws Exception { assertThat(ruleOf(onlyTable(exported.document()).getRow(0))).isEqualTo(STHeightRule.EXACT); assertThat(detailOf(exported.report(), "clipped shape container")) - .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its layers " - + "paint past the foot of the panel, as the page cuts it at the outline, and text added to " + .isEqualTo("its clip is written as its panel's exact height, its outline's: Word cuts what its cell " + + "holds past the foot of the panel, as the page cuts it at the outline, and text added to " + "it in an editor is hidden too, the panel no taller"); assertThat(exported.report().bySubject()).doesNotContainKey("corner radius"); } @@ -461,7 +496,7 @@ private static long spacingBefore(XWPFParagraph paragraph) { } private static long twipsOf(Object value) { - return value instanceof BigInteger big ? big.longValue() : Long.parseLong(String.valueOf(value)); + return DocxTwips.of(value); } private static byte[] png() { 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 1b086933b..a69162caf 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 @@ -491,14 +491,15 @@ record Entry(Fate fate, String note) { "outline:GAP:" + PANEL_WIDER_THAN_ITS_CELL + "; drawn as a shape, corners whose radii differ once " + "held to half its shorter side " + "at the largest of them; a painted box holding text " - + "written as a panel, in a table cell or placed in the body, its corners square; one no shape " + + "written as a panel, in a table cell or placed in the body — but a badge's few letters and a title " + + "and its dates on one line — its corners square; one no shape " + "draws, or composed in a table cell with nowhere to draw it, dropped", "layers:GAP:" + DRAWING_OVER_TEXT + "; written inline one after another in source order where it " + "writes them in the " + "flow; drawn where the page puts them, unreported, where they are all drawing, stacked as the " + "page paints them among those behind the text and among those in front of it; a panel's in its " + "cell, at its first written layer's height, held across to its box — a paragraph's, and in the " - + "body any layer set away from the shape's left — and named where " + + "body any layer set away from the shape's left or of a width of its own — and named where " + "one follows the first, is moved down or up, is set past the shape's side, or is set across by " + "a box the layout does not give, or where a body panel's run past its outline's foot unclipped; " + "one set left of its content in a table cell further than the "