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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A ruled table's rows are as tall in Word as on the page, and a table that states no rule is
ruled as the page rules it.** Word makes room in a table for its horizontal rules — a rule
between two rows half in each, the rule above the table and the one below it whole in their
row — where the page draws a cell's rules on its edges and steps its rows by padding and content
alone. Measured, a row of 12.35pt text and 7pt padding stepped 26.35pt unruled, 27.1 with 0.75pt
rules and 27.85 with 1.5pt ones, and a one-row table with 1.5pt rules stood 3pt taller. Every
ruled table grew row by row: `EditorialProposal`'s timeline and investment tables pushed its
investment block onto a third page, and `NorthlineProposal`'s acceptance heading stood 12pt
below its badge. Each rule now comes off a cell's padding above or below, where Word puts it —
between two rows ruled differently, the lower row's rule, which Word was measured making room
for — so a table ruled alike throughout, with padding enough, steps as the page does; padding
thinner than its share gives what it has.
A table that states no rule was left on Word's own grid, thinner than the engine's default 1pt
black rule the page draws and giving its rows other heights; it is now written with that rule.
In Word `EditorialProposal` is two pages again; across the 62 templates the median drift falls
from 0.94pt to 0.56 and lines more than 2pt off from 1341 to 858, and in LibreOffice the median
from 1.59pt to 0.55.
- **Text stands on the page's baseline in Word's exact lines, and stacked title lines take the
container's height.** Word stands the baseline of an exact line four fifths of the way down it
whatever the face — measured for Spectral, Lato and Arial, in lines 12 to 100pt tall, and
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
| 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 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) |
| 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); 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 |
| 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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ creation date is real metadata.
|---|---|
| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6158,8 +6158,12 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
// The covered positions of a merge take the paint too, so a merged
// region reads as one cell rather than as a striped run of them.
DocumentColor fill = resolveCellFill(node, placement);
applyCellPaint(cell, fill, resolveCellValue(node, placement, DocumentTableStyle::stroke));
applyCellPadding(cell, resolveCellPadding(node, placement));
DocumentStroke stroke = resolveCellStroke(node, placement);
applyCellPaint(cell, fill, stroke);
int next = placement.row() + placement.rowSpan();
DocumentStroke underneath = next < rowCount ? resolveCellStroke(node, cover[next][placement.column()]) : null;
applyCellPadding(cell, clearOfTheRules(resolveCellPadding(node, placement), stroke,
placement.row() == 0, next >= rowCount, underneath));
applyVerticalAnchor(cell, resolveCellAnchor(node, placement));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
Expand Down Expand Up @@ -6271,7 +6275,7 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
*
* <p>A {@link DocumentTableStyle} carries a {@code fillColor} and a {@code stroke}, and
* neither reached the file: a zebra body, a header band and a ruled grid all exported on
* Word's defaults, which is to say with no fill and no borders. Word owns both —
* Word's defaults, which is to say with no fill and Word's own thin grid. Word owns both —
* {@code w:shd} for the fill and {@code w:tcBorders} for the four edges — so this is
* mapping rather than approximation.</p>
*
Expand Down Expand Up @@ -6352,6 +6356,51 @@ private static void applyCellPadding(XWPFTableCell cell, DocumentInsets padding)
padding.right());
}

/**
* A table cell's padding as its margins, with room for the rules above and below it.
*
* <p>The page draws a cell's rules on its edges and steps its rows by their padding and
* content alone. Word gives the rules room: a rule between two rows half to each, the rule
* above the table and the one below it whole to their row. Measured, a row of 12.35pt text
* and 7pt padding stepped 26.35pt unruled, 27.1 with 0.75pt rules and 27.85 with 1.5pt
* ones, and a one-row table with 1.5pt rules stood 3pt taller, its text 1.5pt lower.
* {@code EditorialProposal}'s timeline and investment tables grew that much row by row, and
* their last block went onto a third page. Between two rows ruled differently, Word makes
* room for the lower row's rule: measured, a 1.5pt header over 0.5pt rows stepped as 0.5pt
* rules do, and a 1.5pt row under 0.5pt ones as 1.5pt rules do. So each rule comes off the
* padding where Word puts it; in a table ruled alike throughout, with padding enough, every
* row then stands where the page sets it. The sides keep their padding, as the columns'
* widths are fixed. Padding thinner than its share gives what it has, and the row stands
* that much taller.</p>
*
* @param padding the cell's padding on the page
* @param stroke the cell's own rule
* @param firstRow whether the cell starts the table's first row, under the rule above the table
* @param lastRow whether the cell ends the table's last row, over the rule below it
* @param underneath the rule of the cell under this one, whose width the edge between them
* takes; ignored for the last row
*/
private static DocumentInsets clearOfTheRules(DocumentInsets padding, DocumentStroke stroke,
boolean firstRow, boolean lastRow, DocumentStroke underneath) {
double rule = strokeWidth(stroke);
double below = lastRow ? rule : strokeWidth(underneath) / 2;
return new DocumentInsets(Math.max(0, padding.top() - (firstRow ? rule : rule / 2)), padding.right(),
Math.max(0, padding.bottom() - below), padding.left());
}

/**
* The rule a cell resolves to, most specific wins, with the engine's own default underneath:
* a table that states no rule is drawn with it on the page, and the table's own grid Word
* would otherwise draw is thinner and gives its rows other heights.
*/
private DocumentStroke resolveCellStroke(TableNode node, TableGrid.Placement placement) {
DocumentStroke authored = resolveCellValue(node, placement, DocumentTableStyle::stroke);
return authored != null ? authored : ENGINE_DEFAULT_CELL_STROKE;
}

/** What the engine rules a cell with when nothing states otherwise. */
static final DocumentStroke ENGINE_DEFAULT_CELL_STROKE = DocumentStroke.of(DocumentColor.BLACK, 1.0);

/**
* The padding a cell resolves to, most specific wins, with the engine's own default
* underneath.
Expand Down
Loading
Loading