diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5df42cb..ccdda5b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A chip's label taller than the chip no longer makes it taller in Word.** A shape composed in + a table cell centres its one line of text in it, and the page lets the line pass the outline + where it is taller: `CobaltRota`'s stacked shift chips, 9.2pt outlines round 8.2pt text on a + 10pt line. Word grew each chip to the line, and an outlined one by its borders again, as Word + keeps a cell's borders outside its content: every staff row with two shifts in a day stood + 3.8pt taller than the page's, and one with two plain lines 1.6pt. A label the shape centres top + to bottom is now written as tall as the room Word leaves the cell's content — the outline less + the margins written and the borders — seated where the page sets it, both sides cut alike as + the cell centres the line, and no closer to its letters than three quarters of a point; where + the letters leave less room than that, it is cut as far as they allow. In Word `CobaltRota` fits + one page again; rows with two plain lines stand where the page puts them, rows with an outlined + chip 1.1pt taller, and the 90th-percentile drift falls from 26pt to 18. + - **A table row's cells keep their own vertical padding in Word, not the row's largest.** Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it: measured, a row whose day cells were padded 5.5pt above and 10.25pt below beside a label diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f309de955..492a13e55 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -80,7 +80,7 @@ Payload records live in `core` under | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) | -| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a picture anchored to the page over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing`, anchored to the page where the layout puts it | +| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a picture anchored to the page over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing`, anchored to the page where the layout puts it | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape anchored to the page, and the markers as the shapes they are; they stay where the layout put them when the entries' text is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 7f4d0dfbd..a6eff6a0b 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -569,7 +569,14 @@ tint it was flattened to. Recorded, like the other two. outline's width within the cell and a point for the editor's face, its row held at least its outline's height, with its layers inside, its corners squared and reported — a rota's shift chips keep their colour and their - size. A badge's glyph — a smaller picture in a filled or outlined + size. A one-line label the shape centres top to bottom, on a line taller + than the outline leaves it, is written that much shorter, down to the + room Word leaves the cell's content — the outline less the margins written + and the borders, which Word keeps outside its content — its text seated + where the page sets it: both sides cut alike, as the cell centres the line, + no closer to its letters than three quarters of a point, so an outlined + chip is cut as far as its letters allow. A label set from the shape's top + or bottom edge, or moved up or down, is written as it was. A badge's glyph — a smaller picture in a filled or outlined container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn as a picture anchored to the page over the badge, where the page draws it, rather than written as a line of its own 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 2b858a2bd..76769d220 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 @@ -2938,6 +2938,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container currentCellWidth = Double.isFinite(width) ? width - padding.left() - padding.right() : Double.NaN; XWPFTableCell outerPanelCell = panelCell; panelCell = cell; + if (first && last) { + cutTheLabelToItsOutline(node, borders, cell); + } try { writeInCell(cell, () -> writeChildren(cell.getXWPFDocument(), children, spacingOf(node))); } finally { @@ -3213,6 +3216,77 @@ private void riseIntoItsLine(DocumentNode node) { carriedSpacingBefore += taken; } + /** + * Cuts the one line of text a shape composed in a table cell centres in it to the room its + * outline leaves, where the page lets the line pass the outline and its letters fit inside. + * + *

The page centres the label's line in the shape and draws it past the outline where the + * line is taller. Word grows the panel's row to the line: {@code CobaltRota}'s stacked shift + * chips, 9.2pt outlines round 8.2pt text on a 10pt line, each came out 0.8pt taller, and an + * outlined one its borders taller again, as Word keeps a cell's borders outside its content — + * every staff row with two shifts in a day stood 3.8pt taller than the page's. The line is + * written as tall as the room Word leaves the cell's content — the outline less the margins + * written and the borders — its text seated where the page sets it + * ({@link DocxStackedLines.Line}). Both sides are cut alike, since the cell centres the line, + * and no closer to the letters on either side than {@link DocxStackedLines#INK_MARGIN}: an + * outlined chip, its borders leaving less room than its letters, is cut as far as they allow + * and stays that much taller. Only a label the shape centres top to bottom is cut: one set + * from an edge overflows on one side.

+ */ + private void cutTheLabelToItsOutline(DocumentNode node, DocumentBorders borders, XWPFTableCell cell) { + if (!(node instanceof ShapeContainerNode shape) || layout.placement(node) != null) { + return; + } + ParagraphNode label = null; + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + if (layer.node() instanceof ParagraphNode paragraph) { + // Only a line the page centres top to bottom passes the outline equally above + // and below it; one set from an edge, or moved, overflows on one side. + com.demcha.compose.document.node.LayerAlign align = layer.align(); + boolean centred = align == com.demcha.compose.document.node.LayerAlign.CENTER + || align == com.demcha.compose.document.node.LayerAlign.CENTER_LEFT + || align == com.demcha.compose.document.node.LayerAlign.CENTER_RIGHT; + if (label != null || !centred || layer.offsetY() != 0) { + return; + } + label = paragraph; + } else if (!isDrawing(layer.node())) { + return; + } + } + if (label == null || layout.lineCount(label) != 1 || stackedLineHeights.containsKey(label) + || label.margin().top() != 0 || label.margin().bottom() != 0 + || label.padding().top() != 0 || label.padding().bottom() != 0) { + return; + } + java.util.Optional line = layout.firstLine(label); + double[] ink = inkOf(label); + if (line.isEmpty() || ink == null) { + return; + } + // What Word leaves the content of the cell the panel is: the outline less the margins + // written — its padding, less the half of each border the page draws inside it — and the + // borders Word keeps outside the content. + double room = shape.outline().height() - (cellMargin(cell, true) + cellMargin(cell, false)) / POINT_TO_TWIP + - strokeWidth(borders.top()) - strokeWidth(borders.bottom()); + double lineHeight = line.get().lineHeight(); + double over = (lineHeight - room) / 2; + if (!(over > 0.01) || !(room > 0)) { + return; + } + double baseline = line.get().baselineOffsetFromBottom(); + // Both sides are cut alike, as far as the page passes the outline and no closer to the + // letters than the margin on either side: the cell centres the line, so a cut deeper on one + // side stood the text off the page's by half the difference — 0.42pt high for an outlined + // chip whose foot the letters left less room at. + double cut = Math.min(over, Math.min(lineHeight - baseline - ink[0], baseline - ink[1]) + - DocxStackedLines.INK_MARGIN); + if (!(cut > 0.01)) { + return; + } + stackedLineHeights.put(label, new DocxStackedLines.Line(lineHeight - 2 * cut, -cut, 0)); + } + private static boolean hasRadius(com.demcha.compose.document.style.DocumentCornerRadius radius) { return radius != null && !radius.isZero(); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java index f843d02f3..bbcd19330 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -193,6 +193,86 @@ void anOutlinedPillInACellIsAPanelWithItsBorders() throws Exception { assertThat(chip.getText()).contains("12:00"); } + @Test + void aChipsLabelTallerThanItsOutlineIsCutToIt() throws Exception { + // CobaltRota's stacked chips: a 9.2pt outline round 8.2pt text on a 10pt line. Grown to + // the line in Word, each stood 0.8pt taller than the page draws it. + XWPFTableCell chip = chipCell(null); + double line = lineOf(chip); + + assertThat(line).as("the outline's height, not the text's line").isCloseTo(9.2, org.assertj.core.api.Assertions.within(0.06)); + } + + @Test + void anOutlinedChipsLabelIsCutAlikeAsFarAsItsLettersAllow() throws Exception { + // Word keeps the 1.1pt borders outside the content, leaving 7pt: less than the letters + // and their margin need. Lato's 8.2pt line is 9.84pt, its baseline 1.75pt above the foot, + // and the digits reach 0.07pt below it: 0.75pt from them, the foot can give 0.93pt, and + // the cell centring the line, the top gives no more — 9.84 less twice 0.93. + XWPFTableCell chip = chipCell(com.demcha.compose.document.style.DocumentStroke.of( + com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), 1.1)); + + assertThat(lineOf(chip)).isCloseTo(7.98, org.assertj.core.api.Assertions.within(0.06)); + } + + @Test + void aPaddedOutlinedChipStaysItsOutlinesHeightInWord() throws Exception { + // The cell's margins are its padding less half each border, the page drawing that half + // inside the box; Word keeps the borders outside the content. Margins, borders and the + // line come to the 17.5pt outline, not 1pt short of it. + com.demcha.compose.document.dsl.ShapeContainerBuilder chip = new com.demcha.compose.document.dsl.ShapeContainerBuilder() + .name("Padded").roundedRect(90, 17.5, 4).padding(DocumentInsets.of(2)) + .stroke(com.demcha.compose.document.style.DocumentStroke.of( + com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148), 1.1)) + .center(new com.demcha.compose.document.dsl.ParagraphBuilder().text("09:00-16:00") + .textStyle(com.demcha.compose.document.style.DocumentTextStyle.builder() + .fontName(com.demcha.compose.font.FontName.LATO).size(12).build()).build()); + DocumentNode node = chip.build(); + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("Mon"), DocumentTableCell.node(node)))); + XWPFTableCell padded = cell.getTables().get(0).getRow(0).getCell(0); + var margins = padded.getCTTc().getTcPr().getTcMar(); + double around = (DocxTwips.of(margins.getTop().getW()) + DocxTwips.of(margins.getBottom().getW())) / 20.0 + 2 * 1.1; + + assertThat(around + lineOf(padded)).isCloseTo(17.5, org.assertj.core.api.Assertions.within(0.1)); + } + + @Test + void aLabelSetFromTheChipsTopIsLeftAsItWas() throws Exception { + // It passes the outline below only: cut on both sides, its text would stand high. + XWPFTableCell chip = chipCell(null, com.demcha.compose.document.node.LayerAlign.TOP_LEFT, 8.2); + + assertThat(lineOf(chip)).as("the text's own line").isGreaterThan(9.8); + } + + private static XWPFTableCell chipCell(com.demcha.compose.document.style.DocumentStroke stroke) throws Exception { + return chipCell(stroke, com.demcha.compose.document.node.LayerAlign.CENTER, 8.2); + } + + private static XWPFTableCell chipCell(com.demcha.compose.document.style.DocumentStroke stroke, + com.demcha.compose.document.node.LayerAlign align, double size) throws Exception { + com.demcha.compose.document.dsl.ShapeContainerBuilder chip = new com.demcha.compose.document.dsl.ShapeContainerBuilder() + .name("Stacked").roundedRect(90, 9.2, 4).padding(DocumentInsets.zero()) + .fillColor(com.demcha.compose.document.style.DocumentColor.rgb(20, 160, 70)) + .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("09:00-16:00") + .textStyle(com.demcha.compose.document.style.DocumentTextStyle.builder() + .fontName(com.demcha.compose.font.FontName.LATO).size(size).build()).build(), align); + if (stroke != null) { + chip.stroke(stroke); + } + DocumentNode node = chip.build(); + XWPFTableCell cell = onlyTableCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.text("Mon"), DocumentTableCell.node(node)))); + return cell.getTables().get(0).getRow(0).getCell(0); + } + + private static double lineOf(XWPFTableCell chip) { + var spacing = chip.getParagraphs().get(0).getCTP().getPPr().getSpacing(); + return DocxTwips.of(spacing.getLine()) / 20.0; + } + @Test void aTileHoldingOnlyDrawingIsDrawnWhereThePageDrawsItNotAPanel() throws Exception { java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>();