From 66550ad4ee77ef997b99dac6e275894ff05c93a0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 09:29:23 +0100 Subject: [PATCH 1/2] fix(docx): take a panel's top border inside it when no space above can, and hold the body at a margin a band reaches past --- CHANGELOG.md | 17 ++++ .../architecture/backend-capability-matrix.md | 4 +- docs/recipes/docx-export.md | 14 ++- .../semantic/docx/DocxSemanticBackend.java | 97 ++++++++++++++++--- .../semantic/docx/DocxPanelHeightTest.java | 64 ++++++++++++ .../semantic/docx/DocxTextBandTest.java | 31 ++++++ 6 files changed, 209 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a3f17e0..d857c68ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`MerchantInvoice` fits its page in Word again.** Two things pushed its footer row onto a + second page. + - Its payment panel opens a table cell, so no space above could take the panel's 0.875pt + top border, which Word draws above a cell's content. Every line inside stood that much + low, and the row that much taller. A panel whose cell margin at the top is narrower than + its border, with no space above it, now takes what of the border its padding does not hold + out of the space above its first line, where that line has some, and its held height is + that much less; LibreOffice, which adds the border to a row's height once, draws such a + panel that border's width shorter. + - Its footer reaches 9.8pt from the page's edge, past the page's 3.4pt margin, and Word + moved the body clear of it. A band reaching past the margin now writes that margin + negative (a margin of none as a twentieth of a point), which Word reads as holding the body + at the margin, as the page does. LibreOffice reads it as positive and still moves the body + clear. + `ObsidianInvoice`'s p90 drift in Word falls from 1.6pt to 0.6, `PlatformInvoice`'s from + 0.65pt to 0.4, and `MerchantInvoice`'s from 1.6pt to 0.8. + - **An icon alone in a table cell moves with its row in Word.** A drawing the page places in a cell of its own — a band's icon beside its label — was drawn from the page's edges, and so off its row wherever Word set the rows above it a little taller or shorter than the page: diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 41ecc785c..b8bc6d0b2 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -69,7 +69,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture, followed by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: 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. The table takes the width the layout placed the container at, plus a point of editor slack, and 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. 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 DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — 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: 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. A panel with no space above it — one opening a table cell — and a top margin narrower than its border takes what of the border its padding does not hold from the space above its first line, where that line has some, its held height as much less, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice draws such a panel that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack, and 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. 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 DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — 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 to the page, 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, 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 in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is 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 to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | @@ -113,7 +113,7 @@ honour an option ignores it (documented contract). | Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyOutputOptions` (OPC core properties — title, author as creator, subject, keywords; OPC has no producer field here either, so that value is not representable) | | Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text, in every header part of the section (default, first page, even pages), so it is drawn on every page as on the page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. A fill's alpha is carried as the shape's. Measured in LibreOffice, not in Word: on the `CharcoalGold` CV the charcoal sidebar column is back behind its white text on every page. On a page with no top margin the empty header pushes the first line down about 3pt. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's | | Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ | -| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border; the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (Word moves the body clear of it) are reported | +| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border; the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported | | Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped and reported on the `docx` logger. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | | Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ | | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index bc49b3bc1..26f26996c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -701,7 +701,13 @@ cell's shape from the outer cell's top, so a shape placed from a paragraph in a wherever the nesting put it. Word draws a panel's top and bottom borders outside the cell's shading, where the page strokes them on the panel's edge, so the top border comes out of the space above the panel, and the bottom border out of the space the panel holds below itself -or, past that, out of the space above the paragraph, table or panel that follows. The body's +or, past that, out of the space above the paragraph, table or panel that follows. A panel with +no space above it — one that opens a table cell — whose cell margin at the top is narrower +than its border takes what of the border its padding does not hold out of the space above its +first line inside, where that line has some, and its held height is that much less: Word +starts a cell's content below its top border, or its top margin where that is wider, and draws +both borders outside the row's height. LibreOffice adds the border to the height once, so a +panel whose held height sets its size stands that border's width shorter there. The body's shapes stand above the page backgrounds, which LibreOffice stacks together with them. Two limits, each named in the report: - A transform is not carried: a rotated or scaled shape is drawn upright at its size. @@ -744,8 +750,10 @@ band kept off the first page (`showOnFirstPage(false)`, or counted from page have no Word equivalent and are reported: a band that starts after the second page is written on every page but the first, and page numbers that do not count from 1 on the first page are numbered from 1 by Word. A band alone of -its kind that reaches past the page margin is reported too: the page lets it overlap the -body, Word moves the body clear of it. A band and a page zone of the same +its kind that reaches past the page margin writes that margin negative: the page lets the +band overlap the body, and Word holds the body at a negative margin whatever the band +reaches, where it moves the body clear of a band past a positive one. LibreOffice reads the +margin as positive and still moves the body clear, which is reported. A band and a page zone of the same kind share Word's one header or footer. A `{date}` token keeps a deterministic export byte-identical only within one day unless `-Dgraphcompose.renderDate` pins it, as for the PDF. 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 1faa9d789..e42119182 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 @@ -1286,24 +1286,50 @@ private void placeBand(XWPFDocument document, DocumentHeaderFooter band, boolean } else { margin.setFooter(distance); } - // The page lets a band reach into the body; Word moves the body clear of its header and - // footer instead, which can add a page. A framed band stands beside the flow and moves - // nothing. + // The page lets a band reach into the body; past a positive margin Word moves the body + // clear of its header and footer instead, which can add a page. A framed band stands + // beside the flow and moves nothing. if (framed) { return; } - BigInteger pageMargin = header ? margin.getTop() instanceof BigInteger top ? top : null - : margin.getBottom() instanceof BigInteger bottom ? bottom : null; - double reach = DocxTextBands.distanceFromEdge(band) + DocxTextBands.lineHeight(band) - + (band.isShowSeparator() ? DocxTextBands.separatorSpace(band) + band.getSeparatorThickness() : 0); - // A point of grace: a band as tall as the margin with its separator on the edge moves the - // body by a fraction of a point, which is not worth a note. - if (pageMargin != null && toTwips(reach) > pageMargin.longValue() + toTwips(1)) { + if (reachesPastTheMargin(document, band)) { + // The page lets the band overlap the body. Word moves the body clear of a header or + // footer taller than its margin — MerchantInvoice's footer row, set down to its 3.4pt + // margin, went to a second page under a footer reaching 9.8pt — unless the margin is + // written negative, which holds the body at it whatever the band reaches. + // No margin has no negative: the least one stands for it. + if (header) { + margin.setTop(BigInteger.valueOf(-Math.max(1, twipsOf(margin.getTop())))); + } else { + margin.setBottom(BigInteger.valueOf(-Math.max(1, twipsOf(margin.getBottom())))); + } report.add(DocxExportReport.Severity.APPROXIMATED, "page " + zoneName(band), sectioned ? "section " + (sectionIndex + 1) : null, - "it reaches " + Math.round(reach * 10) / 10.0 + "pt from the page edge, past the " - + "page margin, and Word moves the body clear of it where the page lets the two overlap"); + "it reaches " + Math.round(reachOf(band) * 10) / 10.0 + "pt from the page edge, past the " + + "page margin, which is written negative so that Word holds the body at the margin, as " + + "the page does; LibreOffice moves the body clear of it"); + } + } + + /** How far a text band reaches from its page edge, its separator included, in points. */ + private static double reachOf(DocumentHeaderFooter band) { + return DocxTextBands.distanceFromEdge(band) + DocxTextBands.lineHeight(band) + + (band.isShowSeparator() ? DocxTextBands.separatorSpace(band) + band.getSeparatorThickness() : 0); + } + + /** + * Whether a text band reaches past the page margin on its edge, into the body, where the page + * lets the two overlap. A twentieth of a point of grace is rounding. + */ + private boolean reachesPastTheMargin(XWPFDocument document, DocumentHeaderFooter band) { + CTSectPr sectPr = bodySectPr(document); + if (!sectPr.isSetPgMar()) { + return false; } + CTPageMar margin = sectPr.getPgMar(); + Object edge = band.getZone() == DocumentHeaderFooterZone.HEADER ? margin.getTop() : margin.getBottom(); + return edge instanceof Number pageMargin && pageMargin.longValue() >= 0 + && toTwips(reachOf(band)) > pageMargin.longValue() + 1; } /** @@ -2994,11 +3020,13 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // space above this table too, and a table carries no space above itself. owePendingSpacingAfter(carriedSpacingBefore + (first ? margin.top() : 0)); carriedSpacingBefore = 0; + double topNotTaken = 0; if (first) { // Word draws a row's top and bottom borders outside its shading, so the table is as // much taller than the panel as its borders are thick. The page strokes them on the // box's edge, taking no room. So the border comes out of the space above, and so // does the one a panel just above could not take out of its own space below. + topNotTaken = Math.max(0, strokeWidth(borders.top()) - Math.max(0, pendingSpacingAfter - borderBelow)); pendingSpacingAfter = Math.max(0, pendingSpacingAfter - strokeWidth(borders.top()) - borderBelow); borderBelow = 0; } @@ -3076,12 +3104,24 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // line of text taller. holdToHairline(cell.addParagraph()); } + // Word starts the cell's content below its top border, or its top margin where that is + // wider: the border then stands inside the margin. The page starts it the padding below + // the panel's edge. What of the border no space above took, and what the margin holds + // past the border, less the padding, is how far low the content would stand. + double low = topNotTaken + Math.max(0, margins.top() - strokeWidth(borders.top())) - padding.top(); + double takenInside = takeTheTopBorderInside(cell, low); com.demcha.compose.document.layout.PlacedNode placed = first && last && layout.onOnePage(node) ? layout.placement(node) : null; if (placed != null && placed.placementHeight() > 0) { // Its height is the page's: what makes a panel taller than its text — an icon drawn // where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's // due-date card closed from 59.4pt to its text's 26, and its calendar hung below it. - holdRowAtLeast(table.getRow(0), placed.placementHeight()); + // Less what of its top border came out of the room inside it: Word draws both + // borders outside the row's height, and the one above is no longer taken from the + // space above. Measured on MerchantInvoice's payment panel, Word drew it 0.8pt taller + // than the page without this, and as tall with it. LibreOffice adds the heavier + // border to the height once, which holdRowAtLeast already allows for, so a panel + // whose height this sets stands that border's width shorter there. + holdRowAtLeast(table.getRow(0), placed.placementHeight() - takenInside); } else if (first && last && layout.placement(node) == null && node instanceof ShapeContainerNode shape && shape.outline().height() > 0) { // Composed in a table cell, it has no placement; its outline states its height, as it @@ -3107,6 +3147,37 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container } } + /** + * Takes how far a panel's content would stand low in Word out of the space above its first + * paragraph, as far as that holds, and answers how much it took, in points. + * + *

Word draws a cell's top border above its content unless the cell's top margin is wider; + * the page strokes it on the panel's edge, inside the padding. Where the panel has no padding + * to hold the border and no space above it to take the border from — it opens a cell, or + * follows nothing — every line inside stood the border's width low and the row as much taller: + * {@code MerchantInvoice}'s payment panel, first in its row's cell, stood 0.9pt low in Word + * and pushed its footer onto a second page. The space above its first paragraph — the room the + * page leaves over its heading — takes it instead.

+ */ + private static double takeTheTopBorderInside(XWPFTableCell cell, double low) { + if (!(low > 0) || cell.getBodyElements().isEmpty() + || !(cell.getBodyElements().get(0) instanceof XWPFParagraph first)) { + return 0; + } + CTPPr properties = first.getCTP().getPPr(); + if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) { + return 0; + } + CTSpacing spacing = properties.getSpacing(); + long before = twipsOf(spacing.getBefore()); + long taken = Math.min(before, toTwips(low)); + if (taken <= 0) { + return 0; + } + spacing.setBefore(BigInteger.valueOf(before - taken)); + return taken / POINT_TO_TWIP; + } + /** * Gives the space owed above a table somewhere to go when nothing above it can hold it. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java index d7257be1f..019aaec2d 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java @@ -10,6 +10,7 @@ import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; @@ -88,6 +89,69 @@ void aPanelsHeightIsWrittenLessTheMarginsItsCellHolds() throws Exception { } } + @Test + void aPanelOpeningACellTakesItsTopBorderFromTheSpaceOverItsFirstLine() throws Exception { + // MerchantInvoice's payment panel: first in its row's cell, with no padding and no space + // above to take its border from. Word draws the border above the cell's content, so every + // line inside stood the border's width low and the row as much taller. + XWPFTableCell bordered = panelInARow(1); + XWPFTableCell plain = panelInARow(0); + + assertThat(beforeOf(bordered)).as("the room over the heading, less the 1pt border") + .isEqualTo(beforeOf(plain) - 20); + // The height written is already less the border LibreOffice adds to it; it is also less + // the border taken over the heading, which Word draws above the row's height. + assertThat(heightOf(outerTableOf(bordered))).as("the row's height, less the border twice") + .isEqualTo(heightOf(outerTableOf(plain)) - 20 - 20); + } + + @Test + void aPaddedPanelOpeningACellKeepsTheSpaceOverItsFirstLine() throws Exception { + // Its top margin is wider than its border, which Word then draws inside the margin. + XWPFTableCell bordered = panelInARow(1, 10); + XWPFTableCell plain = panelInARow(0, 10); + + assertThat(beforeOf(bordered)).isEqualTo(beforeOf(plain)); + } + + private static XWPFTableCell panelInARow(double border) throws Exception { + return panelInARow(border, 0); + } + + /** The cell of a painted panel opening a row's first column, its heading 7pt below its top. */ + private static XWPFTableCell panelInARow(double border, double padding) throws Exception { + XWPFDocument document = export(page -> page.addRow("Settlement", row -> row + .columns(com.demcha.compose.document.style.DocumentRowColumn.weight(1), + com.demcha.compose.document.style.DocumentRowColumn.weight(1)) + .addSection("Panel", panel -> { + panel.keepTogether().fillColor(DocumentColor.rgb(247, 249, 246)).padding(DocumentInsets.of(padding)); + if (border > 0) { + panel.stroke(com.demcha.compose.document.style.DocumentStroke.of(DocumentColor.rgb(200, 200, 200), border)); + } + // A row in a row's column is laid in a layer stack, as the template lays it. + panel.addLayerStack(stack -> stack.name("HeadingLayer").layer( + new com.demcha.compose.document.dsl.RowBuilder().name("Heading") + .padding(new DocumentInsets(7, 0, 0, 0)) + .columns(com.demcha.compose.document.style.DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("PAYMENT DETAILS")) + .build())); + panel.addParagraph(p -> p.text("Bank Name: Harbour Bank of Canada")); + }) + .addParagraph(p -> p.text("Subtotal")))); + return document.getTables().get(0).getRow(0).getCell(0).getTables().get(0).getRow(0).getCell(0); + } + + private static XWPFTable outerTableOf(XWPFTableCell cell) { + return cell.getTableRow().getTable(); + } + + /** The space above a cell's first paragraph, in twips. */ + private static long beforeOf(XWPFTableCell cell) { + XWPFParagraph first = (XWPFParagraph) cell.getBodyElements().get(0); + var spacing = first.getCTP().getPPr().getSpacing(); + return spacing.isSetBefore() ? ((Number) spacing.getBefore()).longValue() : 0; + } + /** A table's first row's written height, which the export writes "at least". */ private static int heightOf(XWPFTable table) { org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTrPr properties = table.getRow(0).getCtRow().getTrPr(); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java index 245bd44b4..21df8fca0 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java @@ -292,6 +292,37 @@ void aBandReachingPastTheMarginIsReported() throws Exception { } } + @Test + void aBandReachingPastTheMarginLeavesTheBodyAtTheMargin() throws Exception { + // The page lets the band overlap the body; Word moves the body clear of a header taller + // than its margin unless the margin is written negative. MerchantInvoice's footer row, + // set down to its 3.4pt margin, went to a second page under a 9.8pt footer. + try (XWPFDocument document = export(null, session -> session.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER).height(60).leftText("Tall").build()))) { + Object top = document.getDocument().getBody().getSectPr().getPgMar().getTop(); + assertThat(((Number) top).longValue()).as("the 30pt margin, held whatever the band reaches") + .isEqualTo(-600); + } + } + + @Test + void aFooterReachingPastTheMarginLeavesTheBodyAtTheMargin() throws Exception { + try (XWPFDocument document = export(null, session -> session.footer(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.FOOTER).height(60).leftText("Tall").build()))) { + Object bottom = document.getDocument().getBody().getSectPr().getPgMar().getBottom(); + assertThat(((Number) bottom).longValue()).isEqualTo(-600); + } + } + + @Test + void aBandWithinTheMarginLeavesItAsItIs() throws Exception { + try (XWPFDocument document = export(null, session -> session.footer(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.FOOTER).leftText("Short").build()))) { + Object bottom = document.getDocument().getBody().getSectPr().getPgMar().getBottom(); + assertThat(((Number) bottom).longValue()).isEqualTo(600); + } + } + private static T only(List parts) { assertThat(parts).hasSize(1); return parts.get(0); From b8dc4bdc2c1175761088e88c7e095da69ba082f1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 1 Oct 2026 09:52:48 +0100 Subject: [PATCH 2/2] fix(docx): hold a panel's height less the borders Word draws outside it only where its padding cannot hold them, and leave a framed band's margin as it is --- CHANGELOG.md | 15 ++--- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 16 ++--- .../semantic/docx/DocxSemanticBackend.java | 60 ++++++++++--------- .../semantic/docx/DocxPanelHeightTest.java | 31 ++++++++-- .../semantic/docx/DocxTextBandTest.java | 30 ++++++++++ 6 files changed, 108 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d857c68ac..b3de47b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,14 @@ follow semantic versioning; release dates are ISO 8601. second page. - Its payment panel opens a table cell, so no space above could take the panel's 0.875pt top border, which Word draws above a cell's content. Every line inside stood that much - low, and the row that much taller. A panel whose cell margin at the top is narrower than - its border, with no space above it, now takes what of the border its padding does not hold - out of the space above its first line, where that line has some, and its held height is - that much less; LibreOffice, which adds the border to a row's height once, draws such a - panel that border's width shorter. + low, and the row that much taller. A panel with less space above it than its top border now + takes what of the border neither that space nor its padding holds out of the space above + its first line, where that line has some; where its row holds the page's height, that + height is less the borders Word draws outside it. LibreOffice, which adds the heavier border + to a row's height once, draws such a panel with two borders that border's width shorter. - Its footer reaches 9.8pt from the page's edge, past the page's 3.4pt margin, and Word - moved the body clear of it. A band reaching past the margin now writes that margin + moved the body clear of it. A band alone of its kind reaching past the margin, by any + more than a twentieth of a point, now writes that margin negative (a margin of none as a twentieth of a point), which Word reads as holding the body at the margin, as the page does. LibreOffice reads it as positive and still moves the body clear. @@ -400,7 +401,7 @@ follow semantic versioning; release dates are ISO 8601. legal lines and its page number — stands in a frame at its own height on the page. A band kept off the first page leaves the first page's part empty; a band starting after page 2, page numbers that do not count from 1 on page 1, and a band alone of its kind that reaches past the - page margin, which Word moves the body clear of, are reported. A band and a page zone of the + page margin are reported. A band and a page zone of the same kind share Word's one header or footer. Across the sixty-two template renders, the thirteen with a band now lose no word of it, and no page count or text line elsewhere changes. diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index b8bc6d0b2..6cd868dce 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -69,7 +69,7 @@ Payload records live in `core` under | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture, followed by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: 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. A panel with no space above it — one opening a table cell — and a top margin narrower than its border takes what of the border its padding does not hold from the space above its first line, where that line has some, its held height as much less, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice draws such a panel that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack, and 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. 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 DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — 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: 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. A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice draws such a panel with two borders that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack, and 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. 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 DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — 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 to the page, 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, 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 in a body paragraph rather than a cell's, which Word prints it clipped to, wherever its page has or can be given one — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against what lies under it; the line cap and a link are not carried (a link is 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 to the page, as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored to the page as a rectangle is | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 26f26996c..74afb6d22 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -702,12 +702,13 @@ wherever the nesting put it. Word draws a panel's top and bottom borders outside shading, where the page strokes them on the panel's edge, so the top border comes out of the space above the panel, and the bottom border out of the space the panel holds below itself or, past that, out of the space above the paragraph, table or panel that follows. A panel with -no space above it — one that opens a table cell — whose cell margin at the top is narrower -than its border takes what of the border its padding does not hold out of the space above its -first line inside, where that line has some, and its held height is that much less: Word -starts a cell's content below its top border, or its top margin where that is wider, and draws -both borders outside the row's height. LibreOffice adds the border to the height once, so a -panel whose held height sets its size stands that border's width shorter there. The body's +less space above it than its top border — such as one opening a table cell — takes what of the +border neither that space nor its padding holds out of the space above its first line inside, +where that line has some, and where its row holds the page's height, that height is less the +borders Word draws outside it: Word starts a cell's content below its top border, or its top +margin where that is wider, and draws both borders outside the row's height. LibreOffice adds +the heavier border to the height once, so such a panel with two borders, its height set by the +held row, stands that border's width shorter there. The body's shapes stand above the page backgrounds, which LibreOffice stacks together with them. Two limits, each named in the report: - A transform is not carried: a rotated or scaled shape is drawn upright at its size. @@ -750,7 +751,8 @@ band kept off the first page (`showOnFirstPage(false)`, or counted from page have no Word equivalent and are reported: a band that starts after the second page is written on every page but the first, and page numbers that do not count from 1 on the first page are numbered from 1 by Word. A band alone of -its kind that reaches past the page margin writes that margin negative: the page lets the +its kind that reaches past the page margin, by more than a twentieth of a point, writes that +margin negative: the page lets the band overlap the body, and Word holds the body at a negative margin whatever the band reaches, where it moves the body clear of a band past a positive one. LibreOffice reads the margin as positive and still moves the body clear, which is reported. A band and a page zone of the same 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 e42119182..c97913326 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 @@ -3105,23 +3105,30 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container holdToHairline(cell.addParagraph()); } // Word starts the cell's content below its top border, or its top margin where that is - // wider: the border then stands inside the margin. The page starts it the padding below - // the panel's edge. What of the border no space above took, and what the margin holds - // past the border, less the padding, is how far low the content would stand. - double low = topNotTaken + Math.max(0, margins.top() - strokeWidth(borders.top())) - padding.top(); - double takenInside = takeTheTopBorderInside(cell, low); + // wider, the border then standing inside the margin; the page starts it the padding below + // the panel's edge. What of the border no space above took, less the padding, is how far + // low the content would stand: a padding as wide as the border holds it. + double low = topNotTaken - padding.top(); + takeTheTopBorderInside(cell, low); com.demcha.compose.document.layout.PlacedNode placed = first && last && layout.onOnePage(node) ? layout.placement(node) : null; if (placed != null && placed.placementHeight() > 0) { // Its height is the page's: what makes a panel taller than its text — an icon drawn // where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's // due-date card closed from 59.4pt to its text's 26, and its calendar hung below it. - // Less what of its top border came out of the room inside it: Word draws both - // borders outside the row's height, and the one above is no longer taken from the - // space above. Measured on MerchantInvoice's payment panel, Word drew it 0.8pt taller - // than the page without this, and as tall with it. LibreOffice adds the heavier - // border to the height once, which holdRowAtLeast already allows for, so a panel - // whose height this sets stands that border's width shorter there. - holdRowAtLeast(table.getRow(0), placed.placementHeight() - takenInside); + // Where its padding does not hold its top border, Word draws both borders outside the + // row's height, the panel's top where the space above put it: so the height held is + // the page's less the part of the top border no space above took and less the bottom + // border. holdRowAtLeast already takes the heavier of the two off, which LibreOffice + // adds to the height once; the rest comes off here. Measured on MerchantInvoice's + // payment panel, Word drew it 0.8pt taller than the page without this, and as tall + // with it; LibreOffice draws such a panel that border's width shorter. A padding that + // holds the border leaves the height as it was: Word draws the border inside the + // margin, and InvoiceMetered's card moved its top down taking it off. + double bordersOutside = low > 0 + ? topNotTaken + strokeWidth(borders.bottom()) + - Math.max(strokeWidth(borders.top()), strokeWidth(borders.bottom())) + : 0; + holdRowAtLeast(table.getRow(0), placed.placementHeight() - Math.max(0, bordersOutside)); } else if (first && last && layout.placement(node) == null && node instanceof ShapeContainerNode shape && shape.outline().height() > 0) { // Composed in a table cell, it has no placement; its outline states its height, as it @@ -3149,33 +3156,32 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container /** * Takes how far a panel's content would stand low in Word out of the space above its first - * paragraph, as far as that holds, and answers how much it took, in points. + * paragraph, as far as that holds. * *

Word draws a cell's top border above its content unless the cell's top margin is wider; - * the page strokes it on the panel's edge, inside the padding. Where the panel has no padding - * to hold the border and no space above it to take the border from — it opens a cell, or - * follows nothing — every line inside stood the border's width low and the row as much taller: - * {@code MerchantInvoice}'s payment panel, first in its row's cell, stood 0.9pt low in Word - * and pushed its footer onto a second page. The space above its first paragraph — the room the - * page leaves over its heading — takes it instead.

- */ - private static double takeTheTopBorderInside(XWPFTableCell cell, double low) { + * the page strokes it on the panel's edge. Where the panel has no padding to hold the border + * and less space above it than the border — it opens a cell, or follows nothing — every line + * inside stood the border's width low: {@code MerchantInvoice}'s payment panel, first in its + * row's cell, stood 0.9pt low in Word and pushed its footer onto a second page. The space above + * its first paragraph — the room the page leaves over its heading — takes it instead.

+ * + * @param low how far low the content would stand, in points + */ + private static void takeTheTopBorderInside(XWPFTableCell cell, double low) { if (!(low > 0) || cell.getBodyElements().isEmpty() || !(cell.getBodyElements().get(0) instanceof XWPFParagraph first)) { - return 0; + return; } CTPPr properties = first.getCTP().getPPr(); if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) { - return 0; + return; } CTSpacing spacing = properties.getSpacing(); long before = twipsOf(spacing.getBefore()); long taken = Math.min(before, toTwips(low)); - if (taken <= 0) { - return 0; + if (taken > 0) { + spacing.setBefore(BigInteger.valueOf(before - taken)); } - spacing.setBefore(BigInteger.valueOf(before - taken)); - return taken / POINT_TO_TWIP; } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java index 019aaec2d..2283d7e72 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java @@ -112,21 +112,44 @@ void aPaddedPanelOpeningACellKeepsTheSpaceOverItsFirstLine() throws Exception { XWPFTableCell plain = panelInARow(0, 10); assertThat(beforeOf(bordered)).isEqualTo(beforeOf(plain)); + // Its margins are half a border narrower top and bottom, its border LibreOffice's + // allowance: the height held is the same, nothing more taken off for Word. + assertThat(heightOf(outerTableOf(bordered))).isEqualTo(heightOf(outerTableOf(plain))); + } + + @Test + void aPanelRuledAboveOnlyOpeningACellKeepsTheHeightItHeld() throws Exception { + // Word draws the one border outside the row's height, where LibreOffice's allowance for it + // already took it off; nothing more comes off for it. + XWPFTableCell ruled = panelInARow(1, 0, true); + XWPFTableCell plain = panelInARow(0, 0, false); + + assertThat(beforeOf(ruled)).as("the room over the heading, less the border").isEqualTo(beforeOf(plain) - 20); + assertThat(heightOf(outerTableOf(ruled))).as("less the border once") + .isEqualTo(heightOf(outerTableOf(plain)) - 20); } private static XWPFTableCell panelInARow(double border) throws Exception { - return panelInARow(border, 0); + return panelInARow(border, 0, false); } - /** The cell of a painted panel opening a row's first column, its heading 7pt below its top. */ private static XWPFTableCell panelInARow(double border, double padding) throws Exception { + return panelInARow(border, padding, false); + } + + /** The cell of a painted panel opening a row's first column, its heading 7pt below its top. */ + private static XWPFTableCell panelInARow(double border, double padding, boolean aboveOnly) throws Exception { XWPFDocument document = export(page -> page.addRow("Settlement", row -> row .columns(com.demcha.compose.document.style.DocumentRowColumn.weight(1), com.demcha.compose.document.style.DocumentRowColumn.weight(1)) .addSection("Panel", panel -> { panel.keepTogether().fillColor(DocumentColor.rgb(247, 249, 246)).padding(DocumentInsets.of(padding)); - if (border > 0) { - panel.stroke(com.demcha.compose.document.style.DocumentStroke.of(DocumentColor.rgb(200, 200, 200), border)); + com.demcha.compose.document.style.DocumentStroke stroke = + com.demcha.compose.document.style.DocumentStroke.of(DocumentColor.rgb(200, 200, 200), border); + if (border > 0 && aboveOnly) { + panel.borders(new com.demcha.compose.document.style.DocumentBorders(stroke, null, null, null)); + } else if (border > 0) { + panel.stroke(stroke); } // A row in a row's column is laid in a layer stack, as the template lays it. panel.addLayerStack(stack -> stack.name("HeadingLayer").layer( diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java index 21df8fca0..7e9817d24 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandTest.java @@ -314,6 +314,36 @@ void aFooterReachingPastTheMarginLeavesTheBodyAtTheMargin() throws Exception { } } + @Test + void aFramedBandLeavesTheMarginAsItIs() throws Exception { + // Two footers stand in frames at their own heights, beside the part's flow. + try (XWPFDocument document = export(null, session -> { + session.footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER).height(60) + .leftText("Tall").build()); + session.footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER) + .rightText("Short").build()); + })) { + Object bottom = document.getDocument().getBody().getSectPr().getPgMar().getBottom(); + assertThat(((Number) bottom).longValue()).isEqualTo(600); + } + } + + @Test + void aBandPastNoMarginWritesTheLeastNegativeOne() throws Exception { + // A margin of none has no negative: a twentieth of a point stands for it. + try (DocumentSession session = GraphCompose.document().pageSize(300, 200) + .margin(DocumentInsets.of(0)).create()) { + session.header(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.HEADER) + .leftText("Edge").build()); + session.pageFlow(page -> page.addParagraph(p -> p.text("Body"))); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend())))) { + Object top = document.getDocument().getBody().getSectPr().getPgMar().getTop(); + assertThat(((Number) top).longValue()).isEqualTo(-1); + } + } + } + @Test void aBandWithinTheMarginLeavesItAsItIs() throws Exception { try (XWPFDocument document = export(null, session -> session.footer(DocumentHeaderFooter.builder()