Skip to content
Open
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ follow semantic versioning; release dates are ISO 8601.
position at fault, where before it was drawn wrong. That is the rule the layout pipeline
already applied, so a document the PDF backend refuses is no longer one DOCX accepts.

- **A DOCX table is painted the way it was styled.** `DocumentTableStyle` carries a fill and
a stroke, and neither reached the file: a zebra body, a header band and a ruled grid all
exported on Word's defaults. The fill maps to `w:shd` and the stroke to `w:tcBorders`, and
the cascade that already resolved a cell's text style now resolves every field on its own,
so a table-wide rule survives a row that only overrides the fill. A merged cell is painted
on every position it covers, since a `w:vMerge` continuation draws its own shading and
would otherwise stripe the region. A stroke of no width — how this codebase says "no
border", and what a shipped CV preset uses — writes that instruction rather than omitting
it, so a borderless design no longer inherits the grid Word puts on a table by default.
What a fill loses is its opacity: `w:shd` is opaque, and blending it needs a background
Word owns rather than the backend.

The Word companion example styles its table, so the feature ships with a render behind it
— and both of its committed previews move, the DOCX for the new markup and the PDF because
the fixed-layout backend paints the same style it was never given before.

- **A DOCX image is the size it asked for, in the shape it asked for.** The drawn box came
from the node's literal `width` and `height` and fell back to a hardcoded 100 × 100 pt
when either was absent, so an image sized only by `scale` — or by one dimension with the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for the branch-routing table and the full
| Format | Status | Notes |
|---|---|---|
| PDF | Production | Fixed-layout backend on PDFBox 3.0. Full DSL coverage. |
| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. Tables keep their `colSpan`/`rowSpan` and images their fit mode; **hyperlinks, bookmarks and headers/footers are not implemented**, and table fill and border paint are dropped — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). |
| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. Tables keep their `colSpan`/`rowSpan`, their fill and their borders, and images their fit mode; **hyperlinks, bookmarks and headers/footers are not implemented** — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). |
| PPTX | Beta | Fixed-layout export via Apache POI from the same resolved layout — one page per editable slide with native shapes and text frames; clipped regions land as pixel-exact pictures. First shipped in 2.1, marked `@Beta` while the API shape settles. |

### Text & internationalization
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
Binary file modified assets/readme/examples/word-export-companion.pdf
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 @@ -69,7 +69,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` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ |
| 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; fill and border paint are not applied, and a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty) |
| 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`; a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty, and a fill's opacity is dropped since `w:shd` is opaque) |
| 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 |
| 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) | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.table.DocumentTableStyle;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.font.FontName;
import com.demcha.examples.support.ExampleOutputPaths;
Expand Down Expand Up @@ -133,11 +134,20 @@ public static Path generate() throws Exception {
.addItem("Two spaces of indent per depth in Word", l2 -> l2
.addItem("Custom markers survive the export"))))

.addParagraph("Tables stay tables", heading)
.addParagraph("Tables stay tables, and keep their paint", heading)
.addTable(t -> t
.columns(DocumentTableColumn.auto(),
DocumentTableColumn.auto(),
DocumentTableColumn.auto())
// The rule reaches Word as w:tcBorders and the header band as
// w:shd, so the exported table reads the way it was designed
// rather than on Word's defaults.
.defaultCellStyle(DocumentTableStyle.builder()
.stroke(new DocumentStroke(DocumentColor.rgb(205, 212, 219), 0.75))
.build())
.rowStyle(0, DocumentTableStyle.builder()
.fillColor(DocumentColor.rgb(232, 238, 243))
.build())
.headerRow("Quarter", "Revenue", "Profit")
.row("Q1", "42", "12")
.row("Q2", "55", "17")
Expand Down
17 changes: 11 additions & 6 deletions render-docx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,17 @@ picture.

What maps only in part:

- **Table cells keep their structure, not their paint.** `colSpan` and `rowSpan` map to
Word's own `w:gridSpan` and `w:vMerge`, and a cell's text takes the most specific style
in the table / column / row / cell cascade. Still dropped: the fill and border paint of a
`DocumentTableStyle`, so a merged, styled table exports with the right shape on Word's
default rules. A composed cell writes the shapes a cell can hold — paragraphs, and the
wrappers around them — so one built from an image or a list still lands empty.
- **Table cells keep their structure and their paint.** `colSpan` and `rowSpan` map to
Word's own `w:gridSpan` and `w:vMerge`; a cell's fill maps to `w:shd` and its stroke to
`w:tcBorders`; and text, fill and stroke each take the most specific value in the
table / column / row / cell cascade, resolved per field, so a table-wide rule survives a
row that only overrides the fill. A stroke of no width is read as "no border" and says so
in the file, so a deliberately borderless design does not inherit the grid Word puts on a
table; a table that says nothing about borders keeps that grid, Word owning the look it
was not given. What a fill loses is its opacity — `w:shd` is opaque,
and blending it would need a background Word owns rather than this backend. A composed
cell writes the shapes a cell can hold — paragraphs, and the wrappers around them — so
one built from an image or a list still lands empty.

These are **not implemented** even though Word itself can express them — check the list
before you promise a `.docx` to a reader:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import com.demcha.compose.document.node.SpacerNode;
import com.demcha.compose.document.node.TableNode;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.table.DocumentTableCell;
import com.demcha.compose.document.table.DocumentTableStyle;
Expand All @@ -42,11 +44,16 @@
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -58,6 +65,7 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.concurrent.atomic.AtomicBoolean;

/**
Expand Down Expand Up @@ -535,6 +543,11 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
TableGrid.Placement placement = physical.get(i);
XWPFTableCell cell = row.getCell(i);
applySpans(cell, placement, rowIdx);
// 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.
applyCellPaint(cell,
resolveCellValue(node, placement, DocumentTableStyle::fillColor),
resolveCellValue(node, placement, DocumentTableStyle::stroke));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
continue;
Expand All @@ -549,9 +562,7 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
if (placement.colSpan() == 1 && placement.rowSpan() == 1) {
return;
}
CTTcPr properties = cell.getCTTc().isSetTcPr()
? cell.getCTTc().getTcPr()
: cell.getCTTc().addNewTcPr();
CTTcPr properties = cellProperties(cell);
if (placement.colSpan() > 1) {
properties.addNewGridSpan().setVal(BigInteger.valueOf(placement.colSpan()));
}
Expand All @@ -561,6 +572,72 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
}
}

/**
* Paints a cell with the fill and the edges its style asks for.
*
* <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 —
* {@code w:shd} for the fill and {@code w:tcBorders} for the four edges — so this is
* mapping rather than approximation.</p>
*
* <p>What does not survive is transparency. A {@code w:shd} fill is opaque, so a colour
* carrying an opacity below 1 lands at full strength; the alternative would be blending it
* against a background this backend does not resolve, Word owning the flow.</p>
*/
private void applyCellPaint(XWPFTableCell cell, DocumentColor fill, DocumentStroke stroke) {
if (fill == null && stroke == null) {
return;
}
CTTcPr properties = cellProperties(cell);
if (fill != null) {
CTShd shading = properties.isSetShd() ? properties.getShd() : properties.addNewShd();
shading.setVal(STShd.CLEAR);
shading.setFill(toHexColor(fill.color()));
}
if (stroke != null) {
CTTcBorders borders = properties.isSetTcBorders()
? properties.getTcBorders()
: properties.addNewTcBorders();
if (stroke.width() > 0) {
// w:sz counts eighths of a point, and rounds to at least one so a hairline
// the author asked for stays a line rather than disappearing.
BigInteger eighths = BigInteger.valueOf(
Math.max(1, Math.round(stroke.width() * 8.0)));
String colour = toHexColor(stroke.color().color());
paintEdge(borders.addNewTop(), STBorder.SINGLE, eighths, colour);
paintEdge(borders.addNewBottom(), STBorder.SINGLE, eighths, colour);
paintEdge(borders.addNewLeft(), STBorder.SINGLE, eighths, colour);
paintEdge(borders.addNewRight(), STBorder.SINGLE, eighths, colour);
} else {
// A stroke of no width is how this codebase says "no border" — the fixed-layout
// handler reads the same predicate as draw-nothing. Writing nothing here would
// leave the cell on the table's default grid, so a deliberately borderless
// design would export ruled. The cell says none of its own instead.
paintEdge(borders.addNewTop(), STBorder.NIL, null, null);
paintEdge(borders.addNewBottom(), STBorder.NIL, null, null);
paintEdge(borders.addNewLeft(), STBorder.NIL, null, null);
paintEdge(borders.addNewRight(), STBorder.NIL, null, null);
}
}
}

private static void paintEdge(CTBorder edge, STBorder.Enum kind, BigInteger eighths, String colour) {
edge.setVal(kind);
if (eighths != null) {
edge.setSz(eighths);
}
if (colour != null) {
edge.setColor(colour);
}
}

private static CTTcPr cellProperties(XWPFTableCell cell) {
return cell.getCTTc().isSetTcPr()
? cell.getCTTc().getTcPr()
: cell.getCTTc().addNewTcPr();
}

private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, TableNode node)
throws Exception {
DocumentTableCell source = placement.cell();
Expand Down Expand Up @@ -590,14 +667,27 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
* column's, then the row's, then the cell's own.</p>
*/
private DocumentTextStyle resolveCellTextStyle(TableNode node, TableGrid.Placement placement) {
DocumentTextStyle resolved = null;
return resolveCellValue(node, placement, DocumentTableStyle::textStyle);
}

/**
* Resolves one field of a cell's style, most specific wins.
*
* <p>The cascade the layout pipeline merges in — the table's default, then the column's,
* then the row's, then the cell's own — applied per field rather than per style object, so
* a table-wide border survives a row that only overrides the fill.</p>
*/
private <T> T resolveCellValue(TableNode node, TableGrid.Placement placement,
Function<DocumentTableStyle, T> field) {
T resolved = null;
for (DocumentTableStyle candidate : List.of(
orEmpty(node.defaultCellStyle()),
orEmpty(node.columnStyles().get(placement.column())),
orEmpty(node.rowStyles().get(placement.row())),
orEmpty(placement.cell().style()))) {
if (candidate.textStyle() != null) {
resolved = candidate.textStyle();
T value = field.apply(candidate);
if (value != null) {
resolved = value;
}
}
return resolved;
Expand Down
Loading
Loading