diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ebaf735..f94d5dc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ 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 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, 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 60d11a774..57a8b0616 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..bc38f5257 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -891,9 +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. Two limits, each named in the report: +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 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, 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. @@ -914,8 +924,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..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 @@ -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 the gradient and the dash it leaves out; this one names the rest. + ListThe 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")).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: a link to it points at none, " + + "and a page reference to it is a fixed number rather than a field"); + } + + @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")).startsWith("drawn as a shape") + .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")).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") + .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 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"); + } + + @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")).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(100), DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(new ShapeBuilder().size(20, 20) + .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 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 + 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 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) + .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"); + } + + /** + * 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