From 71b56f6747b6826eee93e89d59893b707dcfc2a6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 26 Oct 2026 08:55:52 +0000
Subject: [PATCH 1/2] fix(docx): bookmark a layer stack's column, so a page
reference to it is a field
A layer stack written as columns writes each layer's content into its
column's cell without dispatching the layer as a block, so a section's or
container's anchor there got no bookmark: a link to it named a bookmark the
file did not hold, and a page reference to it was written as a fixed number.
writeLayerColumns now puts the anchor's bookmark round what each layer
writes, from its first paragraph to its last, as writePlacedNode does for a
block in the flow. A block of the layer written in an earlier layer's
stand-in is recorded by writeInPlaceOf and opens the bookmark there. A
column that writes no paragraph is named as such a block is, and a page
reference to it stays its number.
Measured in Word 16 (fields updated) and LibreOffice 24.2: a reference to a
column on page 2 read 2 before and after a page was put in ahead of it, and
the link went nowhere; now both read 3 there and the link lands on the
column's page. The template corpus declares no anchors and is unchanged.
---
CHANGELOG.md | 18 +
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 5 +-
.../semantic/docx/DocxSemanticBackend.java | 139 +++--
.../docx/DocxCarriedWithoutReportTest.java | 11 +-
.../docx/DocxLayerColumnBookmarkTest.java | 496 ++++++++++++++++++
.../docx/DocxNodeFieldLedgerTest.java | 8 +-
.../semantic/docx/DocxPageReferenceTest.java | 9 +-
.../docx/DocxValueFieldLedgerTest.java | 2 +-
9 files changed, 630 insertions(+), 60 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a97571422..ec4814b4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,24 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export bookmarks a layer stack's column, so a page reference or a link to its anchor
+ works.**
+ - A stack whose layers stand side by side is written as a one-row table, each layer's content in
+ its column's cell; the layer itself is not written as a block, and its anchor got no bookmark.
+ A link to it named a bookmark the file did not hold, and a page reference to it was written as
+ the number the layout read, which no edit updates. The bookmark now goes round what the layer
+ writes, from the first paragraph it writes to the last, as a section's or a container's does in
+ the flow. A block of the layer that an earlier layer's stand-in holds — a subtitle drawn in a
+ later layer for the reading order, written in the strip above it — is written there first, and
+ the bookmark opens there.
+ - Measured in Word 16, fields updated, and LibreOffice 24.2, on an anchored column on page 2 and
+ on the same file with a page put in ahead of it: before, both editors read 2 in each and
+ carried no link; now both read 2 and then 3, and the link goes to the column's page. So it is
+ for a left and a right column, a container, a later layer of a column, a column opening with a
+ table, a stack in a panel, and a table of contents' entry.
+ - A column that writes no paragraph — drawings alone — has no bookmark still, and the report
+ names its anchor, as it does a section's of drawings alone.
+
- **A DOCX export keeps a page break the layout makes by itself after a card where the page
makes it.**
- A block kept together or kept with the next in a table cell — a row's column, a panel's cell —
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 1c1c906ea..44a68e7bc 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -103,7 +103,7 @@ Payload records live in `core` under
| Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ⚠️ `DocxSemanticBackend.newRun` — a paragraph's, a run's, a picture-in-a-line's and a page zone picture's link is a `w:hyperlink` with an external relationship, a run's own link winning over its paragraph's. A block written as a picture, a drawing or a rule — an image, a barcode, a shape, an ellipse, a line — and a table carry none, and the report names the link |
-| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ⚠️ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, on what an external one is written on, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's, image's or barcode's around everything the block wrote, a rule's around its paragraph (`bookmarkAround`). A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a stack's layer written as a column, a page zone's paragraph or picture, and a block that writes no paragraph — a section or a container of drawings alone — hold no bookmark: the report names the anchor, a link to it points at no bookmark, and a page reference to it is written as the number the layout found |
+| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ⚠️ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, on what an external one is written on, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's, image's or barcode's around everything the block wrote, a stack's layer written as a column around what it writes, a rule's around its paragraph (`bookmarkAround`). A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a page zone's paragraph or picture, and a block that writes no paragraph — a section or a container of drawings alone — hold no bookmark: the report names the anchor, a link to it points at no bookmark, and a page reference to it is written as the number the layout found |
| Page references — a table of contents' numbers, `addPageReference(...)` (`PageReferenceNode`) | ✅ `PageReferenceDefinition` lays out the resolved page as text, drawn by `PdfParagraphFragmentRenderHandler` | ✅ the same laid-out text through `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writePageReference` — a `PAGEREF` field to the anchor's bookmark, as a hyperlink (`\h`), storing the page the layout resolved; the editor recomputes it (LibreOffice on layout, Word on a field update). A reference to an anchor the export writes no bookmark for is the text the page prints, since Word turns a `PAGEREF` to a missing bookmark into an error, and the report names it, as it does the margins and padding on the sides its alignment sets it from, which are not written. `w:updateFields` is not set |
| Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | ⚠️ `PptxNavigationWriter` (no PPTX outline concept — slide names where 1:1, extra bookmarks dropped with a note) | ❌ |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 8a1d4cc0f..78bd04b92 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. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) |
| 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. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. 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 in the flow, a stack of one layer's included, or first in a band of layers there, that the layout moves to a new page keeps the space the layout leaves above it there — its margin, its padding, the room above it in its box, the edges of the containers opening with it and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, as a body paragraph does. 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. A block that writes no paragraph — a section or a container of drawings alone, whose drawings stand in the paragraph beside them — has no bookmark, and the report names its anchor: a link to it points at none (measured: the page's link went to the section's place, and Word's and LibreOffice's PDFs carried no link at all), and a page reference to it is written as the number it reads. 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 |
+| 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; a section or a container written as a layer stack's column wraps what it writes the same way, from a block of it an earlier layer's stand-in holds, written there first, to the last it writes in its own cell. A block that writes no paragraph — a section or a container of drawings alone, whose drawings stand in the paragraph beside them — has no bookmark, and the report names its anchor: a link to it points at none (measured: the page's link went to the section's place, and Word's and LibreOffice's PDFs carried no link at all), and a page reference to it is written as the number it reads. 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). In a table cell — a row's column, a panel's cell — such a block keeps its lines together and nothing with the next: Word reads keep-with-next on the paragraph a cell opens with as keeping the whole row with what follows its table, and holds no keep across a break inside the row, so there those keeps did nothing else; a row of columns, which may not split, keeps the block whole (measured: `ObsidianInvoice`'s info cards, kept together in a row's two cells, went with the footer divider under them to the next page in Word, 17 lines below the page's; without keep-with-next in the cards, they stood on their page). The paragraph between two tables in a cell still keeps with the next table: Word does not read it as the paragraph the cell opens with, even after the table the cell opens with (measured: a row at the foot of a page stayed there either way). 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. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms |
| 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. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there is held in its line, less, in a stack of one layer, its layer's offset, which is named. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page 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 |
@@ -1476,8 +1476,7 @@ named in the report:
names a lost outline entry, caps (a border ends flat) or a bar's link, a line's link being
reported as `rule link`.
A picture, a table, a section or a container names a link or an outline entry it loses the
- same way, and a section or a container written as a column of a layer stack names its anchor,
- which no bookmark marks there.
+ same way.
- A timeline's rail stands under every other shape on its page, where the page puts it
under the markers only; a filled card drawn under a timeline covers it.
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 533874d48..0d525e084 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
@@ -365,6 +365,9 @@ public final class DocxSemanticBackend implements SemanticBackend {
private final List moves = new ArrayList<>();
private final java.util.Set writingInAStandIn =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ // What each block written in a stand-in's place wrote there, in the order written: its layer's
+ // bookmark goes round it too (writeLayerColumns).
+ private final List writtenInStandIns = new ArrayList<>();
// The line a layer of text is written at when it overlaps the layer above it, in points:
// see holdStackedLines.
private final java.util.Map stackedLineHeights =
@@ -1220,6 +1223,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
panelCell = null;
moves.clear();
writingInAStandIn.clear();
+ writtenInStandIns.clear();
stackedLineHeights.clear();
lineTopsTakenIn.clear();
cellsGivingTheirPadding.clear();
@@ -3766,38 +3770,40 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex
writeNodeContent(document, node);
return;
}
- // What the block writes lands where writing is going on — the body, or the cell being
- // filled. Read off the body alone, a block inside a card wrote nothing, and its
- // anchor and its keeps were silently dropped.
- XWPFTableCell destination = currentCell;
- java.util.function.Supplier> elements = destination == null
- ? document::getBodyElements
- : destination::getBodyElements;
- int first = elements.get().size();
- // The paragraph closing a table above, if the block takes it over, is the block's.
- XWPFParagraph closer = cellEndsWithItsTableCloser() ? tableCloser : null;
+ boolean inTheBody = currentCell == null;
int notesBefore = report.size();
- writeNodeContent(document, node);
- if (closer != null && tableCloser != closer) {
- first--;
- }
- List written = elements.get().subList(first, elements.get().size());
+ List written = writtenBy(document, () -> writeNodeContent(document, node));
if (keepTogether || keepWithNext) {
- keepOnOnePage(written, keepWithNext, destination == null);
+ keepOnOnePage(written, keepWithNext, inTheBody);
}
// A section or a container that writes no paragraph — drawings alone, standing in the
// paragraph beside them — has nothing for a bookmark to go round, and a link to its anchor is
// written to a bookmark the file lacks: Word's and LibreOffice's PDFs carry no link there.
// Whether another node marks the anchor is known once all are written
// (nameTheBlocksNoBookmarkMarks).
- boolean unmarked = anchor != null && !bookmarkAround(written, anchor);
if (panelBreak != null) {
reportAKeepThePanelBreaks(node, panelBreak, written);
}
- if (unmarked) {
- blocksUnmarked.add(new UnmarkedBlock(notesBefore, report.size(), node.nodeKind(), layout.pathOf(node),
- anchor));
+ markOrName(node, anchor, written, notesBefore);
+ }
+
+ /**
+ * Writes a block and returns what it wrote where writing is going on — the body, or the cell
+ * being filled. Read off the body alone, a block inside a card wrote nothing, and its anchor
+ * and its keeps were silently dropped. The paragraph closing a table above, if the block takes
+ * it over, is the block's.
+ */
+ private List writtenBy(XWPFDocument document, CellContent block) throws Exception {
+ java.util.function.Supplier> elements = currentCell == null
+ ? document::getBodyElements
+ : currentCell::getBodyElements;
+ int first = elements.get().size();
+ XWPFParagraph closer = cellEndsWithItsTableCloser() ? tableCloser : null;
+ block.write();
+ if (closer != null && tableCloser != closer) {
+ first--;
}
+ return elements.get().subList(first, elements.get().size());
}
/** How a block the layout moves to a new page inside a panel may be broken there in Word. */
@@ -11376,6 +11382,55 @@ private record FieldReference(XWPFParagraph paragraph,
private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String path, String anchor) {
}
+ /**
+ * A later layer's block written in an earlier layer's stand-in, and what it wrote there.
+ *
+ * @param block the block, in its own layer
+ * @param written the paragraphs and tables it wrote, in the file's order
+ */
+ private record WrittenInAStandIn(DocumentNode block, List written) {
+ }
+
+ /**
+ * Marks with the anchor's bookmark what a block wrote, or, where that holds no paragraph or
+ * table for one to go round, keeps the block to be named once all are written
+ * ({@link #nameTheBlocksNoBookmarkMarks}).
+ *
+ * @param notesBefore how many notes the report held before the block was written
+ */
+ private void markOrName(DocumentNode node, String anchor, List written, int notesBefore) {
+ if (anchor != null && !bookmarkAround(written, anchor)) {
+ blocksUnmarked.add(new UnmarkedBlock(notesBefore, report.size(), node.nodeKind(), layout.pathOf(node),
+ anchor));
+ }
+ }
+
+ /**
+ * What earlier layers' stand-ins hold of a layer, written there before the layer itself
+ * ({@link #writeInPlaceOf}), in the file's order.
+ *
+ * @param layer a layer of a stack written as columns
+ * @param written how many blocks had been written in a stand-in's place when the layer's own
+ * writing began: those written since are inside what the layer writes itself
+ */
+ private List writtenInAnEarlierLayer(DocumentNode layer, int written) {
+ java.util.Set under = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ java.util.ArrayDeque open = new java.util.ArrayDeque<>(layer.children());
+ while (!open.isEmpty()) {
+ DocumentNode node = open.pop();
+ if (under.add(node)) {
+ open.addAll(node.children());
+ }
+ }
+ List elements = new ArrayList<>();
+ for (WrittenInAStandIn moved : writtenInStandIns.subList(0, written)) {
+ if (under.contains(moved.block())) {
+ elements.addAll(moved.written());
+ }
+ }
+ return elements;
+ }
+
/**
* Names each block that wrote no paragraph for its anchor's bookmark, where no other node marks
* that anchor either: a link to it points at none, and a page reference to it is settled as its
@@ -11421,7 +11476,10 @@ private void nameTheBlocksNoBookmarkMarks() {
* 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.
+ * its first paragraph, before those of the nodes inside it, though it is written after them.
+ * A stack's columns are written left to right, not in the order the stack declares its layers:
+ * where two columns declare one anchor, the bookmark kept may be another node's than the one
+ * the page goes to, which the anchor's note already allows for.
*/
private void keepTheLastBookmarkOfEachAnchor(XWPFDocument document) {
for (String anchor : new java.util.LinkedHashSet<>(anchorsDeclared.keySet())) {
@@ -11478,10 +11536,10 @@ private void keepTheLastBookmarkOfEachAnchor(XWPFDocument document) {
* takes the one whose anchor has none back to the number it reads, saying so in its note.
*
* Whether an anchor gets a bookmark is known only from what the body wrote: an anchor that
- * names nothing, a drawn shape's, a block that writes no paragraph — a section holding only
- * shapes the export draws — or a column of a layer stack has none to put round it. Read off
- * the tree instead, a table's composed cells were missed, and a stack holding only drawing in
- * one would read as an overlay the writer does not write it as. A field on a bookmark the
+ * names nothing, a drawn shape's, or a block that writes no paragraph — a section holding only
+ * shapes the export draws — has none to put round it. Read off the tree instead, a table's
+ * composed cells were missed, and a stack holding only drawing in one would read as an
+ * overlay the writer does not write it as. A field on a bookmark the
* file lacks reads "Error! Bookmark not defined." once Word updates it. The field's marks and
* code go; its text stays, in its style.
*/
@@ -18281,16 +18339,9 @@ private void writeLayerColumns(XWPFDocument document,
pendingSpacingAfter = 0;
resumeLayer(plan.resume(node) + raised);
}
- // A column layer is written as what it holds, never dispatched itself:
- // no bookmark goes round it, and a page reference to it is taken back to
- // its text (settleThePageReferences).
- List columnLost = new ArrayList<>(2);
- String anchor = blockAnchorOf(node, true);
- if (anchor != null && !anchor.isBlank()) {
- columnLost.add("its anchor has no bookmark in the Word file: a link to it names a "
- + "bookmark the file does not hold, and a page reference to it is "
- + "written as the number it reads");
- }
+ List columnLost = new ArrayList<>(1);
+ int notesBefore = report.size();
+ int movedBefore = writtenInStandIns.size();
// Its column runs between its neighbours' bands, not to the width it is fixed at.
com.demcha.compose.document.layout.PlacedNode placedLayer = layout.placement(node);
if (node.flowWidth().isFixed() && holdsWrappedText(node) && placedLayer != null
@@ -18302,7 +18353,18 @@ < availableWidth() - EDITOR_COLUMN_SLACK_POINTS - 0.5) {
}
reportWrittenWithout(node, "written as a column of its layer stack", columnLost);
reportWrittenUnderTheLayersBefore(node, plan.over(node), "column");
- writeChildren(document, node.children(), spacingOf(node));
+ List written = writtenBy(document,
+ () -> writeChildren(document, node.children(), spacingOf(node)));
+ // A column layer is written as what it holds, never dispatched itself: its
+ // anchor's bookmark goes round what it wrote, as a block's does
+ // (writePlacedNode) — from what an earlier layer's stand-in holds of it,
+ // written there first, to the last it wrote in its own place.
+ String anchor = blockAnchorOf(node, true);
+ if (anchor != null) {
+ List marked = writtenInAnEarlierLayer(node, movedBefore);
+ marked.addAll(written);
+ markOrName(node, anchor, marked, notesBefore);
+ }
if (layer > 0 && blocksWritten == blocksBefore && !holdsMovedContent(node)) {
// A later layer that wrote nothing leaves the line above it raised:
// what that line owes below it is still owed in the cell.
@@ -20835,7 +20897,7 @@ private void writeCellNodes(XWPFTableCell cell, List children) thr
}
}
- /** Something written into a cell. */
+ /** Something written into a cell, or wherever writing is going on ({@link #writtenBy}). */
@FunctionalInterface
private interface CellContent {
void write() throws Exception;
@@ -21040,7 +21102,8 @@ private void writeInPlaceOf(XWPFDocument document, SpacerNode standIn) throws Ex
if (placed != null && across != null) {
holdIn(content, placed.placementX(), placed.placementWidth(), across[0], across[1]);
}
- writeNode(document, content);
+ writtenInStandIns.add(new WrittenInAStandIn(content,
+ List.copyOf(writtenBy(document, () -> writeNode(document, content)))));
}
} finally {
writingInAStandIn.removeAll(contents);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
index 776a36a72..b9bff869d 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java
@@ -361,12 +361,8 @@ void aSectionLaidOutAsALayerColumnNamesItsOutlineEntry() throws Exception {
}
@Test
- void aLayerColumnNamesItsAnchorThatNoBookmarkMarks() throws Exception {
- // A column is written as what it holds, with no bookmark round it, while a link to its
- // anchor still names one; a page reference to it is taken back to its text.
- String lost = "written as a column of its layer stack; its anchor has no bookmark in the Word file: "
- + "a link to it names a bookmark the file does not hold, and a page reference to it is "
- + "written as the number it reads";
+ void aLayerColumnsAnchorIsNotNamedWhereItsBookmarkMarksWhatTheColumnWrote() throws Exception {
+ // A column is written as what it holds, its anchor's bookmark round that: nothing is lost.
DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack
.layer(new com.demcha.compose.document.dsl.SectionBuilder().anchor("left")
.margin(new DocumentInsets(0, 220, 0, 0)).addParagraph("Left column").build(),
@@ -376,8 +372,7 @@ void aLayerColumnNamesItsAnchorThatNoBookmarkMarks() throws Exception {
0, DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 200), null, null, null, null, "right"),
com.demcha.compose.document.node.LayerAlign.TOP_LEFT)));
- assertThat(detailOf(report, "SectionNode")).isEqualTo(lost);
- assertThat(detailOf(report, "ContainerNode")).isEqualTo(lost);
+ assertThat(report.bySubject()).doesNotContainKeys("SectionNode", "ContainerNode");
}
@Test
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
new file mode 100644
index 000000000..0f8c95b60
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
@@ -0,0 +1,496 @@
+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.LayerStackBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.ContainerNode;
+import com.demcha.compose.document.node.DocumentBookmarkOptions;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.IBodyElement;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFldChar;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHyperlink;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTMarkupRange;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STFldCharType;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A layer stack's column is bookmarked round what it writes in its cell.
+ *
+ * A stack whose layers stand side by side is written as a one-row table, each layer's content in
+ * its column's cell; the layer itself is never written as a block. Its anchor had no bookmark: a link
+ * to it named a bookmark the file did not hold, and a page reference to it was written as the number
+ * it read. Measured in Word 16 (fields updated) and LibreOffice 24.2, on a column on page 2 and the
+ * same file with a page put in before it: before, both showed 2 in each and carried no link; with the
+ * bookmark, both show 2 and then 3, and the link goes to the column's page — a left or right column,
+ * a container, a later layer of a column, a column opening with a table, a stack in a panel, and a
+ * table of contents' entry alike.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxLayerColumnBookmarkTest {
+
+ private static final DocumentInsets LEFT_BAND = new DocumentInsets(0, 190, 0, 0);
+ private static final DocumentInsets RIGHT_BAND = new DocumentInsets(0, 0, 0, 170);
+ private static final DocumentTextStyle TEXT = DocumentTextStyle.DEFAULT.withSize(12);
+ private static final String NAME = "Jordan Alexander Rivera";
+
+ @Test
+ void eachAnchoredColumnIsBookmarkedRoundWhatItWroteInItsCell() throws Exception {
+ DocumentNode right = new ContainerNode("Right", List.of(paragraph("Right one"), paragraph("Right two")),
+ 0, DocumentInsets.zero(), RIGHT_BAND, null, null, null, null, "right");
+ Exported exported = export(page -> page.add(columns(column("left", LEFT_BAND, "Left one", "Left two"), right)));
+
+ XWPFTable table = exported.document().getTables().get(0);
+ Marked left = marked(exported.document(), "left");
+ assertThat(left.start().getText()).isEqualTo("Left one");
+ assertThat(left.end().getText()).isEqualTo("Left two");
+ assertThat(left.start().getBody()).isSameAs(left.end().getBody()).isSameAs(table.getRow(0).getCell(0));
+ Marked marked = marked(exported.document(), "right");
+ assertThat(marked.start().getText()).isEqualTo("Right one");
+ assertThat(marked.end().getText()).isEqualTo("Right two");
+ assertThat(marked.start().getBody()).isSameAs(marked.end().getBody()).isSameAs(table.getRow(0).getCell(1));
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aLinkAndAPageReferenceToAColumnNameItsBookmark() throws Exception {
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Go to the column").linkTo("left"))
+ .addPageReference("left")
+ .addPageBreak(b -> { })
+ .add(columns(column("left", LEFT_BAND, "Left one"), column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(bookmarkNames(exported.document())).containsExactly("left");
+ assertThat(internalLinks(exported.document())).containsExactly("left");
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF left \\h", "2"));
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aTableOfContentsEntryToAColumnIsAField() throws Exception {
+ Exported exported = export(page -> page
+ .addTableOfContents(toc -> toc.title("Contents").entry("The left column", "left"))
+ .addPageBreak(b -> { })
+ .add(columns(column("left", LEFT_BAND, "Left one"), column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF left \\h", "2"));
+ }
+
+ @Test
+ void aLaterLayerOfAColumnIsBookmarkedFromItsOwnFirstParagraph() throws Exception {
+ DocumentNode lower = new SectionBuilder().anchor("lower").margin(new DocumentInsets(120, 190, 0, 0))
+ .addParagraph("Lower in the band").build();
+ Exported exported = export(page -> page.add(new LayerStackBuilder()
+ .layer(column(null, LEFT_BAND, "Top of the band"), LayerAlign.TOP_LEFT)
+ .layer(lower, LayerAlign.TOP_LEFT)
+ .layer(column(null, RIGHT_BAND, "Right one"), LayerAlign.TOP_LEFT).build()));
+
+ Marked marked = marked(exported.document(), "lower");
+ assertThat(marked.start().getText()).isEqualTo("Lower in the band");
+ assertThat(marked.end()).isSameAs(marked.start());
+ }
+
+ @Test
+ void aLayerAfterATableInItsColumnIsBookmarkedFromItsOwnFirstParagraph() throws Exception {
+ // The paragraph closing the table above is the one the next layer writes its first line in.
+ DocumentNode top = new SectionBuilder().margin(LEFT_BAND)
+ .addTable(t -> t.columns(DocumentTableColumn.fixed(70), DocumentTableColumn.fixed(70))
+ .rowCells(DocumentTableCell.text("A1"), DocumentTableCell.text("B1")))
+ .build();
+ DocumentNode lower = new SectionBuilder().anchor("lower").margin(new DocumentInsets(60, 190, 0, 0))
+ .addParagraph("Under the table").build();
+ Exported exported = export(page -> page.add(new LayerStackBuilder()
+ .layer(top, LayerAlign.TOP_LEFT)
+ .layer(lower, LayerAlign.TOP_LEFT)
+ .layer(column(null, RIGHT_BAND, "Right one"), LayerAlign.TOP_LEFT).build()));
+
+ Marked marked = marked(exported.document(), "lower");
+ assertThat(marked.start().getText()).isEqualTo("Under the table");
+ assertThat(marked.end()).isSameAs(marked.start());
+ }
+
+ @Test
+ void aColumnOpeningWithATableIsBookmarkedFromItsFirstCell() throws Exception {
+ DocumentNode left = new SectionBuilder().anchor("table").margin(LEFT_BAND)
+ .addTable(t -> t.columns(DocumentTableColumn.fixed(70), DocumentTableColumn.fixed(70))
+ .rowCells(DocumentTableCell.text("A1"), DocumentTableCell.text("B1"))
+ .rowCells(DocumentTableCell.text("A2"), DocumentTableCell.text("B2")))
+ .addParagraph("After the table").build();
+ Exported exported = export(page -> page.add(columns(left, column(null, RIGHT_BAND, "Right one"))));
+
+ Marked marked = marked(exported.document(), "table");
+ XWPFTableCell column = exported.document().getTables().get(0).getRow(0).getCell(0);
+ assertThat(marked.start().getText()).isEqualTo("A1");
+ assertThat(tableOf(marked.start())).as("the column's own table, inside its cell")
+ .isSameAs(column.getTables().get(0));
+ assertThat(marked.end().getText()).isEqualTo("After the table");
+ assertThat(marked.end().getBody()).isSameAs(column);
+ }
+
+ @Test
+ void aColumnEndingWithATableIsBookmarkedPastItsTable() throws Exception {
+ // A cell ends with a paragraph after its last table, which the bookmark closes in.
+ DocumentNode left = new SectionBuilder().anchor("table").margin(LEFT_BAND)
+ .addParagraph("Before the table")
+ .addTable(t -> t.columns(DocumentTableColumn.fixed(70), DocumentTableColumn.fixed(70))
+ .rowCells(DocumentTableCell.text("A1"), DocumentTableCell.text("B1")))
+ .build();
+ Exported exported = export(page -> page.add(columns(left, column(null, RIGHT_BAND, "Right one"))));
+
+ Marked marked = marked(exported.document(), "table");
+ XWPFTableCell column = exported.document().getTables().get(0).getRow(0).getCell(0);
+ assertThat(marked.start().getText()).isEqualTo("Before the table");
+ assertThat(marked.end().getBody()).isSameAs(column);
+ assertThat(column.getBodyElements().get(column.getBodyElements().size() - 1)).isSameAs(marked.end());
+ }
+
+ @Test
+ void aColumnOfAStackInAPanelIsBookmarkedInsideThePanel() throws Exception {
+ Exported exported = export(page -> page
+ .addPageReference("inside")
+ .addSection(panel -> panel.fillColor(DocumentColor.rgb(238, 242, 247)).padding(DocumentInsets.of(10))
+ .add(columns(column("inside", LEFT_BAND, "Panel left"), column(null, RIGHT_BAND, "Panel right")))));
+
+ Marked marked = marked(exported.document(), "inside");
+ XWPFTableCell panel = exported.document().getTables().get(0).getRow(0).getCell(0);
+ assertThat(marked.start().getText()).isEqualTo("Panel left");
+ assertThat(tableOf(marked.start())).as("the columns' table, inside the panel's cell")
+ .isSameAs(panel.getTables().get(0));
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF inside \\h", "1"));
+ }
+
+ @Test
+ void aReferenceToAColumnReadsItsOwnBookmarkWhereAnotherCleansToItsName() throws Exception {
+ // The column's first paragraph is anchored "a" and written first, so it is named "a"; the
+ // column's anchor "a." cleans to that name and is pushed onto a suffix.
+ DocumentNode left = new SectionBuilder().anchor("a.").margin(LEFT_BAND)
+ .addParagraph(p -> p.text("Left one").anchor("a")).build();
+ Exported exported = export(page -> page
+ .addPageReference("a.")
+ .add(columns(left, column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(bookmarkNames(exported.document())).containsExactlyInAnyOrder("a", "a_2");
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF a_2 \\h", "1"));
+ }
+
+ @Test
+ void anUnanchoredColumnWritesNoBookmark() throws Exception {
+ Exported exported = export(page -> page
+ .add(columns(column(null, LEFT_BAND, "Left one"), column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(bookmarkNames(exported.document())).isEmpty();
+ }
+
+ @Test
+ void aColumnOfDrawingsAloneIsNamedAndAReferenceToItStaysItsNumber() throws Exception {
+ // Its drawing stands in the paragraph beside it: the column writes none to put a bookmark round.
+ DocumentNode drawn = new SectionBuilder().anchor("drawn").margin(LEFT_BAND)
+ .addCircle(40, DocumentColor.rgb(26, 86, 148)).build();
+ Exported exported = export(page -> page
+ .addPageReference("drawn")
+ .add(columns(drawn, column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(bookmarkNames(exported.document())).isEmpty();
+ assertThat(pageReferences(exported.document())).isEmpty();
+ assertThat(exported.document().getParagraphs().get(0).getText()).as("the number it reads, as text")
+ .isEqualTo("1");
+ assertThat(notesOn(exported.report(), "PageReferenceNode")).singleElement().asString()
+ .contains("its page number is written as text");
+ assertThat(notesOn(exported.report(), "SectionNode")).containsExactly("writes no paragraph, so its anchor "
+ + "\"drawn\" has no bookmark in the Word file: a link to it points at none, and a page reference to "
+ + "it is a fixed number rather than a field");
+ }
+
+ @Test
+ void aColumnThatWritesNothingNamesItsAnchorInItsOwnNote() throws Exception {
+ // The column has a note of its own already — its outline entry — and the anchor joins it.
+ DocumentNode drawn = new SectionBuilder().anchor("drawn").bookmark(new DocumentBookmarkOptions("Drawn"))
+ .margin(LEFT_BAND).addCircle(40, DocumentColor.rgb(26, 86, 148)).build();
+ Exported exported = export(page -> page.add(columns(drawn, column(null, RIGHT_BAND, "Right one"))));
+
+ assertThat(notesOn(exported.report(), "SectionNode")).containsExactly("written as a column of its layer "
+ + "stack; its outline entry is not written; it writes no paragraph, so its anchor \"drawn\" has no "
+ + "bookmark in the Word file: a link to it points at none, and a page reference to it is a fixed "
+ + "number rather than a field");
+ }
+
+ @Test
+ void aLayerWhoseBlockAnEarlierLayersStandInHoldsIsBookmarkedFromThere() throws Exception {
+ // The subtitle is the main layer's, drawn over the strip the layer above paints: it is
+ // written in the strip, before the rest of its layer.
+ DocumentNode stack = strip(340, "main", true);
+ Exported exported = export(page -> page.add(stack));
+
+ Marked marked = marked(exported.document(), "main");
+ assertThat(marked.start().getText()).isEqualTo("Subtitle");
+ assertThat(cellOf(marked.start()).getText()).as("the premise: written in the strip").contains(NAME);
+ assertThat(marked.end().getText()).isEqualTo("Body");
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aLayerAllOfWhichAnEarlierLayersStandInHoldsIsBookmarkedThere() throws Exception {
+ DocumentNode stack = strip(340, "main", false);
+ Exported exported = export(page -> page.addPageReference("main").add(stack));
+
+ Marked marked = marked(exported.document(), "main");
+ assertThat(marked.start().getText()).isEqualTo("Subtitle");
+ assertThat(marked.end()).isSameAs(marked.start());
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF main \\h", "1"));
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aColumnHoldingAStackOfItsOwnIsBookmarkedFromItsOwnFirstLine() throws Exception {
+ // The stack inside writes its subtitle in its strip, after the column's heading: that is
+ // the inner stack's, inside what the column writes, not something written before it.
+ DocumentNode outer = new SectionBuilder().anchor("outer").margin(new DocumentInsets(0, 20, 0, 0))
+ .addParagraph("Heading").add(strip(320, null, true)).build();
+ DocumentNode right = column(null, new DocumentInsets(0, 0, 0, 325), "R");
+ Exported exported = export(page -> page.add(columns(outer, right)));
+
+ Marked marked = marked(exported.document(), "outer");
+ assertThat(cellOf(paragraphHolding(exported.document(), "Subtitle")).getText())
+ .as("the premise: the inner stack wrote its subtitle in its strip").contains(NAME);
+ assertThat(marked.start().getText()).isEqualTo("Heading");
+ }
+
+ private static DocumentNode column(String anchor, DocumentInsets band, String... lines) {
+ SectionBuilder section = new SectionBuilder().margin(band);
+ if (anchor != null) {
+ section.anchor(anchor);
+ }
+ for (String line : lines) {
+ section.addParagraph(line);
+ }
+ return section.build();
+ }
+
+ /**
+ * A sidebar beside a main column whose strip is drawn in two layers, as {@code ReadingOrderColumns}
+ * draws one (see {@code DocxStandInMoveTest}): the first paints the strip with the name and a
+ * stand-in under it, the second holds the name's place with a spacer and writes the subtitle,
+ * which lies inside the stand-in, then, with {@code body}, a body line under the strip.
+ *
+ * @param width the width the stack is offered, in points
+ * @param anchor the main layer's anchor, or {@code null}
+ */
+ private static DocumentNode strip(double width, String anchor, boolean body) throws Exception {
+ double[] name = new double[2];
+ double[] subtitle = new double[2];
+ try (DocumentSession probe = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(20))
+ .create()) {
+ probe.pageFlow(page -> page.addSection("Probe", strip -> strip.padding(DocumentInsets.of(10))
+ .addParagraph(p -> p.name("Name").text(NAME).textStyle(TEXT))
+ .addParagraph(p -> p.name("Subtitle").text("Subtitle").textStyle(TEXT))));
+ for (com.demcha.compose.document.layout.PlacedNode node : probe.layoutGraph().nodes()) {
+ double[] box = "Name".equals(node.semanticName()) ? name
+ : "Subtitle".equals(node.semanticName()) ? subtitle : null;
+ if (box != null) {
+ box[0] = node.placementWidth();
+ box[1] = node.placementHeight();
+ }
+ }
+ }
+ DocumentNode main = layer("MainLayer", 100, 0, anchor, content -> {
+ content.addSection("Hero", hero -> hero.padding(new DocumentInsets(10, 10, 10, 10))
+ .addSpacer(spacer -> spacer.name("NamePlace").width(name[0]).height(name[1]))
+ .addParagraph(p -> p.name("Subtitle").text("Subtitle").textStyle(TEXT)));
+ if (body) {
+ content.addParagraph(p -> p.name("Body").text("Body").textStyle(TEXT));
+ }
+ });
+ return new LayerStackBuilder()
+ .layer(layer("Sidebar", 0, width - 100, null, sidebar -> sidebar
+ .addParagraph(p -> p.text("Contact").textStyle(TEXT))), LayerAlign.TOP_LEFT)
+ .layer(layer("StripLayer", 100, 0, null, content -> content.addSection("Strip", strip -> strip
+ .fillColor(DocumentColor.rgb(240, 240, 236))
+ .padding(DocumentInsets.of(10))
+ .addParagraph(p -> p.name("Name").text(NAME).textStyle(TEXT))
+ .addSpacer(spacer -> spacer.name("SubtitlePlace").width(subtitle[0]).height(subtitle[1])))),
+ LayerAlign.TOP_LEFT)
+ .layer(main, LayerAlign.TOP_LEFT)
+ .build();
+ }
+
+ private static DocumentNode layer(String name, double left, double right, String anchor,
+ Consumer content) {
+ SectionBuilder layer = new SectionBuilder();
+ layer.name(name).spacing(0).padding(new DocumentInsets(0, right, 0, left));
+ if (anchor != null) {
+ layer.anchor(anchor);
+ }
+ layer.addSection(name + "Content", content);
+ return layer.build();
+ }
+
+ private static XWPFTableCell cellOf(XWPFParagraph paragraph) {
+ assertThat(paragraph.getBody()).isInstanceOf(XWPFTableCell.class);
+ return (XWPFTableCell) paragraph.getBody();
+ }
+
+ private static XWPFTable tableOf(XWPFParagraph paragraph) {
+ return cellOf(paragraph).getTableRow().getTable();
+ }
+
+ private static XWPFParagraph paragraphHolding(XWPFDocument document, String text) {
+ return paragraphs(document).stream().filter(paragraph -> paragraph.getText().equals(text)).findFirst()
+ .orElseThrow();
+ }
+
+ private static DocumentNode paragraph(String text) {
+ return new ParagraphBuilder().text(text).build();
+ }
+
+ private static DocumentNode columns(DocumentNode left, DocumentNode right) {
+ return new LayerStackBuilder().layer(left, LayerAlign.TOP_LEFT).layer(right, LayerAlign.TOP_LEFT).build();
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report) {
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(30))
+ .create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend(report::set));
+ }
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get());
+ }
+
+ private static List notesOn(DocxExportReport report, String subject) {
+ return report.notes().stream().filter(note -> note.subject().equals(subject))
+ .map(DocxExportReport.Note::detail).toList();
+ }
+
+ /** Where a bookmark opens and where it closes. */
+ private record Marked(XWPFParagraph start, XWPFParagraph end) {
+ }
+
+ private static Marked marked(XWPFDocument document, String name) {
+ XWPFParagraph start = null;
+ BigInteger id = null;
+ for (XWPFParagraph paragraph : paragraphs(document)) {
+ for (CTBookmark bookmark : paragraph.getCTP().getBookmarkStartList()) {
+ if (name.equals(bookmark.getName())) {
+ start = paragraph;
+ id = bookmark.getId();
+ }
+ }
+ for (CTMarkupRange end : paragraph.getCTP().getBookmarkEndList()) {
+ if (id != null && id.equals(end.getId())) {
+ return new Marked(start, paragraph);
+ }
+ }
+ }
+ throw new AssertionError("no bookmark " + name + " opened and closed in the file");
+ }
+
+ private static List bookmarkNames(XWPFDocument document) {
+ List names = new ArrayList<>();
+ for (XWPFParagraph paragraph : paragraphs(document)) {
+ for (CTBookmark bookmark : paragraph.getCTP().getBookmarkStartList()) {
+ names.add(bookmark.getName());
+ }
+ }
+ return names;
+ }
+
+ private static List internalLinks(XWPFDocument document) {
+ List anchors = new ArrayList<>();
+ for (XWPFParagraph paragraph : paragraphs(document)) {
+ for (CTHyperlink link : paragraph.getCTP().getHyperlinkList()) {
+ if (link.getAnchor() != null) {
+ anchors.add(link.getAnchor());
+ }
+ }
+ }
+ return anchors;
+ }
+
+ /** Every PAGEREF field, its instruction to the text it reads. */
+ private static Map pageReferences(XWPFDocument document) {
+ Map fields = new LinkedHashMap<>();
+ for (XWPFParagraph paragraph : paragraphs(document)) {
+ String instruction = null;
+ boolean reading = false;
+ StringBuilder text = new StringBuilder();
+ List runs = new ArrayList<>(paragraph.getCTP().getRList());
+ for (CTHyperlink link : paragraph.getCTP().getHyperlinkList()) {
+ runs.addAll(link.getRList());
+ }
+ for (CTR run : runs) {
+ for (CTText code : run.getInstrTextList()) {
+ instruction = code.getStringValue().trim();
+ }
+ for (CTFldChar mark : run.getFldCharList()) {
+ if (mark.getFldCharType() == STFldCharType.SEPARATE) {
+ reading = true;
+ } else if (mark.getFldCharType() == STFldCharType.END) {
+ if (instruction != null && instruction.startsWith("PAGEREF")) {
+ fields.put(instruction, text.toString());
+ }
+ instruction = null;
+ reading = false;
+ text.setLength(0);
+ }
+ }
+ if (reading) {
+ run.getTList().forEach(t -> text.append(t.getStringValue()));
+ }
+ }
+ }
+ return fields;
+ }
+
+ /** Every paragraph of the body, in the file's order, those of tables nested in tables included. */
+ private static List paragraphs(XWPFDocument document) {
+ List paragraphs = new ArrayList<>();
+ collect(document.getBodyElements(), paragraphs);
+ return paragraphs;
+ }
+
+ private static void collect(List elements, List paragraphs) {
+ for (IBodyElement element : elements) {
+ if (element instanceof XWPFParagraph paragraph) {
+ paragraphs.add(paragraph);
+ } else if (element instanceof XWPFTable table) {
+ for (XWPFTableRow row : table.getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ collect(cell.getBodyElements(), paragraphs);
+ }
+ }
+ }
+ }
+ }
+}
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 b386c1f71..96bcc501b 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
@@ -327,8 +327,8 @@ record Entry(Fate fate, String note) {
"fillColor:REPORTED:" + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_TRANSLUCENCY,
"stroke:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS,
"cornerRadius:REPORTED", "borders:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS,
- "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, "
- + "which has no bookmark; otherwise, in the flow, it is bookmarked",
+ "anchor:REPORTED:where it writes no paragraph, as of drawings alone, which has no bookmark; "
+ + "otherwise, in the flow or as a layer stack's column, it is bookmarked",
"bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column");
node(EllipseNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + DRAWING_DROPPED,
"fillColor:REPORTED:" + DRAWING_DROPPED, "stroke:REPORTED:" + DRAWING_DROPPED, "linkTarget:REPORTED",
@@ -476,8 +476,8 @@ record Entry(Fate fate, String note) {
"stroke:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS,
"cornerRadius:REPORTED", "borders:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS,
"keepTogether:REPORTED:" + KEEP,
- "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, "
- + "which has no bookmark; otherwise, in the flow, it is bookmarked",
+ "anchor:REPORTED:where it writes no paragraph, as of drawings alone, which has no bookmark; "
+ + "otherwise, in the flow or as a layer stack's column, it is bookmarked",
"bleed:REPORTED:of a panel the page bleeds, in the flow it pages",
"bookmarkOptions:REPORTED", "keepWithNext:REPORTED:" + KEEP,
"flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
index 0d0ed408a..6214bd1e3 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageReferenceTest.java
@@ -188,8 +188,8 @@ void aReferenceNamesNoBookmarkOfItsOwnAndTakesNoNameFromAnother() throws Excepti
}
@Test
- void aReferenceToALayerStacksColumnIsWrittenAsTheNumberItReads() throws Exception {
- // A column of a layer stack is written as what it holds, with no bookmark round it.
+ void aReferenceToALayerStacksColumnIsAFieldOnItsBookmark() throws Exception {
+ // A column of a layer stack is written as what it holds, its bookmark round that.
try (XWPFDocument document = export(page -> page
.addPageReference("left")
.addLayerStack(stack -> stack
@@ -200,9 +200,8 @@ void aReferenceToALayerStacksColumnIsWrittenAsTheNumberItReads() throws Exceptio
.margin(new DocumentInsets(0, 0, 0, 130)).addParagraph("Right").build(),
com.demcha.compose.document.node.LayerAlign.TOP_LEFT)))) {
- assertThat(bookmarkNames(document)).doesNotContain("left");
- assertThat(pageReferences(document)).isEmpty();
- assertThat(document.getParagraphs().get(0).getText()).isEqualTo("1");
+ assertThat(bookmarkNames(document)).containsExactly("left");
+ assertThat(pageReferences(document)).containsExactly(Map.entry("PAGEREF left \\h", "1"));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java
index 6bb4ab290..44d047423 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java
@@ -169,7 +169,7 @@ class DocxValueFieldLedgerTest {
"options:WRITTEN:as an external link from the part the text is in, a header's or footer's included");
value(com.demcha.compose.document.node.InternalLinkTarget.class,
"anchor:REPORTED:written as a link to its bookmark; where the node declaring the anchor writes "
- + "none, drawn, in a page zone, a stack's column or a block writing no paragraph, named on that "
+ + "none, drawn, in a page zone or a block writing no paragraph, named on that "
+ "node; an anchor several nodes declare or a repeated header row holds, named");
value(com.demcha.compose.document.node.ListItem.class,
"children:WRITTEN:each a depth further in, as the next level of a Word list or two spaces more; "
From dfe36a877aa534b53400f8d7d3a80079e01cf3a2 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 26 Oct 2026 09:22:52 +0000
Subject: [PATCH 2/2] fix(docx): record a block written in a stand-in before
what it holds
writeInPlaceOf recorded what a block wrote in a stand-in once the block was
written, so a stack inside it that writes in its own stand-ins was recorded
first, and the column's bookmark opened at that stack's subtitle instead of
where the block starts. The block's place in the record is now taken before
it is written, which keeps the record in the file's order.
The recipe's limit on an anchor whose first block moved now names a
container inside the later layer: the layer's own anchor, where the stack is
written as columns, is marked from the moved block.
---
docs/recipes/docx-export.md | 9 ++-
.../semantic/docx/DocxSemanticBackend.java | 16 ++--
.../docx/DocxLayerColumnBookmarkTest.java | 81 ++++++++++++++-----
3 files changed, 76 insertions(+), 30 deletions(-)
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 78bd04b92..4d52d90ac 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. Its fixed columns are written where every column is fixed; where one is `auto`, the editor divides the width among them (measured: Word and LibreOffice on Windows alike, half the cell each for two), and the report names it (`TableNode`) |
| 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. One contained in its box (`fitToBounds`, `CONTAIN`) is embedded at its fitted size, half the room it leaves above and below written as the space round its paragraph and half at its side as its indent; in a band the room below is its paragraph's own, as the band measures what it owes below from the box. In the flow, in a row's cell and held in by an alignment it stands in the box's middle, as the page draws it. 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 in the flow, a stack of one layer's included, or first in a band of layers there, that the layout moves to a new page keeps the space the layout leaves above it there — its margin, its padding, the room above it in its box, the edges of the containers opening with it and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, as a body paragraph does. 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; a section or a container written as a layer stack's column wraps what it writes the same way, from a block of it an earlier layer's stand-in holds, written there first, to the last it writes in its own cell. A block that writes no paragraph — a section or a container of drawings alone, whose drawings stand in the paragraph beside them — has no bookmark, and the report names its anchor: a link to it points at none (measured: the page's link went to the section's place, and Word's and LibreOffice's PDFs carried no link at all), and a page reference to it is written as the number it reads. 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 |
+| 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; a section or a container written as a layer stack's column wraps what it writes the same way, from a block of it an earlier layer's stand-in holds, written there first, to the last it writes. A block that writes no paragraph — a section or a container of drawings alone, whose drawings stand in the paragraph beside them — has no bookmark, and the report names its anchor: a link to it points at none (measured: the page's link went to the section's place, and Word's and LibreOffice's PDFs carried no link at all), and a page reference to it is written as the number it reads. 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). In a table cell — a row's column, a panel's cell — such a block keeps its lines together and nothing with the next: Word reads keep-with-next on the paragraph a cell opens with as keeping the whole row with what follows its table, and holds no keep across a break inside the row, so there those keeps did nothing else; a row of columns, which may not split, keeps the block whole (measured: `ObsidianInvoice`'s info cards, kept together in a row's two cells, went with the footer divider under them to the next page in Word, 17 lines below the page's; without keep-with-next in the cards, they stood on their page). The paragraph between two tables in a cell still keeps with the next table: Word does not read it as the paragraph the cell opens with, even after the table the cell opens with (measured: a row at the foot of a page stayed there either way). 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. A layer stack or a shape container in the flow is kept on its page as a block kept together is (`w:keepLines` on each paragraph, `w:keepNext` on all but the last), since the layout moves it to a new page whole: told nothing, Word set the first lines of a stack of one layer and of a shape container the page moved down at the foot of the page above, and the first layer of a band of two, in Word and LibreOffice alike. In a table's or a panel's cell they are the row's, which keeps or breaks on its own terms |
| 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. So does a spacer opening a stack of one layer, or a band of layers, which the layout moves to a new page whole: the space the layout leaves above it there is held in its line, less, in a stack of one layer, its layer's offset, which is named. It is kept with what follows it in the stack, which Word would otherwise set at the new page's top with the spacer left at the foot of the page 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 |
@@ -1057,8 +1057,11 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
its own layer. The content has to lie between the spacer's top and foot, and across
within the content of the block the spacer sits in, to half a point; one that does not stays where its
layer writes it. Two limits: a panel's table ends half its bottom border below the
- panel's edge, so the gap under it is that much too tall; and an `anchor()` on the
- later layer's container whose first block moved marks the next block written.
+ panel's edge, so the gap under it is that much too tall; and an `anchor()` on a
+ container inside the later layer whose first block moved marks the next block
+ written, and one whose blocks all moved is named as writing no paragraph. The later
+ layer's own anchor, where the stack is written as columns, is marked from the
+ moved block.
- **Overlapping layers → one band.** Any other layer stack of two or more layers
writes them one after the other, as one band that keeps the page's distances: a
spacer level with another layer's content only keeps its place and is not
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 0d525e084..a7ffa3fb3 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
@@ -11409,11 +11409,11 @@ private void markOrName(DocumentNode node, String anchor, List wri
* What earlier layers' stand-ins hold of a layer, written there before the layer itself
* ({@link #writeInPlaceOf}), in the file's order.
*
- * @param layer a layer of a stack written as columns
- * @param written how many blocks had been written in a stand-in's place when the layer's own
- * writing began: those written since are inside what the layer writes itself
+ * @param layer a layer of a stack written as columns
+ * @param before how many blocks had been written in a stand-in's place when the layer's own
+ * writing began: those written since are inside what the layer writes itself
*/
- private List writtenInAnEarlierLayer(DocumentNode layer, int written) {
+ private List writtenInAnEarlierLayer(DocumentNode layer, int before) {
java.util.Set under = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
java.util.ArrayDeque open = new java.util.ArrayDeque<>(layer.children());
while (!open.isEmpty()) {
@@ -11423,7 +11423,7 @@ private List writtenInAnEarlierLayer(DocumentNode layer, int writt
}
}
List elements = new ArrayList<>();
- for (WrittenInAStandIn moved : writtenInStandIns.subList(0, written)) {
+ for (WrittenInAStandIn moved : writtenInStandIns.subList(0, before)) {
if (under.contains(moved.block())) {
elements.addAll(moved.written());
}
@@ -21102,7 +21102,11 @@ private void writeInPlaceOf(XWPFDocument document, SpacerNode standIn) throws Ex
if (placed != null && across != null) {
holdIn(content, placed.placementX(), placed.placementWidth(), across[0], across[1]);
}
- writtenInStandIns.add(new WrittenInAStandIn(content,
+ // Its place is taken before it is written: a stack inside it writing in its own
+ // stand-ins records them after it, in the order the file holds them.
+ int slot = writtenInStandIns.size();
+ writtenInStandIns.add(new WrittenInAStandIn(content, List.of()));
+ writtenInStandIns.set(slot, new WrittenInAStandIn(content,
List.copyOf(writtenBy(document, () -> writeNode(document, content)))));
}
} finally {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
index 0f8c95b60..86c1f039e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
@@ -61,6 +61,9 @@ class DocxLayerColumnBookmarkTest {
private static final DocumentInsets RIGHT_BAND = new DocumentInsets(0, 0, 0, 170);
private static final DocumentTextStyle TEXT = DocumentTextStyle.DEFAULT.withSize(12);
private static final String NAME = "Jordan Alexander Rivera";
+ private static final String OUTER_NAME = "Morgan Blake Ellison";
+ // Wide enough to lay out on its own any block these tests set in a stand-in.
+ private static final double PROBE_WIDTH = 1000;
@Test
void eachAnchoredColumnIsBookmarkedRoundWhatItWroteInItsCell() throws Exception {
@@ -283,6 +286,23 @@ void aColumnHoldingAStackOfItsOwnIsBookmarkedFromItsOwnFirstLine() throws Except
assertThat(marked.start().getText()).isEqualTo("Heading");
}
+ @Test
+ void aLayerWhoseMovedBlockIsAStackOfItsOwnIsBookmarkedFromThatStacksFirstLine() throws Exception {
+ // The block the stand-in holds is a strip of its own, which writes its subtitle in its own
+ // strip as it is written: the layer's bookmark opens where the block does, not there.
+ DocumentNode inner = strip(460, null, "Inner contact", NAME,
+ new ParagraphBuilder().name("Subtitle").text("Subtitle").textStyle(TEXT).build(), false);
+ DocumentNode outer = strip(580, "main", "Contact", OUTER_NAME, inner, false);
+ Exported exported = export(640, page -> page.add(outer));
+
+ XWPFParagraph innerContact = paragraphHolding(exported.document(), "Inner contact");
+ assertThat(((XWPFTableCell) tableOf(innerContact).getBody()).getText())
+ .as("the premise: the inner strip is written in the outer one").contains(OUTER_NAME);
+ assertThat(cellOf(paragraphHolding(exported.document(), "Subtitle")).getText())
+ .as("the premise: the inner strip writes its subtitle in its own strip").contains(NAME);
+ assertThat(marked(exported.document(), "main").start()).isSameAs(innerContact);
+ }
+
private static DocumentNode column(String anchor, DocumentInsets band, String... lines) {
SectionBuilder section = new SectionBuilder().margin(band);
if (anchor != null) {
@@ -304,43 +324,58 @@ private static DocumentNode column(String anchor, DocumentInsets band, String...
* @param anchor the main layer's anchor, or {@code null}
*/
private static DocumentNode strip(double width, String anchor, boolean body) throws Exception {
- double[] name = new double[2];
- double[] subtitle = new double[2];
- try (DocumentSession probe = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(20))
- .create()) {
- probe.pageFlow(page -> page.addSection("Probe", strip -> strip.padding(DocumentInsets.of(10))
- .addParagraph(p -> p.name("Name").text(NAME).textStyle(TEXT))
- .addParagraph(p -> p.name("Subtitle").text("Subtitle").textStyle(TEXT))));
- for (com.demcha.compose.document.layout.PlacedNode node : probe.layoutGraph().nodes()) {
- double[] box = "Name".equals(node.semanticName()) ? name
- : "Subtitle".equals(node.semanticName()) ? subtitle : null;
- if (box != null) {
- box[0] = node.placementWidth();
- box[1] = node.placementHeight();
- }
- }
- }
+ DocumentNode subtitle = new ParagraphBuilder().name("Subtitle").text("Subtitle").textStyle(TEXT).build();
+ return strip(width, anchor, "Contact", NAME, subtitle, body);
+ }
+
+ /**
+ * As {@link #strip(double, String, boolean)}, with the sidebar's text, the name, and the block
+ * the main layer lays in the stand-in, which is sized to it.
+ */
+ private static DocumentNode strip(double width, String anchor, String contact, String nameText, DocumentNode moved,
+ boolean body) throws Exception {
+ double[] name = sizeOf("Name", page -> page.addSection("Probe", strip -> strip.padding(DocumentInsets.of(10))
+ .addParagraph(p -> p.name("Name").text(nameText).textStyle(TEXT))));
+ double[] place = sizeOf(moved.name(), page -> page.addSection("Probe", strip -> strip
+ .padding(new DocumentInsets(0, 10, 0, PROBE_WIDTH - 60 - width + 110)).add(moved)));
DocumentNode main = layer("MainLayer", 100, 0, anchor, content -> {
content.addSection("Hero", hero -> hero.padding(new DocumentInsets(10, 10, 10, 10))
.addSpacer(spacer -> spacer.name("NamePlace").width(name[0]).height(name[1]))
- .addParagraph(p -> p.name("Subtitle").text("Subtitle").textStyle(TEXT)));
+ .add(moved));
if (body) {
content.addParagraph(p -> p.name("Body").text("Body").textStyle(TEXT));
}
});
return new LayerStackBuilder()
+ .name("Stack")
.layer(layer("Sidebar", 0, width - 100, null, sidebar -> sidebar
- .addParagraph(p -> p.text("Contact").textStyle(TEXT))), LayerAlign.TOP_LEFT)
+ .addParagraph(p -> p.text(contact).textStyle(TEXT))), LayerAlign.TOP_LEFT)
.layer(layer("StripLayer", 100, 0, null, content -> content.addSection("Strip", strip -> strip
.fillColor(DocumentColor.rgb(240, 240, 236))
.padding(DocumentInsets.of(10))
- .addParagraph(p -> p.name("Name").text(NAME).textStyle(TEXT))
- .addSpacer(spacer -> spacer.name("SubtitlePlace").width(subtitle[0]).height(subtitle[1])))),
+ .addParagraph(p -> p.name("Name").text(nameText).textStyle(TEXT))
+ .addSpacer(spacer -> spacer.name("MovedPlace").width(place[0]).height(place[1])))),
LayerAlign.TOP_LEFT)
.layer(main, LayerAlign.TOP_LEFT)
.build();
}
+ /** The width and height the layout gives the node of a name, laid out on its own page. */
+ private static double[] sizeOf(String name, Consumer content) {
+ try (DocumentSession probe = GraphCompose.document().pageSize(PROBE_WIDTH, 500).margin(DocumentInsets.of(30))
+ .create()) {
+ probe.pageFlow(content::accept);
+ for (com.demcha.compose.document.layout.PlacedNode node : probe.layoutGraph().nodes()) {
+ if (name.equals(node.semanticName())) {
+ return new double[] {node.placementWidth(), node.placementHeight()};
+ }
+ }
+ } catch (Exception failure) {
+ throw new IllegalStateException(failure);
+ }
+ throw new AssertionError("no node named " + name);
+ }
+
private static DocumentNode layer(String name, double left, double right, String anchor,
Consumer content) {
SectionBuilder layer = new SectionBuilder();
@@ -378,9 +413,13 @@ private record Exported(XWPFDocument document, DocxExportReport report) {
}
private static Exported export(Consumer content) throws Exception {
+ return export(400, content);
+ }
+
+ private static Exported export(double pageWidth, Consumer content) throws Exception {
AtomicReference report = new AtomicReference<>();
byte[] docx;
- try (DocumentSession session = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(30))
+ try (DocumentSession session = GraphCompose.document().pageSize(pageWidth, 500).margin(DocumentInsets.of(30))
.create()) {
session.pageFlow(content::accept);
docx = session.export(new DocxSemanticBackend(report::set));