diff --git a/CHANGELOG.md b/CHANGELOG.md index b47a197ff..3b103212b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names a panel's right border its table cell's edge would cut off.** Word draws + a nested table's right border outside the table and, on screen, cuts off what passes its cell's + edge. A bordered panel reaching that edge has its table written narrower, out of its right + margin, so the border ends half a point short of the edge. Where the margin is too narrow, as + a shape container's always is, the border still passes it. The report said neither. + - The panel's note (`right border`) names how much narrower than the page draws the panel its + table is written, the editor's slack a panel sized round its content is given not counted, + and about how far its border still passes the cell's edge. A painted row is named the same + way, and so is a shape container written as a panel in a table cell. + - Measured in Word on Windows: Obsidian's cards' right borders stood 0.8 and 0.9pt left of the + page's for tables written 1.7pt narrower, and Editorial Proposal's glance card 0.7pt for + 1.9pt. + - 28 panels of the corpus's 62 documents gain the note: 19 drawn in, and 9 passing the edge, + rota-cobalt's outlined chips and two payment panels. None of the DOCX changes. - **A DOCX export names what a picture among a paragraph's words loses of its place, its width or its paint.** Word stands an inline picture on the baseline, and the export moves it by `w:position` to where the page sets it, from the layout's measure of the paragraph's first diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a666cbc2c..5f558239b 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, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, 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; 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, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not; one whose right border reaches the cell's text edge 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; 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 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 aa913f9f0..e835a3086 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -589,7 +589,14 @@ How it lands: right border outside its right edge and, on screen, cuts off what passes its cell's edge and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its whole right border plus half a point short of that edge, the points taken from its right - margin and no more than it holds, so its text keeps its place and width. + margin and no more than it holds, so its text keeps its place and width. The report names + how much narrower than the page draws the panel its table is written (`right border`), the + editor's slack a panel sized round its content is given not counted; where its border then + stands is Word's (measured: 0.8pt left of the page's for a table 1.7pt narrower). What the + right margin cannot take in, a shape container's included, since its padding is set round its + outline, still passes the cell's edge where the cell's own margin does not hold it, and is + named with it, about how far: reckoned, as Word drew them, with the table starting at the + cell's text and its whole border outside it. - **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 72e6dae12..671957453 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 @@ -6942,6 +6942,37 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint */ private static final double CLEAR_OF_THE_GRIDLINE_POINTS = 0.5; + /** What the report calls a panel's right border held inside its table cell. */ + private static final String RIGHT_BORDER = "right border"; + + /** + * The report's note on a panel's right border that its table cell's edge reaches, or + * {@code null} where the border stands clear of it. + * + *

The table's narrowing is exact. Where its border then stands is Word's: drawn outside + * the table, it stood 0.6 to 1.4pt left of the page's in Word for tables 1.4 to 2.4pt + * narrower (measured). What passes the edge is reckoned with the table starting at the + * cell's text and its whole border outside it, as Word drew them, so it is about that much. + * + * @param narrower how much narrower than the page draws the panel its table is written, out + * of its right margin, in points + * @param past what of the border passes the cell's edge, in points + */ + private static String rightBorderHeldIn(double narrower, double past) { + String written = "its table written " + pointsOf(narrower) + "pt narrower than the page draws it"; + if (past > 0.01) { + return (narrower > 0.01 ? written + ", all its right margin gives, and its right border, which Word " + + "draws outside the table, still" + : "its right border, which Word draws outside its table,") + + " reaching about " + pointsOf(past) + "pt past its table cell's edge, where Word cuts it off " + + "on screen"; + } + return narrower > 0.01 + ? written + ", out of its right margin, so its right border, which Word draws outside the table, " + + "ends short of its table cell's edge, past which Word cuts it off on screen" + : null; + } + /** * The space round a shape that is all its composed cell holds, where the cell sets it down its * height as the page does. @@ -7038,7 +7069,8 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container holdTheSpaceAboveOnItsPage(document); } holdTheSpaceAboveATable(document); - double width = panelWidth(node); + PanelWidth sized = panelWidth(node); + double width = sized.width(); double edge = insetLeft + margin.left(); // In the body the indent places the text, the padding in, and the margin is the whole @@ -7054,6 +7086,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // it, the points taken from its right margin and no more than it holds, so its text keeps // its place and its width. double shortOfTheEdge = 0; + // What of the border its right margin cannot take in, and the cell cannot hold, passes + // the cell's edge. + double pastTheEdge = 0; double room = currentCell != null && Double.isFinite(currentCellWidth) && strokeWidth(borders.right()) > 0 ? currentCellWidth - insetRight - Math.max(0, indent) - strokeWidth(borders.right()) - CLEAR_OF_THE_GRIDLINE_POINTS @@ -7084,6 +7119,14 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container double given = halfLeft + Math.min(halfLeft, Math.max(0, padding.left())); outer -= Math.max(0, Math.min(given, halfLeft + outer - (currentCellWidth - insetRight))); } + if (Double.isFinite(room)) { + // The room ends at the cell's text and half a point clear of the gridline; what + // the cell's right margin, what holds the panel in there and that half point do + // not take passes the cell's edge — all of the border, in a cell too narrow to + // take any of it in. + pastTheEdge = Math.max(0, outer - room - insetRight - cellMarginOf(currentCell, false) + - CLEAR_OF_THE_GRIDLINE_POINTS); + } setTableWidth(table, outer); writeGrid(table, new double[]{outer}); CTTcPr properties = cellProperties(cell); @@ -7105,6 +7148,12 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container if (first) { reportFlattenedPaint(node, translucentFill, translucentSides, "its fill", "its borders"); reportBorderWidths(node, "its borders", true, widthsOf(borders)); + // What the table gives up of the slack it was given past the page's width is not + // narrower than the page draws it. + String rightBorder = rightBorderHeldIn(Math.max(0, shortOfTheEdge - sized.slack()), pastTheEdge); + if (rightBorder != null) { + report.add(DocxExportReport.Severity.APPROXIMATED, RIGHT_BORDER, layout.pathOf(node), rightBorder); + } } DocumentInsets margins = insideTheBorders(padding, borders); applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, margins.right() - shortOfTheEdge), @@ -7383,12 +7432,21 @@ private void holdTheSpaceAboveATable(XWPFDocument document) { } } + /** + * How wide a panel is written, and how much of that is slack past the page's width. + * + * @param width the width, or {@code NaN} where it is left to the editor + * @param slack what of it the editor is given past the width the page draws + */ + private record PanelWidth(double width, double slack) { + } + /** * How wide a panel is: as the layout placed it, or else what is left where it is written, * less its own side margins — or unknown, left to the editor, where neither is known: no * canvas, or a cell whose width this export did not write. */ - private double panelWidth(DocumentNode node) { + private PanelWidth panelWidth(DocumentNode node) { boolean known = currentCell != null ? Double.isFinite(currentCellWidth) : contentWidth < Double.MAX_VALUE; // A shape's padding stands round its outline, as its margin does (writePanelPiece). double sides = node.margin().left() + node.margin().right() @@ -7403,13 +7461,14 @@ private double panelWidth(DocumentNode node) { : shape.outline().width()); } if (placed.isEmpty()) { - return available; + return new PanelWidth(available, 0); } // A panel sized round its content is exactly as wide as its longest line, which an // editor setting the text in its own face can push onto a second line; it gets the // slack an auto column gets, where there is room for it. double room = Double.isFinite(available) ? available - placed.getAsDouble() : EDITOR_COLUMN_SLACK_POINTS; - return placed.getAsDouble() + Math.max(0, Math.min(EDITOR_COLUMN_SLACK_POINTS, room)); + double slack = Math.max(0, Math.min(EDITOR_COLUMN_SLACK_POINTS, room)); + return new PanelWidth(placed.getAsDouble() + slack, slack); } /** 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 ff597dfc1..516218706 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 @@ -129,6 +129,14 @@ record Entry(Fate fate, String note) { + "short of it then needs more than the page's height, the row " + "taller; any other is written"; + // Word cuts off what passes a table cell's edge, and draws a nested table's right border + // outside the table: a panel reaching its cell's edge is narrowed out of its right margin. + static final String RIGHT_BORDER = "a panel's right border reaching its table cell's edge, its table written " + + "narrower out of its right margin so the border ends half a point short " + + "of the edge, which would cut it off; how much narrower than the page, " + + "and about how far what the margin cannot take in still passes the edge, " + + "named (right border)"; + // A row in the flow is written as a panel holding its columns, its paint the panel's. private static final String ROW_PAINT = "composed in a table cell, drawn as a shape where it frames no text " + "and not written where it does; in a page zone's line, in a band of " @@ -153,9 +161,6 @@ record Entry(Fate fate, String note) { + "row's column set at its middle or foot, which Word does not " + "count there, the block set that much lower, not named but for " + "a shape container"; - static final String RIGHT_BORDER = "a panel's right border reaching its table cell's edge, drawn up to its width " - + "and half a point short of it so the cell's gridline does not cover it, not " - + "named"; static final String CELL_PADDING = "a top or bottom a cell keeps where Word sets it, one opening with a table or " + "spanning rows, which Word gives the row's other cells too, not named"; static final String LIST_PICTURE = "in a list's item or marker, placed by the list's first line and the item's " @@ -182,7 +187,7 @@ record Entry(Fate fate, String note) { /** The known cases: a gap's note opens with one of them, or the ledger fails. */ private static final List GAPS = List.of(TOP_PULL, BOTTOM_PULL, PADDING_BELOW_ZERO, SPACE_BELOW_AT_THE_FOOT, - RIGHT_BORDER, CELL_PADDING, LIST_PICTURE, SEAT_UNREAD, ROW_COLUMNS_UNLAID, DRAWING_OVER_TEXT, + CELL_PADDING, LIST_PICTURE, SEAT_UNREAD, ROW_COLUMNS_UNLAID, DRAWING_OVER_TEXT, COMPOSED_STACK_LAYER, LAYER_PAST_THE_EDGE, LAYER_UNLAID, PANEL_WIDER_THAN_ITS_CELL); private static final Map, Map> NODES = new LinkedHashMap<>(); @@ -240,8 +245,8 @@ record Entry(Fate fate, String note) { "padding:GAP:" + PADDING_BELOW_ZERO + "; " + TOP_PULL + "; " + BOTTOM_PULL + "; any other is written", "margin:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + SPACE_BELOW_AT_THE_FOOT + "; " + PULL, "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, - "stroke:GAP:" + RIGHT_BORDER + "; " + PANEL_BORDERS, - "cornerRadius:REPORTED", "borders:GAP:" + RIGHT_BORDER + "; " + PANEL_BORDERS, + "stroke:REPORTED:" + RIGHT_BORDER + "; " + PANEL_BORDERS, + "cornerRadius:REPORTED", "borders:REPORTED:" + RIGHT_BORDER + "; " + PANEL_BORDERS, "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + "which has no bookmark; otherwise, in the flow, it is bookmarked", "bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); @@ -352,10 +357,10 @@ record Entry(Fate fate, String note) { + PADDING_BELOW_ZERO + "; any other is written", "margin:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + SPACE_BELOW_AT_THE_FOOT + "; any other is written", - "fillColor:REPORTED:" + ROW_PAINT, "stroke:GAP:" + RIGHT_BORDER + "; " + ROW_PAINT, + "fillColor:REPORTED:" + ROW_PAINT, "stroke:REPORTED:" + RIGHT_BORDER + "; " + ROW_PAINT, "cornerRadius:REPORTED:a panel's corners, which a Word table cell does not round; with no paint " + "it paints nothing", - "borders:GAP:" + RIGHT_BORDER + "; " + ROW_PAINT, + "borders:REPORTED:" + RIGHT_BORDER + "; " + ROW_PAINT, "columns:GAP:" + ROW_COLUMNS_UNLAID + "; " + ROW_COLUMNS, "verticalAlign:WRITTEN", "arrangement:REPORTED:" + ROW_COLUMNS); // The wrappers the layout adds round a timeline's parts, public records the export writes @@ -369,8 +374,8 @@ record Entry(Fate fate, String note) { "padding:GAP:" + PADDING_BELOW_ZERO + "; " + TOP_PULL + "; " + BOTTOM_PULL + "; any other is written", "margin:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + SPACE_BELOW_AT_THE_FOOT + "; " + PULL, "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, - "stroke:GAP:" + RIGHT_BORDER + "; " + PANEL_BORDERS, - "cornerRadius:REPORTED", "borders:GAP:" + RIGHT_BORDER + "; " + PANEL_BORDERS, + "stroke:REPORTED:" + RIGHT_BORDER + "; " + PANEL_BORDERS, + "cornerRadius:REPORTED", "borders:REPORTED:" + RIGHT_BORDER + "; " + PANEL_BORDERS, "keepTogether:WRITTEN", "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + "which has no bookmark; otherwise, in the flow, it is bookmarked", @@ -394,7 +399,7 @@ record Entry(Fate fate, String note) { "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table", "fillColor:REPORTED:its translucency, as a panel's, flattened against the colour under it; " + "drawn, its alpha is written; any other is written", - "stroke:GAP:" + RIGHT_BORDER + "; its translucency, as a panel's border, flattened against the " + "stroke:REPORTED:" + RIGHT_BORDER + "; its translucency, as a panel's border, flattened against the " + "panel's fill or " + "the colour under it, and as a panel's, " + BORDER_WIDTH + "; drawn, its alpha and its width " + "are written; any other is written", diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelRightBorderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelRightBorderTest.java new file mode 100644 index 000000000..33018d7b0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelRightBorderTest.java @@ -0,0 +1,265 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentBorders; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A panel whose right border reaches its table cell's edge. Word draws a nested table's right + * border outside the table and cuts off what passes the cell's edge, so the export writes the + * panel's table narrower out of its right margin; the report names how much narrower than the + * page draws it, and about how far what the margin cannot take in still passes the cell's edge. + * + *

On a 523pt column, a card's 1.5pt border: the page draws the card as wide as the column, its + * border centred on its edges. Word starts the table at the cell's text and draws the border + * whole outside its right edge, half a point clear of the cell's gridline. + */ +class DocxPanelRightBorderTest { + + private static final String SUBJECT = "right border"; + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + private static final double BORDER = 1.5; + // The page's column, which a panel as wide as its cell fills with no slack past it. + private static final double COLUMN = 523; + private static final DocumentStroke ACCENT = DocumentStroke.of(DocumentColor.rgb(26, 86, 148), BORDER); + private static final Pattern HELD = Pattern.compile( + "^its table written (\\d+(?:\\.\\d+)?)pt narrower than the page draws it, out of its right margin, so " + + "its right border, which Word draws outside the table, ends short of its table cell's edge, past " + + "which Word cuts it off on screen$"); + private static final Pattern NOT_HELD = Pattern.compile( + "^(?:its table written (\\d+(?:\\.\\d+)?)pt narrower than the page draws it, all its right margin " + + "gives, and its right border, which Word draws outside the table, still|its right border, which " + + "Word draws outside its table,) reaching about (\\d+(?:\\.\\d+)?)pt past its table cell's edge, " + + "where Word cuts it off on screen$"); + + @Test + void aCardAsWideAsItsCellNamesHowMuchNarrowerItsTableIsWritten() throws Exception { + Consumer content = card(10); + List notes = notes(content); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(notes.get(0).path()).as("on the card").contains("Inner"); + double narrower = held(notes.get(0)); + // The table starts half a border right of the card's left edge and its border ends a + // whole border and half a point past its right one: 0.75 + 0.75 + 1.5 + 0.5 = 3.5. + assertThat(narrower).as("by half of each side's border, the whole border and the half point") + .isCloseTo(3.5, within(0.01)); + Nested nested = nested(content); + assertThat(nested.room()).as("a cell no wider than the card").isCloseTo(COLUMN, within(0.06)); + assertThat(narrower).as("as the file writes it: the card and its half borders, less its table") + .isCloseTo(COLUMN + BORDER - nested.written(), within(0.06)); + } + + @Test + void aCardTheEditorsSlackWidenedNamesOnlyWhatItsTableIsNarrowerThanThePage() throws Exception { + // 0.6pt narrower than its cell, the card is given that much slack, as a panel sized round + // its content is; its table gives that back first, which is not narrower than the page. + double width = COLUMN - 0.6; + Consumer content = page -> page.addSection("Outer", outer -> outer + .fixedWidth(COLUMN) + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .fixedWidth(width) + .stroke(ACCENT) + .padding(DocumentInsets.of(10)) + .addParagraph(p -> p.text("Inner")))); + List notes = notes(content); + + assertThat(notes).hasSize(1); + double narrower = held(notes.get(0)); + assertThat(narrower).isCloseTo(3.5 - 0.6, within(0.01)); + assertThat(narrower).as("as the file writes it: the card and its half borders, less its table") + .isCloseTo(width + BORDER - nested(content).written(), within(0.06)); + } + + @Test + void aCardWhoseRightMarginCannotTakeItsBorderInNamesWhatStillPassesTheEdge() throws Exception { + // A 1pt padding gives a quarter of a point past the half border drawn over it. Word starts + // the table at the cell's text and its left side gives up what runs past the cell, the + // half border and what its margin gives of the other half: 1.5pt. The table ends a quarter + // of a point short of the cell's text, and its border 1.25pt past it. + List notes = notes(card(1)); + + assertThat(notes).hasSize(1); + double[] past = notHeld(notes.get(0)); + assertThat(past[0]).as("all its right margin gives").isCloseTo(0.25, within(0.01)); + assertThat(past[1]).as("about how far its border passes the cell's edge").isCloseTo(1.25, within(0.01)); + } + + @Test + void aCardWithNoRightPaddingNamesItsBorderPastTheEdge() throws Exception { + // Its table ends half a border past the cell's text, its border 2.25pt past it. + List notes = notes(card(0)); + + assertThat(notes).hasSize(1); + double[] past = notHeld(notes.get(0)); + assertThat(past[0]).as("nothing narrower").isNaN(); + assertThat(past[1]).isCloseTo(2.25, within(0.01)); + } + + @Test + void aShapeContainerAsWideAsItsCellIsNamed() throws Exception { + // A box composed in a table cell is written as a panel. Its padding is set round its + // outline, as a margin: nothing of its right margin is there to take the border in, and + // a cell with no padding of its own holds nothing past its text. + List notes = notes(shapeInACell(200, 0)); + + assertThat(notes).hasSize(1); + double[] past = notHeld(notes.get(0)); + assertThat(past[0]).isNaN(); + assertThat(past[1]).isCloseTo(2.25, within(0.01)); + } + + @Test + void aShapeContainerInACellTooNarrowToTakeAnyOfItsBorderInIsNamed() throws Exception { + // A 2pt column leaves no room for a 1.5pt border and the half point: nothing is taken in, + // and the border passes the edge all the same. + List notes = notes(shapeInACell(2, 0)); + + assertThat(notes).hasSize(1); + double[] past = notHeld(notes.get(0)); + assertThat(past[0]).isNaN(); + assertThat(past[1]).isCloseTo(2.25, within(0.01)); + } + + @Test + void aShapeContainerWhoseBorderReachesIntoItsCellsPaddingIsNotNamed() throws Exception { + // The 2.5pt padding past the cell's text holds the 2.25pt the box's border reaches past + // it: the border ends a quarter of a point inside the cell's edge, within the half point + // left clear of the gridline, and stands where the page draws it. + assertThat(notes(shapeInACell(200, 2.5))).isEmpty(); + } + + @Test + void aPaintedRowAsWideAsItsCellIsNamedTheSameWay() throws Exception { + List notes = notes(page -> page.addSection("Outer", outer -> outer + .fixedWidth(COLUMN) + .fillColor(SURFACE) + .addRow("Band", row -> row + .stroke(ACCENT) + .padding(DocumentInsets.of(10)) + .addParagraph("Left") + .addParagraph("Right")))); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).path()).contains("Band"); + assertThat(held(notes.get(0))).isCloseTo(3.5, within(0.01)); + } + + @Test + void aCardWithRoomPastItsRightBorderIsNotNamed() throws Exception { + // 6pt narrower than its cell, it leaves the border and the half point clear of the gridline + // inside the cell, the editor's slack given too. + assertThat(notes(page -> page.addSection("Outer", outer -> outer + .fixedWidth(COLUMN) + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .fixedWidth(COLUMN - 6) + .stroke(ACCENT) + .padding(DocumentInsets.of(10)) + .addParagraph(p -> p.text("Inner")))))).isEmpty(); + } + + @Test + void aCardWithNoRightBorderIsNotNamed() throws Exception { + assertThat(notes(page -> page.addSection("Outer", outer -> outer + .fixedWidth(COLUMN) + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .borders(DocumentBorders.left(ACCENT)) + .padding(DocumentInsets.of(0)) + .addParagraph(p -> p.text("Inner")))))).isEmpty(); + } + + @Test + void aCardInTheBodyIsNotNamed() throws Exception { + // In the body no cell's edge cuts the border off: the table keeps its whole width. + assertThat(notes(page -> page.addSection("Card", card -> card + .stroke(ACCENT) + .padding(DocumentInsets.of(0)) + .addParagraph(p -> p.text("Card"))))).isEmpty(); + } + + /** A stroked card as wide as the filled panel holding it, padded the same on every side. */ + private static Consumer card(double padding) { + return page -> page.addSection("Outer", outer -> outer + .fixedWidth(COLUMN) + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .fixedWidth(COLUMN) + .stroke(ACCENT) + .padding(DocumentInsets.of(padding)) + .addParagraph(p -> p.text("Inner")))); + } + + /** A stroked box as wide as its table cell's text, the cell padded the same on every side. */ + private static Consumer shapeInACell(double column, double padding) { + return page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(column)) + .rowCells(DocumentTableCell.node(new ShapeContainerBuilder() + .name("Shape").rectangle(column - 2 * padding, 30) + .stroke(ACCENT) + .position(new ParagraphBuilder().name("Label").text("Label").build(), 10, 0, + LayerAlign.CENTER_LEFT) + .build()) + .withStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(padding)).build()))); + } + + /** The right-border notes of an export the layout stands behind. */ + private static List notes(Consumer content) throws Exception { + DocxExportReport report = DocxExports.report(595, 842, 36, content); + assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry"); + return report.bySubject().getOrDefault(SUBJECT, List.of()); + } + + /** How much narrower a note of a border held inside its cell names its table. */ + private static double held(DocxExportReport.Note note) { + Matcher held = HELD.matcher(note.detail()); + assertThat(held.matches()).as("the note: %s", note.detail()).isTrue(); + return Double.parseDouble(held.group(1)); + } + + /** How much narrower a note of a border past the edge names its table, NaN for none, and how far past. */ + private static double[] notHeld(DocxExportReport.Note note) { + Matcher past = NOT_HELD.matcher(note.detail()); + assertThat(past.matches()).as("the note: %s", note.detail()).isTrue(); + return new double[]{past.group(1) == null ? Double.NaN : Double.parseDouble(past.group(1)), + Double.parseDouble(past.group(2))}; + } + + /** The holding cell's text width and the nested table's written width, in points. */ + private record Nested(double room, double written) { + } + + private static Nested nested(Consumer content) throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, content)) { + XWPFTableCell host = document.getTables().get(0).getRow(0).getCell(0); + var properties = host.getCTTc().getTcPr(); + double room = (DocxTwips.of(properties.getTcW().getW()) + - DocxTwips.of(properties.getTcMar().getLeft().getW()) + - DocxTwips.of(properties.getTcMar().getRight().getW())) / 20.0; + double written = DocxTwips.of(host.getTables().get(0).getCTTbl().getTblPr().getTblW().getW()) / 20.0; + return new Nested(room, written); + } + } +} 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 714b153f7..8c996f2d3 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 @@ -292,7 +292,7 @@ class DocxValueFieldLedgerTest { "color:REPORTED:its translucency as a Word border — a panel's, a table cell's, a rule's — flattened " + "against the panel's fill or the colour under it; a chart's, with its drawing; drawn, its alpha is " + "written; any other is written", - "width:GAP:" + RIGHT_BORDER + "width:REPORTED:" + RIGHT_BORDER + "; as a Word border, a width that, rounded to the eighth of a point, is below a quarter " + "of a point or past 12pt, drawn at Word's thinnest or widest, and the room Word makes for a panel's " + "or a cell's border past the page's; a chart's, with its drawing; any other is written"); @@ -317,7 +317,7 @@ class DocxValueFieldLedgerTest { "left:REPORTED:as a panel's: its translucency, flattened against the panel's fill or the colour " + "under it, and a width outside Word's quarter of a point to 12pt; a row's where its paint is not " + "written; any other is written", - "right:GAP:" + RIGHT_BORDER + "right:REPORTED:" + RIGHT_BORDER + "; as a panel's: its translucency, flattened against the panel's fill or the colour " + "under it, and a width outside Word's quarter of a point to 12pt; a row's where its paint is not " + "written; any other is written",