From da338ff5661e0e1d0b5fdbcfc0a0534a9fe1a7f0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 29 Oct 2026 14:01:02 +0000 Subject: [PATCH 1/3] fix(docx): anchor what a table's composed cell draws in that cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drawing a composed cell paints has no node of its own — such a cell's content is laid out under its table, its drawings among the table's own fragments — and stood in the paragraph whose text it was nearest, or on the page where it stood beside none: six invoices' service tiles, 190 of the 204 drawings the fidelity corpus still placed from the page's edges. Edited above, every line row moved in Word and the tiles did not, and LibreOffice 24.2, which sets those rows 1.96pt shorter than the page does, left each tile further out of its row than the last. The cell whose box the layout placed such a drawing in now stands for the block holding it, and the drawing is anchored in the first paragraph of that cell able to carry it, placed from that cell's text column and the paragraph's top, as a drawing a node paints is. The paragraph is found by the cell that holds it, not by a path: the layout names a node standing in two cells once, so a path would send the second cell's drawings into the first cell's paragraph, and a cell composed of a node that stands in more than one place carries none at all. A cell set to the middle or the bottom takes one only where the page fills its box and the carrying cell holds nothing an editor could set lower: LibreOffice sets a cell carrying a drawing, and the cell holding its table, from its top, which moves a line only where the row leaves room to centre in, and a cell with room to centre in centres the drawing along with the paragraph carrying it, so where the drawing stands no longer tells where that paragraph starts. What a cell between the composed cell and the drawing's own leaves its content the layout does not say, so a drawing in a row nested inside a cell set to the middle stays where the page puts it. A drawing no cell's box holds keeps the nearest-text rule, where a neighbouring cell is now held to the same walk as a block's paragraph. A shape is placed from its paragraph's top only as far above it as Word reads back: an offset under 0.30pt comes back to a reader as one of Word's position keywords, while 0.30pt and larger come back as themselves, so the limit that placed such a shape at the paragraph's top is three tenths of a point rather than a stroke's width, which had cost a tile the 0.97pt it needed. On the nearest-text rule a cell is given no drawing standing further above its paragraph than that, since such an offset is now written rather than squared to nothing. The report's note on what a table's cells draw says the rule; the recipe, the capability matrix and the CHANGELOG describe it and its limits. --- CHANGELOG.md | 58 +++- .../architecture/backend-capability-matrix.md | 4 +- docs/recipes/docx-export.md | 39 ++- .../semantic/docx/DocxDrawingAnchors.java | 246 +++++++++++--- .../semantic/docx/DocxSemanticBackend.java | 143 +++++++- .../semantic/docx/DocxCellDrawingTest.java | 43 ++- .../docx/DocxComposedCellAnchorsTest.java | 312 ++++++++++++++++++ .../docx/DocxComposedCellHolderTest.java | 144 ++++++++ .../semantic/docx/DocxDrawingAnchorsTest.java | 24 +- 9 files changed, 943 insertions(+), 70 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b179dbf..3b65a1046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,60 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export anchors what a table's composed cell draws in that cell, so it moves with the + cell's row when the text above is edited.** + - Such a drawing has no node of its own — a composed cell's content is laid out under its + table, its drawings among the table's own fragments — and stood in the paragraph whose text + it was nearest, or on the page where it stood beside none. Six invoices' service tiles were + placed from the page's edges: edited above, every line row moved and the tiles did not, and + LibreOffice 24.2, which sets those rows 1.96pt shorter than the page does, left each tile + further out of its row than the last — 7.9pt by the fifth. + - The cell whose box the layout placed the drawing in now stands for the block holding it, and + the drawing is anchored in the first paragraph of that cell able to carry it, placed from + that cell's text column and the paragraph's top, exactly as a drawing a node paints is. A + drawing no cell's box holds — one the table paints across its cells — keeps the + nearest-text rule, as does one whose cell writes no paragraph that can carry it. + - A cell set to the middle or the bottom takes one only where the page fills its box and the + cell carrying the drawing holds no text of its own, since LibreOffice 24.2 sets a cell + carrying a drawing — and the cell holding its table — from its top. Measured in Word + 16.0.20527 and LibreOffice 24.2: with room to centre in, such a cell had its own text set 4pt + and 23.75pt higher and the text of the cell holding its table 43.7pt higher, while a cell the + page fills moved no line in either editor, and nor did one holding no text whatever room it + was left — but a cell with room to centre in centres the drawing along with the paragraph + carrying it, so where the drawing stands no longer tells where that paragraph starts, and + that is why the page must fill the cell either way. + - The same rule now holds for a drawing the text nearest it carries, which it did not: a + neighbouring cell takes one only where it, and each cell holding its table, sets its content + from its top. It was held to its own alignment alone, and a cell inside one set to the middle + took a drawing LibreOffice would then have moved its row's text for. + - A cell composed of a node that stands in more than one place carries none either: such a node + is laid out once, so the lines of the paragraphs written for it in the second cell are the + first cell's, and a cell that cannot say where its own content stands cannot place a drawing + from it. + - Of the 190 drawings the fidelity corpus composed in table cells, 165 are now anchored in + their cell. The 25 left are `PaymentsInvoice`'s, `WorkspaceInvoice`'s and + `MerchantInvoice`'s, whose rows nest a row inside a cell: the cell between the composed cell + and the drawing's own is set to the middle, and the layout does not say how much room it is + left. Two notes are gone with the change: the shapes `MeteredInvoice`'s longer form anchored + in a cell for want of another paragraph on the page, which Word might have printed clipped to + it, now stand in the cell that holds them. + - Measured in Word 16.0.20527 and LibreOffice 24.2 on `MeteredInvoice`: the tile stood 10.84pt + above its line's title on the page, 9.90, 7.90 and 5.95pt above it down the rows in + LibreOffice before, and 10.85pt above it in every row after; Word keeps it within 0.03pt, and + 0.5pt further across, where Word also sets that row's text. + +- **A drawing is placed from its paragraph's top only where Word reads that offset back.** + - Measured in Word 16.0.20527: an offset of 0.05, 0.13, 0.21, 0.25 or 0.29pt above a paragraph + is drawn where it says but reported to a reader as one of Word's position keywords + (−999999, −999997, −999994, −999993), while 0.30pt and every larger offset — 0.40, 0.50, + 0.60, 0.75, 0.97 — is reported as itself. A shape standing less than 0.30pt above its + paragraph is placed at its top, where it practically stands; one standing further keeps its + own offset. A stroke's width was allowed before, which stood a service tile 0.96pt low. + - A table cell is given no drawing standing further above its paragraph than that, on the + nearest-text rule: a 20.5pt rule placed a stroke's width above its paragraph in a cell moved + that row's text 1.8pt up in the Linux LibreOffice the build checks with, and such an offset is + now written rather than squared to nothing. + - **A DOCX export anchors a drawing in a paragraph of the block it belongs to, so it moves with that block when the text above is edited.** - A shape stood in the paragraph whose text was nearest it, within 48pt, rising above that @@ -25,8 +79,8 @@ follow semantic versioning; release dates are ISO 8601. sitting in one, carries none: the block's next paragraph able to place it takes the drawing, or else the next block up. A drawing no node paints — a timeline's rail — and one whose blocks write no such paragraph on its page go to - the paragraph nearest it there. What a table's composed cell draws keeps the nearest-text - rule. The report's note on each drawing says how it is anchored. + the paragraph nearest it there. What a table's composed cell draws goes to the cell whose box + holds it (above). The report's note on each drawing says how it is anchored. - Measured in Word 16.0.20527 and LibreOffice 24.2: a shape anchored in a table cell's paragraph stands where both say — rising 36pt above its row, hanging below its table, out of its cell, taller than its row, in an exact row or a nested table, in front of the text or diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index c72562112..6a697518c 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -46,7 +46,7 @@ nodes are a different case: `ShapeNode`, `LineNode`, `EllipseNode`, `DocxSemanticBackend.writeNode`. A horizontal `LineNode` or a thin filled `ShapeNode` is written as a rule; any other rectangle, rounded rectangle, ellipse, line, polygon or path is drawn from the fragments the layout placed -it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page — the paragraph nearest it where none does, the paragraph whose text is nearest within reach for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`, +it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`, polygons and paths as custom geometry). Inside a filled panel a shape stands in front of the text unless it frames text; anchored in a paragraph of the panel's cell, it is placed from the cell's text column, as Word lays a shape anchored in a @@ -73,7 +73,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the paragraph whose text is nearest it within reach; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the first paragraph able to carry it of the cell whose box the layout placed it in, which stands for the block holding it (a cell set to the middle or the bottom only where the page fills its box and the carrying cell holds no text), and in the paragraph whose text is nearest it within reach where no cell's box holds it; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 5df776c49..3d15a8ab7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1522,10 +1522,41 @@ offers its place, in a column's cell too; a table's own cell text, a list's item of lines do not yet, so a shape whose block's text is written only so goes to the paragraph nearest it. -What a table's composed cell draws has no node of its own, and is anchored in the paragraph -whose text is nearest it, within 48pt across and down together, when it rises above that -paragraph's top by its own height at most, 24pt at most, as a dot centred on a first line -does — in a table cell by a stroke's width at most — or else stays where the page puts it. +What a table's composed cell draws has no node of its own: a composed cell's content is laid out +under its table, and its drawings stand among the table's own fragments. The cell whose box the +layout placed such a drawing in stands for the block holding it, found by geometry, and the +drawing is anchored in the first paragraph of that cell able to carry it, placed from that cell's +text column and the paragraph's top, by the rules above; the paragraph is found by the cell that +holds it rather than by a path, since the layout names a node standing in two cells once. A cell +set to the middle or the bottom takes one only where the page fills its box — where what the +layout placed in it reaches within a stroke's width of the room its margins leave, above and +below, so that an editor setting the cell's content from its top has nothing to move — and the +cell carrying the drawing must then either be set from its top or hold no text of its own +(measured in Word 16.0.20527 and LibreOffice 24.2: a cell set to the middle with room to centre in +had its own text set 4pt and 23.75pt higher when it carried a drawing, and the text of the cell +holding its table 43.7pt higher in LibreOffice, while a cell the page fills moved no line in +either editor, and nor did one holding no text whatever room it was left; the page must fill the +cell either way, because a cell with room to centre in centres the drawing along with the +paragraph carrying it, and where the drawing stands then no longer tells where that paragraph +starts). What a cell between the composed cell and the drawing's own leaves its content the layout +does not say, so a drawing in a row nested inside a cell set to the middle stays where the page +puts it. So does one in a cell composed of a node that stands in more than one place: such a node +is laid out once, so the lines of the paragraphs written for it in the second cell are the first +cell's. So does a drawing no cell's box holds — one the table paints across its cells — and one +whose cell writes no paragraph that can carry it: those are anchored in the paragraph whose text is +nearest, within 48pt across and down together, when they rise above that paragraph's top by their +own height at most, 24pt at most, as a dot centred on a first line does — in a table cell no +further above it than they are then placed at that top, since a rule a stroke's width above its +paragraph in a cell moved that row's text 1.8pt up in the Linux LibreOffice the build checks with +— or else stay where the page puts them. A neighbouring cell takes such a drawing only where it, +and each cell holding its table, sets its content from its top, as a block's does. + +A shape is placed from its paragraph's top only as far above it as Word reads back: measured in +Word 16.0.20527, an offset of 0.05, 0.13, 0.21, 0.25 or 0.29pt above a paragraph is drawn where +it says but reported to a reader as one of Word's position keywords (−999999, −999997, −999994, +−999993), while 0.30pt and every larger offset — 0.40, 0.50, 0.60, 0.75, 0.97 — is reported as +itself. A shape standing less than 0.30pt above its paragraph is placed at its top, where it +practically stands; one standing further keeps its own offset. A shape no paragraph on its page can carry stays where the page puts it whatever the text does. It is anchored at the start of the first body paragraph written on its page, diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index fb79e4916..6680ffb43 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -10,6 +10,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; @@ -38,11 +39,14 @@ * across takes it — a timeline's dot stands in a column of its own beside its entry's text — * placed from that cell's first paragraph (see {@link Seat#placeHeld}). A shape no node draws — * a timeline's rail — or whose holders write no paragraph on its page goes to the paragraph - * nearest it there. What a table's composed cell draws has no node of its own, and goes to the - * paragraph whose text is nearest, within {@link #REACH}, when the shape rises above its top by - * its own height at most — in a table cell by a stroke's width at most — or else to the page. The - * shapes wait for the section's end, since the paragraph carrying a shape may be written after - * it.

+ * nearest it there. What a table's composed cell draws has no node of its own — such a cell's + * content is laid out under its table — so the cell found by the box the layout placed it in + * stands for the block holding it: it goes to the first paragraph of that cell that can place it + * ({@link #placeInItsCell}). One no cell's box holds, or whose cell's paragraphs cannot place it, + * goes to the paragraph whose text is nearest, within {@link #REACH}, when the shape rises above + * its top by its own height at most — in a table cell by a stroke's width at most — or else to the + * page. The shapes wait for the section's end, since the paragraph carrying a shape may be written + * after it.

* *

A shape no paragraph on its page can carry is positioned from the page's edges, so any * paragraph on its page carries it to the right place — but only one on that page: an anchor @@ -78,6 +82,8 @@ final class DocxDrawingAnchors { private final Map> pending = new TreeMap<>(); // What each waiting shape is drawn for, where something is. private final Map owners = new java.util.IdentityHashMap<>(); + // The cell whose box holds each waiting shape a table's composed cell draws, where one does. + private final Map heldBy = new java.util.IdentityHashMap<>(); // The first body paragraph written on each page of the section, by page. private final Map bodyParagraph = new HashMap<>(); // The first table-cell paragraph written on each page of the section, by page. @@ -134,6 +140,7 @@ final class DocxDrawingAnchors { void reset() { pending.clear(); owners.clear(); + heldBy.clear(); bodyParagraph.clear(); cellParagraph.clear(); breakParagraph.clear(); @@ -243,8 +250,7 @@ List ordered(List shapes, int painted) { * @param path the note's path: the node that draws it, whose holders look for the * paragraph carrying it; a path no node has — a pass of the layout's own — * leaves it to the paragraph nearest it - * @param composed whether a table's composed cell draws it, which has no node of its own: it - * goes to the paragraph whose text it stands nearest, within reach + * @param composed whether a table's composed cell draws it, which has no node of its own */ record Owner(String subject, String path, boolean composed) { @@ -254,6 +260,50 @@ record Owner(String subject, String path, boolean composed) { } } + /** + * The cell whose box holds a drawing its table paints for it, where that drawing goes: the + * cell as written, so that the paragraphs inside it are found by what holds them rather than + * by a path. A composed cell's content is given no path of its own by the layout, and one node + * standing in two cells is given one path for both, which would send the second cell's + * drawings into the first cell's paragraph. + * + *

A cell set to the middle or the bottom carries a drawing only where the page fills it. + * Measured in Word 16.0.20527 and LibreOffice 24.2: a cell set to the middle that carries a + * drawing has its own text set higher — Word by 4pt, LibreOffice by the drawing's height — + * and LibreOffice sets the content of the cell holding its table from that cell's top as well, + * 43.7pt up in a cell 100pt tall round one line. Both only where the row leaves the cell + * something to centre, and a cell the page fills moved no line in either editor. Its + * paragraphs' tops are the row's only then as well: in a cell with room to centre in, Word + * centres the drawing along with the paragraph carrying it, so where the drawing stands no + * longer tells where that paragraph starts.

+ * + * @param cell the cell as written, which the paragraph carrying the drawing must stand in + * @param fills whether the page fills the cell's box, so that its own vertical alignment, and + * that of a cell inside it holding no text, may be left out of the walk + * ({@link Seat#placeHeld}) + */ + record HoldingCell(XWPFTableCell cell, boolean fills) { + + /** Whether a paragraph stands in this cell, at any depth. */ + boolean holds(XWPFParagraph paragraph) { + for (org.apache.poi.xwpf.usermodel.IBody body = paragraph.getBody(); body != null; ) { + if (!(body instanceof XWPFTableCell at)) { + return false; + } + if (at.getCTTc() == cell.getCTTc()) { + return true; + } + body = at.getTableRow().getTable().getBody(); + } + return false; + } + + /** The cell whose vertical alignment the walk may leave out, or {@code null} where none may. */ + CTTc filled() { + return fills ? cell.getCTTc() : null; + } + } + /** * Gives shapes their places in the paint order and keeps them for the section's end, which * anchors them (see {@link #endSection}). @@ -262,17 +312,33 @@ record Owner(String subject, String path, boolean composed) { * @param owner what they are drawn for, or {@code null} where no note names them */ void queue(List shapes, int painted, Owner owner) { - queueOrdered(ordered(shapes, painted), owner); + queueOrdered(ordered(shapes, painted), owner, null); } /** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */ void queueOrdered(List shapes, Owner owner) { + queueOrdered(shapes, owner, null); + } + + /** + * Keeps shapes given their places in the paint order for the section's end, as {@link #queue} + * does, with the cell holding them. + * + * @param held for shapes a table's composed cell draws, the cell whose box holds them; + * {@code null} where no cell's box does, or for shapes a node draws. It is kept + * against the shapes rather than in the owner, so that the note the owner names + * stays one note for the table ({@link Leftovers}). + */ + void queueOrdered(List shapes, Owner owner, HoldingCell held) { // Held until the section ends: the paragraph carrying a shape may be written after it. for (Ordered shape : shapes) { pending.computeIfAbsent(shape.shape().page(), page -> new ArrayList<>()).add(shape); if (owner != null) { owners.put(shape, owner); } + if (held != null) { + heldBy.put(shape, held); + } } } @@ -397,7 +463,8 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes = new ArrayList<>(); for (Ordered shape : all) { Owner owner = owners.get(shape); - Place place = owner != null && owner.composed() ? placeBeside(shape.shape()) + Place place = owner != null && owner.composed() + ? placeInItsCell(shape.shape(), heldBy.get(shape)) : placeInItsHolder(shape.shape(), owner == null ? List.of() : holders.apply(owner.path())); if (place != null) { beside.computeIfAbsent(place.paragraph(), ignored -> new ArrayList<>()).add(new Placed(shape, place)); @@ -442,6 +509,7 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier holders) { return placeBesideTheNearest(shape, onItsPage); } + /** + * Where a shape a table's composed cell draws is anchored: in the first paragraph on its page + * of what the cell whose box holds it is composed of that it can be placed from, or else + * beside the text it stands nearest ({@link #placeBeside}). + * + *

Such a shape has no node of its own — a composed cell's content is laid out under its + * table, its drawings among the table's own fragments — so the cell is found by the box the + * layout placed it in, and that cell stands for the block holding the shape: anchored in a + * paragraph of it, the shape moves with the cell's row wherever an edit above takes it. A cell + * whose paragraphs cannot place it — none on the shape's page, or none set from its top, which + * LibreOffice 24.2 sets a cell carrying a drawing, and the cell holding its table, from — hands + * it back to the text nearest it, as before.

+ * + * @param cell the cell holding it, or {@code null} where no cell's box holds it + */ + private Place placeInItsCell(DocxDrawings.Shape shape, HoldingCell cell) { + if (cell != null) { + for (Seat seat : seats.getOrDefault(shape.page(), List.of())) { + Place place = cell.holds(seat.paragraph()) + ? seat.placeHeld(shape, tops.computeIfAbsent(seat, taken -> taken.paragraphTop(lines)), + cell.filled()) + : null; + if (place != null) { + return place; + } + } + } + return placeBeside(shape); + } + /** Where a paragraph places a shape it carries for a block holding both ({@link Seat#placeHeld}). */ private Place placeHeld(Seat seat, DocxDrawings.Shape shape) { - return seat.placeHeld(shape, tops.computeIfAbsent(seat, taken -> taken.paragraphTop(lines))); + return seat.placeHeld(shape, tops.computeIfAbsent(seat, taken -> taken.paragraphTop(lines)), null); } /** @@ -570,21 +668,21 @@ private Place placeBeside(DocxDrawings.Shape shape) { } /** - * Anchors shapes in the paragraph each was placed from — a paragraph of a block holding it, - * the one nearest it, or for a composed cell's the one whose text it stands nearest — placed - * down from its top, in the order they are drawn: they move with the paragraph wherever an - * edit takes it. + * Anchors shapes in the paragraph each was placed from — a paragraph of a block holding it, one + * of the cell holding a composed cell's, the one nearest it, or the one whose text it stands + * nearest — placed down from its top, in the order they are drawn: they move with the paragraph + * wherever an edit takes it. */ private void anchorBeside(XWPFParagraph paragraph, List shapes) { XWPFRun run = paragraph.insertNewRun(0); for (Placed placed : shapes) { Ordered ordered = placed.ordered(); double column = placed.place().column(); - // A shape less than a stroke's width above the paragraph's top is placed at its top: - // Word 16.0.20430 draws an offset of -0.13pt where it says, but reports it to a reader - // as a Top of -999997. A shape higher still keeps its own offset, which Word reports. + // A shape standing less than READABLE_RISE above the paragraph's top is placed at its + // top instead: Word draws such an offset where it says, but reports it to a reader as + // one of its position keywords. A shape higher keeps its own offset, which Word reports. double rise = placed.place().top() - ordered.shape().top(); - double top = rise > 0 && rise <= SLACK ? ordered.shape().top() : placed.place().top(); + double top = rise > 0 && rise < READABLE_RISE ? ordered.shape().top() : placed.place().top(); run.getCTR().addNewDrawing().set(Double.isInfinite(column) ? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(), top) : DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(), @@ -604,6 +702,18 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { /** A stroke's width either side a shape may reach past its paragraph's top or its cell, in points. */ private static final double SLACK = 1; + /** + * The smallest distance above a paragraph's top a shape may be placed from and still be read + * back, in points: Word draws a smaller offset where it says, but reports it to a reader as one + * of its position keywords rather than as a distance, and the editing protocol reads a + * drawing's place through that. Measured in Word 16.0.20527: offsets of 0.05, 0.13, 0.21, 0.25 + * and 0.29pt above the paragraph came back as -999999, -999997, -999994 and -999993, while + * 0.30pt and every larger offset — 0.40, 0.50, 0.60, 0.75, 0.97 — came back as itself. A + * shape nearer the top than this is placed at the top, which costs less than three tenths of a + * point and is where it practically stands. + */ + private static final double READABLE_RISE = 0.3; + /** * The furthest a shape a table's composed cell draws rises above its paragraph's top in the * body, in points: as far as a dot or an icon's disc centred on a line of text does, as @@ -742,7 +852,7 @@ Place place(DocxDrawings.Shape shape, Function lines) if (holdsAcross(shape, cellLeft, width)) { return new Place(paragraph, cellLeft + leftMargin(cell), top); } - return inTheRow(shape, cell, cellLeft, width, top); + return inTheRow(shape, cell, cellLeft, width, top, null); } /** @@ -768,17 +878,22 @@ Place place(DocxDrawings.Shape shape, Function lines) * which Word repeats on every page with what is anchored in it, nor for a line not set from * the cell's left, whose text column its text does not tell.

* - * @param top where Word places from in this paragraph ({@link #paragraphTop}); NaN where - * something above its line hides it + * @param top where Word places from in this paragraph ({@link #paragraphTop}); NaN where + * something above its line hides it + * @param filled the cell whose box holds a drawing its table paints, where the page fills + * it, whose own vertical alignment is then left out of the walk; a cell + * carrying a drawing then has nothing to move, and nor has one holding its + * table ({@link HoldingCell}). {@code null} for a drawing a node paints, + * whose every cell is held to being set from its top. */ - Place placeHeld(DocxDrawings.Shape shape, double top) { + Place placeHeld(DocxDrawings.Shape shape, double top, CTTc filled) { if (Double.isNaN(top)) { return null; } if (!(paragraph.getBody() instanceof XWPFTableCell cell)) { return new Place(paragraph, Double.POSITIVE_INFINITY, top); } - if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph) || !setFromTheTop(cell)) { + if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph)) { return null; } double width = widthOf(cell); @@ -786,20 +901,34 @@ Place placeHeld(DocxDrawings.Shape shape, double top) { if (Double.isNaN(width) || Double.isNaN(cellLeft)) { return null; } + // Only the cell that ends up carrying the shape has to be set from its top: this one + // may simply be where the row's text is measured, and hand the shape to a neighbour. + Place here = setFromTheTop(cell, filled) + ? new Place(paragraph, cellLeft + leftMargin(cell), top) : null; if (holdsAcross(shape, cellLeft, width)) { - return new Place(paragraph, cellLeft + leftMargin(cell), top); + return here; } - Place own = inTheRow(shape, cell, cellLeft, width, top); + Place own = inTheRow(shape, cell, cellLeft, width, top, filled); // LibreOffice 26.8 pulls a shape holding text back into the column of the cell it is // anchored in: a text box placed 120pt left of its column stood at the column's start. // A shape holding none it draws out of the column, as Word and LibreOffice 24.2 do both. - return own != null || shape.text() != null ? own : new Place(paragraph, cellLeft + leftMargin(cell), top); + return own != null || shape.text() != null ? own : here; } - /** Whether a cell, and each cell holding its table, sets its content from its top. */ - private static boolean setFromTheTop(XWPFTableCell cell) { + /** + * Whether a cell may carry a drawing: it, and each cell holding its table, sets its content + * from its top — or the page fills the cell that holds them all ({@code filled}), which + * leaves that cell nothing to set lower and this one's own paragraph tops where the row + * puts them, and this cell holds no text of its own. Both are measured in Word 16.0.20527 + * and LibreOffice 24.2 (see {@link HoldingCell}); the second is held to the first because a + * cell with room to centre in centres the drawing along with the paragraph carrying it, so + * where the drawing stands no longer tells where that paragraph starts. + */ + private static boolean setFromTheTop(XWPFTableCell cell, CTTc filled) { for (XWPFTableCell at = cell; at != null; ) { - if (!alignedToTheTop(at)) { + boolean nothingToSetLower = filled != null && (at.getCTTc() == filled + || at == cell && !holdsAnyText(at)); + if (!alignedToTheTop(at) && !nothingToSetLower) { return false; } at = at.getTableRow().getTable().getBody() instanceof XWPFTableCell holding ? holding : null; @@ -807,22 +936,49 @@ private static boolean setFromTheTop(XWPFTableCell cell) { return true; } + /** + * Whether a cell holds anything an editor could set lower, at any depth: a line of text, + * or a picture or a drawing of its own, which an editor moves with the text. + */ + private static boolean holdsAnyText(XWPFTableCell cell) { + for (IBodyElement element : cell.getBodyElements()) { + if (element instanceof XWPFParagraph paragraph + && (!paragraph.getText().isBlank() || draws(paragraph))) { + return true; + } + if (element instanceof org.apache.poi.xwpf.usermodel.XWPFTable table + && table.getRows().stream().flatMap(row -> row.getTableCells().stream()) + .anyMatch(Seat::holdsAnyText)) { + return true; + } + } + return false; + } + + /** Whether a paragraph holds a picture or a drawing of its own. */ + private static boolean draws(XWPFParagraph paragraph) { + return paragraph.getCTP().selectPath( + "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' " + + "./w:r/w:drawing | ./w:r/w:pict").length > 0; + } + /** * Whether a shape rises above a paragraph's top no further than it may: in the body, by * its own height, as a dot centred on the paragraph's first line does, and - * {@link #FURTHEST_RISE} at most; in a table cell, by a stroke's width. A 20.5pt rule - * placed 1pt above its paragraph in a cell of a contact row moved that row's text 1.8pt up - * in the Linux LibreOffice the build checks with, though not in Word or LibreOffice on - * Windows. NaN for a top not known is no. + * {@link #FURTHEST_RISE} at most; in a table cell, no further than it is placed at that + * top ({@link #READABLE_RISE}), so no cell on this path is given a shape to carry above + * its paragraph. A 20.5pt rule placed 1pt above its paragraph in a cell of a contact row + * moved that row's text 1.8pt up in the Linux LibreOffice the build checks with, though + * not in Word or LibreOffice on Windows. NaN for a top not known is no. * - *

Only what a table's composed cell draws is held to this + *

Only what a table's composed cell draws, and no cell's box holds, is held to this * ({@link DocxDrawingAnchors#placeBeside}). That contact row's cells are set to the middle, * and LibreOffice 24.2 sets such a cell's text from its top once it carries a drawing, * wherever the drawing stands; a cell set from its top moved no line for a drawing 36pt * above its paragraph ({@link #placeHeld}).

*/ private static boolean risesNoFurther(DocxDrawings.Shape shape, double top, boolean inCell) { - double rise = inCell ? SLACK : Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE)); + double rise = inCell ? READABLE_RISE : Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE)); return shape.top() >= top - rise; } @@ -838,9 +994,9 @@ private static boolean risesNoFurther(DocxDrawings.Shape shape, double top, bool * One with a border above it is not taken: its border stands between its top and its line.

*/ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cellLeft, double width, - double top) { + double top, CTTc filled) { List opening = cell.getBodyElements(); - if (opening.isEmpty() || opening.get(0) != paragraph || !alignedToTheTop(cell)) { + if (opening.isEmpty() || opening.get(0) != paragraph || !(alignedToTheTop(cell) || filled != null)) { return null; } List cells = cell.getTableRow().getTableCells(); @@ -861,7 +1017,7 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell } edge -= neighbour; if (holdsAcross(shape, edge, neighbour)) { - return openingPlace(cells.get(index), edge, top); + return openingPlace(cells.get(index), edge, top, filled); } } edge = cellLeft + width; @@ -871,18 +1027,24 @@ private Place inTheRow(DocxDrawings.Shape shape, XWPFTableCell cell, double cell return null; } if (holdsAcross(shape, edge, neighbour)) { - return openingPlace(cells.get(index), edge, top); + return openingPlace(cells.get(index), edge, top, filled); } edge += neighbour; } return null; } - /** The place at the start of a cell whose left edge stands where given; {@code null} when unknown. */ - private static Place openingPlace(XWPFTableCell cell, double cellLeft, double top) { + /** + * The place at the start of a cell whose left edge stands where given; {@code null} when + * unknown. A cell set to the middle or the bottom takes the shape only for one its table's + * cell is filled with ({@code filled}) and where it holds no text of its own to be set + * lower ({@link #setFromTheTop}). + */ + private static Place openingPlace(XWPFTableCell cell, double cellLeft, double top, CTTc filled) { CTTcPr properties = cell.getCTTc().getTcPr(); List opening = cell.getBodyElements(); - if (properties != null && properties.isSetVMerge() || !alignedToTheTop(cell) || opening.isEmpty() + if (properties != null && properties.isSetVMerge() || opening.isEmpty() + || !setFromTheTop(cell, filled) || !(opening.get(0) instanceof XWPFParagraph first) || borderedAbove(first)) { return null; } 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 dfdc083b2..54af0638c 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 @@ -206,6 +206,17 @@ public final class DocxSemanticBackend implements SemanticBackend { * sets lines on its 0.12pt grid, and a twip of margin moves a line a step or nothing. */ private static final double LEAST_ROW_TEXT_MOVE = 0.1; + /** + * How much room above or below its content a composed cell's box may still leave and count as + * filled, in points: a stroke's width, the same allowance a cell's box is held to + * ({@link DocxLayoutMetrics.CellBox#holds}). Measured on {@code MeteredInvoice}, whose line + * rows leave their cell 0.51pt above its content and 0.47pt below it — the cost of writing the + * cell's margins and the row's height each rounded on its own — and neither editor moved a + * line for the tile that cell carries. A cell the page fills has nothing to set lower, so it + * may carry a drawing although it is set to the middle (see + * {@link DocxDrawingAnchors.HoldingCell}). + */ + private static final double CELL_FILL_SLACK = 1; /** * How a drawing a table's composed cell paints is anchored, as the report says it: such a cell * has no node of its own to be held by (see DocxDrawingAnchors). @@ -213,6 +224,15 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final String ANCHORED_BESIDE_ITS_TEXT = "anchored in the paragraph whose text it stands " + "beside, so it moves with that text when the text above is edited, or to the page where it " + "stands beside none"; + /** + * How a drawing a table's composed cell paints is anchored, as the report says it: in the cell + * whose box the layout placed it in, which stands for the block holding it (see + * DocxDrawingAnchors). + */ + private static final String ANCHORED_IN_ITS_CELL = "anchored in the first paragraph able to carry it " + + "of the cell whose box holds it, so it moves with that cell's row when the text above is " + + "edited; " + ANCHORED_BESIDE_ITS_TEXT + " where no cell's box holds it, or where that cell " + + "has no such paragraph on its page"; /** How a drawing a node paints is anchored, as the report says it (see DocxDrawingAnchors). */ private static final String ANCHORED_IN_ITS_BLOCK = "anchored in the first paragraph able to carry it " + "that the nearest block holding it writes on its page, so it moves with that block when the text " @@ -504,6 +524,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The drawings the composed cells of the table being written paint, not yet anchored, in the // order the layout emitted them; null outside such a table (see anchorComposedDrawing). private List tableDrawings; + // The Word cell each composed cell of the table being written was written in, by where it + // stands in the table: where its drawings are anchored (see cellHolding). + private java.util.Map composedCellsWritten = java.util.Map.of(); // The note what is drawn now is drawn for: one the section's end finds nothing to carry is // named there instead (endSection). Null where no note names it. private DocxDrawingAnchors.Owner drawingFor; @@ -1228,6 +1251,7 @@ private byte[] write(List sections, Path outputFile) throws Exc clipContainer = null; cellDrawing = CellDrawing.NONE; tableDrawings = null; + composedCellsWritten = java.util.Map.of(); takenByRules = java.util.Set.of(); composedCellBoxes = List.of(); composedCell = null; @@ -16016,12 +16040,14 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception } CellDrawing outerCellDrawing = cellDrawing; List outerDrawings = tableDrawings; + java.util.Map outerCellsWritten = composedCellsWritten; java.util.Set outerRuleDrawings = takenByRules; CellDrawing drawn = drawCellDrawing(node); List skipped = new ArrayList<>(outerCellDrawing.skipped()); skipped.addAll(drawn.skipped()); cellDrawing = new CellDrawing(outerCellDrawing.drew() || drawn.drew(), skipped, outerCellDrawing.strayed() || drawn.strayed(), List.of()); + composedCellsWritten = new java.util.HashMap<>(); if (!drawn.pending().isEmpty()) { tableDrawings = new ArrayList<>(drawn.pending()); takenByRules = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); @@ -16029,11 +16055,16 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception try { writeTableRows(document, node); } finally { - // What no cell took is drawn where the page puts it, in the order the table paints it. + // What no cell took is anchored in the cell whose box holds it, in the order the table + // paints it: that cell is the block such a drawing belongs to (see DocxDrawingAnchors). if (tableDrawings != outerDrawings) { - DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node), true); + List composed = composedCellsOf(node); + // One owner for the table, so the note on what its cells draw stays one note. + DocxDrawingAnchors.Owner owner = + new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node), true); for (CellFragment waiting : tableDrawings) { - anchors.queueOrdered(waiting.shapes(), table); + anchors.queueOrdered(waiting.shapes(), owner, + cellHolding(node, composed, waiting.fragment())); } } // Drawn as shapes is what its rules did not take: those are written as rules. @@ -16041,10 +16072,11 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception if (drawn.pending().stream().anyMatch(waiting -> !waiting.shapes().isEmpty() && !rules.contains(waiting))) { report.add(DocxExportReport.Severity.APPROXIMATED, "cell drawing", layout.pathOf(node), "what its cells draw is drawn as shapes where the layout puts it — anchored in the " - + "cell a drawing is all of, and otherwise " + ANCHORED_BESIDE_ITS_TEXT + + "cell a drawing is all of, and otherwise " + ANCHORED_IN_ITS_CELL + "; a transform, a gradient or a dash on it is not carried"); } tableDrawings = outerDrawings; + composedCellsWritten = outerCellsWritten; takenByRules = outerRuleDrawings; cellDrawing = outerCellDrawing; } @@ -16271,6 +16303,107 @@ private record CellFragment(com.demcha.compose.document.layout.PlacedFragment fr List shapes) { } + /** + * A cell of a table composed of a node, as the layout placed it and as it was written. + * + * @param written the cell as written + * @param boxes the boxes, one a page for a header repeated on every page + */ + private record ComposedCell(XWPFTableCell written, List boxes) { + } + + /** + * Where a cell of a table stands in it: its logical row and its first column, which is how a + * cell written for a composed node is found again once the table's rows are written. What it + * is composed of is not the key: one node standing in two cells is one node, and the layout + * gives it one path for both. + * + * @param row the cell's logical row + * @param column the cell's first column + */ + private record CellAt(int row, int column) { + } + + /** + * Each cell of a table that is composed of a node, in the order the grid resolves them, as the + * layout placed it; a cell the layout placed in no box is left out. + */ + private List composedCellsOf(TableNode table) { + List cells = new ArrayList<>(); + for (List row : TableGrid.resolve(table)) { + for (TableGrid.Placement placement : row) { + XWPFTableCell written = composedCellsWritten.get(new CellAt(placement.row(), placement.column())); + // A node standing in more than one place is laid out once, so the lines of the + // paragraphs written for it in this cell are another cell's: the cell cannot say + // where its own content stands, and its drawings keep to the text nearest them. + if (written == null || placement.cell().content() == null + || layout.placedMoreThanOnce(placement.cell().content())) { + continue; + } + List boxes = layout.cellBoxes(table, placement.row(), placement.column()); + if (!boxes.isEmpty()) { + cells.add(new ComposedCell(written, boxes)); + } + } + } + return cells; + } + + /** + * The cell whose box holds a drawing its table paints for it, or {@code null} where no cell's + * box holds it — a drawing the table paints across its cells, or one a layer's offset or a + * margin below zero takes out of its own. + * + *

Where the boxes of two cells hold it, the first the grid resolves takes it. Only the + * table's own cells are among them: a table composed in a cell has no rows of its own in the + * layout, so its cells are given no box ({@link DocxLayoutMetrics#cellBoxes}).

+ * + *

The cell is handed over as written where the page fills its box, so that the drawing may + * be anchored in it although the author set it to the middle (see + * {@link DocxDrawingAnchors.HoldingCell}).

+ */ + private DocxDrawingAnchors.HoldingCell cellHolding(TableNode table, List cells, + com.demcha.compose.document.layout.PlacedFragment fragment) { + for (ComposedCell cell : cells) { + for (DocxLayoutMetrics.CellBox box : cell.boxes()) { + if (box.holds(fragment)) { + return new DocxDrawingAnchors.HoldingCell(cell.written(), + pageFillsTheCell(table, box, cell.written())); + } + } + } + return null; + } + + /** + * Whether the page fills a composed cell's box: what the layout placed in it reaches within a + * stroke's width of the room the cell's own margins leave, above and below, so that an editor + * setting the cell's content from its top has nothing to move. + * + *

The content is read from the fragments the table laid out inside the box, held to the box + * — a composed cell's content is laid out under its table, so those are all it has, and a + * fragment is counted as inside the box a point either side of it. Among them is the row's own + * fragment, where the cell spans its row: a box holds that fragment only where it is as wide + * as the table, and such a cell's box is the row's, which the layout sets at its content. A + * cell narrower than its row is measured by its content alone, the row's fragment reaching + * past it on both sides. The room comes from the cell's margins as written.

+ */ + private boolean pageFillsTheCell(TableNode table, DocxLayoutMetrics.CellBox box, XWPFTableCell written) { + double upper = Double.NEGATIVE_INFINITY; + double lower = Double.POSITIVE_INFINITY; + for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(table)) { + if (box.holds(fragment)) { + upper = Math.max(upper, Math.min(box.top(), fragment.y() + fragment.height())); + lower = Math.min(lower, Math.max(box.bottom(), fragment.y())); + } + } + if (Double.isInfinite(upper)) { + return false; + } + return box.top() - upper - cellMargin(written, true) / TWIPS_PER_POINT <= CELL_FILL_SLACK + && lower - box.bottom() - cellMargin(written, false) / TWIPS_PER_POINT <= CELL_FILL_SLACK; + } + /** * Anchors a drawing composed alone in a table cell in that cell, where the page puts it in * the cell, rather than on the page. @@ -17538,6 +17671,8 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, if (!boxes.isEmpty()) { composedCellBoxes = boxes; composedCell = cell; + // Where its drawings go: the table anchors them once its rows are written. + composedCellsWritten.put(new CellAt(placement.row(), placement.column()), cell); composedCellRepeats = placement.row() < node.repeatedHeaderRowCount(); List rules = new ArrayList<>(); collectRules(source.content(), overlayDepth, oneLayerDepth, rules); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java index 55b1fec99..729cb3aea 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java @@ -112,9 +112,10 @@ void aHeadersIconRepeatedOnTheNextPageIsNotTakenByARowThere() throws Exception { } @Test - void anIconNoCellTookIsNotGivenToTheNextCell() throws Exception { - // The first band's mark holds a line, so it stays on the page; the second band's icon, - // of the same kind and size, takes its own fragments, not the first band's. + void anIconOneRowDrawsIsNotGivenToTheNextRowsCell() throws Exception { + // The first band's mark holds a line, so it is not the one drawing its cell is all of, + // and the cell whose box holds it takes it instead; the second band's icon, of the same + // kind and size, takes its own fragments, not the first band's. DocumentNode ruledMark = new LayerStackBuilder().name("RuledMark") .layer(ICON.node(12)) .layer(new LineBuilder().name("Underline").horizontal(12) @@ -127,9 +128,9 @@ void anIconNoCellTookIsNotGivenToTheNextCell() throws Exception { .rowCells(DocumentTableCell.node(band(BLUE_ICON, 12)))))) { XWPFTable table = document.getTables().get(0); - assertThat(xmlOfIcon(table, 0)).doesNotContain("layoutInCell=\"1\""); + assertThat(xmlOfIcon(table, 0)).contains("layoutInCell=\"1\"").contains("FF0000").doesNotContain("0000FF"); assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); - assertThat(shapesOutOfTheirCells(document)).as("the first mark and its line, out of the cell").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("each mark in its own row's cell").isZero(); } } @@ -176,9 +177,10 @@ void aTableNestedInACellDoesNotLendItsCellsToTheOuterTable() throws Exception { } @Test - void aCellHoldingTwoDrawingsLeavesBothOnThePage() throws Exception { - // The first mark has a margin, so it stays on the page; the second, of the same kind and - // size, would otherwise take the first's fragments, the first of those waiting in the cell. + void aCellHoldingTwoDrawingsKeepsEachInItsOwnCell() throws Exception { + // The first mark has a margin, so it is not the one drawing its cell is all of and the + // cell whose box holds it takes it; the second, of the same kind and size, would + // otherwise take the first's fragments, the first of those waiting in the cell. DocumentNode inset = new com.demcha.compose.document.node.LayerStackNode("Inset", List.of(new com.demcha.compose.document.node.LayerStackNode.Layer(ICON.node(12))), DocumentInsets.zero(), DocumentInsets.of(2)); @@ -187,15 +189,24 @@ void aCellHoldingTwoDrawingsLeavesBothOnThePage() throws Exception { .rowCells(DocumentTableCell.node(new RowBuilder().name("Pair").verticalAlign(RowVerticalAlign.CENTER) .weights(16, 12, 272).add(inset).add(ICON.node(12)).addParagraph(p -> p.text("LABEL")) .build()))))) { - assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); - assertThat(shapesOutOfTheirCells(document)).as("both marks, out of the cell").isEqualTo(2); + XWPFTableCell band = document.getTables().get(0).getRow(0).getCell(0); + List marks = band.getTables().get(0).getRow(0).getTableCells(); + + assertThat(anchors(marks.get(0).getParagraphs().get(0))).as("the inset mark, in its own cell") + .hasSize(1); + assertThat(anchors(marks.get(1).getParagraphs().get(0))).as("the plain mark, in its own cell") + .hasSize(1); + assertThat(shapesOutOfTheirCells(document)).as("neither left to the page").isZero(); } } @Test - void aPaddedStackComposedInACellStaysOnThePage() throws Exception { + void aPaddedStackComposedInACellGoesToTheCellWhoseBoxHoldsIt() throws Exception { // Its padding sets the icon in from the box the layout keeps for it, which the icon's own - // box cannot stand for. + // box cannot stand for, so `anchorInCell` must still refuse it: the cell whose box holds + // it takes it instead, placed from that cell's text column, which puts the icon its + // padding in. Were `anchorInCell` to take it, the cell's column would be read off the + // icon itself and the offset would be nothing. DocumentNode padded = new com.demcha.compose.document.node.LayerStackNode("Padded", List.of(new com.demcha.compose.document.node.LayerStackNode.Layer(ICON.node(12))), DocumentInsets.of(4), DocumentInsets.zero()); @@ -203,8 +214,12 @@ void aPaddedStackComposedInACellStaysOnThePage() throws Exception { .columns(DocumentTableColumn.fixed(300)) .rowCells(DocumentTableCell.node(new RowBuilder().name("Band").verticalAlign(RowVerticalAlign.CENTER) .weights(20, 280).add(padded).addParagraph(p -> p.text("LABEL")).build()))))) { - assertThat(xmlOfIcon(document.getTables().get(0), 0)).doesNotContain("layoutInCell=\"1\""); - assertThat(shapesOutOfTheirCells(document)).isEqualTo(1); + XWPFParagraph carrier = iconCell(document.getTables().get(0).getRow(0).getCell(0)) + .getParagraphs().get(0); + + assertThat(anchors(carrier)).as("the icon, its padding in from its cell's column") + .singleElement().extracting(Anchor::fromH, Anchor::x).containsExactly("column", 4.0); + assertThat(shapesOutOfTheirCells(document)).isZero(); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java new file mode 100644 index 000000000..56edc764f --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java @@ -0,0 +1,312 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc; + +import java.awt.Color; +import java.math.BigInteger; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A shape a table's composed cell draws is anchored in a paragraph of that cell, so it moves with + * the cell's row, and placed from the cell's text column and the paragraph's top. + * + *

Such a shape has no node of its own — a composed cell's content is laid out under its table + * — so the cell found by the box the layout placed it in stands for the block holding it. An + * invoice's service tiles stood where the page had put them: edited above, every row moved and + * they did not, and LibreOffice 24.2, which sets those rows 1.96pt shorter than the page, left + * them further out of their row with each one.

+ * + *

A cell set to the middle or the bottom takes one only where the page fills it, since + * LibreOffice sets a cell carrying a drawing — and the cell holding its table — from its top: + * measured in Word 16.0.20527 and LibreOffice 24.2, a cell with room to centre in had its text + * set 4pt and 23.75pt higher, and the text of the cell holding its table 43.7pt higher, while a + * cell the page fills moved no line in either editor.

+ */ +class DocxComposedCellAnchorsTest { + + private static final String TABLE = "Flow[0]/Table[1]"; + private static final String ROW = TABLE + "/row[0]/cell[0]/Row[0]"; + private static final String TITLE = ROW + "/Text[1]/Title[0]"; + + @Test + void aComposedCellsDrawingIsAnchoredInTheCellWhoseBoxHoldsIt() { + // A tile 40pt wide in the row's first cell, its text in the second: the tile's own cell + // takes it, placed from that cell's column and the row's first paragraph. A paragraph of + // the body above the table is offered first and must not take it, however near it stands: + // only a paragraph the cell holds tells where that cell's content starts. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.outside(), "Flow[0]/Before[0]", composed.outside(), 20, 300, 98, 108); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.outside())).isEmpty(); + String drawing = drawingsIn(composed.tileCell()).get(0); + assertThat(drawing).contains("layoutInCell=\"1\"") + .contains("") + .contains(""); + // The tile's cell starts 40pt left of the text's, and the tile stands 2pt into it. + assertThat(offsetAcross(drawing)).as("from its own cell's column").isCloseTo(2, within(0.01)); + assertThat(offsetDown(drawing)).as("1pt above the row's first paragraph").isCloseTo(-1, within(0.01)); + } + + @Test + void aCellTheLayoutDoesNotFillCarriesNoneWhileItIsSetToTheMiddle() { + // The same cell, the page leaving it room to centre in: LibreOffice would set its row's + // text from the top for the drawing, so the drawing keeps the page. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), false)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.tileCell())).isEmpty(); + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aCellSetToTheMiddleHoldingTextCarriesNoneOfItsOwn() { + // The tile over the text's own cell, which is set to the middle and holds text: that cell + // cannot carry it, since LibreOffice would set its text from the cell's top. The drawing + // goes no further than the rule the export had before, which seats it beside that text — + // read off its own offset, which that rule measures from the text and this one would + // measure from the row's first paragraph. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(70, 100.5)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.tileCell())).isEmpty(); + String drawing = drawingsIn(composed.title()).get(0); + assertThat(offsetAcross(drawing)).as("beside the text, from its own column").isCloseTo(10, within(0.01)); + assertThat(offsetDown(drawing)).as("half a point below the text's top").isCloseTo(0.5, within(0.01)); + } + + @Test + void aDrawingNoCellsBoxHoldsKeepsToTheTextItStandsNearest() { + // The table paints it across its cells: no cell's box holds it, and the rule the export + // had before takes over — the paragraph whose text it stands nearest, within reach. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(70, 100.5)), null); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.title())).singleElement().asString() + .contains(""); + } + + @Test + void aDrawingBesideNoTextOnItsPageStillKeepsThePage() { + // No cell holds it and no text stands within reach: the page keeps it, as before. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 400)), null); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aDrawingNoCellHoldsIsNotGivenToACellItRisesAbove() { + // No cell's box holds it, so the text nearest it takes it — but only as far above that + // text's paragraph as the shape is then placed at its top: a shape given a cell to carry + // it from further above moved that cell's row 1.8pt up in the Linux LibreOffice the build + // checks with, and its offset is now written rather than squared to nothing. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(70, 99.5)), null); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.title())).isEmpty(); + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aCellHoldingOnlyAPictureCarriesNoDrawing() { + // Nothing to set lower means nothing at all: a picture is moved with the text, so a cell + // holding one is not excused its vertical alignment. + Composed composed = composed(new XWPFDocument()); + composed.tileCell().createRun().getCTR().addNewDrawing(); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aRiseWordWouldNotReadBackIsPlacedAtTheParagraphsTop() { + // Word reports an offset of 0.05 to 0.29pt above a paragraph as one of its position + // keywords rather than as a distance, and the editing protocol reads a drawing's place + // through that: such a shape is placed at the paragraph's top instead. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99.71)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(offsetDown(drawingsIn(composed.tileCell()).get(0))).isZero(); + } + + @Test + void aRiseWordReadsBackKeepsItsOwnOffset() { + // Measured in Word 16.0.20527: 0.29pt came back as a keyword and 0.30pt as itself, so the + // two tests stand either side of the limit. 0.30pt itself has no exact double, and the + // page's own arithmetic would decide it. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99.69)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(offsetDown(drawingsIn(composed.tileCell()).get(0))).isCloseTo(-0.31, within(0.01)); + } + + /** + * A table of one cell holding a row of two — a tile's cell and a text cell, both set to the + * middle, as an invoice's service lines are written — and a paragraph of the body beside it. + * + * @param document the document + * @param outside a paragraph of the body, which carries what nothing else does + * @param tileCell the first cell's paragraph, holding no text + * @param title the second cell's paragraph, holding the line's title + * @param cell the composed cell itself, which holds them both + */ + private record Composed(XWPFDocument document, XWPFParagraph outside, XWPFParagraph tileCell, + XWPFParagraph title, XWPFTableCell cell) { + } + + private static Composed composed(XWPFDocument document) { + XWPFParagraph outside = text(document, "Before"); + XWPFTable outer = document.createTable(1, 1); + XWPFTableCell composed = outer.getRow(0).getCell(0); + width(composed, 300); + margin(composed, 0); + middle(composed); + XWPFTable inner = new XWPFTable(composed.getCTTc().addNewTbl(), composed, 1, 1); + composed.insertTable(composed.getTables().size(), inner); + XWPFTableCell tile = inner.getRow(0).getCell(0); + width(tile, 40); + margin(tile, 0); + middle(tile); + XWPFTableCell beside = inner.getRow(0).createCell(); + width(beside, 260); + margin(beside, 0); + middle(beside); + XWPFParagraph title = beside.getParagraphs().get(0); + title.createRun().setText("Compute Instances"); + return new Composed(document, outside, tile.getParagraphs().get(0), title, composed); + } + + private static DocxDrawingAnchors anchors() { + AtomicLong ids = new AtomicLong(1); + return new DocxDrawingAnchors(ids::getAndIncrement, DocxGrownLines.NONE::lineOf, path -> List.of()); + } + + /** Queues shapes a table's composed cell draws, held by a cell or by none. */ + private static void queue(DocxDrawingAnchors anchors, List shapes, + DocxDrawingAnchors.HoldingCell held) { + anchors.queueOrdered(anchors.ordered(shapes, -1), + new DocxDrawingAnchors.Owner("cell drawing", TABLE, true), held); + } + + /** A 20pt tile, as a service line's mark is. */ + private static DocxDrawings.Shape tile(double x, double top) { + return new DocxDrawings.Shape(DocxDrawings.Kind.ROUND_RECT, x, top, 20, 20, Color.WHITE, Color.GRAY, 1, 4, + false, 0); + } + + private static XWPFParagraph text(XWPFDocument document, String text) { + XWPFParagraph paragraph = document.createParagraph(); + XWPFRun run = paragraph.createRun(); + run.setText(text); + return paragraph; + } + + private static void width(XWPFTableCell cell, double points) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + properties.addNewTcW().setType(STTblWidth.DXA); + properties.getTcW().setW(BigInteger.valueOf(Math.round(points * 20))); + } + + private static void margin(XWPFTableCell cell, double points) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + properties.addNewTcMar().addNewLeft().setW(BigInteger.valueOf(Math.round(points * 20))); + } + + private static void middle(XWPFTableCell cell) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + properties.addNewVAlign().setVal(STVerticalJc.CENTER); + } + + private static List drawingsIn(XWPFParagraph paragraph) { + Matcher matcher = Pattern.compile("", Pattern.DOTALL) + .matcher(paragraph.getCTP().xmlText()); + List drawings = new java.util.ArrayList<>(); + while (matcher.find()) { + drawings.add(matcher.group()); + } + return drawings; + } + + private static double offsetDown(String drawing) { + return offset(drawing, "positionV"); + } + + private static double offsetAcross(String drawing) { + return offset(drawing, "positionH"); + } + + private static double offset(String drawing, String axis) { + Matcher matcher = Pattern.compile("(-?\\d+)<") + .matcher(drawing); + assertThat(matcher.find()).as("an offset on " + axis).isTrue(); + try { + return Long.parseLong(matcher.group(1)) / 12700.0; + } catch (NumberFormatException notAnOffset) { + throw new AssertionError("not an offset in EMU: " + matcher.group(1), notAnOffset); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java new file mode 100644 index 000000000..7ede5d9b8 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java @@ -0,0 +1,144 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.RowVerticalAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.svg.SvgIcon; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.xmlbeans.XmlObject; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor; +import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.STRelFromV; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Which cell of a table takes a drawing its composed cells paint: the one whose box the layout + * placed it in, found by geometry, since such a drawing has no node of its own. + * + *

A service line's tile — a bordered square round an icon, drawn as several shapes and so not + * the one drawing its cell is all of — stood where the page had put it: edited above, every row + * of the invoice moved and the tiles did not.

+ */ +class DocxComposedCellHolderTest { + + private static final SvgIcon ICON = SvgIcon.parse("" + + ""); + + @Test + void aTilesDrawingsAreAnchoredInTheCellOfTheRowThatHoldsThem() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(line()))))) { + XWPFTableCell composed = document.getTables().get(0).getRow(1).getCell(0); + XWPFParagraph carrier = composed.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + + assertThat(placedFrom(carrier)).as("the tile and its icon, in the tile's own cell") + .isNotEmpty().allMatch(from -> from == STRelFromV.PARAGRAPH); + assertThat(onThePage(document)).as("none left to the page").isZero(); + } + } + + @Test + void aCellTheRowLeavesRoomToCentreInTakesNone() throws Exception { + // The same cell and the same tile, with six lines of text in the cell beside it: the row + // is then far taller than the tile's cell and the page leaves that cell room to centre + // in, where LibreOffice 24.2 would set its row's text from the top for a drawing. + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("Above"), DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(line()), + new DocumentTableCell(List.of("One", "Two", "Three", "Four", "Five", "Six"), null))))) { + XWPFTableCell composed = document.getTables().get(0).getRow(1).getCell(0); + XWPFParagraph carrier = composed.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + + assertThat(placedFrom(carrier)).as("nothing in the tile's cell").isEmpty(); + assertThat(onThePage(document)).as("left to the page, as before").isPositive(); + } + } + + @Test + void oneNodeComposedIntoTwoCellsTakesNoCell() throws Exception { + // The same node value in two cells. The layout lays such a node out once, so the lines of + // the paragraphs written for it in the second cell are the first cell's, and a cell that + // cannot say where its own content stands cannot be given a drawing to place from it — + // the second cell's drawings would follow the first cell's row. Both cells' drawings keep + // to the text nearest them, as they did before this rule, which is where the page puts + // them. + DocumentNode shared = line(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(shared)) + .rowCells(DocumentTableCell.node(shared))))) { + for (int row = 1; row <= 2; row++) { + XWPFParagraph carrier = document.getTables().get(0).getRow(row).getCell(0) + .getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + assertThat(placedFrom(carrier)).as("row " + row + " takes none").isEmpty(); + } + assertThat(onThePage(document)).as("left where the page puts them").isPositive(); + } + } + + @Test + void theReportSaysWhereWhatACellsDrawGoes() throws Exception { + DocxExportReport report = DocxExports.report(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Above")) + .rowCells(DocumentTableCell.node(line())))); + + assertThat(report.notes()).filteredOn(note -> "cell drawing".equals(note.subject())) + .singleElement().extracting(DocxExportReport.Note::detail).asString() + .contains("anchored in the first paragraph able to carry it of the cell whose box holds it") + .contains("moves with that cell's row when the text above is edited"); + } + + /** One service line: a tile of a bordered square round an icon, and the line's text beside it. */ + private static DocumentNode line() { + return new RowBuilder().name("Line").verticalAlign(RowVerticalAlign.CENTER) + .weights(24, 276) + .add(new ShapeContainerBuilder().name("Tile").rectangle(24, 24) + .stroke(DocumentStroke.of(DocumentColor.of(java.awt.Color.GRAY), 1)) + .layer(ICON.node(12), com.demcha.compose.document.node.LayerAlign.CENTER) + .build()) + .addParagraph(p -> p.name("Title").text("Compute Instances")) + .build(); + } + + /** What each drawing anchored in a paragraph of a cell is placed down from. */ + private static List placedFrom(XWPFParagraph paragraph) { + List from = new ArrayList<>(); + for (CTAnchor anchor : anchorsIn(paragraph.getCTP())) { + from.add(anchor.getPositionV().getRelativeFrom()); + } + return from; + } + + /** How many of the document's drawings are placed down from the page's edge. */ + private static long onThePage(XWPFDocument document) { + return anchorsIn(document.getDocument()).stream() + .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE) + .count(); + } + + private static List anchorsIn(XmlObject root) { + List anchors = new ArrayList<>(); + for (XmlObject found : root.selectPath("declare namespace wp='" + + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'" + + " .//wp:anchor")) { + anchors.add((CTAnchor) found); + } + return anchors; + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java index 7944429cb..60391359a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java @@ -156,14 +156,18 @@ void aDotCentredOnTheFirstLineRisesAboveItsParagraphsTopByItsOffset() { } @Test - void aShapeReachingAStrokePastItsParagraphsTopIsPlacedAtThatTop() { + void aShapeWordWouldNotReadBackIsPlacedAtItsParagraphsTop() { + // Measured in Word 16.0.20527: an offset of 0.05, 0.13, 0.21, 0.25 or 0.29pt above a + // paragraph is drawn where it says but reported to a reader as a position keyword + // (-999999, -999997, -999994, -999993), and the editing protocol reads a drawing's place + // through that. A shape standing 0.2pt above its paragraph is placed at its top instead. XWPFDocument document = new XWPFDocument(); XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); anchors.seat(0, first, null, first, 40, 300, 100, 110); - queue(anchors, List.of(dot(30, 99.5))); + queue(anchors, List.of(dot(30, 99.8))); anchors.endSection(0, null, document::createParagraph); String drawing = drawingsIn(first).get(0); @@ -171,6 +175,22 @@ void aShapeReachingAStrokePastItsParagraphsTopIsPlacedAtThatTop() { assertThat(offsetDown(drawing)).isZero(); } + @Test + void aShapeWordReadsBackKeepsItsOffsetAboveItsParagraph() { + // 0.30pt and every larger offset came back as itself, so it is written as it stands: a + // service line's tile stands 0.97pt above the first paragraph of its row. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "First"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, null, first, 40, 300, 100, 110); + + queue(anchors, List.of(dot(30, 99.5))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(offsetDown(drawingsIn(first).get(0))).isCloseTo(-0.5, within(0.01)); + } + @Test void anExactLinesTopIsWhereWordStandsItsBaselineLessTheSpaceAbove() { // A 15pt exact line, 12pt above it, its text lowered a point: Word stands the baseline From 26919655065fd54b6cb36ba05f2542eb4d81c51f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 29 Oct 2026 15:13:28 +0000 Subject: [PATCH 2/3] fix(docx): read a cell carrying a composed cell's drawing from its top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word centres a cell set to the middle along with the drawing its paragraph carries, and such a drawing is placed from where the page put the row's first paragraph: measured in Word 16.0.20527, a tile carried by that cell in a row four lines tall stood 7.10pt below where the page puts it. The cell holds no text, so its alignment has nothing of its own to move, and it is now written to be read from its top: 0.30pt from the page, and in a row the cell's own content fills — every one the fidelity corpus has — the same place as before, no line moved. Two cells more carry none. A repeated header row's: Word repeats such a row, and what is anchored in it, on every page the table reaches, beside the copy the layout placed there, and a cell written as a nested table hides that row from the anchor's own guard, which reads the row of the paragraph it anchors in. And one composed of a node the document places more than once, which the first commit refuses and the report's note now names. A cell whose only text is a link counted as text-free, since POI leaves a link's runs out of a paragraph's text: the cell is now read through the seat's own holdsText, and a picture is found through the runs rather than an XPath. A shape may be given a cell short of the rise it would be placed at the top from, not up to it, so the two agree at three tenths of a point. The unused two-argument queueOrdered is gone. --- CHANGELOG.md | 9 ++- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 8 +- .../semantic/docx/DocxDrawingAnchors.java | 79 ++++++++++++++----- .../semantic/docx/DocxSemanticBackend.java | 11 ++- .../docx/DocxComposedCellAnchorsTest.java | 43 ++++++++++ .../docx/DocxComposedCellHolderTest.java | 17 ++++ 7 files changed, 144 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b65a1046..61897734b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,10 +34,17 @@ follow semantic versioning; release dates are ISO 8601. neighbouring cell takes one only where it, and each cell holding its table, sets its content from its top. It was held to its own alignment alone, and a cell inside one set to the middle took a drawing LibreOffice would then have moved its row's text for. + - The cell that carries the drawing is written to be read from its top, which it holds no text + to be read any other way by. Word centres a cell set to the middle along with the drawing its + paragraph carries: measured in Word 16.0.20527, a tile carried by such a cell in a row four + lines tall stood 7.10pt below where the page puts it, and 0.30pt from it once the cell was + read from its top; in a row the cell's own content fills — every one the fidelity corpus has + — the two are the same place and nothing in the file moves. - A cell composed of a node that stands in more than one place carries none either: such a node is laid out once, so the lines of the paragraphs written for it in the second cell are the first cell's, and a cell that cannot say where its own content stands cannot place a drawing - from it. + from it. Nor does a cell of a repeated header row: Word repeats such a row, and what is + anchored in it, on every page the table reaches, beside the copy the layout placed there. - Of the 190 drawings the fidelity corpus composed in table cells, 165 are now anchored in their cell. The 25 left are `PaymentsInvoice`'s, `WorkspaceInvoice`'s and `MerchantInvoice`'s, whose rows nest a row inside a cell: the cell between the composed cell diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 6a697518c..6f13022b3 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -73,7 +73,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the first paragraph able to carry it of the cell whose box the layout placed it in, which stands for the block holding it (a cell set to the middle or the bottom only where the page fills its box and the carrying cell holds no text), and in the paragraph whose text is nearest it within reach where no cell's box holds it; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the first paragraph able to carry it of the cell whose box the layout placed it in, which stands for the block holding it, the carrying cell written to be read from its top where it holds no text (a cell set to the middle or the bottom takes one only where the page fills its box and the carrying cell holds no text; a repeated header row and a cell composed of a node placed more than once take none), and in the paragraph whose text is nearest it within reach where no cell's box holds it; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 3d15a8ab7..d82399a3a 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1542,7 +1542,13 @@ starts). What a cell between the composed cell and the drawing's own leaves its does not say, so a drawing in a row nested inside a cell set to the middle stays where the page puts it. So does one in a cell composed of a node that stands in more than one place: such a node is laid out once, so the lines of the paragraphs written for it in the second cell are the first -cell's. So does a drawing no cell's box holds — one the table paints across its cells — and one +cell's. So does one in a repeated header row, which Word repeats on every page the table reaches +with what is anchored in it, beside the copy the layout placed there. The cell that does carry a +drawing is written to be read from its top, which it holds no text to be read any other way by: +Word centres a cell set to the middle along with the drawing its paragraph carries, which stood a +tile 7.10pt below where the page puts it in a row four lines tall, and 0.30pt from it once the +cell was read from its top (in a row the cell's own content fills, the two are the same place). +So does a drawing no cell's box holds — one the table paints across its cells — and one whose cell writes no paragraph that can carry it: those are anchored in the paragraph whose text is nearest, within 48pt across and down together, when they rise above that paragraph's top by their own height at most, 24pt at most, as a dot centred on a first line does — in a table cell no diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index 6680ffb43..1a1413a5a 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -315,11 +315,6 @@ void queue(List shapes, int painted, Owner owner) { queueOrdered(ordered(shapes, painted), owner, null); } - /** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */ - void queueOrdered(List shapes, Owner owner) { - queueOrdered(shapes, owner, null); - } - /** * Keeps shapes given their places in the paint order for the section's end, as {@link #queue} * does, with the cell holding them. @@ -674,6 +669,10 @@ private Place placeBeside(DocxDrawings.Shape shape) { * wherever an edit takes it. */ private void anchorBeside(XWPFParagraph paragraph, List shapes) { + if (shapes.stream().anyMatch(placed -> placed.place().setItsCell()) + && paragraph.getBody() instanceof XWPFTableCell cell) { + readItFromItsTop(cell); + } XWPFRun run = paragraph.insertNewRun(0); for (Placed placed : shapes) { Ordered ordered = placed.ordered(); @@ -691,6 +690,21 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { } } + /** + * Reads a cell from its top, which it holds no text to be read any other way by: Word centres + * a cell set to the middle along with the drawing its paragraph carries, which stood a tile + * 7.10pt below where the page puts it in a row four lines tall, and 0.30pt from it once the + * cell was read from its top (measured in Word 16.0.20527 and LibreOffice 24.2; in a row the + * cell's own content fills, the two are the same place and nothing in the file moves). + */ + private static void readItFromItsTop(XWPFTableCell cell) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); + if (properties.isSetVAlign()) { + properties.getVAlign().setVal( + org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc.TOP); + } + } + /** * How far from a paragraph's text a shape a table's composed cell draws still stands beside * it, across and down added, in points: an icon beside its label in another cell. One further @@ -904,7 +918,8 @@ Place placeHeld(DocxDrawings.Shape shape, double top, CTTc filled) { // Only the cell that ends up carrying the shape has to be set from its top: this one // may simply be where the row's text is measured, and hand the shape to a neighbour. Place here = setFromTheTop(cell, filled) - ? new Place(paragraph, cellLeft + leftMargin(cell), top) : null; + ? new Place(paragraph, cellLeft + leftMargin(cell), top, + !alignedToTheTop(cell) && !holdsAnyText(cell)) : null; if (holdsAcross(shape, cellLeft, width)) { return here; } @@ -937,13 +952,15 @@ private static boolean setFromTheTop(XWPFTableCell cell, CTTc filled) { } /** - * Whether a cell holds anything an editor could set lower, at any depth: a line of text, - * or a picture or a drawing of its own, which an editor moves with the text. + * Whether a cell holds anything an editor could set lower, at any depth: a line of text — + * a link's among it, which POI leaves out of a paragraph's own text + * ({@link #firstRunOfText}) — or a picture or a drawing of its own, which an editor moves + * with the text. */ private static boolean holdsAnyText(XWPFTableCell cell) { for (IBodyElement element : cell.getBodyElements()) { if (element instanceof XWPFParagraph paragraph - && (!paragraph.getText().isBlank() || draws(paragraph))) { + && (new Seat(paragraph, 0, 0, 0, 0, null).holdsText() || draws(paragraph))) { return true; } if (element instanceof org.apache.poi.xwpf.usermodel.XWPFTable table @@ -955,11 +972,17 @@ private static boolean holdsAnyText(XWPFTableCell cell) { return false; } - /** Whether a paragraph holds a picture or a drawing of its own. */ + /** Whether a paragraph holds a picture or a drawing of its own, a link's among them. */ private static boolean draws(XWPFParagraph paragraph) { - return paragraph.getCTP().selectPath( + for (org.apache.xmlbeans.XmlObject found : paragraph.getCTP().selectPath( "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' " - + "./w:r/w:drawing | ./w:r/w:pict").length > 0; + + "./w:r | ./w:hyperlink/w:r")) { + if (found instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run + && (run.sizeOfDrawingArray() > 0 || run.sizeOfPictArray() > 0)) { + return true; + } + } + return false; } /** @@ -978,8 +1001,10 @@ private static boolean draws(XWPFParagraph paragraph) { * above its paragraph ({@link #placeHeld}).

*/ private static boolean risesNoFurther(DocxDrawings.Shape shape, double top, boolean inCell) { - double rise = inCell ? READABLE_RISE : Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE)); - return shape.top() >= top - rise; + // In a cell, short of the rise: one at it is written as its own offset, which is what + // this holds a cell clear of. + return inCell ? shape.top() > top - READABLE_RISE + : shape.top() >= top - Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE)); } /** @@ -1048,7 +1073,7 @@ private static Place openingPlace(XWPFTableCell cell, double cellLeft, double to || !(opening.get(0) instanceof XWPFParagraph first) || borderedAbove(first)) { return null; } - return new Place(first, cellLeft + leftMargin(cell), top); + return new Place(first, cellLeft + leftMargin(cell), top, !alignedToTheTop(cell)); } /** Where the paragraph's text's left edge stands without its indent; NaN when not known. */ @@ -1121,12 +1146,26 @@ private static double points(CTTblWidth width, double otherwise) { /** * Where a shape is anchored and placed from. * - * @param paragraph the paragraph it is anchored in - * @param column where Word's column starts, from the page's left edge; infinite for a - * paragraph of the body, whose shapes are placed from the page's edge - * @param top where the paragraph's top stands, from the page's top edge + * @param paragraph the paragraph it is anchored in + * @param column where Word's column starts, from the page's left edge; infinite for a + * paragraph of the body, whose shapes are placed from the page's edge + * @param top where the paragraph's top stands, from the page's top edge + * @param setItsCell whether the paragraph's cell is to be read from its top, which it is + * where it is set to the middle or the bottom and holds no text: Word + * centres such a cell's paragraph along with the drawing it carries, and + * the row's top is where the page measured the paragraph this shape is + * placed from. Measured in Word 16.0.20527: a tile carried by a cell set + * to the middle of a row four lines tall stood 7.10pt below where the page + * puts it, and 0.30pt from it once that cell was read from its top; in a + * row the cell's own content fills, the two are the same place. Nothing + * moves with it, the cell holding no text ({@link Seat#holdsAnyText}) */ - record Place(XWPFParagraph paragraph, double column, double top) { + record Place(XWPFParagraph paragraph, double column, double top, boolean setItsCell) { + + /** A place in a paragraph whose cell is left as it is. */ + Place(XWPFParagraph paragraph, double column, double top) { + this(paragraph, column, top, false); + } } /** A shape and where it is placed from. */ 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 54af0638c..6eeff802e 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 @@ -231,8 +231,9 @@ public final class DocxSemanticBackend implements SemanticBackend { */ private static final String ANCHORED_IN_ITS_CELL = "anchored in the first paragraph able to carry it " + "of the cell whose box holds it, so it moves with that cell's row when the text above is " - + "edited; " + ANCHORED_BESIDE_ITS_TEXT + " where no cell's box holds it, or where that cell " - + "has no such paragraph on its page"; + + "edited; " + ANCHORED_BESIDE_ITS_TEXT + " where no cell's box holds it, where that cell has " + + "no such paragraph on its page, and where it stands in a repeated header row or in a cell " + + "composed of a node the document places more than once"; /** How a drawing a node paints is anchored, as the report says it (see DocxDrawingAnchors). */ private static final String ANCHORED_IN_ITS_BLOCK = "anchored in the first paragraph able to carry it " + "that the nearest block holding it writes on its page, so it moves with that block when the text " @@ -16336,7 +16337,13 @@ private List composedCellsOf(TableNode table) { // A node standing in more than one place is laid out once, so the lines of the // paragraphs written for it in this cell are another cell's: the cell cannot say // where its own content stands, and its drawings keep to the text nearest them. + // A cell of a repeated header row carries none either: Word repeats such a row, + // and what is anchored in it, on every page the table reaches, so the drawing + // would stand on each of them beside the layout's own copy there. A cell written + // as a nested table hides that from the anchor, whose own guard reads the row of + // the paragraph it anchors in — by then an inner row of its own. if (written == null || placement.cell().content() == null + || placement.row() < table.repeatedHeaderRowCount() || layout.placedMoreThanOnce(placement.cell().content())) { continue; } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java index 56edc764f..aa0785bb0 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java @@ -84,6 +84,25 @@ void aCellTheLayoutDoesNotFillCarriesNoneWhileItIsSetToTheMiddle() { .contains(""); } + @Test + void theCarryingCellIsReadFromItsTop() { + // Word centres a cell set to the middle along with the drawing its paragraph carries, + // which stood a tile 7.10pt below where the page puts it in a row four lines tall, and + // 0.30pt from it once the cell was read from its top. The cell holds no text, so nothing + // in the file moves with it. + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(verticalAlignOf(composed.tileCell())).as("the tile's own cell").isEqualTo("top"); + assertThat(verticalAlignOf(composed.title())).as("the text's cell, left as it is") + .isEqualTo("center"); + } + @Test void aCellSetToTheMiddleHoldingTextCarriesNoneOfItsOwn() { // The tile over the text's own cell, which is set to the middle and holds text: that cell @@ -172,6 +191,23 @@ void aCellHoldingOnlyAPictureCarriesNoDrawing() { .contains(""); } + @Test + void aCellWhoseOnlyTextIsALinkCarriesNoDrawing() { + // POI leaves a link's runs out of a paragraph's own text, so a cell holding nothing but a + // cross-reference would read as text-free and be excused its middle alignment. + Composed composed = composed(new XWPFDocument()); + composed.tileCell().getCTP().addNewHyperlink().addNewR().addNewT().setStringValue("See page"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + @Test void aRiseWordWouldNotReadBackIsPlacedAtTheParagraphsTop() { // Word reports an offset of 0.05 to 0.29pt above a paragraph as one of its position @@ -281,6 +317,13 @@ private static void middle(XWPFTableCell cell) { properties.addNewVAlign().setVal(STVerticalJc.CENTER); } + /** How the cell holding a paragraph is read, top to bottom, or "top" where it does not say. */ + private static String verticalAlignOf(XWPFParagraph paragraph) { + CTTcPr properties = ((XWPFTableCell) paragraph.getBody()).getCTTc().getTcPr(); + return properties == null || !properties.isSetVAlign() ? "top" + : properties.getVAlign().getVal().toString(); + } + private static List drawingsIn(XWPFParagraph paragraph) { Matcher matcher = Pattern.compile("", Pattern.DOTALL) .matcher(paragraph.getCTP().xmlText()); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java index 7ede5d9b8..a694ced19 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellHolderTest.java @@ -91,6 +91,23 @@ void oneNodeComposedIntoTwoCellsTakesNoCell() throws Exception { } } + @Test + void aComposedCellOfARepeatedHeaderRowTakesNone() throws Exception { + // Word repeats such a row, and what is anchored in it, on every page the table reaches, + // so the drawing would stand on each of them beside the layout's own copy there. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(line())) + .rowCells(DocumentTableCell.text("Below")) + .repeatHeader()))) { + XWPFParagraph carrier = document.getTables().get(0).getRow(0).getCell(0) + .getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + + assertThat(placedFrom(carrier)).as("the header's own cell takes none").isEmpty(); + assertThat(onThePage(document)).as("left where the page puts them").isPositive(); + } + } + @Test void theReportSaysWhereWhatACellsDrawGoes() throws Exception { DocxExportReport report = DocxExports.report(400, 600, 20, page -> page.addTable(t -> t From 5f300301794ac623565bcc9d39a2791482742246 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 29 Oct 2026 17:13:02 +0000 Subject: [PATCH 3/3] fix(docx): hold a carrying cell to what an editor would set lower in it A cell was excused its vertical alignment, and written to be read from its top, whenever it held no text. A paragraph border is drawn round its paragraph and moves with it, and a table of the cell's own stands in it and may be ruled or shaded: both now count, as a picture and a link's text already did. The guard is named for what it answers, holdsAnythingToSetLower. The flag that asks for the cell to be read from its top was computed in two places, and in the one serving the cell that carries the drawing in its own paragraph it could never be true: a place is offered only for a paragraph holding text, so that cell always holds some. It is computed where it can be true and the other says why it is not. readItFromItsTop is asked for only where the cell says it is read some other way, so it rewrites that alignment and adds none. The room a composed cell's box leaves its content is read with the layout's y growing up the page, as every reading of a fragment's top does, so the room above the content is measured against the cell's top margin. The locals now say which edge they hold, and a comment says which way the axis runs: the names read as their opposite and invited the reading that the two margins were crossed. The documents said a cell carrying a drawing must hold no text, which is true of a cell inside the one the page fills and not of that cell itself, and that a drawing the rule refuses stays where the page puts it, when it keeps the nearest-text rule. Both are corrected, with the fallbacks the report's note names. LibreOffice's cumulative drift is given per row, and the 0.96pt a tile stood low is tied to the 0.97pt that was squared to nothing. --- CHANGELOG.md | 28 +++++---- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 13 +++-- .../semantic/docx/DocxDrawingAnchors.java | 43 ++++++++------ .../semantic/docx/DocxSemanticBackend.java | 17 +++--- .../docx/DocxComposedCellAnchorsTest.java | 57 ++++++++++++++++--- 6 files changed, 108 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61897734b..4cb0dc5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,21 +15,21 @@ follow semantic versioning; release dates are ISO 8601. it was nearest, or on the page where it stood beside none. Six invoices' service tiles were placed from the page's edges: edited above, every line row moved and the tiles did not, and LibreOffice 24.2, which sets those rows 1.96pt shorter than the page does, left each tile - further out of its row than the last — 7.9pt by the fifth. + further out of its row than the last: 0.94, 2.94, 4.89, 6.84 and 8.79pt down the five rows. - The cell whose box the layout placed the drawing in now stands for the block holding it, and the drawing is anchored in the first paragraph of that cell able to carry it, placed from that cell's text column and the paragraph's top, exactly as a drawing a node paints is. A drawing no cell's box holds — one the table paints across its cells — keeps the nearest-text rule, as does one whose cell writes no paragraph that can carry it. - - A cell set to the middle or the bottom takes one only where the page fills its box and the - cell carrying the drawing holds no text of its own, since LibreOffice 24.2 sets a cell - carrying a drawing — and the cell holding its table — from its top. Measured in Word - 16.0.20527 and LibreOffice 24.2: with room to centre in, such a cell had its own text set 4pt - and 23.75pt higher and the text of the cell holding its table 43.7pt higher, while a cell the - page fills moved no line in either editor, and nor did one holding no text whatever room it - was left — but a cell with room to centre in centres the drawing along with the paragraph - carrying it, so where the drawing stands no longer tells where that paragraph starts, and - that is why the page must fill the cell either way. + - A cell set to the middle or the bottom takes one only where the page fills its box, since + LibreOffice 24.2 sets a cell carrying a drawing — and the cell holding its table — from its + top. Measured in Word 16.0.20527 and LibreOffice 24.2: with room to centre in, such a cell had + its own text set 4pt and 23.75pt higher and the text of the cell holding its table 43.7pt + higher, while a cell the page fills moved no line in either editor, and nor did one holding no + text whatever room it was left. The cell the page fills is excused its own alignment whatever + it holds — the page leaves it nothing to centre — while a cell *inside* it must hold nothing + an editor would set lower: no text, a link's included, no picture, no paragraph border and no + table of its own. - The same rule now holds for a drawing the text nearest it carries, which it did not: a neighbouring cell takes one only where it, and each cell holding its table, sets its content from its top. It was held to its own alignment alone, and a cell inside one set to the middle @@ -45,6 +45,9 @@ follow semantic versioning; release dates are ISO 8601. first cell's, and a cell that cannot say where its own content stands cannot place a drawing from it. Nor does a cell of a repeated header row: Word repeats such a row, and what is anchored in it, on every page the table reaches, beside the copy the layout placed there. + A drawing any of these refuses keeps the nearest-text rule, as one no cell's box holds does — + the paragraph whose text it stands nearest, within reach, and the page where it stands beside + none. The report's note names all four. - Of the 190 drawings the fidelity corpus composed in table cells, 165 are now anchored in their cell. The 25 left are `PaymentsInvoice`'s, `WorkspaceInvoice`'s and `MerchantInvoice`'s, whose rows nest a row inside a cell: the cell between the composed cell @@ -63,8 +66,9 @@ follow semantic versioning; release dates are ISO 8601. (−999999, −999997, −999994, −999993), while 0.30pt and every larger offset — 0.40, 0.50, 0.60, 0.75, 0.97 — is reported as itself. A shape standing less than 0.30pt above its paragraph is placed at its top, where it practically stands; one standing further keeps its - own offset. A stroke's width was allowed before, which stood a service tile 0.96pt low. - - A table cell is given no drawing standing further above its paragraph than that, on the + own offset. A stroke's width was allowed before, which squared the 0.97pt a service tile + needed to nothing and stood it 0.96pt low. + - A table cell is given no drawing standing 0.30pt or further above its paragraph, on the nearest-text rule: a 20.5pt rule placed a stroke's width above its paragraph in a cell moved that row's text 1.8pt up in the Linux LibreOffice the build checks with, and such an offset is now written rather than squared to nothing. diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 6f13022b3..f5c74a5ba 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -73,7 +73,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the first paragraph able to carry it of the cell whose box the layout placed it in, which stands for the block holding it, the carrying cell written to be read from its top where it holds no text (a cell set to the middle or the bottom takes one only where the page fills its box and the carrying cell holds no text; a repeated header row and a cell composed of a node placed more than once take none), and in the paragraph whose text is nearest it within reach where no cell's box holds it; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the first paragraph able to carry it of the cell whose box the layout placed it in, which stands for the block holding it, the carrying cell written to be read from its top where it holds no text (a cell set to the middle or the bottom takes one only where the page fills its box, which is then excused its own alignment whatever it holds while a cell inside it must hold nothing an editor would set lower; a repeated header row and a cell composed of a node placed more than once take none, and what they refuse keeps the nearest-text rule), and in the paragraph whose text is nearest it within reach where no cell's box holds it; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index d82399a3a..4dfa29e8d 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1531,7 +1531,8 @@ holds it rather than by a path, since the layout names a node standing in two ce set to the middle or the bottom takes one only where the page fills its box — where what the layout placed in it reaches within a stroke's width of the room its margins leave, above and below, so that an editor setting the cell's content from its top has nothing to move — and the -cell carrying the drawing must then either be set from its top or hold no text of its own +cell carrying the drawing must then either be set from its top or, where it stands inside the cell +the page fills, hold nothing an editor would set lower (measured in Word 16.0.20527 and LibreOffice 24.2: a cell set to the middle with room to centre in had its own text set 4pt and 23.75pt higher when it carried a drawing, and the text of the cell holding its table 43.7pt higher in LibreOffice, while a cell the page fills moved no line in @@ -1539,8 +1540,8 @@ either editor, and nor did one holding no text whatever room it was left; the pa cell either way, because a cell with room to centre in centres the drawing along with the paragraph carrying it, and where the drawing stands then no longer tells where that paragraph starts). What a cell between the composed cell and the drawing's own leaves its content the layout -does not say, so a drawing in a row nested inside a cell set to the middle stays where the page -puts it. So does one in a cell composed of a node that stands in more than one place: such a node +does not say, so a drawing in a row nested inside a cell set to the middle keeps the nearest-text +rule below. So does one in a cell composed of a node that stands in more than one place: such a node is laid out once, so the lines of the paragraphs written for it in the second cell are the first cell's. So does one in a repeated header row, which Word repeats on every page the table reaches with what is anchored in it, beside the copy the layout placed there. The cell that does carry a @@ -1548,9 +1549,9 @@ drawing is written to be read from its top, which it holds no text to be read an Word centres a cell set to the middle along with the drawing its paragraph carries, which stood a tile 7.10pt below where the page puts it in a row four lines tall, and 0.30pt from it once the cell was read from its top (in a row the cell's own content fills, the two are the same place). -So does a drawing no cell's box holds — one the table paints across its cells — and one -whose cell writes no paragraph that can carry it: those are anchored in the paragraph whose text is -nearest, within 48pt across and down together, when they rise above that paragraph's top by their +A drawing no cell's box holds — one the table paints across its cells — and one whose cell writes +no paragraph that can carry it keep that same rule: all of them are anchored in the paragraph whose +text is nearest, within 48pt across and down together, when they rise above that paragraph's top by their own height at most, 24pt at most, as a dot centred on a first line does — in a table cell no further above it than they are then placed at that top, since a rule a stroke's width above its paragraph in a cell moved that row's text 1.8pt up in the Linux LibreOffice the build checks with diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index 1a1413a5a..b39cd1eb2 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -698,11 +698,10 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { * cell's own content fills, the two are the same place and nothing in the file moves). */ private static void readItFromItsTop(XWPFTableCell cell) { - CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); - if (properties.isSetVAlign()) { - properties.getVAlign().setVal( - org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc.TOP); - } + // Asked for only where the cell says it is read some other way (Place#setItsCell), so it + // has the alignment this rewrites and none is added. + cell.getCTTc().getTcPr().getVAlign().setVal( + org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc.TOP); } /** @@ -917,9 +916,10 @@ Place placeHeld(DocxDrawings.Shape shape, double top, CTTc filled) { } // Only the cell that ends up carrying the shape has to be set from its top: this one // may simply be where the row's text is measured, and hand the shape to a neighbour. + // Its own alignment is left alone whatever it says: a seat is offered only for a + // paragraph holding text, so this cell holds text to be set lower. Place here = setFromTheTop(cell, filled) - ? new Place(paragraph, cellLeft + leftMargin(cell), top, - !alignedToTheTop(cell) && !holdsAnyText(cell)) : null; + ? new Place(paragraph, cellLeft + leftMargin(cell), top) : null; if (holdsAcross(shape, cellLeft, width)) { return here; } @@ -942,7 +942,7 @@ Place placeHeld(DocxDrawings.Shape shape, double top, CTTc filled) { private static boolean setFromTheTop(XWPFTableCell cell, CTTc filled) { for (XWPFTableCell at = cell; at != null; ) { boolean nothingToSetLower = filled != null && (at.getCTTc() == filled - || at == cell && !holdsAnyText(at)); + || at == cell && !holdsAnythingToSetLower(at)); if (!alignedToTheTop(at) && !nothingToSetLower) { return false; } @@ -952,26 +952,33 @@ private static boolean setFromTheTop(XWPFTableCell cell, CTTc filled) { } /** - * Whether a cell holds anything an editor could set lower, at any depth: a line of text — - * a link's among it, which POI leaves out of a paragraph's own text - * ({@link #firstRunOfText}) — or a picture or a drawing of its own, which an editor moves - * with the text. + * Whether a cell holds anything an editor would set lower were the cell read from its top: + * a paragraph holding text — a link's among it, which POI leaves out of a paragraph's own + * text ({@link #firstRunOfText}) — a picture, a drawing, or a border, which is drawn round + * the paragraph and moves with it; and a table of any kind, empty or not, which stands in + * the cell and may be ruled or shaded. Only an empty paragraph with nothing drawn on it is + * nothing to set lower, which is what a cell drawn into alone holds. */ - private static boolean holdsAnyText(XWPFTableCell cell) { + private static boolean holdsAnythingToSetLower(XWPFTableCell cell) { for (IBodyElement element : cell.getBodyElements()) { if (element instanceof XWPFParagraph paragraph - && (new Seat(paragraph, 0, 0, 0, 0, null).holdsText() || draws(paragraph))) { + && (new Seat(paragraph, 0, 0, 0, 0, null).holdsText() || draws(paragraph) + || bordered(paragraph))) { return true; } - if (element instanceof org.apache.poi.xwpf.usermodel.XWPFTable table - && table.getRows().stream().flatMap(row -> row.getTableCells().stream()) - .anyMatch(Seat::holdsAnyText)) { + if (element instanceof org.apache.poi.xwpf.usermodel.XWPFTable) { return true; } } return false; } + /** Whether a paragraph has a border of its own, which is drawn round it and moves with it. */ + private static boolean bordered(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().getPPr(); + return properties != null && properties.isSetPBdr(); + } + /** Whether a paragraph holds a picture or a drawing of its own, a link's among them. */ private static boolean draws(XWPFParagraph paragraph) { for (org.apache.xmlbeans.XmlObject found : paragraph.getCTP().selectPath( @@ -1158,7 +1165,7 @@ private static double points(CTTblWidth width, double otherwise) { * to the middle of a row four lines tall stood 7.10pt below where the page * puts it, and 0.30pt from it once that cell was read from its top; in a * row the cell's own content fills, the two are the same place. Nothing - * moves with it, the cell holding no text ({@link Seat#holdsAnyText}) + * moves with it, the cell holding no text ({@link Seat#holdsAnythingToSetLower}) */ record Place(XWPFParagraph paragraph, double column, double top, boolean setItsCell) { 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 6eeff802e..d4599a2e7 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 @@ -16396,19 +16396,22 @@ private DocxDrawingAnchors.HoldingCell cellHolding(TableNode table, List */ private boolean pageFillsTheCell(TableNode table, DocxLayoutMetrics.CellBox box, XWPFTableCell written) { - double upper = Double.NEGATIVE_INFINITY; - double lower = Double.POSITIVE_INFINITY; + // The layout's y grows up the page, as every reading of a fragment's top shows + // (canvasHeight - y - height), so a box's top edge is its larger y and a fragment's top is + // y + height. The room above the content is measured against the cell's top margin. + double contentTop = Double.NEGATIVE_INFINITY; + double contentBottom = Double.POSITIVE_INFINITY; for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(table)) { if (box.holds(fragment)) { - upper = Math.max(upper, Math.min(box.top(), fragment.y() + fragment.height())); - lower = Math.min(lower, Math.max(box.bottom(), fragment.y())); + contentTop = Math.max(contentTop, Math.min(box.top(), fragment.y() + fragment.height())); + contentBottom = Math.min(contentBottom, Math.max(box.bottom(), fragment.y())); } } - if (Double.isInfinite(upper)) { + if (Double.isInfinite(contentTop)) { return false; } - return box.top() - upper - cellMargin(written, true) / TWIPS_PER_POINT <= CELL_FILL_SLACK - && lower - box.bottom() - cellMargin(written, false) / TWIPS_PER_POINT <= CELL_FILL_SLACK; + return box.top() - contentTop - cellMargin(written, true) / TWIPS_PER_POINT <= CELL_FILL_SLACK + && contentBottom - box.bottom() - cellMargin(written, false) / TWIPS_PER_POINT <= CELL_FILL_SLACK; } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java index aa0785bb0..ed5a116dc 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellAnchorsTest.java @@ -6,6 +6,7 @@ import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc; @@ -106,22 +107,62 @@ void theCarryingCellIsReadFromItsTop() { @Test void aCellSetToTheMiddleHoldingTextCarriesNoneOfItsOwn() { // The tile over the text's own cell, which is set to the middle and holds text: that cell - // cannot carry it, since LibreOffice would set its text from the cell's top. The drawing - // goes no further than the rule the export had before, which seats it beside that text — - // read off its own offset, which that rule measures from the text and this one would - // measure from the row's first paragraph. + // cannot carry it, since LibreOffice would set its text from the cell's top. It stands + // 60pt below that text, further than the nearest-text rule reaches, so nothing else can + // take it either and it keeps the page — where that cell carrying it would have put it in + // the cell instead. Composed composed = composed(new XWPFDocument()); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, composed.outside(), true); anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); - queue(anchors, List.of(tile(70, 100.5)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + queue(anchors, List.of(tile(70, 160)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); anchors.endSection(0, null, composed.document()::createParagraph); assertThat(drawingsIn(composed.tileCell())).isEmpty(); - String drawing = drawingsIn(composed.title()).get(0); - assertThat(offsetAcross(drawing)).as("beside the text, from its own column").isCloseTo(10, within(0.01)); - assertThat(offsetDown(drawing)).as("half a point below the text's top").isCloseTo(0.5, within(0.01)); + assertThat(drawingsIn(composed.title())).isEmpty(); + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aCellHoldingOnlyABorderedParagraphCarriesNoDrawing() { + // A border is drawn round its paragraph and moves with it, so such a cell is not read from + // its top for a drawing: the rule it draws would move from the cell's middle to the row's + // top. Under the paragraph, where the rule a border draws stands and where the refusal of + // a paragraph bordered above it does not reach. + Composed composed = composed(new XWPFDocument()); + CTPPr properties = composed.tileCell().getCTP().isSetPPr() + ? composed.tileCell().getCTP().getPPr() : composed.tileCell().getCTP().addNewPPr(); + properties.addNewPBdr().addNewBottom().setVal( + org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder.SINGLE); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(22, 99)), new DocxDrawingAnchors.HoldingCell(composed.cell(), true)); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); + } + + @Test + void aDrawingIsNotGivenACellFromFurtherAboveThanItIsPlacedAtItsTop() { + // 0.4pt above the text's paragraph: further than the rise a shape is placed at that top + // from, so no cell is given it to carry (a rule placed above its paragraph in a cell moved + // that row's text 1.8pt up in the Linux LibreOffice the build checks with). + Composed composed = composed(new XWPFDocument()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, composed.outside(), true); + anchors.seat(0, composed.title(), TITLE, composed.title(), 60, 300, 100, 110); + + queue(anchors, List.of(tile(70, 99.6)), null); + anchors.endSection(0, null, composed.document()::createParagraph); + + assertThat(drawingsIn(composed.title())).isEmpty(); + assertThat(drawingsIn(composed.outside())).singleElement().asString() + .contains(""); } @Test