From be8f6d0d03a138a2cfaa083879f420f02e24fd86 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 11:45:09 +0100 Subject: [PATCH 1/5] fix(docx): anchor a drawing in the paragraph whose text it stands beside, so it moves with that text A shape the page paints (a timeline's dot, an icon by a heading, a skill's bar, a badge) was placed from the page's edges, so editing the text above it moved the text and left the shape behind. DocxDrawingAnchors now anchors each shape in the nearest paragraph within 48pt that it hangs from, placed down from that paragraph's top as Word reckons it: the space above included, an exact line's baseline four fifths of the way down, raised by the run's position. In a table cell it is laid out in the cell and placed from the cell's text column. A shape beside no such paragraph stays on the page. The Word editing protocol gains a drawings-follow-text scenario. --- CHANGELOG.md | 16 + CONTRIBUTING.md | 8 +- .../architecture/backend-capability-matrix.md | 19 +- docs/architecture/canonical-legacy-parity.md | 2 +- docs/getting-started.md | 2 +- docs/recipes/docx-export.md | 39 +- docs/recipes/shape-as-container.md | 6 +- docs/recipes/timelines.md | 7 +- .../libreoffice-windows-lines.tsv | 116 +++--- .../docx-fidelity/libreoffice-windows.tsv | 2 +- .../docx-fidelity/word-windows-lines.tsv | 104 +++--- .../resources/docx-fidelity/word-windows.tsv | 2 +- .../semantic/docx/DocxDrawingAnchors.java | 334 +++++++++++++++-- .../backend/semantic/docx/DocxDrawings.java | 40 +- .../semantic/docx/DocxLayoutMetrics.java | 33 ++ .../semantic/docx/DocxSemanticBackend.java | 45 ++- .../semantic/docx/DocxCellDrawingTest.java | 36 +- .../semantic/docx/DocxDrawingAnchorsTest.java | 345 ++++++++++++++++++ .../semantic/docx/DocxDrawingsTest.java | 131 ++++++- scripts/docx-visual/edit-protocol-corpus.ps1 | 114 +++++- 20 files changed, 1176 insertions(+), 225 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f97231e79..1f4adea53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX's drawings move with the text they stand beside.** Most shapes the page paints — a + timeline's dot, an icon by a heading, a skill's bar, a badge — were placed from the page's + edges, so when a reader edited the text above one, the text moved and the shape stayed. A shape + standing beside a paragraph's text is now anchored in that paragraph and placed down from its + top: the nearest paragraph within 48pt that it hangs from, and one in a table cell only when + the cell holds it across, its line is set from the left and its row is not a repeated header. The paragraph's top is where + Word places from, the space above its first line included and its line where Word stands it, + measured in Word 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph a + paragraph node is written as offers its place yet; beside a table's own cell text or a list's + items, in the gap between two columns or far from any text, a shape stays on the page. Word's + editing protocol, with a new `drawings-follow-text` scenario that lengthens a paragraph, finds + 132 of the 228 shapes + beside text that moved moving with it, against 12, and 21 of the 35 documents with drawings + passing, against 15. Unedited, no shape stands more than 1.5pt from where it stood in Word. + In LibreOffice the same holds but for `OrangeOps`'s header separator, which follows its line + of text where LibreOffice already sets that line 78pt low. - **Restyling Normal reaches a DOCX's body text when the body is in runs or tables.** Normal is elected from the style the most characters are set in, and the count left two kinds of text out. A paragraph made of runs counted in its paragraph's style, which its runs set aside, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 526b697c5..d9cd12eee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -368,20 +368,24 @@ Choose the smallest tests that match the change: The corpus also answers for how a document edits in Word. `scripts/docx-visual/edit-protocol-corpus.ps1` runs over the DOCX that `-Dgraphcompose.docxFidelity=export` leaves in `qa/target/docx-fidelity/engine`. For each - document it makes nine edits, each on a copy that it saves and reopens: + document it makes ten edits, each on a copy that it saves and reopens: - lengthen the longest paragraph, and shorten it; - press Enter after it and type; - restyle Normal; - add a table row, and delete one; - continue a list; - type at the end until a new page opens; + - lengthen the longest paragraph again, watching the drawings; - save with no edit. It records what it can decide alone in `edit-protocol-corpus.json`: - text kept, and a paragraph grown or shrunk and still above the block after it; - the body size following Normal; - typed text standing below everything else, outside any table, visible, in the size of the - text it continues (or, after an empty paragraph, on lines that fit it). + text it continues (or, after an empty paragraph, on lines that fit it); + - each drawing beside text the lengthening moved moving with it, by as much and onto the same + page. A drawing belongs to the paragraph nearest its top-left corner, up or down from where + the paragraph starts and across from its text; a page's backgrounds are left out. It reads the letters' size and visibility from the text, without the paragraph mark. Beside the lengthened copy and the new-page copy it writes PDFs, from which a person judges whether diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index c5dfc3143..842a148be 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -43,10 +43,11 @@ 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 DrawingML shape anchored to the page behind the text (`DocxDrawings`, +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`, polygons and paths as custom geometry). Inside a filled panel a shape stands in -front of the text unless it frames text, still placed from the page: Word lays -a shape anchored in a table cell out inside the cell. A filled or outlined +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 +table cell out inside the cell. A filled or outlined rectangle, rounded rectangle or ellipse whose one centred layer is a line of at most four characters in one style, left to right and unlinked, is one shape holding that text. A shape filled only with a gradient paint shows nothing the export carries and is dropped with a logged warning. @@ -69,10 +70,10 @@ 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; 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) | | 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, reported `APPROXIMATED`) | | 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: 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 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). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than 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 —, since a nested table running past its cell widens the cell, fixed layout or not. 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. 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 DrawingML shape anchored to the page 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, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — 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; 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` | -| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored to the page, 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, 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 in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — 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 is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is 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 to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | -| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | +| 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: 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 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). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than 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 —, since a nested table running past its cell widens the cell, fixed layout or not. 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. 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, each side border a line along its edge; a gradient paint and a transform are not carried, 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; 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` | +| 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, 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 is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is 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 — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | +| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored as a rectangle is | | Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped | | Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ | | Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ⚠️ `PptxGradientFill` (`circle` path shade — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ | @@ -80,8 +81,8 @@ Payload records live in `core` under | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | -| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a picture anchored to the page over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing`, anchored to the page where the layout puts it | -| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape anchored to the page, and the markers as the shapes they are; they stay where the layout put them when the entries' text is edited | +| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | +| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size | diff --git a/docs/architecture/canonical-legacy-parity.md b/docs/architecture/canonical-legacy-parity.md index b04270f53..1f7e6897f 100644 --- a/docs/architecture/canonical-legacy-parity.md +++ b/docs/architecture/canonical-legacy-parity.md @@ -47,7 +47,7 @@ reaching into engine internals. | Horizontal divider | Done | `DividerBuilder` is the canonical convenience over a thin `ShapeNode`. The "Partial" earlier note reflected the implementation choice (alias rather than dedicated node), not a missing feature — every divider configuration that authors reach for is supported. | | Arbitrary line path | Done | Use `addLine(...)` for horizontal, vertical, diagonal, and custom lines. | | Circle/ellipse | Done | Use `addEllipse(...)` or `addCircle(...)` with fill and stroke. | -| Shape-as-container (clipped) | Partial | Use `addCircle(diameter, fill, inside)` / `addEllipse(w, h, fill, inside)` / `addContainer(...)` (canonical `ShapeContainerNode`). PDF backend honours `ClipPolicy.CLIP_PATH` / `CLIP_BOUNDS` / `OVERFLOW_VISIBLE`; DOCX backend cannot express graphics-state path clipping (Apache POI limitation), so it renders the layers inline without clipping and logs a one-time `docx.export.shape-container-fallback` capability warning per export pass. The outline is drawn as a shape anchored to the page — custom geometry for a star, a diamond or a path — and a picture that fills a container clipped to an ellipse takes the ellipse's shape; authors who need the clip itself must export to PDF. | +| Shape-as-container (clipped) | Partial | Use `addCircle(diameter, fill, inside)` / `addEllipse(w, h, fill, inside)` / `addContainer(...)` (canonical `ShapeContainerNode`). PDF backend honours `ClipPolicy.CLIP_PATH` / `CLIP_BOUNDS` / `OVERFLOW_VISIBLE`; DOCX backend cannot express graphics-state path clipping (Apache POI limitation), so it renders the layers inline without clipping and logs a one-time `docx.export.shape-container-fallback` capability warning per export pass. The outline is drawn as a floating shape — custom geometry for a star, a diamond or a path — and a picture that fills a container clipped to an ellipse takes the ellipse's shape; authors who need the clip itself must export to PDF. | | Transform (rotate / scale) | Done | `ShapeContainerBuilder`, `ShapeBuilder`, `LineBuilder`, `EllipseBuilder`, `ImageBuilder`, and `BarcodeBuilder` all implement `Transformable` via the `rotate(degrees)` / `scale(uniform)` / `scale(sx, sy)` mixin. PDF backend wraps each transformed leaf with a graphics-state `cm` matrix that pivots around the placement centre. DOCX backend ignores the transform and renders the un-transformed geometry — Apache POI cannot express graphics-state matrices, and authors who need rotated output must export to PDF. | | Per-layer z-index | Done via `LayerStack` / `ShapeContainer` | `LayerStackBuilder.layer(node, align, zIndex)` and `ShapeContainerBuilder.layer(node, align, zIndex)` (plus matching `position(...)` overloads with `zIndex`) re-stack layers without re-ordering source. Default `zIndex` is `0` so existing layouts paginate identically. | diff --git a/docs/getting-started.md b/docs/getting-started.md index 1420ab216..bd0a4e284 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -205,7 +205,7 @@ document.pageFlow(page -> page The PDF backend honours every clip policy via graphics-state `saveGraphicsState()` / `clip(path)` / `restoreGraphicsState()` markers emitted by the layout layer; the DOCX backend renders layers inline -without clipping, draws the outline as a shape anchored to the page, and +without clipping, draws the outline as a floating shape, and logs a one-time capability warning. See [`docs/recipes/shape-as-container.md`](recipes/shape-as-container.md) for the full recipe. diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index ccedbeb1e..d3585099c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -727,8 +727,8 @@ tint it was flattened to. That is recorded with the rest. 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 — anchored to the page where the page draws - it. 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 as any + other shape is. 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 @@ -759,7 +759,7 @@ tint it was flattened to. That is recorded with the rest. chip is cut as far as its letters allow. A label set from the shape's top or bottom edge, or moved up or down, is written as it was. A badge's glyph — a smaller picture in a filled or outlined container that clips it to its outline (`CLIP_PATH`) and holds nothing - else but drawing — is drawn as a picture anchored to the page over the + else but drawing — is drawn as a floating picture over the badge, where the page draws it, rather than written as a line of its own above the text beside the badge. Inside a filled panel the badge and its glyph are drawn in front of the panel's shading, which both editors paint @@ -832,12 +832,26 @@ The placement was measured in LibreOffice; Word has not been measured yet. Vertical and slanted lines, ellipses, rectangles and rounded bars — standalone, in a 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 DrawingML shape anchored to the -page, behind the text, at the place, size, fill and outline the layout gives it, and -the report lists it as approximated. A shape stays where it is when the text around it -is edited. It is anchored at the start of the first body paragraph written on its page, +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, that the shape hangs from rather than rising above; one in a table cell +carries the shape only when its cell holds it across, its line is set from the left and its +row is not a repeated header, +and places it from the cell's text column. 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 +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 +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 -prints the shape clipped to. A page with no body paragraph — one laid out entirely in a +measures it from. A page with no body paragraph — one laid out entirely in a table, as a two-column CV is — gets one: on the section's first page a paragraph a hairline tall opens before the table; on its last, the paragraph closing the section carries it; on any other page, the first paragraph of a cell on it does, where Word may @@ -852,10 +866,11 @@ step number — is one shape holding that text centred in it, drawn in front. Th its text in its own box, reaching out to its edges past the preset's inner text rectangle: Word shrinks a text box that does not wrap to the width of its letters, and wraps an ellipse's text in the square inscribed in it. Initials in a corner, in two styles, right to -left or linked are written in the flow as before. Every shape is placed from the page's -edges: Word lays a shape anchored in a table cell out inside the cell and measures a nested -cell's shape from the outer cell's top, so a shape placed from a paragraph in a panel landed -wherever the nesting put it. Word draws a panel's top and bottom borders outside the cell's +left or linked are written in the flow as before. A shape beside text in a panel is anchored in +that text's paragraph in the cell and laid out in the cell, placed from the cell's text +column and the paragraph's top, which Word and LibreOffice both measure from the paragraph, +nested or not; placed from the page's edges instead, Word measured a nested cell's shape from +the outer cell's top. Word draws a panel's top and bottom borders outside the cell's shading, where the page strokes them on the panel's edge, so the top border comes out of the space above the panel, and the bottom border out of the space the panel holds below itself or, past that, out of the space above the paragraph, table or panel that follows. A panel with diff --git a/docs/recipes/shape-as-container.md b/docs/recipes/shape-as-container.md index 6cf9058c6..c4cfeb26d 100644 --- a/docs/recipes/shape-as-container.md +++ b/docs/recipes/shape-as-container.md @@ -129,12 +129,12 @@ session.layoutGraph(); // → AtomicNodeTooLargeException Apache POI cannot express a graphics-state path clip, so the `DocxSemanticBackend` renders the container's *layers* inline without clipping, and logs a one-time `docx.export.shape-container-fallback` -capability warning per export pass. The outline is drawn as a shape -anchored to the page where the page draws it — a star, a diamond or a path +capability warning per export pass. The outline is drawn as a floating shape +where the page draws it — a star, a diamond or a path as custom geometry — and a picture that fills a container clipped to an ellipse takes the ellipse's shape. A badge's glyph — a smaller picture in a painted container that clips it to its outline and holds nothing else but -drawing — is drawn over the outline, anchored to the page where the page +drawing — is drawn over the outline, where the page draws it, outside a filled panel. Content is not clipped to the outline; authors who need the clip must export to PDF. diff --git a/docs/recipes/timelines.md b/docs/recipes/timelines.md index d443ef647..d761db7e3 100644 --- a/docs/recipes/timelines.md +++ b/docs/recipes/timelines.md @@ -235,12 +235,13 @@ only in the degenerate case of a single marker row taller than a whole page. |---|---|---| | **PDF** | ✅ drawn, one fragment per page, beneath the markers | ✅ | | **PPTX** | ✅ same payload, same per-page fragments | ✅ | -| **DOCX** | ⚠️ drawn as a shape anchored to the page | ✅ entries, titles, meta and bodies all export | +| **DOCX** | ⚠️ drawn as shapes, anchored beside the entries' text | ✅ entries, titles, meta and bodies all export | DOCX is a semantic export: it writes the entries as text from the document tree, and reads the rail from the resolved layout, drawing it on each page as a line anchored where the layout put -it, behind the text, with the markers as the shapes they are. The drawing stays where it is -when the entries' text is edited. See the +it, behind the text, with the markers as the shapes they are. A marker or a rail standing beside an entry's +text is anchored in that entry's paragraph and moves with it when the text above is edited; +one beside no text stays where the page puts it. See the [backend capability matrix](../architecture/backend-capability-matrix.md). ## Text styles diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index c82936879..11554768b 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -302,64 +302,64 @@ cv-classic_serif 1:f20997652453 0.19 0.10 languages:english(fluent),german cv-classic_serif 1:ec9d3622395e 0.17 0.10 workeligibility:eligibletoworkin cv-classic_serif 1:dd20ae01c3cf 0.14 0.10 opensource:maintainerofgraphcomp cv-classic_serif 1:33c35e9d236d 0.12 0.10 speaking:jvmsummit2024,devoxxuk2 -cv-compact_mono 0:8f1010120ed6 0.41 0.10 jordanrivera -cv-compact_mono 0:f9e4560bd641 -0.14 0.10 london,uk/+442055551000/jordan.r -cv-compact_mono 0:32c1cf49a2fe 0.34 0.10 skills -cv-compact_mono 0:551282493299 -0.01 0.10 languagesjava21,kotlin,groovy,py -cv-compact_mono 0:8f5cfd05d621 -0.22 0.10 document&printpdfbox,apachepoi -cv-compact_mono 0:148ff6990529 -0.11 0.10 (docx/xlsx),itext,postscript -cv-compact_mono 0:862bd50bcc32 -0.10 0.10 layoutenginescustomdsldesign,sem -cv-compact_mono 0:ec4f3e745260 0.01 0.10 layouttrees,pagination,snapshott -cv-compact_mono 0:55a8b2742c06 0.02 0.10 build&infrastructuremaven,gradle -cv-compact_mono 0:1649ba79bc7a 0.13 0.10 actions,jitpack -cv-compact_mono 0:d4ac2ee01f81 0.14 0.10 testingjunit5,assertj,pdfbox-bas -cv-compact_mono 0:b04e6ebfb02b 0.25 0.10 diff,layout-graphsnapshots -cv-compact_mono 0:93c9f742ef14 0.26 0.10 distributionmavencentral,sonatyp -cv-compact_mono 0:8f21bd149ad7 0.37 0.10 gpgsigning,jitpack -cv-compact_mono 0:68bf0109c6f8 0.93 0.10 education -cv-compact_mono 0:9ad8d9ef1711 0.43 0.10 msccomputerscience/universityof -cv-compact_mono 0:594f6dd11e20 0.50 0.10 manchester/2019-2021 -cv-compact_mono 0:387dc0387824 0.53 0.10 bscsoftwareengineering/imperialc -cv-compact_mono 0:453806946a88 0.60 0.10 london/2015-2019 -cv-compact_mono 0:7816e7a28b71 0.63 0.10 oraclejavacertification/professi -cv-compact_mono 0:d40b22f00acb 0.85 0.10 2023-2024 -cv-compact_mono 0:7b476727faf3 1.41 0.10 additional -cv-compact_mono 0:ecc25e73b19b 0.90 0.10 languages:english(fluent),german -cv-compact_mono 0:ac7f0ee6a49e 0.91 0.10 (intermediate),spanish(basic) -cv-compact_mono 0:970ca9d789c2 0.92 0.10 workeligibility:eligibletoworkin -cv-compact_mono 0:0735680e2596 0.93 0.10 theeu -cv-compact_mono 0:8051a4b5e34f 0.94 0.10 opensource:maintainerofgraphcomp -cv-compact_mono 0:08c2e869a75b 0.95 0.10 regularcontributortopdfboxissuet -cv-compact_mono 0:12dcd1343975 0.96 0.10 speaking:jvmsummit2024,devoxxuk2 -cv-compact_mono 0:3aea7f90f8ed 0.97 0.10 —bothondeclarativedocumentlayout -cv-compact_mono 0:1900eab6c028 0.54 0.09 profile -cv-compact_mono 0:3828b9718a0d -0.05 0.09 platformengineerwith10+yearsbuil -cv-compact_mono 0:f6b5e93ccadd -0.04 0.09 engines,anddeveloper-facingtempl -cv-compact_mono 0:927ef106776a -0.03 0.09 semanticauthoringdsls,andturning -cv-compact_mono 0:330fcb75ed52 -0.02 0.09 librariesthatscale. -cv-compact_mono 0:53e5e7c5a884 0.26 0.09 experience -cv-compact_mono 0:c9efb118bfda -0.09 0.09 seniorplatformengineer,northwind -cv-compact_mono 0:64c4b52dceb1 -0.29 0.09 ledthereusabledocument-generatio -cv-compact_mono 0:1f193cd1d987 -0.27 0.09 productteams.reducedtemplatemain -cv-compact_mono 0:9ccc3e904e5a -0.25 0.09 favourofonecanonicalengine. -cv-compact_mono 0:e21e4e47835a -0.04 0.09 softwareengineer,brightleaflabs| -cv-compact_mono 0:0cdd48f05f9d -0.24 0.09 builtbackendservicesandproductio -cv-compact_mono 0:e48f1e2c853d -0.22 0.09 permonth.drovethemigrationfromit -cv-compact_mono 0:b78a9a72ca9a -0.20 0.09 andcuttingp99renderlatencyfrom1. -cv-compact_mono 0:9f5d87c00954 0.01 0.09 backendengineer,helixprintco|201 -cv-compact_mono 0:a485c37fe7ea -0.19 0.09 maintainedahigh-volumeinvoice-pr -cv-compact_mono 0:5a136b6b9d8c -0.17 0.09 testharnessthatgatedeverytemplat -cv-compact_mono 0:d94eccbb1fbe 0.09 0.09 selectedprojects -cv-compact_mono 0:0059a3aac61e -0.46 0.09 graphcompose(java21,pdfbox,maven -cv-compact_mono 0:18b7f8c840e4 -0.43 0.09 dsl,slot-basedtemplates,snapshot -cv-compact_mono 0:d36f78e88fe0 -0.39 0.09 forhiringtoolsandbillingsystems. -cv-compact_mono 0:55e119878845 -0.38 0.09 templatestudio(kotlin,composedes -cv-compact_mono 0:c7b83853442b -0.35 0.09 proposal,andinvoiceoutputacross1 -cv-compact_mono 0:6b844249c695 -0.31 0.09 freezing. -cv-compact_mono 0:ab89138286bb -0.30 0.09 layoutlint(java21,javaparser,spo -cv-compact_mono 0:2c506ce69aa6 -0.27 0.09 nestedrows,untypedoffsets,implic -cv-compact_mono 0:6793d28e9783 -0.26 0.09 chromeforge(java,graphcompose,pa -cv-compact_mono 0:e3cfea265b3b -0.23 0.09 graphcompose:cinematiccovers,pul +cv-compact_mono 0:8f1010120ed6 0.31 0.10 jordanrivera +cv-compact_mono 0:f9e4560bd641 -0.24 0.10 london,uk/+442055551000/jordan.r +cv-compact_mono 0:32c1cf49a2fe 0.24 0.10 skills +cv-compact_mono 0:551282493299 -0.11 0.10 languagesjava21,kotlin,groovy,py +cv-compact_mono 0:8f5cfd05d621 -0.32 0.10 document&printpdfbox,apachepoi +cv-compact_mono 0:148ff6990529 -0.21 0.10 (docx/xlsx),itext,postscript +cv-compact_mono 0:862bd50bcc32 -0.20 0.10 layoutenginescustomdsldesign,sem +cv-compact_mono 0:ec4f3e745260 -0.09 0.10 layouttrees,pagination,snapshott +cv-compact_mono 0:55a8b2742c06 -0.08 0.10 build&infrastructuremaven,gradle +cv-compact_mono 0:1649ba79bc7a 0.03 0.10 actions,jitpack +cv-compact_mono 0:d4ac2ee01f81 0.04 0.10 testingjunit5,assertj,pdfbox-bas +cv-compact_mono 0:b04e6ebfb02b 0.15 0.10 diff,layout-graphsnapshots +cv-compact_mono 0:93c9f742ef14 0.16 0.10 distributionmavencentral,sonatyp +cv-compact_mono 0:8f21bd149ad7 0.27 0.10 gpgsigning,jitpack +cv-compact_mono 0:68bf0109c6f8 0.83 0.10 education +cv-compact_mono 0:9ad8d9ef1711 0.33 0.10 msccomputerscience/universityof +cv-compact_mono 0:594f6dd11e20 0.40 0.10 manchester/2019-2021 +cv-compact_mono 0:387dc0387824 0.43 0.10 bscsoftwareengineering/imperialc +cv-compact_mono 0:453806946a88 0.50 0.10 london/2015-2019 +cv-compact_mono 0:7816e7a28b71 0.53 0.10 oraclejavacertification/professi +cv-compact_mono 0:d40b22f00acb 0.75 0.10 2023-2024 +cv-compact_mono 0:7b476727faf3 1.31 0.10 additional +cv-compact_mono 0:ecc25e73b19b 0.80 0.10 languages:english(fluent),german +cv-compact_mono 0:ac7f0ee6a49e 0.81 0.10 (intermediate),spanish(basic) +cv-compact_mono 0:970ca9d789c2 0.82 0.10 workeligibility:eligibletoworkin +cv-compact_mono 0:0735680e2596 0.83 0.10 theeu +cv-compact_mono 0:8051a4b5e34f 0.84 0.10 opensource:maintainerofgraphcomp +cv-compact_mono 0:08c2e869a75b 0.85 0.10 regularcontributortopdfboxissuet +cv-compact_mono 0:12dcd1343975 0.86 0.10 speaking:jvmsummit2024,devoxxuk2 +cv-compact_mono 0:3aea7f90f8ed 0.87 0.10 —bothondeclarativedocumentlayout +cv-compact_mono 0:1900eab6c028 0.44 0.09 profile +cv-compact_mono 0:3828b9718a0d -0.15 0.09 platformengineerwith10+yearsbuil +cv-compact_mono 0:f6b5e93ccadd -0.14 0.09 engines,anddeveloper-facingtempl +cv-compact_mono 0:927ef106776a -0.13 0.09 semanticauthoringdsls,andturning +cv-compact_mono 0:330fcb75ed52 -0.12 0.09 librariesthatscale. +cv-compact_mono 0:53e5e7c5a884 0.16 0.09 experience +cv-compact_mono 0:c9efb118bfda -0.19 0.09 seniorplatformengineer,northwind +cv-compact_mono 0:64c4b52dceb1 -0.39 0.09 ledthereusabledocument-generatio +cv-compact_mono 0:1f193cd1d987 -0.37 0.09 productteams.reducedtemplatemain +cv-compact_mono 0:9ccc3e904e5a -0.35 0.09 favourofonecanonicalengine. +cv-compact_mono 0:e21e4e47835a -0.14 0.09 softwareengineer,brightleaflabs| +cv-compact_mono 0:0cdd48f05f9d -0.34 0.09 builtbackendservicesandproductio +cv-compact_mono 0:e48f1e2c853d -0.32 0.09 permonth.drovethemigrationfromit +cv-compact_mono 0:b78a9a72ca9a -0.30 0.09 andcuttingp99renderlatencyfrom1. +cv-compact_mono 0:9f5d87c00954 -0.09 0.09 backendengineer,helixprintco|201 +cv-compact_mono 0:a485c37fe7ea -0.29 0.09 maintainedahigh-volumeinvoice-pr +cv-compact_mono 0:5a136b6b9d8c -0.27 0.09 testharnessthatgatedeverytemplat +cv-compact_mono 0:d94eccbb1fbe -0.01 0.09 selectedprojects +cv-compact_mono 0:0059a3aac61e -0.56 0.09 graphcompose(java21,pdfbox,maven +cv-compact_mono 0:18b7f8c840e4 -0.53 0.09 dsl,slot-basedtemplates,snapshot +cv-compact_mono 0:d36f78e88fe0 -0.49 0.09 forhiringtoolsandbillingsystems. +cv-compact_mono 0:55e119878845 -0.48 0.09 templatestudio(kotlin,composedes +cv-compact_mono 0:c7b83853442b -0.45 0.09 proposal,andinvoiceoutputacross1 +cv-compact_mono 0:6b844249c695 -0.41 0.09 freezing. +cv-compact_mono 0:ab89138286bb -0.40 0.09 layoutlint(java21,javaparser,spo +cv-compact_mono 0:2c506ce69aa6 -0.37 0.09 nestedrows,untypedoffsets,implic +cv-compact_mono 0:6793d28e9783 -0.36 0.09 chromeforge(java,graphcompose,pa +cv-compact_mono 0:e3cfea265b3b -0.33 0.09 graphcompose:cinematiccovers,pul cv-editorial_blue 0:8f1010120ed6 0.45 0.12 jordanrivera cv-editorial_blue 0:2d58f0cadc2d 0.22 0.13 platformengineer cv-editorial_blue 0:1b28eb6a9a2e 0.14 0.13 +442055551000-london,uk diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index 2b4a561b0..b06e0b821 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -5,7 +5,7 @@ cv-boxed_sections 2 2 58 58 0.35 0.63 0 cv-centered_headline 2 2 58 56 0.14 0.32 0 cv-charcoal_gold 1 1 85 83 0.11 0.34 0 cv-classic_serif 2 2 57 54 0.06 0.22 0 -cv-compact_mono 1 1 58 58 0.27 0.93 0 +cv-compact_mono 1 1 58 58 0.34 0.83 0 cv-editorial_blue 2 2 82 82 0.08 0.30 0 cv-engineering_resume 1 1 57 57 0.60 1.62 0 cv-executive 2 2 60 60 0.26 0.50 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 60e7ea56b..74f5e6e94 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -304,62 +304,62 @@ cv-classic_serif 1:dd20ae01c3cf 0.18 0.04 opensource:maintainerofgraphcomp cv-classic_serif 1:33c35e9d236d 0.14 0.04 speaking:jvmsummit2024,devoxxuk2 cv-compact_mono 0:8f1010120ed6 0.39 0.00 jordanrivera cv-compact_mono 0:f9e4560bd641 -0.26 0.00 london,uk/+442055551000/jordan.r -cv-compact_mono 0:32c1cf49a2fe 0.43 -0.04 skills -cv-compact_mono 0:551282493299 0.02 -0.04 languagesjava21,kotlin,groovy,py -cv-compact_mono 0:8f5cfd05d621 -0.24 -0.04 document&printpdfbox,apachepoi -cv-compact_mono 0:148ff6990529 -0.10 -0.04 (docx/xlsx),itext,postscript -cv-compact_mono 0:862bd50bcc32 -0.10 -0.04 layoutenginescustomdsldesign,sem -cv-compact_mono 0:ec4f3e745260 0.04 -0.04 layouttrees,pagination,snapshott -cv-compact_mono 0:55a8b2742c06 0.04 -0.04 build&infrastructuremaven,gradle -cv-compact_mono 0:1649ba79bc7a 0.18 -0.04 actions,jitpack -cv-compact_mono 0:d4ac2ee01f81 0.18 -0.04 testingjunit5,assertj,pdfbox-bas -cv-compact_mono 0:b04e6ebfb02b 0.32 -0.04 diff,layout-graphsnapshots -cv-compact_mono 0:93c9f742ef14 0.32 -0.04 distributionmavencentral,sonatyp -cv-compact_mono 0:8f21bd149ad7 0.49 -0.04 gpgsigning,jitpack -cv-compact_mono 0:68bf0109c6f8 1.06 -0.04 education -cv-compact_mono 0:9ad8d9ef1711 0.46 -0.04 msccomputerscience/universityof -cv-compact_mono 0:594f6dd11e20 0.56 -0.04 manchester/2019-2021 -cv-compact_mono 0:387dc0387824 0.56 -0.04 bscsoftwareengineering/imperialc -cv-compact_mono 0:453806946a88 0.66 -0.04 london/2015-2019 -cv-compact_mono 0:7816e7a28b71 0.66 -0.04 oraclejavacertification/professi -cv-compact_mono 0:d40b22f00acb 0.91 -0.04 2023-2024 -cv-compact_mono 0:7b476727faf3 1.46 -0.04 additional +cv-compact_mono 0:32c1cf49a2fe 0.31 -0.04 skills +cv-compact_mono 0:551282493299 -0.10 -0.04 languagesjava21,kotlin,groovy,py +cv-compact_mono 0:8f5cfd05d621 -0.36 -0.04 document&printpdfbox,apachepoi +cv-compact_mono 0:148ff6990529 -0.22 -0.04 (docx/xlsx),itext,postscript +cv-compact_mono 0:862bd50bcc32 -0.22 -0.04 layoutenginescustomdsldesign,sem +cv-compact_mono 0:ec4f3e745260 -0.08 -0.04 layouttrees,pagination,snapshott +cv-compact_mono 0:55a8b2742c06 -0.08 -0.04 build&infrastructuremaven,gradle +cv-compact_mono 0:1649ba79bc7a 0.06 -0.04 actions,jitpack +cv-compact_mono 0:d4ac2ee01f81 0.06 -0.04 testingjunit5,assertj,pdfbox-bas +cv-compact_mono 0:b04e6ebfb02b 0.20 -0.04 diff,layout-graphsnapshots +cv-compact_mono 0:93c9f742ef14 0.20 -0.04 distributionmavencentral,sonatyp +cv-compact_mono 0:8f21bd149ad7 0.37 -0.04 gpgsigning,jitpack +cv-compact_mono 0:68bf0109c6f8 0.94 -0.04 education +cv-compact_mono 0:9ad8d9ef1711 0.34 -0.04 msccomputerscience/universityof +cv-compact_mono 0:594f6dd11e20 0.44 -0.04 manchester/2019-2021 +cv-compact_mono 0:387dc0387824 0.44 -0.04 bscsoftwareengineering/imperialc +cv-compact_mono 0:453806946a88 0.54 -0.04 london/2015-2019 +cv-compact_mono 0:7816e7a28b71 0.54 -0.04 oraclejavacertification/professi +cv-compact_mono 0:d40b22f00acb 0.79 -0.04 2023-2024 +cv-compact_mono 0:7b476727faf3 1.34 -0.04 additional cv-compact_mono 0:ecc25e73b19b 0.91 -0.04 languages:english(fluent),german cv-compact_mono 0:ac7f0ee6a49e 0.94 -0.04 (intermediate),spanish(basic) -cv-compact_mono 0:970ca9d789c2 0.98 -0.04 workeligibility:eligibletoworkin -cv-compact_mono 0:0735680e2596 1.01 -0.04 theeu -cv-compact_mono 0:8051a4b5e34f 1.07 -0.04 opensource:maintainerofgraphcomp -cv-compact_mono 0:08c2e869a75b 1.10 -0.04 regularcontributortopdfboxissuet -cv-compact_mono 0:12dcd1343975 1.02 -0.04 speaking:jvmsummit2024,devoxxuk2 -cv-compact_mono 0:3aea7f90f8ed 1.05 -0.04 —bothondeclarativedocumentlayout -cv-compact_mono 0:1900eab6c028 0.63 0.06 profile -cv-compact_mono 0:3828b9718a0d -0.04 0.06 platformengineerwith10+yearsbuil -cv-compact_mono 0:f6b5e93ccadd -0.08 0.06 engines,anddeveloper-facingtempl -cv-compact_mono 0:927ef106776a 0.00 0.06 semanticauthoringdsls,andturning -cv-compact_mono 0:330fcb75ed52 -0.04 0.06 librariesthatscale. -cv-compact_mono 0:53e5e7c5a884 0.33 0.06 experience -cv-compact_mono 0:c9efb118bfda -0.05 0.06 seniorplatformengineer,northwind -cv-compact_mono 0:64c4b52dceb1 -0.24 0.06 ledthereusabledocument-generatio -cv-compact_mono 0:1f193cd1d987 -0.24 0.06 productteams.reducedtemplatemain -cv-compact_mono 0:9ccc3e904e5a -0.21 0.06 favourofonecanonicalengine. -cv-compact_mono 0:e21e4e47835a 0.04 0.06 softwareengineer,brightleaflabs| -cv-compact_mono 0:0cdd48f05f9d -0.15 0.06 builtbackendservicesandproductio -cv-compact_mono 0:e48f1e2c853d -0.15 0.06 permonth.drovethemigrationfromit -cv-compact_mono 0:b78a9a72ca9a -0.15 0.06 andcuttingp99renderlatencyfrom1. -cv-compact_mono 0:9f5d87c00954 0.10 0.06 backendengineer,helixprintco|201 -cv-compact_mono 0:a485c37fe7ea -0.09 0.06 maintainedahigh-volumeinvoice-pr -cv-compact_mono 0:5a136b6b9d8c -0.09 0.06 testharnessthatgatedeverytemplat -cv-compact_mono 0:d94eccbb1fbe 0.18 0.06 selectedprojects -cv-compact_mono 0:0059a3aac61e -0.44 0.06 graphcompose(java21,pdfbox,maven -cv-compact_mono 0:18b7f8c840e4 -0.43 0.06 dsl,slot-basedtemplates,snapshot -cv-compact_mono 0:d36f78e88fe0 -0.41 0.06 forhiringtoolsandbillingsystems. +cv-compact_mono 0:970ca9d789c2 0.86 -0.04 workeligibility:eligibletoworkin +cv-compact_mono 0:0735680e2596 0.89 -0.04 theeu +cv-compact_mono 0:8051a4b5e34f 0.95 -0.04 opensource:maintainerofgraphcomp +cv-compact_mono 0:08c2e869a75b 0.98 -0.04 regularcontributortopdfboxissuet +cv-compact_mono 0:12dcd1343975 0.90 -0.04 speaking:jvmsummit2024,devoxxuk2 +cv-compact_mono 0:3aea7f90f8ed 0.93 -0.04 —bothondeclarativedocumentlayout +cv-compact_mono 0:1900eab6c028 0.51 0.06 profile +cv-compact_mono 0:3828b9718a0d -0.16 0.06 platformengineerwith10+yearsbuil +cv-compact_mono 0:f6b5e93ccadd -0.20 0.06 engines,anddeveloper-facingtempl +cv-compact_mono 0:927ef106776a -0.12 0.06 semanticauthoringdsls,andturning +cv-compact_mono 0:330fcb75ed52 -0.16 0.06 librariesthatscale. +cv-compact_mono 0:53e5e7c5a884 0.21 0.06 experience +cv-compact_mono 0:c9efb118bfda -0.17 0.06 seniorplatformengineer,northwind +cv-compact_mono 0:64c4b52dceb1 -0.36 0.06 ledthereusabledocument-generatio +cv-compact_mono 0:1f193cd1d987 -0.36 0.06 productteams.reducedtemplatemain +cv-compact_mono 0:9ccc3e904e5a -0.33 0.06 favourofonecanonicalengine. +cv-compact_mono 0:e21e4e47835a -0.08 0.06 softwareengineer,brightleaflabs| +cv-compact_mono 0:0cdd48f05f9d -0.27 0.06 builtbackendservicesandproductio +cv-compact_mono 0:e48f1e2c853d -0.27 0.06 permonth.drovethemigrationfromit +cv-compact_mono 0:b78a9a72ca9a -0.27 0.06 andcuttingp99renderlatencyfrom1. +cv-compact_mono 0:9f5d87c00954 -0.02 0.06 backendengineer,helixprintco|201 +cv-compact_mono 0:a485c37fe7ea -0.21 0.06 maintainedahigh-volumeinvoice-pr +cv-compact_mono 0:5a136b6b9d8c -0.21 0.06 testharnessthatgatedeverytemplat +cv-compact_mono 0:d94eccbb1fbe 0.06 0.06 selectedprojects +cv-compact_mono 0:0059a3aac61e -0.56 0.06 graphcompose(java21,pdfbox,maven +cv-compact_mono 0:18b7f8c840e4 -0.55 0.06 dsl,slot-basedtemplates,snapshot +cv-compact_mono 0:d36f78e88fe0 -0.53 0.06 forhiringtoolsandbillingsystems. cv-compact_mono 0:55e119878845 -0.38 0.06 templatestudio(kotlin,composedes cv-compact_mono 0:c7b83853442b -0.37 0.06 proposal,andinvoiceoutputacross1 -cv-compact_mono 0:6b844249c695 -0.23 0.06 freezing. -cv-compact_mono 0:ab89138286bb -0.22 0.06 layoutlint(java21,javaparser,spo -cv-compact_mono 0:2c506ce69aa6 -0.21 0.06 nestedrows,untypedoffsets,implic -cv-compact_mono 0:6793d28e9783 -0.20 0.06 chromeforge(java,graphcompose,pa -cv-compact_mono 0:e3cfea265b3b -0.19 0.06 graphcompose:cinematiccovers,pul +cv-compact_mono 0:6b844249c695 -0.35 0.06 freezing. +cv-compact_mono 0:ab89138286bb -0.34 0.06 layoutlint(java21,javaparser,spo +cv-compact_mono 0:2c506ce69aa6 -0.33 0.06 nestedrows,untypedoffsets,implic +cv-compact_mono 0:6793d28e9783 -0.32 0.06 chromeforge(java,graphcompose,pa +cv-compact_mono 0:e3cfea265b3b -0.31 0.06 graphcompose:cinematiccovers,pul cv-editorial_blue 0:8f1010120ed6 0.40 -0.01 jordanrivera cv-editorial_blue 0:2d58f0cadc2d 0.31 -0.05 platformengineer cv-editorial_blue 0:1b28eb6a9a2e 0.18 -0.02 +442055551000-london,uk diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 810331312..7dcce56e3 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -5,7 +5,7 @@ cv-boxed_sections 2 2 58 58 0.29 0.54 0 cv-centered_headline 2 2 58 56 0.11 0.26 0 cv-charcoal_gold 1 1 85 83 0.13 0.44 0 cv-classic_serif 2 2 57 54 0.17 0.25 0 -cv-compact_mono 1 1 58 58 0.26 1.01 0 +cv-compact_mono 1 1 58 58 0.34 0.91 0 cv-editorial_blue 2 2 82 82 0.14 0.27 0 cv-engineering_resume 1 1 57 57 0.58 1.54 0 cv-executive 2 2 60 60 0.21 0.43 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 4029455df..5a759724e 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 @@ -2,6 +2,16 @@ import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import java.util.ArrayList; import java.util.HashMap; @@ -12,16 +22,24 @@ import java.util.function.Supplier; /** - * Where each shape {@link DocxDrawings} draws is anchored: in a paragraph written on its page. + * 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. * - *

A shape 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 - * prints a shape anchored in a cell clipped to that cell, so a timeline's rings anchored in the - * sidebar's cell came out cut where the sidebar ends. The first body paragraph written on each - * page is kept; a shape whose page already has one is anchored there at once, and one whose - * page has none yet waits for it. 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.

+ *

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}, that the shape hangs + * from rather than rising above, and, for a paragraph in a table cell, whose cell holds the + * shape across. The shapes wait for the section's end, since the paragraph a shape stands beside + * 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 + * 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.

* *

A page laid out entirely in a table — a two-column CV is one row of two cells — has no * body paragraph. On the section's first page a paragraph a hairline tall is opened before the @@ -45,6 +63,10 @@ final class DocxDrawingAnchors { private final Map bodyParagraph = new HashMap<>(); // The first table-cell paragraph written on each page of the section, by page. private final Map cellParagraph = new HashMap<>(); + // The paragraphs written on each page of the section a shape can be anchored beside, by page. + 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<>(); // Identifiers unique in the document, shared with the other drawings the export writes. private final LongSupplier ids; // The order the shapes are painted in, across the document. @@ -59,6 +81,8 @@ void reset() { pending.clear(); bodyParagraph.clear(); cellParagraph.clear(); + seats.clear(); + offers.clear(); order = 0; } @@ -87,28 +111,62 @@ List ordered(List shapes) { } /** - * Anchors shapes in the body paragraph already written on their page, or keeps them for - * the first one written there. + * Gives shapes their places in the paint order and keeps them for the section's end, which + * anchors them (see {@link #endSection}). */ void queue(List shapes) { queueOrdered(ordered(shapes)); } - /** Anchors shapes given their places in the paint order, as {@link #queue} does. */ + /** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */ void queueOrdered(List shapes) { + // Held until the section ends: the paragraph a shape stands beside may be written after it. for (Ordered shape : shapes) { - XWPFParagraph carrier = bodyParagraph.get(shape.shape().page()); - if (carrier != null) { - anchor(carrier, List.of(shape)); - } else { - pending.computeIfAbsent(shape.shape().page(), page -> new ArrayList<>()).add(shape); + pending.computeIfAbsent(shape.shape().page(), page -> new ArrayList<>()).add(shape); + } + } + + /** + * Records a paragraph a shape on its page can be anchored beside, and where the page sets + * its text. + * + *

Every paragraph written for a piece of text is offered; the first that holds text when + * the section ends takes the place. A paragraph the layout starts on a new page is written + * after an empty line holding its top edge there, and that line is not where its text is.

+ * + * @param page the page, counted within the section + * @param owner what the text is written for: one of its paragraphs takes the place + * @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) { + if (page >= 0) { + offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline))); + } + } + + /** A paragraph offered as a seat, and what its text is written for. */ + private record Offer(int page, Object owner, Seat seat) { + } + + /** Takes, for each piece of text offered, the first of its paragraphs that holds text. */ + private void takeTheSeats() { + java.util.Set taken = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (Offer offer : offers) { + if (offer.seat().holdsText() && taken.add(offer.owner())) { + seats.computeIfAbsent(offer.page(), ignored -> new ArrayList<>()).add(offer.seat()); } } + offers.clear(); } /** - * Records a paragraph written on a page. The page's first body paragraph takes the shapes - * waiting for it; a cell's is kept in case the page has no other. + * 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. * * @param page the page, counted within the section * @param paragraph a paragraph the layout puts on that page @@ -122,13 +180,7 @@ void paragraphOn(int page, XWPFParagraph paragraph, boolean inBody) { cellParagraph.putIfAbsent(page, paragraph); return; } - if (bodyParagraph.putIfAbsent(page, paragraph) != null) { - return; - } - List waiting = pending.remove(page); - if (waiting != null && !waiting.isEmpty()) { - anchor(paragraph, waiting); - } + bodyParagraph.putIfAbsent(page, paragraph); } /** @@ -164,9 +216,25 @@ XWPFParagraph cellParagraphOn(int page) { Leftovers endSection(int lastPage, Supplier opening, Supplier closing) { Map inCells = new TreeMap<>(); Map dropped = new TreeMap<>(); - pending.forEach((page, shapes) -> { + takeTheSeats(); + Map> beside = new java.util.LinkedHashMap<>(); + pending.forEach((page, all) -> { + List shapes = new ArrayList<>(); + for (Ordered shape : all) { + Seat seat = seatBeside(shape.shape()); + if (seat != null) { + beside.computeIfAbsent(seat, ignored -> new ArrayList<>()).add(shape); + } else { + shapes.add(shape); + } + } + if (shapes.isEmpty()) { + return; + } XWPFParagraph carrier; - if (page == 0 && opening != null) { + if (bodyParagraph.containsKey(page)) { + carrier = bodyParagraph.get(page); + } else if (page == 0 && opening != null) { carrier = opening.get(); } else if (page == lastPage) { carrier = closing.get(); @@ -182,9 +250,11 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes, DocxDrawings.Cell } } + /** + * The paragraph whose text a shape stands nearest, within reach, that can hold it placed + * down from its top; {@code null} when none can. + */ + private Seat seatBeside(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()); + // A shape hangs from its paragraph's top, never above it: LibreOffice keeps a shape + // laid out in a cell from rising out of it, and set one 45pt above its paragraph's top + // at the head of the next page. + if (by <= REACH && by < nearestBy && shape.top() >= seat.paragraphTop() - SLACK + && !Double.isNaN(seat.column(shape))) { + nearest = seat; + nearestBy = by; + } + } + return nearest; + } + + /** + * 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. + */ + private void anchorBeside(Seat seat, List shapes) { + double paragraphTop = seat.paragraphTop(); + XWPFRun run = seat.paragraph().insertNewRun(0); + for (Ordered ordered : shapes) { + double column = seat.column(ordered.shape()); + // A shape reaching up to a stroke's width past the paragraph's top is placed at its top: + // for a skill bar 0.13pt above its paragraph in a cell, Word 16.0.20430 reported no + // offset at all, but a Top of -999997. + double top = Math.min(paragraphTop, ordered.shape().top()); + run.getCTR().addNewDrawing().set(Double.isInfinite(column) + ? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), top) + : DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(), + new DocxDrawings.CellOrigin(column, top))); + } + } + + /** + * 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. + */ + private static final double REACH = 48; + + /** A stroke's width either side a shape may reach past its paragraph's top or its cell, in points. */ + private static final double SLACK = 1; + + /** Word's room for a cell's text either side, where neither the cell nor its table states one. */ + private static final double DEFAULT_CELL_MARGIN = 5.4; + + /** + * A paragraph a shape can be anchored beside, and where the page sets its text. + * + * @param paragraph the paragraph + * @param left its text's left edge, from the page's left edge + * @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 + */ + record Seat(XWPFParagraph paragraph, double left, double right, double top, double baseline) { + + /** + * Where the page puts the top Word places a paragraph's shapes from: its first line's, + * less the space written above it. NaN where something else stands above the line — a + * top border, or space Word reckons in lines, by itself or against the paragraph before. + * + *

Word's line is not the page's: the export stands Word's baseline where the page's + * is, and Word stands an exact line's baseline four fifths of the way down it, raised by + * the runs' position. A 15pt exact line round 8.6pt text starts 4.6pt above the page's + * line, and a shape placed from the page's line stood that much high.

+ */ + double paragraphTop() { + CTPPr properties = paragraph.getCTP().getPPr(); + double before = 0; + double lineTop = top; + if (properties != null) { + if (properties.isSetPBdr() && properties.getPBdr().isSetTop() + || properties.isSetContextualSpacing() + && org.apache.poi.ooxml.util.POIXMLUnits.parseOnOff(properties.getContextualSpacing())) { + return Double.NaN; + } + if (properties.isSetSpacing()) { + CTSpacing spacing = properties.getSpacing(); + if (spacing.isSetBeforeLines() || spacing.isSetBeforeAutospacing()) { + return Double.NaN; + } + if (spacing.isSetBefore()) { + if (!(spacing.getBefore() instanceof Number twips)) { + return Double.NaN; + } + before = twips.doubleValue() / 20; + } + if (spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT) { + if (!(spacing.getLine() instanceof Number twips)) { + return Double.NaN; + } + lineTop = baseline - (DocxTextBands.BASELINE_SHARE * twips.doubleValue() / 20 - raised()); + } + } + } + return lineTop - before; + } + + /** How far the paragraph's first run of text is raised, in points. */ + private double raised() { + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run = firstRunOfText(); + CTRPr properties = run == null ? null : run.getRPr(); + return properties != null && properties.sizeOfPositionArray() > 0 + && properties.getPositionArray(0).getVal() instanceof Number halfPoints + ? halfPoints.doubleValue() / 2 : 0; + } + + /** Whether the paragraph holds any text. */ + boolean holdsText() { + return firstRunOfText() != null; + } + + /** + * The paragraph's first run holding text, a link's among them: POI leaves an internal + * link's runs out of a paragraph's runs and its text. + */ + private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfText() { + for (org.apache.xmlbeans.XmlObject found : paragraph.getCTP().selectPath( + "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' " + + "./w:r | ./w:hyperlink/w:r")) { + if (found instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run + && run.getTList().stream().anyMatch(text -> !text.getStringValue().isBlank())) { + return run; + } + } + return null; + } + + /** + * Where Word places a shape across from: infinite for a paragraph of the body, placed + * from the page's edge; for one in a table cell, the cell's text column, where the page + * puts it; NaN for a cell that does not hold the shape across — one in the gap between + * two columns belongs to neither —, for a line not set from the cell's left, and for a + * repeated header row, which Word repeats on every page with what is anchored in it. + */ + double column(DocxDrawings.Shape shape) { + if (!(paragraph.getBody() instanceof XWPFTableCell cell)) { + return Double.POSITIVE_INFINITY; + } + CTTcPr cellProperties = cell.getCTTc().getTcPr(); + // A line centred or set right in a cell is written with the editor's slack taken off + // its indent, so its text's left edge is not where Word's column starts. + if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph) + || cellProperties == null || !cellProperties.isSetTcW() + || cellProperties.getTcW().getType() != STTblWidth.DXA + || !(cellProperties.getTcW().getW() instanceof Number width)) { + return Double.NaN; + } + double margin = leftMargin(cell); + CTPPr properties = paragraph.getCTP().getPPr(); + double indent = 0; + if (properties != null && properties.isSetInd() && properties.getInd().isSetLeft()) { + if (!(properties.getInd().getLeft() instanceof Number twips)) { + return Double.NaN; + } + indent = twips.doubleValue() / 20; + } + double column = left - indent; + double cellLeft = column - margin; + boolean holds = shape.x() >= cellLeft - SLACK + && shape.x() + shape.width() <= cellLeft + width.doubleValue() / 20 + SLACK; + return holds ? column : Double.NaN; + } + + /** Whether a paragraph's lines start at its left indent: not centred, set right or right to left. */ + private static boolean leftAligned(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().getPPr(); + if (properties == null) { + return true; + } + if (properties.isSetBidi() && org.apache.poi.ooxml.util.POIXMLUnits.parseOnOff(properties.getBidi())) { + return false; + } + if (!properties.isSetJc()) { + return true; + } + STJc.Enum alignment = properties.getJc().getVal(); + return alignment != STJc.CENTER && alignment != STJc.RIGHT && alignment != STJc.END; + } + + private static double leftMargin(XWPFTableCell cell) { + CTTcPr cellProperties = cell.getCTTc().getTcPr(); + if (cellProperties != null && cellProperties.isSetTcMar() && cellProperties.getTcMar().isSetLeft()) { + return points(cellProperties.getTcMar().getLeft()); + } + CTTblPr table = cell.getTableRow().getTable().getCTTbl().getTblPr(); + if (table != null && table.isSetTblCellMar() && table.getTblCellMar().isSetLeft()) { + return points(table.getTblCellMar().getLeft()); + } + return DEFAULT_CELL_MARGIN; + } + + private static double points(CTTblWidth width) { + return width.getW() instanceof Number twips ? twips.doubleValue() / 20 : DEFAULT_CELL_MARGIN; + } + } + private void anchor(XWPFParagraph carrier, List shapes) { XWPFRun run = carrier.insertNewRun(0); for (Ordered shape : shapes) { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java index 25ff59caa..29df2c456 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java @@ -25,16 +25,16 @@ * *

A timeline's rail and the dots on it, a badge's circle, a ring round a portrait: the export * wrote none of them, since none is text, and a CV came out as its words on a bare page. Each is - * a DrawingML shape anchored to the page at the layout's position, behind the text, in the order + * a floating DrawingML shape at the layout's position, behind the text, in the order * the page paints them — a rectangle, a rectangle with rounded corners, an ellipse or a line, * filled and outlined as on the page.

* - *

The shape does not move with the text. It is anchored in a paragraph written on the same - * page, so it stays on the page it belongs to; within the page it stands where the layout put it, - * which is where the text around it stands too as long as the text lands where the page sets it. - * A reader who then edits the text moves the text, not the drawing — a drawing is decoration, - * and Word treats a floating shape the same way. A drawing that is all a table cell holds is - * the exception ({@link #drawingInCell}): anchored in that cell, it moves with its row.

+ *

A shape standing beside a paragraph's text is anchored in that paragraph and placed down from + * its top ({@link #drawingInParagraph}, or {@link #drawingInCell} for one in a table cell): a + * reader who edits the text above moves the paragraph, and the shape with it. A shape beside no + * paragraph is placed from the page's edges and stays there whatever the text does. Which is + * which, {@link DocxDrawingAnchors} decides. A drawing that is all a table cell holds is anchored + * in that cell ({@link #drawingInCell}), and moves with its row.

* *

A picture a badge holds is drawn the same way, over the badge: written in the flow, as a * paragraph of its own, it stood on its own line above the title beside the badge instead of in @@ -300,10 +300,27 @@ static CTDrawing drawingInCell(Shape shape, long id, int order, CellOrigin origi return drawing(shape, id, order, origin); } + /** + * A shape or a picture as a drawing anchored in a paragraph of the body, placed across from + * the page's left edge and down from that paragraph's top, so it moves with the paragraph + * wherever Word sets it. + * + * @param shape the shape + * @param id an identifier for the drawing, unique in the document + * @param order its place among the drawings: a later one is drawn over an earlier one + * @param paragraphTop where the page puts the paragraph's top, the space above its first line + * included, in points from the page's top edge + * @return the drawing, to be added to a run of that paragraph + */ + static CTDrawing drawingInParagraph(Shape shape, long id, int order, double paragraphTop) { + return drawing(shape, id, order, new CellOrigin(Double.NaN, paragraphTop)); + } + /** * Where the page puts a cell's text column and a paragraph's top in it. * - * @param x from the page's left edge, in points + * @param x from the page's left edge, in points; NaN for a paragraph of the body, whose + * shapes are placed across from the page's left edge * @param top from the page's top edge, in points */ record CellOrigin(double x, double top) { @@ -314,6 +331,7 @@ private static CTDrawing drawing(Shape shape, long id, int order, CellOrigin ori long cy = Units.toEMU(shape.height()); boolean picture = shape.kind() == Kind.PICTURE; String graphic = picture ? pictureGraphic(shape, id, cx, cy) : shapeGraphic(shape, cx, cy); + boolean inCell = origin != null && !Double.isNaN(origin.x()); String xml = "" + + " layoutInCell=\"" + (inCell ? 1 : 0) + "\" allowOverlap=\"1\">" + "" - + "" - + Units.toEMU(origin == null ? shape.x() : shape.x() - origin.x()) + + "" + + Units.toEMU(inCell ? shape.x() - origin.x() : shape.x()) + "" + "" + Units.toEMU(origin == null ? shape.top() : shape.top() - origin.top()) 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 0b6787630..30f8abe60 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 @@ -785,6 +785,39 @@ OptionalDouble firstLineTop(DocumentNode node) { return OptionalDouble.empty(); } + /** + * Where the page sets a node's first lines of text: the content box of the first fragment + * holding them, inside the paragraph's padding. + * + * @param node any node that lays out as paragraph lines + * @return the box, or empty when the node laid out nothing + */ + java.util.Optional firstTextBox(DocumentNode node) { + for (PlacedFragment fragment : textFragmentsOf(node)) { + if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) { + double top = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), paragraph.padding().top()); + ParagraphLine first = paragraph.lines().get(0); + return java.util.Optional.of(new TextBox(fragment.pageIndex(), + fragment.x() + paragraph.padding().left(), + fragment.x() + fragment.width() - paragraph.padding().right(), + top, top - first.lineHeight() + first.baselineOffsetFromBottom())); + } + } + return java.util.Optional.empty(); + } + + /** + * The content box of a node's first fragment of text, and its first line's baseline. + * + * @param page the page, counted within the section + * @param left its left edge, from the page's left edge + * @param right its right edge, from the page's left edge + * @param top its top edge, measured up from the foot of the page + * @param baseline its first line's baseline, measured up from the foot of the page + */ + record TextBox(int page, double left, double right, double top, double baseline) { + } + /** * Every line a node laid out, page after page, in order. * 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 ba15e7a11..fc5a34972 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 @@ -188,6 +188,10 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final double POINT_TO_TWIP = 20.0; /** The least difference between Word's baseline and the page's that is moved: one half point. */ private static final double LEAST_BASELINE_SHIFT_POINTS = 0.5; + /** How a drawing the page paints is anchored, as the report says it (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"; private static final Logger LOG = LoggerFactory.getLogger(DocxSemanticBackend.class); // The page's content width, so an image is held to the same bound layout holds it to. // Set per export; Double.MAX_VALUE means "no canvas, so nothing to clamp against". @@ -281,6 +285,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The page the node being written starts on, as the layout placed it, counted within the // section being written. private int currentPage; + // The innermost node being written: a paragraph written for a paragraph node is offered to + // the shapes beside its text. + private DocumentNode writing; /** The page the last block written in the flow ended on, -1 before the first. */ private int lastEndPage = -1; @@ -689,6 +696,7 @@ private byte[] write(List sections, Path outputFile) throws Exc cellTextShift = 0; nextDrawingId = 100_000; anchors.reset(); + writing = null; currentPage = 0; lastEndPage = -1; topEdgeHeldAbove = null; @@ -758,8 +766,7 @@ private byte[] write(List sections, Path outputFile) throws Exc for (com.demcha.compose.document.layout.PlacedFragment pass : layout.passFragments()) { if (queueDrawing(pass, drawsInFront())) { report.add(DocxExportReport.Severity.APPROXIMATED, "timeline rail", pass.path(), - "drawn as a line anchored to the page where the layout puts it: it stays " - + "there when the text around it is edited"); + "drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); } } for (DocumentNode root : section.graph().roots()) { @@ -1741,9 +1748,12 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio currentPage = placed.startPage(); } boolean inTheBody = currentCell == null; + DocumentNode outer = writing; + writing = node; try { writePlacedNode(document, node); } finally { + writing = outer; if (placed != null && inTheBody) { lastEndPage = Math.max(lastEndPage, placed.endPage()); } @@ -2406,8 +2416,7 @@ private boolean drawOwnFragments(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 anchored to the page where the " - + "layout puts it: it stays there when the text around it is edited"); + : "drawn as a shape where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); if (badgeText != null) { message.append("; its text is held in the shape, which stands in front of the text"); } else if (front) { @@ -2521,8 +2530,9 @@ boolean holds(DocxDrawings.Shape shape) { * The cell a drawing is written alone in, when its shapes can be anchored in that cell's * paragraph rather than on the page; {@code null} when they cannot. * - *

Every other shape is placed from the page's edges (see {@link DocxDrawingAnchors}), which - * holds it where the page puts it — and, in a table, off the row it belongs to wherever Word + *

Every other shape is anchored as {@link DocxDrawingAnchors} decides: beside the text it + * stands by, or else from the page's edges, which hold it where the page puts it — and, in a + * table, off the row it belongs to wherever Word * sets the rows above a little taller or shorter than the page. {@code CobaltRota}'s band * icons, each alone in the first column of its navy strip, stood 4pt, 9pt and 14pt above * their labels, the last out of its strip. A drawing that is all its cell holds is placed @@ -4637,9 +4647,25 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) { pendingSpacingAfter = 0; lastBodyParagraph = para; anchors.paragraphOn(currentPage, para, currentCell == null); + offerASeat(para); return para; } + /** + * Offers a paragraph written for a paragraph node to the shapes standing beside its text + * (see {@link DocxDrawingAnchors}), with where the page sets that text's first line: the + * first of them that holds text takes the place. + */ + private void offerASeat(XWPFParagraph para) { + if (!(writing instanceof ParagraphNode node) || Double.isNaN(canvasHeight)) { + return; + } + // 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))); + } + /** * Leaves out the space a section still holds below its last block. * @@ -8077,8 +8103,7 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte drewInCell ? how + ", anchored in the table cell it fills, where the layout puts it in the cell: " + "it moves with the row" - : how + ", anchored to the page where the layout puts it: it stays " - + "there when the text around it is edited"); + : how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); } /** @@ -8495,8 +8520,8 @@ private CellDrawing drawCellDrawing(TableNode table) { if (drew) { report.add(DocxExportReport.Severity.APPROXIMATED, "cell drawing", layout.pathOf(table), "what its cells draw is drawn as shapes where the layout puts it — anchored in the " - + "cell a drawing is all of, and to the page otherwise, where it stays when the text " - + "around it is edited; a clip, a transform, a gradient or a dash on it is not carried"); + + "cell a drawing is all of, and otherwise " + ANCHORED_BESIDE_ITS_TEXT + + "; a clip, a transform, a gradient or a dash on it is not carried"); } return new CellDrawing(drew, skipped, pending); } 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 3ef1f81f8..57e8a668a 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 @@ -20,7 +20,7 @@ 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 org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.STRelFromH; import java.util.ArrayList; import java.util.List; @@ -55,7 +55,7 @@ void anIconAloneInACellComposedInATableIsAnchoredInThatCell() throws Exception { .containsExactly(new Anchor("column", 0, "paragraph", 0, 12)); assertThat(DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine())) .as("its paragraph as tall as the icon").isEqualTo(240); - assertThat(pageAnchoredShapes(document)).as("none left on the page").isZero(); + assertThat(shapesOutOfTheirCells(document)).as("none left out of its cell").isZero(); } } @@ -76,7 +76,7 @@ void iconsOfOneSizeInTwoCellsAreEachAnchoredInTheirOwn() throws Exception { .contains("FF0000").doesNotContain("0000FF"); assertThat(second).as("the blue one in the second").contains("layoutInCell=\"1\"") .contains("0000FF").doesNotContain("FF0000"); - assertThat(pageAnchoredShapes(document)).isZero(); + assertThat(shapesOutOfTheirCells(document)).isZero(); } } @@ -107,7 +107,7 @@ void aHeadersIconRepeatedOnTheNextPageIsNotTakenByARowThere() throws Exception { // Word repeats the header row, the icon anchored in it with it, on every page: the // layout's copy there is not drawn again on the page. assertThat(table.getRow(0).getCtRow().getTrPr().sizeOfTblHeaderArray()).as("a repeated header").isPositive(); - assertThat(pageAnchoredShapes(document)).as("no second copy of the header's icon").isZero(); + assertThat(shapesOutOfTheirCells(document)).as("no second copy of the header's icon").isZero(); } } @@ -129,7 +129,7 @@ void anIconNoCellTookIsNotGivenToTheNextCell() throws Exception { assertThat(xmlOfIcon(table, 0)).doesNotContain("layoutInCell=\"1\""); assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("0000FF").doesNotContain("FF0000"); - assertThat(pageAnchoredShapes(document)).as("the first mark and its line, on the page").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("the first mark and its line, out of the cell").isEqualTo(2); } } @@ -171,7 +171,7 @@ void aTableNestedInACellDoesNotLendItsCellsToTheOuterTable() throws Exception { assertThat(xmlOfIcon(table, 1)).contains("layoutInCell=\"1\"").contains("FF0000"); assertThat(xmlOfIcon(table, 2)).contains("layoutInCell=\"1\"").contains("0000FF"); - assertThat(pageAnchoredShapes(document)).isZero(); + assertThat(shapesOutOfTheirCells(document)).isZero(); } } @@ -188,7 +188,7 @@ void aCellHoldingTwoDrawingsLeavesBothOnThePage() throws Exception { .weights(16, 12, 272).add(inset).add(ICON.node(12)).addParagraph(p -> p.text("LABEL")) .build()))))) { assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); - assertThat(pageAnchoredShapes(document)).as("both marks, on the page").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("both marks, out of the cell").isEqualTo(2); } } @@ -204,7 +204,7 @@ void aPaddedStackComposedInACellStaysOnThePage() throws Exception { .rowCells(DocumentTableCell.node(new RowBuilder().name("Band").verticalAlign(RowVerticalAlign.CENTER) .weights(20, 280).add(padded).addParagraph(p -> p.text("LABEL")).build()))))) { assertThat(xmlOfIcon(document.getTables().get(0), 0)).doesNotContain("layoutInCell=\"1\""); - assertThat(pageAnchoredShapes(document)).isEqualTo(1); + assertThat(shapesOutOfTheirCells(document)).isEqualTo(1); } } @@ -228,7 +228,7 @@ void aDrawingWithAMarginStaysOnThePage() throws Exception { XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); assertThat(anchors(cell.getParagraphs().get(0))).isEmpty(); - assertThat(pageAnchoredShapes(document)).as("the disc and its mark, on the page").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("the disc and its mark, out of the cell").isEqualTo(2); } } @@ -245,7 +245,7 @@ void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception { String body = document.getDocument().xmlText(); assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\""); - assertThat(pageAnchoredShapes(document)).as("the line, drawn on the page as before").isEqualTo(1); + assertThat(shapesOutOfTheirCells(document)).as("the line, out of the cell as before").isEqualTo(1); } } @@ -262,7 +262,7 @@ void aMarkHoldingALineInACellOfARowInTheFlowStaysOnThePage() throws Exception { .build()) .addParagraph(p -> p.text("LABEL"))))) { assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); - assertThat(pageAnchoredShapes(document)).as("the mark and its line, on the page").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("the mark and its line, out of the cell").isEqualTo(2); } } @@ -279,7 +279,7 @@ void aDrawingPaintingPastItsBoxStaysOnThePage() throws Exception { .build()) .addParagraph(p -> p.text("LABEL"))))) { assertThat(document.getDocument().xmlText()).doesNotContain("layoutInCell=\"1\""); - assertThat(pageAnchoredShapes(document)).as("both marks, on the page").isEqualTo(2); + assertThat(shapesOutOfTheirCells(document)).as("both marks, out of the cell").isEqualTo(2); } } @@ -294,7 +294,7 @@ void anIconAloneInACellOfARowInTheFlowIsAnchoredInThatCell() throws Exception { assertThat(anchors(cell.getParagraphs().get(0))).as("the icon, in its own cell") .containsExactly(new Anchor("column", 0, "paragraph", 0, 12)); - assertThat(pageAnchoredShapes(document)).isZero(); + assertThat(shapesOutOfTheirCells(document)).isZero(); } } @@ -373,10 +373,14 @@ private static double points(long emu) { return Math.round(emu / 12700.0 * 10) / 10.0; } - /** How many shapes the document places from the page's edges. */ - private static long pageAnchoredShapes(XWPFDocument document) { + /** + * How many shapes the document places across from the page's edge, out of any cell: down + * from the page's top edge, or from the top of the body paragraph beside them. + */ + private static long shapesOutOfTheirCells(XWPFDocument document) { return anchorsIn(document.getDocument()).stream() - .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE) + .filter(anchor -> anchor.getPositionH().getRelativeFrom() == STRelFromH.PAGE + && !anchor.getLayoutInCell()) .count(); } } 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 new file mode 100644 index 000000000..638d7b690 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java @@ -0,0 +1,345 @@ +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.CTPPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; + +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; + +/** + * Which paragraph a shape is anchored in, and where it is placed from. + * + *

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.

+ */ +class DocxDrawingAnchorsTest { + + @Test + void aShapeIsAnchoredInTheParagraphWhoseTextItStandsNearest() { + 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, first, 40, 300, 40, 50); + anchors.seat(0, second, second, 40, 300, 100, 110); + + anchors.queue(List.of(dot(30, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(first)).isEmpty(); + String drawing = drawingsIn(second).get(0); + assertThat(drawing).contains("layoutInCell=\"0\"") + .contains("" + emu(30) + "<") + .contains(""); + // No space above the paragraph and no exact line: Word's top is the page's line top. + assertThat(offsetDown(drawing)).isCloseTo(2, within(0.01)); + } + + @Test + void aShapeOutOfReachOfEveryParagraphStaysWhereThePagePutsIt() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "First"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, first, 40, 300, 40, 50); + + anchors.queue(List.of(dot(30, 200))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(first)).singleElement().asString() + .contains("" + emu(200) + "<"); + } + + @Test + void aShapeIsNeverPlacedAboveTheTopOfItsParagraph() { + // The ring stands 20pt above the only paragraph near it. Placed from that paragraph, it + // would hang above it, which LibreOffice does not keep in a table cell. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "First"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, first, 40, 300, 120, 130); + + anchors.queue(List.of(dot(30, 100))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(first)).singleElement().asString() + .contains(""); + } + + @Test + void aShapeReachingAStrokePastItsParagraphsTopIsPlacedAtThatTop() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph first = text(document, "First"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, first, true); + anchors.seat(0, first, first, 40, 300, 100, 110); + + anchors.queue(List.of(dot(30, 99.5))); + anchors.endSection(0, null, document::createParagraph); + + String drawing = drawingsIn(first).get(0); + assertThat(drawing).contains(""); + assertThat(offsetDown(drawing)).isZero(); + } + + @Test + void anExactLinesTopIsWhereWordStandsItsBaselineLessTheSpaceAbove() { + // A 15pt exact line, 12pt above it, its text lowered a point: Word stands the baseline + // four fifths of the way down the line, raised by the run's position. The page draws the + // baseline at 172, so Word's line starts at 172 - (12 - -1) = 159, its paragraph at 147. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = text(document, "Languages"); + spacing(paragraph, 240, 300); + 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.queue(List.of(dot(30, 150))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(offsetDown(drawingsIn(paragraph).get(0))).isCloseTo(150 - 147, within(0.01)); + } + + @Test + void aShapeInACellIsPlacedFromTheCellsTextColumn() { + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText("Entry"); + paragraph.getCTP().addNewPPr().addNewInd().setLeft(BigInteger.valueOf(200)); + cell.getCTTc().getTcPr().addNewTcMar().addNewLeft().setW(BigInteger.valueOf(0)); + 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.queue(List.of(dot(103, 62))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph).get(0)).contains("layoutInCell=\"1\"") + .contains("" + emu(3) + "<") + .contains(""); + } + + @Test + void aCellThatCannotHoldTheShapeDoesNotCarryIt() { + // The dot stands left of the cell, in the gap between two columns. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText("Entry"); + cell.getCTTc().getTcPr().addNewTcMar().addNewLeft().setW(BigInteger.valueOf(0)); + DocxDrawingAnchors anchors = anchors(); + XWPFParagraph body = text(document, "Body"); + anchors.paragraphOn(0, body, true); + anchors.seat(0, paragraph, paragraph, 100, 290, 60, 70); + + anchors.queue(List.of(dot(80, 62))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).isEmpty(); + assertThat(drawingsIn(body)).singleElement().asString().contains(""); + } + + @Test + void aCellOfARepeatedHeaderRowDoesNotCarryAShape() { + // Word repeats the row on every page, and what is anchored in it with it. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + table.getRow(0).setRepeatHeader(true); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText("Header"); + DocxDrawingAnchors anchors = anchors(); + XWPFParagraph body = text(document, "Body"); + anchors.paragraphOn(0, body, true); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); + + anchors.queue(List.of(dot(120, 62))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).isEmpty(); + } + + @Test + void theParagraphHoldingTheTextTakesThePlaceNotTheEmptyLineBeforeIt() { + // A paragraph the layout starts on a new page is written after an empty line holding its + // top edge there; both are written for the same text, and only the second holds it. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph spacer = document.createParagraph(); + XWPFParagraph entry = text(document, "Entry"); + 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.queue(List.of(dot(30, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(spacer)).isEmpty(); + assertThat(drawingsIn(entry)).singleElement().asString() + .contains(""); + } + + @Test + void aLineCentredInACellDoesNotCarryAShape() { + // Its indent is written short of the page's by the editor's slack, so its text's left + // edge does not tell where the cell's column starts. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText("Centred"); + paragraph.setAlignment(org.apache.poi.xwpf.usermodel.ParagraphAlignment.CENTER); + cell.getCTTc().getTcPr().addNewTcMar().addNewLeft().setW(BigInteger.valueOf(0)); + DocxDrawingAnchors anchors = anchors(); + XWPFParagraph body = text(document, "Body"); + anchors.paragraphOn(0, body, true); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); + + anchors.queue(List.of(dot(120, 62))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).isEmpty(); + } + + @Test + void aLinksTextRaisedInItsLineMovesTheTopWithIt() { + // POI leaves an internal link's runs out of a paragraph's runs; the raise is read from + // them all the same. As in the exact-line case: 172 - (12 - -1) - 12 = 147. + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = document.createParagraph(); + spacing(paragraph, 240, 300); + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run = + paragraph.getCTP().addNewHyperlink().addNewR(); + run.addNewT().setStringValue("Linked"); + 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.queue(List.of(dot(30, 150))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(offsetDown(drawingsIn(paragraph).get(0))).isCloseTo(150 - 147, within(0.01)); + } + + @Test + void contextualSpacingSwitchedOffLeavesTheTopWhereItIs() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = text(document, "Entry"); + paragraph.getCTP().addNewPPr().addNewContextualSpacing().setVal("0"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, paragraph, true); + anchors.seat(0, paragraph, paragraph, 40, 300, 100, 110); + + anchors.queue(List.of(dot(30, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).singleElement().asString() + .contains(""); + } + + @Test + void shapesBesideOneParagraphAreWrittenInTheOrderTheyAreDrawn() { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = text(document, "Title"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, paragraph, true); + anchors.seat(0, paragraph, paragraph, 40, 300, 40, 50); + + anchors.queue(List.of(dot(10, 42), dot(20, 42))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).extracting(DocxDrawingAnchorsTest::offsetAcross) + .containsExactly(10.0, 20.0); + } + + private static DocxDrawingAnchors anchors() { + AtomicLong ids = new AtomicLong(1); + return new DocxDrawingAnchors(ids::getAndIncrement); + } + + 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 spacing(XWPFParagraph paragraph, long before, long line) { + CTPPr properties = paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr(); + CTSpacing spacing = properties.addNewSpacing(); + spacing.setBefore(BigInteger.valueOf(before)); + spacing.setLine(BigInteger.valueOf(line)); + spacing.setLineRule(STLineSpacingRule.EXACT); + } + + 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 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(); + return Long.parseLong(matcher.group(1)) / 12700.0; + } + + 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 0e4515bc1..95558287d 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 @@ -28,10 +28,12 @@ import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; /** * Drawing the export has no Word element for — a dot, a ring round a portrait, a timeline's - * rail — is a shape anchored to the page where the page draws it, behind the text. + * rail — is a floating shape where the page draws it, behind the text, anchored beside the text + * it stands by or else to the page. * * @author Artem Demchyshyn */ @@ -58,9 +60,12 @@ void anEllipseIsDrawnWhereThePagePutsIt() throws Exception { .contains("behindDoc=\"1\"") .contains("" + Units.toEMU(dot.get().x()) + "") - .contains("" - + Units.toEMU(top) + "") + .contains("") .contains("srgbClr val=\"1A5694\""); + // Held by the paragraph above it, which Word sets at the page's top margin: placed + // down from that paragraph, it stands where the page puts it, and moves with it. + assertThat(paragraphCarrying(document, "prst=\"ellipse\"")).isEqualTo("Above"); + assertThat(offsetDown(anchor)).isCloseTo(top - 20, within(0.5)); assertThat(document.getParagraphs()).extracting(p -> p.getText()) .as("the text around it is written as before") .contains("Above", "Below"); @@ -134,8 +139,11 @@ void aGlyphInAPaintedBadgeIsDrawnOverTheBadgeWhereThePagePutsIt() throws Excepti assertThat(picture).contains("r:embed=\"") .contains("" + Units.toEMU(glyph.get().x()) + "") - .contains("" - + Units.toEMU(top) + ""); + .contains(""); + // Placed down from the title beside the badge, the badge with it: they move together. + assertThat(paragraphCarrying(document, " session.pageFlow(page -> page .addSection("Card", s -> s.fillColor(DocumentColor.rgb(220, 230, 240)) .addContainer(row -> row.name("Fact").rectangle(300, 30) @@ -273,18 +283,80 @@ void anIconDrawnBesideItsLabelInAPaintedPanelStandsInFrontOfItsShading() throws 34, 0, LayerAlign.CENTER_LEFT)))))) { assertThat(anchors(document.getDocument().xmlText())).hasSize(2) .allMatch(anchor -> anchor.contains("behindDoc=\"0\"")) - .allMatch(anchor -> anchor.contains("")); + .allMatch(anchor -> anchor.contains("layoutInCell=\"1\"")) + .allMatch(anchor -> anchor.contains("")); + assertThat(cellParagraphCarrying(document, "roundRect")).isEqualTo("Compliant"); } } + /** The text of the table-cell paragraph, at any depth, whose XML holds a marker. */ + private static String cellParagraphCarrying(XWPFDocument document, String marker) { + java.util.Deque tables = new java.util.ArrayDeque<>(document.getTables()); + while (!tables.isEmpty()) { + for (org.apache.poi.xwpf.usermodel.XWPFTableRow row : tables.pop().getRows()) { + for (org.apache.poi.xwpf.usermodel.XWPFTableCell cell : row.getTableCells()) { + tables.addAll(cell.getTables()); + for (org.apache.poi.xwpf.usermodel.XWPFParagraph paragraph : cell.getParagraphs()) { + if (paragraph.getCTP().xmlText().contains(marker)) { + return paragraph.getText(); + } + } + } + } + } + throw new AssertionError("no cell paragraph holds " + marker); + } + @Test - void aPanelsDrawingWithNoParagraphBesideItIsAnchoredToThePage() throws Exception { + void aPanelsDrawingWithNoParagraphInItIsAnchoredBesideTheParagraphAboveIt() throws Exception { + // The panel holds no paragraph; the one above it is the text the drawing stands by, and + // the panel moves with it. try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page .addParagraph(p -> p.text("Above")) .addSection("Card", s -> s.fillColor(DocumentColor.rgb(220, 230, 240)) .add(new com.demcha.compose.document.dsl.EllipseBuilder().name("Dot").circle(16) .fillColor(ACCENT).build()))))) { assertThat(anchors(document.getDocument().xmlText())).singleElement().asString() + .contains("layoutInCell=\"0\"") + .contains("") + .contains(""); + assertThat(paragraphCarrying(document, "prst=\"ellipse\"")).isEqualTo("Above"); + } + } + + @Test + void aShapeBesideASeatedTitleIsPlacedFromWhereWordStandsTheTitle() throws Exception { + // A display title seated by its top: the page draws its baseline off where its line's + // box puts it, and the export raises it in Word's line to match. The title is the page's + // first paragraph, which Word sets at the top margin: placed down from it, the rule under + // the title stands where the page draws it. + AtomicReference rule = new AtomicReference<>(); + try (XWPFDocument document = export(null, session -> { + session.pageFlow(page -> page + .addParagraph(p -> p.text("INVOICE").verticalAlign( + com.demcha.compose.document.node.TextVerticalAlign.TOP) + .textStyle(com.demcha.compose.document.style.DocumentTextStyle.builder() + .fontName(com.demcha.compose.font.FontName.SPECTRAL).size(30) + .color(DocumentColor.BLACK).build())) + .addEllipse(e -> e.name("Rule").size(60, 4).fillColor(ACCENT))); + rule.set(fragmentNamed(session, "Rule")); + })) { + String anchor = anchors(document.getDocument().xmlText()).get(0); + double top = 400 - rule.get().y() - rule.get().height(); + + assertThat(paragraphCarrying(document, "prst=\"ellipse\"")).isEqualTo("INVOICE"); + assertThat(offsetDown(anchor)).isCloseTo(top - 20, within(0.5)); + } + } + + @Test + void aShapeWithNoParagraphWithinReachStaysOnThePage() throws Exception { + 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(""); } } @@ -485,9 +557,9 @@ void aLastPageALongTableFillsCarriesItsShapesInTheBodyNotInARow() throws Excepti } @Test - void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Exception { - // Two columns written as one row: the page has no body paragraph, and a shape anchored - // in the sidebar's cell was printed clipped to it. A hairline paragraph opens the page. + void aShapeBesideAColumnsTextIsAnchoredInItsCellAndMovesWithIt() throws Exception { + // Two columns written as one row. The ring stands over the main column's text, in the + // space its paragraph holds above itself: placed down from that paragraph, in its cell. 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"))), @@ -496,6 +568,29 @@ void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Ex .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT)) .addParagraph(p -> p.text("Body"))), com.demcha.compose.document.node.LayerAlign.TOP_LEFT))))) { + assertThat(document.getBodyElements().get(0)).as("no hairline opens the page") + .isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class); + org.apache.poi.xwpf.usermodel.XWPFParagraph body = document.getTables().get(0).getRow(0) + .getTableCells().stream().flatMap(cell -> cell.getParagraphs().stream()) + .filter(paragraph -> paragraph.getText().equals("Body")).findFirst().orElseThrow(); + String anchor = anchors(body.getCTP().xmlText()).get(0); + assertThat(anchor).contains("prst=\"ellipse\"").contains("layoutInCell=\"1\"") + .contains("") + .contains(""); + } + } + + @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. + 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)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFParagraph.class); @@ -718,9 +813,9 @@ void aLaterSectionOpeningWithATableGetsItsBodyParagraphBeforeThatTable() throws second.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) + // The ring alone in its column: no paragraph stands beside it. .layer(column("Main", 100, 0, main -> main - .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT)) - .addParagraph(p -> p.text("Body"))), + .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))), com.demcha.compose.document.node.LayerAlign.TOP_LEFT))); byte[] docx; try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() @@ -880,6 +975,14 @@ private static PlacedFragment fragmentNamed(DocumentSession session, String name .orElseThrow(); } + /** How far down from what it is placed from a drawing stands, in points. */ + private static double offsetDown(String anchor) { + Matcher matcher = Pattern.compile("(-?\\d+)<") + .matcher(anchor); + assertThat(matcher.find()).isTrue(); + return Long.parseLong(matcher.group(1)) / 12700.0; + } + /** Every anchored drawing in a part's XML, in document order. */ private static List anchors(String xml) { List anchors = new ArrayList<>(); diff --git a/scripts/docx-visual/edit-protocol-corpus.ps1 b/scripts/docx-visual/edit-protocol-corpus.ps1 index 2cb785d8f..8c885bcb7 100644 --- a/scripts/docx-visual/edit-protocol-corpus.ps1 +++ b/scripts/docx-visual/edit-protocol-corpus.ps1 @@ -13,7 +13,8 @@ What it decides by itself is objective: text kept or lost, a paragraph grown or shrunk and still above the block after it, a row held to an exact height that would hide what the edit added, the body size following the Normal style, a list continued by Enter, text typed at - the end of the document readable below everything else, a save that changes nothing. It + the end of the document readable below everything else, a drawing moving with the text it + stands beside, a save that changes nothing. It reads letters' size, face and visibility from the text alone, not the paragraph mark, which a range's font reports with it. Whether an edited page still looks right is read by a person from the PDFs written beside the lengthened copy and the new-page copy. @@ -48,7 +49,7 @@ param( $ErrorActionPreference = 'Stop' $SCENARIOS = 'lengthen-a-paragraph', 'shorten-a-paragraph', 'insert-a-paragraph', 'restyle-body-through-normal', - 'insert-a-table-row', 'delete-a-table-row', 'continue-a-list', 'add-a-page', 'round-trip' + 'insert-a-table-row', 'delete-a-table-row', 'continue-a-list', 'add-a-page', 'drawings-follow-text', 'round-trip' # `-File` hands "a,b" over as one string: split it, and refuse a name no scenario has rather # than run nothing and report an empty protocol. $Scenario = @($Scenario | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) @@ -63,6 +64,7 @@ $OutputDir = (Resolve-Path $OutputDir).Path # Word constants, numeric so the script needs no type library. $WD_STATISTIC_PAGES = 2 $WD_ACTIVE_END_PAGE = 3 +$WD_HORIZONTAL_POSITION_ON_PAGE = 5 $WD_VERTICAL_POSITION_ON_PAGE = 6 $WD_WITHIN_TABLE = 12 $WD_ROW_HEIGHT_EXACTLY = 2 @@ -72,6 +74,8 @@ $WD_LINE_SPACE_EXACTLY = 4 $WD_HEADER_FOOTER_PRIMARY = 1 $WD_HEADER_FOOTER_FIRST_PAGE = 2 $WD_HEADER_FOOTER_EVEN_PAGES = 3 +$WD_RELATIVE_TO_MARGIN = 0 +$WD_RELATIVE_TO_PAGE = 1 # An exact line shorter than this share of its letters' size cuts them on Word's screen: Word # sets a single line of its faces at 1.15 to 1.2 of the size. @@ -235,6 +239,70 @@ function Get-FooterOn { return ($Section.Footers($kind).Range.Text -replace "[`r`a0-9]", '').Trim() } +# Where each floating drawing of the main story stands, by its name: its place as Get-Place +# counts it, and its left edge. One placed from the page stands at its offset on the page its +# anchor lands on; one placed from its paragraph or its column, at its offset from them. +function Get-DrawingPlaces { + param($Doc) + $places = @{} + foreach ($shape in $Doc.Shapes) { + # A page's background belongs to the page, not to the text on it. + if ($shape.Name -like 'Page background*') { continue } + if ($places.ContainsKey($shape.Name)) { $places[$shape.Name] = $null; continue } + $anchor = $shape.Anchor.Paragraphs(1).Range.Duplicate + $anchor.Collapse(1) + $page = [double]$anchor.Information($WD_ACTIVE_END_PAGE) + $place = switch ($shape.RelativeVerticalPosition) { + $WD_RELATIVE_TO_PAGE { $page * 10000 + $shape.Top } + $WD_RELATIVE_TO_MARGIN { $page * 10000 + $Doc.Sections(1).PageSetup.TopMargin + $shape.Top } + default { (Get-Place $anchor) + $shape.Top } + } + $left = if ($shape.RelativeHorizontalPosition -eq $WD_RELATIVE_TO_PAGE) { $shape.Left } ` + else { [double]$anchor.Information($WD_HORIZONTAL_POSITION_ON_PAGE) + $shape.Left } + $places[$shape.Name] = @{ place = $place; left = $left } + } + return $places +} + +# Where each paragraph with text starts, by its index: its place, and how far across its text +# can run — from its first letter to the right edge of its cell, or of the page's margin. +function Get-ParagraphPlaces { + param($Doc) + $places = @{} + $setup = $Doc.Sections(1).PageSetup + $marginRight = $setup.PageWidth - $setup.RightMargin + for ($i = 1; $i -le $Doc.Paragraphs.Count; $i++) { + $p = $Doc.Paragraphs($i) + if ((Get-Text $p).Length -eq 0) { continue } + $start = $p.Range.Duplicate + $start.Collapse(1) + $left = [double]$start.Information($WD_HORIZONTAL_POSITION_ON_PAGE) + $right = $marginRight + if ($start.Information($WD_WITHIN_TABLE)) { + try { $right = $left - $p.LeftIndent + $p.Range.Cells(1).Width } catch { } + } + $places[$i] = @{ place = Get-Place $start; left = $left; right = $right } + } + return $places +} + +# The paragraph a drawing belongs beside: of those with text starting on its page, the nearest +# to its top-left corner, up or down from the paragraph's start and across from its text. A dot stands +# at the start of its entry, a rail at its first entry's, a skill's bar beside its label. +function Find-Owner { + param($Drawing, $Paragraphs) + $page = [Math]::Floor($Drawing.place / 10000) + $best = $null; $nearest = [double]::MaxValue + foreach ($index in $Paragraphs.Keys) { + $p = $Paragraphs[$index] + if ([Math]::Floor($p.place / 10000) -ne $page) { continue } + $across = [Math]::Max(0, $p.left - $Drawing.left) + [Math]::Max(0, $Drawing.left - $p.right) + $distance = [Math]::Abs($p.place - $Drawing.place) + $across + if ($distance -lt $nearest) { $best = $index; $nearest = $distance } + } + return $best +} + # An edit Word refuses is a result of that scenario, not the end of the document's protocol. function Invoke-Scenario { param($Word, $Source, [string]$Name, [scriptblock]$Edit, [scriptblock]$Verify, [switch]$Pdf) @@ -503,7 +571,47 @@ function Invoke-Protocol { New-Result 'add-a-page' 'PASS' $detail } - # 8. Save and reopen with no edit. + # 8. Lengthen the same paragraph as the first scenario, and see that each drawing beside + # text the edit moved — a timeline's dot, a rail, an icon, a badge — moved with it, by as + # much and onto the same page. A drawing is held to the paragraph nearest its top-left + # corner when the document opens. + $results += Invoke-Scenario $Word $Source 'drawings-follow-text' -Edit { + param($doc, $state) + $state.index = Find-BodyParagraph $doc + if ($state.index -lt 0 -or $doc.Shapes.Count -eq 0) { return $false } + $state.drawings = Get-DrawingPlaces $doc + $state.paragraphs = Get-ParagraphPlaces $doc + $state.owners = @{} + foreach ($name in $state.drawings.Keys) { + if ($null -eq $state.drawings[$name]) { continue } + $owner = Find-Owner $state.drawings[$name] $state.paragraphs + if ($null -ne $owner) { $state.owners[$name] = $owner } + } + $p = $doc.Paragraphs($state.index) + $state.target = (Get-Text $p).Substring(0, 30) + (Get-TextRange $p).InsertAfter($ADDED) + } -Verify { + param($doc, $state) + $drawings = Get-DrawingPlaces $doc + $paragraphs = Get-ParagraphPlaces $doc + $moved = 0; $followed = 0; $lost = 0; $stray = @() + foreach ($name in $state.owners.Keys) { + $owner = $state.owners[$name] + if (-not $drawings.ContainsKey($name) -or $null -eq $drawings[$name] -or -not $paragraphs.ContainsKey($owner)) { $lost++; continue } + $ownerMoved = $paragraphs[$owner].place - $state.paragraphs[$owner].place + if ([Math]::Abs($ownerMoved) -le 0.5) { continue } + $moved++ + $drawingMoved = $drawings[$name].place - $state.drawings[$name].place + if ([Math]::Abs($drawingMoved - $ownerMoved) -le 1) { $followed++; continue } + $stray += "$name by $([Math]::Round($drawingMoved, 1)) beside '$((Get-Text $doc.Paragraphs($owner)) -replace '^(.{0,24}).*', '$1')' by $([Math]::Round($ownerMoved, 1))" + } + $detail = "'$($state.target)…' lengthened; $($state.owners.Count) drawings, $moved beside text that moved, $followed of them followed it, $lost not found again" + if ($stray.Count -gt 0) { $detail += "; not following: " + (($stray | Select-Object -First 3) -join '; ') } + if ($stray.Count -gt 0 -or $lost -gt 0) { return New-Result 'drawings-follow-text' 'FAIL' $detail } + New-Result 'drawings-follow-text' 'PASS' $detail + } + + # 9. Save and reopen with no edit. $results += Invoke-Scenario $Word $Source 'round-trip' -Edit { param($doc, $state) } -Verify { From b1237a92a44e39e1f0ea5ce6c4537932fdf882bd Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 12:07:22 +0100 Subject: [PATCH 2/5] fix(docx): take a shape into a table cell only beside the cell's text, not below it A cell holding a shape that hangs below its row moved the row's text in the Linux LibreOffice: an icon beside the note under a totals table, taken into the table's last cell, set two invoices' 'Total due' 2pt and 4pt up. A shape now goes into a cell only when it starts above the foot of the text written there and reaches past it by half its height at most. The tests' offset helpers fail an assertion on a value that is not a number. --- .../semantic/docx/DocxDrawingAnchors.java | 41 +++++++++++-- .../semantic/docx/DocxLayoutMetrics.java | 6 +- .../semantic/docx/DocxSemanticBackend.java | 2 +- .../semantic/docx/DocxDrawingAnchorsTest.java | 60 ++++++++++++++----- .../semantic/docx/DocxDrawingsTest.java | 6 +- 5 files changed, 89 insertions(+), 26 deletions(-) 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 5a759724e..df9d6fe91 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 @@ -67,6 +67,8 @@ 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<>(); + // How far down, from the page's top edge, the text written in each table cell reaches, by page. + private final Map> cellFoot = new HashMap<>(); // Identifiers unique in the document, shared with the other drawings the export writes. private final LongSupplier ids; // The order the shapes are painted in, across the document. @@ -83,6 +85,7 @@ void reset() { cellParagraph.clear(); seats.clear(); offers.clear(); + cellFoot.clear(); order = 0; } @@ -141,22 +144,30 @@ void queueOrdered(List shapes) { * @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 + * @param bottom the text's bottom edge on that page, from the page's top edge */ void seat(int page, Object owner, XWPFParagraph paragraph, double left, double right, double top, - double baseline) { + double baseline, double bottom) { 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), bottom)); } } - /** A paragraph offered as a seat, and what its text is written for. */ - private record Offer(int page, Object owner, Seat seat) { + /** A paragraph offered as a seat, what its text is written for, and where that text ends. */ + private record Offer(int page, Object owner, Seat seat, double bottom) { } - /** Takes, for each piece of text offered, the first of its paragraphs that holds text. */ + /** + * Takes, for each piece of text offered, the first of its paragraphs that holds text, and + * notes how far down each table cell's text reaches on each page. + */ private void takeTheSeats() { java.util.Set taken = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); for (Offer offer : offers) { + if (offer.seat().paragraph().getBody() instanceof XWPFTableCell cell) { + cellFoot.computeIfAbsent(offer.page(), ignored -> new java.util.IdentityHashMap<>()) + .merge(cell, offer.bottom(), Math::max); + } if (offer.seat().holdsText() && taken.add(offer.owner())) { seats.computeIfAbsent(offer.page(), ignored -> new ArrayList<>()).add(offer.seat()); } @@ -164,6 +175,23 @@ private void takeTheSeats() { offers.clear(); } + /** + * Whether a shape stands beside the text written in the seat's table cell rather than below + * it: it starts above that text's foot and reaches past it by half its height at most, as a + * tile centred beside a one-line label does. A cell holding a shape below its text moved the + * row's text in the Linux LibreOffice the build checks with: an icon beside the note under + * a totals table, taken into the table's last cell, set two invoices' 'Total due' 2pt and + * 4pt up. A seat in the body holds any shape. + */ + private boolean holdsDown(Seat seat, DocxDrawings.Shape shape) { + if (!(seat.paragraph().getBody() instanceof XWPFTableCell cell)) { + return true; + } + Double foot = cellFoot.getOrDefault(shape.page(), Map.of()).get(cell); + return foot != null && shape.top() < foot + && shape.top() + shape.height() <= foot + shape.height() / 2 + SLACK; + } + /** * 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. @@ -255,6 +283,7 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier= seat.paragraphTop() - SLACK - && !Double.isNaN(seat.column(shape))) { + && !Double.isNaN(seat.column(shape)) && holdsDown(seat, shape)) { nearest = seat; nearestBy = by; } 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 30f8abe60..e8fdb59b2 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 @@ -800,7 +800,8 @@ java.util.Optional firstTextBox(DocumentNode node) { return java.util.Optional.of(new TextBox(fragment.pageIndex(), fragment.x() + paragraph.padding().left(), fragment.x() + fragment.width() - paragraph.padding().right(), - top, top - first.lineHeight() + first.baselineOffsetFromBottom())); + top, top - first.lineHeight() + first.baselineOffsetFromBottom(), + fragment.y() + paragraph.padding().bottom())); } } return java.util.Optional.empty(); @@ -814,8 +815,9 @@ java.util.Optional firstTextBox(DocumentNode node) { * @param right its right edge, from the page's left edge * @param top its top edge, measured up from the foot of the page * @param baseline its first line's baseline, measured up from the foot of the page + * @param bottom its bottom edge on that page, measured up from the foot of the page */ - record TextBox(int page, double left, double right, double top, double baseline) { + record TextBox(int page, double left, double right, double top, double baseline, double bottom) { } /** 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 fc5a34972..20b5511ac 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 @@ -4663,7 +4663,7 @@ 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))); + canvasHeight - box.baseline() - seatShift(node), canvasHeight - box.bottom())); } /** 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 638d7b690..9b4f7a1e5 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 @@ -40,8 +40,8 @@ void aShapeIsAnchoredInTheParagraphWhoseTextItStandsNearest() { XWPFParagraph second = text(document, "Second"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 40, 50); - anchors.seat(0, second, second, 40, 300, 100, 110); + anchors.seat(0, first, first, 40, 300, 40, 50, 60); + anchors.seat(0, second, second, 40, 300, 100, 110, 120); anchors.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -61,7 +61,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, first, 40, 300, 40, 50, 60); anchors.queue(List.of(dot(30, 200))); anchors.endSection(0, null, document::createParagraph); @@ -78,7 +78,7 @@ void aShapeIsNeverPlacedAboveTheTopOfItsParagraph() { 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, first, 40, 300, 120, 130, 140); anchors.queue(List.of(dot(30, 100))); anchors.endSection(0, null, document::createParagraph); @@ -93,7 +93,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, first, 40, 300, 100, 110, 120); anchors.queue(List.of(dot(30, 99.5))); anchors.endSection(0, null, document::createParagraph); @@ -114,7 +114,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, paragraph, 40, 300, 163.5, 172, 182); anchors.queue(List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -135,7 +135,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, paragraph, 110, 290, 60, 70, 80); anchors.queue(List.of(dot(103, 62))); anchors.endSection(0, null, document::createParagraph); @@ -158,7 +158,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, paragraph, 100, 290, 60, 70, 80); anchors.queue(List.of(dot(80, 62))); anchors.endSection(0, null, document::createParagraph); @@ -167,6 +167,30 @@ void aCellThatCannotHoldTheShapeDoesNotCarryIt() { assertThat(drawingsIn(body)).singleElement().asString().contains(""); } + @Test + void aCellDoesNotCarryAShapeHangingBelowItsText() { + // An icon beside the note under a totals table stands within reach of the table's last + // cell; laid out in that cell, it would hang below the row, and LibreOffice moved the + // row's text for it. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); + XWPFParagraph paragraph = cell.getParagraphs().get(0); + paragraph.createRun().setText("Total due"); + cell.getCTTc().getTcPr().addNewTcMar().addNewLeft().setW(BigInteger.valueOf(0)); + DocxDrawingAnchors anchors = anchors(); + XWPFParagraph body = text(document, "Body"); + anchors.paragraphOn(0, body, true); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70, 74); + + anchors.queue(List.of(dot(120, 100))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph)).isEmpty(); + assertThat(drawingsIn(body)).singleElement().asString().contains(""); + } + @Test void aCellOfARepeatedHeaderRowDoesNotCarryAShape() { // Word repeats the row on every page, and what is anchored in it with it. @@ -180,7 +204,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, paragraph, 110, 290, 60, 70, 80); anchors.queue(List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -198,8 +222,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, spacer, 40, 300, 100, 110, 120); + anchors.seat(0, owner, entry, 40, 300, 100, 110, 120); anchors.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -224,7 +248,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, paragraph, 110, 290, 60, 70, 80); anchors.queue(List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -245,7 +269,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, paragraph, 40, 300, 163.5, 172, 182); anchors.queue(List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -260,7 +284,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, paragraph, 40, 300, 100, 110, 120); anchors.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -275,7 +299,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, paragraph, 40, 300, 40, 50, 60); anchors.queue(List.of(dot(10, 42), dot(20, 42))); anchors.endSection(0, null, document::createParagraph); @@ -336,7 +360,11 @@ private static double offset(String drawing, String axis) { Matcher matcher = Pattern.compile("(-?\\d+)<") .matcher(drawing); assertThat(matcher.find()).isTrue(); - return Long.parseLong(matcher.group(1)) / 12700.0; + 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) { 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 95558287d..80acc3513 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 @@ -980,7 +980,11 @@ private static double offsetDown(String anchor) { Matcher matcher = Pattern.compile("(-?\\d+)<") .matcher(anchor); assertThat(matcher.find()).isTrue(); - return Long.parseLong(matcher.group(1)) / 12700.0; + try { + return Long.parseLong(matcher.group(1)) / 12700.0; + } catch (NumberFormatException notAnOffset) { + throw new AssertionError("not an offset in EMU: " + matcher.group(1), notAnOffset); + } } /** Every anchored drawing in a part's XML, in document order. */ From f4c370d1a115402c523ff0bf72920a56a2eeb578 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 12:36:19 +0100 Subject: [PATCH 3/5] fix(docx): leave a shape its nearest paragraph cannot hold on the page, not to one further off The previous commit kept a shape out of a cell below the cell's text, judged by the text the export wrote there as paragraphs. That missed what a cell holds as list items or rows, and turned 23 shapes beside such text back to the page. The icon it set out to keep out stood just above the note it is beside: the note refused it, and the totals table's last cell, further off, took it. The nearest paragraph is now the only one considered; when it cannot hold the shape, the shape stays on the page. Tests pin reach at 48pt, the paragraphs that offer no top, Word's own cell margin and the nearest paragraph among several in reach. The Linux LibreOffice baseline is taken from CI: CompactMono's lines 0.1pt up, as on Windows. --- CHANGELOG.md | 10 +- docs/recipes/docx-export.md | 11 +- .../docx-fidelity/libreoffice-linux-lines.tsv | 120 ++++++++--------- .../docx-fidelity/libreoffice-linux.tsv | 4 +- .../semantic/docx/DocxDrawingAnchors.java | 70 ++++------ .../semantic/docx/DocxLayoutMetrics.java | 6 +- .../semantic/docx/DocxSemanticBackend.java | 2 +- .../semantic/docx/DocxDrawingAnchorsTest.java | 126 ++++++++++++++---- .../semantic/docx/DocxDrawingsTest.java | 6 +- 9 files changed, 205 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4adea53..7cc2e2f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ follow semantic versioning; release dates are ISO 8601. timeline's dot, an icon by a heading, a skill's bar, a badge — were placed from the page's edges, so when a reader edited the text above one, the text moved and the shape stayed. A shape standing beside a paragraph's text is now anchored in that paragraph and placed down from its - top: the nearest paragraph within 48pt that it hangs from, and one in a table cell only when - the cell holds it across, its line is set from the left and its row is not a repeated header. The paragraph's top is where + top: the paragraph whose text is nearest, within 48pt, when the shape hangs from it rather than + rising above it and, in a table cell, when the cell holds it across, its line is set from the + left and its row is not a repeated header. When the nearest paragraph cannot hold the shape, it + stays on the page rather than going to one further off. The paragraph's top is where Word places from, the space above its first line included and its line where Word stands it, - measured in Word 16.0.20430 and LibreOffice to a tenth of a point. Only the first paragraph a - paragraph node is written as offers its place yet; beside a table's own cell text or a list's + 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 yet; beside a table's own cell text or a list's items, in the gap between two columns or far from any text, a shape stays on the page. Word's editing protocol, with a new `drawings-follow-text` scenario that lengthens a paragraph, finds 132 of the 228 shapes diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index d3585099c..1f5436bee 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -838,14 +838,15 @@ it as approximated. A shape standing beside a paragraph's text — a timeline's 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, that the shape hangs from rather than rising above; one in a table cell -carries the shape only when its cell holds it across, its line is set from the left and its -row is not a repeated header, -and places it from the cell's text column. The paragraph's top is where Word places from: +and down together, when the shape hangs from it rather than rising above it; one in a table +cell carries the shape only when its cell holds it across, its line is set from the left and +its row is not a repeated header, and places it from the cell's text column. 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. 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 -a paragraph node is written as offers its place, in a column's cell too; a table's own cell +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 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 f2f425791..3742718f4 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -302,64 +302,64 @@ cv-classic_serif 1:f20997652453 0.19 0.10 languages:english(fluent),german cv-classic_serif 1:ec9d3622395e 0.17 0.10 workeligibility:eligibletoworkin cv-classic_serif 1:dd20ae01c3cf 0.14 0.10 opensource:maintainerofgraphcomp cv-classic_serif 1:33c35e9d236d 0.12 0.10 speaking:jvmsummit2024,devoxxuk2 -cv-compact_mono 0:8f1010120ed6 0.41 0.10 jordanrivera -cv-compact_mono 0:f9e4560bd641 -0.14 0.10 london,uk/+442055551000/jordan.r -cv-compact_mono 0:32c1cf49a2fe 0.34 0.10 skills -cv-compact_mono 0:551282493299 -0.01 0.10 languagesjava21,kotlin,groovy,py -cv-compact_mono 0:8f5cfd05d621 -0.22 0.10 document&printpdfbox,apachepoi -cv-compact_mono 0:148ff6990529 -0.11 0.10 (docx/xlsx),itext,postscript -cv-compact_mono 0:862bd50bcc32 -0.10 0.10 layoutenginescustomdsldesign,sem -cv-compact_mono 0:ec4f3e745260 0.01 0.10 layouttrees,pagination,snapshott -cv-compact_mono 0:55a8b2742c06 0.02 0.10 build&infrastructuremaven,gradle -cv-compact_mono 0:1649ba79bc7a 0.13 0.10 actions,jitpack -cv-compact_mono 0:d4ac2ee01f81 0.14 0.10 testingjunit5,assertj,pdfbox-bas -cv-compact_mono 0:b04e6ebfb02b 0.25 0.10 diff,layout-graphsnapshots -cv-compact_mono 0:93c9f742ef14 0.26 0.10 distributionmavencentral,sonatyp -cv-compact_mono 0:8f21bd149ad7 0.37 0.10 gpgsigning,jitpack -cv-compact_mono 0:68bf0109c6f8 0.93 0.10 education -cv-compact_mono 0:9ad8d9ef1711 0.43 0.10 msccomputerscience/universityof -cv-compact_mono 0:594f6dd11e20 0.50 0.10 manchester/2019-2021 -cv-compact_mono 0:387dc0387824 0.53 0.10 bscsoftwareengineering/imperialc -cv-compact_mono 0:453806946a88 0.60 0.10 london/2015-2019 -cv-compact_mono 0:7816e7a28b71 0.63 0.10 oraclejavacertification/professi -cv-compact_mono 0:d40b22f00acb 0.85 0.10 2023-2024 -cv-compact_mono 0:7b476727faf3 1.41 0.10 additional -cv-compact_mono 0:ecc25e73b19b 0.90 0.10 languages:english(fluent),german -cv-compact_mono 0:ac7f0ee6a49e 0.91 0.10 (intermediate),spanish(basic) -cv-compact_mono 0:970ca9d789c2 0.92 0.10 workeligibility:eligibletoworkin -cv-compact_mono 0:0735680e2596 0.93 0.10 theeu -cv-compact_mono 0:8051a4b5e34f 0.94 0.10 opensource:maintainerofgraphcomp -cv-compact_mono 0:08c2e869a75b 0.95 0.10 regularcontributortopdfboxissuet -cv-compact_mono 0:12dcd1343975 0.96 0.10 speaking:jvmsummit2024,devoxxuk2 -cv-compact_mono 0:3aea7f90f8ed 0.97 0.10 —bothondeclarativedocumentlayout -cv-compact_mono 0:1900eab6c028 0.54 0.09 profile -cv-compact_mono 0:3828b9718a0d -0.05 0.09 platformengineerwith10+yearsbuil -cv-compact_mono 0:f6b5e93ccadd -0.04 0.09 engines,anddeveloper-facingtempl -cv-compact_mono 0:927ef106776a -0.03 0.09 semanticauthoringdsls,andturning -cv-compact_mono 0:330fcb75ed52 -0.02 0.09 librariesthatscale. -cv-compact_mono 0:53e5e7c5a884 0.26 0.09 experience -cv-compact_mono 0:c9efb118bfda -0.09 0.09 seniorplatformengineer,northwind -cv-compact_mono 0:64c4b52dceb1 -0.29 0.09 ledthereusabledocument-generatio -cv-compact_mono 0:1f193cd1d987 -0.27 0.09 productteams.reducedtemplatemain -cv-compact_mono 0:9ccc3e904e5a -0.25 0.09 favourofonecanonicalengine. -cv-compact_mono 0:e21e4e47835a -0.04 0.09 softwareengineer,brightleaflabs| -cv-compact_mono 0:0cdd48f05f9d -0.24 0.09 builtbackendservicesandproductio -cv-compact_mono 0:e48f1e2c853d -0.22 0.09 permonth.drovethemigrationfromit -cv-compact_mono 0:b78a9a72ca9a -0.20 0.09 andcuttingp99renderlatencyfrom1. -cv-compact_mono 0:9f5d87c00954 0.01 0.09 backendengineer,helixprintco|201 -cv-compact_mono 0:a485c37fe7ea -0.19 0.09 maintainedahigh-volumeinvoice-pr -cv-compact_mono 0:5a136b6b9d8c -0.17 0.09 testharnessthatgatedeverytemplat -cv-compact_mono 0:d94eccbb1fbe 0.09 0.09 selectedprojects -cv-compact_mono 0:0059a3aac61e -0.46 0.09 graphcompose(java21,pdfbox,maven -cv-compact_mono 0:18b7f8c840e4 -0.43 0.09 dsl,slot-basedtemplates,snapshot -cv-compact_mono 0:d36f78e88fe0 -0.39 0.09 forhiringtoolsandbillingsystems. -cv-compact_mono 0:55e119878845 -0.38 0.09 templatestudio(kotlin,composedes -cv-compact_mono 0:c7b83853442b -0.35 0.09 proposal,andinvoiceoutputacross1 -cv-compact_mono 0:6b844249c695 -0.31 0.09 freezing. -cv-compact_mono 0:ab89138286bb -0.30 0.09 layoutlint(java21,javaparser,spo -cv-compact_mono 0:2c506ce69aa6 -0.27 0.09 nestedrows,untypedoffsets,implic -cv-compact_mono 0:6793d28e9783 -0.26 0.09 chromeforge(java,graphcompose,pa -cv-compact_mono 0:e3cfea265b3b -0.23 0.09 graphcompose:cinematiccovers,pul +cv-compact_mono 0:8f1010120ed6 0.31 0.10 jordanrivera +cv-compact_mono 0:f9e4560bd641 -0.24 0.10 london,uk/+442055551000/jordan.r +cv-compact_mono 0:32c1cf49a2fe 0.24 0.10 skills +cv-compact_mono 0:551282493299 -0.11 0.10 languagesjava21,kotlin,groovy,py +cv-compact_mono 0:8f5cfd05d621 -0.32 0.10 document&printpdfbox,apachepoi +cv-compact_mono 0:148ff6990529 -0.21 0.10 (docx/xlsx),itext,postscript +cv-compact_mono 0:862bd50bcc32 -0.20 0.10 layoutenginescustomdsldesign,sem +cv-compact_mono 0:ec4f3e745260 -0.09 0.10 layouttrees,pagination,snapshott +cv-compact_mono 0:55a8b2742c06 -0.08 0.10 build&infrastructuremaven,gradle +cv-compact_mono 0:1649ba79bc7a 0.03 0.10 actions,jitpack +cv-compact_mono 0:d4ac2ee01f81 0.04 0.10 testingjunit5,assertj,pdfbox-bas +cv-compact_mono 0:b04e6ebfb02b 0.15 0.10 diff,layout-graphsnapshots +cv-compact_mono 0:93c9f742ef14 0.16 0.10 distributionmavencentral,sonatyp +cv-compact_mono 0:8f21bd149ad7 0.27 0.10 gpgsigning,jitpack +cv-compact_mono 0:68bf0109c6f8 0.83 0.10 education +cv-compact_mono 0:9ad8d9ef1711 0.33 0.10 msccomputerscience/universityof +cv-compact_mono 0:594f6dd11e20 0.40 0.10 manchester/2019-2021 +cv-compact_mono 0:387dc0387824 0.43 0.10 bscsoftwareengineering/imperialc +cv-compact_mono 0:453806946a88 0.50 0.10 london/2015-2019 +cv-compact_mono 0:7816e7a28b71 0.53 0.10 oraclejavacertification/professi +cv-compact_mono 0:d40b22f00acb 0.75 0.10 2023-2024 +cv-compact_mono 0:7b476727faf3 1.31 0.10 additional +cv-compact_mono 0:ecc25e73b19b 0.80 0.10 languages:english(fluent),german +cv-compact_mono 0:ac7f0ee6a49e 0.81 0.10 (intermediate),spanish(basic) +cv-compact_mono 0:970ca9d789c2 0.82 0.10 workeligibility:eligibletoworkin +cv-compact_mono 0:0735680e2596 0.83 0.10 theeu +cv-compact_mono 0:8051a4b5e34f 0.84 0.10 opensource:maintainerofgraphcomp +cv-compact_mono 0:08c2e869a75b 0.85 0.10 regularcontributortopdfboxissuet +cv-compact_mono 0:12dcd1343975 0.86 0.10 speaking:jvmsummit2024,devoxxuk2 +cv-compact_mono 0:3aea7f90f8ed 0.87 0.10 —bothondeclarativedocumentlayout +cv-compact_mono 0:1900eab6c028 0.44 0.09 profile +cv-compact_mono 0:3828b9718a0d -0.15 0.09 platformengineerwith10+yearsbuil +cv-compact_mono 0:f6b5e93ccadd -0.14 0.09 engines,anddeveloper-facingtempl +cv-compact_mono 0:927ef106776a -0.13 0.09 semanticauthoringdsls,andturning +cv-compact_mono 0:330fcb75ed52 -0.12 0.09 librariesthatscale. +cv-compact_mono 0:53e5e7c5a884 0.16 0.09 experience +cv-compact_mono 0:c9efb118bfda -0.19 0.09 seniorplatformengineer,northwind +cv-compact_mono 0:64c4b52dceb1 -0.39 0.09 ledthereusabledocument-generatio +cv-compact_mono 0:1f193cd1d987 -0.37 0.09 productteams.reducedtemplatemain +cv-compact_mono 0:9ccc3e904e5a -0.35 0.09 favourofonecanonicalengine. +cv-compact_mono 0:e21e4e47835a -0.14 0.09 softwareengineer,brightleaflabs| +cv-compact_mono 0:0cdd48f05f9d -0.34 0.09 builtbackendservicesandproductio +cv-compact_mono 0:e48f1e2c853d -0.32 0.09 permonth.drovethemigrationfromit +cv-compact_mono 0:b78a9a72ca9a -0.30 0.09 andcuttingp99renderlatencyfrom1. +cv-compact_mono 0:9f5d87c00954 -0.09 0.09 backendengineer,helixprintco|201 +cv-compact_mono 0:a485c37fe7ea -0.29 0.09 maintainedahigh-volumeinvoice-pr +cv-compact_mono 0:5a136b6b9d8c -0.27 0.09 testharnessthatgatedeverytemplat +cv-compact_mono 0:d94eccbb1fbe -0.01 0.09 selectedprojects +cv-compact_mono 0:0059a3aac61e -0.56 0.09 graphcompose(java21,pdfbox,maven +cv-compact_mono 0:18b7f8c840e4 -0.53 0.09 dsl,slot-basedtemplates,snapshot +cv-compact_mono 0:d36f78e88fe0 -0.49 0.09 forhiringtoolsandbillingsystems. +cv-compact_mono 0:55e119878845 -0.48 0.09 templatestudio(kotlin,composedes +cv-compact_mono 0:c7b83853442b -0.45 0.09 proposal,andinvoiceoutputacross1 +cv-compact_mono 0:6b844249c695 -0.41 0.09 freezing. +cv-compact_mono 0:ab89138286bb -0.40 0.09 layoutlint(java21,javaparser,spo +cv-compact_mono 0:2c506ce69aa6 -0.37 0.09 nestedrows,untypedoffsets,implic +cv-compact_mono 0:6793d28e9783 -0.36 0.09 chromeforge(java,graphcompose,pa +cv-compact_mono 0:e3cfea265b3b -0.33 0.09 graphcompose:cinematiccovers,pul cv-editorial_blue 0:8f1010120ed6 0.45 0.12 jordanrivera cv-editorial_blue 0:2d58f0cadc2d 0.22 0.13 platformengineer cv-editorial_blue 0:1b28eb6a9a2e 0.14 0.13 +442055551000-london,uk @@ -1483,8 +1483,8 @@ cv-slate_orange 0:01a1e16912b7 0.85 0.07 •microsoftexcel:advancedformulas cv-slate_orange 0:07ef0ade6bb2 1.15 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.93 0.14 isabella.moore@email.com +cv-teal_pulse 0:613c7b77c405 -0.07 0.17 manchester,unitedkingdom +cv-teal_pulse 0:4ddba42ce115 0.08 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.25 0.10 skills diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index d30ed489d..9fa2c4997 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -5,7 +5,7 @@ cv-boxed_sections 2 2 58 58 0.35 0.63 0 cv-centered_headline 2 2 58 56 0.14 0.32 0 cv-charcoal_gold 1 1 85 83 0.11 0.34 0 cv-classic_serif 2 2 57 54 0.06 0.22 0 -cv-compact_mono 1 1 58 58 0.27 0.93 0 +cv-compact_mono 1 1 58 58 0.34 0.83 0 cv-editorial_blue 2 2 82 82 0.08 0.30 0 cv-engineering_resume 1 1 57 57 0.60 1.62 0 cv-executive 2 2 60 60 0.26 0.50 0 @@ -22,7 +22,7 @@ cv-professional_sidebar 1 1 68 64 0.36 0.58 0 cv-serif_headline 1 1 103 103 0.44 0.88 0 cv-sidebar_portrait 1 1 49 49 0.17 0.31 0 cv-slate_orange 1 1 87 86 0.42 1.02 0 -cv-teal_pulse 1 1 57 57 0.22 0.64 0 +cv-teal_pulse 1 1 57 57 0.21 0.62 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 86 0.45 0.50 0 cv-violet_grid 1 1 80 78 0.22 0.44 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 df9d6fe91..2454f4a01 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 @@ -29,10 +29,11 @@ * 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}, that the shape hangs - * from rather than rising above, and, for a paragraph in a table cell, whose cell holds the - * shape across. The shapes wait for the section's end, since the paragraph a shape stands beside - * may be written after it.

+ * The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape hangs from + * it rather than rising above it and, for a paragraph in a table cell, when its cell holds the + * shape across; when the nearest cannot hold it, the shape 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 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 @@ -67,8 +68,6 @@ 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<>(); - // How far down, from the page's top edge, the text written in each table cell reaches, by page. - private final Map> cellFoot = new HashMap<>(); // Identifiers unique in the document, shared with the other drawings the export writes. private final LongSupplier ids; // The order the shapes are painted in, across the document. @@ -85,7 +84,6 @@ void reset() { cellParagraph.clear(); seats.clear(); offers.clear(); - cellFoot.clear(); order = 0; } @@ -144,30 +142,22 @@ void queueOrdered(List shapes) { * @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 - * @param bottom the text's bottom edge on that page, from the page's top edge */ void seat(int page, Object owner, XWPFParagraph paragraph, double left, double right, double top, - double baseline, double bottom) { + double baseline) { if (page >= 0) { - offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline), bottom)); + offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline))); } } - /** A paragraph offered as a seat, what its text is written for, and where that text ends. */ - private record Offer(int page, Object owner, Seat seat, double bottom) { + /** A paragraph offered as a seat, and what its text is written for. */ + private record Offer(int page, Object owner, Seat seat) { } - /** - * Takes, for each piece of text offered, the first of its paragraphs that holds text, and - * notes how far down each table cell's text reaches on each page. - */ + /** Takes, for each piece of text offered, the first of its paragraphs that holds text. */ private void takeTheSeats() { java.util.Set taken = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); for (Offer offer : offers) { - if (offer.seat().paragraph().getBody() instanceof XWPFTableCell cell) { - cellFoot.computeIfAbsent(offer.page(), ignored -> new java.util.IdentityHashMap<>()) - .merge(cell, offer.bottom(), Math::max); - } if (offer.seat().holdsText() && taken.add(offer.owner())) { seats.computeIfAbsent(offer.page(), ignored -> new ArrayList<>()).add(offer.seat()); } @@ -175,23 +165,6 @@ private void takeTheSeats() { offers.clear(); } - /** - * Whether a shape stands beside the text written in the seat's table cell rather than below - * it: it starts above that text's foot and reaches past it by half its height at most, as a - * tile centred beside a one-line label does. A cell holding a shape below its text moved the - * row's text in the Linux LibreOffice the build checks with: an icon beside the note under - * a totals table, taken into the table's last cell, set two invoices' 'Total due' 2pt and - * 4pt up. A seat in the body holds any shape. - */ - private boolean holdsDown(Seat seat, DocxDrawings.Shape shape) { - if (!(seat.paragraph().getBody() instanceof XWPFTableCell cell)) { - return true; - } - Double foot = cellFoot.getOrDefault(shape.page(), Map.of()).get(cell); - return foot != null && shape.top() < foot - && shape.top() + shape.height() <= foot + shape.height() / 2 + SLACK; - } - /** * 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. @@ -283,7 +256,6 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes, DocxDrawings.Cell } /** - * The paragraph whose text a shape stands nearest, within reach, that can hold it placed - * down from its top; {@code null} when none can. + * 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. + * + *

A paragraph further off is never taken instead: it is not the text the shape stands + * beside. An icon centred on the line of a note under a totals table stands a little above + * the note's top, and the table's last cell, 44pt up, took it in; the icon hung below that + * cell's row, and the Linux LibreOffice the build checks with set the row's 'Total due' 2pt + * and 4pt up in two invoices.

*/ private Seat seatBeside(DocxDrawings.Shape shape) { Seat nearest = null; @@ -325,16 +303,16 @@ private Seat seatBeside(DocxDrawings.Shape shape) { double by = Math.abs(shape.top() - seat.top()) + Math.max(0, seat.left() - (shape.x() + shape.width())) + Math.max(0, shape.x() - seat.right()); - // A shape hangs from its paragraph's top, never above it: LibreOffice keeps a shape - // laid out in a cell from rising out of it, and set one 45pt above its paragraph's top - // at the head of the next page. - if (by <= REACH && by < nearestBy && shape.top() >= seat.paragraphTop() - SLACK - && !Double.isNaN(seat.column(shape)) && holdsDown(seat, shape)) { + if (by <= REACH && by < nearestBy) { nearest = seat; nearestBy = by; } } - return nearest; + // A shape hangs from its paragraph's top, never above it: LibreOffice keeps a shape laid + // out in a cell from rising out of it, and set one 45pt above its paragraph's top at the + // head of the next page. + return nearest != null && shape.top() >= nearest.paragraphTop() - SLACK + && !Double.isNaN(nearest.column(shape)) ? nearest : null; } /** 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 e8fdb59b2..30f8abe60 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 @@ -800,8 +800,7 @@ java.util.Optional firstTextBox(DocumentNode node) { return java.util.Optional.of(new TextBox(fragment.pageIndex(), fragment.x() + paragraph.padding().left(), fragment.x() + fragment.width() - paragraph.padding().right(), - top, top - first.lineHeight() + first.baselineOffsetFromBottom(), - fragment.y() + paragraph.padding().bottom())); + top, top - first.lineHeight() + first.baselineOffsetFromBottom())); } } return java.util.Optional.empty(); @@ -815,9 +814,8 @@ java.util.Optional firstTextBox(DocumentNode node) { * @param right its right edge, from the page's left edge * @param top its top edge, measured up from the foot of the page * @param baseline its first line's baseline, measured up from the foot of the page - * @param bottom its bottom edge on that page, measured up from the foot of the page */ - record TextBox(int page, double left, double right, double top, double baseline, double bottom) { + record TextBox(int page, double left, double right, double top, double baseline) { } /** 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 20b5511ac..fc5a34972 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 @@ -4663,7 +4663,7 @@ 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), canvasHeight - box.bottom())); + canvasHeight - box.baseline() - seatShift(node))); } /** 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 9b4f7a1e5..f2e815e4e 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 @@ -40,8 +40,9 @@ void aShapeIsAnchoredInTheParagraphWhoseTextItStandsNearest() { XWPFParagraph second = text(document, "Second"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 40, 50, 60); - anchors.seat(0, second, second, 40, 300, 100, 110, 120); + // 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.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -55,13 +56,61 @@ void aShapeIsAnchoredInTheParagraphWhoseTextItStandsNearest() { assertThat(offsetDown(drawing)).isCloseTo(2, within(0.01)); } + @Test + void reachIsFortyEightPointsAcrossAndDownTogether() { + // 40pt down and 8pt across is in reach; 40pt down and 9pt across is not. The dot is 6pt + // wide, so one 8pt left of the text starts at 40 - 8 - 6. + assertThat(seated(dot(26, 140), 300)).as("48pt").isTrue(); + assertThat(seated(dot(25, 140), 300)).as("49pt").isFalse(); + // Across alone: a dot level with the text, 49pt right of where it ends. + assertThat(seated(dot(349, 100), 300)).as("49pt across").isFalse(); + } + + /** Whether a dot is anchored beside a paragraph whose text starts at (40, 100). */ + private static boolean seated(DocxDrawings.Shape dot, double right) { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = text(document, "Text"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, paragraph, true); + anchors.seat(0, paragraph, paragraph, 40, right, 100, 110); + anchors.queue(List.of(dot)); + anchors.endSection(0, null, document::createParagraph); + return drawingsIn(paragraph).get(0).contains(""); + } + + @Test + void aParagraphWithSomethingAboveItsLineOffersNoTop() { + // Word sets a top border, contextual spacing and space reckoned in lines or by itself + // between the paragraph's top and its line: its line's top does not tell where it starts. + List> above = List.of( + properties -> properties.addNewPBdr().addNewTop().setVal( + org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder.SINGLE), + properties -> properties.addNewContextualSpacing(), + properties -> properties.addNewSpacing().setBeforeLines(BigInteger.valueOf(100)), + properties -> properties.addNewSpacing().setBeforeAutospacing( + org.openxmlformats.schemas.officeDocument.x2006.sharedTypes.STOnOff1.ON)); + for (java.util.function.Consumer something : above) { + XWPFDocument document = new XWPFDocument(); + XWPFParagraph paragraph = text(document, "Text"); + something.accept(paragraph.getCTP().addNewPPr()); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, paragraph, true); + anchors.seat(0, paragraph, paragraph, 40, 300, 100, 110); + anchors.queue(List.of(dot(30, 102))); + anchors.endSection(0, null, document::createParagraph); + + assertThat(drawingsIn(paragraph).get(0)).as(paragraph.getCTP().getPPr().xmlText()) + .contains(""); + } + } + @Test void aShapeOutOfReachOfEveryParagraphStaysWhereThePagePutsIt() { XWPFDocument document = new XWPFDocument(); XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 40, 50, 60); + anchors.seat(0, first, first, 40, 300, 40, 50); anchors.queue(List.of(dot(30, 200))); anchors.endSection(0, null, document::createParagraph); @@ -78,7 +127,7 @@ void aShapeIsNeverPlacedAboveTheTopOfItsParagraph() { XWPFParagraph first = text(document, "First"); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, first, true); - anchors.seat(0, first, first, 40, 300, 120, 130, 140); + anchors.seat(0, first, first, 40, 300, 120, 130); anchors.queue(List.of(dot(30, 100))); anchors.endSection(0, null, document::createParagraph); @@ -93,7 +142,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, 120); + anchors.seat(0, first, first, 40, 300, 100, 110); anchors.queue(List.of(dot(30, 99.5))); anchors.endSection(0, null, document::createParagraph); @@ -114,7 +163,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, 182); + anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172); anchors.queue(List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -135,7 +184,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, 80); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); anchors.queue(List.of(dot(103, 62))); anchors.endSection(0, null, document::createParagraph); @@ -158,7 +207,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, 80); + anchors.seat(0, paragraph, paragraph, 100, 290, 60, 70); anchors.queue(List.of(dot(80, 62))); anchors.endSection(0, null, document::createParagraph); @@ -168,27 +217,52 @@ void aCellThatCannotHoldTheShapeDoesNotCarryIt() { } @Test - void aCellDoesNotCarryAShapeHangingBelowItsText() { - // An icon beside the note under a totals table stands within reach of the table's last - // cell; laid out in that cell, it would hang below the row, and LibreOffice moved the - // row's text for it. + void aCellStatingNoMarginTakesWordsOwn() { + // Neither the cell nor its table states a left margin: Word's own 5.4pt stands between + // the cell's edge and its column, so a cell 200pt wide from 100 holds a dot at 100. XWPFDocument document = new XWPFDocument(); XWPFTable table = document.createTable(1, 1); + if (table.getCTTbl().getTblPr().isSetTblCellMar()) { + table.getCTTbl().getTblPr().unsetTblCellMar(); + } XWPFTableCell cell = table.getRow(0).getCell(0); width(cell, 200); XWPFParagraph paragraph = cell.getParagraphs().get(0); - paragraph.createRun().setText("Total due"); + paragraph.createRun().setText("Entry"); + DocxDrawingAnchors anchors = anchors(); + anchors.paragraphOn(0, paragraph, false); + anchors.seat(0, paragraph, paragraph, 105.4, 290, 60, 70); + + anchors.queue(List.of(dot(100, 62))); + anchors.endSection(0, null, document::createParagraph); + + String drawing = drawingsIn(paragraph).get(0); + assertThat(offsetAcross(drawing)).isCloseTo(100 - 105.4, within(0.01)); + assertThat(offsetDown(drawing)).isCloseTo(2, within(0.01)); + } + + @Test + void aShapeItsNearestParagraphCannotHoldIsNotGivenToOneFurtherOff() { + // An icon centred on a note's line stands a little above the note's top; the totals + // table's last cell above is within reach too, but it is not the text the icon is by. + XWPFDocument document = new XWPFDocument(); + XWPFTable table = document.createTable(1, 1); + XWPFTableCell cell = table.getRow(0).getCell(0); + width(cell, 200); cell.getCTTc().getTcPr().addNewTcMar().addNewLeft().setW(BigInteger.valueOf(0)); + XWPFParagraph total = cell.getParagraphs().get(0); + total.createRun().setText("Total due"); + XWPFParagraph note = text(document, "Thank you"); DocxDrawingAnchors anchors = anchors(); - XWPFParagraph body = text(document, "Body"); - anchors.paragraphOn(0, body, true); - anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70, 74); + anchors.paragraphOn(0, note, true); + anchors.seat(0, total, total, 110, 290, 60, 70); + anchors.seat(0, note, note, 110, 290, 106, 116); - anchors.queue(List.of(dot(120, 100))); + anchors.queue(List.of(dot(120, 104))); anchors.endSection(0, null, document::createParagraph); - assertThat(drawingsIn(paragraph)).isEmpty(); - assertThat(drawingsIn(body)).singleElement().asString().contains(""); + assertThat(drawingsIn(total)).isEmpty(); + assertThat(drawingsIn(note)).singleElement().asString().contains(""); } @Test @@ -204,7 +278,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, 80); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); anchors.queue(List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -222,8 +296,8 @@ void theParagraphHoldingTheTextTakesThePlaceNotTheEmptyLineBeforeIt() { Object owner = new Object(); DocxDrawingAnchors anchors = anchors(); anchors.paragraphOn(0, spacer, true); - anchors.seat(0, owner, spacer, 40, 300, 100, 110, 120); - anchors.seat(0, owner, entry, 40, 300, 100, 110, 120); + anchors.seat(0, owner, spacer, 40, 300, 100, 110); + anchors.seat(0, owner, entry, 40, 300, 100, 110); anchors.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -248,7 +322,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, 80); + anchors.seat(0, paragraph, paragraph, 110, 290, 60, 70); anchors.queue(List.of(dot(120, 62))); anchors.endSection(0, null, document::createParagraph); @@ -269,7 +343,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, 182); + anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172); anchors.queue(List.of(dot(30, 150))); anchors.endSection(0, null, document::createParagraph); @@ -284,7 +358,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, 120); + anchors.seat(0, paragraph, paragraph, 40, 300, 100, 110); anchors.queue(List.of(dot(30, 102))); anchors.endSection(0, null, document::createParagraph); @@ -299,7 +373,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, 60); + anchors.seat(0, paragraph, paragraph, 40, 300, 40, 50); anchors.queue(List.of(dot(10, 42), dot(20, 42))); anchors.endSection(0, null, document::createParagraph); 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 80acc3513..f2ed0d175 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 @@ -132,7 +132,6 @@ void aGlyphInAPaintedBadgeIsDrawnOverTheBadgeWhereThePagePutsIt() throws Excepti String body = document.getDocument().xmlText(); List anchors = anchors(body); String picture = anchors.stream().filter(a -> a.contains(" a.contains("prst=\"ellipse\"")).findFirst().orElseThrow(); + assertThat(offsetDown(picture) - offsetDown(disc)).isCloseTo((22 - 11) / 2.0, within(0.01)); assertThat(relativeHeight(document, " Date: Mon, 5 Oct 2026 12:40:09 +0100 Subject: [PATCH 4/5] test(docx): take the Linux LibreOffice baseline from CI for shapes left to the page by their nearest paragraph --- qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv | 2 +- qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 3742718f4..0854e2111 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -1483,7 +1483,7 @@ cv-slate_orange 0:01a1e16912b7 0.85 0.07 •microsoftexcel:advancedformulas cv-slate_orange 0:07ef0ade6bb2 1.15 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 -0.07 0.17 manchester,unitedkingdom +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:a8acc3a90a7b 0.68 0.17 +447700900123 cv-teal_pulse 0:84308bea4540 0.83 0.16 linkedin diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 9fa2c4997..71d897c42 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -22,7 +22,7 @@ cv-professional_sidebar 1 1 68 64 0.36 0.58 0 cv-serif_headline 1 1 103 103 0.44 0.88 0 cv-sidebar_portrait 1 1 49 49 0.17 0.31 0 cv-slate_orange 1 1 87 86 0.42 1.02 0 -cv-teal_pulse 1 1 57 57 0.21 0.62 0 +cv-teal_pulse 1 1 57 57 0.22 0.63 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 86 0.45 0.50 0 cv-violet_grid 1 1 80 78 0.22 0.44 0 From 4a92f2e69696d3968e63c33041e20d04c7910d78 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 12:57:00 +0100 Subject: [PATCH 5/5] docs(docx): record the editing protocol's count for shapes left to the page by their nearest paragraph --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc2e2f18..5a28e0926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ follow semantic versioning; release dates are ISO 8601. text that a paragraph node is written as offers its place yet; beside a table's own cell text or a list's items, in the gap between two columns or far from any text, a shape stays on the page. Word's editing protocol, with a new `drawings-follow-text` scenario that lengthens a paragraph, finds - 132 of the 228 shapes - beside text that moved moving with it, against 12, and 21 of the 35 documents with drawings + 104 of the 228 shapes + beside text that moved moving with it, against 12, and 19 of the 35 documents with drawings passing, against 15. Unedited, no shape stands more than 1.5pt from where it stood in Word. In LibreOffice the same holds but for `OrangeOps`'s header separator, which follows its line of text where LibreOffice already sets that line 78pt low.