diff --git a/CHANGELOG.md b/CHANGELOG.md index 32975b74c..d28df7c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,50 @@ 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, 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: + 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 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 + 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, 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.** - A section, a container or a layer stack's column holding nothing but drawings writes no @@ -4269,6 +4313,19 @@ 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. 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. + - **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..d69e33431 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,56 @@ 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 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 — 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 + 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 + 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 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; 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. 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-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 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..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 @@ -438,6 +438,10 @@ public final class DocxSemanticBackend implements SemanticBackendHeld 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 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"); + } + + /** + * 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,50 @@ 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 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 any other — a shape, a canvas, a table — has a width of its own: each is held to it.
*/ - 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)) { + 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 : 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.OptionalSo 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 +9635,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 +9728,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"); @@ -11049,7 +11220,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. @@ -11059,12 +11231,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..65ce5663c --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBodyShapePanelTest.java @@ -0,0 +1,510 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ImageBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.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; +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 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.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 + * 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("