diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b71b464c..88ebaf735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,41 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **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 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 — 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`) 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 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/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..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 @@ -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,17 @@ 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", + 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 " + + "paragraphs, page fields and spacers only"); + } } private void writeNode(XWPFDocument document, DocumentNode node) throws Exception { @@ -2368,12 +2422,49 @@ 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()); + // 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(), + isBuiltIn(node) ? why : "a node kind the DOCX export does not know"); } 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 +2523,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 +9705,83 @@ 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() || !standsTall(node)) { + return; + } + 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", 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) && 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 + (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 + (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()) + .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..504c7d702 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -0,0 +1,327 @@ +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.

+ * + *

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, + * 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", + // 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"); + 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 { + // 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())); + } + + @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(nodeClasses()).as("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); + } + })); + 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(); + } + + 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 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) { + 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.endsWith("package-info.class")) { + continue; + } + // 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); + } + } + 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..e615f9e7e --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java @@ -0,0 +1,415 @@ +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 com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What a 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("the row's fill, outline and 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 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 -> { + 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 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() + .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); + } + } +}