From 7d19b09e3e2fc1349305b7cf37dbc94877de0c4d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 12:29:19 +0100 Subject: [PATCH 1/2] fix(docx): set a panel set in from its cell's text where the page sets its text - In a cell, Word and LibreOffice set a nested table's left border with its outer edge at the cell's text, or as far in as its indent sets it, and its text that border's centre and the cell's margin in. A panel's margin was its padding less half that border, so a panel set in from its cell's text stood its text half that border left of the page's. - It now keeps its whole left padding as its margin where it is set in half its border or more, and what it is set in by of that half where less. A panel hung out of its cell or bled left is unchanged. - Measured on Windows: CharcoalGold's headings stood their text 0.72pt left of the page's in Word and 0.71 to 0.81pt in LibreOffice, now 0.12 and 0.04 to 0.14pt right, their rules with them; a card set in 5pt from a filled panel's text 1 and 0.9pt left, now 0.06 and 0.1pt right. No line breaks differently in either editor. - Tests: a nested card set in, set in half a point, hung and bled; a padding thinner than half its border; a heading set in from a stack's column edge. --- CHANGELOG.md | 17 +++++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 14 +++- .../semantic/docx/DocxSemanticBackend.java | 24 ++++++- .../semantic/docx/DocxContainerPaintTest.java | 72 +++++++++++++++++-- 5 files changed, 118 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2488c9be..dcda5484d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export sets a panel set in from its table cell's text where the page sets its text.** + - In a cell, Word and LibreOffice set a nested table's left border with its outer edge at the + cell's text, or as far in as its indent sets it, and its text that border's centre and the + cell's margin in. A panel's margin was its padding less half that border. A panel set in + from its cell's text by half that border or more stood its text that half left of the page's, + its border where the page draws it; one set in less stood it as far left as it was set in. + - It now keeps its whole left padding as its margin where it is set in half its border or + more, and what it is set in by of that half where it is set in less. A panel on its cell's + text or bled left is unchanged: its editors move it that much right. + - Measured on Windows: `CharcoalGold`'s headings, set in from their column's edge, stood their + text 0.72pt left of the page's in Word and 0.71 to 0.81pt in LibreOffice, now 0.12pt and 0.04 + to 0.14pt right. Their rules stood 0.7pt left with them, now 0.13 to 0.16pt right. A card + with a 2pt accent set in 5pt from a filled panel's text stood its text 1 and 0.9pt left, now + 0.06 and 0.1pt right; set in half a point, 0.48 and 0.4pt left, now 0 and 0.1pt right. No + line breaks differently or moves down in either editor. + - 1 of the corpus's 62 documents changes, `cv-charcoal_gold`. The notes are unchanged. + - **A DOCX export holds a row's auto column's editor point in the gap after it.** - A row column sized to its content (`DocumentRowColumn.auto()`) is given a point for an editor's face. It came out of the row's weight columns and moved what stood right of the auto diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index b2646c476..5710a7520 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -73,7 +73,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 68cd0eaea..e2ba0b9d7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -557,8 +557,11 @@ How it lands: table, and gives no more of its margin than holding it in takes. - **Borders.** The page centres a border on the panel's edge; in a cell, Word keeps a cell's border inside the cell. Half of each border comes off that side's margin and the table - widens by the other half, so the text and the border land where the page draws them. A - panel in the body is the exception on its left: Word 16 and LibreOffice on Windows centre a + widens by the other half, so the text and the border land where the page draws them. On the + left, a nested panel set in from its cell's text keeps of that half what it is set in by, + and all of it set in half its border or more, as both editors set its text its border's centre + and its margin in (see the left edge, under **Width**). A panel in the body is the exception + on its left: Word 16 and LibreOffice on Windows centre a body table's left border on its edge, as the page does, and start its text at its indent, the margin past that edge, so its left margin is the whole padding and the table is no wider on that side. An older LibreOffice — the build on the Linux CI — reads the indent as @@ -638,6 +641,13 @@ How it lands: past its text further than its margin holds takes back what of it the cell does not take, and the panel is named wider (`right end`): `EngineeringResume`'s role card would lose the stop ending its line otherwise. + Both editors set a nested panel's text its left border's centre and its margin in. A panel + set in from its cell's text by half that border or more keeps its whole left padding as its + margin, and one set in less keeps what it is set in by of that half: less the half, + `CharcoalGold`'s headings, set in from their column's edge, stood their text 0.72pt left of + the page's in Word and 0.71 to 0.81pt in LibreOffice, and a card set in 5pt from a filled + panel's text, with a 2pt accent, 1 and 0.9pt (measured; now 0.12pt and 0.04 to 0.14pt, and + 0.06 and 0.1pt, their bars where they were). - **Keeping together.** A `keepTogether()` panel the layout placed on one page is a row Word may not split. Anchors on the blocks inside a panel carry as they do anywhere else, and so do their keeps, but Word and LibreOffice do not hold them where the panel's row breaks over 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 d72fc2690..f1f5fd9a3 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 @@ -6903,7 +6903,12 @@ && known && holdsWrappedText(node) * inside the cell and starts the cell's margin after it. So each margin is the padding less * half that side's border, and the table is wider than the panel by half of each side * border: the text then lands the padding in from the panel's edge, and the border - * straddles the edge, as on the page. A panel in the body is the exception on its left: + * straddles the edge, as on the page. On the left, a nested table's border stands with its + * outer edge at its cell's text, or as far in as its indent sets it, and its text that + * border's centre and its margin in: a panel set in from its cell's text keeps of that half + * what it is set in by, the whole of it set in half its border or more (writePanelPiece), + * and one hung on the cell's text, moved that much right, gives it. A panel in the body is + * the exception on its left: * Word 16 and LibreOffice on Windows centre a body table's left border on its edge and * start its text at its indent, the margin past that edge, so its left margin is the whole * padding, its indent the text's place and the table no wider on that side — measured on @@ -7426,8 +7431,20 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container leftEdgeHungOut(-indent, edge < 0)); } } + // In a cell, Word and LibreOffice set a nested table's left border with its outer edge at the + // cell's text, or as far in as its indent sets it, and its text that border's centre and + // the cell's margin in. A panel set in half its border or more from the cell's text keeps + // its whole left padding as its margin: less half its border, CharcoalGold's headings, set + // in from their column's edge, stood their text 0.72pt left of the page's in Word and 0.71 + // to 0.81pt in LibreOffice (measured), their bars where the page draws them. One set in + // less keeps what it is set in by of that half; one on the cell's text or bled left keeps + // none, as its editors move it that much right (whatTheHangGivesUp). + double setIn = inTheBody ? 0 : Math.min(halfLeft, Math.max(0, edge)); + // Below zero — a padding thinner than what of the half it does not keep — written as none + // (setCellMargin). + double marginLeft = inTheBody ? padding.left() : padding.left() - halfLeft + setIn; applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, marginHeld - slackTaken), - margins.bottom(), inTheBody ? padding.left() : margins.left())); + margins.bottom(), marginLeft)); // A row is laid out as one piece, never across a page break (writeRow): so is its panel. if ((node.keepTogether() || node instanceof RowNode) && layout.onOnePage(node)) { table.getRow(0).setCantSplitRow(true); @@ -7784,7 +7801,8 @@ private PanelWidth panelWidth(DocumentNode node) { * A panel's padding as a cell's margins: less half the border on each side, the half that * lies inside the panel on the page and inside the cell's margin in the editor (see * {@link #writePanel}). A panel in the body writes its whole left padding instead, its text - * placed by its indent ({@link #writePanelPiece}). + * placed by its indent, and one in a cell keeps of its left border's half what it is set in + * from the cell's text by ({@link #writePanelPiece}). */ private static DocumentInsets insideTheBorders(DocumentInsets padding, DocumentBorders borders) { return new DocumentInsets( diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java index 7fc25edbf..86a559525 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java @@ -213,14 +213,76 @@ void aNestedPanelIsPlacedByItsBordersOuterEdge() throws Exception { assertThat(DocxTwips.of(nested.getCTTbl().getTblPr().getTblInd().getW())) .as("its margin, less the half of the border outside its edge") .isEqualTo(4 * 20L); - // In a cell the editor keeps the border inside the cell: the padding less half of it, - // unlike a panel in the body. - assertThat(DocxTwips.of(nested.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getLeft().getW())) - .as("the padding less half the 2pt border") - .isEqualTo(7 * 20L); + // Word and LibreOffice set its text the border's centre and its margin in: set in past + // half its border, its margin is its whole padding. Less half its border, its text + // stood a point left of the page's in Word, 0.9pt in LibreOffice (measured). + assertThat(leftMargin(nested)).as("its whole padding").isEqualTo(8 * 20L); } } + @Test + void aNestedPanelKeepsOfHalfItsBorderWhatItIsSetInBy() throws Exception { + // Set in half a point from the cell's text, a 2pt border on its edge: its border's outer + // edge stands at the cell's text, half a point right of the page's, and its text that + // border's centre and its margin in, the padding less the half point of the border's half + // that is not set in. Hung on the text, it keeps none of that half; bled left, none. + assertThat(nestedCardsLeftMargin(0.5, 2, 8)).isEqualTo(150L); + assertThat(nestedCardsLeftMargin(0, 2, 8)).as("hung on the cell's text").isEqualTo(7 * 20L); + assertThat(nestedCardsLeftMargin(-4, 2, 8)).as("bled left").isEqualTo(7 * 20L); + } + + @Test + void aNestedPanelPaddedThinnerThanHalfItsBorderKeepsNoMoreThanItsPadding() throws Exception { + // A 4pt border, a point of padding: set in a point or less, it has none to keep; set in 3pt, + // all of it. + assertThat(nestedCardsLeftMargin(0.5, 4, 1)).isZero(); + assertThat(nestedCardsLeftMargin(1, 4, 1)).isZero(); + assertThat(nestedCardsLeftMargin(3, 4, 1)).isEqualTo(20L); + } + + @Test + void aPanelInAStacksColumnSetInFromItsEdgeKeepsItsPadding() throws Exception { + // CharcoalGold's main column: a layer as wide as the stack, inset past the sidebar, padded + // in from its edge, and a heading there with a 1.7pt accent and 9pt of padding. + com.demcha.compose.document.dsl.SectionBuilder sidebar = new com.demcha.compose.document.dsl.SectionBuilder(); + sidebar.name("SidebarLayer").padding(new DocumentInsets(0, 220, 0, 0)).addParagraph("Sidebar"); + com.demcha.compose.document.dsl.SectionBuilder main = new com.demcha.compose.document.dsl.SectionBuilder(); + main.name("MainLayer").padding(new DocumentInsets(0, 0, 0, 120)).addSection("MainColumn", column -> column + .padding(new DocumentInsets(0, 0, 0, 23.7)) + .addSection("Heading", heading -> heading + .padding(new DocumentInsets(0, 0, 0, 9)).accentLeft(ACCENT, 1.7).addParagraph("Experience")) + .addParagraph("Column text")); + try (XWPFDocument document = export(page -> page.addLayerStack(stack -> stack.name("Body") + .layer(sidebar.build(), com.demcha.compose.document.node.LayerAlign.TOP_LEFT) + .layer(main.build(), com.demcha.compose.document.node.LayerAlign.TOP_LEFT)))) { + XWPFTable heading = document.getTables().get(0).getRow(0).getCell(1).getTables().get(0); + + assertThat(leftMargin(heading)).as("its whole padding").isEqualTo(9 * 20L); + } + } + + /** + * The left margin, in twips, of a card set in from a filled panel's text by a margin, with a left + * border of that width and that padding. + */ + private static long nestedCardsLeftMargin(double margin, double border, double padding) throws Exception { + try (XWPFDocument document = export(page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE) + .padding(DocumentInsets.of(10)) + .addSection("Inner", inner -> inner + .accentLeft(ACCENT, border) + .padding(DocumentInsets.of(padding)) + .margin(new DocumentInsets(0, 0, 0, margin)) + .addParagraph(p -> p.text("Inner text")))))) { + return leftMargin(onlyCell(document).getTables().get(0)); + } + } + + /** A panel's cell's left margin as written, in twips. */ + private static long leftMargin(XWPFTable panel) { + return DocxTwips.of(panel.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar().getLeft().getW()); + } + @Test void aPanelHungLeftOfItsRowCellEndsInsideTheCellAsWordStartsIt() throws Exception { // Word starts it half its border in from the cell's text, not hung left of it, and a From 43ce9d91b439705970655a1475635283e2525086 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 13:01:25 +0100 Subject: [PATCH 2/2] test(docx): pin a row column's card to its cell's text whatever its margin - A card in a row's column, set in by a margin on the page, stands on its cell's text, which starts past that margin, and keeps none of its border's half. - The CHANGELOG and the comments say a panel hung on its cell's text is moved right by half its border, and a bled one by its bleed too, its text with it. - The recipe's Borders bullet points to the left edge under Nesting, and its Measured bullet counts a card set in from a filled panel's text as measured. --- CHANGELOG.md | 3 ++- docs/recipes/docx-export.md | 8 ++++---- .../semantic/docx/DocxSemanticBackend.java | 7 ++++--- .../semantic/docx/DocxContainerPaintTest.java | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcda5484d..557fe3bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ follow semantic versioning; release dates are ISO 8601. its border where the page draws it; one set in less stood it as far left as it was set in. - It now keeps its whole left padding as its margin where it is set in half its border or more, and what it is set in by of that half where it is set in less. A panel on its cell's - text or bled left is unchanged: its editors move it that much right. + text or bled left is unchanged: its editors move it right by half its border, and a bled one + by its bleed too, its text with it, as the report says (`left edge`). - Measured on Windows: `CharcoalGold`'s headings, set in from their column's edge, stood their text 0.72pt left of the page's in Word and 0.71 to 0.81pt in LibreOffice, now 0.12pt and 0.04 to 0.14pt right. Their rules stood 0.7pt left with them, now 0.13 to 0.16pt right. A card diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index e2ba0b9d7..a1c927424 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -560,7 +560,7 @@ How it lands: widens by the other half, so the text and the border land where the page draws them. On the left, a nested panel set in from its cell's text keeps of that half what it is set in by, and all of it set in half its border or more, as both editors set its text its border's centre - and its margin in (see the left edge, under **Width**). A panel in the body is the exception + and its margin in (see the left edge, under **Nesting**). A panel in the body is the exception on its left: Word 16 and LibreOffice on Windows centre a body table's left border on its edge, as the page does, and start its text at its indent, the margin past that edge, so its left margin is the whole padding and the table is no @@ -661,9 +661,9 @@ How it lands: whose own row may not split, and a row in the panel move whole, as the page sets them. - **Page breaks.** Word breaks no page inside a table cell, so a page break among a panel's children closes the panel there and opens it again after the break. -- **Measured.** Cards stacked in a row's columns, panels nested in its table, stand where the - page sets them in Word 16 and LibreOffice on Windows; a panel inside a panel has not been - measured in Word, and may sit a few points off there. +- **Measured.** Cards stacked in a row's columns, panels nested in its table, and a card set in + from a filled panel's text stand where the page sets them in Word 16 and LibreOffice on + Windows, as far as the left edge and the right end above say. Not representable, and left undone rather than approximated: 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 f1f5fd9a3..7e63c70cc 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 @@ -6907,7 +6907,7 @@ && known && holdsWrappedText(node) * outer edge at its cell's text, or as far in as its indent sets it, and its text that * border's centre and its margin in: a panel set in from its cell's text keeps of that half * what it is set in by, the whole of it set in half its border or more (writePanelPiece), - * and one hung on the cell's text, moved that much right, gives it. A panel in the body is + * and one hung on the cell's text, moved right by that half, gives it. A panel in the body is * the exception on its left: * Word 16 and LibreOffice on Windows centre a body table's left border on its edge and * start its text at its indent, the margin past that edge, so its left margin is the whole @@ -7437,8 +7437,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // its whole left padding as its margin: less half its border, CharcoalGold's headings, set // in from their column's edge, stood their text 0.72pt left of the page's in Word and 0.71 // to 0.81pt in LibreOffice (measured), their bars where the page draws them. One set in - // less keeps what it is set in by of that half; one on the cell's text or bled left keeps - // none, as its editors move it that much right (whatTheHangGivesUp). + // less keeps what it is set in by of that half; one on the cell's text keeps none, its + // editors moving it right by that half, and one bled left none, moved by its bleed too, its + // text with it (leftEdgeHungOut). double setIn = inTheBody ? 0 : Math.min(halfLeft, Math.max(0, edge)); // Below zero — a padding thinner than what of the half it does not keep — written as none // (setCellMargin). diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java index 86a559525..387e973a4 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java @@ -231,6 +231,22 @@ void aNestedPanelKeepsOfHalfItsBorderWhatItIsSetInBy() throws Exception { assertThat(nestedCardsLeftMargin(-4, 2, 8)).as("bled left").isEqualTo(7 * 20L); } + @Test + void aCardInARowsColumnIsOnItsCellsTextWhateverItsMargin() throws Exception { + // A row's cell starts past its child's left margin: the card stands on the cell's text and + // keeps none of its border's half, however far its margin sets it in on the page. + try (XWPFDocument document = export(page -> page.addRow(row -> row + .spacing(12) + .weights(1, 1) + .addSection("Card", card -> card.accentLeft(ACCENT, 2).padding(DocumentInsets.of(8)) + .margin(new DocumentInsets(0, 0, 0, 5)).addParagraph(p -> p.text("Card text"))) + .addParagraph("Beside")))) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + + assertThat(leftMargin(card)).as("the padding less half the border").isEqualTo(7 * 20L); + } + } + @Test void aNestedPanelPaddedThinnerThanHalfItsBorderKeepsNoMoreThanItsPadding() throws Exception { // A 4pt border, a point of padding: set in a point or less, it has none to keep; set in 3pt,