From 1cd2b02cd57e02be02314f3220835fb6750def8e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 21:20:38 +0100 Subject: [PATCH 1/2] fix(docx): report the losses the export left silent, and hold every node field to a decision A row's fill, outline and side borders, anything in a page zone but text and page fields, a watermark, a protection, viewer preferences and a node kind of the caller's own all reached the Word file with at most a log line, so a caller reading DocxExportReport was told the document lost nothing. Each is now a report note: row paint (DROPPED; APPROXIMATED for a row composed in a table cell, whose paint the cell drawing may draw), page zone content (once per node, by identity, though a zone is written into each kind of header it is given), watermark per section, protection and viewer preferences once for the file, and an unknown node kind named as unknown, with what it holds, instead of as dropped geometry. A page field in the body says why it is dropped. Built-in kinds are those of the node and layout packages exactly. DocxNodeFieldLedgerTest records, for every field of every public node class and every output option, whether the export writes it, reports it, has nothing to carry, or leaves a gap naming what is lost, and fails on a kind, field or non-record node class nobody decided. The DOCX bytes do not change. --- CHANGELOG.md | 29 ++ .../architecture/backend-capability-matrix.md | 8 +- docs/recipes/docx-export.md | 11 +- render-docx/README.md | 6 +- .../semantic/docx/DocxSemanticBackend.java | 150 +++++++- .../docx/DocxNodeFieldLedgerTest.java | 308 ++++++++++++++++ .../semantic/docx/DocxReportedLossesTest.java | 345 ++++++++++++++++++ 7 files changed, 844 insertions(+), 13 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b71b464c..f4c8a6ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **The DOCX export report names what it used to leave out in silence.** Four 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: + - a row's fill, outline and side borders (`RowBuilder.fillColor`, `stroke`, `borders`): the + row is written as a table with no shading or borders, and a page zone's row as one line. + Now `DROPPED`, `row paint`, naming which of the three, a row of no columns included; a + corner radius with nothing to round paints nothing and is not named. A row composed in a + table cell is `APPROXIMATED` instead: what a cell paints is drawn as a shape where it + frames no text, and the layout does not say which box is the row's; + - anything in a page zone but paragraphs, page fields and spacers, such as a logo: now + `DROPPED`, `page zone content`, once for each, though a zone is written into each kind of + header or footer it is given — and two logos of no name are two notes; + - a watermark, a protection and viewer preferences: now `DROPPED`, `watermark` (once per + section that sets one), `protection` and `viewer preferences` (once for the file) — a file + asked to be protected opens and edits without a password, and the report says so; + - a node kind of the caller's own (a `DocumentNode` with its own `NodeDefinition`): it was + reported as dropped geometry "with no semantic Word analogue". It is now named as a kind + the export does not know, losing its text, its pictures and what it holds — counted where + the node lists its children; the shapes it paints itself are still drawn, and say the + same. A page field in the body, not a page zone, is dropped for that reason rather than + for its geometry. + + Which kinds are the export's own is decided by the node's package, exactly: a caller's node + in a package under the same root is not taken for a built-in one. A new test, + `DocxNodeFieldLedgerTest`, records for every field of every public node class in the node + and layout packages, and every output option, whether the export writes it, reports it, has + nothing to carry, or leaves a gap that names what is lost — 29 fields have nothing to carry + and 55 such gaps remain, each named. A node kind that is not a record, or a kind or field + added to the engine, fails it until someone decides. - **A DOCX timeline's dots, and drawings centred on a line, move with their text too.** A drawing in a table cell went into that cell only when the cell held it across, and a dot set in a column of its own beside its entry's text — `CharcoalGold`'s timeline, a row of diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 842a148be..60d11a774 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -113,11 +113,11 @@ honour an option ignores it (documented contract). |---|---|---|---| | Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyOutputOptions` (OPC core properties — title, author as creator, subject, keywords; OPC has no producer field here either, so that value is not representable) | | Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's | -| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ | +| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) | | Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border; the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported | -| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped and reported on the `docx` logger. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | -| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ | -| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a | +| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | +| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) | +| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | | Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it | | Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 040ddcbd1..f2d0a1725 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -484,7 +484,9 @@ How it lands: padding takes back. Unwritten, Word 16 puts the table's edge on the margin and the text a padding further in (measured). - **Nesting.** A panel inside a panel is a table inside its cell. So is a row, with no fill - of its own, so the panel shows through it. A table keeps its own cell fills, and a cell + of its own, so the panel shows through it. A row's own fill, outline and side borders + (`RowBuilder.fillColor`, `stroke`, `borders`) are not written yet, in or out of a panel, + and the export report names them (`row paint`). A table keeps its own cell fills, and a cell no style fills is written white, as the page draws it on the card. Word draws a table's right border outside its right edge and, on screen, cuts off what passes its cell's edge and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its @@ -914,7 +916,9 @@ 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. -Watermarks and protection options are also ignored by the current exporter. +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. A text header or footer (`session.header(...)` / `session.footer(...)`, a `DocumentHeaderFooter`) is not: it exports as a line of a real Word header @@ -948,6 +952,9 @@ Word header or footer part, with the page number as a live field, and it sits as far from its page edge as the page puts it — the distance is read from where the zone's content landed in the resolved layout and written as `w:pgMar/@w:header` or `@w:footer`, rather than left to Word's 36pt. +A zone is written from its paragraphs, page fields and spacers: anything else in it — a +logo, a panel, a table — is not written, and the export report names it once +(`page zone content`). A zone drawn on some pages only (`appliesTo(...)`) lands on the same pages when Word can say so. Word has a header and footer for the first page, for even pages and for the rest, diff --git a/render-docx/README.md b/render-docx/README.md index 2b8703b87..196dae6cb 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -108,8 +108,10 @@ What is not written — each one is named in the export report children in order, without their positions and without the clip; a rotation or scale is not carried. A layer stack whose layers are side-by-side columns is the exception: it is written as one table row, a cell per column. -- **In a page zone**, a barcode or a rule. -- **Watermarks and protection options.** +- **Watermarks, protection and viewer preferences**, each named in the export report. +- **A row's own fill, outline and side borders**, named in the export report as `row paint`. +- **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode, + a rule — named in the export report as `page zone content`. - **`markerGap`** on a hanging-indent list: Word places the item text at its own indent. Multi-section documents export through `MultiSectionDocument.toDocxBytes()`, 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 fc5a34972..fb2ff20ec 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 @@ -204,6 +204,11 @@ public final class DocxSemanticBackend implements SemanticBackend { // Geometry-only node kinds already warned about this export pass. private final java.util.Set warnedNodeKinds = java.util.concurrent.ConcurrentHashMap.newKeySet(); + // The page zone content already reported this export. By identity: a zone's content is built + // once a section and written into each kind of header Word is given, and two logos of one + // kind and no name are two losses. + private final java.util.Set zonePartsReported = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); private final AtomicBoolean containerRadiusWarned = new AtomicBoolean(false); // The fill of the panel being written into, or null: a table cell with no fill of its own // is drawn white by the engine, and inside a filled panel has to say so rather than let the @@ -682,6 +687,7 @@ private byte[] write(List sections, Path outputFile) throws Exc chartWarned.set(false); containerRadiusWarned.set(false); warnedNodeKinds.clear(); + zonePartsReported.clear(); surfaceBehind = null; overlayDepth = 0; bandDepth = 0; @@ -755,6 +761,7 @@ private byte[] write(List sections, Path outputFile) throws Exc writeStylesPart(document); DocxFontTable.write(document, whole, fonts, report); applyMetadata(document, metadataOf(sections)); + reportUnwrittenOutputOptions(sections); } earlierZones.addAll(applyPageZones(document, context.outputOptions().zones(), context.outputOptions().headersAndFooters(), evenAndOdd, earlierZones)); @@ -1100,8 +1107,39 @@ private void applyMetadata(XWPFDocument document, DocumentMetadata metadata) { props.getCoreProperties().setKeywords(metadata.getKeywords()); } } - // The watermark and protection are still ignored. The text header and footer are - // written with the page zones — see applyPageZones. + // The text header and footer are written with the page zones — see applyPageZones; the + // watermark, protection and viewer preferences are not written, and are reported + // (reportUnwrittenOutputOptions). + } + + /** + * Reports what a section's output options ask of the file that a Word file is not given: a + * watermark, a protection and viewer preferences are the PDF backend's alone. Without the + * note an export asked to protect a document hands back an open one, and says nothing. + */ + private void reportUnwrittenOutputOptions(List sections) { + boolean protection = false; + boolean viewerPreferences = false; + for (int i = 0; i < sections.size(); i++) { + DocumentOutputOptions options = sections.get(i).context().outputOptions(); + // A watermark belongs to its section's pages; a protection and viewer preferences + // belong to the whole file, as a multi-section PDF takes the first a section sets. + if (options.watermark() != null) { + report.add(DocxExportReport.Severity.DROPPED, "watermark", + sections.size() > 1 ? "section " + (i + 1) : null, + "the DOCX export writes no watermark, so the pages have none"); + } + protection |= options.protection() != null; + viewerPreferences |= options.viewerPreferences() != null; + } + if (protection) { + report.add(DocxExportReport.Severity.DROPPED, "protection", null, + "the DOCX export writes no protection, so the file opens and edits without a password"); + } + if (viewerPreferences) { + report.add(DocxExportReport.Severity.DROPPED, "viewer preferences", null, + "the DOCX export writes no viewer preferences, so Word opens the file its own way"); + } } /** @@ -1656,6 +1694,11 @@ private void writeZoneLine(XWPFHeaderFooter target, DocumentNode content) { tab.setPos(java.math.BigInteger.valueOf(Math.round(contentWidth * TWIPS_PER_POINT))); List parts = content instanceof RowNode row ? row.children() : List.of(content); + // A zone's row is its children on one line; its paint is lost as a body row's is, and + // said once, though the line is written into each kind of header the zone is given. + if (content instanceof RowNode row && zonePartsReported.add(row)) { + reportUnwrittenRowPaint(row, true); + } for (DocumentNode part : parts) { appendZonePart(para, part); } @@ -1736,6 +1779,16 @@ private void warnUnsupportedZoneNode(DocumentNode node) { + " and spacers onto a Word header/footer; other nodes are skipped", node.nodeKind()); } + // A zone's content is written into each kind of header Word is given — the first page's, + // the even pages', the rest —, so the same logo would be reported once per kind: once is + // what the caller needs. + if (zonePartsReported.add(node)) { + report.add(DocxExportReport.Severity.DROPPED, "page zone content", null, + "a page zone's " + node.nodeKind() + + (node.name().isEmpty() ? "" : " '" + node.name() + "'") + + " is not written: a Word header or footer is written from the zone's " + + "paragraphs, page fields and spacers only"); + } } private void writeNode(XWPFDocument document, DocumentNode node) throws Exception { @@ -2368,12 +2421,47 @@ private void warnUnsupported(DocumentNode node) { // Drawn: by the node, or, composed in a cell, by its table. return; } + String why = droppedBecause(node); if (warnedNodeKinds.add(node.nodeKind())) { - LOG.warn("DocxSemanticBackend: dropping '{}' node(s) — geometry has no semantic " - + "Word analogue; use the PDF backend for pixel-perfect output", node.nodeKind()); + LOG.warn("DocxSemanticBackend: dropping '{}' node(s) — {}; use the PDF backend for " + + "pixel-perfect output", node.nodeKind(), why); } report.add(DocxExportReport.Severity.DROPPED, node.nodeKind(), layout.pathOf(node), - "geometry has no semantic Word analogue, so it is not in the document at all"); + why + ", so it is not in the document"); + } + + /** 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) { + return "a page field is written in a page zone, as a Word header's or footer's field, " + + "and not in the body"; + } + if (!isBuiltIn(node)) { + return "a node kind the DOCX export does not know: not its text, its pictures" + heldNodes(node); + } + return "its geometry has no semantic Word analogue"; + } + + /** + * 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 — + * a caller's node in a package of its own under the same root is still unknown here. + */ + private static boolean isBuiltIn(DocumentNode node) { + String pkg = node.getClass().getPackageName(); + return pkg.equals(DocumentNode.class.getPackageName()) + || pkg.equals(com.demcha.compose.document.layout.LayoutAnchorNode.class.getPackageName()); + } + + /** + * How a report names what an unknown node holds, which is lost with it: counted where the + * node says, and otherwise named without a number — a definition can lay out children the + * node does not list. + */ + private static String heldNodes(DocumentNode node) { + int held = node.children().size(); + return held == 0 ? ", nor anything it holds" + : ", nor the " + held + (held == 1 ? " node" : " nodes") + " it holds"; } /** @@ -2432,6 +2520,10 @@ 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"); } + 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)); + } report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), message.toString()); } @@ -9610,10 +9702,58 @@ private static DocumentTableStyle orEmpty(DocumentTableStyle style) { return style == null ? DocumentTableStyle.empty() : style; } + /** + * Reports the paint a row asks for that its table is not given: the page paints a row's + * fill, outline and side borders round its columns (RowDefinition), and the row's table is + * written without them. A corner radius on its own paints nothing, so it is not reported. + * + *

A row composed in a table cell is the exception: what the cell paints is drawn as + * shapes where it frames no text (drawCellDrawing), and the layout does not say which of + * those boxes is the row's, so the note says which way it went rather than that it was + * lost.

+ */ + private void reportUnwrittenRowPaint(RowNode node, boolean inAZone) { + List paint = new ArrayList<>(); + if (node.fillColor() != null) { + paint.add("fill"); + } + if (node.stroke() != null && node.stroke().width() > 0) { + paint.add("outline"); + } + if (paintsASide(node.borders())) { + paint.add("borders"); + } + if (paint.isEmpty()) { + return; + } + String what = "the row's " + String.join(", ", paint) + (paint.size() == 1 ? " is" : " are"); + if (inAZone) { + // A zone's content is not in the layout, so it is no cell's either. + report.add(DocxExportReport.Severity.DROPPED, "row paint", null, + what + " not written: a page zone's row is written as one line of the header or " + + "footer"); + } else if (composedInACell(node)) { + report.add(DocxExportReport.Severity.APPROXIMATED, "row paint", layout.pathOf(node), + what + " drawn as a shape behind its cell where it frames no text, and not written " + + "where it does"); + } else { + report.add(DocxExportReport.Severity.DROPPED, "row paint", layout.pathOf(node), + what + " not written: its columns are, as a table with no shading or borders"); + } + } + + /** Whether any side of a set of borders is drawn: a side of no width draws nothing. */ + private static boolean paintsASide(com.demcha.compose.document.style.DocumentBorders borders) { + return java.util.stream.Stream.of(borders.top(), borders.right(), borders.bottom(), borders.left()) + .anyMatch(side -> side != null && side.width() > 0); + } + private void writeRow(XWPFDocument document, RowNode node) throws Exception { // Represent rows as a single one-row table so downstream editors get a // visual side-by-side layout; each cell holds its child as it is written // anywhere else (writeCellBody). + // Before the empty row returns: a row of no columns the page still paints. + reportUnwrittenRowPaint(node, false); if (node.children().isEmpty()) { return; } 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 new file mode 100644 index 000000000..bcce88fca --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -0,0 +1,308 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.BarcodeNode; +import com.demcha.compose.document.node.CanvasLayerNode; +import com.demcha.compose.document.node.ChartNode; +import com.demcha.compose.document.node.ContainerNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.ImageNode; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.LineNode; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.node.PageFieldNode; +import com.demcha.compose.document.node.PageReferenceNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.PathNode; +import com.demcha.compose.document.node.PolygonNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.ShapeContainerNode; +import com.demcha.compose.document.node.ShapeNode; +import com.demcha.compose.document.node.SpacerNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.output.DocumentOutputOptions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the DOCX export does with every field of every node, and with every output option: + * writes it, reports it when the author set it, has nothing for Word to carry, or leaves a + * known gap that names what is lost. + * + *

The export's report promises a caller that what the file does not carry is said, not + * approximated in silence. That promise is only as good as the fields someone thought about: + * a field added to a node later is written by the PDF backend at once and, until the DOCX export + * decides, lost from the Word file without a word. This ledger fails on any node kind or field + * it has no entry for, so the decision is made when the field is added, not when a reader finds + * the loss.

+ * + *

The entries are claims about the export, not proofs of it: a {@code WRITTEN} field is + * proved by the export's own tests, a {@code REPORTED} one by a report test, and a {@code GAP} + * is a loss the report does not yet name, each with what is lost. Closing a gap moves its entry, + * in the same change that makes the export write or report it.

+ */ +class DocxNodeFieldLedgerTest { + + private enum Fate { + /** Reaches the Word file, directly or through the geometry the layout resolved. */ + WRITTEN, + /** Not in the Word file, and named in the export's report when the author set it. */ + REPORTED, + /** + * Nothing for a Word file to carry: a name the report addresses the node by, or an + * identity the layout resolves a wrapper's place by. + */ + INERT, + /** Lost, in some or all cases, without a report note yet; the entry says what. */ + GAP + } + + /** A field's fate, and for a gap what is lost, for a report what names it. */ + private record Entry(Fate fate, String note) { + } + + private static final Map, Map> NODES = new LinkedHashMap<>(); + private static final Map OUTPUT_OPTIONS = fields( + "metadata:WRITTEN", + "watermark:REPORTED", + "protection:REPORTED", + "viewerPreferences:REPORTED", + "headersAndFooters:WRITTEN", + "zones:WRITTEN"); + + 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", + "padding:GAP:its left and right sides", "margin:GAP:its left and right sides", + "transform:REPORTED", "anchor:WRITTEN"); + node(CanvasLayerNode.class, "name:INERT", + "width:GAP:the room the canvas holds in the flow", + "height:GAP:the room the canvas holds in the flow", + "placements:GAP:where its text, pictures and tables stand; they are written one after another", + "clipPolicy:GAP:the clip", "padding:WRITTEN", "margin:WRITTEN"); + node(ChartNode.class, "name:INERT", "spec:REPORTED", "style:REPORTED:in the chart's 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", + "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", + "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", + "anchor:GAP:its bookmark; a page reference to it is written as text"); + 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", + "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", + "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", + "lineSpacing:GAP:the gap between an item's lines where the layout's item count differs", + "itemSpacing:WRITTEN", "continuationIndent:GAP:the indent of an item's wrapped lines", + "normalizeMarkers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", + "hangingIndent:GAP:the marker column of a nesting list's deeper levels", + "markerGap:GAP:the gap of a nesting list's deeper levels"); + node(PageBreakNode.class, "name:INERT", "margin:INERT"); + node(PageFieldNode.class, "name:INERT", "kind:WRITTEN", "textStyle:WRITTEN", + "align:GAP:its alignment in a page zone", "padding:GAP:its sides in a page zone", + "margin:GAP:its sides in a page zone"); + node(PageReferenceNode.class, "name:INERT", + "anchor:GAP:the live field, where the anchor has no bookmark; the number is written as text", + "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", + "padding:GAP:its left and right sides", "margin:GAP:its left and right sides"); + node(ParagraphNode.class, "name:INERT", "text:WRITTEN", "inlineRuns:WRITTEN", "textStyle:WRITTEN", + "align:WRITTEN", "lineSpacing:WRITTEN", "bulletOffset:GAP:the letters of a prefix that has any", + "indentStrategy:WRITTEN", "linkTarget:WRITTEN", "bookmarkOptions:GAP:the outline entry's own title", + "padding:WRITTEN", "margin:WRITTEN", "autoSize:GAP:the size the text was fitted to", + "verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN"); + node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN", + "fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED", + "padding:WRITTEN", "margin:WRITTEN", "dashPattern:REPORTED", "lineCap:REPORTED", "lineJoin:REPORTED"); + node(PolygonNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "points:WRITTEN", + "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN"); + node(RowNode.class, "name:INERT", "children:WRITTEN", "weights:WRITTEN", "gap:WRITTEN", "padding:WRITTEN", + "margin:WRITTEN", "fillColor:REPORTED", "stroke:REPORTED", + "cornerRadius:REPORTED:with the paint it rounds; with none it paints nothing", + "borders:REPORTED", "columns:WRITTEN", "verticalAlign:WRITTEN", "arrangement:WRITTEN"); + // The wrappers the layout adds round a timeline's parts, public records the export writes + // through to their child. + node(com.demcha.compose.document.layout.LayoutAnchorNode.class, "name:INERT", "id:INERT", "child:WRITTEN"); + node(com.demcha.compose.document.layout.HorizontalBandsNode.class, "name:INERT", "key:INERT", + "child:WRITTEN"); + node(com.demcha.compose.document.layout.HorizontalBandContentNode.class, "name:INERT", "key:INERT", + "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", + "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"); + 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", + "margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN"); + } + + @Test + void everyNodeKindGraphComposeShipsHasAnEntry() throws Exception { + assertThat(names(publicNodeClasses())) + .as("the public node classes of %s: a kind with no entry has fields nobody decided " + + "the DOCX export's answer for", PACKAGES) + .containsExactlyInAnyOrderElementsOf(names(NODES.keySet())); + } + + @Test + void everyNodeKindGraphComposeShipsIsARecord() throws Exception { + // The ledger reads a node's fields as its record components; a node class that is not a + // record would have fields this test cannot see, and so no decision for any of them. + assertThat(publicNodeClasses()).as("public node classes that are not records") + .filteredOn(kind -> !kind.isRecord()).isEmpty(); + } + + @Test + void everyFieldOfEveryNodeHasAnEntry() { + Map> undecided = new LinkedHashMap<>(); + Map> stale = new LinkedHashMap<>(); + NODES.forEach((kind, entries) -> { + Set fields = components(kind); + Set missing = new TreeSet<>(fields); + missing.removeAll(entries.keySet()); + Set extra = new TreeSet<>(entries.keySet()); + extra.removeAll(fields); + if (!missing.isEmpty()) { + undecided.put(kind.getSimpleName(), missing); + } + if (!extra.isEmpty()) { + stale.put(kind.getSimpleName(), extra); + } + }); + assertThat(undecided).as("fields with no entry: decide whether the DOCX export writes them, " + + "reports them, or leaves a gap, and say which here").isEmpty(); + assertThat(stale).as("entries for fields the node no longer has").isEmpty(); + } + + @Test + void everyOutputOptionHasAnEntry() { + assertThat(components(DocumentOutputOptions.class)) + .as("the output options a session hands every export") + .containsExactlyInAnyOrderElementsOf(OUTPUT_OPTIONS.keySet()); + } + + @Test + void everyGapSaysWhatIsLost() { + List unexplained = new ArrayList<>(); + NODES.forEach((kind, entries) -> entries.forEach((field, entry) -> { + if (entry.fate() == Fate.GAP && entry.note().isBlank()) { + unexplained.add(kind.getSimpleName() + "." + field); + } + })); + assertThat(unexplained).as("a gap names what the Word file loses").isEmpty(); + } + + private static void node(Class kind, String... specs) { + NODES.put(kind, fields(specs)); + } + + /** {@code field:FATE}, or {@code field:GAP:what is lost}. */ + private static Map fields(String... specs) { + Map entries = new LinkedHashMap<>(); + for (String spec : specs) { + String[] parts = spec.split(":", 3); + Entry entry = new Entry(Fate.valueOf(parts[1]), parts.length > 2 ? parts[2] : ""); + if (entries.put(parts[0], entry) != null) { + throw new IllegalStateException("two entries for " + parts[0]); + } + } + return entries; + } + + private static Set components(Class record) { + return Arrays.stream(record.getRecordComponents()).map(RecordComponent::getName) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static Set names(java.util.Collection> kinds) { + return kinds.stream().map(Class::getName).collect(Collectors.toCollection(TreeSet::new)); + } + + /** + * The packages whose node kinds the export counts as its own: the public node records, and + * the wrappers the layout adds (see {@code DocxSemanticBackend.isBuiltIn}). + */ + private static final List PACKAGES = List.of(DocumentNode.class.getPackageName(), + com.demcha.compose.document.layout.LayoutAnchorNode.class.getPackageName()); + + /** The public, concrete node classes the core module ships in {@link #PACKAGES}. */ + private static List> publicNodeClasses() throws IOException, URISyntaxException, ClassNotFoundException { + Path source = Path.of(DocumentNode.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + List classFiles = new ArrayList<>(); + for (String pkg : PACKAGES) { + String folder = pkg.replace('.', '/') + "/"; + if (Files.isDirectory(source)) { + try (Stream files = Files.list(source.resolve(folder))) { + files.map(file -> folder + file.getFileName()).forEach(classFiles::add); + } + } else { + try (JarFile jar = new JarFile(source.toFile())) { + jar.stream().map(java.util.zip.ZipEntry::getName) + .filter(name -> name.startsWith(folder) && name.indexOf('/', folder.length()) < 0) + .forEach(classFiles::add); + } + } + } + List> kinds = new ArrayList<>(); + for (String file : classFiles) { + if (!file.endsWith(".class") || file.contains("$")) { + continue; + } + Class type = Class.forName(file.substring(0, file.length() - ".class".length()).replace('/', '.')); + if (DocumentNode.class.isAssignableFrom(type) && Modifier.isPublic(type.getModifiers()) + && !type.isInterface() && !Modifier.isAbstract(type.getModifiers())) { + kinds.add(type); + } + } + return kinds; + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java new file mode 100644 index 000000000..52fb2dbaa --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java @@ -0,0 +1,345 @@ +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.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.layout.BoxConstraints; +import com.demcha.compose.document.layout.FragmentContext; +import com.demcha.compose.document.layout.FragmentPlacement; +import com.demcha.compose.document.layout.LayoutFragment; +import com.demcha.compose.document.layout.MeasureResult; +import com.demcha.compose.document.layout.NodeDefinition; +import com.demcha.compose.document.layout.PaginationPolicy; +import com.demcha.compose.document.layout.PrepareContext; +import com.demcha.compose.document.layout.PreparedNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.output.DocumentPageZone; +import com.demcha.compose.document.output.DocumentProtection; +import com.demcha.compose.document.output.DocumentViewerPreferences; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.document.style.DocumentBorders; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +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 document asks for that the Word file is not given is in the report, not only in the + * file's absence. + * + *

A row's fill, a logo in a page header, a watermark, a protection and a node kind the + * export does not know were each left out of the Word file with no more than a log line, or + * nothing: a caller reading the report was told the document lost nothing. Each is now a note + * naming what the file does not carry.

+ */ +class DocxReportedLossesTest { + + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + private static final DocumentStroke RULE = DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1); + + @Test + void aRowsFillOutlineAndBordersAreReportedAsNotWritten() throws Exception { + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row + .name("Totals").fillColor(SURFACE).stroke(RULE).borders(DocumentBorders.bottom(RULE)) + .addParagraph("Subtotal").addParagraph("120.00")))); + + List notes = report.bySubject().get("row paint"); + assertThat(notes).as("one note for the row").hasSize(1); + assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED); + assertThat(notes.get(0).path()).contains("Totals"); + assertThat(notes.get(0).detail()).contains("fill, outline, borders are not written"); + } + + @Test + void aRowWithAFillAloneSaysSo() throws Exception { + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row + .fillColor(SURFACE).addParagraph("Left").addParagraph("Right")))); + + assertThat(report.bySubject().get("row paint")).singleElement() + .extracting(DocxExportReport.Note::detail).asString().contains("the row's fill is not written"); + } + + @Test + void aRowThatPaintsNothingReportsNothing() throws Exception { + // A corner radius rounds a paint; with none to round, the page draws nothing either. + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row + .cornerRadius(6).stroke(DocumentStroke.of(SURFACE, 0)).addParagraph("Left").addParagraph("Right")))); + + assertThat(report.bySubject()).doesNotContainKey("row paint"); + } + + @Test + void aPictureInAPageZoneIsReportedOnceThoughWrittenIntoTwoHeaders() throws Exception { + // A second zone on the first page alone gives the section a first-page header, so the + // logo's zone is written into it and into the ordinary header: told once all the same. + DocxExportReport report = reportOf(session -> { + session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder() + .addImage(image -> image.name("Logo").source(DocumentImageData.fromBytes(png())).size(24, 24)) + .addParagraph("Quarterly report") + .build())); + session.chrome().zone(onTheFirstPage(DocumentPageZone.footer(20, + page -> new ParagraphBuilder().text("Cover").build()))); + longBody(session); + }); + + List notes = report.bySubject().get("page zone content"); + assertThat(notes).as("the logo, once").hasSize(1); + assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED); + assertThat(notes.get(0).detail()).contains("ImageNode 'Logo' is not written"); + } + + @Test + void twoPicturesOfNoNameInAPageZoneAreTwoNotes() throws Exception { + DocxExportReport report = reportOf(session -> { + session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder() + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24)) + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24)) + .build())); + session.pageFlow(page -> page.addParagraph("Body")); + }); + + assertThat(report.bySubject().get("page zone content")).as("each picture is a loss of its own").hasSize(2); + } + + @Test + void aZoneOfTextAndPageNumbersReportsNothing() throws Exception { + DocxExportReport report = reportOf(session -> { + session.chrome().zone(DocumentPageZone.footer(20, page -> new RowBuilder() + .addParagraph("Quarterly report").add(page.pageNumber()).build())); + session.pageFlow(page -> page.addParagraph("Body")); + }); + + assertThat(report.bySubject()).doesNotContainKey("page zone content"); + } + + @Test + void aPaintedRowInAPageZoneIsReportedOnceThoughWrittenIntoTwoFooters() throws Exception { + // A zone's row is written as one line of its children, into each kind of footer it is given. + DocxExportReport report = reportOf(session -> { + session.chrome().zone(DocumentPageZone.footer(20, page -> new RowBuilder().name("Band") + .fillColor(SURFACE).addParagraph("Quarterly report").add(page.pageNumber()).build())); + session.chrome().zone(onTheFirstPage(DocumentPageZone.header(20, + page -> new ParagraphBuilder().text("Cover").build()))); + longBody(session); + }); + + assertThat(report.bySubject().get("row paint")).as("the band's fill, once").singleElement() + .extracting(DocxExportReport.Note::detail).asString() + .contains("the row's fill is not written: a page zone's row is written as one line"); + } + + @Test + void aRowOfNoColumnsThePagePaintsIsReported() throws Exception { + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row + .fillColor(SURFACE).padding(DocumentInsets.of(8))))); + + assertThat(report.bySubject()).containsKey("row paint"); + } + + @Test + void aWatermarkAProtectionAndViewerPreferencesAreReportedAsNotWritten() throws Exception { + DocxExportReport report = reportOf(session -> { + session.chrome() + .watermark(DocumentWatermark.builder().text("DRAFT").build()) + .protect(DocumentProtection.builder().userPassword("u").build()) + .viewerPreferences(DocumentViewerPreferences.openOutline()); + session.pageFlow(page -> page.addParagraph("Body")); + }); + + assertThat(report.bySubject()).containsKeys("watermark", "protection", "viewer preferences"); + assertThat(report.bySubject().get("protection")).singleElement() + .extracting(DocxExportReport.Note::detail).asString().contains("opens and edits without a password"); + } + + @Test + void aWatermarkIsReportedPerSectionAndAProtectionOnceForTheFile() throws Exception { + // A watermark is on its section's pages; a protection is the whole file's, however many + // sections ask for it. + AtomicReference captured = new AtomicReference<>(); + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(protectedWatermarked("Cover")) + .section(protectedWatermarked("Body")) + .create()) { + document.export(new DocxSemanticBackend(captured::set)); + } + + assertThat(captured.get().bySubject().get("watermark")).extracting(DocxExportReport.Note::path) + .containsExactly("section 1", "section 2"); + assertThat(captured.get().bySubject().get("protection")).hasSize(1); + } + + @Test + void aPageFieldInTheBodyIsDroppedForWhatItIs() throws Exception { + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page + .add(com.demcha.compose.document.output.PageContext.unpaginated().pageNumber()))); + + assertThat(report.notes()).filteredOn(note -> note.subject().contains("PageField")).singleElement() + .extracting(DocxExportReport.Note::detail).asString() + .contains("a page field is written in a page zone").doesNotContain("geometry"); + } + + @Test + void anUnknownNodeKindIsReportedWithWhatIsLostWithIt() throws Exception { + DocxExportReport report = reportOf(session -> { + session.registerNodeDefinition(new StampDefinition()); + session.pageFlow(page -> page.add(new Stamp("Approval", + List.of(new ParagraphBuilder().text("Approved by finance").build())))); + }); + + List notes = report.bySubject().get("Stamp"); + assertThat(notes).hasSize(1); + assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED); + assertThat(notes.get(0).detail()) + .as("not the geometry reason a built-in drawing gets") + .contains("a node kind the DOCX export does not know") + .contains("nor the 1 node it holds") + .doesNotContain("geometry"); + } + + @Test + void anUnknownNodeKindThatPaintsAShapeIsDrawnAndSaysWhatElseIsLost() throws Exception { + DocxExportReport report = reportOf(session -> { + session.registerNodeDefinition(new SealDefinition()); + session.pageFlow(page -> page.addParagraph("Signed").add(new Seal("Seal"))); + }); + + assertThat(report.bySubject().get("Seal")).singleElement().satisfies(note -> { + assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(note.detail()).contains("drawn as a shape") + .contains("only the shapes it paints itself are written"); + }); + } + + @Test + void aBuiltInDrawingNothingShowsIsStillDroppedForItsGeometry() throws Exception { + // A path painted only with a gradient is a kind the export knows and cannot draw. + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page + .addPath(path -> path.name("Rail").size(20, 20).moveTo(0, 0).lineTo(1, 0).lineTo(0.5, 1) + .closePath().fill(com.demcha.compose.document.style.DocumentPaint.linear(SURFACE, + DocumentColor.rgb(26, 86, 148)))))); + + assertThat(report.notes()).filteredOn(note -> note.severity() == DocxExportReport.Severity.DROPPED) + .singleElement().extracting(DocxExportReport.Note::detail).asString() + .contains("geometry has no semantic Word analogue").doesNotContain("does not know"); + } + + /** A node kind of the caller's own that paints a circle the export can draw. */ + private record Seal(String name) implements DocumentNode { + } + + private static final class SealDefinition implements NodeDefinition { + @Override + public Class nodeType() { + return Seal.class; + } + + @Override + public PreparedNode prepare(Seal node, PrepareContext ctx, BoxConstraints constraints) { + return PreparedNode.leaf(node, new MeasureResult(30, 30)); + } + + @Override + public PaginationPolicy paginationPolicy(Seal node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List emitFragments(PreparedNode prepared, FragmentContext ctx, + FragmentPlacement placement) { + return List.of(new LayoutFragment(placement.path(), 0, 0, 0, placement.width(), placement.height(), + new com.demcha.compose.document.layout.payloads.EllipseFragmentPayload( + java.awt.Color.RED, null, null, null))); + } + } + + /** A node kind of the caller's own, holding a paragraph the export never sees. */ + private record Stamp(String name, List children) implements DocumentNode { + } + + private static final class StampDefinition implements NodeDefinition { + @Override + public Class nodeType() { + return Stamp.class; + } + + @Override + public PreparedNode prepare(Stamp node, PrepareContext ctx, BoxConstraints constraints) { + return PreparedNode.leaf(node, new MeasureResult(120, 30)); + } + + @Override + public List children(Stamp node) { + // A leaf to the layout: the stamp lays out and paints what it holds itself. + return List.of(); + } + + @Override + public PaginationPolicy paginationPolicy(Stamp node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List emitFragments(PreparedNode prepared, FragmentContext ctx, + FragmentPlacement placement) { + return List.of(new LayoutFragment(placement.path(), 0, 0, 0, + placement.width(), placement.height(), "stamp")); + } + } + + private static DocxExportReport reportOf(Consumer compose) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 600) + .margin(DocumentInsets.of(20)) + .create()) { + compose.accept(session); + session.export(new DocxSemanticBackend(captured::set)); + } + assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull(); + return captured.get(); + } + + /** A zone drawn on the first page alone, which gives its section a first-page header. */ + private static DocumentPageZone onTheFirstPage(DocumentPageZone zone) { + return zone.toBuilder().appliesTo(com.demcha.compose.document.output.PageContext::isFirst).build(); + } + + private static void longBody(DocumentSession session) { + session.pageFlow(page -> { + for (int i = 0; i < 60; i++) { + page.addParagraph("A paragraph long enough to run the document onto further pages: " + i); + } + }); + } + + private static DocumentSession protectedWatermarked(String text) { + DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create(); + session.chrome() + .watermark(DocumentWatermark.builder().text("DRAFT").build()) + .protect(DocumentProtection.builder().userPassword("u").build()); + session.pageFlow(page -> page.addParagraph(text)); + return session; + } + + private static byte[] png() { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(new BufferedImage(24, 24, BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} From 15497dabd74c8fade285d5140b55c0e781ea65b1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 5 Oct 2026 22:03:32 +0100 Subject: [PATCH 2/2] fix(docx): report a cell row's paint by what its table drew, and record a zone's losses A row composed in a table cell was always reported APPROXIMATED, though the cell drawing draws a cell's paint only where it frames no text: a filled label-and-value row lost its fill and the report called it approximated. It is now DROPPED where the table drew none of its cells' paint, and APPROXIMATED, saying which way, where it drew shapes. A row of no columns and no height, which the page does not paint, is no longer reported; the note names a rounded paint and lists its parts as a sentence. Page zone notes name their section in a file of several. The once-a-kind log line no longer carries the first node's count. The ledger scans every concrete node class in the node and layout packages, nested and non-public ones included, and records page zones' own gap - a paragraph's alignment, spacing and direction and a row's columns are not written there - under the zones option. --- CHANGELOG.md | 34 +++++---- .../semantic/docx/DocxSemanticBackend.java | 46 +++++++++--- .../docx/DocxNodeFieldLedgerTest.java | 37 +++++++--- .../semantic/docx/DocxReportedLossesTest.java | 72 ++++++++++++++++++- 4 files changed, 156 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c8a6ad0..88ebaf735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,26 @@ follow semantic versioning; release dates are ISO 8601. ### Public API -- **The DOCX export report names what it used to leave out in silence.** Four 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: +- **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 + reason: - a row's fill, outline and side borders (`RowBuilder.fillColor`, `stroke`, `borders`): the row is written as a table with no shading or borders, and a page zone's row as one line. - Now `DROPPED`, `row paint`, naming which of the three, a row of no columns included; a - corner radius with nothing to round paints nothing and is not named. A row composed in a - table cell is `APPROXIMATED` instead: what a cell paints is drawn as a shape where it - frames no text, and the layout does not say which box is the row's; + Now `DROPPED`, `row paint`, naming which of them, and `rounded` where a corner radius + rounds them; a row of no columns is named where the layout gives it height, as the page + paints only then. A row composed in a table cell is `DROPPED` where the cell drew none of + its paint — round text it never draws any — and `APPROXIMATED` where the table drew + shapes, since a cell's paint is drawn where it frames no text and the layout does not say + which box is the row's; - anything in a page zone but paragraphs, page fields and spacers, such as a logo: now `DROPPED`, `page zone content`, once for each, though a zone is written into each kind of - header or footer it is given — and two logos of no name are two notes; + header or footer it is given — two logos of no name are two notes, and in a file of several + sections each names its section; - a watermark, a protection and viewer preferences: now `DROPPED`, `watermark` (once per section that sets one), `protection` and `viewer preferences` (once for the file) — a file asked to be protected opens and edits without a password, and the report says so; - - a node kind of the caller's own (a `DocumentNode` with its own `NodeDefinition`): it was + - a node kind of the caller's own (a `DocumentNode` with its own `NodeDefinition`) was reported as dropped geometry "with no semantic Word analogue". It is now named as a kind the export does not know, losing its text, its pictures and what it holds — counted where the node lists its children; the shapes it paints itself are still drawn, and say the @@ -32,11 +36,13 @@ follow semantic versioning; release dates are ISO 8601. Which kinds are the export's own is decided by the node's package, exactly: a caller's node in a package under the same root is not taken for a built-in one. A new test, - `DocxNodeFieldLedgerTest`, records for every field of every public node class in the node - and layout packages, and every output option, whether the export writes it, reports it, has - nothing to carry, or leaves a gap that names what is lost — 29 fields have nothing to carry - and 55 such gaps remain, each named. A node kind that is not a record, or a kind or field - added to the engine, fails it until someone decides. + `DocxNodeFieldLedgerTest`, records for every field of every node class in the node and layout + packages — nested and non-public ones included — and every output option, whether the export + writes it in the body, reports it, has nothing to carry, or leaves a gap that names what is + lost: 29 fields have nothing to carry, 55 node fields are gaps, and so are page zones, where + a paragraph's alignment, spacing and direction and a row's columns are not written. A node + class that is not a record, or a kind or field added to the engine, fails it until someone + decides. - **A DOCX timeline's dots, and drawings centred on a line, move with their text too.** A drawing in a table cell went into that cell only when the cell held it across, and a dot set in a column of its own beside its entry's text — `CharcoalGold`'s timeline, a row of 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 fb2ff20ec..a9468d5c6 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 @@ -1783,7 +1783,8 @@ private void warnUnsupportedZoneNode(DocumentNode node) { // the even pages', the rest —, so the same logo would be reported once per kind: once is // what the caller needs. if (zonePartsReported.add(node)) { - report.add(DocxExportReport.Severity.DROPPED, "page zone content", null, + report.add(DocxExportReport.Severity.DROPPED, "page zone content", + sectioned ? "section " + (sectionIndex + 1) : null, "a page zone's " + node.nodeKind() + (node.name().isEmpty() ? "" : " '" + node.name() + "'") + " is not written: a Word header or footer is written from the zone's " @@ -2423,8 +2424,10 @@ private void warnUnsupported(DocumentNode node) { } String why = droppedBecause(node); if (warnedNodeKinds.add(node.nodeKind())) { + // Once a kind, so without what the first of them held. LOG.warn("DocxSemanticBackend: dropping '{}' node(s) — {}; use the PDF backend for " - + "pixel-perfect output", node.nodeKind(), why); + + "pixel-perfect output", node.nodeKind(), + isBuiltIn(node) ? why : "a node kind the DOCX export does not know"); } report.add(DocxExportReport.Severity.DROPPED, node.nodeKind(), layout.pathOf(node), why + ", so it is not in the document"); @@ -9723,25 +9726,50 @@ private void reportUnwrittenRowPaint(RowNode node, boolean inAZone) { if (paintsASide(node.borders())) { paint.add("borders"); } - if (paint.isEmpty()) { + if (paint.isEmpty() || !standsTall(node)) { return; } - String what = "the row's " + String.join(", ", paint) + (paint.size() == 1 ? " is" : " are"); + String what = "the row's " + (node.cornerRadius().isZero() ? "" : "rounded ") + listed(paint) + + (paint.size() == 1 ? " is" : " are"); if (inAZone) { // A zone's content is not in the layout, so it is no cell's either. - report.add(DocxExportReport.Severity.DROPPED, "row paint", null, + report.add(DocxExportReport.Severity.DROPPED, "row paint", sectioned ? "section " + (sectionIndex + 1) : null, what + " not written: a page zone's row is written as one line of the header or " + "footer"); - } else if (composedInACell(node)) { + } else if (composedInACell(node) && cellDrawing.drew()) { + // What a cell paints the table draws as shapes, where it frames no text + // (drawCellDrawing); the layout does not say which of those boxes is the row's. report.add(DocxExportReport.Severity.APPROXIMATED, "row paint", layout.pathOf(node), - what + " drawn as a shape behind its cell where it frames no text, and not written " - + "where it does"); + what + (cellDrawing.skippedBoxes() + ? " drawn as a shape where it frames no text, and not written where it does" + : " drawn as a shape where the layout puts it in its cell")); } else { report.add(DocxExportReport.Severity.DROPPED, "row paint", layout.pathOf(node), - what + " not written: its columns are, as a table with no shading or borders"); + what + (composedInACell(node) + ? " not written: a table cell's paint is drawn only where it frames no text, " + + "and this table's cells drew none" + : " not written: its columns are, as a table with no shading or borders")); } } + /** + * Whether a row stands tall enough to be painted: the page paints a row's box only where + * the layout gives it some height, which a row of no columns and no padding does not have. + */ + private boolean standsTall(RowNode node) { + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(node); + if (placed != null) { + return placed.placementHeight() > 0.01; + } + return !node.children().isEmpty() || node.padding().top() + node.padding().bottom() > 0; + } + + /** A short list as a sentence names it: {@code a}, {@code a and b}, {@code a, b and c}. */ + private static String listed(List items) { + return items.size() == 1 ? items.get(0) + : String.join(", ", items.subList(0, items.size() - 1)) + " and " + items.get(items.size() - 1); + } + /** Whether any side of a set of borders is drawn: a side of no width draws nothing. */ private static boolean paintsASide(com.demcha.compose.document.style.DocumentBorders borders) { return java.util.stream.Stream.of(borders.top(), borders.right(), borders.bottom(), borders.left()) 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 bcce88fca..504c7d702 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 @@ -57,6 +57,10 @@ * it has no entry for, so the decision is made when the field is added, not when a reader finds * the loss.

* + *

A node's entries are about the body. A page zone is written as one line of its + * paragraphs' runs, page fields and tabs, which loses more of a paragraph and a row than the + * body does; that is recorded once, as the {@code zones} output option's gap.

+ * *

The entries are claims about the export, not proofs of it: a {@code WRITTEN} field is * proved by the export's own tests, a {@code REPORTED} one by a report test, and a {@code GAP} * is a loss the report does not yet name, each with what is lost. Closing a gap moves its entry, @@ -89,7 +93,10 @@ private record Entry(Fate fate, String note) { "protection:REPORTED", "viewerPreferences:REPORTED", "headersAndFooters:WRITTEN", - "zones:WRITTEN"); + // The node entries below are the body's. A zone is written as one line of its + // paragraphs' runs, page fields and tabs, which is a gap of its own. + "zones:GAP:in a page zone, a paragraph's alignment, spacing and direction and a row's columns, gap " + + "and padding; whatever else a zone holds is reported"); static { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN"); @@ -187,8 +194,10 @@ private record Entry(Fate fate, String note) { @Test void everyNodeKindGraphComposeShipsHasAnEntry() throws Exception { - assertThat(names(publicNodeClasses())) - .as("the public node classes of %s: a kind with no entry has fields nobody decided " + // Every concrete one, nested or not, public or not: the export counts all of them as its + // own (DocxSemanticBackend.isBuiltIn), so none may go undecided. + assertThat(names(nodeClasses())) + .as("the node classes of %s: a kind with no entry has fields nobody decided " + "the DOCX export's answer for", PACKAGES) .containsExactlyInAnyOrderElementsOf(names(NODES.keySet())); } @@ -197,7 +206,7 @@ void everyNodeKindGraphComposeShipsHasAnEntry() throws Exception { void everyNodeKindGraphComposeShipsIsARecord() throws Exception { // The ledger reads a node's fields as its record components; a node class that is not a // record would have fields this test cannot see, and so no decision for any of them. - assertThat(publicNodeClasses()).as("public node classes that are not records") + assertThat(nodeClasses()).as("node classes that are not records") .filteredOn(kind -> !kind.isRecord()).isEmpty(); } @@ -238,6 +247,11 @@ void everyGapSaysWhatIsLost() { unexplained.add(kind.getSimpleName() + "." + field); } })); + OUTPUT_OPTIONS.forEach((option, entry) -> { + if (entry.fate() == Fate.GAP && entry.note().isBlank()) { + unexplained.add("DocumentOutputOptions." + option); + } + }); assertThat(unexplained).as("a gap names what the Word file loses").isEmpty(); } @@ -274,8 +288,11 @@ private static Set names(java.util.Collection> kinds) { private static final List PACKAGES = List.of(DocumentNode.class.getPackageName(), com.demcha.compose.document.layout.LayoutAnchorNode.class.getPackageName()); - /** The public, concrete node classes the core module ships in {@link #PACKAGES}. */ - private static List> publicNodeClasses() throws IOException, URISyntaxException, ClassNotFoundException { + /** + * The concrete node classes the core module ships in {@link #PACKAGES} — nested ones, and + * ones not public, included. + */ + private static List> nodeClasses() throws IOException, URISyntaxException, ClassNotFoundException { Path source = Path.of(DocumentNode.class.getProtectionDomain().getCodeSource().getLocation().toURI()); List classFiles = new ArrayList<>(); for (String pkg : PACKAGES) { @@ -294,11 +311,13 @@ private static List> publicNodeClasses() throws IOException, URISyntaxE } List> kinds = new ArrayList<>(); for (String file : classFiles) { - if (!file.endsWith(".class") || file.contains("$")) { + if (!file.endsWith(".class") || file.endsWith("package-info.class")) { continue; } - Class type = Class.forName(file.substring(0, file.length() - ".class".length()).replace('/', '.')); - if (DocumentNode.class.isAssignableFrom(type) && Modifier.isPublic(type.getModifiers()) + // Loaded without initialising: a nested class's binary name keeps its '$'. + Class type = Class.forName(file.substring(0, file.length() - ".class".length()).replace('/', '.'), + false, DocumentNode.class.getClassLoader()); + if (DocumentNode.class.isAssignableFrom(type) && !type.isInterface() && !Modifier.isAbstract(type.getModifiers())) { kinds.add(type); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java index 52fb2dbaa..e615f9e7e 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java @@ -23,6 +23,8 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; import org.junit.jupiter.api.Test; import javax.imageio.ImageIO; @@ -60,7 +62,7 @@ void aRowsFillOutlineAndBordersAreReportedAsNotWritten() throws Exception { assertThat(notes).as("one note for the row").hasSize(1); assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED); assertThat(notes.get(0).path()).contains("Totals"); - assertThat(notes.get(0).detail()).contains("fill, outline, borders are not written"); + assertThat(notes.get(0).detail()).contains("the row's fill, outline and borders are not written"); } @Test @@ -149,6 +151,65 @@ void aRowOfNoColumnsThePagePaintsIsReported() throws Exception { assertThat(report.bySubject()).containsKey("row paint"); } + @Test + void aRowOfNoColumnsAndNoHeightIsNotReported() throws Exception { + // The page paints a row's box only where the layout gives it some height. + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page + .addParagraph("Before").addRow(row -> row.fillColor(SURFACE)).addParagraph("After"))); + + assertThat(report.bySubject()).doesNotContainKey("row paint"); + } + + @Test + void aRoundedRowSaysItsPaintWasRounded() throws Exception { + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addRow(row -> row + .fillColor(SURFACE).cornerRadius(6).addParagraph("Left").addParagraph("Right")))); + + assertThat(report.bySubject().get("row paint")).singleElement() + .extracting(DocxExportReport.Note::detail).asString().contains("the row's rounded fill is not written"); + } + + @Test + void aFilledRowRoundTextInATableCellIsDroppedNotApproximated() throws Exception { + // A cell's paint is drawn as a shape only where it frames no text; round a label and a + // value it is not drawn at all. + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new RowBuilder().fillColor(SURFACE) + .addParagraph("Due").addParagraph("120.00").build()))))); + + assertThat(report.bySubject().get("row paint")).singleElement().satisfies(note -> { + assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.DROPPED); + assertThat(note.detail()).contains("drawn only where it frames no text"); + }); + } + + @Test + void aFilledRowRoundNoTextInATableCellIsApproximated() throws Exception { + // Round no text, the cell's drawing draws the row's box as a shape in the cell. + DocxExportReport report = reportOf(session -> session.pageFlow(page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new RowBuilder().fillColor(SURFACE).padding(DocumentInsets.of(6)) + .addSpacer(40).addSpacer(40).build()))))); + + assertThat(report.bySubject().get("row paint")).singleElement() + .extracting(DocxExportReport.Note::severity).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + } + + @Test + void aZonesLossesInASectionedFileNameTheirSection() throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(withALogoHeader("Cover")) + .section(withALogoHeader("Body")) + .create()) { + document.export(new DocxSemanticBackend(captured::set)); + } + + assertThat(captured.get().bySubject().get("page zone content")).extracting(DocxExportReport.Note::path) + .containsExactly("section 1", "section 2"); + } + @Test void aWatermarkAProtectionAndViewerPreferencesAreReportedAsNotWritten() throws Exception { DocxExportReport report = reportOf(session -> { @@ -325,6 +386,15 @@ private static void longBody(DocumentSession session) { }); } + private static DocumentSession withALogoHeader(String text) { + DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create(); + session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder() + .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24)) + .addParagraph(text).build())); + session.pageFlow(page -> page.addParagraph(text)); + return session; + } + private static DocumentSession protectedWatermarked(String text) { DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create(); session.chrome()