From 387aba296b8496257d42e213bfe2f50f7ad47c5d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 22:47:46 +0100
Subject: [PATCH 1/2] fix(docx): name in the report what each written or drawn
node goes without
The export wrote or drew these nodes and said nothing of what it left behind:
a linked logo became an unlinked picture, a rotated photo stood upright, a
dashed line was drawn solid, an anchor on a drawn shape left a page reference
or a link to it pointing at no bookmark.
carriedWithout names, per node, a link, an outline entry, a drawn shape's
anchor, gradient fill and unequal corners, a line's dash and caps, and an
image's transform. Drawn shapes, pictures beside their text and barcodes add
these to the note they had; a rule, a picture in the flow, a table, a section
or container - the page flow, a panel, a layer stack's column, one laid over
the flow - gets a note of its own; a drawing composed in a table cell names
what no drawing carries, leaving its paint to the table's note. A shape
painted only with a gradient is dropped for that reason. A drawn line's caps
are said to be left to the editor, as its outline is written without one.
The ledger moves 21 node fields from a gap to REPORTED, and records a
section's anchor as a gap where the section is a layer stack's column: its
bookmark is not written, though a page reference to it is a PAGEREF. The DOCX
bytes do not change.
---
CHANGELOG.md | 25 ++
.../architecture/backend-capability-matrix.md | 6 +-
docs/recipes/docx-export.md | 14 +-
.../semantic/docx/DocxSemanticBackend.java | 137 +++++++++-
.../docx/DocxCarriedWithoutReportTest.java | 247 ++++++++++++++++++
.../docx/DocxNodeFieldLedgerTest.java | 39 +--
6 files changed, 437 insertions(+), 31 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88ebaf735..cf3b548e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,31 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export's report names what each written or drawn node goes without.** The export
+ wrote these nodes and said nothing of what it left behind: a linked logo became an unlinked
+ picture, a rotated photo stood upright, a dashed line was drawn solid, and an anchor on a
+ drawn shape left a page reference or a link to it pointing at no bookmark. Each node's
+ report note now names:
+ - on a shape, ellipse or line drawn as a shape: its link, its outline entry, its anchor (a
+ drawing holds no bookmark), a shape's gradient fill and unequal corners (drawn at the
+ largest radius), a line's dash pattern (drawn solid) and caps (not written, so the editor
+ ends the line its own way) — added to the note the drawing already had;
+ - on a line or thin bar written as a rule: a bar's link (a line's was already
+ `rule link`), either's outline entry, and a line's caps (a border ends flat), in a note
+ `written as a paragraph border`; a rule keeps its anchor as a bookmark;
+ - on a picture in the flow, a table, a section or a container — the page flow itself, a
+ panel, a column of a layer stack, a section laid over the flow —: its link, its outline
+ entry, and a picture's transform, in a note on the node; a picture laid beside its text
+ and a barcode add them to the note they had;
+ - on a drawing composed in a table cell, whose drawing is its table's: its link, outline
+ entry and anchor; its paint and stroke stay the table's note's to name.
+
+ A shape painted only with a gradient is dropped for that reason, rather than for its
+ geometry. None changes what is written: the 62 documents of the DOCX fidelity corpus export
+ to the same bytes. In `DocxNodeFieldLedgerTest` 21 node fields move from a gap to
+ `REPORTED`, and one written field is a gap after all — a section's anchor where the section
+ is a layer stack's column, whose bookmark is not written though a page reference to it is a
+ `PAGEREF`; 35 node-field gaps remain, each named.
- **The DOCX export report names what it used to leave out in silence, or misnamed.** Three
kinds of loss reached the Word file with no more than a log line, or nothing, so a caller
reading the report was told the document lost nothing, and a fourth was reported for the wrong
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 60d11a774..0e84ca858 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -70,9 +70,9 @@ 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; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else 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 — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). 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, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. 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 floating DrawingML shape 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 the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — 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 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 as a rectangle is — 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 as a rectangle is — the dash pattern, cap and a transform are not carried; a line in a page zone is dropped and reported |
+| 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 — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). 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, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. 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 floating DrawingML shape 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, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — 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 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, nor a link, an outline entry or an anchor's bookmark — each named in the ellipse's report note, 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 as a rectangle is — 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 (both reported, a link as `rule link`; an outline entry on it is reported too). 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 as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark — each named in the line's report note; a line in a page zone is dropped and reported |
| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored as a rectangle is |
| Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped |
| Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index f2d0a1725..ca6da431c 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -891,9 +891,17 @@ borders Word draws outside it: Word starts a cell's content below its top border
margin where that is wider, and draws both borders outside the row's height. Measured,
`MerchantInvoice`'s payment panel, its height set by its content in LibreOffice, 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:
+shapes stand above the page backgrounds, which LibreOffice stacks together with them. These limits are each named in the shape's report note:
- A transform is not carried: a rotated or scaled shape is drawn upright at its size.
+- A drawing carries no link, no outline entry and no bookmark: a page reference or a link to
+ a drawn shape's anchor has nothing to point at. A gradient fill, unequal corners (drawn at
+ the largest radius) and a line's dash pattern (drawn solid) are not carried either, and a
+ line's caps are not written, which leaves its ends to the editor. A rule — a line or a thin
+ bar written as a paragraph border — keeps its anchor; its note names a lost outline entry,
+ caps (a border ends flat) or a bar's link, a line's link being reported as `rule link`.
+ A picture, a table, a section or a container names a link or an outline entry it loses the
+ same way.
- A timeline's rail stands under every other shape on its page, where the page puts it
under the markers only; a filled card drawn under a timeline covers it.
@@ -914,8 +922,8 @@ heading, is left to the row it sits in, which is held as tall as the page makes
A barcode in the body is not skipped: it exports as a picture of the symbol at
its size, the same matrix the PDF draws, so it scans, with its data as the
picture's description. Its data is part of the picture — changing it means
-exporting again — and the report says so, and names a link or a transform on it
-as not carried. In a page zone a barcode is still skipped.
+exporting again — and the report says so, and names a link, a transform or an
+outline entry on it as not carried. In a page zone a barcode is still skipped.
A watermark, a protection and viewer preferences are not written; the export report names
each one the document sets (`watermark`, `protection`, `viewer preferences`), so a file
asked to be protected is not handed back open in silence.
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 a9468d5c6..f386fb432 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
@@ -2027,6 +2027,7 @@ private void writeNodeContent(XWPFDocument document, DocumentNode node) throws E
// Inside a node laid over the flow every container is only what it holds: its text
// goes in text boxes, not in columns, a line pair, a band or a panel in the flow.
drawOutlineOf(node);
+ reportWrittenWithout(node, "written over the flow as what it holds");
for (DocumentNode child : inPaintOrder(node)) {
writeNode(document, child);
}
@@ -2418,8 +2419,17 @@ private void dispatchNode(XWPFDocument document, DocumentNode node) throws Excep
* the summary and the report is the record.
*/
private void warnUnsupported(DocumentNode node) {
- if (drawOwnFragments(node) || drawnByItsTable(node)) {
- // Drawn: by the node, or, composed in a cell, by its table.
+ if (drawOwnFragments(node)) {
+ return;
+ }
+ if (drawnByItsTable(node)) {
+ // Composed in a cell, what it paints is its table's to draw, and the table's note
+ // names that, paint and stroke included; this one names what no drawing carries.
+ List lost = carriedWithout(node, true, false);
+ if (!lost.isEmpty()) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node),
+ "composed in a table cell, whose drawing is its table's; " + String.join("; ", lost));
+ }
return;
}
String why = droppedBecause(node);
@@ -2433,6 +2443,97 @@ private void warnUnsupported(DocumentNode node) {
why + ", so it is not in the document");
}
+ /**
+ * What a node the export writes or draws goes without, as the phrases a report note lists
+ * after what the node became: its link, its outline entry, and — drawn as a shape — the
+ * anchor, paint and stroke a drawing does not carry, or an image's transform. Empty when it
+ * loses none of them. A drawn shape's transform and a path's paint are said where the
+ * drawing is (drawOwnFragments), and a rule's link and dash where the rule is.
+ *
+ * @param drawn whether the node is drawn as a floating shape rather than written in the flow
+ */
+ private static List carriedWithout(DocumentNode node, boolean drawn) {
+ return carriedWithout(node, drawn, true);
+ }
+
+ /**
+ * As {@link #carriedWithout(DocumentNode, boolean)}, leaving out the paint and stroke a
+ * drawing goes without when {@code paint} is false: a table's own note names those for every
+ * drawing its cells hold.
+ */
+ private static List carriedWithout(DocumentNode node, boolean drawn, boolean paint) {
+ List lost = new ArrayList<>();
+ com.demcha.compose.document.node.DocumentLinkTarget link = null;
+ com.demcha.compose.document.node.DocumentBookmarkOptions outline = null;
+ String anchor = null;
+ if (node instanceof com.demcha.compose.document.node.ShapeNode shape) {
+ link = shape.linkTarget();
+ outline = shape.bookmarkOptions();
+ anchor = shape.anchor();
+ if (drawn && paint && shape.fillPaint() != null
+ && !(shape.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)) {
+ lost.add("its gradient fill is not carried");
+ }
+ if (drawn && paint && shape.cornerRadius() != null && !shape.cornerRadius().isUniform()) {
+ lost.add("its corners are drawn at the largest of their radii");
+ }
+ } else if (node instanceof com.demcha.compose.document.node.EllipseNode ellipse) {
+ link = ellipse.linkTarget();
+ outline = ellipse.bookmarkOptions();
+ anchor = ellipse.anchor();
+ } else if (node instanceof com.demcha.compose.document.node.LineNode line) {
+ link = drawn ? line.linkTarget() : null;
+ outline = line.bookmarkOptions();
+ anchor = line.anchor();
+ if (drawn && paint && line.dashPattern() != null && !line.dashPattern().isSolid()) {
+ lost.add("its dash pattern is not carried, so it is drawn solid");
+ }
+ // A drawn line's outline is written with no cap, which leaves its ends to the editor;
+ // a border has no caps, and ends flat.
+ if (paint && line.lineCap() != null
+ && line.lineCap() != com.demcha.compose.document.style.DocumentLineCap.BUTT) {
+ lost.add(drawn ? "its caps are not written, so the editor ends it its own way"
+ : "its caps are not carried: a border ends flat");
+ }
+ } else if (node instanceof ImageNode image) {
+ link = image.linkTarget();
+ outline = image.bookmarkOptions();
+ if (image.transform() != null && !image.transform().isIdentity()) {
+ lost.add("its transform is not carried, so it is drawn upright at its size");
+ }
+ } else if (node instanceof com.demcha.compose.document.node.BarcodeNode barcode) {
+ outline = barcode.bookmarkOptions();
+ } else if (node instanceof TableNode table) {
+ link = table.linkTarget();
+ outline = table.bookmarkOptions();
+ } else if (node instanceof SectionNode section) {
+ outline = section.bookmarkOptions();
+ } else if (node instanceof ContainerNode container) {
+ outline = container.bookmarkOptions();
+ }
+ if (link != null) {
+ lost.add("its link is not carried");
+ }
+ if (outline != null) {
+ lost.add("its outline entry is not written");
+ }
+ // A drawing is no paragraph a bookmark can hold; a rule is one, and holds its own.
+ if (drawn && anchor != null && !anchor.isBlank()) {
+ lost.add("its anchor has no bookmark in the Word file, so a page reference or a link to "
+ + "it has nothing to point at");
+ }
+ return lost;
+ }
+
+ /** Reports what a node written into the flow goes without, if anything (carriedWithout). */
+ private void reportWrittenWithout(DocumentNode node, String writtenAs) {
+ List lost = carriedWithout(node, false);
+ if (!lost.isEmpty()) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node),
+ writtenAs + "; " + String.join("; ", lost));
+ }
+ }
+
/** Why a node the export neither writes nor draws is dropped, as the report says it. */
private static String droppedBecause(DocumentNode node) {
if (node instanceof PageFieldNode) {
@@ -2442,6 +2543,11 @@ private static String droppedBecause(DocumentNode node) {
if (!isBuiltIn(node)) {
return "a node kind the DOCX export does not know: not its text, its pictures" + heldNodes(node);
}
+ if (node instanceof com.demcha.compose.document.node.ShapeNode shape && shape.fillPaint() != null
+ && !(shape.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)) {
+ // Painted only with a gradient, a shape has no colour a drawing is filled with.
+ return "it is painted only with a gradient, which the export does not draw";
+ }
return "its geometry has no semantic Word analogue";
}
@@ -2523,6 +2629,9 @@ private boolean drawOwnFragments(DocumentNode node) {
if (node instanceof com.demcha.compose.document.node.PathNode path && losesStyle(path)) {
message.append("; its gradient paint, dash pattern, caps and joins are not carried");
}
+ for (String lost : carriedWithout(node, true)) {
+ message.append("; ").append(lost);
+ }
if (!isBuiltIn(node)) {
message.append("; a node kind the DOCX export does not know: only the shapes it "
+ "paints itself are written, not its text, its pictures").append(heldNodes(node));
@@ -3646,6 +3755,7 @@ private void writeContainerChildren(XWPFDocument document, DocumentNode node) th
// hangBelowItsBox does a shape container's, so that line keeps its own height.
holdStackedLines(node.children(), Double.NaN);
ContainerPaint paint = paintOf(node);
+ reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel");
if (paint.isEmpty()) {
writeContainerBody(document, node);
return;
@@ -7964,6 +8074,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception
.setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE);
}
}
+ reportWrittenWithout(node, "written as a picture in the flow");
}
/**
@@ -8194,11 +8305,15 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte
relationship);
drewInCell = false;
queueDrawings(List.of(picture), inFront);
+ StringBuilder message = new StringBuilder(drewInCell
+ ? how + ", anchored in the table cell it fills, where the layout puts it in the cell: "
+ + "it moves with the row"
+ : how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
+ for (String lost : carriedWithout(image, false)) {
+ message.append("; ").append(lost);
+ }
report.add(DocxExportReport.Severity.APPROXIMATED, image.nodeKind(), layout.pathOf(image),
- drewInCell
- ? how + ", anchored in the table cell it fills, where the layout puts it in the cell: "
- + "it moves with the row"
- : how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
+ message.toString());
}
/**
@@ -8236,6 +8351,9 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod
if (node.transform() != null && !node.transform().isIdentity()) {
message.append("; its transform is not carried, so it is drawn upright at its size");
}
+ for (String lost : carriedWithout(node, false)) {
+ message.append("; ").append(lost);
+ }
report.add(DocxExportReport.Severity.APPROXIMATED, "barcode", layout.pathOf(node), message.toString());
}
@@ -8313,6 +8431,8 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule
"drawn in Word's own dash for a border, which keeps the rule dashed but not "
+ "the pattern's lengths");
}
+ // A line's link is the rule link above; a filled bar's, and either's outline entry, here.
+ reportWrittenWithout(node, "written as a paragraph border");
}
/** Word's border for a rule's dash: dots where the dashes are no longer than the stroke. */
@@ -8434,6 +8554,9 @@ private static boolean startsWith(byte[] bytes, int... signature) {
*/
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
throws Exception {
+ if (node instanceof TableNode) {
+ reportWrittenWithout(node, "written as a Word table");
+ }
// The layout starts a block it moves to a new page at the block's own top edge: what
// the page above holds below its last block, and the gap between the two, stay there.
boolean onANewPage = currentCell == null && startsAPageOfItsOwn(node);
@@ -10086,6 +10209,8 @@ private void writeLayerColumns(XWPFDocument document,
pullBelow = 0;
resumeSpacing = plan.resume(node);
}
+ // A column layer is written as what it holds, never dispatched itself.
+ reportWrittenWithout(node, "written as a column of its layer stack");
writeChildren(document, node.children(), spacingOf(node));
if (layer > 0 && blocksWritten == blocksBefore && holdsMovedContent(node)) {
// A layer that wrote nothing — its content all written in an
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
new file mode 100644
index 000000000..ef9a7974f
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
@@ -0,0 +1,247 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.node.DocumentBookmarkOptions;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentDashPattern;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLineCap;
+import com.demcha.compose.document.style.DocumentPaint;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTransform;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What a node the export writes or draws goes without is in its report note: a link, an
+ * outline entry, an anchor a drawing cannot hold, a gradient, unequal corners, a dash, caps, an
+ * image's transform.
+ *
+ * The export wrote each of these nodes and said nothing of what it left behind: a linked
+ * logo became an unlinked picture, a rotated photo stood upright, a dashed rail was drawn
+ * solid, an anchor on a drawn shape left a page reference to it as plain text — and the report
+ * listed no loss.
+ */
+class DocxCarriedWithoutReportTest {
+
+ private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+ private static final DocumentBookmarkOptions OUTLINE = new DocumentBookmarkOptions("Entry");
+
+ @Test
+ void aDrawnShapeNamesItsGradientCornersLinkOutlineEntryAndAnchor() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addShape(shape -> shape.name("Box").size(120, 30)
+ .fill(DocumentPaint.linear(SURFACE, INK)).stroke(DocumentStroke.of(INK, 1))
+ .cornerRadius(DocumentCornerRadius.of(2, 8, 2, 8))
+ .linkTo("target").bookmark(OUTLINE).anchor("box")));
+
+ assertThat(detailOf(report, "ShapeNode")).contains("drawn as a shape")
+ .contains("its gradient fill is not carried")
+ .contains("its corners are drawn at the largest of their radii")
+ .contains("its link is not carried")
+ .contains("its outline entry is not written")
+ .contains("its anchor has no bookmark in the Word file");
+ }
+
+ @Test
+ void aDrawnEllipseNamesItsLinkOutlineEntryAndAnchor() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addEllipse(ellipse -> ellipse.size(30, 30).fillColor(INK)
+ .linkTo("target").bookmark(OUTLINE).anchor("dot")));
+
+ assertThat(detailOf(report, "EllipseNode")).contains("its link is not carried")
+ .contains("its outline entry is not written").contains("its anchor has no bookmark");
+ }
+
+ @Test
+ void aDrawnLineNamesItsDashCapsAndLink() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addLine(line -> line.vertical(60).thickness(2).color(INK)
+ .dashed(DocumentDashPattern.of(4, 2)).lineCap(DocumentLineCap.ROUND).linkTo("target")
+ .bookmark(OUTLINE).anchor("rail")));
+
+ assertThat(detailOf(report, "LineNode")).contains("drawn as a shape")
+ .contains("its dash pattern is not carried, so it is drawn solid")
+ .contains("its caps are not written, so the editor ends it its own way")
+ .contains("its link is not carried").contains("its outline entry is not written")
+ .contains("its anchor has no bookmark in the Word file");
+ }
+
+ @Test
+ void aFilledBarWrittenAsARuleNamesItsLinkAndOutlineEntry() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addShape(shape -> shape.size(200, 2).fillColor(INK).linkTo("target").bookmark(OUTLINE)
+ .anchor("bar")));
+
+ assertThat(detailOf(report, "ShapeNode")).startsWith("written as a paragraph border")
+ .contains("its link is not carried").contains("its outline entry is not written")
+ .as("a rule in the flow holds its own anchor's bookmark").doesNotContain("anchor");
+ }
+
+ @Test
+ void aRuleWithRoundCapsSaysTheyAreLost() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addLine(line -> line.horizontal(200).thickness(2).color(INK).lineCap(DocumentLineCap.ROUND)));
+
+ assertThat(detailOf(report, "LineNode")).startsWith("written as a paragraph border")
+ .contains("its caps are not carried: a border ends flat");
+ }
+
+ @Test
+ void anImageInTheFlowNamesItsTransformLinkAndOutlineEntry() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40)
+ .transform(DocumentTransform.rotate(15)).linkTo("target").bookmark(OUTLINE)));
+
+ assertThat(detailOf(report, "ImageNode")).startsWith("written as a picture in the flow")
+ .contains("its transform is not carried, so it is drawn upright at its size")
+ .contains("its link is not carried").contains("its outline entry is not written");
+ }
+
+ @Test
+ void aTableNamesItsLinkAndOutlineEntry() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell")
+ .linkTo("target").bookmark(OUTLINE)));
+
+ assertThat(detailOf(report, "TableNode")).startsWith("written as a Word table")
+ .contains("its link is not carried").contains("its outline entry is not written");
+ }
+
+ @Test
+ void aSectionAndABarcodeNameTheirOutlineEntries() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addSection(section -> section.bookmark(OUTLINE).addParagraph("Inside"))
+ .addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60).bookmark(OUTLINE)));
+
+ assertThat(detailOf(report, "SectionNode")).isEqualTo("written as its contents; its outline entry is not written");
+ assertThat(detailOf(report, "barcode")).contains("its outline entry is not written");
+ }
+
+ @Test
+ void aDrawingComposedInATableCellNamesWhatItGoesWithout() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(200))
+ .rowCells(DocumentTableCell.node(new ShapeBuilder().size(20, 20)
+ .fill(DocumentPaint.linear(SURFACE, INK)).stroke(DocumentStroke.of(INK, 1))
+ .linkTo("target").build()))));
+
+ assertThat(detailOf(report, "ShapeNode")).startsWith("composed in a table cell, whose drawing is its table's")
+ .contains("its link is not carried")
+ .as("its paint is the table's note's to name").doesNotContain("gradient");
+ }
+
+ @Test
+ void aSectionLaidOutAsALayerColumnNamesItsOutlineEntry() throws Exception {
+ // A layer stack of two columns side by side writes each column as what it holds,
+ // without dispatching the column's own node.
+ DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack
+ .layer(new com.demcha.compose.document.dsl.SectionBuilder().bookmark(OUTLINE)
+ .margin(new DocumentInsets(0, 220, 0, 0)).addParagraph("Left column").build(),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
+ .layer(new com.demcha.compose.document.dsl.SectionBuilder()
+ .margin(new DocumentInsets(0, 0, 0, 200)).addParagraph("Right column").build(),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT)));
+
+ assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a column of its layer stack; its outline entry is not written");
+ }
+
+ @Test
+ void thePageFlowAndAPanelNameTheirOutlineEntries() throws Exception {
+ DocxExportReport report = reportOf(page -> page.bookmark(OUTLINE)
+ .addSection(panel -> panel.fillColor(SURFACE).bookmark(OUTLINE).addParagraph("Inside")));
+
+ assertThat(detailOf(report, "ContainerNode")).isEqualTo("written as its contents; its outline entry is not written");
+ assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a panel; its outline entry is not written");
+ }
+
+ @Test
+ void aPictureBesideItsLabelNamesItsLink() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("Target").anchor("target"))
+ .addLayerStack(stack -> stack
+ .layer(new com.demcha.compose.document.dsl.ImageBuilder()
+ .source(DocumentImageData.fromBytes(png())).size(12, 12).linkTo("target").build(),
+ com.demcha.compose.document.node.LayerAlign.CENTER_LEFT)
+ .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Label")
+ .margin(new DocumentInsets(0, 0, 0, 18)).build(),
+ com.demcha.compose.document.node.LayerAlign.CENTER_LEFT)));
+
+ assertThat(detailOf(report, "ImageNode")).startsWith("drawn beside the text it labels")
+ .contains("its link is not carried");
+ }
+
+ @Test
+ void aShapePaintedOnlyWithAGradientIsDroppedForThat() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addShape(shape -> shape.size(80, 30).fill(DocumentPaint.linear(SURFACE, INK))));
+
+ assertThat(detailOf(report, "ShapeNode")).contains("painted only with a gradient")
+ .doesNotContain("geometry");
+ }
+
+ @Test
+ void nodesThatLoseNoneOfThemAddNoNote() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph("Plain")
+ .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40))
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Cell"))
+ .addSection(section -> section.addParagraph("Inside"))
+ .addShape(shape -> shape.size(200, 2).fillColor(INK)));
+
+ assertThat(report.bySubject()).doesNotContainKeys("ImageNode", "TableNode", "SectionNode", "ShapeNode");
+ }
+
+ private static String detailOf(DocxExportReport report, String subject) {
+ List notes = report.bySubject().get(subject);
+ assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1);
+ return notes.get(0).detail();
+ }
+
+ private static DocxExportReport reportOf(Consumer content) throws Exception {
+ AtomicReference captured = new AtomicReference<>();
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 600)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(content::accept);
+ session.export(new DocxSemanticBackend(captured::set));
+ }
+ assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull();
+ return captured.get();
+ }
+
+ private static byte[] png() {
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ ImageIO.write(new BufferedImage(40, 20, BufferedImage.TYPE_INT_RGB), "png", out);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index 504c7d702..3c5fce511 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -101,7 +101,7 @@ private record Entry(Fate fate, String note) {
static {
node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN");
node(BarcodeNode.class, "name:INERT", "barcodeOptions:WRITTEN", "width:WRITTEN", "height:WRITTEN",
- "linkTarget:REPORTED", "bookmarkOptions:GAP:its outline entry",
+ "linkTarget:REPORTED", "bookmarkOptions:REPORTED",
"padding:GAP:its left and right sides", "margin:GAP:its left and right sides",
"transform:REPORTED", "anchor:WRITTEN");
node(CanvasLayerNode.class, "name:INERT",
@@ -113,25 +113,25 @@ private record Entry(Fate fate, String note) {
"margin:GAP:the space round the chart's table", "padding:GAP:the space round the chart's table");
node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
"margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
- "anchor:WRITTEN", "bookmarkOptions:GAP:its outline entry",
+ "anchor:WRITTEN", "bookmarkOptions:REPORTED",
"flowWidth:GAP:the width of an unpainted container");
node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN",
- "stroke:WRITTEN", "linkTarget:GAP:its link", "bookmarkOptions:GAP:its outline entry",
+ "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED",
"padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED",
- "anchor:GAP:its bookmark; a page reference to it is written as text");
+ "anchor:REPORTED");
node(ImageNode.class, "name:INERT", "imageData:WRITTEN", "width:WRITTEN", "height:WRITTEN",
- "scale:WRITTEN", "fitMode:WRITTEN", "linkTarget:GAP:its link",
- "bookmarkOptions:GAP:its outline entry", "padding:GAP:its left and right sides",
- "margin:GAP:its left and right sides", "transform:GAP:its rotation and scale; it is written upright",
+ "scale:WRITTEN", "fitMode:WRITTEN", "linkTarget:REPORTED",
+ "bookmarkOptions:REPORTED", "padding:GAP:its left and right sides",
+ "margin:GAP:its left and right sides", "transform:REPORTED",
"anchor:WRITTEN");
node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",
"clipToBounds:GAP:the clip");
node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN",
"endX:WRITTEN", "endY:WRITTEN", "stroke:WRITTEN",
- "linkTarget:GAP:the link of a line drawn as a shape (a rule reports it)",
- "bookmarkOptions:GAP:its outline entry", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED",
- "dashPattern:GAP:the dash of a line drawn as a shape (a rule reports it)",
- "anchor:GAP:the bookmark of a line drawn as a shape", "lineCap:GAP:its caps",
+ "linkTarget:REPORTED",
+ "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED",
+ "dashPattern:REPORTED",
+ "anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a cap other than butt; whether an editor ends a drawn butt line flat is not measured",
"fillWidth:WRITTEN", "keepWithNext:GAP:the keep of a line drawn as a shape");
node(ListNode.class, "name:INERT", "items:WRITTEN", "nestedItems:WRITTEN", "marker:WRITTEN",
"textStyle:WRITTEN", "align:GAP:a centred or right-aligned list is written flush left",
@@ -171,24 +171,25 @@ private record Entry(Fate fate, String note) {
"slot:WRITTEN", "child:WRITTEN");
node(SectionNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
"margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
- "keepTogether:WRITTEN", "anchor:WRITTEN", "bleed:GAP:the paint's bleed past the section",
- "bookmarkOptions:GAP:its outline entry", "keepWithNext:WRITTEN",
+ "keepTogether:WRITTEN",
+ "anchor:GAP:the bookmark of a section written as a layer stack's column; a page reference to it points at none", "bleed:GAP:the paint's bleed past the section",
+ "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN",
"flowWidth:GAP:the width of an unpainted section");
node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN",
"clipPolicy:GAP:the clip of a container written as a badge, a line pair or over the flow",
"fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",
"transform:GAP:the transform of a container whose outline paints nothing");
node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN",
- "stroke:WRITTEN", "cornerRadius:GAP:unequal corners, drawn at the largest radius",
- "linkTarget:GAP:its link", "bookmarkOptions:GAP:its outline entry", "padding:WRITTEN",
- "margin:WRITTEN", "transform:REPORTED", "fillPaint:GAP:a gradient fill",
- "anchor:GAP:the bookmark of a shape drawn as a shape");
+ "stroke:WRITTEN", "cornerRadius:REPORTED:unequal corners, drawn at the largest radius",
+ "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN",
+ "margin:WRITTEN", "transform:REPORTED", "fillPaint:REPORTED",
+ "anchor:REPORTED:drawn; a rule in the flow is bookmarked");
node(SpacerNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN",
"padding:GAP:its padding in the body flow", "margin:GAP:its margin in the body flow",
"grow:WRITTEN");
node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN",
- "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:GAP:its link",
- "bookmarkOptions:GAP:its outline entry", "padding:GAP:its left and right sides",
+ "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED",
+ "bookmarkOptions:REPORTED", "padding:GAP:its left and right sides",
"margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN");
}
From 69f85efda9c0b0c5fcced867d5d7d412e1b6ce98 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 5 Oct 2026 23:19:14 +0100
Subject: [PATCH 2/2] fix(docx): name a column's anchor and a cell drawing's
corners and caps in the report
A section or container written as a layer stack's column gets no bookmark,
while a link or a page reference to its anchor still names one; its note
now says so, and the ledger moves both anchors from written to reported.
A drawing composed in a table cell names its unequal corners and caps,
which the table's note does not; only the gradient and the dash are left
to it. A gradient-only shape in a cell is no longer taken for drawn by its
table, and is dropped for its gradient. A painted section laid over the
flow names its outline entry once. A drawn anchor's page reference is said
to be a fixed number, which is what is written.
---
CHANGELOG.md | 46 ++++----
.../architecture/backend-capability-matrix.md | 4 +-
docs/recipes/docx-export.md | 18 ++--
.../semantic/docx/DocxSemanticBackend.java | 84 ++++++++++-----
.../docx/DocxCarriedWithoutReportTest.java | 101 ++++++++++++++++--
.../docx/DocxNodeFieldLedgerTest.java | 7 +-
6 files changed, 190 insertions(+), 70 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf3b548e7..f94d5dc01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,29 +10,31 @@ follow semantic versioning; release dates are ISO 8601.
- **A DOCX export's report names what each written or drawn node goes without.** The export
wrote these nodes and said nothing of what it left behind: a linked logo became an unlinked
- picture, a rotated photo stood upright, a dashed line was drawn solid, and an anchor on a
- drawn shape left a page reference or a link to it pointing at no bookmark. Each node's
- report note now names:
+ picture, a rotated photo stood upright, a dashed line was drawn solid, and a link to an anchor
+ on a drawn shape pointed at no bookmark. Each node's report note now names:
- on a shape, ellipse or line drawn as a shape: its link, its outline entry, its anchor (a
- drawing holds no bookmark), a shape's gradient fill and unequal corners (drawn at the
- largest radius), a line's dash pattern (drawn solid) and caps (not written, so the editor
- ends the line its own way) — added to the note the drawing already had;
- - on a line or thin bar written as a rule: a bar's link (a line's was already
- `rule link`), either's outline entry, and a line's caps (a border ends flat), in a note
- `written as a paragraph border`; a rule keeps its anchor as a bookmark;
- - on a picture in the flow, a table, a section or a container — the page flow itself, a
- panel, a column of a layer stack, a section laid over the flow —: its link, its outline
- entry, and a picture's transform, in a note on the node; a picture laid beside its text
- and a barcode add them to the note they had;
- - on a drawing composed in a table cell, whose drawing is its table's: its link, outline
- entry and anchor; its paint and stroke stay the table's note's to name.
-
- A shape painted only with a gradient is dropped for that reason, rather than for its
- geometry. None changes what is written: the 62 documents of the DOCX fidelity corpus export
- to the same bytes. In `DocxNodeFieldLedgerTest` 21 node fields move from a gap to
- `REPORTED`, and one written field is a gap after all — a section's anchor where the section
- is a layer stack's column, whose bookmark is not written though a page reference to it is a
- `PAGEREF`; 35 node-field gaps remain, each named.
+ drawing holds no bookmark, so a link to it points at none and a page reference to it is a
+ fixed number), a shape's gradient fill and unequal corners (drawn at the largest radius), a
+ line's dash pattern (drawn solid) and caps (not written, so the editor ends the line its own
+ way) — added to the note the drawing already had;
+ - on a line or thin bar written as a rule: a bar's link (a line's was already `rule link`),
+ either's outline entry, and a line's caps (a border ends flat), in a note `written as a
+ paragraph border`; a rule keeps its anchor as a bookmark;
+ - on a picture in the flow, a table, a section or a container: its link, its outline entry and
+ a picture's transform, in a note on the node — the page flow itself, a panel, a column of a
+ layer stack and a section inside a node laid over the flow included; a picture laid beside
+ its text and a barcode add them to the note they had;
+ - on a section or a container written as a column of a layer stack: its anchor, which no
+ bookmark marks there, though a link or a page reference to it names one;
+ - on a drawing composed in a table cell, whose drawing is its table's: its link, outline entry,
+ anchor, unequal corners and caps; its gradient and dash stay the table's note's to name.
+
+ A shape painted only with a gradient is dropped for that reason rather than for its geometry,
+ and in a table cell it is no longer taken for drawn by its table and left unreported. None
+ changes what is written: the 62 documents of the DOCX fidelity corpus export to the same bytes.
+ In `DocxNodeFieldLedgerTest` 21 node fields move from a gap to `REPORTED`, and so do two
+ written ones, a section's and a container's anchor, which as a layer stack's column get no
+ bookmark; 34 node-field gaps remain, each named.
- **The DOCX export report names what it used to leave out in silence, or misnamed.** Three
kinds of loss reached the Word file with no more than a log line, or nothing, so a caller
reading the report was told the document lost nothing, and a fourth was reported for the wrong
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 0e84ca858..57a8b0616 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -71,8 +71,8 @@ Payload records live in `core` under
| 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 — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). 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, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not. 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 floating DrawingML shape 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, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — 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 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, nor a link, an outline entry or an anchor's bookmark — each named in the ellipse's report note, 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 as a rectangle is — 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 (both reported, a link as `rule link`; an outline entry on it is reported too). 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 as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark — each named in the line's report note; a line in a page zone is dropped and reported |
+| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored 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, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), 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 as a rectangle is — 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 (both reported, a link as `rule link`; an outline entry on it is reported too). 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 as a rectangle is — the dash pattern, cap and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported |
| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, anchored as a rectangle is |
| Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, a gradient paint, the dash pattern, cap and join are not; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped |
| Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index ca6da431c..bc38f5257 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -891,17 +891,19 @@ borders Word draws outside it: Word starts a cell's content below its top border
margin where that is wider, and draws both borders outside the row's height. Measured,
`MerchantInvoice`'s payment panel, its height set by its content in LibreOffice, stands that
border's width shorter there. The body's
-shapes stand above the page backgrounds, which LibreOffice stacks together with them. These limits are each named in the shape's report note:
+shapes stand above the page backgrounds, which LibreOffice stacks together with them. Each of these limits is named in the report:
- A transform is not carried: a rotated or scaled shape is drawn upright at its size.
-- A drawing carries no link, no outline entry and no bookmark: a page reference or a link to
- a drawn shape's anchor has nothing to point at. A gradient fill, unequal corners (drawn at
- the largest radius) and a line's dash pattern (drawn solid) are not carried either, and a
- line's caps are not written, which leaves its ends to the editor. A rule — a line or a thin
- bar written as a paragraph border — keeps its anchor; its note names a lost outline entry,
- caps (a border ends flat) or a bar's link, a line's link being reported as `rule link`.
+- A drawing carries no link, no outline entry and no bookmark: a link to a drawn shape's
+ anchor points at none, and a page reference to it is a fixed number. A gradient fill,
+ unequal corners (drawn at the largest radius) and a line's dash pattern (drawn solid) are
+ not carried either, and a line's caps are not written, which leaves its ends to the editor.
+ A rule — a line or a thin bar written as a paragraph border — keeps its anchor; its note
+ names a lost outline entry, caps (a border ends flat) or a bar's link, a line's link being
+ reported as `rule link`.
A picture, a table, a section or a container names a link or an outline entry it loses the
- same way.
+ same way, and a section or a container written as a column of a layer stack names its anchor,
+ which no bookmark marks there.
- A timeline's rail stands under every other shape on its page, where the page puts it
under the markers only; a filled card drawn under a timeline covers it.
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 f386fb432..f4816e0a5 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
@@ -2424,7 +2424,7 @@ private void warnUnsupported(DocumentNode node) {
}
if (drawnByItsTable(node)) {
// Composed in a cell, what it paints is its table's to draw, and the table's note
- // names that, paint and stroke included; this one names what no drawing carries.
+ // names the gradient and the dash it leaves out; this one names the rest.
List lost = carriedWithout(node, true, false);
if (!lost.isEmpty()) {
report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node),
@@ -2448,23 +2448,30 @@ private void warnUnsupported(DocumentNode node) {
* after what the node became: its link, its outline entry, and — drawn as a shape — the
* anchor, paint and stroke a drawing does not carry, or an image's transform. Empty when it
* loses none of them. A drawn shape's transform and a path's paint are said where the
- * drawing is (drawOwnFragments), and a rule's link and dash where the rule is.
+ * drawing is ({@link #drawOwnFragments}), and a rule's link and dash where the rule is.
*
+ * @param node the node written or drawn
* @param drawn whether the node is drawn as a floating shape rather than written in the flow
+ * @return the phrases, in the order a note lists them
*/
private static List carriedWithout(DocumentNode node, boolean drawn) {
return carriedWithout(node, drawn, true);
}
/**
- * As {@link #carriedWithout(DocumentNode, boolean)}, leaving out the paint and stroke a
- * drawing goes without when {@code paint} is false: a table's own note names those for every
- * drawing its cells hold.
+ * As {@link #carriedWithout(DocumentNode, boolean)}, leaving out a gradient fill and a dash
+ * pattern when {@code paint} is false: a table's own note names those for every drawing its
+ * cells hold.
+ *
+ * @param node the node written or drawn
+ * @param drawn whether the node is drawn as a floating shape rather than written in the flow
+ * @param paint whether to name a gradient fill and a dash pattern the drawing leaves out
+ * @return the phrases, in the order a note lists them
*/
private static List carriedWithout(DocumentNode node, boolean drawn, boolean paint) {
List lost = new ArrayList<>();
- com.demcha.compose.document.node.DocumentLinkTarget link = null;
- com.demcha.compose.document.node.DocumentBookmarkOptions outline = null;
+ DocumentLinkTarget link = null;
+ DocumentBookmarkOptions outline = null;
String anchor = null;
if (node instanceof com.demcha.compose.document.node.ShapeNode shape) {
link = shape.linkTarget();
@@ -2474,7 +2481,7 @@ private static List carriedWithout(DocumentNode node, boolean drawn, boo
&& !(shape.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)) {
lost.add("its gradient fill is not carried");
}
- if (drawn && paint && shape.cornerRadius() != null && !shape.cornerRadius().isUniform()) {
+ if (drawn && shape.cornerRadius() != null && !shape.cornerRadius().isUniform()) {
lost.add("its corners are drawn at the largest of their radii");
}
} else if (node instanceof com.demcha.compose.document.node.EllipseNode ellipse) {
@@ -2490,8 +2497,7 @@ private static List carriedWithout(DocumentNode node, boolean drawn, boo
}
// A drawn line's outline is written with no cap, which leaves its ends to the editor;
// a border has no caps, and ends flat.
- if (paint && line.lineCap() != null
- && line.lineCap() != com.demcha.compose.document.style.DocumentLineCap.BUTT) {
+ if (line.lineCap() != null && line.lineCap() != com.demcha.compose.document.style.DocumentLineCap.BUTT) {
lost.add(drawn ? "its caps are not written, so the editor ends it its own way"
: "its caps are not carried: a border ends flat");
}
@@ -2517,17 +2523,27 @@ private static List carriedWithout(DocumentNode node, boolean drawn, boo
if (outline != null) {
lost.add("its outline entry is not written");
}
- // A drawing is no paragraph a bookmark can hold; a rule is one, and holds its own.
+ // A drawing is no paragraph a bookmark can hold; a rule is one, and holds its own. A page
+ // reference to an anchor no bookmark marks is written as the number the layout found.
if (drawn && anchor != null && !anchor.isBlank()) {
- lost.add("its anchor has no bookmark in the Word file, so a page reference or a link to "
- + "it has nothing to point at");
+ lost.add("its anchor has no bookmark in the Word file: a link to it points at none, and "
+ + "a page reference to it is a fixed number rather than a field");
}
return lost;
}
/** Reports what a node written into the flow goes without, if anything (carriedWithout). */
private void reportWrittenWithout(DocumentNode node, String writtenAs) {
- List lost = carriedWithout(node, false);
+ reportWrittenWithout(node, writtenAs, List.of());
+ }
+
+ /**
+ * As {@link #reportWrittenWithout(DocumentNode, String)}, with what else the path that
+ * wrote it left out.
+ */
+ private void reportWrittenWithout(DocumentNode node, String writtenAs, List alsoLost) {
+ List lost = new ArrayList<>(carriedWithout(node, false));
+ lost.addAll(alsoLost);
if (!lost.isEmpty()) {
report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node),
writtenAs + "; " + String.join("; ", lost));
@@ -2543,14 +2559,22 @@ private static String droppedBecause(DocumentNode node) {
if (!isBuiltIn(node)) {
return "a node kind the DOCX export does not know: not its text, its pictures" + heldNodes(node);
}
- if (node instanceof com.demcha.compose.document.node.ShapeNode shape && shape.fillPaint() != null
- && !(shape.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)) {
- // Painted only with a gradient, a shape has no colour a drawing is filled with.
+ if (node instanceof com.demcha.compose.document.node.ShapeNode shape && paintedOnlyWithAGradient(shape)) {
return "it is painted only with a gradient, which the export does not draw";
}
return "its geometry has no semantic Word analogue";
}
+ /**
+ * Whether a shape's one paint is a gradient fill: the layout gives a shape so filled no flat
+ * colour, and with no stroke round it there is nothing a drawing shows.
+ */
+ private static boolean paintedOnlyWithAGradient(com.demcha.compose.document.node.ShapeNode shape) {
+ return shape.fillPaint() != null
+ && !(shape.fillPaint() instanceof com.demcha.compose.document.style.DocumentPaint.Solid)
+ && (shape.stroke() == null || shape.stroke().width() <= 0);
+ }
+
/**
* Whether a node is one of GraphCompose's own kinds, every one of which this export knows:
* the public node records, and the wrappers the layout adds itself. A prefix would not do —
@@ -2629,8 +2653,11 @@ private boolean drawOwnFragments(DocumentNode node) {
if (node instanceof com.demcha.compose.document.node.PathNode path && losesStyle(path)) {
message.append("; its gradient paint, dash pattern, caps and joins are not carried");
}
- for (String lost : carriedWithout(node, true)) {
- message.append("; ").append(lost);
+ if (isDrawing(node)) {
+ // A section's or a container's own losses are named where it is written.
+ for (String lost : carriedWithout(node, true)) {
+ message.append("; ").append(lost);
+ }
}
if (!isBuiltIn(node)) {
message.append("; a node kind the DOCX export does not know: only the shapes it "
@@ -8074,7 +8101,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception
.setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE);
}
}
- reportWrittenWithout(node, "written as a picture in the flow");
+ reportWrittenWithout(node, "written as an inline picture");
}
/**
@@ -8554,7 +8581,7 @@ private static boolean startsWith(byte[] bytes, int... signature) {
*/
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
throws Exception {
- if (node instanceof TableNode) {
+ if (node instanceof TableNode table && !table.rows().isEmpty()) {
reportWrittenWithout(node, "written as a Word table");
}
// The layout starts a block it moves to a new page at the block's own top edge: what
@@ -8668,8 +8695,9 @@ private boolean drawnByItsTable(DocumentNode node) {
// A node kind the table's drawing does not cover is reported as ever.
return false;
}
- if (node instanceof com.demcha.compose.document.node.PathNode path && !drawsAFlatColour(path)) {
- // A path painted only with a gradient is no shape the table draws.
+ if (node instanceof com.demcha.compose.document.node.PathNode path && !drawsAFlatColour(path)
+ || node instanceof com.demcha.compose.document.node.ShapeNode shape && paintedOnlyWithAGradient(shape)) {
+ // A path or a box painted only with a gradient is no shape the table draws.
return false;
}
boolean box = node instanceof com.demcha.compose.document.node.ShapeNode
@@ -10209,8 +10237,14 @@ private void writeLayerColumns(XWPFDocument document,
pullBelow = 0;
resumeSpacing = plan.resume(node);
}
- // A column layer is written as what it holds, never dispatched itself.
- reportWrittenWithout(node, "written as a column of its layer stack");
+ // A column layer is written as what it holds, never dispatched itself:
+ // no bookmark goes round it, though bookmarkedAnchorsIn counts one.
+ String anchor = blockAnchorOf(node, true);
+ reportWrittenWithout(node, "written as a column of its layer stack",
+ anchor == null || anchor.isBlank() ? List.of()
+ : List.of("its anchor has no bookmark in the Word file: a link "
+ + "or a page reference to it names a bookmark the file "
+ + "does not hold"));
writeChildren(document, node.children(), spacingOf(node));
if (layer > 0 && blocksWritten == blocksBefore && holdsMovedContent(node)) {
// A layer that wrote nothing — its content all written in an
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
index ef9a7974f..a83a0daa0 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
@@ -54,12 +54,13 @@ void aDrawnShapeNamesItsGradientCornersLinkOutlineEntryAndAnchor() throws Except
.cornerRadius(DocumentCornerRadius.of(2, 8, 2, 8))
.linkTo("target").bookmark(OUTLINE).anchor("box")));
- assertThat(detailOf(report, "ShapeNode")).contains("drawn as a shape")
+ assertThat(detailOf(report, "ShapeNode")).startsWith("drawn as a shape")
.contains("its gradient fill is not carried")
.contains("its corners are drawn at the largest of their radii")
.contains("its link is not carried")
.contains("its outline entry is not written")
- .contains("its anchor has no bookmark in the Word file");
+ .contains("its anchor has no bookmark in the Word file: a link to it points at none, "
+ + "and a page reference to it is a fixed number rather than a field");
}
@Test
@@ -69,7 +70,8 @@ void aDrawnEllipseNamesItsLinkOutlineEntryAndAnchor() throws Exception {
.addEllipse(ellipse -> ellipse.size(30, 30).fillColor(INK)
.linkTo("target").bookmark(OUTLINE).anchor("dot")));
- assertThat(detailOf(report, "EllipseNode")).contains("its link is not carried")
+ assertThat(detailOf(report, "EllipseNode")).startsWith("drawn as a shape")
+ .contains("its link is not carried")
.contains("its outline entry is not written").contains("its anchor has no bookmark");
}
@@ -81,7 +83,7 @@ void aDrawnLineNamesItsDashCapsAndLink() throws Exception {
.dashed(DocumentDashPattern.of(4, 2)).lineCap(DocumentLineCap.ROUND).linkTo("target")
.bookmark(OUTLINE).anchor("rail")));
- assertThat(detailOf(report, "LineNode")).contains("drawn as a shape")
+ assertThat(detailOf(report, "LineNode")).startsWith("drawn as a shape")
.contains("its dash pattern is not carried, so it is drawn solid")
.contains("its caps are not written, so the editor ends it its own way")
.contains("its link is not carried").contains("its outline entry is not written")
@@ -116,7 +118,7 @@ void anImageInTheFlowNamesItsTransformLinkAndOutlineEntry() throws Exception {
.addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(80, 40)
.transform(DocumentTransform.rotate(15)).linkTo("target").bookmark(OUTLINE)));
- assertThat(detailOf(report, "ImageNode")).startsWith("written as a picture in the flow")
+ assertThat(detailOf(report, "ImageNode")).startsWith("written as an inline picture")
.contains("its transform is not carried, so it is drawn upright at its size")
.contains("its link is not carried").contains("its outline entry is not written");
}
@@ -139,21 +141,42 @@ void aSectionAndABarcodeNameTheirOutlineEntries() throws Exception {
.addBarcode(barcode -> barcode.qrCode().data("GC-1").size(60, 60).bookmark(OUTLINE)));
assertThat(detailOf(report, "SectionNode")).isEqualTo("written as its contents; its outline entry is not written");
- assertThat(detailOf(report, "barcode")).contains("its outline entry is not written");
+ assertThat(detailOf(report, "barcode")).startsWith("written as a picture of the symbol")
+ .contains("its outline entry is not written");
}
@Test
void aDrawingComposedInATableCellNamesWhatItGoesWithout() throws Exception {
DocxExportReport report = reportOf(page -> page
.addParagraph(p -> p.text("Target").anchor("target"))
- .addTable(table -> table.columns(DocumentTableColumn.fixed(200))
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
.rowCells(DocumentTableCell.node(new ShapeBuilder().size(20, 20)
- .fill(DocumentPaint.linear(SURFACE, INK)).stroke(DocumentStroke.of(INK, 1))
- .linkTo("target").build()))));
+ .fill(DocumentPaint.linear(SURFACE, INK)).stroke(DocumentStroke.of(INK, 1))
+ .cornerRadius(DocumentCornerRadius.of(8, 8, 0, 0))
+ .linkTo("target").build()),
+ DocumentTableCell.node(new com.demcha.compose.document.dsl.LineBuilder().vertical(20)
+ .thickness(1).color(INK).lineCap(DocumentLineCap.ROUND).build()))));
assertThat(detailOf(report, "ShapeNode")).startsWith("composed in a table cell, whose drawing is its table's")
+ .contains("its corners are drawn at the largest of their radii")
.contains("its link is not carried")
- .as("its paint is the table's note's to name").doesNotContain("gradient");
+ .as("its gradient is the table's note's to name").doesNotContain("gradient");
+ assertThat(detailOf(report, "LineNode")).startsWith("composed in a table cell, whose drawing is its table's")
+ .contains("its caps are not written, so the editor ends it its own way");
+ }
+
+ @Test
+ void aShapeComposedInACellPaintedOnlyWithAGradientIsDroppedForThat() throws Exception {
+ // The table draws the solid box beside it, and still nothing draws a gradient alone.
+ DocxExportReport report = reportOf(page -> page
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.node(new ShapeBuilder().size(20, 20).fillColor(INK).build()),
+ DocumentTableCell.node(new ShapeBuilder().size(20, 20)
+ .fill(DocumentPaint.linear(SURFACE, INK)).build()))));
+
+ assertThat(detailOf(report, "ShapeNode")).startsWith("it is painted only with a gradient");
+ assertThat(report.bySubject().get("ShapeNode").get(0).severity())
+ .isEqualTo(DocxExportReport.Severity.DROPPED);
}
@Test
@@ -171,6 +194,50 @@ void aSectionLaidOutAsALayerColumnNamesItsOutlineEntry() throws Exception {
assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a column of its layer stack; its outline entry is not written");
}
+ @Test
+ void aLayerColumnNamesItsAnchorThatNoBookmarkMarks() throws Exception {
+ // A column is written as what it holds, with no bookmark round it, while a link or a page
+ // reference to its anchor still names one.
+ String lost = "written as a column of its layer stack; its anchor has no bookmark in the Word file: "
+ + "a link or a page reference to it names a bookmark the file does not hold";
+ DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack
+ .layer(new com.demcha.compose.document.dsl.SectionBuilder().anchor("left")
+ .margin(new DocumentInsets(0, 220, 0, 0)).addParagraph("Left column").build(),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
+ .layer(new com.demcha.compose.document.node.ContainerNode("Right",
+ List.of(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Right column").build()),
+ 0, DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 200), null, null, null, null, "right"),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT)));
+
+ assertThat(detailOf(report, "SectionNode")).isEqualTo(lost);
+ assertThat(detailOf(report, "ContainerNode")).isEqualTo(lost);
+ }
+
+ @Test
+ void aSectionInsideANodeLaidOverTheFlowNamesItsOutlineEntry() throws Exception {
+ DocxExportReport report = reportOf(page -> page
+ .add(laidOverTheFlow(new com.demcha.compose.document.dsl.SectionBuilder().bookmark(OUTLINE)
+ .addParagraph("Studio").build()))
+ .addParagraph("Masthead"));
+
+ assertThat(detailOf(report, "SectionNode"))
+ .isEqualTo("written over the flow as what it holds; its outline entry is not written");
+ }
+
+ @Test
+ void aPaintedSectionInsideANodeLaidOverTheFlowNamesItsOutlineEntryOnce() throws Exception {
+ // Its fill is drawn as a shape and its text set in text boxes: two notes, one loss.
+ DocxExportReport report = reportOf(page -> page
+ .add(laidOverTheFlow(new com.demcha.compose.document.dsl.SectionBuilder().fillColor(SURFACE)
+ .bookmark(OUTLINE).addParagraph("Studio").build()))
+ .addParagraph("Masthead"));
+
+ assertThat(report.bySubject().get("SectionNode"))
+ .extracting(DocxExportReport.Note::detail)
+ .filteredOn(detail -> detail.contains("its outline entry is not written"))
+ .containsExactly("written over the flow as what it holds; its outline entry is not written");
+ }
+
@Test
void thePageFlowAndAPanelNameTheirOutlineEntries() throws Exception {
DocxExportReport report = reportOf(page -> page.bookmark(OUTLINE)
@@ -217,6 +284,20 @@ void nodesThatLoseNoneOfThemAddNoNote() throws Exception {
assertThat(report.bySubject()).doesNotContainKeys("ImageNode", "TableNode", "SectionNode", "ShapeNode");
}
+ /**
+ * A sidebar laid over the flow, holding the given layer: an overlay its margins give no room,
+ * pulled up over the page's top margin and handing its height back below.
+ */
+ private static com.demcha.compose.document.node.DocumentNode laidOverTheFlow(
+ com.demcha.compose.document.node.DocumentNode layer) {
+ double height = 120;
+ return new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("Sidebar")
+ .rectangle(100, height).clipPolicy(com.demcha.compose.document.style.ClipPolicy.OVERFLOW_VISIBLE)
+ .margin(new DocumentInsets(-20, 0, 20 - height, -20))
+ .position(layer, 10, 10, com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
+ .build();
+ }
+
private static String detailOf(DocxExportReport report, String subject) {
List notes = report.bySubject().get(subject);
assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index 3c5fce511..097227b80 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -113,8 +113,8 @@ private record Entry(Fate fate, String note) {
"margin:GAP:the space round the chart's table", "padding:GAP:the space round the chart's table");
node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
"margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
- "anchor:WRITTEN", "bookmarkOptions:REPORTED",
- "flowWidth:GAP:the width of an unpainted container");
+ "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked",
+ "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of an unpainted container");
node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN",
"stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED",
"padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED",
@@ -172,7 +172,8 @@ private record Entry(Fate fate, String note) {
node(SectionNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
"margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
"keepTogether:WRITTEN",
- "anchor:GAP:the bookmark of a section written as a layer stack's column; a page reference to it points at none", "bleed:GAP:the paint's bleed past the section",
+ "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked",
+ "bleed:GAP:the paint's bleed past the section",
"bookmarkOptions:REPORTED", "keepWithNext:WRITTEN",
"flowWidth:GAP:the width of an unpainted section");
node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN",