Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*/
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<com.demcha.compose.document.layout.payloads.ParagraphLine> 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();
}
Expand Down
Loading
Loading