From 2e520891d98a3d3c0fbccf89ad14b958982a9e7b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 22 Oct 2026 21:10:00 +0100 Subject: [PATCH 1/2] fix(docx): hold an auto column's editor slack in its cells' margins - A table column sized to its content gives its text a point of slack so an editor's face keeps the page's line breaks. It widened the column, moving every rule and fill right of it, and the table's right end, a point right of the page's. It now comes out of the column's text cells' margins, on the side their text does not lean on, and the column keeps the page's width. - A cell spanning more than one such column shares its margin among them. A composed cell keeps its margins; what a column's cells do not hold widens it and is named (right end). - An indented table is indented by the margin its first cell keeps, which both editors measure the indent to. - Measured on Windows: Word stood the rules right of the auto column 0.94 to 1.06pt right of the page's, now within 0.12pt; LibreOffice 0.66 to 1.06pt, now with the tables' other rules. No line breaks change in either editor. - Tests: margins by alignment, direction, span, header and thin padding, a composed cell widening its column with the note, an indented table's tblInd; cell padding tests expect the right margin less the slack. --- CHANGELOG.md | 22 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 155 ++++++++++- .../semantic/docx/DocxCellPaddingTest.java | 41 +-- .../semantic/docx/DocxEditorSlackTest.java | 247 +++++++++++++++++- .../docx/DocxNodeFieldLedgerTest.java | 7 +- .../docx/DocxValueFieldLedgerTest.java | 17 +- 8 files changed, 443 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c39df341..df41ceafd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export stands a table's rules where the page draws them beside a column sized to its + content.** + - An auto column's text is given a point of slack past the page's width, so an editor setting it + in its own face keeps the page's line breaks. The slack widened the column, so every rule and + fill right of it, and the table's right end, stood a point right of the page's. + - It now comes out of the column's text cells' margins, on the side their text does not lean on: + the right where it is set left, the left where it is set right, half from each where it is + centred. The column keeps the page's width, its text the same room. A cell spanning more than + one such column shares its margin among them. + - A composed cell keeps its margins: a paragraph in it may run past its text into them, which + Word cuts off at the cell's edge. What a column's cells do not hold widens it, and the report + names it (`right end`): beside a composed cell, or where a cell's padding on the side it gives + from is thinner than the point (each side thinner than half of it, centred). + - Measured on Windows: + - Word stood Classic and Modern Invoice's and Modern Proposal's rules right of the auto column + 0.94 to 1.06pt right of the page's, and now within 0.12pt. LibreOffice stood them 0.66 to + 1.06pt right, and now with the tables' other rules. + - Every line breaks where it did in both editors. The text right of the column moves the + point back to where the page sets it. + - Orange Ops's aside headings, composed in a table with no padding, keep their accent rules + 1pt longer in Word, named. + - 5 of the corpus's 62 documents change. 4 tables gain the `right end` note. - **A DOCX export names a panel's left edge its table cell holds in.** - The page sets a panel in a table cell with its left border centred on the cell's text edge, half of it outside, or further out by a margin below zero. LibreOffice starts a nested table diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index bc073790f..20f2eccfa 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, the room it leaves in its box held round it as the page centres it — half above and below as its paragraph's space, half at its side as its indent — COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; a picture in the flow, a stack of one layer's included, that the layout moves to a new page keeps the space above it there, in a line kept with it, as a paragraph's is; 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) — 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) | +| 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; a column sized to its content gives its text the editor's point out of its text cells' side margins, on the side their text does not lean on, so the column keeps the page's width, and what its cells do not hold — a composed cell's, padding too thin — widens it, reported `APPROXIMATED` (`right end`)) | | 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 10363f1cc..a6048cc63 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 stack of one layer is in the flow: the paragraph opening one does the same, with the stack's own edges, though not the layer's offset in the stack, up or down, which is named and not written. So does the first paragraph of a band of layers the layout moves to a new page, in the flow, in a stack of one layer or in a section, a backdrop drawn before it or not: the band's own edges, its room above that paragraph and the edges of the containers opening with it (measured: a band with a 14pt margin, one with a backdrop laid first, one nested in a stack of one layer with that margin, and one in a section with a 10pt margin each stood that much high in Word and LibreOffice; held, each stands where the page sets it, within a point, the backdrop where the page draws it). A title and its dates an overlay sets as one line do the same, the room above the line inside the overlay held with its edges (measured: such a pair in a stack with a 14pt margin, in the flow or opening a band, stood 14pt high in Word and LibreOffice; held, within 0.1pt of a paragraph moved so); one whose text the page sets above its overlay's top is left as it was. A paragraph in a table cell or any other overlay is left to its container, and a list's items to their list (see Lists) | | 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. A list the layout moves to a new page — in the flow, a stack of one layer's included, or first in a band of layers opening its page — keeps the space the layout leaves above its first item there, its margin, its padding and the edges of the containers opening with it, as an empty line that tall before it, kept with it, as a body paragraph does; a wrapped first item takes the gap between its lines out of that line (measured: a list with a 14pt margin stood 14pt high in Word and LibreOffice, one padded 10pt or opening a section padded 10pt 10pt, one opening a stack of one layer or a band with a 14pt margin 14pt; held, each within 0.1pt of a paragraph moved so). 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). 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 in the flow (body paragraphs, spacers and pictures: see their rows). A table, a row or a panel opening a band of layers the layout moves to a new page holds the band's own top edge there with its own — the band's margin and the room it leaves above the block — while the gap before the band stays at the foot of the page above (measured: each, opening a band with a 14pt margin, stood 14pt high in Word and LibreOffice, the margin left on the page above; held, within 0.1pt of the same block moved to a new page in the flow). 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 gives its text a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell: out of its text cells' margins, on the side their text does not lean on — the right where it is set left, the left where it is set right, half from each where it is centred — so the column stays as wide as the page sets it and its rules stand where the page draws them (measured: widened instead, `ClassicInvoice`'s line items stood every rule right of the description column 0.98 to 1.05pt right of the page's in Word and 0.96 to 1.06pt in LibreOffice; held in the margins, within 0.12pt in Word and with the table's other rules in LibreOffice, every line breaking where it did in both). A cell spanning more than one such column shares its margin among them. A composed cell keeps its margins — a paragraph in it may run past its text into them, which Word would cut off at the cell's edge — and what a column's cells do not hold — beside a composed cell, or where a cell's padding on the side it gives from is thinner than the point (each side thinner than half of it, centred) — widens it, its right end and what stands right of it that much further right, named (`right end`; `OrangeOps`'s aside headings, their accent rules 1pt longer in Word). 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 in the flow (body paragraphs, spacers and pictures: see their rows). A table, a row or a panel opening a band of layers the layout moves to a new page holds the band's own top edge there with its own — the band's margin and the room it leaves above the block — while the gap before the band stays at the foot of the page above (measured: each, opening a band with a 14pt margin, stood 14pt high in Word and LibreOffice, the margin left on the page above; held, within 0.1pt of the same block moved to a new page in the flow). 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. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture in the flow, a stack of one layer's included, or first in a band of layers there, that the layout moves to a new page keeps the space the layout leaves above it there — its margin, its padding, the room above it in its box, the edges of the containers opening with it and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, as a body paragraph does. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | 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 8edf4634a..af816d707 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 @@ -15874,7 +15874,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except // cell is one cell carrying a span, not several, so a row's physical count is not // the column count. XWPFTable table = newTable(document, rowCount, 1); - applyTableWidth(table, node, columnCount); + double[] slackHeld = applyTableWidth(table, node, columnCount, cover); boolean translucentFills = false; boolean translucentRules = false; java.util.Set ruleWidths = new java.util.TreeSet<>(); @@ -15918,8 +15918,8 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except int next = placement.row() + placement.rowSpan(); DocumentStroke underneath = next < rowCount ? resolveCellStroke(node, cover[next][placement.column()]) : null; DocumentInsets cellPadding = resolveCellPadding(node, placement); - applyCellPadding(cell, clearOfTheRules(cellPadding, stroke, - placement.row() == 0, next >= rowCount, underneath)); + applyCellPadding(cell, clearOfTheRules(givingTheSlack(node, placement, cellPadding, slackHeld), + 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)); @@ -19018,18 +19018,41 @@ static boolean fixedBesideAuto(TableNode node) { * numbers the document already carries. A table with an {@code auto} column and no * stated width keeps Word's own sizing until resolved layout can supply the measured * widths.

+ * + * @param cover the table's cells by row and column + * @return the editor's slack each column's cells' margins hold, in points + * ({@link #slackTheMarginsHold}), or {@code null} where the layout measured no columns */ - private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { + private double[] applyTableWidth(XWPFTable table, TableNode node, int columnCount, + TableGrid.Placement[][] cover) { double[] measured = layout.tableColumns(node, columnCount); if (measured != null && measured.length > 0) { // The layout resolved every column, an auto one included, so there is nothing // left to decide: write the widths it arrived at and stop Word re-fitting them — - // with the editor's margin on the columns sized to their content. - double[] columns = withEditorSlack(node, measured); + // with the editor's margin on the columns sized to their content, in their cells' + // margins where those hold it. + double[] slackened = withEditorSlack(node, measured); + double[] held = slackTheMarginsHold(node, cover, measured, slackened); + double[] columns = new double[measured.length]; + double wider = 0; + for (int index = 0; index < columns.length; index++) { + columns[index] = slackened[index] - held[index]; + wider += columns[index] - measured[index]; + } writeGrid(table, columns); setTableWidth(table, sum(columns)); setFixedLayout(table); - return; + // Less than a tenth of a point further right is not seen. Named painted or not: the text + // of the columns right of a widened one moves with it, and an editor shows the table's + // right edge as a gridline. + if (wider >= 0.1) { + report.add(DocxExportReport.Severity.APPROXIMATED, RIGHT_END, layout.pathOf(node), + "its columns sized to their content written " + pointsOf(wider) + "pt wider than the " + + "page sets them, the room an editor setting their text in its own face may need, which " + + "their cells' margins do not hold: its right end, and what stands right of them, up to " + + "that much further right"); + } + return held; } Double authored = node.width() != null && node.width() > 0 ? node.width() : null; @@ -19046,7 +19069,7 @@ private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { setTableWidth(table, nestedTableWidth()); } nameTheColumnsLeftToTheEditor(node); - return; + return null; } double natural = fixedColumns.stream().mapToDouble(Double::doubleValue).sum(); @@ -19062,10 +19085,111 @@ private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { // first in a different place than the PDF draws it. columns[columns.length - 1] += width - natural; writeGrid(table, columns); + return null; + } + + /** + * How much of each column's editor slack its cells' margins hold, in points + * ({@link #withEditorSlack}): the column is written that much narrower, as wide as the page + * sets it where they hold all of it, and its cells' text keeps the room the slack gives it. + * + *

A column widened by its slack moves every rule and fill right of it, and the table's right + * end, that much right of the page's: measured in Word, {@code ClassicInvoice}'s line items + * stood their column rules 0.98 to 1.05pt right of the page's from the description column on. + * Taken off a cell's margin, the slack gives its text the same room, and so the same line + * breaks, with the rules where the page draws them. A text cell gives it from the side its text + * does not lean on, so its text stays where the page sets it: the right where it is set left, + * the left where it is set right, half from each where it is centred. A cell spanning more than + * one of the columns given slack shares its margin among them. A composed cell gives none: a + * paragraph in it may run past its text into the margin ({@link #measureAtWordsSize}), and with + * the margin taken, past the cell's edge, where Word cuts it off. A column holds the least any + * of its cells gives; the rest widens it, and {@link #applyTableWidth} names it.

+ * + * @param measured the columns as the layout measured them, in points + * @param slackened the same with the editor's slack + * @return the slack each column's cells' margins hold, in points + */ + private double[] slackTheMarginsHold(TableNode node, TableGrid.Placement[][] cover, + double[] measured, double[] slackened) { + double[] held = new double[measured.length]; + for (int index = 0; index < held.length; index++) { + held[index] = Math.max(0, slackened[index] - measured[index]); + } + java.util.Set seen = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (TableGrid.Placement[] row : cover) { + for (TableGrid.Placement placement : row) { + if (placement == null || !seen.add(placement)) { + continue; + } + int end = Math.min(placement.column() + placement.colSpan(), held.length); + int sharing = 0; + for (int column = placement.column(); column < end; column++) { + if (slackened[column] > measured[column]) { + sharing++; + } + } + if (sharing == 0) { + continue; + } + double share = marginGivingTheSlack(node, placement) / sharing; + for (int column = placement.column(); column < end; column++) { + held[column] = Math.min(held[column], share); + } + } + } + return held; + } + + /** + * How much of its columns' slack a cell's padding can give, in points + * ({@link #slackTheMarginsHold}): a text cell's on the side its text does not lean on, a + * centred one's twice the thinner side; none of a composed cell's. + */ + private double marginGivingTheSlack(TableNode node, TableGrid.Placement placement) { + if (placement.cell() == null || placement.cell().content() != null) { + return 0; + } + DocumentInsets padding = resolveCellPadding(node, placement); + double left = Math.max(0, padding.left()); + double right = Math.max(0, padding.right()); + TextAlign leans = leanOf(node, placement); + return leans == TextAlign.RIGHT ? left : leans == TextAlign.CENTER ? 2 * Math.min(left, right) : right; } /** - * Widens the columns sized to their content by a point, where there is room for it. + * A text cell's padding less the slack its columns' margins hold ({@link #slackTheMarginsHold}), + * off the side its text does not lean on; as it is where they hold none. + * + * @param held the slack each column's cells' margins hold, or {@code null} where none is given + */ + private DocumentInsets givingTheSlack(TableNode node, TableGrid.Placement placement, DocumentInsets padding, + double[] held) { + if (held == null) { + return padding; + } + double given = 0; + for (int column = placement.column(); column < Math.min(placement.column() + placement.colSpan(), + held.length); column++) { + given += held[column]; + } + if (!(given > 0)) { + return padding; + } + TextAlign leans = leanOf(node, placement); + double left = leans == TextAlign.RIGHT ? given : leans == TextAlign.CENTER ? given / 2 : 0; + return new DocumentInsets(padding.top(), padding.right() - (given - left), padding.bottom(), + padding.left() - left); + } + + /** The side the page sets a text cell's lines against: left, right or the middle. */ + private TextAlign leanOf(TableNode node, TableGrid.Placement placement) { + boolean rightToLeft = resolveCellDirection(node, placement, placement.cell().lines()); + return horizontalOf(resolveCellAnchor(node, placement, rightToLeft)); + } + + /** + * The columns, those sized to their content a point wider, where there is room for it. * *

An auto column is exactly as wide as its widest unwrapped cell: the engine gives it * no slack, because on the page it needs none. An editor does. It sets the text in its @@ -19074,11 +19198,14 @@ private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { * measured on the probe corpus through LibreOffice, a billing table's widest item * wrapped and its row doubled the moment its cells were written at their true 14pt.

* - *

So each auto column gets {@value #EDITOR_COLUMN_SLACK_POINTS}pt more than the page - * gave it — below anything a reader compares by eye, and what keeps the page's line - * breaks. Only where it costs nothing the document stated: a table with an authored - * width keeps that width exactly, a fixed column keeps its size, and the slack never - * takes the table past the width it sits in.

+ *

So each auto column's text gets {@value #EDITOR_COLUMN_SLACK_POINTS}pt more than the + * page gave it — below anything a reader compares by eye, and what keeps the page's line + * breaks — out of its cells' margins where they hold it ({@link #slackTheMarginsHold}), the + * column as wide as the page sets it. Only where it costs nothing the document stated: a + * table with an authored width keeps that width exactly, a fixed column keeps its size, and + * the slack never takes the table past the width it sits in.

+ * + * @return the columns with the slack, in points */ private double[] withEditorSlack(TableNode node, double[] measured) { if (node.width() != null) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java index 22e4a162e..1874efaaa 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java @@ -42,6 +42,12 @@ class DocxCellPaddingTest { * much smaller and their bottom one that much larger than their padding gives. */ private static final long SEAT = DocxExports.DEFAULT_LINE_RAISE; + /** + * What a text cell of a column sized to its content gives of its right margin for the room an + * editor's face is given ({@code DocxEditorSlackTest}): the column stays as wide as the page sets + * it, and its text has that much more room. + */ + private static final long SLACK = Math.round(DocxSemanticBackend.EDITOR_COLUMN_SLACK_POINTS * TWIPS_PER_POINT); @Test void aStatedPaddingReachesAllFourSides() throws Exception { @@ -53,14 +59,14 @@ void aStatedPaddingReachesAllFourSides() throws Exception { .row("Padded"))); // Above and below, less the engine's default 1pt rule, which Word gives room of its own. - assertThat(margins(cell)).containsExactly(160L - SEAT, 140L, 80L + SEAT, 60L); + assertThat(margins(cell)).containsExactly(160L - SEAT, 140L - SLACK, 80L + SEAT, 60L); } @Test void aRuledCellGivesItsRulesOutOfItsPaddingWhereWordPutsThem() throws Exception { // Word gives a rule between two rows half to each, and the rules above and below the // table whole to their row: measured, a 1.5pt rule made each row 1.5pt taller and a - // one-row table 3pt taller. The sides keep their padding. + // one-row table 3pt taller. The sides keep their padding, the right one less its slack. XWPFTable table = firstTable(page -> page.addTable(t -> t .columns(DocumentTableColumn.auto()) .defaultCellStyle(DocumentTableStyle.builder() @@ -72,9 +78,9 @@ void aRuledCellGivesItsRulesOutOfItsPaddingWhereWordPutsThem() throws Exception .row("Second"))); assertThat(margins(table.getRow(0).getCell(0))).as("the rule above the table, half the one between") - .containsExactly(110L - SEAT, 140L, 125L + SEAT, 140L); + .containsExactly(110L - SEAT, 140L - SLACK, 125L + SEAT, 140L); assertThat(margins(table.getRow(1).getCell(0))).as("half the rule between, the rule below the table") - .containsExactly(125L - SEAT, 140L, 110L + SEAT, 140L); + .containsExactly(125L - SEAT, 140L - SLACK, 110L + SEAT, 140L); } @Test @@ -92,7 +98,7 @@ void aRuleComesOffThePaddingAsWideAsWordDrawsIt() throws Exception { assertThat(((Number) cell.getCTTc().getTcPr().getTcBorders().getTop().getSz()).intValue()) .as("the premise: written in eighths").isEqualTo(6); - assertThat(margins(cell)).containsExactly(140L - 15 - SEAT, 140L, 140L - 15 + SEAT, 140L); + assertThat(margins(cell)).containsExactly(140L - 15 - SEAT, 140L - SLACK, 140L - 15 + SEAT, 140L); } @Test @@ -108,7 +114,7 @@ void aRuleThickerThanWordDrawsIsWrittenAndTakenOffAtWordsThickest() throws Excep assertThat(((Number) cell.getCTTc().getTcPr().getTcBorders().getTop().getSz()).intValue()).isEqualTo(96); assertThat(margins(cell)).as("12pt off each side, less the row's seat above, more below") - .containsExactly(400L - 240 - SEAT, 400L, 400L - 240 + SEAT, 400L); + .containsExactly(400L - 240 - SEAT, 400L - SLACK, 400L - 240 + SEAT, 400L); } @Test @@ -123,7 +129,7 @@ void aRuleThinnerThanWordDrawsIsWrittenAndTakenOffAtWordsThinnest() throws Excep .row("Hairline"))); assertThat(((Number) cell.getCTTc().getTcPr().getTcBorders().getTop().getSz()).intValue()).isEqualTo(2); - assertThat(margins(cell)).containsExactly(140L - 5 - SEAT, 140L, 140L - 5 + SEAT, 140L); + assertThat(margins(cell)).containsExactly(140L - 5 - SEAT, 140L - SLACK, 140L - 5 + SEAT, 140L); } @Test @@ -141,7 +147,7 @@ void aRuleOfHalfATwipMoreIsCountedRoundedDownAsBothEditorsCountIt() throws Excep long twips = (long) rule[1]; assertThat(margins(cell)).as("a %spt rule", rule[0]) - .containsExactly(140L - twips - SEAT, 140L, 140L - twips + SEAT, 140L); + .containsExactly(140L - twips - SEAT, 140L - SLACK, 140L - twips + SEAT, 140L); } } @@ -159,9 +165,9 @@ void aRuleBetweenTwoRowsIsSharedInWholeTwipsTheRowAboveTakingHalfRoundedDown() t .row("Second"))); assertThat(margins(table.getRow(0).getCell(0))).as("the rule above the table whole, 7 of the one under it") - .containsExactly(140L - 15 - SEAT, 140L, 140L - 7 + SEAT, 140L); + .containsExactly(140L - 15 - SEAT, 140L - SLACK, 140L - 7 + SEAT, 140L); assertThat(margins(table.getRow(1).getCell(0))).as("the other 8 above, the rule below the table whole") - .containsExactly(140L - 8 - SEAT, 140L, 140L - 15 + SEAT, 140L); + .containsExactly(140L - 8 - SEAT, 140L - SLACK, 140L - 15 + SEAT, 140L); } @Test @@ -194,9 +200,9 @@ void betweenRowsRuledDifferentlyTheLowerRowsRuleIsTheOneGivenUp() throws Excepti .row("Body"))); assertThat(margins(table.getRow(0).getCell(0))).as("its own 1.5pt above, half the body's 0.5pt below") - .containsExactly(110L - SEAT, 140L, 135L + SEAT, 140L); + .containsExactly(110L - SEAT, 140L - SLACK, 135L + SEAT, 140L); assertThat(margins(table.getRow(1).getCell(0))).as("half its own 0.5pt above, its own below") - .containsExactly(135L - SEAT, 140L, 130L + SEAT, 140L); + .containsExactly(135L - SEAT, 140L - SLACK, 130L + SEAT, 140L); } @Test @@ -209,7 +215,7 @@ void aCellSpanningToTheLastRowGivesUpTheRuleBelowTheTable() throws Exception { .rowCells(DocumentTableCell.text("Low")))); assertThat(margins(table.getRow(0).getCell(0))).as("above the table and below it, both whole") - .containsExactly(110L, 140L, 110L, 140L); + .containsExactly(110L, 140L - SLACK, 110L, 140L); } @Test @@ -225,9 +231,10 @@ void aRuleOfNoWidthGivesUpNothingAndPaddingThinnerThanItsShareGivesWhatItHas() t .padding(DocumentInsets.of(1)).stroke(DocumentStroke.of(DocumentColor.BLACK, 2)).build()) .row("Thin"))); - assertThat(margins(unruled)).containsExactly(140L - SEAT, 140L, 140L + SEAT, 140L); - assertThat(margins(thin)).as("none left above and below, the sides kept; none to raise the text into") - .containsExactly(0L, 20L, 0L, 20L); + assertThat(margins(unruled)).containsExactly(140L - SEAT, 140L - SLACK, 140L + SEAT, 140L); + assertThat(margins(thin)).as("none left above and below, the sides kept but for the right's slack; none " + + "to raise the text into") + .containsExactly(0L, 20L - SLACK, 0L, 20L); } @Test @@ -257,7 +264,7 @@ void aTableThatStatesNothingIsWrittenWithTheEnginesOwnDefault() throws Exception long four = Math.round(4 * TWIPS_PER_POINT); long three = Math.round(3 * TWIPS_PER_POINT); - assertThat(margins(cell)).containsExactly(three - SEAT, four, three + SEAT, four); + assertThat(margins(cell)).containsExactly(three - SEAT, four - SLACK, three + SEAT, four); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java index e48984243..8b95c0448 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java @@ -3,26 +3,33 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.RowBuilder; import com.demcha.compose.document.layout.PlacedNode; import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload; +import com.demcha.compose.document.node.TextDirection; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.table.DocumentTableCell; import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.document.table.DocumentTableTextAnchor; import com.demcha.compose.engine.components.content.table.TableResolvedCell; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.junit.jupiter.api.Test; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar; import java.io.ByteArrayInputStream; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; /** - * A column sized to its content gets a point more in Word than on the page. + * A column sized to its content gives its text a point more in Word than on the page. * *

An auto column is exactly as wide as its widest unwrapped cell: the engine gives it no * slack, because on the page it needs none. An editor sets the text in its own substitute @@ -31,35 +38,233 @@ * billing table's widest item wrapped and its row doubled once its cells were written at their * true size. A point per auto column keeps the page's line breaks.

* + *

The point comes out of the column's cells' margins, on the side their text does not lean + * on, so the column stays as wide as the page sets it: widened instead, it moved every rule right + * of it a point right of the page's (measured in Word, {@code ClassicInvoice}'s line items 0.98 to + * 1.05pt). What the margins do not hold — beside a composed cell, or padding too thin on the side a + * cell gives from — widens the column, and the report names it.

+ * * @author Artem Demchyshyn */ class DocxEditorSlackTest { private static final long SLACK_TWIPS = Math.round(DocxSemanticBackend.EDITOR_COLUMN_SLACK_POINTS * 20); + /** The engine's cell padding a side, in twips, where nothing states one. */ + private static final long PADDING_TWIPS = Math.round(DocxSemanticBackend.ENGINE_DEFAULT_CELL_PADDING_POINTS * 20); + private static final String RIGHT_END = "right end"; @Test - void anAutoColumnGetsAPointMoreThanTheLayoutGaveIt() throws Exception { + void anAutoColumnsTextCellsGiveThePointOutOfTheirRightMargins() throws Exception { Exported exported = export(page -> page.addTable(t -> t .name("Billing") .autoColumns(2) .row("Platform subscription", "1 440.00"))); for (int column = 0; column < 2; column++) { - assertThat(exported.gridTwips(column)) - .as("column %d: the layout's width and the editor's point", column) - .isEqualTo(exported.layoutTwips(column) + SLACK_TWIPS); + assertThat(exported.gridTwips(column)).as("column %d: as wide as the page sets it", column) + .isEqualTo(exported.layoutTwips(column)); + assertThat(exported.sideMargins(0, column)).as("column %d: its text set left, the point off its right", + column) + .containsExactly(PADDING_TWIPS, PADDING_TWIPS - SLACK_TWIPS); + assertThat(exported.textTwips(0, column)).as("column %d: its text the editor's point wider", column) + .isEqualTo(exported.layoutTwips(column) - 2 * PADDING_TWIPS + SLACK_TWIPS); } + assertThat(exported.notes(RIGHT_END)).as("nothing stands right of the page's").isEmpty(); + } + + @Test + void aCellSetRightGivesThePointOutOfItsLeftMarginAndACentredOneHalfFromEach() throws Exception { + Exported exported = export(page -> page.addTable(t -> t + .name("Amounts") + .autoColumns(2) + .rowCells(DocumentTableCell.text("1 440.00").withStyle(DocumentTableStyle.builder() + .textAnchor(DocumentTableTextAnchor.CENTER_RIGHT).build()), + DocumentTableCell.text("Paid").withStyle(DocumentTableStyle.builder() + .textAnchor(DocumentTableTextAnchor.CENTER).build())))); + + assertThat(exported.sideMargins(0, 0)).as("set right: its left margin gives it") + .containsExactly(PADDING_TWIPS - SLACK_TWIPS, PADDING_TWIPS); + assertThat(exported.sideMargins(0, 1)).as("centred: half from each side") + .containsExactly(PADDING_TWIPS - SLACK_TWIPS / 2, PADDING_TWIPS - SLACK_TWIPS / 2); + assertThat(exported.gridTwips(0)).isEqualTo(exported.layoutTwips(0)); + assertThat(exported.gridTwips(1)).isEqualTo(exported.layoutTwips(1)); + } + + @Test + void aCellSetRightGivesOnlyWhatItsLeftMarginHas() throws Exception { + // A quarter of a point on its left and 4pt on its right: the right one, which its text leans + // on, keeps all of it. + Exported exported = export(page -> page.addTable(t -> t + .name("Amounts") + .autoColumns(1) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(4, 4, 4, 0.25)) + .textAnchor(DocumentTableTextAnchor.CENTER_RIGHT).build()) + .row("1 440.00"))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.layoutTwips(0) + 15); + assertThat(exported.sideMargins(0, 0)).containsExactly(0L, PADDING_TWIPS); + assertThat(exported.notes(RIGHT_END)).hasSize(1); + } + + @Test + void aCentredCellWithHalfAPointASideHoldsThePointWhole() throws Exception { + Exported exported = export(page -> page.addTable(t -> t + .name("Centred") + .autoColumns(1) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(4, 0.5, 4, 0.5)) + .textAnchor(DocumentTableTextAnchor.CENTER).build()) + .row("Paid"))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.layoutTwips(0)); + assertThat(exported.sideMargins(0, 0)).containsExactly(0L, 0L); + assertThat(exported.notes(RIGHT_END)).isEmpty(); + } + + @Test + void aRightToLeftCellSetAtItsStartGivesThePointOutOfItsLeftMargin() throws Exception { + // The page sets a right-to-left cell that states no anchor against its right edge. + Exported exported = export(page -> page.addTable(t -> t + .name("Hebrew") + .autoColumns(1) + .defaultCellStyle(DocumentTableStyle.builder().direction(TextDirection.RTL).build()) + .row("שלום עולם"))); + + assertThat(exported.sideMargins(0, 0)).containsExactly(PADDING_TWIPS - SLACK_TWIPS, PADDING_TWIPS); + } + + @Test + void aRightToLeftCellSetLeftGivesThePointOutOfItsRightMargin() throws Exception { + Exported exported = export(page -> page.addTable(t -> t + .name("Hebrew") + .autoColumns(1) + .defaultCellStyle(DocumentTableStyle.builder().direction(TextDirection.RTL) + .textAnchor(DocumentTableTextAnchor.CENTER_LEFT).build()) + .row("שלום עולם"))); + + assertThat(exported.sideMargins(0, 0)).containsExactly(PADDING_TWIPS, PADDING_TWIPS - SLACK_TWIPS); } @Test - void aFixedColumnKeepsTheWidthTheDocumentStated() throws Exception { + void aFixedColumnKeepsTheWidthTheDocumentStatedAndItsMargins() throws Exception { Exported exported = export(page -> page.addTable(t -> t .name("Fixed") .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.auto()) .row("Label", "Value"))); assertThat(exported.gridTwips(0)).isEqualTo(Math.round(120 * 20.0)); - assertThat(exported.gridTwips(1)).isEqualTo(exported.layoutTwips(1) + SLACK_TWIPS); + assertThat(exported.sideMargins(0, 0)).containsExactly(PADDING_TWIPS, PADDING_TWIPS); + assertThat(exported.gridTwips(1)).isEqualTo(exported.layoutTwips(1)); + assertThat(exported.sideMargins(0, 1)).containsExactly(PADDING_TWIPS, PADDING_TWIPS - SLACK_TWIPS); + } + + @Test + void paddingThinnerThanThePointGivesWhatItHasAndTheRestWidensTheColumnNamed() throws Exception { + // A quarter of a point on the right: the column is the other three quarters wider. + Exported exported = export(page -> page.addTable(t -> t + .name("Tight") + .autoColumns(1) + .defaultCellStyle(DocumentTableStyle.builder().padding(new DocumentInsets(4, 0.25, 4, 4)).build()) + .row("Platform subscription") + .row("Support"))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.layoutTwips(0) + 15); + for (int row = 0; row < 2; row++) { + assertThat(exported.sideMargins(row, 0)).as("row %d: all of its right margin given", row) + .containsExactly(80L, 0L); + } + assertThat(exported.notes(RIGHT_END)).singleElement().satisfies(note -> { + assertThat(note.path()).contains("Tight"); + assertThat(note.detail()).isEqualTo("its columns sized to their content written 0.75pt wider than the " + + "page sets them, the room an editor setting their text in its own face may need, which their " + + "cells' margins do not hold: its right end, and what stands right of them, up to that much " + + "further right"); + }); + } + + @Test + void aHeaderPaddedThinnerThanTheBodyHoldsTheColumnToWhatItGives() throws Exception { + // Half a point on the header's right: the body's cells give no more, their column the other half wider. + Exported exported = export(page -> page.addTable(t -> t + .name("Headed") + .autoColumns(1) + .headerRow("Description") + .headerStyle(DocumentTableStyle.builder().padding(new DocumentInsets(4, 0.5, 4, 4)).build()) + .row("Platform subscription"))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.layoutTwips(0) + SLACK_TWIPS / 2); + assertThat(exported.sideMargins(0, 0)).as("the header").containsExactly(80L, 0L); + assertThat(exported.sideMargins(1, 0)).as("the body").containsExactly(80L, 80L - SLACK_TWIPS / 2); + assertThat(exported.notes(RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).contains("written 0.5pt wider")); + } + + @Test + void anIndentedTableIsIndentedByWhatItsFirstCellKeepsOfItsLeftMargin() throws Exception { + // Word and LibreOffice measure a table's indent to its first cell's text: indented by the + // margin as written, a set-right first column's table stood its left rule where the page draws + // it, as a set-left one's (measured: in a section padded 20pt, Word 0.06pt right, LibreOffice + // on the page, either way). + Exported exported = export(page -> page.addSection("Box", box -> box.padding(DocumentInsets.of(20)) + .addTable(t -> t + .name("Amounts") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.fixed(80)) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(6)) + .textAnchor(DocumentTableTextAnchor.CENTER_RIGHT).build()) + .row("1 440.00", "Label")))); + + assertThat(exported.sideMargins(0, 0)).containsExactly(120L - SLACK_TWIPS, 120L); + assertThat(DocxTwips.of(exported.table().getCTTbl().getTblPr().getTblInd().getW())) + .as("the section's padding and the margin the first cell keeps") + .isEqualTo(400L + 120L - SLACK_TWIPS); + } + + @Test + void aComposedCellKeepsItsMarginsAndItsColumnTakesThePointNamed() throws Exception { + // A paragraph in a composed cell may run past its text into the margin (measureAtWordsSize): + // with the margin taken, Word would cut it off at the cell's edge. + Exported exported = export(page -> page.addTable(t -> t + .name("Heading") + .autoColumns(2) + .row("Platform subscription", "1 440.00") + .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Skills").build()), + DocumentTableCell.text("Paid")))); + + assertThat(exported.gridTwips(0)).as("the composed cell's column a point wider") + .isEqualTo(exported.layoutTwips(0) + SLACK_TWIPS); + assertThat(exported.sideMargins(0, 0)).as("so the text beside it keeps its margins") + .containsExactly(PADDING_TWIPS, PADDING_TWIPS); + assertThat(exported.sideMargins(1, 0)).containsExactly(PADDING_TWIPS, PADDING_TWIPS); + assertThat(exported.gridTwips(1)).as("the other column as the page sets it") + .isEqualTo(exported.layoutTwips(1)); + assertThat(exported.sideMargins(1, 1)).containsExactly(PADDING_TWIPS, PADDING_TWIPS - SLACK_TWIPS); + assertThat(exported.notes(RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).startsWith("its columns sized to their content " + + "written 1pt wider than the page sets them")); + } + + @Test + void aCellSpanningTwoAutoColumnsSharesItsMarginBetweenThem() throws Exception { + // A point on its right for two columns' points: half of each column's in the margins, half + // its width. + Exported exported = export(page -> page.addTable(t -> t + .name("Spanned") + .autoColumns(2) + .row("Platform", "1 440.00") + .rowCells(DocumentTableCell.text("Total").colSpan(2).withStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(4, 1, 4, 4)).build())))); + + for (int column = 0; column < 2; column++) { + assertThat(exported.gridTwips(column)).as("column %d", column) + .isEqualTo(exported.layoutTwips(column) + SLACK_TWIPS / 2); + assertThat(exported.sideMargins(0, column)).as("column %d: half its point off the right", column) + .containsExactly(PADDING_TWIPS, PADDING_TWIPS - SLACK_TWIPS / 2); + } + assertThat(exported.sideMargins(1, 0)).as("the span: its whole right margin").containsExactly(80L, 0L); + assertThat(exported.notes(RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).contains("written 1pt wider")); } @Test @@ -71,6 +276,8 @@ void aTableWithAStatedWidthKeepsItExactly() throws Exception { .row("A", "B"))); assertThat(exported.gridTwips(0) + exported.gridTwips(1)).isEqualTo(Math.round(200 * 20.0)); + assertThat(exported.sideMargins(0, 0)).as("no slack to give").containsExactly(PADDING_TWIPS, PADDING_TWIPS); + assertThat(exported.notes(RIGHT_END)).isEmpty(); } @Test @@ -195,8 +402,8 @@ private static ExportedRow exportRow(Consumer content) throws Except } } - /** A table exported through a session, with the layout the session sized it with. */ - private record Exported(XWPFTable table, List firstRow) { + /** A table exported through a session, with the layout the session sized it with and the report. */ + private record Exported(XWPFTable table, List firstRow, DocxExportReport report) { long gridTwips(int column) { CTTblGrid grid = table.getCTTbl().getTblGrid(); @@ -206,6 +413,22 @@ long gridTwips(int column) { long layoutTwips(int column) { return Math.round(firstRow.get(column).width() * 20.0); } + + /** A cell's left and right margins as written, in twips. */ + long[] sideMargins(int row, int cell) { + CTTcMar margins = table.getRow(row).getCell(cell).getCTTc().getTcPr().getTcMar(); + return new long[]{DocxTwips.of(margins.getLeft().getW()), DocxTwips.of(margins.getRight().getW())}; + } + + /** The room a cell of one column leaves its text in Word: its column less its side margins. */ + long textTwips(int row, int column) { + long[] margins = sideMargins(row, column); + return gridTwips(column) - margins[0] - margins[1]; + } + + List notes(String subject) { + return report.bySubject().getOrDefault(subject, List.of()); + } } private static Exported export(Consumer content) throws Exception { @@ -219,9 +442,11 @@ private static Exported export(Consumer content) throws Excepti .map(fragment -> ((TableRowFragmentPayload) fragment.payload()).cells()) .findFirst() .orElseThrow(); - byte[] docx = session.export(new DocxSemanticBackend()); + AtomicReference report = new AtomicReference<>(); + byte[] docx = session.export(new DocxSemanticBackend(report::set)); XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx)); - return new Exported(document.getTables().get(0), firstRow); + assertThat(report.get().bySubject()).as("laid out").doesNotContainKey("measured geometry"); + return new Exported(document.getTables().get(0), firstRow, report.get()); } } } 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 2466873d9..7d30415a6 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 @@ -142,7 +142,8 @@ record Entry(Fate fate, String note) { + "and about how far what the margin cannot take in still passes the edge, " + "named (right border)"; - // A panel's text is given a point of slack past the page's width, out of its right margin. + // A panel's text is given a point of slack past the page's width, out of its right margin; a + // table's auto column's, out of its cells' margins (DocxValueFieldLedgerTest). static final String RIGHT_END = "a panel's right end, written past the page's by the slack an editor's face is " + "given where its right margin cannot hold it, alone or beside a line Word sets " + "wider than the page, named (right end)"; @@ -457,7 +458,9 @@ record Entry(Fate fate, String note) { node(TableNode.class, "name:INERT", "columns:REPORTED:where one is auto and the layout does not show their widths — composed in a " + "table cell, or with a column edge no row shows — the editor's, a fixed one beside it with " - + "them; with no layout, in the section's note; any other is written", + + "them; with no layout, in the section's note; an auto one's room for an editor's face, where " + + "its text cells' margins cannot hold it, written past the page's width, named (right end); any " + + "other is written", "rows:GAP:" + CELL_PADDING + "; " + PADDING_BELOW_ZERO + "; " + CELL_TRANSLUCENCY, "defaultCellStyle:GAP:" + CELL_PADDING + "; " + PADDING_BELOW_ZERO + "; " + CELL_TRANSLUCENCY, "rowStyles:GAP:" + CELL_PADDING + "; " + PADDING_BELOW_ZERO + "; " + CELL_TRANSLUCENCY, diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java index 4d06ef7e4..7c83ff1cd 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java @@ -72,6 +72,13 @@ class DocxValueFieldLedgerTest { + "it paints, or is dropped; an inline icon's is drawn flat, in its first " + "colour; a chart's, with its drawing"; + // An auto column's text is given a point past the page's width, out of its cells' margins. + private static final String AUTO_COLUMN_ROOM = "the room an editor's face is given in it, held in its text " + + "cells' margins; what they do not hold — beside a composed " + + "cell, or where a cell's padding on the side it gives from " + + "is thinner — written past the page's width, named (right " + + "end)"; + private static final Map, Map> VALUES = new LinkedHashMap<>(); static { @@ -658,7 +665,9 @@ class DocxValueFieldLedgerTest { "padding:GAP:" + CELL_PADDING + "; " + PADDING_BELOW_ZERO + "; a text cell's thinner than its rules' share, which Word makes room for, where its " + "row then stands taller than the page's, named with the rules; any other is written, less " - + "that share", + + "that share, and a text cell's in a column sized to its content less what of the room an " + + "editor's face is given there the column's cells all hold, off the side its text does not " + + "lean on", "fillColor:REPORTED:its translucency, flattened against the colour under the cell; any other is " + "written", "stroke:REPORTED:its translucency, flattened against the cell's fill or the colour under it; a width " @@ -674,13 +683,13 @@ class DocxValueFieldLedgerTest { value(com.demcha.compose.document.table.DocumentTableColumn.class, "type:REPORTED:where a table has an auto one whose width the layout does not show — composed in a " + "table cell, or with a column edge no row shows — named, its columns the editor's; with no " - + "layout, in the section's note; any other is written", + + "layout, in the section's note; " + AUTO_COLUMN_ROOM + "; any other is written", "fixedWidth:REPORTED:beside an auto column, where the layout does not show the widths, not " + "written, named with the table; with no layout, in the section's note; any other is written"); value(com.demcha.compose.document.table.DocumentTableColumn.Type.class, "AUTO:REPORTED:where the layout does not show its width — composed in a table cell, or with a " - + "column edge no row shows — the editor's, named; with no layout, in the section's note; any " - + "other is written, as the layout measured it", + + "column edge no row shows — the editor's, named; with no layout, in the section's note; " + + AUTO_COLUMN_ROOM + "; any other is written, as the layout measured it", "FIXED:REPORTED:beside an auto column, where the layout does not show the widths, its width not " + "written, named with the table; with no layout, in the section's note; any other is written"); value(com.demcha.compose.document.table.DocumentTableTextAnchor.class, From ba1d8629acea2e711d88a9b86cb8510268337870 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 22 Oct 2026 21:39:27 +0100 Subject: [PATCH 2/2] fix(docx): keep a cell's text off its rule when its margin gives the slack - A side of a text cell gives an auto column's slack only past the inner half of the cell's rule, as wide as Word draws it: taken further, a line an editor's face sets wider would run onto the rule. What it cannot give widens the column, named (right end). - Re-render the Word export example's DOCX preview: its quarterly table's three auto columns are a point narrower each, the point in their cells' right margins. - Tests: a 1pt padding beside a 1pt rule gives half a point; a cell spanning two rows gives the point in the merge's covered position; the thin-padding tests are unruled. --- CHANGELOG.md | 4 +- .../examples/word-export-companion.docx | Bin 10244 -> 10248 bytes docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 9 ++-- .../semantic/docx/DocxCellPaddingTest.java | 6 +-- .../semantic/docx/DocxEditorSlackTest.java | 47 ++++++++++++++++-- .../docx/DocxNodeFieldLedgerTest.java | 2 +- .../docx/DocxValueFieldLedgerTest.java | 6 +-- 8 files changed, 59 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df41ceafd..2f75f2fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ follow semantic versioning; release dates are ISO 8601. - A composed cell keeps its margins: a paragraph in it may run past its text into them, which Word cuts off at the cell's edge. What a column's cells do not hold widens it, and the report names it (`right end`): beside a composed cell, or where a cell's padding on the side it gives - from is thinner than the point (each side thinner than half of it, centred). + from is thinner than the point (each side thinner than half of it, centred). A side gives only + what it has past the inner half of the cell's rule, so a line an editor's face sets wider does + not run onto it. - Measured on Windows: - Word stood Classic and Modern Invoice's and Modern Proposal's rules right of the auto column 0.94 to 1.06pt right of the page's, and now within 0.12pt. LibreOffice stood them 0.66 to diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index c15ef364d5daa3a0f49fcd3556acaa3f132d5164..84873b8eb177a6c911656f31bae12e3c9ef652ea 100644 GIT binary patch delta 3506 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 VSnLdSpCWerXRrj5uk_!|{s#<0g^~aO delta 3583 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