diff --git a/CHANGELOG.md b/CHANGELOG.md index 07561e7f6..e40488a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export ends a panel hung out of its table cell where the page ends it.** + - A panel whose left border the page centres on its cell's text edge is written with a negative + indent, its table half that border wider than the panel. LibreOffice and Word start a nested + table half that border in from its cell's text, so the table stood the whole border right of + where it is written: its left border half that border right of the page's, and its right end + the whole border. The table gave up width only where it then ran past its cell. + - It now always gives up what its left side had given its text: the half border it is wider by, + and what its left margin gave back of the other half. Its right end stands where the page's + does. What holding its right border in from the cell's edge has already taken counts towards + it, so a card held in beside a gap stays where it was. + - What of it the cell does not take goes back to a line set past its text further than its + margin holds. Engineering Resume's role card would lose the stop ending a line in both editors + otherwise. The panel is then named wider (`right end`), as far as it reaches. + - Measured on Windows: + - A rail with a 3pt accent in a row's column ended its fill 3.07pt right of the page's in Word + and 2.99pt in LibreOffice, and now 0.07 and −0.01pt. The same rail in a filled panel's cell + ended 1.1 and 1.02pt right, now 0.14 and 0.02pt. + - Compact Mono's rail ended 0.9pt right in Word and 0.86pt in LibreOffice, now 0.06 and −0.04pt. + Compact Mono's and Engineering Resume's bordered cards and Nordic Clean's rail ended 0.43 to + 0.64pt right in Word and 0.35 to 0.54pt in LibreOffice, now 0.24 to 0.35pt and 0.09 to + 0.22pt, as a bordered panel's right border stands in a cell. + - No word moves in either editor. + - 11 of the corpus's 62 documents change. Engineering Resume's report names three panels wider + (`right end`), one of them now at the 3pt its fill stands right of the page's. - **A DOCX export ends a rule where the page ends it in Word.** - A horizontal line, a divider or a thin bar is written as an empty paragraph's bottom border, its ends the paragraph's indents. Word draws a paragraph's border past both indents, so every diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 264809160..b2646c476 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -73,7 +73,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what runs past the cell's text as Word starts it, half that border in — what more it gives up there than it would held in at the text coming off its right margin, and no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not; one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 5dd20d273..06fcef880 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -626,7 +626,18 @@ How it lands: to that much right of the page's, its left border and fill, and with a margin below zero its text too, and the report names how far (`left edge`). Measured, 3pt accents stood 1.5pt right of the page's in LibreOffice; in Word, 1.55 to 1.73pt in every cell measured — a row's or a - table's, filled or not, a filled panel's, and a row's inside a filled or a ruled panel. + table's, filled or not, a filled panel's, and a row's inside a filled or a ruled panel. Its + table, half that border wider than the panel, gives up the whole border — the half it is + wider by, and what its left margin gave back of the other — so its right end stands where the + page's does, where it ran the whole border further right (measured: a rail with a 3pt accent + ended its fill 3.07pt right of the page's in Word and 2.99pt in LibreOffice, and 0.07 and + −0.01pt once given up; `CompactMono`'s rail 0.9 and 0.86pt, now 0.06 and −0.04pt; bordered + cards 0.43 to 0.64pt in Word and 0.35 to 0.54pt in LibreOffice, now 0.24 to 0.35pt and 0.09 + to 0.22pt, as a bordered panel's right border stands in a cell). + What holding its right border in from the cell's edge takes counts towards it. A line set + past its text further than its margin holds takes back what of it the cell does not take, + and the panel is named wider (`right end`): `EngineeringResume`'s role card would lose the + stop ending its line otherwise. - **Keeping together.** A `keepTogether()` panel the layout placed on one page is a row Word may not split. Anchors on the blocks inside a panel carry as they do anywhere else, and so do their keeps, but Word and LibreOffice do not hold them where the panel's row breaks over diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 884c58dc5..c2a23011d 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 @@ -7007,27 +7007,90 @@ private static boolean paintsItsRightEnd(DocumentColor fill, DocumentBorders bor * What a panel in a cell whose left border hangs left of the cell's text gives up of its * table's width, in points. * - *

Word, in any cell, starts such a panel half that border in from the cell's text - * ({@link #leftEdgeHungOut}), and a table that then runs past the cell widens the cell, fixed - * layout or not: CompactMono's rail, a 3pt accent at its column's edge, pushed the main - * column 2.3pt right in Word. It gives up what runs past, no more than its left side had given - * its text — the half border the table is wider by, and what of the other half its margin - * could give back — so its text keeps the page's width and the slack. A panel bled left by a - * negative margin keeps the rest, as its text would otherwise narrow and wrap.

+ *

Word and LibreOffice, in any cell, start such a panel half that border in from the + * cell's text: the border's outer edge on the text, which is where the {@code left edge} note + * counts from ({@link #leftEdgeHungOut}). Its table is half that border wider than the panel, + * so its right end stands the whole border right of the page's: measured, a rail with a 3pt + * accent in a row's column ended its fill 3.07pt right of the page's in Word and 2.99pt in + * LibreOffice. A table that runs past its cell widens the cell too, fixed layout or not: + * CompactMono's rail pushed the main column 2.3pt right in Word. It gives up what its left side + * had given its text — the half border the table is wider by, and what of the other half its + * margin could give back — so its right end stands where the page's does and its text, where + * nothing holds its right border in, keeps the page's width and the slack. What holding its + * right border in from the cell's edge has already taken counts towards it: that narrowing is + * reckoned with the table where the editors start it, and the two taken together set a card + * beside a gap up to 0.96pt further left than the border's alone did (measured).

+ * + *

Its text keeps more, no more than what the hang gives, in three cases. Where holding its + * right border in takes less than the hang gives, its margin still gives what it took: an + * approximation no document of the corpus meets. Where its cell's margin holds its right border, + * its right margin gives what the hang gives up more there than held in at the cell's text: the + * cards beside a gap of Panel, MeteredInvoice, ObsidianInvoice and WorkspaceInvoice, which keep + * the width they had before the hang gave anything up. And where a line set past its text takes + * the hang back, to the table alone ({@link #whatTheHangSpares}): given back to the margin too, + * it cut off the stop ending a line of EngineeringResume's role card in both editors + * (measured).

+ * + *

Measured with its border on the cell's text. A panel set in less than half its border + * gives up no more than the editors move it, and one bled left by a negative margin keeps the + * bleed, as its text would otherwise narrow and wrap: both reckoned from that case, not + * measured. One set in by half its border or more does not hang and gives up nothing, a step of + * half its border from one set in a hair less.

* * @param outer the table's width, in points * @param indent where the table starts from the cell's text, in points * @param halfLeft half the panel's left border, in points * @param padding the panel's padding, whose left side gave its text the other half + * @param pulled what holding its right border in from the cell's edge has taken off it, in + * points ({@link #pullOf}) */ - private double whatTheHangGivesUp(double outer, double indent, double halfLeft, DocumentInsets padding) { - if (currentCell == null || !(indent < 0) || !(halfLeft > 0) || !Double.isFinite(currentCellWidth)) { + private double whatTheHangGivesUp(double outer, double indent, double halfLeft, DocumentInsets padding, + double pulled) { + if (!hangsInItsCell(indent, halfLeft)) { return 0; } - double given = halfLeft + Math.min(halfLeft, Math.max(0, padding.left())); + double given = hangGiven(halfLeft, padding, indent); + return Math.max(whatTheHangMustGiveUp(outer, halfLeft, given), + Math.max(0, Math.min(outer, given - pulled))); + } + + /** + * What of {@link #whatTheHangGivesUp} the panel gives up only to stand its right end where the + * page's does, in points: past what the cell takes ({@link #whatTheHangMustGiveUp}). A line + * running further past its text than the margin holds may take it back. + */ + private double whatTheHangSpares(double outer, double indent, double halfLeft, DocumentInsets padding, + double pulled) { + if (!hangsInItsCell(indent, halfLeft)) { + return 0; + } + return whatTheHangGivesUp(outer, indent, halfLeft, padding, pulled) + - whatTheHangMustGiveUp(outer, halfLeft, hangGiven(halfLeft, padding, indent)); + } + + /** + * What of the hang's give-up the cell takes whatever the panel's lines need: what of the table, + * started half its left border in from the cell's text, runs past the cell, which Word widens + * to hold it. + */ + private double whatTheHangMustGiveUp(double outer, double halfLeft, double given) { return Math.max(0, Math.min(given, halfLeft + outer - (currentCellWidth - insetRight))); } + /** Whether a panel with a left border writes its table starting left of its cell's text. */ + private boolean hangsInItsCell(double indent, double halfLeft) { + return currentCell != null && indent < 0 && halfLeft > 0 && Double.isFinite(currentCellWidth); + } + + /** + * What a hung panel's left side gave its text, in points: the half border its table is wider + * by, and what of the other half its margin could give back — no more than the editors move + * the table, half its left border in from the cell's text, from where it is written. + */ + private static double hangGiven(double halfLeft, DocumentInsets padding, double indent) { + return Math.min(halfLeft + Math.min(halfLeft, Math.max(0, padding.left())), halfLeft - indent); + } + /** * How wide a panel's table is written in the cell it is in: as wide as asked, less what holding * its right border in from the cell's edge takes, no more than its right margin holds, and @@ -7040,8 +7103,8 @@ private double whatTheHangGivesUp(double outer, double indent, double halfLeft, */ private double heldInItsCell(double outer, double room, double margin, double indent, double halfLeft, DocumentInsets padding) { - double held = outer - pullOf(outer, room, margin); - return held - whatTheHangGivesUp(held, indent, halfLeft, padding); + double pulled = pullOf(outer, room, margin); + return outer - pulled - whatTheHangGivesUp(outer - pulled, indent, halfLeft, padding, pulled); } /** @@ -7290,6 +7353,8 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // and is named. double slackTaken = 0; double slackKept = 0; + // What the hang gave up past what the cell takes of it (whatTheHangMustGiveUp). + double hangSpare = 0; // The right margin, less what holding the border in from the cell's edge takes of it. double marginHeld = margins.right(); // The table as wide as the page draws the panel, held in from nothing at its right. @@ -7299,13 +7364,19 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container double asked = width + (inTheBody ? 0 : halfLeft) + halfRight; outer = heldInItsCell(asked, room, margins.right(), indent, halfLeft, padding); shortOfTheEdge = pullOf(asked, room, margins.right()); - // Held in at the cell's text, a table whose left border hangs out of the cell gave up - // only what then ran past the text, its text keeping the rest of its width. Held at - // the cell's edge it runs further, and gives up more: that comes off its right margin - // too, so its text keeps that width. + // Held at the cell's edge, a table whose left border hangs out of the cell gives up more + // than held in at the cell's text: it runs further past the cell, and holding its + // border in has taken less of it (whatTheHangGivesUp). That comes off its right margin + // too, so its text keeps the width it has held in at the text. The cards beside a gap + // of Panel, MeteredInvoice, ObsidianInvoice and WorkspaceInvoice, which the cell's text + // would hold in by more than the hang gives, so keep the width they had before the + // hang gave anything up. double shortAtTheText = pullOf(asked, room - heldByTheCell, margins.right()); - double hungMore = whatTheHangGivesUp(asked - shortOfTheEdge, indent, halfLeft, padding) - - whatTheHangGivesUp(asked - shortAtTheText, indent, halfLeft, padding); + double hungMore = whatTheHangGivesUp(asked - shortOfTheEdge, indent, halfLeft, padding, shortOfTheEdge) + - whatTheHangGivesUp(asked - shortAtTheText, indent, halfLeft, padding, shortAtTheText); + // What of the hang's give-up only stands the right end where the page's does, which a + // line running past its text may take back (below). + hangSpare = whatTheHangSpares(asked - shortOfTheEdge, indent, halfLeft, padding, shortOfTheEdge); marginHeld = margins.right() - shortOfTheEdge - Math.max(0, hungMore); double pageAsked = width - sized.slack() + (inTheBody ? 0 : halfLeft) + halfRight; slackKept = Math.max(0, outer - heldInItsCell(pageAsked, room, margins.right(), indent, halfLeft, @@ -7313,7 +7384,8 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container slackTaken = Math.min(slackKept, marginHeld); slackKept -= slackTaken; outer -= slackTaken; - pageWide = pageAsked - whatTheHangGivesUp(pageAsked, indent, halfLeft, padding); + // As the hang gives up beside what holding the border in takes (whatTheHangGivesUp). + pageWide = pageAsked - whatTheHangGivesUp(pageAsked, indent, halfLeft, padding, shortOfTheEdge); if (Double.isFinite(room)) { // The room ends half a point clear of the gridline; what that half point, what // holds the panel in from the cell's text and what of the cell's margin the room @@ -7430,11 +7502,15 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // (measureAtWordsSize). Where it runs, and the point an editor's face may set it wider, // further than the margin still holds, the slack goes back to the table, no more than came // off it: Word cuts off what passes the panel's edge, the stop ending a line of - // EngineeringResume's role card, set at 7.5pt where the page sets 7.25. - double givenBack = Math.min(slackTaken, - Math.max(0, linesPast + EDITOR_COLUMN_SLACK_POINTS - (marginHeld - slackTaken))); - if (givenBack > 0) { - outer += givenBack; + // EngineeringResume's role card, set at 7.5pt where the page sets 7.25. Then what the hang + // spared, to the table alone, as it came off the table and not the margin, its text that + // much wider: given back to the margin too, it cut that stop off again in Word and + // LibreOffice. + double past = Math.max(0, linesPast + EDITOR_COLUMN_SLACK_POINTS - (marginHeld - slackTaken)); + double givenBack = Math.min(slackTaken, past); + double hangBack = Math.min(hangSpare, past - givenBack); + if (givenBack + hangBack > 0) { + outer += givenBack + hangBack; setTableWidth(table, outer); writeGrid(table, new double[]{outer}); cellProperties(cell).getTcW().setW(BigInteger.valueOf(toTwips(outer))); @@ -7443,9 +7519,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // Less than a tenth of a point further right is not seen, nor a table holding slack its // right border's narrowing has already taken back. A panel broken over pages is a table a // page, and named once, at its widest. - double wider = Math.min(slackKept + givenBack, outer - pageWide); + double wider = Math.min(slackKept + givenBack + hangBack, outer - pageWide); if (wider >= 0.1 && paintsItsRightEnd(fill, borders)) { - rightEnds.merge(node, new RightEnd(wider, givenBack > 0), RightEnd::wider); + rightEnds.merge(node, new RightEnd(wider, givenBack + hangBack > 0), RightEnd::wider); } RightEnd rightEnd = last ? rightEnds.remove(node) : null; if (rightEnd != null) { 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 63fc19e1a..bf72a5094 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 @@ -146,7 +146,8 @@ record Entry(Fate fate, String note) { // 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)"; + + "wider than the page, and by what a hung left border gave up that such a line " + + "takes back, named (right end)"; // LibreOffice and Word start a nested table at its cell's text. static final String LEFT_EDGE = "a panel's left edge in a table cell, its left border hanging out of the cell or " diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelSlackTest.java index a55484969..64076033a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelSlackTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelSlackTest.java @@ -289,11 +289,11 @@ void aLineWordSetsAsThePageDoesLeavesTheSlackInThePadding() throws Exception { } @Test - void aPanelTheCellsEdgeAlreadyHoldsInKeepsItsMarginWhole() throws Exception { - // 161pt wide in a 163pt column, its 3pt left border hanging out of the cell: Word starts it - // half that border in, and the table gives up what then runs past the cell, the slack - // with it. Nothing of the slack is left in the table to take out of the margin, and the - // text keeps its width. + void aRailHungOutOfTheColumnItFillsEndsWhereThePageEndsIt() throws Exception { + // 161pt wide in a 163pt column, its 3pt left border hanging out of the cell: Word and + // LibreOffice start it half that border in, and the table gives up the whole border, so + // it ends where the page ends it, clear of the cell's edge. Its slack then comes out of + // its right margin, as any panel's does. Consumer content = page -> page.addRow(row -> row .spacing(14) .weights(1, 1) @@ -312,9 +312,107 @@ void aPanelTheCellsEdgeAlreadyHoldsInKeepsItsMarginWhole() throws Exception { assertThat(room(cell)).as("the column").isCloseTo(163, within(0.05)); assertThat(indent).isEqualTo(-1.5); - assertThat(1.5 + width).as("from half its border in, it ends at the cell's text") - .isCloseTo(room(cell), within(0.05)); - assertThat(twips(rightMargin(rail))).as("all its padding").isEqualTo(4 * 20L); + assertThat(1.5 + width).as("from half its border in, it ends where the page ends it, 161pt in") + .isCloseTo(161, within(0.05)); + assertThat(twips(rightMargin(rail))).as("its padding less the slack") + .isEqualTo(Math.round((4 - SLACK) * 20)); + } + } + + @Test + void aHungCardsLineSetPastItsTextTakesBackWhatTheHangGaveUp() throws Exception { + // A 2pt accent hanging out of its column: the table gives up the whole border, 2pt, to end + // where the page ends the card. At 7.25pt, written at 7.5, its lines run past its text + // further than its 1pt margin holds, and take back the slack and those 2pt; at 7.5pt they + // do not, and the card ends where the page ends it. + java.util.function.DoubleFunction> hung = size -> page -> page.addRow(row -> row + .spacing(12) + .weights(1, 1) + .addSection("Card", card -> card.fixedWidth(160).fillColor(SURFACE) + .accentLeft(ACCENT.color(), 2).padding(DocumentInsets.of(1)) + .addParagraph(p -> p.text(FILL).textStyle(DocumentTextStyle.DEFAULT.withSize(size)))) + .addSection("Other", other -> other.addParagraph(p -> p.text("Beside")))); + double placed = placedWidth(hung.apply(7.25), "Card"); + try (XWPFDocument document = export(hung.apply(7.25))) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + assertThat(twips(card.getCTTbl().getTblPr().getTblW().getW())) + .as("its half border, the slack and the 2pt the hang gave up, back") + .isEqualTo(Math.round((placed + 1 + SLACK) * 20)); + assertThat(twips(rightMargin(card))).as("all its padding, the hang's taken back off the table alone") + .isEqualTo(20L); + } + assertThat(notes(hung.apply(7.25), RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).contains("3pt wider than the page draws it") + .contains("beside the lines it sets past its text")); + try (XWPFDocument document = export(hung.apply(7.5))) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + assertThat(twips(card.getCTTbl().getTblPr().getTblW().getW())) + .as("its half border in, the whole border given up: ending where the page ends it") + .isEqualTo(Math.round((placed + 1 - 2) * 20)); + assertThat(twips(rightMargin(card))).as("its padding less the slack") + .isEqualTo(Math.round((1 - SLACK) * 20)); + } + assertThat(notes(hung.apply(7.5), RIGHT_END)).isEmpty(); + } + + @Test + void aCardBledLeftOfItsCellGivesUpItsBorderAndKeepsItsBleed() throws Exception { + // Bled 4pt left of its filled panel's text by a margin below zero, a 2pt accent on its edge: + // the editors move it, text and all, to the cell's text, and the table gives up the border + // alone, 2pt. Giving up the bleed too would narrow the text the editors move with it. + Consumer content = page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE).padding(DocumentInsets.of(10)) + .addSection("Card", card -> card.fixedWidth(140).fillColor(DocumentColor.WHITE) + .accentLeft(ACCENT.color(), 2).padding(DocumentInsets.of(4)) + .margin(new DocumentInsets(0, 0, 0, -4)).addParagraph(p -> p.text("Bled")))); + double placed = placedWidth(content, "Card"); + try (XWPFDocument document = export(content)) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + assertThat(twips(card.getCTTbl().getTblPr().getTblInd().getW())).as("its bleed and half its border out") + .isEqualTo(-100L); + assertThat(twips(card.getCTTbl().getTblPr().getTblW().getW())).as("its half border in, its border given up") + .isEqualTo(Math.round((placed + 1 - 2) * 20)); + } + } + + @Test + void aCardSetInLessThanHalfItsBorderGivesUpOnlyWhatTheEditorsMoveIt() throws Exception { + // Half a point in from its filled panel's text, a 2pt accent on its edge: written half a + // point left of the text, it is moved a point and a half, half its border in from the + // text, and gives up that much, not the whole border. + Consumer content = page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE).padding(DocumentInsets.of(10)) + .addSection("Card", card -> card.fixedWidth(140).fillColor(DocumentColor.WHITE) + .accentLeft(ACCENT.color(), 2).padding(DocumentInsets.of(4)) + .margin(new DocumentInsets(0, 0, 0, 0.5)).addParagraph(p -> p.text("In")))); + double placed = placedWidth(content, "Card"); + try (XWPFDocument document = export(content)) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + assertThat(twips(card.getCTTbl().getTblPr().getTblInd().getW())).isEqualTo(-10L); + assertThat(twips(card.getCTTbl().getTblPr().getTblW().getW())).as("its half border in, 1.5pt given up") + .isEqualTo(Math.round((placed + 1 - 1.5) * 20)); + } + } + + @Test + void aHungCardFillingItsColumnKeepsWhatItsCellTakesWhateverItsLinesNeed() throws Exception { + // As wide as its column, the 2pt accent hanging out of it: started a point in, the table + // would run past the cell, which Word widens to hold it. Its lines at 7.25pt run past its + // text, but do not take back the 2pt the cell takes: it ends at the cell's text. As wide as + // its column, it is given no slack. + Consumer content = page -> page.addRow(row -> row + .spacing(12) + .weights(1, 1) + .addSection("Card", card -> card.fixedWidth(COLUMN).fillColor(SURFACE) + .accentLeft(ACCENT.color(), 2).padding(DocumentInsets.of(1)) + .addParagraph(p -> p.text(FILL).textStyle(DocumentTextStyle.DEFAULT.withSize(7.25)))) + .addSection("Other", other -> other.addParagraph(p -> p.text("Beside")))); + double placed = placedWidth(content, "Card"); + try (XWPFDocument document = export(content)) { + XWPFTable card = document.getTables().get(0).getRow(0).getCell(0).getTables().get(0); + assertThat(twips(card.getCTTbl().getTblPr().getTblW().getW())) + .as("its half border in, the 2pt the cell takes: from a point in, at the cell's text") + .isEqualTo(Math.round((placed + 1 - 2) * 20)); } } @@ -337,10 +435,17 @@ void aBorderTheCellsRightMarginHoldsIsNotHeldIn() throws Exception { assertThat(twips(cell.getCTTc().getTcPr().getTcMar().getRight().getW())).as("the gap") .isEqualTo(12 * 20L); XWPFTable left = cell.getTables().get(0); + // Its half borders in, less the whole of its left one: the editors start it half that + // border in from the cell's text (whatTheHangGivesUp). assertThat(twips(left.getCTTbl().getTblPr().getTblW().getW())) - .as("as wide as the page draws it, and its half borders") - .isEqualTo(Math.round((placed + 1) * 20)); - assertThat(textWidth(left)).as("the page's text, and its two borders").isCloseTo(placed - 18, within(0.05)); + .as("as wide as the page draws it, its right end where the page's is") + .isEqualTo(Math.round(placed * 20)); + // Held in at the cell's text, its border would take half a point of its margin, and + // its hang give up the rest of its point: held at the gap's edge, the hang gives up + // the whole point, and that half point comes off its margin. + assertThat(textWidth(left)) + .as("the page's text and its two borders, less the point the hang gives up but for that half point") + .isCloseTo(placed - 18 - 1 + 0.5, within(0.05)); } List notes = notes(content, RIGHT_BORDER); assertThat(notes).as("only the right card's border is held in").hasSize(1);