diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70d5d4af3..62b79f6d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,25 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export says why a drawing is not in it and what a section with no layout leaves to
+ the editor, names the application that made it, gives every table a grid, and names an anchor
+ in a repeated table header.** Without the layout — a document the engine refused, or a bare
+ `export(graph, context)` — a drawing was dropped as having "no semantic Word analogue"; it is
+ now said to have no place, the layout being what places it, and a drawing that paints nothing
+ is said to paint nothing, with a layout too. The section's note named line heights, the space
+ between lines and auto columns, and not the rest only a layout places or keeps: it now names,
+ where the section holds them, a row its arrangement or a growing spacer divides, what an
+ alignment or a stack's layer is set at across the width, written from the left, and the keeps
+ the page makes and Word is not told — a block kept together or with the next, a table's row in
+ one piece, a row of columns. Where the session states metadata, its creator is the file's
+ `Application`, which read "Apache POI"; a Word file has no producer, and a producer of the
+ caller's own other than it is named. A table whose columns are left to Word was
+ written with no `w:tblGrid`, which a Word table may not be without; it has an empty one, laid
+ out as before in Word and LibreOffice. An anchor declared in two cells of one table was
+ counted as one node, and the bookmark the file took out of the first unsaid; nodes are now
+ counted off the document, and one of the caller's own kind, which only the layout tells of,
+ still counts. An anchor in a table's header row the page repeats goes, on the
+ page, to the last repeat, and in Word to the one row the file holds; the report names it.
- **A DOCX export keeps each anchor's bookmark its own, writes no control character the page
draws nothing for, sets a margin below zero out past its side, and says what a rounded panel
keeps.** A bookmark name a suffix made for one anchor — "Section-1" after "Section 1" took
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index cfc089d96..56723b6e0 100644
Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 5aca02675..aecc9c7ff 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -84,7 +84,7 @@ Payload records live in `core` under
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less its borders and margins, which keep inside it, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it, from the shape's top where the line is taller than its outline and Word sets it on the shape's middle, and a label it holds whole written at the line height that stands its text on the page's baseline, as far as its letters and the room allow, by a tenth of a point or more and not in a standard face the export does not embed — its row held and its label cut in whole twips rounded down, as the row the cell is in holds the page's height rounded once, and what its letters keep the label past that room taken out of the margins of the cell it is in, where it is all that cell holds and the cell all its row holds; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is |
| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing |
-| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; an anchor two nodes declare is marked on the last node the file writes a bookmark for, and reported |
+| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; an anchor two nodes declare — two cells of one table too — is marked on the last node the file writes a bookmark for, and reported; one in a table's header row the page repeats, which the page sends a link to on its last repeat, is marked on the one row the file holds, where the table starts, and reported |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size |
| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's own fill left out, what stands on it being written in the row's panel, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) |
@@ -111,7 +111,7 @@ honour an option ignores it (documented contract).
| Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| 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) |
+| Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyMetadata` (OPC core properties — title, author as creator, subject, keywords — and the creating application as the extended `Application`; OPC has no producer field here either, so that value is not representable, and the report names one the caller set other than the application) |
| 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) | ❌ (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, a translucent one flattened against white and reported (`translucency`); 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 |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 2fc895549..afabecc64 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -73,7 +73,7 @@ creation date is real metadata.
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too |
-| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
+| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
| Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel |
| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A table of one row in the body kept with what follows — a row of columns — whose cells hold paragraphs alone is written with a row that may break: LibreOffice reads a one-row table whose row may not break as a table that may not split, and keeps such a table with nothing (measured: `EditorialProposal`'s page-two masthead stood at the foot of page one in LibreOffice, and all of page two 45 to 47pt high; freed, it opens page two in LibreOffice, Word set it there either way, and moved down to straddle the foot of page one it went to page two whole in both). A row holding a table keeps `w:cantSplit`: the keep chains a cell's own paragraphs, not the rows of a table in it. A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses |
| Spacers | An empty paragraph a tenth of a point tall, which Word keeps; the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer shorter than that tenth — a width anchor of no height, as `Panel`'s and `PanelLetter`'s cards open with — has its line reach past it by the rest, which the space below takes, as it takes a line hanging below a block, and what it cannot take is named (`space above`): taken nowhere, the line stood every card a tenth of a point taller in Word. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included |
@@ -81,7 +81,9 @@ creation date is real metadata.
Page geometry (size, margins and orientation — a page wider than it is tall is stated as
landscape) and session metadata (title, author, subject, keywords) carry into the Word
-document as well.
+document as well, with the metadata's creator as the package's `Application`. A Word file names
+one application and has no producer; a producer the metadata states of its own, other than the
+application written, is named in the report.
Page backgrounds (`session.pageBackgrounds(...)` — a full-page tint, a sidebar column, a
band) are painted behind the text of every page. Word colours a page in one colour only, so
@@ -774,7 +776,14 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
columns only when they are weights, an even split or fixed, and no line height is
written at all. An `auto` column and the flex path are measurements, and a guess in
their place would be right for a table whose text fills the line and wrong for one
- holding three short values.
+ holding three short values. A row its arrangement or a growing spacer divides has columns
+ Word sizes; what an alignment or a stack's layer is set at across the width is written from
+ the left; and the keeps the page makes — a block kept together or with the next, a table's row
+ in one piece, a row of columns — are written only where the layout shows the page held them on
+ one page, so none is (a repeated header row is still kept with the row under it). The
+ report's note on the section names each of these the section holds. A drawing, which stands
+ where the layout puts it, has no place and is dropped, each with a note of its own saying so;
+ a rule is written as one still.
- **Charts → data table.** A chart compiles to vector geometry, which this
export does not draw. Its *semantic*
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarations.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarations.java
new file mode 100644
index 000000000..c97d2147b
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarations.java
@@ -0,0 +1,117 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.node.BarcodeNode;
+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.LineNode;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.node.SectionNode;
+import com.demcha.compose.document.node.ShapeNode;
+import com.demcha.compose.document.node.TableNode;
+import com.demcha.compose.document.table.DocumentTableCell;
+
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * The anchors a document's nodes declare, counted node by node.
+ *
+ * The layout registers an anchor where it draws the node, and that is not once a node: every
+ * cell of a table built from a node registers its anchor under the table's path, and a header row
+ * the page repeats registers its anchor again on each page it is drawn on. Counted off the layout,
+ * two cells of one table declaring an anchor were one node, and the file keeping one of their
+ * bookmarks went unsaid. Counted here, off the document, each node declaring an anchor is one,
+ * a table's composed cells and a header row included.
+ *
+ * @author Artem Demchyshyn
+ */
+final class DocxAnchorDeclarations {
+
+ private DocxAnchorDeclarations() {
+ }
+
+ /**
+ * Adds what a section's nodes declare to what the sections before it declared.
+ *
+ * @param graph the section's document
+ * @param nodes how many nodes declare each anchor, added to
+ * @param inARepeatedHeader the anchors a node in a table's repeated header row declares,
+ * added to
+ */
+ static void count(DocumentGraph graph, Map nodes, Set inARepeatedHeader) {
+ for (DocumentNode root : graph.roots()) {
+ count(root, false, nodes, inARepeatedHeader);
+ }
+ }
+
+ private static void count(DocumentNode node, boolean inAHeader, Map nodes,
+ Set inARepeatedHeader) {
+ String anchor = declaredBy(node);
+ if (anchor != null) {
+ nodes.merge(anchor, 1, Integer::sum);
+ if (inAHeader) {
+ inARepeatedHeader.add(anchor);
+ }
+ }
+ Set repeated = repeatedHeaderContent(node);
+ for (DocumentNode child : DocxWrittenTree.under(node)) {
+ count(child, inAHeader || repeated.contains(child), nodes, inARepeatedHeader);
+ }
+ }
+
+ /** What the cells of a table's repeated header rows are built from; empty for any other node. */
+ private static Set repeatedHeaderContent(DocumentNode node) {
+ if (!(node instanceof TableNode table) || table.repeatedHeaderRowCount() <= 0) {
+ return Set.of();
+ }
+ Set content = Collections.newSetFromMap(new IdentityHashMap<>());
+ List> rows = table.rows();
+ for (int row = 0; row < Math.min(table.repeatedHeaderRowCount(), rows.size()); row++) {
+ for (DocumentTableCell cell : rows.get(row)) {
+ if (cell != null && cell.content() != null) {
+ content.add(cell.content());
+ }
+ }
+ }
+ return content;
+ }
+
+ /**
+ * The anchor each node kind that declares one holds, by its exact class: node records are
+ * final. A page reference names an anchor and declares none, so it is not here.
+ */
+ static final Map, Function> DECLARING = Map.ofEntries(
+ declaring(ParagraphNode.class, ParagraphNode::anchor),
+ declaring(SectionNode.class, SectionNode::anchor),
+ declaring(ContainerNode.class, ContainerNode::anchor),
+ declaring(TableNode.class, TableNode::anchor),
+ declaring(ImageNode.class, ImageNode::anchor),
+ declaring(BarcodeNode.class, BarcodeNode::anchor),
+ declaring(LineNode.class, LineNode::anchor),
+ declaring(ShapeNode.class, ShapeNode::anchor),
+ declaring(EllipseNode.class, EllipseNode::anchor));
+
+ private static Map.Entry, Function>
+ declaring(Class kind, Function anchor) {
+ return Map.entry(kind, node -> anchor.apply(kind.cast(node)));
+ }
+
+ /**
+ * The anchor a node declares — the place a link and a page reference go to — or null.
+ *
+ * @param node a node of the document
+ * @return its anchor, or null when it declares none
+ */
+ static String declaredBy(DocumentNode node) {
+ Function declared = DECLARING.get(node.getClass());
+ String anchor = declared == null ? null : declared.apply(node);
+ return anchor == null || anchor.isBlank() ? null : anchor;
+ }
+}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 34d089ed5..f9f71ce6e 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -637,14 +637,15 @@ private Map zoneFragments(int zoneIndex, int page,
}
/**
- * How many nodes declare each anchor the layout registers: the page sends a link to the last.
- * A node a repeated table header draws again is one node; a page zone's, for which the file
- * holds no bookmark, is left out.
+ * Under how many paths the layout registers each anchor: a node of a kind the export does not
+ * know registers its own, which only the layout tells, though a table's composed cells all
+ * register under the table's one. A page zone's, for which the file holds no bookmark, is left
+ * out.
*
- * @return each anchor's count of declaring nodes, empty without a layout
+ * @return each anchor's count of paths, empty without a layout
*/
- Map anchorsDeclared() {
- Map> nodes = new HashMap<>();
+ Map anchorPaths() {
+ Map> paths = new HashMap<>();
for (Map.Entry> entry : fragments.entrySet()) {
if (entry.getKey().startsWith("@page-zone")) {
continue;
@@ -652,15 +653,45 @@ Map anchorsDeclared() {
for (PlacedFragment fragment : entry.getValue()) {
if (fragment.payload() instanceof com.demcha.compose.document.layout.payloads.AnchorMarkerPayload marker
&& !marker.anchor().isEmpty()) {
- nodes.computeIfAbsent(marker.anchor(), key -> new java.util.HashSet<>()).add(entry.getKey());
+ paths.computeIfAbsent(marker.anchor(), key -> new java.util.HashSet<>()).add(entry.getKey());
}
}
}
Map counts = new HashMap<>();
- nodes.forEach((anchor, declaring) -> counts.put(anchor, declaring.size()));
+ paths.forEach((anchor, registering) -> counts.put(anchor, registering.size()));
return counts;
}
+ /**
+ * The anchors the layout registers under one path on more than one page, and on how many:
+ * a node in a table's header row is drawn again on every page the header repeats on, and the
+ * page sends a link to it to the last ({@link com.demcha.compose.document.snapshot.PageIndex}).
+ * A page zone's, for which the file holds no bookmark, is left out.
+ *
+ * @return each such anchor's count of pages, empty without a layout
+ */
+ Map anchorsDrawnAgain() {
+ Map pages = new HashMap<>();
+ for (Map.Entry> entry : fragments.entrySet()) {
+ if (entry.getKey().startsWith("@page-zone")) {
+ continue;
+ }
+ Map> onPages = new HashMap<>();
+ for (PlacedFragment fragment : entry.getValue()) {
+ if (fragment.payload() instanceof com.demcha.compose.document.layout.payloads.AnchorMarkerPayload marker
+ && !marker.anchor().isEmpty()) {
+ onPages.computeIfAbsent(marker.anchor(), key -> new java.util.HashSet<>()).add(fragment.pageIndex());
+ }
+ }
+ onPages.forEach((anchor, drawn) -> {
+ if (drawn.size() > 1) {
+ pages.merge(anchor, drawn.size(), Math::max);
+ }
+ });
+ }
+ return pages;
+ }
+
/**
* The first page a page zone is drawn on.
*
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 80f05845e..d3e9a71f1 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
@@ -835,8 +835,14 @@ private int startPageOf(DocumentNode node) {
// The id of each bookmark written for an anchor, in the order written: an anchor two nodes
// declare keeps the last one's (keepTheLastBookmarkOfEachAnchor).
private final java.util.Map> bookmarksOfAnchors = new java.util.LinkedHashMap<>();
- // How many nodes the layouts register each anchor on, a bookmark written or not.
+ // How many nodes declare each anchor, a bookmark written or not (DocxAnchorDeclarations), and
+ // under how many paths the layouts register it, which counts a kind the export does not know.
private final java.util.Map anchorsDeclared = new java.util.LinkedHashMap<>();
+ private final java.util.Map anchorPaths = new java.util.HashMap<>();
+ // The anchors a node in a table's repeated header row declares, and of those the layouts
+ // register under one path on more than one page, the most pages.
+ private final java.util.Set anchorsInARepeatedHeader = new java.util.HashSet<>();
+ private final java.util.Map anchorsDrawnAgain = new java.util.HashMap<>();
// The page references written as fields, to be checked against those bookmarks.
private final List fieldReferences = new ArrayList<>();
// Where the finished report goes, when the caller configured somewhere for it to go.
@@ -1140,6 +1146,9 @@ private byte[] write(List sections, Path outputFile) throws Exc
bookmarksWritten.clear();
bookmarksOfAnchors.clear();
anchorsDeclared.clear();
+ anchorPaths.clear();
+ anchorsInARepeatedHeader.clear();
+ anchorsDrawnAgain.clear();
fieldReferences.clear();
java.util.NavigableSet outlineLevels = outlineLevelsIn(whole);
headingLevels = outlineLevels.stream().map(level -> Math.min(level, MAX_HEADING_LEVEL))
@@ -1255,17 +1264,18 @@ private void beginSection(SemanticSection section, int index) {
sectionNotesFrom = report.size();
SemanticExportContext context = section.context();
layout = DocxLayoutMetrics.of(section.graph(), context.layoutGraph());
- layout.anchorsDeclared().forEach((anchor, nodes) -> anchorsDeclared.merge(anchor, nodes, Integer::sum));
+ DocxAnchorDeclarations.count(section.graph(), anchorsDeclared, anchorsInARepeatedHeader);
+ layout.anchorPaths().forEach((anchor, paths) -> anchorPaths.merge(anchor, paths, Integer::sum));
+ layout.anchorsDrawnAgain().forEach((anchor, pages) -> anchorsDrawnAgain.merge(anchor, pages, Math::max));
if (layout.isEmpty()) {
// Said once per section, naming it when there are several: without measurements
// the line height is Word's, a lineSpacing is put between no lines, and every auto
// column is Word's, and a caller comparing this file against the rendered page
- // deserves to know that before they look.
+ // deserves to know that before they look — and what else the section holds that
+ // only a layout places or keeps (DocxWithoutLayout).
report.add(DocxExportReport.Severity.APPROXIMATED, "measured geometry",
sectioned ? "section " + (index + 1) : null,
- (sectioned ? "this section" : "this document")
- + " could not be laid out, so line heights, the space between lines and auto column "
- + "widths are the editor's rather than the engine's");
+ DocxWithoutLayout.note(section.graph(), sectioned ? "this section" : "this document"));
}
carriedSpacingBefore = 0;
pendingSpacingAfter = 0;
@@ -1665,6 +1675,19 @@ private static DocumentMetadata metadataOf(List sections) {
return null;
}
+ /** The producer every metadata starts with, which states nothing of the caller's. */
+ private static final String DEFAULT_PRODUCER = DocumentMetadata.builder().build().getProducer();
+
+ /**
+ * Writes the document's metadata into the package's properties: title, author, subject and
+ * keywords as its core properties, the author as their creator, and the application that
+ * created it as the extended {@code Application}, which read "Apache POI" without it.
+ *
+ * A Word file names one application and has no field for a producer, the program a PDF
+ * says turned the document into that PDF. Where the metadata states a producer of its own —
+ * not the one every metadata starts with, which a session setting only its creator still
+ * carries — other than the application written, the report names it.
+ */
private void applyMetadata(XWPFDocument document, DocumentMetadata metadata) {
if (metadata != null) {
org.apache.poi.ooxml.POIXMLProperties props = document.getProperties();
@@ -1680,6 +1703,17 @@ private void applyMetadata(XWPFDocument document, DocumentMetadata metadata) {
if (metadata.getKeywords() != null) {
props.getCoreProperties().setKeywords(metadata.getKeywords());
}
+ String application = metadata.getCreator() != null ? metadata.getCreator() : metadata.getProducer();
+ if (application != null) {
+ props.getExtendedProperties().setApplication(application);
+ }
+ if (metadata.getProducer() != null && !metadata.getProducer().equals(application)
+ && !metadata.getProducer().equals(DEFAULT_PRODUCER)) {
+ report.add(DocxExportReport.Severity.DROPPED, "producer", null,
+ "a Word file names the application that created it, \"" + application
+ + "\", and has no field for a producer, so \"" + metadata.getProducer()
+ + "\" is not in it");
+ }
}
// 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
@@ -4268,8 +4302,12 @@ private List insetSidesLost(DocumentNode node, boolean left) {
return sides;
}
- /** Why a node the export neither writes nor draws is dropped, as the report says it. */
- private static String droppedBecause(DocumentNode node) {
+ /**
+ * Why a node the export neither writes nor draws is dropped, as the report says it. A drawing
+ * is drawn where the layout puts it, so with no layout behind the export there is no place to
+ * draw one at; the reason is that, and not the drawing's.
+ */
+ private 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";
@@ -4281,9 +4319,42 @@ private static String droppedBecause(DocumentNode node) {
|| node instanceof com.demcha.compose.document.node.PathNode path && paintedOnlyWithAGradient(path)) {
return "it is painted only with a gradient, which the export does not draw";
}
+ if (paintsNothing(node)) {
+ return "it paints nothing a shape could show";
+ }
+ if (isDrawing(node) && layout.isEmpty()) {
+ return "with no layout behind the export, nothing says where the page draws it";
+ }
return "its geometry has no semantic Word analogue";
}
+ /**
+ * Whether a drawing paints nothing: no fill, no fill paint and no stroke wide enough to draw,
+ * so the page shows nothing of it either.
+ */
+ private static boolean paintsNothing(DocumentNode node) {
+ if (node instanceof com.demcha.compose.document.node.ShapeNode shape) {
+ return shape.fillColor() == null && shape.fillPaint() == null && !drawsALine(shape.stroke());
+ }
+ if (node instanceof com.demcha.compose.document.node.EllipseNode ellipse) {
+ return ellipse.fillColor() == null && !drawsALine(ellipse.stroke());
+ }
+ if (node instanceof com.demcha.compose.document.node.PathNode path) {
+ return path.fillColor() == null && path.fillPaint() == null && !drawsALine(path.stroke());
+ }
+ if (node instanceof com.demcha.compose.document.node.PolygonNode polygon) {
+ return polygon.fillColor() == null && !drawsALine(polygon.stroke());
+ }
+ if (node instanceof com.demcha.compose.document.node.LineNode line) {
+ return !drawsALine(line.stroke());
+ }
+ return false;
+ }
+
+ private static boolean drawsALine(DocumentStroke stroke) {
+ return stroke != null && stroke.width() > 0;
+ }
+
/**
* Whether a shape's one paint is a gradient fill: the layout gives a shape so filled no flat
* colour, and with no stroke round it there is nothing a drawing shows.
@@ -9533,9 +9604,17 @@ private record FieldReference(XWPFParagraph paragraph,
* declaration, and a page reference reads that one's page
* ({@link com.demcha.compose.document.snapshot.PageIndex}). Word goes to the first bookmark of
* a name, and a file holding two of one name has them cut down to one by the editor. So the
- * ones before the last are taken out, and the report names the anchor — as it does where the
- * layout registers it on more nodes than the file writes a bookmark for, a drawn shape's say:
- * the last bookmark is then another node's than the one the page goes to.
+ * ones before the last are taken out, and the report names the anchor — as it does where more
+ * nodes declare it than the file writes a bookmark for, a drawn shape's say: the last bookmark
+ * is then another node's than the one the page goes to. The nodes are counted off the document
+ * ({@link DocxAnchorDeclarations}): the layout registers a table's composed cells under the
+ * table, and two of them declaring one anchor read as one node. A node of a kind the export
+ * does not know declares an anchor only the layout tells of, under a path of its own: the
+ * count is the larger of the two.
+ *
+ * A node in a table's header row the page repeats is drawn again on each page it repeats on,
+ * and the page goes to the last of them; the file holds the row once, at the table's start, and
+ * the report names an anchor declared there too.
*
* The last is the last in the file, whose order is the page's: a block's bookmark opens at
* its first paragraph, before those of the nodes inside it, though it is written after them.
@@ -9545,12 +9624,20 @@ private void keepTheLastBookmarkOfEachAnchor(XWPFDocument document) {
bookmarksOfAnchors.putIfAbsent(anchor, List.of());
}
for (java.util.Map.Entry> anchor : bookmarksOfAnchors.entrySet()) {
- int nodes = anchorsDeclared.getOrDefault(anchor.getKey(), anchor.getValue().size());
+ int nodes = Math.max(anchorsDeclared.getOrDefault(anchor.getKey(), anchor.getValue().size()),
+ anchorPaths.getOrDefault(anchor.getKey(), 0));
if (nodes >= 2) {
report.add(DocxExportReport.Severity.APPROXIMATED, "anchor", null,
"\"" + anchor.getKey() + "\" is the anchor of " + nodes + " nodes: a link to it goes to the "
+ "last on the page, and the Word file, one place a bookmark name can mark, marks the last "
+ "it writes a bookmark for");
+ } else if (nodes == 1 && !anchor.getValue().isEmpty() && anchorsInARepeatedHeader.contains(anchor.getKey())
+ && anchorsDrawnAgain.containsKey(anchor.getKey())) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, "anchor", null,
+ "\"" + anchor.getKey() + "\" is in a table's header row, which the page repeats on "
+ + anchorsDrawnAgain.get(anchor.getKey()) + " pages: a link to it goes to the last of them, "
+ + "and a page reference reads that one's page; the Word file holds the row once, where the "
+ + "table starts, which its link goes to and a page reference reads once Word updates it");
}
}
String w = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' ";
@@ -17012,7 +17099,8 @@ private static double[] resolveRowSlots(RowNode node, double outerWidth) {
return slots;
}
- private static boolean hasGrowChild(RowNode node) {
+ /** Whether a row holds a spacer that grows into the room its columns leave. */
+ static boolean hasGrowChild(RowNode node) {
for (DocumentNode child : node.children()) {
if (child instanceof SpacerNode spacer && spacer.grow() > 0.0) {
return true;
@@ -17412,9 +17500,9 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
lastBodyParagraph = null;
blocksWritten++;
if (currentCell == null) {
- return document.createTable(rows, columns);
+ return withAGrid(document.createTable(rows, columns));
}
- XWPFTable nested = new XWPFTable(currentCell.getCTTc().addNewTbl(), currentCell, rows, columns);
+ XWPFTable nested = withAGrid(new XWPFTable(currentCell.getCTTc().addNewTbl(), currentCell, rows, columns));
// The XML already carries the table; this is what tells the cell's own lists about
// it, so reading the cell back finds it. getTables() is unmodifiable on purpose —
// adding to it throws rather than quietly leaving the model and the XML disagreeing.
@@ -17432,6 +17520,21 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
return nested;
}
+ /**
+ * Gives a table the grid a Word table cannot be without, empty: POI makes a table with none,
+ * the columns written later fill it, and a table whose columns are left to Word — a chart's
+ * data table, a table or a row with no layout, a row the layout does not place in a cell —
+ * keeps it empty. Word and LibreOffice lay out a table with an empty grid as one without
+ * (measured: every word where it stood, in both, in a row of columns in the flow and in a cell,
+ * a table of auto columns with and without a width, and {@code WordExportCompanion}'s chart).
+ */
+ private static XWPFTable withAGrid(XWPFTable table) {
+ if (table.getCTTbl().getTblGrid() == null) {
+ table.getCTTbl().addNewTblGrid();
+ }
+ return table;
+ }
+
/**
* Moves a table in from the page margin by what the containers around it hold in, as
* its paragraphs are: a row or a table inside a padded section starts where the
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayout.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayout.java
new file mode 100644
index 000000000..fc54b1adc
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayout.java
@@ -0,0 +1,153 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.node.AlignNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.HorizontalAlign;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.LayerStackNode;
+import com.demcha.compose.document.node.RowArrangement;
+import com.demcha.compose.document.node.RowNode;
+import com.demcha.compose.document.node.ShapeContainerNode;
+import com.demcha.compose.document.node.TableNode;
+import com.demcha.compose.document.table.DocumentTableCell;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * What a section exported with no layout behind it leaves to the editor, as the report's note on
+ * it says.
+ *
+ * Without the layout nothing is measured or placed, and the export writes what the document
+ * states. Line heights, the space between lines and auto columns are the editor's then, and so is
+ * more, which the note named none of: a row its arrangement or a growing spacer divides is written
+ * as columns Word sizes; what an alignment or a stack's layer is set at across the width stands at
+ * the left; and the keeps the page makes — a block kept together or with the next, a table's row
+ * in one piece, a row of columns — are written only where the layout shows the page held them on
+ * one page, which is nowhere. (A table's repeated header row is still kept with the row under it:
+ * that needs no layout.) Each of those is named where the section holds one.
+ *
+ * @author Artem Demchyshyn
+ */
+final class DocxWithoutLayout {
+
+ private DocxWithoutLayout() {
+ }
+
+ /**
+ * The note on a section exported with no layout behind it.
+ *
+ * @param graph the section's document
+ * @param what how the note names the section: "this document", or "this section"
+ * @return the note's text
+ */
+ static String note(DocumentGraph graph, String what) {
+ Found found = new Found();
+ for (DocumentNode root : graph.roots()) {
+ found.look(root, false);
+ }
+ List editors = new ArrayList<>(List.of("line heights", "the space between lines",
+ "auto column widths"));
+ if (found.dividedRows) {
+ editors.add("the columns of a row its arrangement or a growing spacer divides");
+ }
+ StringBuilder note = new StringBuilder(what).append(" could not be laid out, so ")
+ .append(listed(editors)).append(" are the editor's rather than the engine's");
+ if (found.placedAcross) {
+ note.append("; what an alignment or a stack's layer is set at across the width is written from the left");
+ }
+ List kept = new ArrayList<>(3);
+ if (found.keptBlocks) {
+ kept.add("a block kept together or with the next");
+ }
+ if (found.tableRows) {
+ kept.add("a table's row in one piece");
+ }
+ if (found.rowsOfColumns) {
+ kept.add("a row of columns");
+ }
+ if (!kept.isEmpty()) {
+ note.append("; Word is not told to keep what the page keeps from a page break: ")
+ .append(String.join(", ", kept));
+ }
+ return note.toString();
+ }
+
+ private static String listed(List items) {
+ return String.join(", ", items.subList(0, items.size() - 1)) + " and " + items.get(items.size() - 1);
+ }
+
+ /** What the section holds that the note names. */
+ private static final class Found {
+
+ private boolean dividedRows;
+ private boolean placedAcross;
+ private boolean keptBlocks;
+ private boolean tableRows;
+ private boolean rowsOfColumns;
+
+ /**
+ * Looks through a node and what the export reaches under it.
+ *
+ * @param inACell whether the node is in what a table's cell is built from, where neither a
+ * block nor a row of columns is kept on its own, with a layout or without:
+ * the table row it stands in is
+ */
+ private void look(DocumentNode node, boolean inACell) {
+ // A row with nothing in it writes nothing.
+ if (node instanceof RowNode row && !row.children().isEmpty()) {
+ dividedRows |= row.arrangement() != RowArrangement.START || DocxSemanticBackend.hasGrowChild(row);
+ rowsOfColumns |= !inACell;
+ }
+ if (node instanceof AlignNode align && align.align() != HorizontalAlign.LEFT) {
+ placedAcross = true;
+ }
+ if (node instanceof LayerStackNode stack) {
+ placedAcross |= setAcross(stack.layers());
+ }
+ if (node instanceof ShapeContainerNode container) {
+ placedAcross |= setAcross(container.layers());
+ }
+ if (node instanceof TableNode table && !table.rows().isEmpty()) {
+ tableRows = true;
+ }
+ keptBlocks |= !inACell && (node.keepTogether() || node.keepWithNext());
+ Set cells = cellContentOf(node);
+ for (DocumentNode child : DocxWrittenTree.under(node)) {
+ look(child, inACell || cells.contains(child));
+ }
+ }
+
+ /** Whether a stack sets any of its layers in from its left edge: by alignment, or by offset. */
+ private static boolean setAcross(List layers) {
+ for (LayerStackNode.Layer layer : layers) {
+ if (layer.offsetX() != 0 || layer.align() != LayerAlign.TOP_LEFT
+ && layer.align() != LayerAlign.CENTER_LEFT
+ && layer.align() != LayerAlign.BOTTOM_LEFT) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** What a table's cells are built from; empty for any other node. */
+ private static Set cellContentOf(DocumentNode node) {
+ if (!(node instanceof TableNode table)) {
+ return Set.of();
+ }
+ Set content = Collections.newSetFromMap(new IdentityHashMap<>());
+ for (List row : table.rows()) {
+ for (DocumentTableCell cell : row) {
+ if (cell != null && cell.content() != null) {
+ content.add(cell.content());
+ }
+ }
+ }
+ return content;
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarationsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarationsTest.java
new file mode 100644
index 000000000..3f359868c
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorDeclarationsTest.java
@@ -0,0 +1,33 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.node.PageReferenceNode;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Every node kind GraphCompose ships with an anchor is one whose anchor the export counts.
+ */
+class DocxAnchorDeclarationsTest {
+
+ @Test
+ void everyNodeKindWithAnAnchorIsCountedAsDeclaringIt() throws Exception {
+ // A kind added later with an anchor of its own would go uncounted, and two nodes declaring
+ // one anchor, the page going to the last, read as one.
+ Set> withAnAnchor = DocxNodeFieldLedgerTest.nodeClasses().stream()
+ .filter(kind -> Arrays.stream(kind.getRecordComponents()).map(RecordComponent::getName)
+ .anyMatch("anchor"::equals))
+ .filter(kind -> kind != PageReferenceNode.class)
+ .collect(Collectors.toSet());
+
+ Set> counted = Set.copyOf(DocxAnchorDeclarations.DECLARING.keySet());
+ assertThat(counted)
+ .as("the node kinds whose anchor the export counts; a page reference names one and declares none")
+ .containsExactlyInAnyOrderElementsOf(withAnAnchor);
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
index 6b4249070..8d415420b 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
@@ -70,6 +70,17 @@ static XWPFDocument withoutLayout(double pageWidth, double pageHeight, double ma
return new XWPFDocument(new ByteArrayInputStream(docx));
}
+ /** The report of an export through a session, with the compiled layout behind it. */
+ static DocxExportReport report(double pageWidth, double pageHeight, double margin,
+ Consumer content) throws Exception {
+ java.util.concurrent.atomic.AtomicReference report =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ try (DocumentSession session = session(pageWidth, pageHeight, margin, content)) {
+ session.export(new DocxSemanticBackend(report::set));
+ }
+ return report.get();
+ }
+
/** The report of an export with no layout behind it, the way a bare caller makes one. */
static DocxExportReport reportWithoutLayout(double pageWidth, double pageHeight, double margin,
Consumer content) throws Exception {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java
index 1c975e5b2..08d20aa40 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java
@@ -224,6 +224,88 @@ void anAnchorWhoseLastDeclarationHasNoBookmarkIsNamed() throws Exception {
.satisfies(note -> assertThat(note.detail()).startsWith("\"dup\" is the anchor of 2 nodes"));
}
+ @Test
+ void twoCellsOfOneTableDeclaringAnAnchorAreTwoNodes() throws Exception {
+ // The layout registers both under the table's path: counted off it, they were one node.
+ List notes = new java.util.ArrayList<>();
+ byte[] docx;
+ try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document()
+ .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addTable(t -> t
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(200))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(
+ new com.demcha.compose.document.dsl.ParagraphBuilder().text("One").anchor("dup").build()))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(
+ new com.demcha.compose.document.dsl.ParagraphBuilder().text("Two").anchor("dup").build()))));
+ docx = session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
+ }
+ try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(docx))) {
+ String xml = document.getDocument().xmlText();
+ assertThat(count(xml, "w:bookmarkStart")).isEqualTo(1);
+ assertThat(xml.substring(xml.indexOf("w:bookmarkStart"), xml.indexOf("w:bookmarkEnd")))
+ .as("the last cell's").contains(">Two<").doesNotContain(">One<");
+ }
+ assertThat(notes).filteredOn(note -> note.subject().equals("anchor")).singleElement()
+ .satisfies(note -> assertThat(note.detail()).startsWith("\"dup\" is the anchor of 2 nodes"));
+
+ // The second a drawn circle, the file writes one bookmark: only the nodes say there are two.
+ List drawn = new java.util.ArrayList<>();
+ try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document()
+ .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addTable(t -> t
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(200))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(
+ new com.demcha.compose.document.dsl.ParagraphBuilder().text("One").anchor("dup").build()))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(
+ new com.demcha.compose.document.dsl.EllipseBuilder().circle(10)
+ .fillColor(com.demcha.compose.document.style.DocumentColor.BLACK)
+ .anchor("dup").build()))));
+ session.export(DocxSemanticBackend.builder().reportSink(report -> drawn.addAll(report.notes())).build());
+ }
+ assertThat(drawn).filteredOn(note -> note.subject().equals("anchor")).singleElement()
+ .satisfies(note -> assertThat(note.detail()).startsWith("\"dup\" is the anchor of 2 nodes"));
+ }
+
+ @Test
+ void anAnchorInATableHeaderThePageRepeatsIsNamed() throws Exception {
+ // Drawn again on every page the header repeats on, the page goes to the last; the file holds
+ // the row once, where the table starts (measured: Word puts the bookmark on page 1, and a page
+ // reference reading 5 reads 1 once updated).
+ String named = "\"hdr\" is in a table's header row, which the page repeats on 5 pages: a link to it goes "
+ + "to the last of them, and a page reference reads that one's page; the Word file holds the "
+ + "row once, where the table starts, which its link goes to and a page reference reads once "
+ + "Word updates it";
+ com.demcha.compose.document.node.DocumentNode item =
+ new com.demcha.compose.document.dsl.ParagraphBuilder().text("Item").anchor("hdr").build();
+ assertThat(headerAnchorNotes(item, 30)).singleElement().isEqualTo(named);
+ assertThat(headerAnchorNotes(new com.demcha.compose.document.dsl.SectionBuilder().addParagraph(
+ p -> p.text("Item").anchor("hdr")).build(), 30))
+ .as("declared inside a block the header cell is built from").singleElement().isEqualTo(named);
+ assertThat(headerAnchorNotes(item, 3)).as("a header drawn on one page is drawn once").isEmpty();
+ assertThat(headerAnchorNotes(new com.demcha.compose.document.dsl.EllipseBuilder().circle(10)
+ .fillColor(com.demcha.compose.document.style.DocumentColor.BLACK).anchor("hdr").build(), 30))
+ .as("a drawn circle has no bookmark for a link to go to, which the page reference's note says")
+ .isEmpty();
+ }
+
+ private static List headerAnchorNotes(com.demcha.compose.document.node.DocumentNode item, int rows)
+ throws Exception {
+ List notes = new java.util.ArrayList<>();
+ try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document()
+ .pageSize(300, 200).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addTable(table -> {
+ table.autoColumns(2).repeatHeader().headerCells(
+ com.demcha.compose.document.table.DocumentTableCell.node(item),
+ com.demcha.compose.document.table.DocumentTableCell.text("Amount"));
+ for (int row = 0; row < rows; row++) {
+ table.row("Row " + row, "1");
+ }
+ }).addPageReference("hdr"));
+ session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
+ }
+ return notes.stream().filter(note -> note.subject().equals("anchor")).map(DocxExportReport.Note::detail).toList();
+ }
+
@Test
void anAnchorStartingWithADigitIsStillAName() throws Exception {
// Word will not take a bookmark whose name starts with anything but a letter, and
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 3aaae6dfc..1713f58ea 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
@@ -384,7 +384,7 @@ private static Set names(java.util.Collection> kinds) {
* 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 {
+ 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) {
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 b2aa0dbe9..bba8276bb 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
@@ -313,6 +313,46 @@ void aBuiltInPathNothingShowsIsDroppedForItsGradient() throws Exception {
+ "the document");
}
+ @Test
+ void anAnchorAKindOfTheCallersOwnDeclaresTooIsCountedAsItsNode() throws Exception {
+ // Only the layout tells of the anchor such a node declares: the page goes to the last of the
+ // two, and the file marks the paragraph.
+ DocxExportReport report = reportOf(session -> {
+ session.registerNodeDefinition(new MarkDefinition());
+ session.pageFlow(page -> page.addParagraph(p -> p.text("Terms").anchor("terms")).add(new Mark("Mark")));
+ });
+
+ assertThat(report.bySubject().get("anchor")).singleElement().extracting(DocxExportReport.Note::detail)
+ .asString().startsWith("\"terms\" is the anchor of 2 nodes");
+ }
+
+ /** A node kind of the caller's own that declares the anchor "terms" where the layout puts it. */
+ private record Mark(String name) implements DocumentNode {
+ }
+
+ private static final class MarkDefinition implements NodeDefinition {
+ @Override
+ public Class nodeType() {
+ return Mark.class;
+ }
+
+ @Override
+ public PreparedNode prepare(Mark node, PrepareContext ctx, BoxConstraints constraints) {
+ return PreparedNode.leaf(node, new MeasureResult(30, 10));
+ }
+
+ @Override
+ public PaginationPolicy paginationPolicy(Mark node) {
+ return PaginationPolicy.ATOMIC;
+ }
+
+ @Override
+ public List emitFragments(PreparedNode prepared, FragmentContext ctx,
+ FragmentPlacement placement) {
+ return List.of(com.demcha.compose.document.layout.NodeDefinitionSupport.anchorMarkerFragment("terms", placement));
+ }
+ }
+
/** A node kind of the caller's own that paints a circle the export can draw. */
private record Seal(String name) implements DocumentNode {
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
index 90e35f9ac..0d383e9d9 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
@@ -377,6 +377,52 @@ void exportPropagatesSessionLevelMetadataIntoCoreProperties() throws Exception {
assertThat(props.getTitle()).isEqualTo("Q1 Report");
assertThat(props.getCreator()).isEqualTo("Engineering");
assertThat(props.getKeywords()).isEqualTo("docx, metadata, report");
+ assertThat(document.getProperties().getExtendedProperties().getApplication())
+ .as("the application that created it, GraphCompose by default, as a PDF's creator")
+ .isEqualTo("GraphCompose");
+ }
+ }
+
+ @Test
+ void theCreatingApplicationIsWrittenAndAProducerApartFromItIsNamed() throws Exception {
+ java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>();
+ byte[] docxBytes;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.metadata(DocumentMetadata.builder().creator("Billing").producer("Billing PDF").build());
+ session.pageFlow(page -> page.addParagraph("Body"));
+ docxBytes = session.export(new DocxSemanticBackend(report::set));
+ }
+
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
+ assertThat(document.getProperties().getExtendedProperties().getApplication()).isEqualTo("Billing");
+ }
+ assertThat(report.get().notes()).filteredOn(note -> note.subject().equals("producer"))
+ .extracting(DocxExportReport.Note::severity, DocxExportReport.Note::detail)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple(DocxExportReport.Severity.DROPPED,
+ "a Word file names the application that created it, \"Billing\", and has no field for "
+ + "a producer, so \"Billing PDF\" is not in it"));
+ }
+
+ @Test
+ void aProducerThatIsTheApplicationWrittenOrNoOneSetIsNotNamed() throws Exception {
+ // Setting only a creator leaves the producer every metadata starts with, which states nothing.
+ for (DocumentMetadata metadata : List.of(DocumentMetadata.builder().title("Defaults").build(),
+ DocumentMetadata.builder().creator(null).producer("Billing").build(),
+ DocumentMetadata.builder().creator("Billing").build())) {
+ java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>();
+ byte[] docxBytes;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.metadata(metadata);
+ session.pageFlow(page -> page.addParagraph("Body"));
+ docxBytes = session.export(new DocxSemanticBackend(report::set));
+ }
+
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
+ assertThat(document.getProperties().getExtendedProperties().getApplication())
+ .as("with no creator, the producer is the application named")
+ .isEqualTo(metadata.getCreator() != null ? metadata.getCreator() : metadata.getProducer());
+ }
+ assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("producer"));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayoutTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayoutTest.java
new file mode 100644
index 000000000..ee86ae08c
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWithoutLayoutTest.java
@@ -0,0 +1,183 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.RowBuilder;
+import com.demcha.compose.document.dsl.SpacerBuilder;
+import com.demcha.compose.document.node.HorizontalAlign;
+import com.demcha.compose.document.node.RowArrangement;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What an export with no layout behind it says it leaves to the editor, and what it does with
+ * the drawings and the rows only a layout places.
+ */
+class DocxWithoutLayoutTest {
+
+ private static final String BASE = "this document could not be laid out, so line heights, the space between "
+ + "lines and auto column widths are the editor's rather than the engine's";
+
+ @Test
+ void aDocumentOfParagraphsIsToldWhatItLeavesAndNoMore() throws Exception {
+ assertThat(sectionNote(page -> page.addParagraph("Plain text"))).isEqualTo(BASE);
+ }
+
+ @Test
+ void aRowItsArrangementOrAGrowingSpacerDividesIsNamed() throws Exception {
+ String arranged = sectionNote(page -> page.addRow(row -> row.arrangement(RowArrangement.SPACE_BETWEEN)
+ .add(paragraph("Total")).add(paragraph("$120"))));
+ String grown = sectionNote(page -> page.addRow(row -> row
+ .add(paragraph("Total")).add(new SpacerBuilder().grow(1).build()).add(paragraph("$120"))));
+ String weighed = sectionNote(page -> page.addRow(row -> row.add(paragraph("Total")).add(paragraph("$120"))));
+
+ String divided = "line heights, the space between lines, auto column widths and the columns of a row its "
+ + "arrangement or a growing spacer divides are the editor's";
+ assertThat(arranged).contains(divided);
+ assertThat(grown).contains(divided);
+ assertThat(weighed).doesNotContain("divides")
+ .as("its columns are its weights, written as they are")
+ .startsWith(BASE);
+ }
+
+ @Test
+ void anAlignmentOrAStacksLayerIsNamedAsWrittenFromTheLeftAndALeftOneIsNot() throws Exception {
+ String across = BASE + "; what an alignment or a stack's layer is set at across the width is written from the left";
+ assertThat(sectionNote(page -> page.addAligned(HorizontalAlign.CENTER, paragraph("Centred"))))
+ .isEqualTo(across);
+ assertThat(sectionNote(page -> page.addLayerStack(stack -> stack.center(paragraph("Centred")))))
+ .isEqualTo(across);
+ assertThat(sectionNote(page -> page.addLayerStack(stack -> stack.position(paragraph("Moved"), 12, 0,
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT))))
+ .as("set in by its offset").isEqualTo(across);
+ assertThat(sectionNote(page -> page.addCircle(40, DocumentColor.BLACK, circle -> circle.center(paragraph("A")))))
+ .as("a shape container's layer").startsWith(across);
+ assertThat(sectionNote(page -> page.addAligned(HorizontalAlign.LEFT, paragraph("Left"))))
+ .isEqualTo(BASE);
+ assertThat(sectionNote(page -> page.addLayerStack(stack -> stack.centerLeft(paragraph("Left")))))
+ .isEqualTo(BASE);
+ }
+
+ @Test
+ void whatThePageKeepsFromAPageBreakIsNamedKindByKind() throws Exception {
+ String keep = "; Word is not told to keep what the page keeps from a page break: ";
+ assertThat(sectionNote(page -> page.addSection(s -> s.keepTogether().addParagraph("A").addParagraph("B"))))
+ .isEqualTo(BASE + keep + "a block kept together or with the next");
+ assertThat(sectionNote(page -> page.addSection(s -> s.keepWithNext().addParagraph("Heading"))
+ .addParagraph("Body")))
+ .isEqualTo(BASE + keep + "a block kept together or with the next");
+ assertThat(sectionNote(page -> page.addTable(t -> t.autoColumns(2).row("Net", "100"))))
+ .isEqualTo(BASE + keep + "a table's row in one piece");
+ assertThat(sectionNote(page -> page.addRow(row -> row.add(paragraph("A")).add(paragraph("B")))))
+ .isEqualTo(BASE + keep + "a row of columns");
+ assertThat(sectionNote(page -> page.addRow(row -> { })))
+ .as("a row with nothing in it writes nothing").isEqualTo(BASE);
+ assertThat(sectionNote(page -> page.addLine(l -> l.horizontal(100).keepWithNext()).addParagraph("Below")
+ .addTable(t -> t.autoColumns(2).row("Net", "100"))
+ .addRow(row -> row.add(paragraph("A")).add(paragraph("B")))))
+ .isEqualTo(BASE + keep + "a block kept together or with the next, a table's row in one piece, "
+ + "a row of columns");
+ }
+
+ @Test
+ void aRowOrABlockComposedInATableCellIsKeptByItsTablesRowAndNotNamed() throws Exception {
+ String note = sectionNote(page -> page.addTable(t -> t.columns(DocumentTableColumn.fixed(300))
+ .rowCells(DocumentTableCell.node(new RowBuilder().add(paragraph("A")).add(paragraph("B")).build()))
+ .rowCells(DocumentTableCell.node(new com.demcha.compose.document.dsl.SectionBuilder().keepTogether()
+ .addParagraph("C").addParagraph("D").build()))));
+
+ assertThat(note).endsWith("a page break: a table's row in one piece");
+ }
+
+ @Test
+ void aDrawingWithNoLayoutIsDroppedForTheLayoutItLacks() throws Exception {
+ String why = "with no layout behind the export, nothing says where the page draws it, so it is not in the document";
+ assertThat(dropped(page -> page.addLine(l -> l.vertical(40).thickness(2).color(DocumentColor.BLACK))))
+ .containsExactly(why);
+ assertThat(dropped(page -> page.addShape(80, 40, DocumentColor.BLACK))).containsExactly(why);
+ assertThat(dropped(page -> page.addEllipse(80, 40, DocumentColor.BLACK))).containsExactly(why);
+ assertThat(dropped(page -> page.addPath(path -> path.size(40, 40).moveTo(0, 0).lineTo(40, 40)
+ .stroke(new DocumentStroke(DocumentColor.BLACK, 1))))).containsExactly(why);
+ assertThat(dropped(page -> page.addPath(path -> path.size(40, 40).moveTo(0, 0).lineTo(40, 0).lineTo(20, 40)
+ .closePath().fillColor(DocumentColor.BLACK)))).containsExactly(why);
+ assertThat(dropped(page -> page.addShape(s -> s.size(40, 20)
+ .fill(com.demcha.compose.document.style.DocumentPaint.solid(DocumentColor.BLACK)))))
+ .as("a paint is a fill").containsExactly(why);
+ assertThat(dropped(page -> page.addLine(l -> l.horizontal(100).thickness(2).color(DocumentColor.BLACK))))
+ .as("a rule is written as one, with a layout or without")
+ .isEmpty();
+ }
+
+ @Test
+ void aDrawingThatPaintsNothingIsSaidToPaintNothing() throws Exception {
+ String why = "it paints nothing a shape could show, so it is not in the document";
+ List laidOut = new ArrayList<>();
+ for (DocxExportReport.Note note : DocxExports.report(400, 600, 20, page -> page.addCircle(60)).notes()) {
+ if (note.severity() == DocxExportReport.Severity.DROPPED) {
+ laidOut.add(note.detail());
+ }
+ }
+ assertThat(laidOut).containsExactly(why);
+ assertThat(dropped(page -> page.addCircle(60))).containsExactly(why);
+ assertThat(dropped(page -> page.addShape(s -> s.size(40, 20).stroke(new DocumentStroke(DocumentColor.BLACK, 0)))))
+ .as("a stroke no wider than nothing draws no line")
+ .containsExactly(why);
+ assertThat(dropped(page -> page.addPath(path -> path.size(40, 40).moveTo(0, 0).lineTo(40, 40))))
+ .containsExactly(why);
+ assertThat(dropped(page -> page.addLine(l -> l.vertical(40).stroke(null)))).containsExactly(why);
+ }
+
+ @Test
+ void everyTableHasAGridARowLeftToWordAnEmptyOne() throws Exception {
+ Consumer content = page -> page
+ .addRow(row -> row.arrangement(RowArrangement.SPACE_BETWEEN).add(paragraph("Total")).add(paragraph("$120")))
+ .addTable(t -> t.columns(DocumentTableColumn.fixed(300))
+ .rowCells(DocumentTableCell.node(new RowBuilder().arrangement(RowArrangement.SPACE_BETWEEN)
+ .add(paragraph("Net")).add(paragraph("$99")).build())));
+ try (XWPFDocument document = DocxExports.withoutLayout(400, 600, 20, content)) {
+ List tables = new ArrayList<>(document.getTables());
+ tables.add(document.getTables().get(1).getRow(0).getCell(0).getTables().get(0));
+
+ assertThat(tables).hasSize(3).allSatisfy(table -> assertThat(table.getCTTbl().getTblGrid()).isNotNull());
+ assertThat(tables.get(0).getCTTbl().getTblGrid().sizeOfGridColArray()).isZero();
+ assertThat(tables.get(2).getCTTbl().getTblGrid().sizeOfGridColArray()).isZero();
+ assertThat(tables.get(1).getCTTbl().getTblGrid().sizeOfGridColArray()).isEqualTo(1);
+ assertThat(tables).allSatisfy(table -> assertThat(table.getCTTbl().xmlText())
+ .as("in the order a table's schema sets: properties, grid, rows")
+ .containsPattern("|>)")
+ .matches("(?s).*].*"));
+ }
+ }
+
+ private static String sectionNote(Consumer content) throws Exception {
+ return DocxExports.reportWithoutLayout(400, 600, 20, content).notes().stream()
+ .filter(note -> note.subject().equals("measured geometry"))
+ .map(DocxExportReport.Note::detail)
+ .reduce((first, second) -> {
+ throw new AssertionError("two section notes");
+ })
+ .orElseThrow();
+ }
+
+ private static List dropped(Consumer content) throws Exception {
+ return DocxExports.reportWithoutLayout(400, 600, 20, content).notes().stream()
+ .filter(note -> note.severity() == DocxExportReport.Severity.DROPPED)
+ .map(DocxExportReport.Note::detail)
+ .toList();
+ }
+
+ private static com.demcha.compose.document.node.ParagraphNode paragraph(String text) {
+ return new ParagraphBuilder().text(text).build();
+ }
+}