diff --git a/CHANGELOG.md b/CHANGELOG.md index 5746c8590..6c575803e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **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 + paragraph's top by 24pt at most — in a table cell by a stroke's width —, and otherwise was + placed from the page's edges, where it stayed while the text moved away from it: an icon laid + over a card's corner, a sidebar's portrait, a skill's meter beside its label in a row, a + timeline's dots in a column of their own. + - A drawing a node paints is now anchored in the first paragraph able to carry it that the + nearest block holding it writes on its page — the node itself, a layer stack, a panel, a row, + but not the page's flow —, placed from that paragraph's top however far from its text it + stands. In a table cell, the cell of that paragraph's row holding the drawing across takes it, + its own where it stands in a column of its own; otherwise the paragraph's own cell keeps it, + placed out of its column, unless it holds text — a text box, a badge's letters —, which + LibreOffice 26.8 pulls back into the column. A cell set to the middle or the bottom, or + 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. + - 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 + behind it, and across the fidelity corpus from 155pt above its paragraph to 620pt below —; + neither clips it nor moves a line for it, and both move it with its row when the text above + is edited. LibreOffice 24.2 sets a cell carrying a drawing from its top whatever its vertical + alignment says, 16pt high in a cell set to the middle and 32pt in one set to the bottom. + - Of the 465 drawings the fidelity corpus placed on the page, 270 were drawn by a node, 5 were + timelines' rails and 190 were composed in table cells. All but 14 of the first 275 are now + anchored in a paragraph: the text boxes of `LumaStudio`'s sidebar and closing note, which no + paragraph of their column holds across, and two drawings on pages whose only text is a pair + of lines. + - **A DOCX export writes a painted box holding text in the body as a panel, its text inside its outline.** - A shape container shaped as a rectangle or a rounded rectangle, filled or outlined, holding diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 808a3ef6f..f804b8074 100644 Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 670bb8cc6..c72562112 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 paragraph whose text it stands beside 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 paragraph whose text is nearest within reach 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 paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, 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 stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in 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` | | 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 32515cfa7..5df776c49 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -73,9 +73,9 @@ creation date is real metadata. | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture in the flow, a stack of one layer's included, or first in a band of layers there, that the layout moves to a new page keeps the space the layout leaves above it there — its margin, its padding, the room above it in its box, the edges of the containers opening with it and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, as a body paragraph does. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | -| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line; a section or a container written as a layer stack's column wraps what it writes the same way, from a block of it an earlier layer's stand-in holds, written there first, to the last it writes. A block that writes no paragraph — a section, a container or a layer stack's column of drawings alone, whose drawings stand in the paragraph beside them or in one written on their page — is bookmarked at the start of the paragraph its first drawing is anchored in, the drawing on the earliest page and highest on it: Word keeps a drawing on its paragraph's page and moves it with that paragraph, so a page reference reads the page the block starts on and goes on reading it once the text above is edited, and a link goes to that page (measured in Word 16, fields updated, and LibreOffice 24.2: a block on page 2 read 2, and 3 with a page put in ahead of it, where the file had held no bookmark and the reference a fixed 2). A block that draws nothing, one whose first drawing stands on a page nothing is written on, and one composed in a table's cell, whose drawings its table draws, have no bookmark, and the report names the anchor: a link to it points at none, and a page reference to it is written as the number it reads. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | +| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line; a section or a container written as a layer stack's column wraps what it writes the same way, from a block of it an earlier layer's stand-in holds, written there first, to the last it writes. A block that writes no paragraph — a section, a container or a layer stack's column of drawings alone, whose drawings stand in a paragraph of a block holding it, in the one nearest them or in one written on their page — is bookmarked at the start of the paragraph its first drawing is anchored in, the drawing on the earliest page and highest on it: Word keeps a drawing on its paragraph's page and moves it with that paragraph, so a page reference reads the page the block starts on and goes on reading it once the text above is edited, and a link goes to that page (measured in Word 16, fields updated, and LibreOffice 24.2: a block on page 2 read 2, and 3 with a page put in ahead of it, where the file had held no bookmark and the reference a fixed 2). A block that draws nothing, one whose first drawing stands on a page nothing is written on, and one composed in a table's cell, whose drawings its table draws, have no bookmark, and the report names the anchor: a link to it points at none, and a page reference to it is written as the number it reads. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel | -| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). In a table cell — a row's column, a panel's cell — such a block keeps its lines together and nothing with the next: Word reads keep-with-next on the paragraph a cell opens with as keeping the whole row with what follows its table, and holds no keep across a break inside the row, so there those keeps did nothing else; a row of columns, which may not split, keeps the block whole (measured: `ObsidianInvoice`'s info cards, kept together in a row's two cells, went with the footer divider under them to the next page in Word, 17 lines below the page's; without keep-with-next in the cards, they stood on their page). The paragraph between two tables in a cell still keeps with the next table: Word does not read it as the paragraph the cell opens with, even after the table the cell opens with (measured: a row at the foot of a page stayed there either way). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms | +| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). In a table cell — a row's column, a panel's cell — such a block keeps its lines together and nothing with the next: Word reads keep-with-next on the paragraph a cell opens with as keeping the whole row with what follows its table, and holds no keep across a break inside the row, so there those keeps did nothing else; a row of columns, which may not split, keeps the block whole (measured: `ObsidianInvoice`'s info cards, kept together in a row's two cells, went with the footer divider under them to the next page in Word, 17 lines below the page's; without keep-with-next in the cards, they stood on their page). The paragraph between two tables in a cell still keeps with the next table: Word does not read it as the paragraph the cell opens with, even after the table the cell opens with (measured: a row at the foot of a page stayed there either way). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph of a block holding it, or near it, a page can end between it and the next block, and in that same flow the report names the keep it loses. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms | | Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there is held in its line, less, in a stack of one layer, its layer's offset, which is named. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | | Page breaks | Explicit Word page breaks | @@ -1097,8 +1097,9 @@ recolour what is under it in Word and it keeps the colour it was flattened to. among the layers: a panel opening the band writes its top padding again inside its cell, and one closing it its bottom padding, so the band stands that much too tall there. A card with 14pt padding under an icon laid over its corner stood 14pt low in - Word and LibreOffice, and the paragraph under it 28pt (`icon_over_card` in the - fidelity corpus holds its lines as they stand, not where the icon stands). What a + Word and LibreOffice, and the paragraph under it 28pt; the icon, anchored in the + card's text, stands on the card's corner as each editor sets the card + (`icon_over_card` in the fidelity corpus holds the lines, not where the icon stands). What a later layer's first block holds above its content past the page's distance — a margin, a padding, a contained picture's room reaching above where the layers before it end — comes out of its own @@ -1271,8 +1272,9 @@ recolour what is under it in Word and it keeps the colour it was flattened to. widened by that much, as Word cuts a cell's content at its edge. What a table cell composes (`DocumentTableCell.node(...)`) has no place of its own in the layout: its drawing belongs to the table, and the table draws it — an icon, a - tile, a disc under a number — where the page draws it, anchored as any - other shape is. A layer stack of shapes that is all its cell holds — an icon alone in + tile, a disc under a number — where the page draws it, anchored in the + paragraph whose text is nearest it, within reach: it has no node of its own for + a block to hold (see "drawn as shapes" below). A layer stack of shapes that is all its cell holds — an icon alone in the first column of a band — is anchored in that cell instead: the cell's paragraph is held at the drawing's height and carries it, placed from its top and the cell's text column, which is taken to start where the drawing @@ -1293,7 +1295,8 @@ recolour what is under it in Word and it keeps the colour it was flattened to. 1.35pt above their row in both editors, and Editorial's goal ticks up to 3.1pt. A drawing holding a line, a lone shape, a composed tile and a drawing sharing its table cell with another - stay on the page. A box framing text is left to the panel it is written as; + are not held in their cell so: what a node draws goes by the block holding it, + a composed tile to the text nearest it within reach, as "drawn as shapes" says. A box framing text is left to the panel it is written as; the layout does not say which of a cell's boxes is which node, so in a cell such a box reaches into a box node is reported dropped, and in any other the table draws it and does not report it dropped. @@ -1482,31 +1485,49 @@ Vertical and slanted lines, ellipses, rectangles and rounded bars — standalone layer stack, as a shape container's outline, or drawn by the layout itself, such as a timeline's rail — are **drawn as shapes**: each is a floating DrawingML shape, behind the text, at the place, size, fill and outline the layout gives it, and the report lists -it as approximated. A shape standing beside a paragraph's text — a timeline's dot by its -entry, an icon by its heading, a skill's bar by its label — is anchored in that paragraph -and placed down from its top, so it moves with the paragraph when a reader edits the text -above it. The paragraph is the one whose text is nearest the shape, within 48pt across -and down together, when the shape rises above its top by its own height at most, 24pt at -most, as a dot centred on its first line does — in a table cell by a stroke's width at most: -a rule placed 1pt above its paragraph in a cell moved its row's text in LibreOffice on Linux. -In a table cell, the cell that holds the shape across takes -it, from its text column: the paragraph's own cell, or another of its row from that cell's -first paragraph — a timeline's dot in a column of its own beside its entry's text. Both -editors start each cell's first paragraph at the row's top where the cells set their content -from the top, and give every cell of a row the row's largest top margin, so the other cell's -first paragraph starts where the text's does; one with a border above it is not used; a row whose top is not known that way, a line not set -from the left and a repeated header row carry none. When the nearest paragraph cannot carry -the shape, it stays on the page rather than going to a paragraph further off, which is not -the text it stands by: an icon laid over a panel's top corner whose nearest paragraph is -the panel's first, more than a stroke's width below the icon's top, stays where the page -draws it when the text above the panel moves. The paragraph's top is where Word places from: -the space written above its first line included, and the line where Word stands it — an -exact line's baseline four fifths of the way down it, raised by the run's position — -measured in Word 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph -holding text that a paragraph node is written as offers its place, in a column's cell too; a table's own cell -text and a list's items do not yet. - -A shape beside no paragraph stays where the page puts it whatever the text does. It is +it as approximated. A shape belongs to the node that draws it and to the blocks holding that +node — an icon laid over a card's corner to the card's stack, a skill's bar to its row, a +portrait to its sidebar — and is anchored in the first paragraph the nearest of them writes on +its page, placed down from that paragraph's top, so it moves with that block when a reader +edits the text above it. However far from the paragraph's text it stands, where it stands +gives only its offset: measured in Word 16.0.20527 and LibreOffice 24.2, a shape anchored in a +table cell's paragraph stands where both say when it rises above its row or its table, hangs +below them, stands out of its cell, is taller than its row, or sits in a row of an exact height +or in a nested table, in front of the text or behind it — from 155pt above its paragraph to +620pt below across the fidelity corpus; neither editor clips it or moves a line for it, and both move it with the row when the text above is edited. The page's flow, +which holds every block on its pages, is not such a block. In a table cell, the cell of the +paragraph's row that holds the shape across takes it, from its text column: the paragraph's +own cell, or the shape's own of its row from that cell's first paragraph — a timeline's dot in +a column of its own beside its entry's text. Both editors start each cell's first paragraph at +the row's top where the cells set their content from the top, and give every cell of a row the +row's largest top margin, so that cell's first paragraph starts where the text's does; one with +a border above it is not used. A cell that holds the shape across in no other way keeps it in +the paragraph, placed out of its column — unless the shape holds text, a text box or a badge's +letters: LibreOffice 26.8 pulled a text box placed 120pt left of its column back to the column's +start, where Word and LibreOffice 24.2 draw it where it is placed, so such a shape goes on to a +paragraph that holds it across, or to the page. A cell that sets its content from the middle or the +bottom, or sits in one, carries none: LibreOffice 24.2 sets the text of a cell carrying a +drawing from the cell's top, whatever the cell says — 16pt high in a cell set to the middle, +32pt in one set to the bottom, its row three lines tall — so the shape goes to that block's +next paragraph able to place it, or else to the next block up that writes one. A line not set from the cell's left and a repeated +header row carry none either. A shape no node draws — a timeline's rail — and one none of +whose blocks writes such a paragraph on its page — a badge beside a table holding its stack's +text — is anchored in the paragraph nearest it on its page that can place it, however far off. +The paragraph's top is where Word places from: the space written above its first line +included, and the line where Word stands it — an exact line's baseline four fifths of the way +down it, raised by the run's position — measured in Word 16.0.20430 and LibreOffice to a tenth +of a point; a paragraph with a border above its line, contextual spacing or space reckoned in +lines carries none. Only the first paragraph holding text that a paragraph node is written as +offers its place, in a column's cell too; a table's own cell text, a list's items and a pair +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. + +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, whether that was written before the shape or after — not in a table cell's, which Word measures it from. A page with no body paragraph — one laid out entirely in a diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index 2714dd32e..fd32e9832 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -2,6 +2,7 @@ # rows of invoice-metered_long, invoice-obsidian_long, synthetic-break_in_column, synthetic-break_in_panel, synthetic-card_over_pages: libreoffice build 420(Build:2) on linux # rows of synthetic-clipped_container, synthetic-narrow_container, synthetic-rounded_card: libreoffice build 420(Build:2) on linux # rows of synthetic-icon_over_card: libreoffice build 420(Build:2) on linux +# rows of cv-teal_pulse: libreoffice build 420(Build:2) on linux, from CI run 40503780286. Its email line, set in a cell set to the middle, stood +0.08pt while the contact separator was anchored in that cell, LibreOffice 24.2 then setting the cell from its top; the separator now goes elsewhere, as a cell set to the middle carries no drawing, and LibreOffice centres the cell as its neighbours: +0.93pt, Word +1.14pt. Ten other lines stand 0.1 to 0.3pt further, as the same run sets lines of documents the export does not change cv-blue_banner 0:8f1010120ed6 0.02 1.90 jordanrivera cv-blue_banner 0:8d7a2cca3060 0.00 0.14 +442055551000|jordan.rivera@exam cv-blue_banner 0:b944906ed84c -0.20 -0.38 professionalsummary @@ -1487,60 +1488,60 @@ cv-slate_orange 0:07ef0ade6bb2 0.05 0.07 •servsafefoodhandlercertificatio cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse cv-teal_pulse 0:613c7b77c405 1.08 0.17 manchester,unitedkingdom -cv-teal_pulse 0:4ddba42ce115 0.08 0.14 isabella.moore@email.com +cv-teal_pulse 0:4ddba42ce115 0.93 0.14 isabella.moore@email.com cv-teal_pulse 0:a8acc3a90a7b 0.68 0.17 +447700900123 cv-teal_pulse 0:84308bea4540 0.83 0.16 linkedin cv-teal_pulse 0:32c1cf49a2fe 0.15 0.10 skills -cv-teal_pulse 0:9836c1fde1ab -0.09 0.08 patientassessment -cv-teal_pulse 0:7344802265f6 -0.11 0.08 careplanning -cv-teal_pulse 0:32b1d161b612 -0.13 0.08 medicationadministration -cv-teal_pulse 0:b9fff349f0ab -0.15 0.08 clinicaldocumentation -cv-teal_pulse 0:cc981996b30d -0.16 0.08 infectionprevention -cv-teal_pulse 0:48d90339937f -0.18 0.08 ivtherapy -cv-teal_pulse 0:a392ecf06df5 -0.20 0.08 woundcare -cv-teal_pulse 0:7016cd40c2ef -0.21 0.08 patienteducation -cv-teal_pulse 0:ff0a6cdd9508 -0.23 0.08 safeguarding -cv-teal_pulse 0:7b6511893d61 -0.25 0.08 teamcollaboration -cv-teal_pulse 0:d9ff32cb0736 -0.26 0.08 dischargecoordination -cv-teal_pulse 0:ceb75137b9fb -0.28 0.08 electronichealthrecords -cv-teal_pulse 0:b944906ed84c -0.30 0.08 professionalsummary -cv-teal_pulse 0:f8343b9099ef 0.65 0.06 compassionateregisterednursewith -cv-teal_pulse 0:50347a4f84fc 0.64 0.06 high-qualitypatientcareacrossacu -cv-teal_pulse 0:d24f5be95f6a 0.63 0.06 settings.skilledinpatientassessm -cv-teal_pulse 0:824bf3935437 0.62 0.06 multidisciplinarycollaboration,d -cv-teal_pulse 0:8d526cd52034 0.60 0.06 accurateclinicaldocumentation.kn -cv-teal_pulse 0:4d61455b351b 0.59 0.06 communication,andpatient-centred -cv-teal_pulse 0:c2f296ef30a3 -0.35 0.08 professionalexperience +cv-teal_pulse 0:9836c1fde1ab 0.21 0.08 patientassessment +cv-teal_pulse 0:7344802265f6 0.19 0.08 careplanning +cv-teal_pulse 0:32b1d161b612 0.17 0.08 medicationadministration +cv-teal_pulse 0:b9fff349f0ab 0.15 0.08 clinicaldocumentation +cv-teal_pulse 0:cc981996b30d 0.14 0.08 infectionprevention +cv-teal_pulse 0:48d90339937f 0.12 0.08 ivtherapy +cv-teal_pulse 0:a392ecf06df5 0.10 0.08 woundcare +cv-teal_pulse 0:7016cd40c2ef 0.09 0.08 patienteducation +cv-teal_pulse 0:ff0a6cdd9508 0.07 0.08 safeguarding +cv-teal_pulse 0:7b6511893d61 0.05 0.08 teamcollaboration +cv-teal_pulse 0:d9ff32cb0736 0.04 0.08 dischargecoordination +cv-teal_pulse 0:ceb75137b9fb 0.02 0.08 electronichealthrecords +cv-teal_pulse 0:b944906ed84c 0.25 0.08 professionalsummary +cv-teal_pulse 0:f8343b9099ef 0.15 0.06 compassionateregisterednursewith +cv-teal_pulse 0:50347a4f84fc 0.14 0.06 high-qualitypatientcareacrossacu +cv-teal_pulse 0:d24f5be95f6a 0.13 0.06 settings.skilledinpatientassessm +cv-teal_pulse 0:824bf3935437 0.12 0.06 multidisciplinarycollaboration,d +cv-teal_pulse 0:8d526cd52034 0.10 0.06 accurateclinicaldocumentation.kn +cv-teal_pulse 0:4d61455b351b 0.09 0.06 communication,andpatient-centred +cv-teal_pulse 0:c2f296ef30a3 0.20 0.08 professionalexperience cv-teal_pulse 0:5cad9261fd60 0.14 0.06 seniorstaffnurse—manchesterroyal -cv-teal_pulse 0:5149e8e5e6b6 -0.08 0.05 deliverdirectnursingcareforadult -cv-teal_pulse 0:8dcc0d173718 -0.08 0.05 coordinatecareplanswithdoctors,t -cv-teal_pulse 0:325626fb2f7a -0.09 0.05 supervisejuniornursesandsupports -cv-teal_pulse 0:c2864de08768 -0.09 0.05 improveddischargecoordinationand -cv-teal_pulse 0:3ac4bbda9488 -0.10 0.05 maintainprecisedocumentationande +cv-teal_pulse 0:5149e8e5e6b6 0.22 0.05 deliverdirectnursingcareforadult +cv-teal_pulse 0:8dcc0d173718 0.22 0.05 coordinatecareplanswithdoctors,t +cv-teal_pulse 0:325626fb2f7a 0.21 0.05 supervisejuniornursesandsupports +cv-teal_pulse 0:c2864de08768 0.21 0.05 improveddischargecoordinationand +cv-teal_pulse 0:3ac4bbda9488 0.20 0.05 maintainprecisedocumentationande cv-teal_pulse 0:59a7aa760eea 0.13 0.06 staffnurse—salfordgeneralhospita -cv-teal_pulse 0:99bff63b0b23 -0.09 0.05 providednursingcareacrossmedical -cv-teal_pulse 0:dee1c05c380e -0.09 0.05 administeredmedications,monitore -cv-teal_pulse 0:a0cc1403feda -0.10 0.05 supportedinfectioncontrolprocedu -cv-teal_pulse 0:25239222c011 -0.20 0.05 builtstrongrapportwithpatientsan +cv-teal_pulse 0:99bff63b0b23 0.21 0.05 providednursingcareacrossmedical +cv-teal_pulse 0:dee1c05c380e 0.21 0.05 administeredmedications,monitore +cv-teal_pulse 0:a0cc1403feda 0.20 0.05 supportedinfectioncontrolprocedu +cv-teal_pulse 0:25239222c011 0.10 0.05 builtstrongrapportwithpatientsan cv-teal_pulse 0:f25d4331b19b 0.02 0.06 communitynurse—northcarecommunit -cv-teal_pulse 0:19eefb6b6368 -0.19 0.05 deliveredhome-basednursingcarean -cv-teal_pulse 0:16412b3fd5d8 -0.20 0.05 managedwoundcare,medicationsuppo -cv-teal_pulse 0:fb42708d13d3 -0.20 0.05 workedcloselywithgps,socialservi -cv-teal_pulse 0:68bf0109c6f8 -0.31 0.11 education +cv-teal_pulse 0:19eefb6b6368 0.11 0.05 deliveredhome-basednursingcarean +cv-teal_pulse 0:16412b3fd5d8 0.10 0.05 managedwoundcare,medicationsuppo +cv-teal_pulse 0:fb42708d13d3 0.10 0.05 workedcloselywithgps,socialservi +cv-teal_pulse 0:68bf0109c6f8 0.09 0.11 education cv-teal_pulse 0:c1596da36d69 0.04 0.10 bsc(hons)adultnursing -cv-teal_pulse 0:47ce0218bbea -0.02 0.10 universityofmanchester -cv-teal_pulse 0:1da060005bfc -0.25 0.10 2013–2016 -cv-teal_pulse 0:e831beb04c00 -0.31 0.06 certifications -cv-teal_pulse 0:ec8bd1e014bb -0.24 0.04 nmcregisterednurse -cv-teal_pulse 0:669f27afcccf -0.27 0.04 immediatelifesupport(ils) -cv-teal_pulse 0:34b32be15830 -0.30 0.04 venepunctureandcannulationcertif -cv-teal_pulse 0:32215e12a886 -0.33 0.04 safeguardingadultslevel3 -cv-teal_pulse 0:676dfdd56ad2 -0.31 0.08 additionalinformation +cv-teal_pulse 0:47ce0218bbea 0.03 0.10 universityofmanchester +cv-teal_pulse 0:1da060005bfc 0.00 0.10 2013–2016 +cv-teal_pulse 0:e831beb04c00 0.09 0.06 certifications +cv-teal_pulse 0:ec8bd1e014bb 0.06 0.04 nmcregisterednurse +cv-teal_pulse 0:669f27afcccf 0.03 0.04 immediatelifesupport(ils) +cv-teal_pulse 0:34b32be15830 0.00 0.04 venepunctureandcannulationcertif +cv-teal_pulse 0:32215e12a886 -0.03 0.04 safeguardingadultslevel3 +cv-teal_pulse 0:676dfdd56ad2 0.09 0.08 additionalinformation cv-teal_pulse 0:bcc890a69aa7 0.06 0.07 languages:english(native), cv-teal_pulse 0:de15ca9e3d9f 0.03 0.07 polish(conversational) -cv-teal_pulse 0:b035d3696f4e -0.02 0.07 righttowork:unitedkingdom -cv-teal_pulse 0:cfa1d6dd779c -0.17 0.07 availability:1monthnotice -cv-teal_pulse 0:b9b8965717b3 -0.28 1.62 compassionatecare.clinicalexcell +cv-teal_pulse 0:b035d3696f4e 0.03 0.07 righttowork:unitedkingdom +cv-teal_pulse 0:cfa1d6dd779c 0.03 0.07 availability:1monthnotice +cv-teal_pulse 0:b9b8965717b3 0.07 1.62 compassionatecare.clinicalexcell cv-terracotta_rail 0:920a88ef5818 0.10 0.12 oliver.bennett@email.com cv-terracotta_rail 0:a8acc3a90a7b 0.10 0.12 +447700900123 cv-terracotta_rail 0:e8c463a6c54d 0.10 0.12 bristol,unitedkingdom diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index cf14305c8..cc8d69ca8 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -3,6 +3,7 @@ document engine_pages editor_pages lines matched median p90 over2 # rows of invoice-metered_long, invoice-obsidian_long, synthetic-break_in_column, synthetic-break_in_panel, synthetic-card_over_pages: libreoffice build 420(Build:2) on linux # rows of synthetic-clipped_container, synthetic-narrow_container, synthetic-rounded_card: libreoffice build 420(Build:2) on linux # rows of synthetic-icon_over_card: libreoffice build 420(Build:2) on linux +# rows of cv-teal_pulse: libreoffice build 420(Build:2) on linux, from CI run 40503780286. Its email line, set in a cell set to the middle, stood +0.08pt while the contact separator was anchored in that cell, LibreOffice 24.2 then setting the cell from its top; the separator now goes elsewhere, as a cell set to the middle carries no drawing, and LibreOffice centres the cell as its neighbours: +0.93pt, Word +1.14pt. Ten other lines stand 0.1 to 0.3pt further, as the same run sets lines of documents the export does not change cv-blue_banner 2 2 54 52 0.16 0.25 0 cv-boxed_sections 2 2 58 58 0.06 0.11 0 cv-centered_headline 2 2 58 56 0.12 0.29 0 @@ -25,7 +26,7 @@ cv-professional_sidebar 1 1 68 64 0.20 0.28 0 cv-serif_headline 1 1 103 103 0.19 0.59 0 cv-sidebar_portrait 1 1 49 49 0.07 0.18 0 cv-slate_orange 1 1 87 86 0.23 0.99 0 -cv-teal_pulse 1 1 57 57 0.20 0.63 0 +cv-teal_pulse 1 1 57 57 0.12 0.25 0 cv-terracotta_rail 1 1 102 102 0.17 0.52 0 cv-timeline_minimal 2 2 89 87 0.05 0.11 0 cv-violet_grid 1 1 80 78 0.27 0.59 0 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 558a1561b..fb79e4916 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 @@ -25,26 +25,30 @@ import java.util.function.Supplier; /** - * Where each shape {@link DocxDrawings} draws is anchored: in the paragraph whose text it stands - * beside, or else in a paragraph written on its page. + * Where each shape {@link DocxDrawings} draws is anchored: in a paragraph of the block it belongs + * to, or else in a paragraph written on its page. * - *

A shape standing beside a paragraph's text — a timeline's dot by its entry, an icon by its - * heading, a skill's bar by its label — is anchored in that paragraph and placed down from its - * top ({@link #seat}): Word moves it with the paragraph, wherever an edit above takes it. Placed - * from the page's edges, it stayed where the page had put it while the text moved away from it. - * The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape rises - * above its top by the shape's own height at most, as a dot centred on its first line does — in a - * table cell by a stroke's width at most. In a - * table cell, the cell that holds the shape across takes it — the paragraph's own, or another of - * its row, as a timeline's dot in a column of its own beside its entry's text — placed from that - * cell's first paragraph (see {@link Seat#place}). When the nearest paragraph cannot place the - * shape, it is left to the page rather than given to one further off. The shapes wait for the - * section's end, since the paragraph a shape stands beside may be written after it.

+ *

A shape a node draws belongs to that node and to the blocks holding it: an icon laid over a + * card's corner to the card's stack, a skill's bar to its row, a portrait to its sidebar. It is + * anchored in the first paragraph the nearest of them writes on its page that can place it, and + * placed from that paragraph's top ({@link #seat}), so Word moves it with that block wherever an + * edit above takes it; where it stands gives only its offset, however far from the paragraph's + * text that is. Placed from the page's edges, it stayed where the page had put it while the text + * moved away from it. In a table cell, the cell of the paragraph's row that holds the shape + * 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.

* - *

A shape beside no paragraph 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 takes its shape to - * the page the anchoring paragraph lands on. And a paragraph of the body rather than of a table - * cell: Word measures a shape anchored in a cell from the cell, not the page. The first body + *

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 + * takes its shape to the page the anchoring paragraph lands on. And a paragraph of the body + * rather than of a table cell: Word measures a shape anchored in a cell from the cell, not the + * page. The first body * paragraph written on each page carries such shapes. The run is put at the front of the * paragraph, so a paragraph that runs on to the next page does not take the shape with it.

* @@ -84,10 +88,15 @@ final class DocxDrawingAnchors { private final Map> seats = new HashMap<>(); // The paragraphs offered as seats in the section, in the order they were written. private final List offers = new ArrayList<>(); + // Where Word places from in each paragraph taken as a seat in the section, once asked: a + // drawing's holders and the paragraphs nearest it try the same paragraphs again. + private final Map tops = new java.util.IdentityHashMap<>(); // Identifiers unique in the document, shared with the other drawings the export writes. private final LongSupplier ids; // How tall Word stands a written paragraph's line, and its baseline in it. private final Function lines; + // The paths of the node at a path and of the blocks holding it, nearest first. + private final Function> holders; // The places given each fragment's shapes in the section so far, by the fragment's place in // the page's paint order. private final Map placesTaken = new HashMap<>(); @@ -107,18 +116,18 @@ final class DocxDrawingAnchors { */ static final int PLACES_PER_FRAGMENT = 8; - DocxDrawingAnchors(LongSupplier ids) { - this(ids, DocxGrownLines.NONE::lineOf); - } - /** - * @param ids identifiers unique in the document - * @param lines how tall Word stands a written paragraph's line, and its baseline in it - * ({@link DocxGrownLines#lineOf}) + * @param ids identifiers unique in the document + * @param lines how tall Word stands a written paragraph's line, and its baseline in it + * ({@link DocxGrownLines#lineOf}) + * @param holders the paths of the node at a path and of the blocks holding it, nearest first, + * the page's flow left out ({@link DocxLayoutMetrics#holdersOf}) */ - DocxDrawingAnchors(LongSupplier ids, Function lines) { + DocxDrawingAnchors(LongSupplier ids, Function lines, + Function> holders) { this.ids = ids; this.lines = lines; + this.holders = holders; } /** Forgets everything, for a new export. */ @@ -130,6 +139,7 @@ void reset() { breakParagraph.clear(); seats.clear(); offers.clear(); + tops.clear(); placesTaken.clear(); latest = -1; drawn = 0; @@ -226,12 +236,22 @@ List ordered(List shapes, int painted) { /** * What a drawing is drawn for: the report's note on it, which a drawing the section's end - * finds nothing to carry is named on instead (see {@link Leftovers}). + * finds nothing to carry is named on instead (see {@link Leftovers}), and the node whose + * holders carry it. * - * @param subject the note's subject - * @param path the note's path + * @param subject the note's subject + * @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 */ - record Owner(String subject, String path) { + record Owner(String subject, String path, boolean composed) { + + /** What a node draws, or a pass of the layout's own. */ + Owner(String subject, String path) { + this(subject, path, false); + } } /** @@ -247,7 +267,7 @@ void queue(List shapes, int painted, Owner owner) { /** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */ void queueOrdered(List shapes, Owner owner) { - // Held until the section ends: the paragraph a shape stands beside may be written after it. + // 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) { @@ -266,16 +286,18 @@ void queueOrdered(List shapes, Owner owner) { * * @param page the page, counted within the section * @param owner what the text is written for: one of its paragraphs takes the place + * @param path the path of the node the text is written for, which the blocks holding a + * drawing are matched against; {@code null} for text no node writes * @param paragraph the paragraph holding that text * @param left the text's left edge, from the page's left edge * @param right the text's right edge, from the page's left edge * @param top the top of its first line, from the page's top edge * @param baseline its first line's baseline, from the page's top edge */ - void seat(int page, Object owner, XWPFParagraph paragraph, double left, double right, double top, - double baseline) { + void seat(int page, Object owner, String path, XWPFParagraph paragraph, double left, double right, + double top, double baseline) { if (page >= 0) { - offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline))); + offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline, path))); } } @@ -296,7 +318,7 @@ private void takeTheSeats() { /** * Records a paragraph written on a page. The page's first body paragraph carries the shapes - * no paragraph beside them does; a cell's is kept in case the page has no other. + * no other paragraph does; a cell's is kept in case the page has no other. * * @param page the page, counted within the section * @param paragraph a paragraph the layout puts on that page @@ -374,7 +396,9 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier { List shapes = new ArrayList<>(); for (Ordered shape : all) { - Place place = placeBeside(shape.shape()); + Owner owner = owners.get(shape); + Place place = owner != null && owner.composed() ? placeBeside(shape.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)); } else { @@ -422,6 +446,7 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes, DocxDrawings.Cell } } + /** + * Where a shape a node draws is anchored: in the first paragraph on its page of the nearest + * of its holders that writes one there it can be placed from, or else in the paragraph + * nearest it on its page; {@code null} where no paragraph on its page can carry it. + * + *

Its holders — the node drawing it, then each block holding that node — are what it + * belongs to: an icon laid over a card's corner is the card's stack's, a skill's bar its + * row's, a portrait its sidebar's. Anchored in the first paragraph one of them writes on its + * page, it moves with that block wherever an edit above takes it, however far from the + * paragraph's text it stands: where it stands gives only its offset. A holder that writes no + * paragraph there, or only ones that cannot place it, passes it on to the next one up. A shape + * no holder carries — a timeline's rail, which no node draws, or one whose holders' text is a + * list or a table — goes to the paragraph nearest it.

+ * + * @param holders the paths of the node drawing it and of the blocks holding it, nearest first + */ + private Place placeInItsHolder(DocxDrawings.Shape shape, List holders) { + List onItsPage = seats.getOrDefault(shape.page(), List.of()); + for (String holder : holders) { + for (Seat seat : onItsPage) { + Place place = seat.isWithin(holder) ? placeHeld(seat, shape) : null; + if (place != null) { + return place; + } + } + } + return placeBesideTheNearest(shape, onItsPage); + } + + /** 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))); + } + + /** + * The paragraph on a shape's page whose text it stands nearest that can place it, however far + * off; {@code null} where none can. + */ + private Place placeBesideTheNearest(DocxDrawings.Shape shape, List onItsPage) { + List byDistance = new ArrayList<>(onItsPage); + byDistance.sort(java.util.Comparator.comparingDouble(seat -> distance(shape, seat))); + for (Seat seat : byDistance) { + Place place = placeHeld(seat, shape); + if (place != null) { + return place; + } + } + return null; + } + + /** How far a shape stands from a paragraph's text: down from its first line's top, and across past its edges. */ + private static double distance(DocxDrawings.Shape shape, Seat seat) { + return Math.abs(shape.top() - seat.top()) + + Math.max(0, seat.left() - (shape.x() + shape.width())) + + Math.max(0, shape.x() - seat.right()); + } + /** * The paragraph whose text a shape stands nearest, within reach, when it can hold the shape * placed down from its top; {@code null} when there is none, or it cannot. @@ -474,9 +556,7 @@ private Place placeBeside(DocxDrawings.Shape shape) { Seat nearest = null; double nearestBy = Double.POSITIVE_INFINITY; for (Seat seat : seats.getOrDefault(shape.page(), List.of())) { - double by = Math.abs(shape.top() - seat.top()) - + Math.max(0, seat.left() - (shape.x() + shape.width())) - + Math.max(0, shape.x() - seat.right()); + double by = distance(shape, seat); if (by <= REACH && by < nearestBy) { nearest = seat; nearestBy = by; @@ -490,8 +570,10 @@ private Place placeBeside(DocxDrawings.Shape shape) { } /** - * Anchors shapes in the paragraph whose text they stand 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, + * 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. */ private void anchorBeside(XWPFParagraph paragraph, List shapes) { XWPFRun run = paragraph.insertNewRun(0); @@ -512,9 +594,10 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { } /** - * How far from a paragraph's text a shape still stands beside it, across and down added, in - * points: a timeline's dot beside its entry, a rail from its first entry, an icon beside its - * heading. A shape further from every paragraph stays where the page puts it. + * 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 + * from every paragraph stays where the page puts it. A shape a node draws goes by the blocks + * holding it, however far off ({@link #placeInItsHolder}). */ private static final double REACH = 48; @@ -522,11 +605,11 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { private static final double SLACK = 1; /** - * The furthest a shape 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 — NavySidebar's 22.6pt badge is the - * corpus's largest — where Word and LibreOffice were measured placing 6pt and 10pt dots up - * to 8pt above their paragraphs. A taller shape — a rail, an accent bar — rising further - * stands by other text. + * 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 + * NavySidebar's 22.6pt badge did when every shape was placed by the text nearest it — Word and + * LibreOffice were measured placing 6pt and 10pt dots up to 8pt above their paragraphs. A + * taller shape — a rail, an accent bar — rising further stands by other text. */ private static final double FURTHEST_RISE = 24; @@ -541,8 +624,16 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { * @param right its text's right edge, from the page's left edge * @param top its first line's top, from the page's top edge * @param baseline its first line's baseline, from the page's top edge + * @param path the path of the node its text is written for; {@code null} for none */ - record Seat(XWPFParagraph paragraph, double left, double right, double top, double baseline) { + record Seat(XWPFParagraph paragraph, double left, double right, double top, double baseline, String path) { + + /** Whether its text is written for the node at a path, or for a node that node holds. */ + boolean isWithin(String holder) { + return path != null && (path.equals(holder) + || path.length() > holder.length() && path.startsWith(holder) + && path.charAt(holder.length()) == '/'); + } /** * Where the page puts the top Word places a paragraph's shapes from: its first line's, @@ -621,9 +712,11 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex } /** - * Where Word places a shape beside this paragraph's text from, or {@code null} where it - * cannot. The shape may rise above the paragraph's top as {@code risesNoFurther} allows: - * by its own height in the body, by a stroke's width in a table cell. + * Where Word places a shape a table's composed cell draws beside this paragraph's text + * from, or {@code null} where it cannot; a shape a node draws is placed by + * {@link #placeHeld}. The shape may rise above the paragraph's top as + * {@code risesNoFurther} allows: by its own height in the body, by a stroke's width in a + * table cell. * *

A paragraph of the body places it across from the page's edge. One in a table cell * places it from the cell's text column; a cell that does not hold the shape across hands @@ -652,6 +745,68 @@ Place place(DocxDrawings.Shape shape, Function lines) return inTheRow(shape, cell, cellLeft, width, top); } + /** + * Where Word places a shape this paragraph carries for a block holding both, however far + * from its text the shape stands; {@code null} where it cannot. + * + *

Measured in Word 16.0.20527 and LibreOffice 24.2: a shape anchored in a table cell's + * paragraph, placed from the cell's text column and the paragraph's top, stands where both + * say when it rises above its row or its table, hangs below them, stands out of its cell, + * is taller than its row, sits in a row of an exact height or in a nested table, in front of + * the text or behind it — from 36pt above its paragraph to 70pt below in probes, from 155pt + * above to 620pt below in the fidelity corpus. Neither editor clips it or moves a line for + * it, and both move it with the row when the text above it is edited. A cell set to the + * middle or the bottom, or held in one, carries none: LibreOffice 24.2 sets the text of a + * cell carrying a drawing from the cell's top, whatever drawing it is — 16pt and 32pt high + * in a row three lines taller than it. A paragraph of the body places a shape across from + * the page's edge.

+ * + *

A cell holding the shape across takes it in this paragraph; one that does not hands it + * to the cell of its row that does, the shape's own ({@link #inTheRow}), or else keeps it, + * placed out of its column — unless it holds text, a text box or a badge's letters, which + * LibreOffice 26.8 pulls back into the column. None takes it in a repeated header row, + * 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 + */ + Place placeHeld(DocxDrawings.Shape shape, double top) { + 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)) { + return null; + } + double width = widthOf(cell); + double cellLeft = textLeft() - leftMargin(cell); + if (Double.isNaN(width) || Double.isNaN(cellLeft)) { + return null; + } + if (holdsAcross(shape, cellLeft, width)) { + return new Place(paragraph, cellLeft + leftMargin(cell), top); + } + Place own = inTheRow(shape, cell, cellLeft, width, top); + // 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); + } + + /** Whether a cell, and each cell holding its table, sets its content from its top. */ + private static boolean setFromTheTop(XWPFTableCell cell) { + for (XWPFTableCell at = cell; at != null; ) { + if (!alignedToTheTop(at)) { + return false; + } + at = at.getTableRow().getTable().getBody() instanceof XWPFTableCell holding ? holding : null; + } + return true; + } + /** * 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 @@ -659,6 +814,12 @@ Place place(DocxDrawings.Shape shape, Function lines) * 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 + * ({@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)); diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 3a3a02a57..e69cda22e 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -873,6 +873,38 @@ String pathOf(DocumentNode node) { return node == null ? null : paths.get(node); } + /** + * The paths of a node and of the blocks holding it, nearest first: where a drawing it paints + * looks for the paragraph that carries it ({@code DocxDrawingAnchors}). + * + *

A container the tree starts at is taken to be the page's flow, holding every block on its + * pages and so none in particular, and is left out: a page flow is one, and every document of + * the fidelity corpus starts at one. A painted container added at the root is left out too, + * its drawings going to the paragraph nearest them. Any other node the tree starts at — a + * section or a stack added at the root — is a block and stays, as does a container further in. + * A path the tree does not name — a pass of the layout's own, a timeline's rail — has no + * holders.

+ * + * @param path a node's path ({@link #pathOf}) + * @return its holders' paths, itself first; empty for a path no node of the tree has + */ + List holdersOf(String path) { + if (path == null || !nodesByPath().containsKey(path)) { + return List.of(); + } + List holders = new ArrayList<>(); + for (String holder = path; holder != null; ) { + int last = holder.lastIndexOf('/'); + String above = last < 0 ? null : holder.substring(0, last); + if (above == null && nodesByPath().get(holder) instanceof com.demcha.compose.document.node.ContainerNode) { + break; + } + holders.add(holder); + holder = above; + } + return holders; + } + /** * The height of one line of the paragraph's text, as the engine measured it. * 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 4e7984280..dfdc083b2 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,10 +206,22 @@ 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 a drawing the page paints is anchored, as the report says it (see DocxDrawingAnchors). */ + /** + * 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). + */ 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 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 " + + "above is edited; in the paragraph nearest it where no block holding it writes one there, or to " + + "the page where no paragraph on its page can carry it"; + /** How a drawing no node paints — a timeline's rail — is anchored, as the report says it. */ + private static final String ANCHORED_IN_THE_NEAREST = "anchored in the paragraph nearest it on its page, " + + "so it moves with that paragraph when the text above is edited, or to the page where no " + + "paragraph on its page can carry it"; /** * The report's subject for a translucent colour Word holds only opaque — a cell's shading, a * border — flattened against the colour under it (see {@link DocxTranslucency}). @@ -452,7 +464,7 @@ public final class DocxSemanticBackend implements SemanticBackend { private final java.util.Map cutsDecided = new java.util.IdentityHashMap<>(); // Where the shapes this export draws are anchored — see DocxDrawingAnchors. private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++, - paragraph -> grownLines.lineOf(paragraph)); + paragraph -> grownLines.lineOf(paragraph), path -> this.layout.holdersOf(path)); // The page the node being written starts on, as the layout placed it, counted within the // section being written. private int currentPage; @@ -1314,7 +1326,7 @@ private byte[] write(List sections, Path outputFile) throws Exc drawingFor = new DocxDrawingAnchors.Owner("timeline rail", pass.path()); if (queueDrawing(pass, drawsInFront()) && railsNamed.add(pass.path())) { report.add(DocxExportReport.Severity.APPROXIMATED, "timeline rail", pass.path(), - "drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); + "drawn as a line where the layout puts it, " + ANCHORED_IN_THE_NEAREST); } drawingFor = null; } @@ -4443,7 +4455,8 @@ private void writeTextOverTheFlow(XWPFDocument document, ParagraphNode node) { paragraphXml(para))), layout.paintIndexOf(node), new DocxDrawingAnchors.Owner(node.nodeKind(), layout.pathOf(node))); List lost = new ArrayList<>(); - lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it"); + lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it, " + + ANCHORED_IN_ITS_BLOCK); lost.addAll(paragraphLost(node, roomLostInItsBox(node), null)); report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), String.join("; ", lost)); @@ -5073,7 +5086,7 @@ private boolean drawAndNameOwnFragments(DocumentNode node) { StringBuilder message = new StringBuilder(drewInCell ? "drawn as a shape anchored in the table cell it fills, where the layout puts it " + "in the cell: it moves with the row" - : "drawn as a shape where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); + : "drawn as a shape where the layout puts it, " + ANCHORED_IN_ITS_BLOCK); if (badgeText != null) { message.append("; its text is held in the shape, which stands in front of the text"); } else if (front) { @@ -9838,8 +9851,8 @@ private void offerASeat(XWPFParagraph para) { } // The baseline the page draws the first line on, seated as the page seats it. layout.firstTextBox(node).ifPresent(box -> - anchors.seat(box.page(), node, para, box.left(), box.right(), canvasHeight - box.top(), - canvasHeight - box.baseline() - seatShift(node))); + anchors.seat(box.page(), node, layout.pathOf(node), para, box.left(), box.right(), + canvasHeight - box.top(), canvasHeight - box.baseline() - seatShift(node))); } /** @@ -15517,7 +15530,7 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte StringBuilder message = new StringBuilder(drewInCell ? how + ", anchored in the table cell it fills, where the layout puts it in the cell: " + "it moves with the row" - : how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); + : how + ", where the layout puts it, " + ANCHORED_IN_ITS_BLOCK); for (String lost : carriedWithout(image, false)) { message.append("; ").append(lost); } @@ -16018,7 +16031,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception } finally { // What no cell took is drawn where the page puts it, in the order the table paints it. if (tableDrawings != outerDrawings) { - DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node)); + DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node), true); for (CellFragment waiting : tableDrawings) { anchors.queueOrdered(waiting.shapes(), table); } 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 57e8a668a..55b1fec99 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 @@ -233,7 +233,9 @@ void aDrawingWithAMarginStaysOnThePage() throws Exception { } @Test - void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception { + void aLineAloneInACellOfARowInTheFlowIsNotHeldToItsHeightButGoesToItsRow() throws Exception { + // A line may be a rule, so its cell's paragraph is not set to its height as a drawing + // alone in a cell is; drawn, it is its row's, and its own cell of that row carries it. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Above")) .addRow("Ruled", row -> row.weights(150, 150) @@ -242,10 +244,15 @@ void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception { .stroke(DocumentStroke.of(DocumentColor.rgb(200, 200, 200), 1)).build()) .build()) .addParagraph(p -> p.text("LABEL"))))) { - String body = document.getDocument().xmlText(); - - assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\""); - assertThat(shapesOutOfTheirCells(document)).as("the line, out of the cell as before").isEqualTo(1); + XWPFParagraph carrier = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + + assertThat(anchors(carrier)).as("the line, in its own cell, from the row's top").singleElement() + .satisfies(line -> assertThat(line.fromV()).isEqualTo("paragraph")); + assertThat(carrier.getCTP().isSetPPr() && carrier.getCTP().getPPr().isSetSpacing() + && carrier.getCTP().getPPr().getSpacing().isSetLine() + && DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine()) == 20) + .as("its paragraph not held to the line's height").isFalse(); + assertThat(shapesOutOfTheirCells(document)).isZero(); } } 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 dadd0d03d..7944429cb 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 @@ -23,13 +23,16 @@ import static org.assertj.core.api.Assertions.within; /** - * Which paragraph a shape is anchored in, and where it is placed from. + * Which paragraph a shape a table's composed cell draws is anchored in, and where it is placed + * from; and what every shape shares, wherever anchored. * *

A shape anchored to the page stays where the page put it when a reader edits the text - * above it; the text moves and the shape does not. A shape standing beside a paragraph's text is - * anchored in that paragraph and placed down from its top, so Word moves it with the paragraph. - * The paragraph's top is where Word places the shape from: the space written above its first - * line included, and its line where Word stands it, not where the page draws it.

+ * above it; the text moves and the shape does not. A composed cell has no node of its own, so + * its shape is anchored in the paragraph whose text it stands beside, placed down from its top, + * and Word moves it with the paragraph. The paragraph's top is where Word places the shape from: + * the space written above its first line included, and its line where Word stands it, not where + * the page draws it. A shape a node draws is anchored by the blocks holding it + * ({@link DocxDrawingOwnerAnchorsTest}).

*/ class DocxDrawingAnchorsTest { @@ -41,8 +44,8 @@ void aShapeIsAnchoredInTheParagraphWhoseTextItStandsNearest() { DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); // Both within reach, and the dot hangs from both: 32pt from the first, 2pt from the second. - anchors.seat(0, first, first, 40, 300, 70, 80); - anchors.seat(0, second, second, 40, 300, 100, 110); + anchors.seat(0, first, null, first, 40, 300, 70, 80); + anchors.seat(0, second, null, second, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -72,7 +75,7 @@ private static boolean seated(DocxDrawings.Shape dot, double right) { XWPFParagraph paragraph = text(document, "Text"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, right, 100, 110); + anchors.seat(0, paragraph, null, paragraph, 40, right, 100, 110); queue(anchors, List.of(dot)); anchors.endSection(0, null, document::createParagraph); return drawingsIn(paragraph).get(0).contains(""); @@ -95,7 +98,7 @@ void aParagraphWithSomethingAboveItsLineOffersNoTop() { something.accept(paragraph.getCTP().addNewPPr()); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 100, 110); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -110,7 +113,7 @@ void aShapeOutOfReachOfEveryParagraphStaysWhereThePagePutsIt() { XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 40, 50); + anchors.seat(0, first, null, first, 40, 300, 40, 50); queue(anchors, List.of(dot(30, 200))); anchors.endSection(0, null, document::createParagraph); @@ -127,7 +130,7 @@ void aShapeRisingAboveItsParagraphByMoreThanItsHeightIsNotPlacedFromIt() { XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 120, 130); + anchors.seat(0, first, null, first, 40, 300, 120, 130); queue(anchors, List.of(dot(30, 100))); anchors.endSection(0, null, document::createParagraph); @@ -144,7 +147,7 @@ void aDotCentredOnTheFirstLineRisesAboveItsParagraphsTopByItsOffset() { XWPFParagraph first = text(document, "Date"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 100, 110); + anchors.seat(0, first, null, first, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 96))); anchors.endSection(0, null, document::createParagraph); @@ -158,7 +161,7 @@ void aShapeReachingAStrokePastItsParagraphsTopIsPlacedAtThatTop() { XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 100, 110); + anchors.seat(0, first, null, first, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 99.5))); anchors.endSection(0, null, document::createParagraph); @@ -179,7 +182,7 @@ void anExactLinesTopIsWhereWordStandsItsBaselineLessTheSpaceAbove() { paragraph.getRuns().get(0).getCTR().addNewRPr().addNewPosition().setVal(BigInteger.valueOf(-2)); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 163.5, 172); queue(anchors, List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -198,9 +201,9 @@ void aLeastLinesTopIsItsBaselineLessWhatItsRunsReachAboveItLessTheSpaceAbove() { paragraph.getCTP().getPPr().getSpacing().setLineRule(STLineSpacingRule.AT_LEAST); AtomicLong ids = new AtomicLong(1); DocxDrawingAnchors anchors = new DocxDrawingAnchors(ids::getAndIncrement, - ctp -> ctp == paragraph.getCTP() ? new DocxGrownLines.Line(15, 13) : null); + ctp -> ctp == paragraph.getCTP() ? new DocxGrownLines.Line(15, 13) : null, path -> List.of()); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 163.5, 172); queue(anchors, List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -221,7 +224,7 @@ void aShapeInACellIsPlacedFromTheCellsTextColumn() { DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, false); // The text starts 10pt into a cell whose left edge the page puts at 100. - anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); + anchors.seat(0, paragraph, null, paragraph, 110, 290, 60, 70); queue(anchors, List.of(dot(103, 62))); anchors.endSection(0, null, document::createParagraph); @@ -244,7 +247,7 @@ void aCellThatCannotHoldTheShapeDoesNotCarryIt() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, paragraph, paragraph, 100, 290, 60, 70); + anchors.seat(0, paragraph, null, paragraph, 100, 290, 60, 70); queue(anchors, List.of(dot(80, 62))); anchors.endSection(0, null, document::createParagraph); @@ -268,7 +271,7 @@ void aCellStatingNoMarginTakesWordsOwn() { paragraph.createRun().setText("Entry"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, false); - anchors.seat(0, paragraph, paragraph, 105.4, 290, 60, 70); + anchors.seat(0, paragraph, null, paragraph, 105.4, 290, 60, 70); queue(anchors, List.of(dot(100, 62))); anchors.endSection(0, null, document::createParagraph); @@ -293,8 +296,8 @@ void aShapeItsNearestParagraphCannotHoldIsNotGivenToOneFurtherOff() { XWPFParagraph note = text(document, "Thank you"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, note, true); - anchors.seat(0, total, total, 110, 290, 60, 70); - anchors.seat(0, note, note, 110, 290, 106, 116); + anchors.seat(0, total, null, total, 110, 290, 60, 70); + anchors.seat(0, note, null, note, 110, 290, 106, 116); queue(anchors, List.of(dot(120, 98))); anchors.endSection(0, null, document::createParagraph); @@ -321,7 +324,7 @@ void aDotInAColumnOfItsOwnIsAnchoredInItsCellFromTheRowsTop() { entry.createRun().setText("Senior Engineer"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, entry, false); - anchors.seat(0, entry, entry, 100, 300, 60, 70); + anchors.seat(0, entry, null, entry, 100, 300, 60, 70); queue(anchors, List.of(dot(92, 62))); anchors.endSection(0, null, document::createParagraph); @@ -351,7 +354,7 @@ void aNeighbourCellsFirstParagraphStartsWhereTheParagraphDoesWhateverItsMargin() entry.createRun().setText("Senior Engineer"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, entry, false); - anchors.seat(0, entry, entry, 100, 300, 60, 70); + anchors.seat(0, entry, null, entry, 100, 300, 60, 70); queue(anchors, List.of(dot(92, 66))); anchors.endSection(0, null, document::createParagraph); @@ -382,7 +385,7 @@ void aNeighbourCellIsNotUsedWhereARowsTopIsNotKnown() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, entry, entry, 100, 300, 60, 70); + anchors.seat(0, entry, null, entry, 100, 300, 60, 70); queue(anchors, List.of(dot(92, 62))); anchors.endSection(0, null, document::createParagraph); @@ -412,7 +415,7 @@ void aNeighbourCellToTheRightTakesAShapeTooButNotOneMergedFromAbove() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, entry, entry, 100, 290, 60, 70); + anchors.seat(0, entry, null, entry, 100, 290, 60, 70); queue(anchors, List.of(dot(302, 62))); anchors.endSection(0, null, document::createParagraph); @@ -443,7 +446,7 @@ void inACellAShapeRisesAboveItsParagraphByAStrokeAtMost() { entry.createRun().setText("Date"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, text(document, "Body"), true); - anchors.seat(0, entry, entry, 110, 290, 100, 110); + anchors.seat(0, entry, null, entry, 110, 290, 100, 110); queue(anchors, List.of(dot(120, 96))); anchors.endSection(0, null, document::createParagraph); @@ -459,7 +462,7 @@ void aTallShapeRisesNoFurtherThanADisc() { XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 100, 110); + anchors.seat(0, first, null, first, 40, 300, 100, 110); queue(anchors, List.of(new DocxDrawings.Shape(DocxDrawings.Kind.RECT, 30, 70, 2, 40, Color.BLUE, null, 0, 0, false, 0))); @@ -498,7 +501,7 @@ void noNeighbourIsUsedWhereTheRowCannotBeWalked() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, entry, entry, 100, 300, 60, 70); + anchors.seat(0, entry, null, entry, 100, 300, 60, 70); queue(anchors, List.of(dot(82, 62))); anchors.endSection(0, null, document::createParagraph); @@ -526,7 +529,7 @@ void aCellOfARepeatedHeaderRowDoesNotCarryAShape() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); + anchors.seat(0, paragraph, null, paragraph, 110, 290, 60, 70); queue(anchors, List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -544,8 +547,8 @@ void theParagraphHoldingTheTextTakesThePlaceNotTheEmptyLineBeforeIt() { Object owner = new Object(); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, spacer, true); - anchors.seat(0, owner, spacer, 40, 300, 100, 110); - anchors.seat(0, owner, entry, 40, 300, 100, 110); + anchors.seat(0, owner, null, spacer, 40, 300, 100, 110); + anchors.seat(0, owner, null, entry, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -570,7 +573,7 @@ void aLineCentredInACellDoesNotCarryAShape() { DocxDrawingAnchors anchors = anchors(); XWPFParagraph body = text(document, "Body"); anchors.paragraphOn(0, body, true); - anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); + anchors.seat(0, paragraph, null, paragraph, 110, 290, 60, 70); queue(anchors, List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -591,7 +594,7 @@ void aLinksTextRaisedInItsLineMovesTheTopWithIt() { run.addNewRPr().addNewPosition().setVal(BigInteger.valueOf(-2)); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 163.5, 172); queue(anchors, List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -606,7 +609,7 @@ void contextualSpacingSwitchedOffLeavesTheTopWhereItIs() { paragraph.getCTP().addNewPPr().addNewContextualSpacing().setVal("0"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 100, 110); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 100, 110); queue(anchors, List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -621,7 +624,7 @@ void shapesBesideOneParagraphAreWrittenInTheOrderTheyAreDrawn() { XWPFParagraph paragraph = text(document, "Title"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, paragraph, true); - anchors.seat(0, paragraph, paragraph, 40, 300, 40, 50); + anchors.seat(0, paragraph, null, paragraph, 40, 300, 40, 50); queue(anchors, List.of(dot(10, 42), dot(20, 42))); anchors.endSection(0, null, document::createParagraph); @@ -630,14 +633,18 @@ void shapesBesideOneParagraphAreWrittenInTheOrderTheyAreDrawn() { .containsExactly(10.0, 20.0); } + /** Anchors for drawings no block holds: what a node draws here is held by nothing written. */ private static DocxDrawingAnchors anchors() { AtomicLong ids = new AtomicLong(1); - return new DocxDrawingAnchors(ids::getAndIncrement); + return new DocxDrawingAnchors(ids::getAndIncrement, DocxGrownLines.NONE::lineOf, path -> List.of()); } - /** Queues shapes no note names, from no fragment of a layout, as drawing here is drawn for nothing. */ + /** + * Queues shapes as a table's composed cell draws them, from no fragment of a layout: with no + * node of their own, they go to the paragraph whose text is nearest, within reach. + */ private static void queue(DocxDrawingAnchors anchors, List shapes) { - anchors.queue(shapes, -1, null); + anchors.queue(shapes, -1, new DocxDrawingAnchors.Owner("cell drawing", "Root[0]/Table[0]", true)); } @Test @@ -733,8 +740,8 @@ void theFirstOfSomeShapesIsCarriedByTheParagraphOfTheOneOnTheEarliestPageHighest DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, upper, true); anchors.paragraphOn(1, next, true); - anchors.seat(0, upper, upper, 40, 300, 100, 110); - anchors.seat(0, lower, lower, 40, 300, 300, 310); + anchors.seat(0, upper, null, upper, 40, 300, 100, 110); + anchors.seat(0, lower, null, lower, 40, 300, 300, 310); int from = anchors.drawnSoFar(); queue(anchors, List.of(dot(30, 302), onPage(1, dot(30, 50)), dot(30, 102))); int to = anchors.drawnSoFar(); @@ -770,7 +777,8 @@ void shapesWhoseFirstNothingCarriesHaveNoParagraph() { anchors.paragraphOn(0, text(document, "First"), true); anchors.paragraphOn(2, last, true); int from = anchors.drawnSoFar(); - queue(anchors, List.of(onPage(1, dot(30, 300)), onPage(2, dot(30, 100)))); + // Drawn for nothing a note names, so counted by its page alone. + anchors.queue(List.of(onPage(1, dot(30, 300)), onPage(2, dot(30, 100))), -1, null); int to = anchors.drawnSoFar(); assertThat(anchors.endSection(2, null, document::createParagraph).dropped()).containsEntry(1, 1); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java new file mode 100644 index 000000000..8c8e0f846 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java @@ -0,0 +1,177 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.LineBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.svg.SvgIcon; +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 com.demcha.compose.document.style.DocumentRowColumn.fixed; +import static com.demcha.compose.document.style.DocumentRowColumn.weight; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A drawing a node paints is anchored in a paragraph of the block it belongs to, so Word moves it + * with that block when the text above is edited. + * + *

Chosen by where it stood, an icon laid over a card's corner was left to the page — the card's + * text starts its padding below the icon's top, more than a shape was let rise above a paragraph + * in a cell — and Word's editing protocol moved the card 36pt and the icon not at all. So was a + * skill's bar beside its label in a row: in a cell, a shape was let rise above its paragraph by a + * stroke's width only.

+ */ +class DocxDrawingHolderTest { + + private static final SvgIcon ICON = SvgIcon.parse("" + + ""); + + @Test + void anIconLaidOverACardsCornerIsAnchoredInTheCardsText() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 500, 20, page -> page + .addParagraph(p -> p.text("Before the card.")) + .addLayerStack(stack -> stack + .layer(new SectionBuilder().softPanel(DocumentColor.rgb(232, 240, 250), 0, 14) + .addParagraph(p -> p.text("The card's text.")).build()) + .layer(ICON.node(18), LayerAlign.TOP_RIGHT)) + .addParagraph(p -> p.text("After the card.")))) { + XWPFParagraph card = cellParagraphHolding(document, "The card's text."); + List held = anchorsIn(card.getCTP()); + + assertThat(held).as("the icon, in the card's paragraph").singleElement() + .satisfies(icon -> { + assertThat(icon.getLayoutInCell()).isTrue(); + assertThat(icon.getPositionV().getRelativeFrom()).isEqualTo(STRelFromV.PARAGRAPH); + assertThat(points(icon.getPositionV().getPosOffset())) + .as("at the card's top, its padding above the text").isCloseTo(-14, within(1.0)); + }); + assertThat(onThePage(document)).as("nothing left to the page").isZero(); + } + } + + @Test + void aMeterBesideItsLabelInARowIsAnchoredInTheRow() throws Exception { + // A skill's meter: a track and its fill, drawn as lines in a stack of their own. + DocumentNode meter = new LayerStackBuilder().name("Meter") + .layer(new LineBuilder().horizontal(80).stroke(DocumentStroke.of(DocumentColor.rgb(220, 226, 236), 4)).build()) + .layer(new LineBuilder().horizontal(50).stroke(DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 4)).build(), + LayerAlign.TOP_LEFT) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 500, 20, page -> page + .addParagraph(p -> p.text("Skills")) + .addRow(row -> row.columns(fixed(120), weight(1)) + .addParagraph(p -> p.text("Negotiation")) + .add(meter)))) { + assertThat(onThePage(document)).as("nothing left to the page").isZero(); + XWPFParagraph label = cellParagraphHolding(document, "Negotiation"); + XWPFTableCell barCell = ((XWPFTableCell) label.getBody()).getTableRow().getCell(1); + assertThat(anchorsIn(label.getCTP())).as("not the label's paragraph").isEmpty(); + assertThat(anchorsIn(barCell.getCTTc())).as("the track and its fill, in their own cell of the row") + .hasSize(2).allSatisfy(anchor -> { + assertThat(anchor.getLayoutInCell()).isTrue(); + assertThat(anchor.getPositionV().getRelativeFrom()).isEqualTo(STRelFromV.PARAGRAPH); + }); + } + } + + @Test + void theHoldersOfANodeAreItAndTheBlocksAboveItButNotThePagesFlow() throws Exception { + DocumentNode icon = ICON.node(18); + try (DocumentSession session = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addLayerStack(stack -> stack + .layer(new ParagraphBuilder().text("Text").build()) + .layer(icon, LayerAlign.TOP_RIGHT))); + DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); + String path = layout.pathOf(icon); + + assertThat(layout.holdersOf(path)).containsExactly(path, path.substring(0, path.lastIndexOf('/'))); + assertThat(layout.holdersOf("@timeline-rail")).as("no node's").isEmpty(); + } + } + + @Test + void aBlockTheTreeStartsAtAndAContainerFurtherInAreHolders() { + // Added at the root, a section is a block, not the page's flow. + DocumentNode icon = ICON.node(18); + DocumentNode section = new SectionBuilder().name("Root") + .add(new LayerStackBuilder().name("Stack").layer(icon).build()).build(); + DocxLayoutMetrics atTheRoot = DocxLayoutMetrics.of( + new com.demcha.compose.document.layout.DocumentGraph(List.of(section)), null); + assertThat(atTheRoot.holdersOf(atTheRoot.pathOf(icon))) + .containsExactly(atTheRoot.pathOf(icon), "Root[0]/Stack[0]", "Root[0]"); + + // A container the flow holds is a block; only the flow the tree starts at is left out. + DocumentNode mark = ICON.node(18); + DocumentNode inner = new com.demcha.compose.document.node.ContainerNode("Inner", List.of(mark), 0, + DocumentInsets.zero(), DocumentInsets.zero(), null, null); + DocumentNode flow = new com.demcha.compose.document.node.ContainerNode("Flow", List.of(inner), 0, + DocumentInsets.zero(), DocumentInsets.zero(), null, null); + DocxLayoutMetrics inAFlow = DocxLayoutMetrics.of( + new com.demcha.compose.document.layout.DocumentGraph(List.of(flow)), null); + assertThat(inAFlow.holdersOf(inAFlow.pathOf(mark))).containsExactly(inAFlow.pathOf(mark), "Flow[0]/Inner[0]"); + } + + /** The paragraph of a table cell whose text starts so. */ + private static XWPFParagraph cellParagraphHolding(XWPFDocument document, String text) { + for (XmlObject found : document.getDocument().selectPath("declare namespace w='" + + "http://schemas.openxmlformats.org/wordprocessingml/2006/main'" + + " .//w:tc//w:p")) { + XWPFParagraph paragraph = document.getParagraph((org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP) found); + if (paragraph != null && paragraph.getText().startsWith(text)) { + return paragraph; + } + } + return document.getTables().stream() + .flatMap(table -> table.getRows().stream()) + .flatMap(row -> row.getTableCells().stream()) + .flatMap(cell -> paragraphsIn(cell).stream()) + .filter(paragraph -> paragraph.getText().startsWith(text)) + .findFirst().orElseThrow(() -> new AssertionError("no cell paragraph starts '" + text + "'")); + } + + private static List paragraphsIn(XWPFTableCell cell) { + List paragraphs = new ArrayList<>(cell.getParagraphs()); + cell.getTables().forEach(table -> table.getRows().forEach(row -> row.getTableCells() + .forEach(inner -> paragraphs.addAll(paragraphsIn(inner))))); + return paragraphs; + } + + /** How many drawings the body places down from the page's top edge. */ + private static long onThePage(XWPFDocument document) { + return anchorsIn(document.getDocument().getBody()).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; + } + + private static double points(Object emu) { + return ((Number) emu).doubleValue() / 12700.0; + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java new file mode 100644 index 000000000..48f8b7440 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java @@ -0,0 +1,513 @@ +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 node draws is anchored in the first paragraph the nearest block holding it writes on + * its page, and placed from there however far off it stands; one no block carries goes to the + * paragraph nearest it. + * + *

Chosen by where it stood, an icon laid over a card's corner stood 14pt above the card's + * text and was left to the page, as was every skill bar of a sidebar's rows and its portrait: + * edited above, the text moved and they did not. Anchored by what holds them, they move with + * it. Measured in Word 16.0.20527 and LibreOffice 24.2, a shape in a table cell's paragraph stands + * where both say at any offset — above its row, below its table, out of its cell — unless the + * cell sets its content from the middle or the bottom, whose text LibreOffice moves for it.

+ */ +class DocxDrawingOwnerAnchorsTest { + + private static final String STACK = "Flow[0]/Stack[1]"; + private static final String ICON = STACK + "/Icon[1]"; + + @Test + void aShapeIsAnchoredInTheFirstParagraphItsHolderWritesNotTheOneNearestIt() { + // The paragraph above the stack stands 6pt from the icon; the card's text, the stack's + // first paragraph, 14pt below it. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph before = text(document, "Before"); + XWPFParagraph card = text(document, "Card"); + XWPFParagraph more = text(document, "More"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, before, true); + anchors.seat(0, before, "Flow[0]/Before[0]", before, 40, 300, 94, 104); + anchors.seat(0, card, STACK + "/Card[0]/Text[0]", card, 54, 300, 114, 124); + anchors.seat(0, more, STACK + "/Card[0]/More[1]", more, 54, 300, 130, 140); + + queue(anchors, ICON, List.of(dot(290, 100))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(before)).isEmpty(); + assertThat(drawingsIn(more)).isEmpty(); + String drawing = drawingsIn(card).get(0); + assertThat(drawing).contains("") + .contains("" + emu(290) + "<"); + assertThat(offsetDown(drawing)).as("14pt above the card's text").isCloseTo(-14, within(0.01)); + } + + @Test + void howFarFromItsParagraphItStandsGivesOnlyItsOffset() { + // Further below than any nearest paragraph's reach, and further above than a disc rises. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "Heading"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, STACK + "/Heading[0]", first, 40, 300, 200, 210); + + queue(anchors, ICON, List.of(dot(30, 320), dot(30, 140))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(first)).extracting(DocxDrawingOwnerAnchorsTest::offsetDown) + .containsExactly(120.0, -60.0); + } + + @Test + void inACellItRisesAboveItsParagraphAsFarAsItStands() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph entry = cellParagraph(document, 200, "Entry"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, text(document, "Body"), true); + anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110); + + queue(anchors, ICON, List.of(dot(120, 80))); + anchors.endSection(0, null, document::createParagraph); + + String drawing = drawingsIn(entry).get(0); + assertThat(drawing).contains("layoutInCell=\"1\"").contains(""); + assertThat(offsetAcross(drawing)).as("from the cell's text column").isCloseTo(10, within(0.01)); + assertThat(offsetDown(drawing)).isCloseTo(-20, within(0.01)); + } + + @Test + void aCellNotHoldingTheShapeAcrossKeepsItOutOfItsColumn() { + // Its row has no other cell: the shape, left of the cell, is placed from the cell's column. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph entry = cellParagraph(document, 200, "Entry"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, text(document, "Body"), true); + anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110); + + queue(anchors, ICON, List.of(dot(60, 104))); + anchors.endSection(0, null, document::createParagraph); + + String drawing = drawingsIn(entry).get(0); + assertThat(drawing).contains("layoutInCell=\"1\""); + assertThat(offsetAcross(drawing)).isCloseTo(-50, within(0.01)); + } + + @Test + void aShapeHoldingTextIsKeptOnlyByACellHoldingItAcross() { + // A text box left of the cell, as the dot of the case above: LibreOffice 26.8 pulled one + // anchored so back into the column. The body paragraph under the table places it instead. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph entry = cellParagraph(document, 200, "Entry"); + XWPFParagraph below = text(document, "Below"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, below, true); + anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110); + anchors.seat(0, below, STACK + "/Below[1]", below, 40, 300, 160, 170); + + queue(anchors, ICON, List.of(DocxDrawings.Shape.textBox(60, 104, 30, 12, 0, ""))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(entry)).isEmpty(); + String box = drawingsIn(below).get(0); + assertThat(box).contains("" + emu(60) + "<"); + assertThat(offsetDown(box)).isCloseTo(-56, within(0.01)); + } + + @Test + void itsOwnCellInTheRowOfItsHoldersParagraphTakesIt() { + // A dot in an empty column beside its entry's text, as a timeline's: the dot's cell takes + // it, placed from that cell's column and the row's top. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 2); + XWPFTableCell dotCell = table.getRow(0).getCell(0); + XWPFTableCell textCell = table.getRow(0).getCell(1); + width(dotCell, 20); + width(textCell, 200); + margin(dotCell, 0); + margin(textCell, 0); + XWPFParagraph entry = textCell.getParagraphs().get(0); + entry.createRun().setText("Entry"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, text(document, "Body"), true); + anchors.seat(0, entry, "Flow[0]/Row[1]/Text[1]", entry, 120, 320, 100, 110); + + queue(anchors, "Flow[0]/Row[1]/Rail[0]/Dot[0]", List.of(dot(107, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(entry)).isEmpty(); + String drawing = drawingsIn(dotCell.getParagraphs().get(0)).get(0); + assertThat(offsetAcross(drawing)).as("from the dot's own cell, 100pt across").isCloseTo(7, within(0.01)); + assertThat(offsetDown(drawing)).isCloseTo(2, within(0.01)); + } + + @Test + void aLaterParagraphOfItsHolderNearerTheShapeDoesNotTakeItFromTheFirst() { + // The card's second paragraph stands 2pt from the icon, its first 14pt. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph card = text(document, "Card"); + XWPFParagraph more = text(document, "More"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, card, true); + anchors.seat(0, card, STACK + "/Card[0]/Text[0]", card, 54, 300, 114, 124); + anchors.seat(0, more, STACK + "/Card[0]/More[1]", more, 54, 300, 130, 140); + + queue(anchors, ICON, List.of(dot(290, 128))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(more)).isEmpty(); + assertThat(offsetDown(drawingsIn(card).get(0))).isCloseTo(14, within(0.01)); + } + + @Test + void aHolderWritingNoParagraphOnItsPagePassesItUp() { + // The stack's text is all on the page before. On the shape's page its section's heading + // stands 30pt above it, and a note of another block 2pt below its top. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph earlier = text(document, "Card"); + XWPFParagraph heading = text(document, "Heading"); + XWPFParagraph note = text(document, "Note"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, earlier, true); + anchors.paragraphOn(1, heading, true); + anchors.seat(0, earlier, "Flow[0]/Section[2]/Stack[0]/Card[0]", earlier, 40, 300, 700, 710); + anchors.seat(1, heading, "Flow[0]/Section[2]/Heading[1]", heading, 40, 300, 60, 70); + anchors.seat(1, note, "Flow[0]/Aside[3]/Note[0]", note, 40, 300, 92, 102); + + queue(anchors, "Flow[0]/Section[2]/Stack[0]/Icon[1]", List.of(onPage(1, dot(30, 90)))); + anchors.endSection(1, null, document::createParagraph); + + assertThat(drawingsIn(earlier)).as("a paragraph on another page").isEmpty(); + assertThat(drawingsIn(note)).as("nearer, but no holder's").isEmpty(); + assertThat(offsetDown(drawingsIn(heading).get(0))).as("from the section's heading").isCloseTo(30, within(0.01)); + } + + @Test + void aCellSetToTheMiddleOrTheBottomCarriesNoneAndTheNextHolderUpDoes() { + for (STVerticalJc.Enum alignment : List.of(STVerticalJc.CENTER, STVerticalJc.BOTTOM)) { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph heading = text(document, "Heading"); + XWPFParagraph entry = cellParagraph(document, 200, "Entry"); + ((XWPFTableCell) entry.getBody()).getCTTc().getTcPr().addNewVAlign().setVal(alignment); + XWPFParagraph note = text(document, "Note"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, heading, true); + anchors.seat(0, heading, "Flow[0]/Section[0]/Heading[0]", heading, 40, 300, 60, 70); + anchors.seat(0, entry, "Flow[0]/Section[0]/Row[1]/Entry[0]", entry, 110, 290, 100, 110); + // Another block's paragraph, nearer the bar than the heading. + anchors.seat(0, note, "Flow[0]/Aside[1]/Note[0]", note, 40, 300, 104, 114); + + queue(anchors, "Flow[0]/Section[0]/Row[1]/Bar[1]", List.of(dot(120, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(entry)).as(alignment.toString()).isEmpty(); + assertThat(drawingsIn(note)).as(alignment + ": no holder's").isEmpty(); + assertThat(drawingsIn(heading)).as(alignment + ": the section's").singleElement().asString() + .contains(""); + } + } + + @Test + void aHoldersNextParagraphTakesItWhereItsFirstCannot() { + // The card's first paragraph stands in a cell set to the middle; its caption under the + // table is the card's too. Another block's note stands nearer the icon than the caption. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph entry = cellParagraph(document, 200, "Entry"); + ((XWPFTableCell) entry.getBody()).getCTTc().getTcPr().addNewVAlign().setVal(STVerticalJc.CENTER); + XWPFParagraph caption = text(document, "Caption"); + XWPFParagraph note = text(document, "Note"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, caption, true); + anchors.seat(0, entry, "Flow[0]/Card[1]/Entry[0]", entry, 110, 290, 100, 110); + anchors.seat(0, caption, "Flow[0]/Card[1]/Caption[1]", caption, 40, 300, 160, 170); + anchors.seat(0, note, "Flow[0]/Aside[2]/Note[0]", note, 40, 300, 106, 116); + + queue(anchors, "Flow[0]/Card[1]/Icon[2]", List.of(dot(120, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(entry)).isEmpty(); + assertThat(drawingsIn(note)).isEmpty(); + assertThat(offsetDown(drawingsIn(caption).get(0))).isCloseTo(-58, within(0.01)); + } + + @Test + void itsOwnCellMergedFromAboveLeavesItToTheParagraphsCellOutOfItsColumn() { + // As a timeline's dot in a column of its own, but that cell continues one from the row + // above: the text's cell keeps the dot, placed from its own column. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 2); + XWPFTableCell dotCell = table.getRow(0).getCell(0); + XWPFTableCell textCell = table.getRow(0).getCell(1); + width(dotCell, 20); + width(textCell, 200); + margin(dotCell, 0); + margin(textCell, 0); + dotCell.getCTTc().getTcPr().addNewVMerge() + .setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge.CONTINUE); + XWPFParagraph entry = textCell.getParagraphs().get(0); + entry.createRun().setText("Entry"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, text(document, "Body"), true); + anchors.seat(0, entry, "Flow[0]/Row[1]/Text[1]", entry, 120, 320, 100, 110); + + queue(anchors, "Flow[0]/Row[1]/Rail[0]/Dot[0]", List.of(dot(107, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(dotCell.getParagraphs().get(0))).isEmpty(); + String drawing = drawingsIn(entry).get(0); + assertThat(drawing).contains("layoutInCell=\"1\""); + assertThat(offsetAcross(drawing)).as("left of the text's column").isCloseTo(-13, within(0.01)); + } + + @Test + void aCellHeldInACellSetToTheMiddleCarriesNoneEither() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph heading = text(document, "Heading"); + XWPFTable outer = document.createTable(1, 1); + XWPFTableCell holding = outer.getRow(0).getCell(0); + width(holding, 300); + holding.getCTTc().getTcPr().addNewVAlign().setVal(STVerticalJc.CENTER); + XWPFTable inner = new XWPFTable(holding.getCTTc().addNewTbl(), holding, 1, 1); + holding.insertTable(holding.getTables().size(), inner); + XWPFTableCell cell = inner.getRow(0).getCell(0); + width(cell, 200); + margin(cell, 0); + XWPFParagraph entry = cell.getParagraphs().get(0); + entry.createRun().setText("Entry"); + XWPFParagraph note = text(document, "Note"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, heading, true); + anchors.seat(0, heading, "Flow[0]/Section[0]/Heading[0]", heading, 40, 300, 60, 70); + anchors.seat(0, entry, "Flow[0]/Section[0]/Row[1]/Entry[0]", entry, 110, 290, 100, 110); + anchors.seat(0, note, "Flow[0]/Aside[1]/Note[0]", note, 40, 300, 104, 114); + + queue(anchors, "Flow[0]/Section[0]/Row[1]/Bar[1]", List.of(dot(120, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(entry)).isEmpty(); + assertThat(drawingsIn(note)).isEmpty(); + assertThat(drawingsIn(heading)).hasSize(1); + } + + @Test + void aShapeNoNodeDrawsGoesToTheParagraphNearestItHoweverFar() { + // A timeline's rail, 80pt from the nearer of two entries. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "First"); + XWPFParagraph second = text(document, "Second"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, "Flow[0]/First[0]", first, 40, 300, 100, 110); + anchors.seat(0, second, "Flow[0]/Second[1]", second, 40, 300, 400, 410); + + anchors.queue(List.of(dot(30, 180)), -1, new DocxDrawingAnchors.Owner("timeline rail", "@timeline-rail")); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(second)).isEmpty(); + assertThat(offsetDown(drawingsIn(first).get(0))).isCloseTo(80, within(0.01)); + } + + @Test + void aShapeWhoseHoldersWriteNoParagraphOnItsPageGoesToTheNearest() { + // A badge beside a table holding the stack's text, which offers no paragraph: the heading + // under it is nearer than the page's first paragraph. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph name = text(document, "Name"); + XWPFParagraph heading = text(document, "Heading"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, name, true); + anchors.seat(0, name, "Flow[0]/Name[0]", name, 40, 300, 60, 70); + anchors.seat(0, heading, "Flow[0]/Heading[3]", heading, 40, 300, 500, 510); + + queue(anchors, "Flow[0]/Bodies[4]/Body[0]/Badge[1]", List.of(dot(300, 530))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(name)).as("not the page's first paragraph").isEmpty(); + assertThat(offsetDown(drawingsIn(heading).get(0))).isCloseTo(30, within(0.01)); + } + + @Test + void aParagraphThatCannotPlaceItIsPassedOverForTheNextNearest() { + // The nearer paragraph has a border above its line, which hides where it starts. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph bordered = text(document, "Bordered"); + bordered.getCTP().addNewPPr().addNewPBdr().addNewTop() + .setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder.SINGLE); + XWPFParagraph plain = text(document, "Plain"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, bordered, true); + anchors.seat(0, bordered, "Flow[0]/Bordered[0]", bordered, 40, 300, 100, 110); + anchors.seat(0, plain, "Flow[0]/Plain[1]", plain, 40, 300, 200, 210); + + anchors.queue(List.of(dot(30, 104)), -1, null); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(bordered)).isEmpty(); + assertThat(offsetDown(drawingsIn(plain).get(0))).isCloseTo(-96, within(0.01)); + } + + @Test + void whereNoParagraphOnItsPageCanPlaceItItStaysWhereThePagePutsIt() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph header = cellParagraph(document, 200, "Header"); + ((XWPFTableCell) header.getBody()).getTableRow().setRepeatHeader(true); + XWPFParagraph body = text(document, "Body"); + body.getCTP().addNewPPr().addNewContextualSpacing(); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, body, true); + anchors.seat(0, header, STACK + "/Header[0]", header, 110, 290, 100, 110); + anchors.seat(0, body, STACK + "/Body[1]", body, 40, 300, 200, 210); + + queue(anchors, ICON, List.of(dot(120, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(header)).as("a repeated header row, which Word repeats with it").isEmpty(); + assertThat(drawingsIn(body)).singleElement().asString() + .contains("" + emu(102) + "<"); + } + + @Test + void aParagraphIsWithinItsNodeAndTheBlocksHoldingItOnly() { + assertThat(seatOf("Flow[0]/Stack[1]/Text[0]").isWithin("Flow[0]/Stack[1]")).isTrue(); + assertThat(seatOf("Flow[0]/Stack[1]").isWithin("Flow[0]/Stack[1]")).as("its own node").isTrue(); + assertThat(seatOf("Flow[0]/Stack[10]/Text[0]").isWithin("Flow[0]/Stack[1]")).as("a sibling sharing a prefix") + .isFalse(); + assertThat(seatOf("Flow[0]/Stack[1]x/Text[0]").isWithin("Flow[0]/Stack[1]")).isFalse(); + assertThat(seatOf("Flow[0]").isWithin("Flow[0]/Stack[1]")).as("a block holding it").isFalse(); + assertThat(seatOf(null).isWithin("Flow[0]")).as("text no node writes").isFalse(); + } + + private static DocxDrawingAnchors.Seat seatOf(String path) { + return new DocxDrawingAnchors.Seat(null, 0, 0, 0, 0, path); + } + + @Test + void aComposedCellsShapeStillGoesOnlyToTheTextNearestItWithinReach() { + // The same shape, 120pt below the holder's paragraph: a node's goes there, a composed + // cell's — which has no node — stays where the page puts it. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "Heading"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, STACK + "/Heading[0]", first, 40, 300, 200, 210); + + anchors.queue(List.of(dot(30, 320)), -1, new DocxDrawingAnchors.Owner("cell drawing", STACK, true)); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(first)).singleElement().asString().contains(""); + } + + private static DocxDrawingAnchors anchors() { + AtomicLong ids = new AtomicLong(1); + return new DocxDrawingAnchors(ids::getAndIncrement, DocxGrownLines.NONE::lineOf, + DocxDrawingOwnerAnchorsTest::holders); + } + + /** + * A path and the paths above it, nearest first, as the layout names a node's holders: the + * page's flow, where every path here starts, left out ({@link DocxLayoutMetrics#holdersOf}). + */ + private static List holders(String path) { + List holders = new java.util.ArrayList<>(); + for (String holder = path; holder != null && holder.contains("/"); holder = holder.substring(0, + holder.lastIndexOf('/'))) { + holders.add(holder); + } + return holders; + } + + /** Queues shapes a node at a path draws. */ + private static void queue(DocxDrawingAnchors anchors, String path, List shapes) { + anchors.queue(shapes, -1, new DocxDrawingAnchors.Owner("ShapeNode", path)); + } + + /** A paragraph opening the only cell of a new table, its cell 200pt wide at no stated margin. */ + private static XWPFParagraph cellParagraph(XWPFDocument document, double cellWidth, String text) { + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, cellWidth); + margin(cell, 0); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText(text); + return paragraph; + } + + private static DocxDrawings.Shape onPage(int page, DocxDrawings.Shape shape) { + return new DocxDrawings.Shape(shape.kind(), shape.x(), shape.top(), shape.width(), shape.height(), shape.fill(), + shape.stroke(), shape.strokeWidth(), shape.radius(), shape.flipH(), page); + } + + private static DocxDrawings.Shape dot(double x, double top) { + return new DocxDrawings.Shape(DocxDrawings.Kind.ELLIPSE, x, top, 6, 6, Color.BLUE, null, 0, 0, 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 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()).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); + } + } + + private static long emu(double points) { + return org.apache.poi.util.Units.toEMU(points); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java index 7ef917df7..5ce17f540 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java @@ -357,14 +357,19 @@ void aShapeBesideASeatedTitleIsPlacedFromWhereWordStandsTheTitle() throws Except } @Test - void aShapeWithNoParagraphWithinReachStaysOnThePage() throws Exception { + void aShapeNoBlockHoldsGoesToTheParagraphNearestItHoweverFar() throws Exception { + // The page's flow holds the dot and nothing else does: the paragraph 80pt above takes it, + // further off than the 48pt a composed cell's drawing may stand from its text. try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page .addParagraph(p -> p.text("Above")) .addSpacer(s -> s.height(80)) .addEllipse(e -> e.name("Dot").circle(16).fillColor(ACCENT))))) { - assertThat(anchors(document.getDocument().xmlText())).singleElement().asString() - .contains("") - .contains(""); + org.apache.poi.xwpf.usermodel.XWPFParagraph above = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals("Above")).findFirst().orElseThrow(); + String anchor = anchors(above.getCTP().xmlText()).get(0); + assertThat(anchor).contains("") + .contains(""); + assertThat(offsetDown(anchor)).isGreaterThan(80); } } @@ -597,11 +602,13 @@ void aShapeBesideAColumnsTextIsAnchoredInItsCellAndMovesWithIt() throws Exceptio @Test void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Exception { - // Two columns written as one row: the page has no body paragraph, and the ring has no - // paragraph beside it in its own column. A hairline paragraph opens the page for it. + // Two columns written as one row: the page has no body paragraph, and its one paragraph, + // centred in its cell, does not tell where that cell's column starts to place the ring + // from. A hairline paragraph opens the page for it. try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page .addLayerStack(stack -> stack - .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))), + .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact") + .align(com.demcha.compose.document.node.TextAlign.CENTER))), com.demcha.compose.document.node.LayerAlign.TOP_LEFT) .layer(column("Main", 100, 0, main -> main .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))), @@ -622,6 +629,33 @@ void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Ex } } + @Test + void aRingAloneInItsColumnBesideALeftSetParagraphIsAnchoredInItsOwnCellWithNoHairline() throws Exception { + // The page above, its paragraph set from the left: the ring's own cell of the columns' + // row carries it, placed from that paragraph's top, and nothing opens the page before + // the table. + try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page + .addLayerStack(stack -> stack + .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))), + com.demcha.compose.document.node.LayerAlign.TOP_LEFT) + .layer(column("Main", 100, 0, main -> main + .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))), + com.demcha.compose.document.node.LayerAlign.TOP_LEFT))))) { + List body = document.getBodyElements(); + + assertThat(body.get(0)).as("the columns' row opens the page") + .isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class); + List carriers = document.getTables().get(0).getRows().stream() + .flatMap(row -> row.getTableCells().stream()) + .filter(cell -> cell.getCTTc().xmlText().contains("prst=\"ellipse\"")).toList(); + assertThat(carriers).singleElement().satisfies(cell -> { + assertThat(cell.getText()).as("the ring's own cell, not the sidebar's").doesNotContain("Contact"); + assertThat(anchors(cell.getCTTc().xmlText()).get(0)).contains("layoutInCell=\"1\"") + .contains(""); + }); + } + } + @Test void aShapeInAPaintedPanelIsDrawnInFrontOfTheText() throws Exception { // Both editors paint a shaded cell over what lies behind the text. @@ -826,9 +860,11 @@ void aLaterSectionOpeningWithATableGetsItsBodyParagraphBeforeThatTable() throws first.pageFlow(page -> page.addParagraph(p -> p.text("Cover"))); DocumentSession second = GraphCompose.document().pageSize(400, 400).margin(DocumentInsets.of(20)).create(); second.pageFlow(page -> page.addLayerStack(stack -> stack - .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))), + .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact") + .align(com.demcha.compose.document.node.TextAlign.CENTER))), com.demcha.compose.document.node.LayerAlign.TOP_LEFT) - // The ring alone in its column: no paragraph stands beside it. + // The ring alone in its column, and the page's one paragraph centred: nothing on + // the page can place it. .layer(column("Main", 100, 0, main -> main .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))), com.demcha.compose.document.node.LayerAlign.TOP_LEFT))); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java index 05cd9d513..108f00dd4 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java @@ -152,7 +152,11 @@ void aPrefixIsNamedWhereThePathThatWritesTheParagraphWritesNone() throws Excepti .isEqualTo("written as one side of a line it shares; its bulletOffset's letters, \"•\", are not " + "written before its first line; " + room); // A text box over the flow. - String boxed = "laid over the flow, which gives it no room: set in a text box where the page sets it"; + String boxed = "laid over the flow, which gives it no room: set in a text box where the page sets it, " + + "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 above is edited; in the paragraph " + + "nearest it where no block holding it writes one there, or to the page where no paragraph " + + "on its page can carry it"; assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L") .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead"))) .contains(boxed + "; its bulletOffset's letters, \"•\", are not written before its first line; " + room);