diff --git a/CHANGELOG.md b/CHANGELOG.md index 88772e551..fcc942c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export holds a shape's row less its borders as wide as Word draws them, and a table's + row between two others less the rules it holds.** + - **A shape's borders come off as wide as Word draws them.** A border is written in eighths of a + point, and a row counts it two and a half twips an eighth, rounded down; a shape container + held whole had the lighter of its borders taken off its row's height, and both off the room + it leaves its label, at the width the page strokes them. A 20.3pt chip outlined at 0.7pt, + which Word draws 0.75pt wide, stood a twip taller in Word and LibreOffice on Windows, and six + of them in a table's rows six twips lower below them; one outlined at 0.9pt, drawn 0.875pt + wide, was held a twip short, the row round it holding it (measured). A panel's border is now + written from two to ninety-six eighths of a point, as a table cell's rule is, where a hairline + thinner than 0.19pt was written at one eighth and a border past 12pt above the range. + - **A row between two others is held less the rules it holds:** the rest of its own rule above + it and half the rule of the cell under it, which is one rule where the two are ruled alike, as + it was held. A row is held less the most any one of its cells takes above and below its + content, its margins and those rules together. Ruled unlike the next, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point + taller in Word and all under it half a point lower; held so, the rows of five, ruled either + way round in the middle, stand where the page sets them in Word (measured). LibreOffice makes + room at the edge between two rows for the heavier of their rules, where Word makes it for the + lower row's: under a row ruled heavier than it, a row and all under it stand the difference + lower there, 1pt for these, on Windows and in LibreOffice 24.2. + + No document of the fidelity corpus moves: its DOCX are byte-identical. + - **A DOCX export seats the label of a chip composed in a table cell where the page sets it.** - **A label taller than its chip is seated from the chip's top.** The page starts a line taller than a shape's outline at the outline's top and draws it past it below; the export seated it diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 239cf1019..9c501fb41 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -70,7 +70,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; 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, 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 borders and margins. 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. 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, 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; 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 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 are. 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. 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, 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; 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 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 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). 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, 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, anchored as a rectangle is | @@ -80,7 +80,7 @@ Payload records live in `core` under | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | | 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; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | -| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `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), each as wide as Word draws it, in eighths of a point, and counted as both editors count it, two and a half twips an eighth rounded down (a rule between two rows shared in whole twips, the row above it taking half rounded down; a cell's top and bottom margins rounded to the twip together); a row of text cells moved by its top and bottom margins together, from Word's seat four fifths of the way down an exact line onto the page's baselines, by the least its cells' lines call for and no further than the margins hold — not a row holding a composed cell, one the layout did not measure, one in a vertical merge or whose cells call for opposite ways, nor a least call under a tenth of a point — and what the margins leave of a cell's call, half a point and more, moved by its text's position; a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | +| 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), each as wide as Word draws it, in eighths of a point, and counted as both editors count it, two and a half twips an eighth rounded down (a rule between two rows shared in whole twips, the row above it taking half rounded down; a cell's top and bottom margins rounded to the twip together), a row held at the page's height less the most any of its cells takes, its margins and the rules it holds — between two others, the rest of its own above and half that of the cell under it; LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's, and under a row ruled heavier than it a row stands the difference lower there; a row of text cells moved by its top and bottom margins together, from Word's seat four fifths of the way down an exact line onto the page's baselines, by the least its cells' lines call for and no further than the margins hold — not a row holding a composed cell, one the layout did not measure, one in a vertical merge or whose cells call for opposite ways, nor a least call under a tenth of a point — and what the margins leave of a cell's call, half a point and more, moved by its text's position; a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less its borders and margins, which keep inside it, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it, from the shape's top where the line is taller than its outline and Word sets it on the shape's middle, and a label it holds whole written at the line height that stands its text on the page's baseline, as far as its letters and the room allow, by a tenth of a point or more and not in a standard face the export does not embed — its row held and its label cut in whole twips rounded down, as the row the cell is in holds the page's height rounded once, and what its letters keep the label past that room taken out of the margins of the cell it is in, where it is all that cell holds and the cell all its row holds; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index eda5bebca..74e552377 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,7 +69,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | @@ -533,8 +533,12 @@ How it lands: padding, the border drawn that much inside the panel's edge and the content where the page sets it; above, then, the room over its first line. What none of them takes stands the content, or the block below, that much lower, and the report names it: on the panel for its - top border, on the block below for its bottom one (`space above`). A shape container's - borders, its layers centred in it, stay inside its outline. Measured in Word 16 and + top border, on the block below for its bottom one (`space above`). A panel's border is + written from two to ninety-six eighths of a point, as a table cell's rule is. A shape container's + borders, its layers centred in it, stay inside its outline, which its row holds less them as + wide as Word draws them, two and a half twips an eighth of a point rounded down (measured: + taken off at the 0.7pt the page strokes, a 20.3pt chip whose border Word draws 0.75pt wide + stood a twip taller in Word and LibreOffice on Windows). Measured in Word 16 and LibreOffice on Windows alike on 2pt borders with no padding and with 1 and 4pt of it, every line stood where the page sets it, or as much lower as the report names: within 0.15pt in Word and 0.05pt in LibreOffice. @@ -896,7 +900,8 @@ recolour what is under it in Word and it keeps the colour it was flattened to. size. A one-line label the shape centres top to bottom, on a line taller than the outline leaves it, is written that much shorter, down to the room Word leaves the cell's content — the outline less the margins written - and the borders, which Word keeps outside its content — its text seated + and the borders, which Word keeps outside its content, as wide as it draws + them — its text seated where the page sets it, from the shape's top where the line is taller than its outline, as the page starts it there and draws it past the outline below — where Word sets the line on the shape's middle, as it does where the 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 c3ca8d5b7..73bcfe35a 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 @@ -5410,7 +5410,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container currentCellWidth = Double.isFinite(width) ? width - padding.left() - padding.right() : Double.NaN; XWPFTableCell outerPanelCell = panelCell; panelCell = cell; - ParagraphNode cutLabel = first && last ? cutTheLabelToItsOutline(node, borders, cell) : null; + ParagraphNode cutLabel = first && last ? cutTheLabelToItsOutline(node, cell) : null; // What of the top border's reach the padding cannot take comes out of the space above the // first paragraph (takeTheTopBorderInside, below): that much of it is no room to raise a // line into. @@ -5449,14 +5449,13 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // Its height is the page's: what makes a panel taller than its text — an icon drawn // where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's // due-date card closed from 59.4pt to its text's 26, and its calendar hung below it. - holdThePanelsHeight(table.getRow(0), placed.placementHeight() - fromTheContent, padding, borders, - heldWhole, false); + holdThePanelsHeight(table.getRow(0), placed.placementHeight() - fromTheContent, padding, heldWhole, false); } else if (first && last && layout.placement(node) == null && node instanceof ShapeContainerNode shape && shape.outline().height() > 0) { // Composed in a table cell, it has no placement; its outline states its height, as it // states its width (panelWidth). CobaltRota's shift chips, 17.5pt outlines round a // line of text, closed to the text's 12.7pt in Word. - holdThePanelsHeight(table.getRow(0), shape.outline().height(), padding, borders, true, true); + holdThePanelsHeight(table.getRow(0), shape.outline().height(), padding, true, true); if (cutLabel != null && aloneInItsRow == node) { giveBackWhatTheLabelNeeds(table.getRow(0), cutLabel); } @@ -5545,7 +5544,11 @@ private static double takeOffTheCellMargin(XWPFTableCell cell, boolean top, doub * ({@link #outsideTheRow}). A shape container, its layers centred in it, holds its outline's * height whole, its margins and borders off its content, as the page draws them * inside its outline: CobaltRota's outlined chips, 9.2pt outlines with a 1.125pt border, stood - * 10.3pt tall in Word and 10.2pt in LibreOffice with neither border taken off.
+ * 10.3pt tall in Word and 10.2pt in LibreOffice with neither border taken off. The borders come + * off as wide as Word draws them, as a row counts them ({@link #eighthsInTwips}): taken off at + * the 0.7pt the page strokes, a 20.3pt chip whose border Word draws 0.75pt wide stood a twip + * taller in Word and LibreOffice, and six of them in a table's rows six twips lower below them + * (measured). * *A shape composed in a table cell holds its height rounded down to the twip. The row it is * in holds the page's height, rounded once; anything in it rounded up grows the row past that. @@ -5558,11 +5561,12 @@ private static double takeOffTheCellMargin(XWPFTableCell cell, boolean top, doub * holding the height; ignored otherwise */ private static void holdThePanelsHeight(XWPFTableRow row, double height, DocumentInsets padding, - DocumentBorders borders, boolean whole, boolean inACell) { + boolean whole, boolean inACell) { if (whole) { - // holdRowAtLeast takes the margins and the heavier border off; the lighter one too. - holdRowAtLeast(row, height - Math.min(strokeWidth(borders.top()), strokeWidth(borders.bottom())), 0, - inACell); + // holdRowAtLeast takes the margins and the heavier border off; the lighter one too, as + // wide as Word draws it. + XWPFTableCell cell = row.getCell(0); + holdRowAtLeast(row, height, Math.min(rule(cell, true), rule(cell, false)), inACell); return; } long twips = toTwips(height - Math.max(0, padding.top()) - Math.max(0, padding.bottom())); @@ -5728,9 +5732,10 @@ private static void paintCellSide(CTBorder edge, DocumentStroke stroke) { paintEdge(edge, STBorder.NIL, null, null); return; } - // w:sz counts eighths of a point, rounded to at least one so a hairline the author - // asked for stays a line rather than vanishing. - paintEdge(edge, STBorder.SINGLE, BigInteger.valueOf(Math.max(1, Math.round(stroke.width() * 8.0))), + // w:sz counts eighths of a point, from Word's thinnest to its thickest, as a table cell's + // rule does: a hairline the author asked for stays a line, and a row counts the border at + // the width written (eighthsInTwips). + paintEdge(edge, STBorder.SINGLE, BigInteger.valueOf(ruleEighths(stroke.width())), toHexColor(stroke.color().color())); } @@ -5941,7 +5946,7 @@ private void riseIntoItsLine(DocumentNode node) { * * @return the label cut, or {@code null} when none is */ - private ParagraphNode cutTheLabelToItsOutline(DocumentNode node, DocumentBorders borders, XWPFTableCell cell) { + private ParagraphNode cutTheLabelToItsOutline(DocumentNode node, XWPFTableCell cell) { ParagraphNode label = composedLabelOf(node); if (label == null) { return null; @@ -5954,12 +5959,11 @@ private ParagraphNode cutTheLabelToItsOutline(DocumentNode node, DocumentBorders } // What Word leaves the content of the cell the panel is: the outline less the margins // written — its padding, less the half of each border the page draws inside it — and the - // borders Word keeps outside the content. + // borders Word keeps outside the content, as wide as it draws them. // In whole twips, rounded down, as the panel holds its height (holdThePanelsHeight): a line // written at the room rounded up grows the row the panel is in past the page's. - double room = twipsDown(shape.outline().height() - - (cellMargin(cell, true) + cellMargin(cell, false)) / POINT_TO_TWIP - - strokeWidth(borders.top()) - strokeWidth(borders.bottom())) / POINT_TO_TWIP; + double room = (twipsDown(shape.outline().height()) - cellMargin(cell, true) - cellMargin(cell, false) + - rule(cell, true) - rule(cell, false)) / POINT_TO_TWIP; double lineHeight = line.get().lineHeight(); double over = (lineHeight - room) / 2; if (!(room > 0)) { @@ -5975,7 +5979,7 @@ private ParagraphNode cutTheLabelToItsOutline(DocumentNode node, DocumentBorders // is taller lower than the middle the cell centres the written line on. double low = Math.max(0, (lineHeight - shape.outline().height()) / 2); double cut = cutAlike(over, lineHeight, baseline, ink, low); - if (low > 0 && (cut < 0 || !setOnTheShapesMiddle(node, borders, cell, lineHeight - 2 * cut))) { + if (low > 0 && (cut < 0 || !setOnTheShapesMiddle(node, cell, lineHeight - 2 * cut))) { // Read from the middle, as the cell centres it: where Word would set the line from the // top of a chip it grows downwards, or no line on that middle holds the letters where // the page sets them — a position would lower them out of it. @@ -6019,11 +6023,10 @@ private static double cutAlike(double over, double lineHeight, double baseline, * * @param line the label line's height as written, in points */ - private boolean setOnTheShapesMiddle(DocumentNode node, DocumentBorders borders, XWPFTableCell cell, double line) { + private boolean setOnTheShapesMiddle(DocumentNode node, XWPFTableCell cell, double line) { // The height holdThePanelsHeight holds the shape's row at. - long held = twipsDown(((ShapeContainerNode) node).outline().height() - - Math.min(strokeWidth(borders.top()), strokeWidth(borders.bottom()))) - - verticalMargins(cell); + long held = twipsDown(((ShapeContainerNode) node).outline().height()) + - Math.min(rule(cell, true), rule(cell, false)) - verticalMargins(cell); long over = toTwips(line) - held; XWPFTableCell around = currentCell; return over <= 0 || aloneInItsRow == node && around != null @@ -11584,25 +11587,27 @@ private static boolean framesText(com.demcha.compose.document.layout.PlacedFragm * needs more room than the page's, the row still grows to hold it.
* *The height is written less the most any of the row's cells takes above and below its - * content — top and bottom margins, and a horizontal border's width, with half of the rule - * above the table and of the one below it more in the first row and the last - * ({@link #outerRulesBeyondOne}). LibreOffice reads a row's height as its cells' content and - * adds those to it: written whole, {@code PlatformInvoice}'s 36pt rows, padded 6.1pt above - * and below, came out 48pt. Word does too, measured on a ruled table. Less them, a row is the - * page's height in both.
+ * content — top and bottom margins, and a horizontal border's width, with what else of the + * rules round it the row holds: half of the rule above the table and of the one below it more + * in the first row and the last, and half the difference between its own and the next row's + * in a row between two others ({@link #rulesBeyondOne}). LibreOffice reads a row's height + * as its cells' content and adds those to it: written whole, {@code PlatformInvoice}'s 36pt + * rows, padded 6.1pt above and below, came out 48pt. Word does too, measured on a ruled table. + * Less them, a row is the page's height in both. */ private void holdRowHeight(XWPFTable table, TableNode node, int rowIdx) { java.util.OptionalDouble height = layout.rowHeight(node, rowIdx); if (height.isPresent()) { XWPFTableRow row = table.getRow(rowIdx); - holdRowAtLeast(row, height.getAsDouble(), outerRulesBeyondOne(table, rowIdx)); + holdRowAtLeast(row, height.getAsDouble(), rulesBeyondOne(table, rowIdx)); fitBlankLinesToTheRow(row); } } /** - * What a table's first or last row holds of its rules beyond the one {@link #verticalMargins} - * counts, in twips. + * What a table's row holds of its rules beyond the one {@link #verticalMargins} counts, in + * twips: the most any of its cells takes above and below its content — its margins and the + * rules it holds — less the most that counts of any; below zero where it holds less. * *Word gives a rule between two rows half to each — the lower row's, where the two differ * — and the rule above the table and the one below it whole to their row; it reads a row's @@ -11614,35 +11619,51 @@ private void holdRowHeight(XWPFTable table, TableNode node, int rowIdx) { * Word, and the page under them that much low. A rule between two rows is shared in whole * twips as {@link #clearOfTheRules} shares it: the row above it holds half, rounded down, and * the row under it the rest.
+ * + *A row between two others holds the rest of its own rule above it and half the one under + * it, which is its own rule where the next row is ruled alike. Ruled unlike the next, it holds + * half the difference more or less: measured, a row ruled at 0.5pt over one ruled at 1.5pt, + * held less its own rule alone, stood half a point taller in Word, its text a quarter of a + * point lower and all under it half a point.
*/ - private static long outerRulesBeyondOne(XWPFTable table, int rowIdx) { - int rows = table.getNumberOfRows(); + private static long rulesBeyondOne(XWPFTable table, int rowIdx) { boolean first = rowIdx == 0; - boolean last = rowIdx == rows - 1; - if (!first && !last) { - return 0; - } - if (first && !last) { - // Its own rule above the table counts; its share of the next row's rule is the rest. - return mostRule(table.getRow(1), true) / 2; - } - long most = 0; + boolean last = rowIdx == table.getNumberOfRows() - 1; + XWPFTableRow next = last ? null : table.getRow(rowIdx + 1); + long most = Long.MIN_VALUE; + long counted = Long.MIN_VALUE; + int column = 0; for (XWPFTableCell cell : table.getRow(rowIdx).getTableCells()) { long above = rule(cell, true); long below = rule(cell, false); - long held = (first ? above : above - above / 2) + below; - most = Math.max(most, held - Math.max(above, below)); + // Its share of the rule under it: the next row's, of the cell under it, half rounded + // down — what clearOfTheRules took off its padding. + long under = last ? below : ruleAt(next, column) / 2; + long held = (first ? above : above - above / 2) + under; + most = Math.max(most, cellMargin(cell, true) + cellMargin(cell, false) + held); + counted = Math.max(counted, verticalMargins(cell)); + column += gridSpan(cell); } - return most; + return most == Long.MIN_VALUE ? 0 : most - counted; } - /** The heaviest top (or bottom) rule of a row's cells, in twips. */ - private static long mostRule(XWPFTableRow row, boolean top) { - long most = 0; + /** The top rule of the cell of a row that covers a column of the grid, in twips; 0 past its last cell. */ + private static long ruleAt(XWPFTableRow row, int column) { + int end = 0; for (XWPFTableCell cell : row.getTableCells()) { - most = Math.max(most, rule(cell, top)); + end += gridSpan(cell); + if (column < end) { + return rule(cell, true); + } } - return most; + return 0; + } + + /** How many columns of the grid a cell spans. */ + private static int gridSpan(XWPFTableCell cell) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null; + return properties != null && properties.isSetGridSpan() && properties.getGridSpan().getVal() != null + ? Math.max(1, properties.getGridSpan().getVal().intValue()) : 1; } /** A cell's own top (or bottom) rule, in twips. */ @@ -11671,8 +11692,8 @@ private static long rule(XWPFTableCell cell, boolean top) { * content, the cells' content and margins filling the row's whole around it. So a * cell's room is that height, as much again as the row's written height leaves out, less * what the cell itself takes and its paragraph's space above and below — the page's row, - * less the cell's own margins and border, and in a table's first or last row its share of the - * rule next to it more ({@link #outerRulesBeyondOne}). Taken off the written height alone, a blank row's + * less the cell's own margins and border, and what else of the rules round it its row holds + * ({@link #rulesBeyondOne}). Taken off the written height alone, a blank row's * line would lose its padding twice. */ private static void fitBlankLinesToTheRow(XWPFTableRow row) { @@ -11885,7 +11906,7 @@ private static void holdRowAtLeast(XWPFTableRow row, double points) { /** * {@link #holdRowAtLeast(XWPFTableRow, double)}, less {@code beyond} twips more of rules the - * row holds besides the one its cells' margins count ({@link #outerRulesBeyondOne}). + * row holds besides the one its cells' margins count ({@link #rulesBeyondOne}). */ private static void holdRowAtLeast(XWPFTableRow row, double points, long beyond) { holdRowAtLeast(row, points, beyond, false); @@ -12036,13 +12057,10 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except } evenTheRowsMargins(row); moveTheRowsText(row); - // The first row's share of the rule under it is the next row's rule, written next. - if (rowIdx > 0 || rowCount == 1) { - holdRowHeight(table, node, rowIdx); - } } - if (rowCount > 1) { - holdRowHeight(table, node, 0); + // A row's share of the rule under it is the next row's rule, written after it. + for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { + holdRowHeight(table, node, rowIdx); } breakRowsWhereTheLayoutDoes(table, node); indentTable(table); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java index d4794eff9..4a1e6ff43 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -252,17 +252,46 @@ void aLabelSetFromTheChipsTopIsLeftAsItWas() throws Exception { void anOutlinedChipHoldsItsRowLessBothBorders() throws Exception { // Word and LibreOffice draw a cell's top and bottom borders outside the height its row // holds: CobaltRota's outlined chips, 9.2pt outlines with a 1.125pt border, stood 10.3pt - // tall in Word, and each row holding one 1.1pt taller than the page's. + // tall in Word, and each row holding one 1.1pt taller than the page's. A 1.125pt border is + // written at nine eighths, and a row counts it 22 twips. XWPFTableCell outlined = chipCell(com.demcha.compose.document.style.DocumentStroke.of( com.demcha.compose.document.style.DocumentColor.rgb(20, 160, 70), 1.125)); XWPFTableCell filled = chipCell(null); - assertThat(heightOf(outlined.getTableRow().getTable())).as("9.2pt less two 1.125pt borders") - .isEqualTo(139); + assertThat(heightOf(outlined.getTableRow().getTable())).as("184 twips less two borders of 22") + .isEqualTo(140); assertThat(heightOf(filled.getTableRow().getTable())).as("a chip with no border keeps its outline's") .isEqualTo(184); } + @Test + void anOutlinedChipHoldsItsRowLessItsBordersAsWordDrawsThem() throws Exception { + // A border is written in eighths of a point, and a row counts it two and a half twips an + // eighth, rounded down: a 0.7pt outline, written at six eighths, takes 15 twips, and a + // 0.9pt one, at seven, 17. Taken off as the page strokes them, 14 and 18, a 20.3pt chip + // outlined at 0.7pt stood a twip taller in Word and LibreOffice, and six of them in a + // table's rows six twips lower below them. + XWPFTableCell thin = aroundTheChip(stroke(0.7), com.demcha.compose.document.node.LayerAlign.CENTER, 8.2, 20.3) + .getTables().get(0).getRow(0).getCell(0); + XWPFTableCell thick = aroundTheChip(stroke(0.9), com.demcha.compose.document.node.LayerAlign.CENTER, 8.2, 20.3) + .getTables().get(0).getRow(0).getCell(0); + + assertThat(heightOf(thin.getTableRow().getTable())).as("406 twips less two borders of 15").isEqualTo(376); + assertThat(heightOf(thick.getTableRow().getTable())).as("406 twips less two borders of 17").isEqualTo(372); + } + + @Test + void anOutlinedChipsLabelHasTheRoomItsBordersLeaveAsWordDrawsThem() throws Exception { + // An 11.8pt chip outlined at 0.7pt leaves Lato's line 236 twips less two borders of 15, + // 206, short of the 212 that would seat it. Counted at 14 each, the line and the row it is + // held at were written 208 and 207, and stood the chip that much taller in Word. + XWPFTableCell chip = beside(labelledChip("09:00-16:00", com.demcha.compose.font.FontName.LATO, 8.2, 11.8, + DocumentInsets.zero(), stroke(0.7), null)); + + assertThat(lineOf(chip)).isEqualTo(206 / 20.0); + assertThat(heightOf(chip.getTableRow().getTable())).isEqualTo(206); + } + @Test void aChipComposedInACellIsHeldAndItsLabelCutRoundedDownToTheTwip() throws Exception { // CobaltRota's stacked chips are 183.69 twips: held at 184 and their labels cut to 184, @@ -295,11 +324,11 @@ void theCellAnOutlinedChipIsAllOfGivesBackWhatItsLettersKeepPastItsOutline() thr @Test void anOddNumberOfTwipsIsGivenBackHalfRoundedDownAboveAndTheRestBelow() throws Exception { - // A 1.05pt outline is written at eight eighths, 20 twips: the chip's row holds 163 less 20, - // 143, and its letters keep its label at 172 — 29 twips, 14 above and 15 below. + // A 1.05pt outline is written at eight eighths, 20 twips: a 9.195pt chip's row holds 183 less + // two of them, 143, and its letters keep its label at 172 — 29 twips, 14 above and 15 below. com.demcha.compose.document.style.DocumentStroke outline = com.demcha.compose.document.style.DocumentStroke.of( com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), 1.05); - XWPFTableCell around = aloneInARow(outline, DocumentInsets.of(4)); + XWPFTableCell around = aloneInARow(outline, DocumentInsets.of(4), 9.195); XWPFTableCell chip = around.getTables().get(0).getRow(0).getCell(0); assertThat(DocxTwips.of(chip.getParagraphs().get(0).getCTP().getPPr().getSpacing().getLine()) @@ -307,6 +336,20 @@ void anOddNumberOfTwipsIsGivenBackHalfRoundedDownAboveAndTheRestBelow() throws E assertThat(verticalMarginsOf(around)).containsExactly(60L - 14, 60L - 15); } + @Test + void aCellWhoseMarginsHoldJustWhatItsChipGrowsByGivesItAllBack() throws Exception { + // The chip of the odd case above, its cell's margins 14 twips above and 15 below — its + // padding less the 1pt rule: all Word grows the chip by, given back, the label seated on + // the chip's middle. Its borders counted at the 1.05pt the page strokes rather than the 20 + // twips Word draws, the chip would grow by 30, more than the margins above hold. + XWPFTableCell around = aloneInARow(com.demcha.compose.document.style.DocumentStroke.of( + com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), 1.05), + new DocumentInsets(1.7, 4, 1.75, 4), 9.195); + + assertThat(verticalMarginsOf(around)).containsExactly(0L, 0L); + assertThat(lineOf(around.getTables().get(0).getRow(0).getCell(0))).isEqualTo(172 / 20.0); + } + @Test void aCellGivesBackNoMoreThanItsMarginsHold() throws Exception { // 1.25pt of padding less the 1pt rule leaves 5 twips above and below, less than the 10 each. @@ -540,10 +583,21 @@ private static long positionOf(XWPFTableCell chip) { private static final com.demcha.compose.document.style.DocumentStroke OUTLINE = com.demcha.compose.document.style.DocumentStroke.of(com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), 1.1); + /** An outline {@code width} wide. */ + private static com.demcha.compose.document.style.DocumentStroke stroke(double width) { + return com.demcha.compose.document.style.DocumentStroke.of(com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), width); + } + /** The cell of a one-column table a 9.2pt chip is all of. */ private static XWPFTableCell aloneInARow(com.demcha.compose.document.style.DocumentStroke stroke, DocumentInsets padding) throws Exception { - DocumentNode node = chip(stroke, 9.2); + return aloneInARow(stroke, padding, 9.2); + } + + /** The cell of a one-column table a chip {@code height} tall is all of. */ + private static XWPFTableCell aloneInARow(com.demcha.compose.document.style.DocumentStroke stroke, + DocumentInsets padding, double height) throws Exception { + DocumentNode node = chip(stroke, height); return onlyTableCell(page -> page.addTable(t -> t .columns(DocumentTableColumn.fixed(100)) .defaultCellStyle(com.demcha.compose.document.table.DocumentTableStyle.builder().padding(padding).build()) 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 91d5946e6..aa9bb3e02 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 @@ -95,6 +95,21 @@ void aUniformStrokeDrawsAllFourSides() throws Exception { } } + @Test + void aPanelsBorderIsWrittenFromWordsThinnestToItsThickest() throws Exception { + // A line border's w:sz runs from two eighths of a point to ninety-six, as a table cell's + // rule is written: a row counts the border at the width written. + for (double[] stroke : new double[][] {{0.1, 2}, {14, 96}}) { + try (XWPFDocument document = export(page -> page.addSection("Card", card -> card + .stroke(DocumentStroke.of(ACCENT, stroke[0])) + .addParagraph(p -> p.text("Outlined"))))) { + var edges = onlyCell(document).getCTTc().getTcPr().getTcBorders(); + + assertThat(edges.getTop().getSz().intValue()).as("a %spt border", stroke[0]).isEqualTo((int) stroke[1]); + } + } + } + @Test void anUnpaintedSectionIsNotATable() throws Exception { try (XWPFDocument document = export(page -> page.addSection("Plain", plain -> plain diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuledRowHeightTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuledRowHeightTest.java index 6d4484d72..8dc7afb75 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuledRowHeightTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuledRowHeightTest.java @@ -107,6 +107,112 @@ void aHeaderRuledHeavierThanItsRowsGivesHalfTheirRuleNotItsOwn() throws Exceptio } } + @Test + void aRowRuledUnlikeTheNextIsHeldAtItsContentsHeight() throws Exception { + // A row between two others gives the rest of its own rule above it and half the next row's + // below. Measured, a row ruled at 0.5pt over one ruled at 1.5pt, held less its own rule + // alone, stood half a point taller in Word, and all under it half a point lower; the row + // ruled at 1.5pt was held half a point short, its content holding it. Either way round, in + // the middle of five rows. + for (double[] rules : new double[][] {{0.5, 1.5}, {1.5, 0.5}}) { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> { + t.columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(7)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, rules[0])) + .build()) + .rowStyle(2, DocumentTableStyle.builder().stroke(DocumentStroke.of(DocumentColor.BLACK, rules[1])).build()); + for (int i = 0; i < 5; i++) { + t.row("Row"); + } + }))) { + var table = document.getTables().get(0); + long content = line(table.getRow(0)); + + for (int i = 0; i < 5; i++) { + assertThat((long) table.getRow(i).getHeight()).as("row %s, ruled %s with %s in the middle", i, + rules[0], rules[1]).isEqualTo(content); + } + } + } + } + + @Test + void aTableRuledColumnByColumnHoldsEveryRowAsOneRuledAlike() throws Exception { + // Each cell gives half the rule of the cell under it, not of the heaviest in the next row: + // a column ruled at 1.5pt beside one at 0.5pt, alike from row to row, holds its rows where + // a table ruled alike does. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> { + t.columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(7)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0.5)) + .build()) + .columnStyle(1, DocumentTableStyle.builder().stroke(DocumentStroke.of(DocumentColor.BLACK, 1.5)).build()); + for (int i = 0; i < 4; i++) { + t.row("Row", "Row"); + } + }))) { + var table = document.getTables().get(0); + long content = line(table.getRow(0)); + + for (int i = 0; i < 4; i++) { + assertThat((long) table.getRow(i).getHeight()).as("row %s", i).isEqualTo(content); + } + } + } + + @Test + void aCellAfterASpanGivesHalfTheRuleOfTheCellUnderIt() throws Exception { + // The second row's last cell starts the third column of the grid, past a cell spanning two: + // the cell under it is the third column's, ruled at 0.5pt, not the second's at 1.5pt. The + // spanning cell takes the rule under its first column, at 0.5pt, as its padding gives up + // half of it (clearOfTheRules). + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(7)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0.5)) + .build()) + .columnStyle(1, DocumentTableStyle.builder().stroke(DocumentStroke.of(DocumentColor.BLACK, 1.5)).build()) + .row("Row", "Row", "Row") + .rowCells(com.demcha.compose.document.table.DocumentTableCell.text("Row").colSpan(2), + com.demcha.compose.document.table.DocumentTableCell.text("Row")) + .row("Row", "Row", "Row") + .row("Row", "Row", "Row")))) { + XWPFTableRow spanned = document.getTables().get(0).getRow(1); + + assertThat((long) spanned.getHeight()).isEqualTo(line(spanned)); + } + } + + @Test + void aRowRuledCellByCellIsHeldLessWhatItsMostDemandingCellTakes() throws Exception { + // Cells ruled 1.5pt and 0.5pt over cells ruled the other way round: each gives 20 twips of + // rules and keeps 250 of margins, its other 10 of padding moved into its paragraph. The + // row is held less 270, the most any one cell takes, not the 280 the heavier cell's rule + // counts and the 10 more the lighter one holds beyond its own, taken from different cells. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(7)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0.5)) + .build()) + .row("Row", "Row") + .rowCells(ruled("Row", 1.5), ruled("Row", 0.5)) + .rowCells(ruled("Row", 0.5), ruled("Row", 1.5)) + .row("Row", "Row")))) { + XWPFTableRow row = document.getTables().get(0).getRow(1); + + assertThat((long) row.getHeight()).as("its line and the padding in its paragraphs").isEqualTo(line(row) + 10); + } + } + + private static com.demcha.compose.document.table.DocumentTableCell ruled(String text, double rule) { + return com.demcha.compose.document.table.DocumentTableCell.text(text) + .withStyle(DocumentTableStyle.builder().stroke(DocumentStroke.of(DocumentColor.BLACK, rule)).build()); + } + @Test void aRowItsRulesFillIsStillHeldAtATwip() throws Exception { // A one-row table no taller than its two 1pt rules: the height left for its content is