From 16b40cc58edf1481f0d0cf49430a074788604a4d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 18 Oct 2026 23:26:15 +0100
Subject: [PATCH 1/2] fix(docx): name a table its rules stand taller in Word
Word makes room in a table's rows for its horizontal rules, half of a
rule between two rows to each and the rules above and below the table
whole to their row, where the page draws them over the cells' edges.
The export takes that room off the cell's padding, and where the
padding is thinner than its share, the row stood taller in Word and the
report said nothing. Measured in Word and LibreOffice on Windows, four
rows of text with no padding and 3pt rules set the paragraph under the
table 15pt lower than under the same table unruled.
Word sets each cell at its content and, on each side, its padding or
its share of the rules where that is more, and the row at its tallest
cell, which DocxRuleRoom counts row by row against the page's height:
a short cell beside a taller one that sets its row adds nothing, as
measured, and a header row repeated on every page counts once a page.
The report names the table (cell rule) with about how much taller, or
with the most it can be where the layout does not tell a row's height
or measure its text. Cells built from a node, cells spanning rows, and
empty cells in rows held at the page's height are not counted: in the
corpus's invoices and rota, whose rows are built from nodes or hold
empty cells, the rows stood within half a point of the page's in Word.
---
CHANGELOG.md | 13 +
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../backend/semantic/docx/DocxRuleRoom.java | 103 ++++++++
.../semantic/docx/DocxSemanticBackend.java | 85 ++++++-
.../semantic/docx/DocxCellRuleRoomTest.java | 226 ++++++++++++++++++
.../docx/DocxNodeFieldLedgerTest.java | 5 +-
.../semantic/docx/DocxRuleRoomTest.java | 72 ++++++
8 files changed, 500 insertions(+), 8 deletions(-)
create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoom.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoomTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 289fed0c8..47d2d061b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export names a table its rules stand taller in Word.** Word makes room for a table's
+ horizontal rules in their rows, and the page draws them over the cells' edges; the export takes
+ that room off a cell's padding, and where the padding is thinner than its share of the rules,
+ the row stood taller in Word with nothing said: measured in Word and LibreOffice on Windows,
+ four rows of text with no padding and 3pt rules set the paragraph under the table 15pt lower
+ than under the same table unruled. The report now names such a table (`cell rule`) with about
+ how much taller — counting each text cell on its own and a row at its tallest cell, as both
+ editors do, so a short cell beside a taller one that sets its row adds nothing, and a header
+ row repeated on every page once a page — or with the most it can be where the layout does not
+ tell a row's height or measure its text. Cells built from a node, cells spanning rows, and
+ empty cells where the row is held at the page's height are not counted: in the corpus's
+ invoices and rota whose rows are built from nodes or hold empty cells, the rows stood within
+ half a point of the page's in Word.
- **A DOCX export writes a chart in a table cell as its data table, and draws nothing of it over
that table.** Composed in a cell, a chart was written as its data table and drawn as well: its
bars and grid lines stood over the table in Word and LibreOffice on Windows and hid its numbers
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index d13231603..e1fa1350b 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -83,7 +83,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, its columns the layout's widths — where one is `auto` and the layout does not show them, composed in a table cell or with a column edge no row shows, the editor's, and reported (`TableNode`): `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 (one outside Word's range reported, `border width`), 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, and less, from a tenth of a point, by the height of its lines, each written shorter and the space after it taller, or the other way as far as that space holds, which keeps the cell's content as tall — where every paragraph of the cell is one line, in a face whose letters the export reads, and its letters stay clear of the line's top and foot; 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, its columns the layout's widths — where one is `auto` and the layout does not show them, composed in a table cell or with a column edge no row shows, the editor's, and reported (`TableNode`): `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row) — a row where a cell of text its padding then leaves short of that room needs more than the page's height stands taller in Word, reported (`cell rule`) — each as wide as Word draws it, in eighths of a point (one outside Word's range reported, `border width`), 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, and less, from a tenth of a point, by the height of its lines, each written shorter and the space after it taller, or the other way as far as that space holds, which keeps the cell's content as tall — where every paragraph of the cell is one line, in a face whose letters the export reads, and its letters stay clear of the line's top and foot; 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 2d24ad852..0093f5a7e 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 character the page draws nothing for — a control such as a tab or a bell, a soft hyphen, a zero-width space, one of private use — is not written either, where the file held a question mark for most controls, the direction marks and joiners kept; a margin below zero on a paragraph, a list or a block round them sets its text out past that side in the flow as far as the page does — in a table cell, a panel's included, as far as the cell's text and no further, and the report names what is held back; a row's first child the page sets out past the row's left edge starts at it, named; 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 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. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). 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`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `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). Padding thinner than its share gives what it has; Word sets each cell at its content and, on each side, its padding or its share where that is more, and the row at its tallest cell, so a row stands taller where a cell of text then needs more than the page's height — by that much, which the report names, added up for the table (`cell rule`; measured in Word and LibreOffice on Windows against the same table unruled, to a tenth of a point: four rows with no padding, 3pt rules 15pt and 1pt rules 5pt; a two-line cell with no padding beside a padded one-line cell, each counted on its own). Cells built from a node, cells spanning rows and empty cells in rows held at the page's height are not counted; where the layout does not tell a row's height — a table composed in a cell, or no layout — the report names the most it can be. 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. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). 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`) — but the row of a one-row table of paragraphs kept with what follows (see Sections / containers) — `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. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size. 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 |
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoom.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoom.java
new file mode 100644
index 000000000..f035e3b21
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoom.java
@@ -0,0 +1,103 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.layout.TableGrid;
+import com.demcha.compose.document.style.DocumentInsets;
+
+import java.util.OptionalDouble;
+
+/**
+ * How much taller Word sets a table than the page for the room it makes in its rows for the
+ * table's horizontal rules, row by row.
+ *
+ * The page draws a cell's rules over its edge; Word makes room for them in the row, and the
+ * export takes that room off the cell's padding. Word sets each cell at its content and, on each
+ * side, its padding or its share of the rules there where that is more, and the row at its
+ * tallest cell: a row stands taller by what that comes to past the page's height — not by a short
+ * cell's padding falling short where a taller cell sets the row. Measured in Word and LibreOffice
+ * on Windows, to a tenth of a point (see {@code DocxSemanticBackend.nameTheRoomForTheRules}).
+ *
+ * A cell of text that shows is counted where the layout tells its row's height on the page;
+ * a blank one there is not, its line being cut to the room its row leaves it. Where it does not
+ * tell — a table composed in a cell, or no layout, whose rows are not held — or does not measure a
+ * counted cell, the row is counted at the most any of its text cells' padding falls short of
+ * taking, blank ones included: no more than that. A cell built from a node, and one spanning rows,
+ * is not counted. A header row Word repeats on every page counts on each.
+ *
+ * @author Artem Demchyshyn
+ */
+final class DocxRuleRoom {
+
+ private final double[] tallestInWord;
+ private final double[] mostNotTaken;
+ private final double[] mostNotTakenAny;
+ private final boolean[] unmeasured;
+
+ DocxRuleRoom(int rows) {
+ tallestInWord = new double[rows];
+ mostNotTaken = new double[rows];
+ mostNotTakenAny = new double[rows];
+ unmeasured = new boolean[rows];
+ }
+
+ /**
+ * Counts one text cell of a row.
+ *
+ * @param row the row's index
+ * @param placement the cell on the table's grid
+ * @param padding its padding on the page
+ * @param round what Word holds round its content, its rules' shares included, in points
+ * @param textHeight its lines' height on the page, or {@code NaN} where the layout does not tell
+ */
+ void count(int row, TableGrid.Placement placement, DocumentInsets padding, double round, double textHeight) {
+ double shortfall = round - Math.max(0, padding.top()) - Math.max(0, padding.bottom());
+ mostNotTakenAny[row] = Math.max(mostNotTakenAny[row], shortfall);
+ if (!showsText(placement)) {
+ return;
+ }
+ mostNotTaken[row] = Math.max(mostNotTaken[row], shortfall);
+ if (Double.isNaN(textHeight)) {
+ unmeasured[row] = true;
+ } else {
+ tallestInWord[row] = Math.max(tallestInWord[row], textHeight + round);
+ }
+ }
+
+ /**
+ * How much taller Word sets the table, and whether that is only the most it can be.
+ *
+ * @param pageHeight the page's height of each row, empty where the layout does not tell
+ * @param times how many times the page places each row: a repeated header's once a page
+ */
+ Taller taller(java.util.function.IntFunction pageHeight,
+ java.util.function.IntUnaryOperator times) {
+ double taller = 0;
+ boolean atMost = false;
+ for (int row = 0; row < tallestInWord.length; row++) {
+ OptionalDouble page = pageHeight.apply(row);
+ double rowTaller;
+ if (page.isPresent() && !unmeasured[row]) {
+ rowTaller = Math.max(0, tallestInWord[row] - page.getAsDouble());
+ } else {
+ rowTaller = Math.max(0, page.isPresent() ? mostNotTaken[row] : mostNotTakenAny[row]);
+ atMost |= rowTaller > 0;
+ }
+ taller += rowTaller * Math.max(1, times.applyAsInt(row));
+ }
+ return new Taller(taller, atMost);
+ }
+
+ /** Whether a cell's text shows anything: one that is empty is written as an empty paragraph. */
+ static boolean showsText(TableGrid.Placement placement) {
+ return placement.cell() != null && placement.cell().content() == null
+ && placement.cell().lines().stream().anyMatch(line -> line != null && !line.isEmpty());
+ }
+
+ /**
+ * How much taller Word sets a table.
+ *
+ * @param points the height, in points
+ * @param atMost whether a row of it is counted at the most it can be
+ */
+ record Taller(double points, boolean atMost) {
+ }
+}
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 40326cbd7..68e9e4b32 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
@@ -14400,6 +14400,8 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
boolean translucentFills = false;
boolean translucentRules = false;
java.util.Set ruleWidths = new java.util.TreeSet<>();
+ // What Word sets the table taller by, for the room it makes in its rows for the rules.
+ DocxRuleRoom ruleRoom = new DocxRuleRoom(rowCount);
for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) {
XWPFTableRow row = table.getRow(rowIdx);
List physical = new ArrayList<>();
@@ -14437,8 +14439,13 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
applyCellPaint(cell, fill, DocxTranslucency.flattenedStroke(stroke, fill != null ? fill.color() : under));
int next = placement.row() + placement.rowSpan();
DocumentStroke underneath = next < rowCount ? resolveCellStroke(node, cover[next][placement.column()]) : null;
- applyCellPadding(cell, clearOfTheRules(resolveCellPadding(node, placement), stroke,
+ DocumentInsets cellPadding = resolveCellPadding(node, placement);
+ applyCellPadding(cell, clearOfTheRules(cellPadding, stroke,
placement.row() == 0, next >= rowCount, underneath));
+ if (placement.rowSpan() == 1 && placement.cell() != null && placement.cell().content() == null) {
+ ruleRoom.count(rowIdx, placement, cellPadding, roundTheContent(cellPadding, stroke,
+ placement.row() == 0, next >= rowCount, underneath), textHeight(node, placement));
+ }
applyVerticalAnchor(cell, resolveCellAnchor(node, placement));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
@@ -14475,6 +14482,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
carryDrawingsInRows(table, node);
reportFlattenedPaint(node, translucentFills, translucentRules, "its cells' fills", "its cells' rules");
reportBorderWidths(node, "its cells' rules", true, ruleWidths);
+ nameTheRoomForTheRules(node, ruleRoom);
}
/**
@@ -14698,7 +14706,8 @@ private static void applyCellPadding(XWPFTableCell cell, DocumentInsets padding)
* its margin, a rule of 15 twips came off as 7 and 7, and every row of
* {@code NorthlineProposal}'s investment table stood a twip taller. The sides
* keep their padding, as the columns' widths are fixed. Padding thinner than its share gives
- * what it has, and the row stands that much taller.
+ * what it has, and the row stands taller where that cell then needs more than the page's
+ * height (named by {@link #nameTheRoomForTheRules}).
*
* @param padding the cell's padding on the page
* @param stroke the cell's own rule
@@ -14709,13 +14718,79 @@ private static void applyCellPadding(XWPFTableCell cell, DocumentInsets padding)
*/
private static DocumentInsets clearOfTheRules(DocumentInsets padding, DocumentStroke stroke,
boolean firstRow, boolean lastRow, DocumentStroke underneath) {
- long rule = ruleTwips(stroke);
- long above = firstRow ? rule : rule - rule / 2;
- long below = lastRow ? rule : ruleTwips(underneath) / 2;
+ long above = ruleShareAbove(stroke, firstRow);
+ long below = ruleShareBelow(lastRow, underneath, stroke);
return new DocumentInsets(Math.max(0, padding.top() - above / POINT_TO_TWIP), padding.right(),
Math.max(0, padding.bottom() - below / POINT_TO_TWIP), padding.left());
}
+ /** The twips of rules Word makes room for above a cell's text: its own rule, whole on the first row. */
+ private static long ruleShareAbove(DocumentStroke stroke, boolean firstRow) {
+ long rule = ruleTwips(stroke);
+ return firstRow ? rule : rule - rule / 2;
+ }
+
+ /** The twips below it: its own rule on the last row, else half of the rule of the cell under it. */
+ private static long ruleShareBelow(boolean lastRow, DocumentStroke underneath, DocumentStroke stroke) {
+ return lastRow ? ruleTwips(stroke) : ruleTwips(underneath) / 2;
+ }
+
+ /**
+ * What Word holds of a cell's row above and below its content, in points: on each side its
+ * padding, or its share of the rules there where that is more — the margin written is what
+ * the padding has past the share, and Word makes room for the share as well
+ * ({@link #clearOfTheRules}).
+ */
+ private static double roundTheContent(DocumentInsets padding, DocumentStroke stroke,
+ boolean firstRow, boolean lastRow, DocumentStroke underneath) {
+ return Math.max(Math.max(0, padding.top()), ruleShareAbove(stroke, firstRow) / POINT_TO_TWIP)
+ + Math.max(Math.max(0, padding.bottom()), ruleShareBelow(lastRow, underneath, stroke) / POINT_TO_TWIP);
+ }
+
+ /**
+ * How tall a text cell's lines stand on the page, in points, as the layout measured them —
+ * {@code NaN} where it does not tell.
+ */
+ private double textHeight(TableNode node, TableGrid.Placement placement) {
+ TableResolvedCell measured = layout.measuredCell(node, placement.row(), placement.column());
+ if (measured == null) {
+ return Double.NaN;
+ }
+ int lines = Math.max(1, measured.lines().size());
+ Double spacing = measured.style() == null ? null : measured.style().lineSpacing();
+ return lines * measured.lineHeight() + (lines - 1) * (spacing == null ? 0 : spacing);
+ }
+
+ /**
+ * Names a table Word sets taller than the page for the room it makes in its rows for the
+ * table's rules, which the page draws over the cells' edges ({@link DocxRuleRoom}): about how
+ * much, or the most it can be where the layout does not tell a row's height or measure its
+ * text. A header row Word repeats counts once for each page the layout places it on.
+ *
+ * Measured in Word and LibreOffice on Windows, the paragraph under the table that much lower
+ * than under the same table unruled, to a tenth of a point: four rows of text with no padding,
+ * 3pt rules 15pt and 1pt rules 5pt; with a point of padding, 3pt rules 7pt; with 3pt of padding,
+ * none. Three rows of 2pt rules, a two-line cell with no padding beside a one-line cell padded
+ * 2pt, 8pt — each cell counted on its own, where the most any cell holds round its content
+ * beside the tallest content would come to 12 — and with the two-line cell the padded one,
+ * none. Two rows of 2pt rules padded 5pt above and none below, 3pt: padding past a share holds
+ * its side alone. In the invoices and the rota whose rows are built from nodes or hold blank
+ * cells, ruled past their padding, the rows stood within half a point of the page's in Word,
+ * where counting those cells said 2 to 4pt: they are not counted. A row whose margins are evened
+ * to one a cell keeps ({@link #evenTheRowsMargins}) is counted as though each kept its own,
+ * which is not measured.
+ */
+ private void nameTheRoomForTheRules(TableNode node, DocxRuleRoom room) {
+ DocxRuleRoom.Taller taller = room.taller(row -> layout.rowHeight(node, row),
+ row -> row < node.repeatedHeaderRowCount() ? layout.cellBoxes(node, row, 0).size() : 1);
+ if (taller.points() >= 0.1) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, "cell rule", layout.pathOf(node),
+ "its cells' rules are wider than the padding that takes them: Word makes room for each in "
+ + "its row, where the page draws it over the cell's edge, and sets the table "
+ + (taller.atMost() ? "up to " : "about ") + pointsOf(taller.points()) + "pt taller than the page");
+ }
+ }
+
/**
* The rule a cell resolves to, most specific wins, with the engine's own default underneath:
* a table that states no rule is drawn with it on the page, and the table's own grid Word
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
new file mode 100644
index 000000000..8bf624076
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
@@ -0,0 +1,226 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.TableBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A table whose cells' rules are wider than the padding that takes them stands taller in Word,
+ * which makes room for each rule in its row, than on the page, which draws it over the cell's
+ * edge, where a cell of text then needs more than the page's height — and the report says about
+ * how much, or the most it can be where the layout does not tell its rows' heights.
+ *
+ * The "about" figures of four rows with no padding, of a point of padding, of padding past a
+ * share and of a two-line cell beside a padded one are the measured ones: the paragraph under
+ * the table that much lower in Word and LibreOffice on Windows than under the same table unruled,
+ * to a tenth of a point. The others are what the same count comes to.
+ */
+class DocxCellRuleRoomTest {
+
+ private static final String SUBJECT = "cell rule";
+
+ @Test
+ void rulesNoPaddingTakesStandTheTableTallerByWhatEachRowGives() throws Exception {
+ // The first row gives its own rule above and half the next row's below, the last row half
+ // its own above and its own below, a row between half of each: 3pt rules, 4.5 + 3 + 3 + 4.5.
+ assertThat(fourRows(3, 0)).containsExactly(note("about 15"));
+ assertThat(fourRows(1, 0)).containsExactly(note("about 5"));
+ // A point of padding takes a point of each share.
+ assertThat(fourRows(3, 1)).containsExactly(note("about 7"));
+ // Padding past a share holds the row by itself: 5pt above takes the rules above, nothing
+ // below, so 2pt rules over two rows stand them 1 and 2pt taller.
+ DocumentTableStyle above = DocumentTableStyle.builder().padding(new DocumentInsets(5, 0, 0, 0))
+ .stroke(DocumentStroke.of(DocumentColor.BLACK, 2)).build();
+ assertThat(details(page -> page.addTable(t -> t.defaultCellStyle(above)
+ .columns(DocumentTableColumn.fixed(150)).row("Alpha").row("Beta"))))
+ .containsExactly(note("about 3"));
+ }
+
+ @Test
+ void aSmallRoomIsNamedToo() throws Exception {
+ // A quarter-point rule is five twips: 5 + 2 above and below the first row, 3 + 5 the last.
+ DocumentTableStyle hairline = DocumentTableStyle.builder().padding(DocumentInsets.zero())
+ .stroke(DocumentStroke.of(DocumentColor.BLACK, 0.25)).build();
+ assertThat(details(page -> page.addTable(t -> t.defaultCellStyle(hairline)
+ .columns(DocumentTableColumn.fixed(150)).row("Alpha").row("Beta"))))
+ .containsExactly(note("about 0.75"));
+ }
+
+ @Test
+ void paddingThatTakesTheRulesOrNoRulesAreNotNamed() throws Exception {
+ assertThat(fourRows(3, 3)).isEmpty();
+ assertThat(fourRows(0, 0)).as("a stroke of no width is no rule").isEmpty();
+ assertThat(details(page -> page.addTable(t -> t.columns(DocumentTableColumn.fixed(150))
+ .row("Alpha").row("Beta")))).as("the engine's own rule and padding").isEmpty();
+ }
+
+ @Test
+ void aRowStandsTallerOnlyWhereTheCellItsPaddingFallsShortInSetsItsHeight() throws Exception {
+ // 2pt rules over three rows: a cell with no padding holds 2 + 1, 1 + 1 and 1 + 2 round its
+ // text, each cell counted on its own — the padded cell's 4 beside the bare cell's two lines
+ // would make it 12 (measured: 8).
+ DocumentTableStyle bare = style(2, 0);
+ DocumentTableStyle padded = style(2, 2);
+ DocumentTableCell tall = DocumentTableCell.lines("Alpha", "Beta").withStyle(bare);
+ DocumentTableCell beside = DocumentTableCell.text("One").withStyle(padded);
+ assertThat(details(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(tall, beside).rowCells(tall, beside).rowCells(tall, beside))))
+ .as("the bare cell's two lines set each row's height").containsExactly(note("about 8"));
+ assertThat(details(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.text("Alpha").withStyle(bare),
+ DocumentTableCell.lines("One", "Two").withStyle(padded)))))
+ .as("the padded cell's two lines set it, and the bare cell's line has room in it").isEmpty();
+ assertThat(details(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.text("Alpha").withStyle(bare),
+ DocumentTableCell.text("One").withStyle(padded)))))
+ .as("one line each: the padded cell sets the row at the height the bare one needs").isEmpty();
+ }
+
+ @Test
+ void aCellsLineSpacingIsPartOfItsHeight() throws Exception {
+ // Two lines 6pt apart with no padding against two lines padded 2pt: the spaced cell sets the
+ // row, and its 2pt rules, both whole to the one row, stand it 4pt taller.
+ DocumentTableStyle spaced = DocumentTableStyle.builder().padding(DocumentInsets.zero()).lineSpacing(6.0)
+ .stroke(DocumentStroke.of(DocumentColor.BLACK, 2)).build();
+ assertThat(details(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.lines("Alpha", "Beta").withStyle(spaced),
+ DocumentTableCell.lines("One", "Two").withStyle(style(2, 2))))))
+ .containsExactly(note("about 4"));
+ }
+
+ @Test
+ void aHeavierHeaderGivesItsOwnRuleAboveAndHalfTheRowsBelow() throws Exception {
+ // 2pt over 0.5pt rules: 2 + 0.25 the header, 0.25 + 0.25 a row between, 0.25 + 0.5 the last.
+ assertThat(details(page -> page.addTable(t -> t.defaultCellStyle(style(0.5, 0))
+ .columns(DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.text("Head").withStyle(style(2, 0)))
+ .row("Alpha").row("Beta"))))
+ .containsExactly(note("about 3.5"));
+ }
+
+ @Test
+ void aHeaderRowRepeatedOnEveryPageCountsOnEach() throws Exception {
+ double once = points(details(table(false)));
+ double repeated = points(details(table(true)));
+ // 1pt rules: the header row gives 1 + 0.5 on each page it is drawn on.
+ assertThat(repeated - once).as("the header's room again on each further page")
+ .isGreaterThanOrEqualTo(1.5).satisfies(more -> assertThat(more % 1.5).isCloseTo(0, within()));
+ }
+
+ @Test
+ void aCellBuiltFromANodeOrEmptyIsNotCountedAndOneOfASpaceIs() throws Exception {
+ // Ruled past their padding as the corpus's invoices and rota are, whose rows stood within
+ // half a point of the page's in Word; an empty cell's line is cut to its row's room.
+ DocumentTableStyle bare = style(3, 0);
+ assertThat(details(page -> page.addTable(t -> t.defaultCellStyle(bare)
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Alpha").build()),
+ DocumentTableCell.text(""))
+ .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Beta").build()),
+ DocumentTableCell.text(""))))).isEmpty();
+ // A space is written as a run, whose line keeps its height.
+ assertThat(details(page -> page.addTable(t -> t.defaultCellStyle(bare)
+ .columns(DocumentTableColumn.fixed(150)).row(" ").row(" ")))).containsExactly(note("about 9"));
+ }
+
+ @Test
+ void aCellSpanningRowsIsNotCounted() throws Exception {
+ // Its three lines stand across both rows; the cells beside it take their rules in their padding.
+ assertThat(details(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.lines("A", "B", "C").withStyle(style(3, 0)).rowSpan(2),
+ DocumentTableCell.text("One").withStyle(style(3, 3)))
+ .rowCells(DocumentTableCell.text("Two").withStyle(style(3, 3)))))).isEmpty();
+ }
+
+ @Test
+ void aTableComposedInACellOrWithNoLayoutIsNamedAtTheMostItCanBe() throws Exception {
+ List composed = DocxExports.report(500, 400, 30, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(320))
+ .rowCells(DocumentTableCell.node(new TableBuilder().defaultCellStyle(style(3, 0))
+ .columns(DocumentTableColumn.fixed(150)).row("Alpha").row("").build()))))
+ .bySubject().getOrDefault(SUBJECT, List.of());
+ assertThat(composed).singleElement().satisfies(note -> {
+ assertThat(note.detail()).as("its rows not held, an empty cell's line keeps its height: 4.5 + 4.5")
+ .isEqualTo(note("up to 9"));
+ assertThat(note.path()).as("composed content has no path of its own").isNull();
+ });
+ assertThat(DocxExports.reportWithoutLayout(500, 400, 30, rows(3, 0)).bySubject().get(SUBJECT))
+ .as("what the padding does not take needs no layout to tell").extracting(DocxExportReport.Note::detail)
+ .containsExactly(note("up to 15"));
+ assertThat(DocxExports.report(500, 400, 30, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(320))
+ .rowCells(DocumentTableCell.node(new TableBuilder().defaultCellStyle(style(3, 0))
+ .columns(DocumentTableColumn.fixed(150))
+ .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Alpha").build()))
+ .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Beta").build())).build()))))
+ .bySubject()).as("a cell built from a node is not counted in the bound either")
+ .doesNotContainKey(SUBJECT);
+ }
+
+ private static String note(String howMuch) {
+ return "its cells' rules are wider than the padding that takes them: Word makes room for each in its row, "
+ + "where the page draws it over the cell's edge, and sets the table " + howMuch
+ + "pt taller than the page";
+ }
+
+ private static double points(List notes) {
+ assertThat(notes).hasSize(1);
+ String about = notes.get(0).replaceAll(".*sets the table about ([0-9.]+)pt.*", "$1");
+ return Double.parseDouble(about);
+ }
+
+ private static org.assertj.core.data.Offset within() {
+ return org.assertj.core.data.Offset.offset(1e-6);
+ }
+
+ /** Forty rows of 1pt rules and no padding, over pages, its first row repeated or not. */
+ private static Consumer table(boolean repeated) {
+ return page -> page.addTable(t -> {
+ t.defaultCellStyle(style(1, 0)).columns(DocumentTableColumn.fixed(150));
+ if (repeated) {
+ t.repeatHeader(1);
+ }
+ for (int row = 0; row < 40; row++) {
+ t.row("Row " + row);
+ }
+ });
+ }
+
+ private static List fourRows(double rule, double padding) throws Exception {
+ return details(rows(rule, padding));
+ }
+
+ private static Consumer rows(double rule, double padding) {
+ return page -> page.addTable(t -> t.defaultCellStyle(style(rule, padding))
+ .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150))
+ .row("Alpha", "One").row("Beta", "Two").row("Gamma", "Three").row("Delta", "Four"));
+ }
+
+ private static DocumentTableStyle style(double rule, double padding) {
+ return DocumentTableStyle.builder().padding(DocumentInsets.of(padding))
+ .stroke(DocumentStroke.of(DocumentColor.BLACK, rule)).build();
+ }
+
+ private static List details(Consumer content) throws Exception {
+ DocxExportReport report = DocxExports.report(500, 400, 30, content);
+ assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry");
+ return report.bySubject().getOrDefault(SUBJECT, List.of()).stream().map(DocxExportReport.Note::detail).toList();
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index 85393cd15..7755e82d7 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -115,7 +115,10 @@ private record Entry(Fate fate, String note) {
private static final String RULE_WIDTH = "as a rule, " + BORDER_WIDTH + "; any other is written";
private static final String CELL_TRANSLUCENCY = "the translucency of a cell's fill, flattened against the colour "
+ "under the cell, and of its rules, against its fill where it has "
- + "one; of its rules, " + BORDER_WIDTH + "; any other is written";
+ + "one; of its rules, " + BORDER_WIDTH + ", and the room Word "
+ + "makes for them in a row where a text cell its padding leaves "
+ + "short of it then needs more than the page's height, the row "
+ + "taller; any other is written";
// A row in the flow is written as a panel holding its columns, its paint the panel's.
private static final String ROW_PAINT = "composed in a table cell, drawn as a shape where it frames no text "
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoomTest.java
new file mode 100644
index 000000000..34394fab9
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleRoomTest.java
@@ -0,0 +1,72 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.layout.TableGrid;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.table.DocumentTableCell;
+import org.junit.jupiter.api.Test;
+
+import java.util.OptionalDouble;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Row by row: a row the layout tells the height of and measures the text of counts what its
+ * tallest cell comes to past that height; any other counts the most it can be, and only that row.
+ */
+class DocxRuleRoomTest {
+
+ private static final TableGrid.Placement TEXT = new TableGrid.Placement(0, 0, 1, 1,
+ DocumentTableCell.text("Alpha"));
+ private static final TableGrid.Placement EMPTY = new TableGrid.Placement(0, 1, 1, 1,
+ DocumentTableCell.text(""));
+ private static final DocumentInsets NONE = DocumentInsets.zero();
+
+ @Test
+ void aMeasuredRowCountsItsTallestCellPastThePagesHeight() {
+ DocxRuleRoom room = new DocxRuleRoom(1);
+ room.count(0, TEXT, NONE, 4.5, 12);
+ DocxRuleRoom.Taller taller = room.taller(row -> OptionalDouble.of(12), row -> 1);
+ assertThat(taller.points()).isCloseTo(4.5, within(1e-9));
+ assertThat(taller.atMost()).isFalse();
+ }
+
+ @Test
+ void aRowTheLayoutDoesNotTellCountsTheMostItCanBeAndOnlyThatRow() {
+ DocxRuleRoom room = new DocxRuleRoom(2);
+ room.count(0, TEXT, NONE, 4.5, 12);
+ room.count(1, TEXT, NONE, 3, 12);
+ // The first row told and measured, padded so its cell needs no more than the page gives it;
+ // the second not told.
+ DocxRuleRoom.Taller taller = room.taller(row -> row == 0 ? OptionalDouble.of(16.5) : OptionalDouble.empty(),
+ row -> 1);
+ assertThat(taller.points()).as("nothing for the first, the most for the second").isCloseTo(3, within(1e-9));
+ assertThat(taller.atMost()).isTrue();
+ }
+
+ @Test
+ void aRowWhoseTextIsNotMeasuredCountsTheMostItCanBe() {
+ DocxRuleRoom room = new DocxRuleRoom(1);
+ room.count(0, TEXT, new DocumentInsets(1, 0, 1, 0), 4.5, Double.NaN);
+ DocxRuleRoom.Taller taller = room.taller(row -> OptionalDouble.of(14), row -> 1);
+ assertThat(taller.points()).isCloseTo(2.5, within(1e-9));
+ assertThat(taller.atMost()).isTrue();
+ }
+
+ @Test
+ void anEmptyCellCountsOnlyWhereItsRowIsNotHeld() {
+ DocxRuleRoom room = new DocxRuleRoom(1);
+ room.count(0, EMPTY, NONE, 4.5, 12);
+ assertThat(room.taller(row -> OptionalDouble.of(12), row -> 1).points())
+ .as("held at the page's height, its line is cut to the row's room").isZero();
+ assertThat(room.taller(row -> OptionalDouble.empty(), row -> 1).points())
+ .as("not held, its line keeps its height").isCloseTo(4.5, within(1e-9));
+ }
+
+ @Test
+ void aRowPlacedOnSeveralPagesCountsOnEach() {
+ DocxRuleRoom room = new DocxRuleRoom(1);
+ room.count(0, TEXT, NONE, 1.5, 12);
+ assertThat(room.taller(row -> OptionalDouble.of(12), row -> 3).points()).isCloseTo(4.5, within(1e-9));
+ }
+}
From ef1be811090a8757fb45dd657a1dde3a5b8a4e94 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 18 Oct 2026 23:36:58 +0100
Subject: [PATCH 2/2] test(docx): read the rule room's figure off a note it
must name
The test reading how much taller a note names parsed whatever was left
of the note when the figure was not in it; it now finds the figure, and
fails naming the note where there is none. The composed table's cases
assert the layout stood behind the export, as the others do. The note's
Javadoc says empty cells, which is what is not counted, and two long
lines are wrapped.
---
.../semantic/docx/DocxSemanticBackend.java | 10 ++++---
.../semantic/docx/DocxCellRuleRoomTest.java | 26 ++++++++++++++-----
2 files changed, 25 insertions(+), 11 deletions(-)
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 68e9e4b32..cfe329b55 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
@@ -14743,8 +14743,9 @@ private static long ruleShareBelow(boolean lastRow, DocumentStroke underneath, D
*/
private static double roundTheContent(DocumentInsets padding, DocumentStroke stroke,
boolean firstRow, boolean lastRow, DocumentStroke underneath) {
- return Math.max(Math.max(0, padding.top()), ruleShareAbove(stroke, firstRow) / POINT_TO_TWIP)
- + Math.max(Math.max(0, padding.bottom()), ruleShareBelow(lastRow, underneath, stroke) / POINT_TO_TWIP);
+ double above = ruleShareAbove(stroke, firstRow) / POINT_TO_TWIP;
+ double below = ruleShareBelow(lastRow, underneath, stroke) / POINT_TO_TWIP;
+ return Math.max(Math.max(0, padding.top()), above) + Math.max(Math.max(0, padding.bottom()), below);
}
/**
@@ -14774,7 +14775,7 @@ private double textHeight(TableNode node, TableGrid.Placement placement) {
* 2pt, 8pt — each cell counted on its own, where the most any cell holds round its content
* beside the tallest content would come to 12 — and with the two-line cell the padded one,
* none. Two rows of 2pt rules padded 5pt above and none below, 3pt: padding past a share holds
- * its side alone. In the invoices and the rota whose rows are built from nodes or hold blank
+ * its side alone. In the invoices and the rota whose rows are built from nodes or hold empty
* cells, ruled past their padding, the rows stood within half a point of the page's in Word,
* where counting those cells said 2 to 4pt: they are not counted. A row whose margins are evened
* to one a cell keeps ({@link #evenTheRowsMargins}) is counted as though each kept its own,
@@ -14787,7 +14788,8 @@ private void nameTheRoomForTheRules(TableNode node, DocxRuleRoom room) {
report.add(DocxExportReport.Severity.APPROXIMATED, "cell rule", layout.pathOf(node),
"its cells' rules are wider than the padding that takes them: Word makes room for each in "
+ "its row, where the page draws it over the cell's edge, and sets the table "
- + (taller.atMost() ? "up to " : "about ") + pointsOf(taller.points()) + "pt taller than the page");
+ + (taller.atMost() ? "up to " : "about ") + pointsOf(taller.points())
+ + "pt taller than the page");
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
index 8bf624076..bc3e9c9f6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java
@@ -151,7 +151,8 @@ void aCellSpanningRowsIsNotCounted() throws Exception {
@Test
void aTableComposedInACellOrWithNoLayoutIsNamedAtTheMostItCanBe() throws Exception {
- List composed = DocxExports.report(500, 400, 30, page -> page.addTable(t -> t
+ // The outer table laid out: what is not told is the composed table's rows alone.
+ List composed = laidOut(page -> page.addTable(t -> t
.columns(DocumentTableColumn.fixed(320))
.rowCells(DocumentTableCell.node(new TableBuilder().defaultCellStyle(style(3, 0))
.columns(DocumentTableColumn.fixed(150)).row("Alpha").row("").build()))))
@@ -164,7 +165,7 @@ void aTableComposedInACellOrWithNoLayoutIsNamedAtTheMostItCanBe() throws Excepti
assertThat(DocxExports.reportWithoutLayout(500, 400, 30, rows(3, 0)).bySubject().get(SUBJECT))
.as("what the padding does not take needs no layout to tell").extracting(DocxExportReport.Note::detail)
.containsExactly(note("up to 15"));
- assertThat(DocxExports.report(500, 400, 30, page -> page.addTable(t -> t
+ assertThat(laidOut(page -> page.addTable(t -> t
.columns(DocumentTableColumn.fixed(320))
.rowCells(DocumentTableCell.node(new TableBuilder().defaultCellStyle(style(3, 0))
.columns(DocumentTableColumn.fixed(150))
@@ -174,16 +175,28 @@ void aTableComposedInACellOrWithNoLayoutIsNamedAtTheMostItCanBe() throws Excepti
.doesNotContainKey(SUBJECT);
}
+ /** The report of an export the layout stands behind: a document it refuses is another case. */
+ private static DocxExportReport laidOut(Consumer content) throws Exception {
+ DocxExportReport report = DocxExports.report(500, 400, 30, content);
+ assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry");
+ return report;
+ }
+
private static String note(String howMuch) {
return "its cells' rules are wider than the padding that takes them: Word makes room for each in its row, "
+ "where the page draws it over the cell's edge, and sets the table " + howMuch
+ "pt taller than the page";
}
+ private static final java.util.regex.Pattern ABOUT =
+ java.util.regex.Pattern.compile("sets the table about (\\d+(?:\\.\\d+)?)pt taller");
+
+ /** The figure a single note names, the note failing the test where it names none. */
private static double points(List notes) {
assertThat(notes).hasSize(1);
- String about = notes.get(0).replaceAll(".*sets the table about ([0-9.]+)pt.*", "$1");
- return Double.parseDouble(about);
+ java.util.regex.Matcher about = ABOUT.matcher(notes.get(0));
+ assertThat(about.find()).as("a note naming about how much: %s", notes.get(0)).isTrue();
+ return Double.parseDouble(about.group(1));
}
private static org.assertj.core.data.Offset within() {
@@ -219,8 +232,7 @@ private static DocumentTableStyle style(double rule, double padding) {
}
private static List details(Consumer content) throws Exception {
- DocxExportReport report = DocxExports.report(500, 400, 30, content);
- assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry");
- return report.bySubject().getOrDefault(SUBJECT, List.of()).stream().map(DocxExportReport.Note::detail).toList();
+ return laidOut(content).bySubject().getOrDefault(SUBJECT, List.of()).stream()
+ .map(DocxExportReport.Note::detail).toList();
}
}