From 66b3caac8dc696131b8eb369db4749ab5766498a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 26 Oct 2026 11:37:08 +0000
Subject: [PATCH 1/3] fix(docx): bookmark a block of drawings alone where its
first drawing is anchored
A section, a container or a layer stack's column holding nothing but
drawings writes no paragraph for its anchor's bookmark to go round, so a
link to the anchor named a bookmark the file did not hold and a page
reference to it was written as a fixed number.
DocxDrawingAnchors now keeps the paragraph each shape is anchored in, by
the shape's number, and the shapes nothing carries. At each section's end,
once its drawings are anchored and before a table is split for its pages'
margins, the export bookmarks such a block at the start of the paragraph
holding its first drawing - the one on the earliest page, highest on it.
Word keeps a drawing on its paragraph's page and moves it with that
paragraph, so the reference reads the block's page and follows it through
edits. A block that draws nothing, whose first drawing nothing carries, or
that is composed in a table's cell keeps its report note.
Measured in Word 16 (fields updated) and LibreOffice 24.2 on such a block on
page 2 and on the same file with a page put in ahead of it: before, both
read 2 in each and carried no link; now both read 2 and then 3, and the
link goes to the block's page. The template corpus declares no anchors and
is unchanged.
---
CHANGELOG.md | 25 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 12 +-
.../semantic/docx/DocxDrawingAnchors.java | 60 +++
.../semantic/docx/DocxSemanticBackend.java | 145 ++++--
.../docx/DocxAnchorWithoutABookmarkTest.java | 85 ++--
.../semantic/docx/DocxDrawingAnchorsTest.java | 75 +++
.../docx/DocxDrawnBlockBookmarkTest.java | 446 ++++++++++++++++++
.../docx/DocxLayerColumnBookmarkTest.java | 36 +-
.../docx/DocxNodeFieldLedgerTest.java | 14 +-
.../semantic/docx/DocxPanelOverPagesTest.java | 12 +-
.../docx/DocxValueFieldLedgerTest.java | 5 +-
12 files changed, 806 insertions(+), 111 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec4814b4d..18a09babe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,27 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export bookmarks a block of drawings alone where its first drawing is anchored, so a
+ page reference or a link to its anchor works.**
+ - A section, a container or a layer stack's column holding nothing but drawings writes no
+ paragraph for a bookmark to go round: its drawings are anchored in the paragraphs beside them,
+ or in one written on their page. 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 the layout read, which
+ no edit updates, with a report note. The bookmark now goes at the start of the paragraph its
+ first drawing is anchored in — the drawing on the earliest page, highest on it. Word keeps a
+ drawing on its paragraph's page and moves it with that paragraph, so the reference reads the
+ page the block starts on, and goes on reading it once the text above is edited.
+ - Measured in Word 16, fields updated, and LibreOffice 24.2, on such a block 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 block's page. So it is for a
+ section and a container of drawings, one opening its page, one alone on its page, one in a
+ panel, a layer stack's column of drawings, and an anchor named with spaces past the 40
+ characters Word keeps; a block at the foot of page 1 whose drawings run onto page 2 reads 1,
+ and then 2.
+ - A block that draws nothing, one whose first drawing stands on a page nothing is written on,
+ and one composed in a table's cell, whose drawings its table draws, still have no bookmark, and
+ the report names the anchor.
+
- **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
@@ -23,8 +44,8 @@ follow semantic versioning; release dates are ISO 8601.
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 column that writes no paragraph — drawings alone — is bookmarked where its first drawing is
+ anchored, as a section of drawings alone is (above).
- **A DOCX export keeps a page break the layout makes by itself after a card where the page
makes it.**
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 44a68e7bc..10fc0694f 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 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 |
+| 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 block that writes no paragraph — a section, a container or a column of drawings alone — at the start of the paragraph its first drawing is anchored in, which Word keeps the drawing on the page of. 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 draws nothing, whose first drawing stands on a page nothing is written on, or that is composed in a table's cell 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 4d52d90ac..e0107f818 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. 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, a container or a layer stack's column of drawings alone, whose drawings stand in the paragraph beside them or in one written on their page — is bookmarked at the start of the paragraph its first drawing is anchored in, the drawing on the earliest page and highest on it: Word keeps a drawing on its paragraph's page and moves it with that paragraph, so a page reference reads the page the block starts on and goes on reading it once the text above is edited, and a link goes to that page (measured in Word 16, fields updated, and LibreOffice 24.2: a block on page 2 read 2, and 3 with a page put in ahead of it, where the file had held no bookmark and the reference a fixed 2). A block that draws nothing, one whose first drawing stands on a page nothing is written on, and one composed in a table's cell, whose drawings its table draws, have no bookmark, and the report names the anchor: a link to it points at none, 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 |
@@ -126,8 +126,9 @@ The export does not set `w:updateFields`. It would make Word ask, on every open,
update fields — to recompute numbers that already read correctly.
A page reference to an anchor the export writes no bookmark for — a drawn shape's, or a
-section's that holds only shapes the export draws and so writes no paragraph to put one
-round — is written as the text the page prints, a number no edit updates, and its report note
+block's that writes no paragraph and draws nothing a paragraph carries as its own, as a
+section that draws nothing or one composed in a table's cell (see the links and anchors row
+above) — is written as the text the page prints, a number no edit updates, and its report note
says so: Word turns a `PAGEREF` to a missing bookmark into "Error! Bookmark not defined." the
first time it updates. Each field is named after the bookmark its anchor was written under,
or taken back to its text, once the body is written, so a reference before its anchor is
@@ -1469,7 +1470,10 @@ named in the report:
is what a turned shape container holds — named with its outline, or on its own where the
outline draws nothing.
- A drawing carries no link, no outline entry and no bookmark: a link to a drawn shape's
- anchor points at none, and a page reference to it is a fixed number. A gradient fill,
+ anchor points at none, and a page reference to it is a fixed number. A section, a container
+ or a layer stack's column of drawings alone is bookmarked all the same, at the start of the
+ paragraph its first drawing is anchored in, where one carries it (see the links and anchors
+ row). A gradient fill,
unequal corners (drawn at the largest radius; corners whose radii differ only past half the
box's shorter side, where the page holds them, are drawn alike and not named — a shape
composed in a table cell is held to half its own shorter side, so one in a cell narrower than
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
index 285b00188..00c195cae 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
@@ -63,6 +63,10 @@
* is drawn from ({@link #ordered}), not when it is drawn or when it finds its paragraph: Word
* stacks drawings by those places, a later one over an earlier one, and the export draws a
* stack's layers, a band's and a table's cells in the order it writes them.
+ *
+ * The paragraph each shape is anchored in is kept for the whole document
+ * ({@link #carrierOfTheFirst}): a block of drawings alone writes no paragraph of its own, and its
+ * anchor's bookmark goes in the one holding its first drawing.
*/
final class DocxDrawingAnchors {
@@ -92,6 +96,9 @@ final class DocxDrawingAnchors {
private int latest = -1;
// How many shapes have been drawn in the document, which numbers their names.
private int drawn;
+ // The paragraph each shape anchored in the document is anchored in, by its number
+ // (Ordered#drawn), with where the page puts it.
+ private final Map carried = new HashMap<>();
/**
* How many places in the paint order a fragment's shapes take, at most: one drawn as more
@@ -126,6 +133,55 @@ void reset() {
placesTaken.clear();
latest = -1;
drawn = 0;
+ carried.clear();
+ }
+
+ /**
+ * How many shapes the document has given a place in the paint order so far: those given
+ * one from here on are numbered from it ({@link Ordered#drawn}).
+ */
+ int drawnSoFar() {
+ return drawn;
+ }
+
+ /**
+ * The paragraph a shape is anchored in, and where the page puts the shape.
+ *
+ * @param paragraph the paragraph whose run holds the shape's anchor, or {@code null} for a shape
+ * the section's end found nothing to carry
+ * @param page the shape's page, counted within its section
+ * @param top the shape's top, from the page's top edge
+ */
+ private record Carried(XWPFParagraph paragraph, int page, double top) {
+ }
+
+ /**
+ * The paragraph that carries the first of the shapes numbered {@code from} up to
+ * {@code to}: the one on the earliest page and highest on it. {@code null} where none of them
+ * has been anchored, or where the first is on a page nothing carries ({@link Leftovers}): one
+ * carried on a later page is not where they start.
+ *
+ * Word anchors a shape in a paragraph on its page and keeps it there, whatever an edit
+ * does to the text above: the paragraph's page is the shape's.
+ *
+ * @param from the number of the first shape, as {@link #drawnSoFar} read before they were drawn
+ * @param to the number after the last
+ */
+ XWPFParagraph carrierOfTheFirst(int from, int to) {
+ Carried first = null;
+ for (int number = from; number < to; number++) {
+ Carried shape = carried.get(number);
+ if (shape != null && (first == null || shape.page() < first.page()
+ || shape.page() == first.page() && shape.top() < first.top())) {
+ first = shape;
+ }
+ }
+ return first == null ? null : first.paragraph();
+ }
+
+ /** Records the paragraph a shape is anchored in: {@code null} where nothing carries it. */
+ private void carry(XWPFParagraph paragraph, Ordered shape) {
+ carried.put(shape.drawn(), new Carried(paragraph, shape.shape().page(), shape.shape().top()));
}
/**
@@ -342,6 +398,7 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes, DocxDrawings.Cell
for (Ordered shape : shapes) {
run.getCTR().addNewDrawing().set(
DocxDrawings.drawingInCell(shape.shape(), ids.getAsLong(), shape.order(), shape.drawn(), origin));
+ carry(carrier, shape);
}
}
@@ -445,6 +503,7 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(), top)
: DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(),
new DocxDrawings.CellOrigin(column, top)));
+ carry(paragraph, ordered);
}
}
@@ -751,6 +810,7 @@ private void anchor(XWPFParagraph carrier, List shapes) {
XWPFRun run = carrier.insertNewRun(0);
for (Ordered shape : shapes) {
run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape.shape(), ids.getAsLong(), shape.order(), shape.drawn()));
+ carry(carrier, shape);
}
}
}
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 a7ffa3fb3..a76ef3eae 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
@@ -918,7 +918,8 @@ 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<>();
- // The blocks that wrote no paragraph for their anchor's bookmark, in the order they were written
+ // The blocks that wrote no paragraph for their anchor's bookmark, in the order they were written:
+ // marked where their first drawing is anchored (markTheBlocksOfDrawingsAlone), or named
// (nameTheBlocksNoBookmarkMarks).
private final List blocksUnmarked = new ArrayList<>();
// How many nodes declare each anchor, a bookmark written or not (DocxAnchorDeclarations), and
@@ -1339,6 +1340,10 @@ bodyOwners, tableSeparators, cutsOpeningSections(), layout, report,
reportDrawingsLeftOver(document,
dropTheSpaceAtTheEnd(document, roomBelowTheFlow(margins.lastPageBottom()),
index == sections.size() - 1));
+ // Its drawings anchored, a block of drawings alone is marked where its first one is —
+ // before a table is split for its pages' margins, which takes the rows it moves out
+ // of the table the paragraph holding a drawing was read from.
+ markTheBlocksOfDrawingsAlone();
DocxPageMarginSections.apply(document, margins);
}
// Every bookmark is written now, those a reference on an earlier page reads included.
@@ -3772,19 +3777,19 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex
}
boolean inTheBody = currentCell == null;
int notesBefore = report.size();
+ int drawnBefore = anchors.drawnSoFar();
List written = writtenBy(document, () -> writeNodeContent(document, node));
if (keepTogether || keepWithNext) {
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).
+ // paragraph beside them — has nothing for a bookmark to go round. Its drawings find their
+ // paragraphs when the section ends, and its bookmark goes in the first one's
+ // (markTheBlocksOfDrawingsAlone).
if (panelBreak != null) {
reportAKeepThePanelBreaks(node, panelBreak, written);
}
- markOrName(node, anchor, written, notesBefore);
+ markOrName(node, anchor, written, notesBefore, drawnBefore);
}
/**
@@ -3977,19 +3982,34 @@ private boolean bookmarkAround(List written, String anchor) {
}
// Named only where it marks something: a name is kept once given, and one given here for
// nothing would push another anchor that cleans to it onto a suffix.
- String name = first == null || last == null ? null : bookmarkNames.nameFor(anchor);
- if (name == null) {
+ if (first == null || last == null || anchor == null || anchor.isBlank()) {
return false;
}
+ CTBookmark start = startBookmark(first, anchor);
+ // Read before it moves: the object moved from is left behind.
+ BigInteger id = start.getId();
+ moveToParagraphStart(first.getCTP(), start);
+ last.getCTP().addNewBookmarkEnd().setId(id);
+ return true;
+ }
+
+ /**
+ * Opens an anchor's bookmark at the end of a paragraph, under the name Word is given for the
+ * anchor, and records it: which anchors the file marks, and under what name, a page reference
+ * reads ({@link #settleThePageReferences}), and which bookmarks of one anchor there are, the
+ * last of which is kept ({@link #keepTheLastBookmarkOfEachAnchor}).
+ *
+ * @param anchor a non-blank anchor
+ */
+ private CTBookmark startBookmark(XWPFParagraph paragraph, String anchor) {
+ String name = bookmarkNames.nameFor(anchor);
int id = bookmarkNames.nextId();
- CTBookmark start = first.getCTP().addNewBookmarkStart();
+ CTBookmark start = paragraph.getCTP().addNewBookmarkStart();
start.setId(BigInteger.valueOf(id));
start.setName(name);
bookmarksWritten.putIfAbsent(anchor, name);
bookmarksOfAnchors.computeIfAbsent(anchor, key -> new ArrayList<>()).add(id);
- moveToParagraphStart(first.getCTP(), start);
- last.getCTP().addNewBookmarkEnd().setId(BigInteger.valueOf(id));
- return true;
+ return start;
}
/**
@@ -11378,8 +11398,12 @@ private record FieldReference(XWPFParagraph paragraph,
* @param notesTo how many it held once the block was written: its own note, where it has one,
* is between the two. A path is its section's own, and a later section's node, or
* a later place the same node is written, may have the same
+ * @param drawnFrom how many shapes the document had drawn before the block was written: those it
+ * drew are numbered from here ({@link DocxDrawingAnchors#drawnSoFar})
+ * @param drawnTo how many it had drawn once the block was written
*/
- private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String path, String anchor) {
+ private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String path, String anchor,
+ int drawnFrom, int drawnTo) {
}
/**
@@ -11393,15 +11417,18 @@ 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}).
+ * table for one to go round, keeps the block to be marked where its first drawing is anchored,
+ * once its section's drawings are anchored ({@link #markTheBlocksOfDrawingsAlone}), or to be
+ * named once all are written ({@link #nameTheBlocksNoBookmarkMarks}).
*
* @param notesBefore how many notes the report held before the block was written
+ * @param drawnBefore how many shapes the document had drawn before the block was written
*/
- private void markOrName(DocumentNode node, String anchor, List written, int notesBefore) {
+ private void markOrName(DocumentNode node, String anchor, List written, int notesBefore,
+ int drawnBefore) {
if (anchor != null && !bookmarkAround(written, anchor)) {
blocksUnmarked.add(new UnmarkedBlock(notesBefore, report.size(), node.nodeKind(), layout.pathOf(node),
- anchor));
+ anchor, drawnBefore, anchors.drawnSoFar()));
}
}
@@ -11432,11 +11459,48 @@ private List writtenInAnEarlierLayer(DocumentNode layer, int befor
}
/**
- * 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
- * number ({@link #settleThePageReferences}). Where another node's bookmark holds the name, a link
- * goes there, and the anchor two nodes declare is named as such
- * ({@link #keepTheLastBookmarkOfEachAnchor}).
+ * Bookmarks each block that wrote no paragraph at the start of the paragraph its first drawing
+ * is anchored in, once its section's drawings are anchored.
+ *
+ * A section, a container or a layer stack's column of drawings alone writes no paragraph for
+ * its bookmark to go round: its drawings are anchored in the paragraphs beside them, or in one
+ * written on their page ({@link DocxDrawingAnchors}), when the section ends. Word keeps a
+ * drawing on the page of the paragraph it is anchored in, and moves it with that paragraph
+ * wherever an edit takes it. So the bookmark goes at the start of the paragraph holding its
+ * first drawing — the one on the earliest page, highest on it — a place rather than a range, the
+ * paragraph's text not being the block's: a page reference reads the page the block starts on,
+ * and goes on reading it once the text above is edited, and a link lands beside the drawing.
+ * Measured in Word 16, its fields updated, and LibreOffice 24.2, on such a block on page 2 and on
+ * the same file with a page put in before it: the reference read 2, then 3, and the link went to
+ * the block's page, where the file had held no bookmark and the reference a fixed 2.
+ *
+ * Marked before the section's pages are given their margins: a table split there leaves the
+ * rows it moves to its second part out of the first, the paragraph a drawing was anchored in with
+ * them. A block marked is kept no more; one whose first drawing no paragraph carries is kept to be
+ * named once all are marked ({@link #nameTheBlocksNoBookmarkMarks}).
+ */
+ private void markTheBlocksOfDrawingsAlone() {
+ for (java.util.Iterator blocks = blocksUnmarked.iterator(); blocks.hasNext(); ) {
+ UnmarkedBlock block = blocks.next();
+ XWPFParagraph holder = anchors.carrierOfTheFirst(block.drawnFrom(), block.drawnTo());
+ if (holder != null) {
+ bookmarkAtTheStartOf(holder, block.anchor());
+ blocks.remove();
+ }
+ }
+ }
+
+ /**
+ * Names each block that wrote no paragraph and has no paragraph holding its first drawing
+ * either ({@link #markTheBlocksOfDrawingsAlone}), where no other node marks its anchor: a link
+ * to it points at none, and a page reference to it is settled as its number
+ * ({@link #settleThePageReferences}).
+ *
+ * Such a block draws nothing a paragraph carries as its own: nothing at all, a first drawing
+ * on a page no paragraph is written on, or drawings composed in a table's cell, which its table
+ * draws. Where another node's bookmark holds the name, a link goes there, and the anchor two nodes
+ * declare is named as such ({@link #keepTheLastBookmarkOfEachAnchor}): it is named once every
+ * block of drawings alone, in every section, is marked.
*
* The loss goes into the block's own note where it has one, or a note of its own after the
* others: a page reference's note is put in where it was counted, after this, and a note put in
@@ -11450,12 +11514,28 @@ private void nameTheBlocksNoBookmarkMarks() {
}
String lost = "its anchor \"" + block.anchor() + "\" has no bookmark in the Word file: a link to it "
+ "points at none, and a page reference to it is a fixed number rather than a field";
+ String why = "writes no paragraph, and no paragraph carries the first drawing of its own, so " + lost;
report.addTo(block.notesFrom(), block.notesTo(), DocxExportReport.Severity.APPROXIMATED, block.subject(),
- block.path(), "it writes no paragraph, so " + lost, "writes no paragraph, so " + lost);
+ block.path(), "it " + why, why);
}
blocksUnmarked.clear();
}
+ /**
+ * Marks the start of a paragraph with an anchor's bookmark, opened and closed there: a place
+ * rather than a range, for a block whose drawing the paragraph carries
+ * ({@link #markTheBlocksOfDrawingsAlone}).
+ */
+ private void bookmarkAtTheStartOf(XWPFParagraph holder, String anchor) {
+ CTBookmark start = startBookmark(holder, anchor);
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.CTMarkupRange end = holder.getCTP().addNewBookmarkEnd();
+ end.setId(start.getId());
+ // The end first, so the start goes in before it. Each is moved once: the object moved
+ // from is left behind.
+ moveToParagraphStart(holder.getCTP(), end);
+ moveToParagraphStart(holder.getCTP(), start);
+ }
+
/**
* Keeps one bookmark of an anchor two nodes declare: the last one's.
*
@@ -11536,8 +11616,9 @@ 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, 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
+ * names nothing, a drawn shape's, or a block that writes no paragraph and whose first drawing no
+ * paragraph carries as its own — a section that draws nothing, or one of drawings composed in a
+ * table's cell — 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
@@ -14767,17 +14848,10 @@ private XWPFRun newRun(XWPFParagraph para, DocumentLinkTarget target) {
* a link goes, and inventing a heading from it would restyle the document.
*/
private int openAnchor(XWPFParagraph para, String anchor) {
- String name = bookmarkNames.nameFor(anchor);
- if (name == null) {
+ if (anchor == null || anchor.isBlank()) {
return -1;
}
- int id = bookmarkNames.nextId();
- CTBookmark start = para.getCTP().addNewBookmarkStart();
- start.setId(BigInteger.valueOf(id));
- start.setName(name);
- bookmarksWritten.putIfAbsent(anchor, name);
- bookmarksOfAnchors.computeIfAbsent(anchor, key -> new ArrayList<>()).add(id);
- return id;
+ return startBookmark(para, anchor).getId().intValue();
}
/**
@@ -18353,6 +18427,7 @@ < availableWidth() - EDITOR_COLUMN_SLACK_POINTS - 0.5) {
}
reportWrittenWithout(node, "written as a column of its layer stack", columnLost);
reportWrittenUnderTheLayersBefore(node, plan.over(node), "column");
+ int drawnBefore = anchors.drawnSoFar();
List written = writtenBy(document,
() -> writeChildren(document, node.children(), spacingOf(node)));
// A column layer is written as what it holds, never dispatched itself: its
@@ -18363,7 +18438,7 @@ < availableWidth() - EDITOR_COLUMN_SLACK_POINTS - 0.5) {
if (anchor != null) {
List marked = writtenInAnEarlierLayer(node, movedBefore);
marked.addAll(written);
- markOrName(node, anchor, marked, notesBefore);
+ markOrName(node, anchor, marked, notesBefore, drawnBefore);
}
if (layer > 0 && blocksWritten == blocksBefore && !holdsMovedContent(node)) {
// A later layer that wrote nothing leaves the line above it raised:
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
index 0e7667b50..d13de32ee 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
@@ -21,14 +21,16 @@
import static org.assertj.core.api.Assertions.assertThat;
/**
- * A block that writes no paragraph names the anchor it declares, where no bookmark marks it.
+ * A block that writes no paragraph, and draws nothing a paragraph carries as its own, names the
+ * anchor it declares, where no bookmark marks it.
*
- * A bookmark goes round the paragraphs a block writes. A section of drawings alone writes none:
- * its drawings stand in the paragraph beside them. A link to its anchor is written all the same, to
- * a bookmark the file lacks. Measured in Word 16 and LibreOffice on Windows: the page's link went to
- * the section's place on page 1, and in both editors' PDFs the text carried no link at all, with
- * nothing in the report. A page reference to it was already named, as the number it is written
- * as.
+ * A bookmark goes round the paragraphs a block writes, or, for a block of drawings alone, at the
+ * start of the paragraph its first drawing is anchored in ({@code DocxDrawnBlockBookmarkTest}). A
+ * block that draws nothing has neither, and nor has one composed in a table's cell, whose drawings its
+ * table draws as its own. A link to its anchor is written all the same, to a bookmark the file lacks:
+ * measured in Word 16 and LibreOffice on Windows, the page's link went to the block's place, and in
+ * both editors' PDFs the text carried no link at all, with nothing in the report. A page reference to
+ * it was already named, as the number it is written as.
*
* @author Artem Demchyshyn
*/
@@ -36,53 +38,53 @@ class DocxAnchorWithoutABookmarkTest {
private static final String LOST = "its anchor \"target\" 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";
+ private static final String WHY = "writes no paragraph, and no paragraph carries the first drawing of its own, so ";
@Test
- void aSectionOfDrawingsAloneNamesItsAnchor() throws Exception {
+ void aSectionThatDrawsNothingNamesItsAnchor() throws Exception {
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
- .addSection(section -> section.anchor("target").add(drawing()))
+ .add(nothing("target"))
.addParagraph("After"));
- assertThat(notesOn(exported.report(), "SectionNode")).singleElement()
- .isEqualTo("writes no paragraph, so " + LOST);
+ assertThat(notesOn(exported.report(), "SectionNode")).singleElement().isEqualTo(WHY + LOST);
String xml = exported.document().getDocument().xmlText();
assertThat(xml).as("the link, written all the same").contains("w:anchor=\"target\"");
assertThat(xml).as("no bookmark for it to go to").doesNotContain("w:name=\"target\"");
}
@Test
- void aContainerOfDrawingsAloneNamesItsAnchor() throws Exception {
+ void aContainerThatDrawsNothingNamesItsAnchor() throws Exception {
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
- .add(new com.demcha.compose.document.node.ContainerNode("Box", List.of(drawing()), 0,
+ .add(new com.demcha.compose.document.node.ContainerNode("Box", List.of(), 0,
DocumentInsets.zero(), DocumentInsets.zero(), null, null, null, null, "target"))
.addParagraph("After"));
- assertThat(notesOn(exported.report(), "ContainerNode")).singleElement()
- .isEqualTo("writes no paragraph, so " + LOST);
+ assertThat(notesOn(exported.report(), "ContainerNode")).singleElement().isEqualTo(WHY + LOST);
}
@Test
void aSectionWithANoteOfItsOwnNamesItsAnchorThere() throws Exception {
// One node, one note: its outline entry and its anchor together.
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
- .addSection(section -> section.anchor("target").bookmark(new DocumentBookmarkOptions("Entry"))
- .add(drawing())));
+ .add(new SectionBuilder().anchor("target").bookmark(new DocumentBookmarkOptions("Entry")).build())
+ .addParagraph("After"));
assertThat(notesOn(exported.report(), "SectionNode")).singleElement()
- .isEqualTo("written as its contents; its outline entry is not written; it writes no paragraph, so "
- + LOST);
+ .isEqualTo("written as its contents; its outline entry is not written; it " + WHY + LOST);
}
@Test
void aSectionComposedInACellWithANoteOfItsOwnNamesItsAnchorThere() throws Exception {
- // Told by its path under the table, it has one note, as in the flow.
+ // Its drawing is its table's, anchored as the table's cell drawing. Told by its path under the
+ // table, it has one note, as in the flow.
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
.addTable(table -> table.autoColumns(1).rowCells(DocumentTableCell.node(new SectionBuilder()
.anchor("target").bookmark(new DocumentBookmarkOptions("Entry")).add(drawing()).build()))));
assertThat(notesOn(exported.report(), "SectionNode")).singleElement().asString()
.contains("its outline entry is not written")
- .endsWith("it writes no paragraph, so " + LOST);
+ .endsWith("it " + WHY + LOST);
+ assertThat(exported.document().getDocument().xmlText()).doesNotContain("w:name=\"target\"");
}
@Test
@@ -91,9 +93,9 @@ void aSectionInAPanelsCellNamesItsAnchor() throws Exception {
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
.addSection(panel -> panel.fillColor(DocumentColor.LIGHT_GRAY).padding(DocumentInsets.of(6))
.addParagraph("Inside")
- .add(new SectionBuilder().anchor("target").add(drawing()).build())));
+ .add(nothing("target"))));
- assertThat(notesOn(exported.report(), "SectionNode")).contains("writes no paragraph, so " + LOST);
+ assertThat(notesOn(exported.report(), "SectionNode")).contains(WHY + LOST);
}
@Test
@@ -106,7 +108,7 @@ void aSectionInATablesComposedCellHasANoteOfItsOwn() throws Exception {
new SectionBuilder().anchor("target").add(inner).build()))));
List notes = notesOn(exported.report(), "SectionNode");
- assertThat(notes).contains("writes no paragraph, so " + LOST);
+ assertThat(notes).contains(WHY + LOST);
assertThat(notes).filteredOn(note -> note.contains("outline entry")).as("the inner section's note")
.isNotEmpty()
.allSatisfy(note -> assertThat(note).as("its anchor not added").doesNotContain("\"target\""));
@@ -116,19 +118,19 @@ void aSectionInATablesComposedCellHasANoteOfItsOwn() throws Exception {
void aBlockWithNothingToMarkTakesNoBookmarkName() throws Exception {
// Names are kept once given, without case: one given to the section, written before anything
// asks for "Intro", would have put the paragraph's onto a suffix.
- Exported exported = export(page -> page.addSection(section -> section.anchor("intro").add(drawing()))
+ Exported exported = export(page -> page.add(nothing("intro"))
.addParagraph(p -> p.text("Go there").linkTo("Intro"))
.addParagraph(p -> p.text("Here").anchor("Intro")));
assertThat(exported.document().getDocument().xmlText()).contains("w:name=\"Intro\"")
- .doesNotContain("Intro_2");
+ .doesNotContainIgnoringCase("Intro_2");
}
@Test
void anAnchorAnotherNodeMarksIsNotNamedOnTheBlock() throws Exception {
// The paragraph's bookmark holds the name: a link goes there, and a page reference reads it.
Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
- .addSection(section -> section.anchor("target").add(drawing()))
+ .add(nothing("target"))
.addParagraph(p -> p.text("Here").anchor("target")));
assertThat(exported.report().bySubject()).doesNotContainKey("SectionNode");
@@ -150,10 +152,10 @@ void aSectionThatWritesAParagraphIsBookmarkedAndNamesNothing() throws Exception
void anAnchorWithNothingLinkingToItIsNamedAllTheSame() throws Exception {
// Word's own "go to" a bookmark finds none either.
Exported exported = export(page -> page.addParagraph("Before")
- .addSection(section -> section.anchor("target").add(drawing())));
+ .add(nothing("target"))
+ .addParagraph("After"));
- assertThat(notesOn(exported.report(), "SectionNode")).singleElement()
- .isEqualTo("writes no paragraph, so " + LOST);
+ assertThat(notesOn(exported.report(), "SectionNode")).singleElement().isEqualTo(WHY + LOST);
}
@Test
@@ -161,38 +163,38 @@ void aLaterSectionsNodeAtTheSamePathKeepsItsOwnNote() throws Exception {
// A path is its section's own: the second section's first block, with its outline entry, has
// the path the first section's anchored block has.
List notes = exportSections(
- page -> page.addSection(section -> section.anchor("cover").add(drawing())),
+ page -> page.add(nothing("cover")).addParagraph("Cover"),
page -> page.addSection(section -> section.bookmark(new DocumentBookmarkOptions("Entry")).add(drawing())));
assertThat(notes).filteredOn(note -> note.subject().equals("SectionNode")).extracting(DocxExportReport.Note::detail)
.containsExactlyInAnyOrder("written as its contents; its outline entry is not written",
- "writes no paragraph, so " + LOST.replace("\"target\"", "\"cover\""));
+ WHY + LOST.replace("\"target\"", "\"cover\""));
}
@Test
void blocksAtOnePathInTwoSectionsHaveANoteEach() throws Exception {
List notes = exportSections(
- page -> page.addSection(section -> section.anchor("one").add(drawing())),
- page -> page.addSection(section -> section.anchor("two").add(drawing())));
+ page -> page.add(nothing("one")).addParagraph("One"),
+ page -> page.add(nothing("two")).addParagraph("Two"));
assertThat(notes).filteredOn(note -> note.subject().equals("SectionNode")).extracting(DocxExportReport.Note::detail)
- .containsExactlyInAnyOrder("writes no paragraph, so " + LOST.replace("\"target\"", "\"one\""),
- "writes no paragraph, so " + LOST.replace("\"target\"", "\"two\""));
+ .containsExactlyInAnyOrder(WHY + LOST.replace("\"target\"", "\"one\""),
+ WHY + LOST.replace("\"target\"", "\"two\""));
}
@Test
void aPageReferenceAfterTheBlockKeepsItsNoteInItsPlace() throws Exception {
// The reference's note is put in where it was counted while writing, after the block's is
// named: a note put in among the others before it would move that place onto another note.
- Exported exported = export(page -> page.addSection(section -> section.anchor("target").add(drawing()))
+ Exported exported = export(page -> page.add(nothing("target"))
+ .add(drawing())
.addParagraph("Page:").addPageReference("target")
.addParagraph("After"));
assertThat(notesOn(exported.report(), "ShapeNode")).singleElement().asString().startsWith("drawn as a shape");
assertThat(notesOn(exported.report(), "PageReferenceNode")).singleElement().asString()
.contains("its anchor has no bookmark in the Word file");
- assertThat(notesOn(exported.report(), "SectionNode")).singleElement()
- .isEqualTo("writes no paragraph, so " + LOST);
+ assertThat(notesOn(exported.report(), "SectionNode")).singleElement().isEqualTo(WHY + LOST);
assertThat(DocxExports.besidesTheExactLines(exported.report())).extracting(DocxExportReport.Note::subject)
.as("each where its node was written, the block's own after them")
.containsExactly("ShapeNode", "PageReferenceNode", "SectionNode");
@@ -214,6 +216,11 @@ private static List exportSections(ConsumerA section or a container holding nothing but drawings writes no paragraph for a bookmark to go
+ * round: its drawings are anchored in the paragraphs beside them, or in one written on their page,
+ * when the section ends. 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. Word keeps a drawing on the
+ * page of the paragraph it is anchored in, and moves it with that paragraph. Measured in Word 16
+ * (fields updated) and LibreOffice 24.2, on such a block 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 in the
+ * paragraph holding its first drawing, both show 2 and then 3, and the link goes to the block's page
+ * — a section, a container, one opening its page, one alone on it, one at a page's foot, one in a
+ * panel and an anchor named with spaces alike.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxDrawnBlockBookmarkTest {
+
+ @Test
+ void aSectionOfDrawingsAloneIsMarkedWhereItsFirstDrawingIsAnchored() throws Exception {
+ // Its shape, the higher of its drawings, stands beside the paragraph above it; its circle,
+ // beside the one under it.
+ Exported exported = export(page -> {
+ referTo("drawn").accept(page);
+ page.addPageBreak(b -> { });
+ lines(page, "Top of page two", 3);
+ page.add(drawings("drawn"));
+ page.addParagraph("After the drawings");
+ });
+
+ XWPFParagraph holder = placeMarked(exported.document(), "drawn");
+ assertThat(holder.getText()).isEqualTo("Top of page two 3");
+ assertThat(drawingsIn(holder)).as("it carries the block's first drawing").isPositive();
+ assertThat(placeMarksIn(paragraphWith(exported.document(), "After the drawings")))
+ .as("the paragraph carrying its circle holds no mark").isEmpty();
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
+ assertThat(internalLinks(exported.document())).containsExactly("drawn");
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aContainerOfDrawingsAloneIsMarkedTheSameWay() throws Exception {
+ Exported exported = export(page -> {
+ referTo("box").accept(page);
+ page.addPageBreak(b -> { });
+ lines(page, "Top of page two", 3);
+ page.add(new ContainerNode("Box", List.of(drawings(null)), 0, DocumentInsets.zero(),
+ DocumentInsets.zero(), null, null, null, null, "box"));
+ page.addParagraph("After the drawings");
+ });
+
+ assertThat(placeMarked(exported.document(), "box").getText()).isEqualTo("Top of page two 3");
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF box \\h", "2"));
+ assertThat(internalLinks(exported.document())).containsExactly("box");
+ assertThat(exported.report().notes()).noneMatch(note -> note.detail().contains("bookmark"));
+ }
+
+ @Test
+ void aBlockCarriedByARowATableIsSplitAtForItsPagesMarginsIsMarkedInThatRow() throws Exception {
+ // Page 2 has other margins, so the table is split where page 2 starts. The block's shape stands
+ // at page 2's foot, which no paragraph of the body is written on: a row of the table on page 2
+ // carries it, and that row goes to the second part. Marked once the document was written, the
+ // row's paragraph was the first part's, taken out of the file by then: the export threw.
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 300).margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(PageMarginRule.from(2, new DocumentInsets(45, 20, 34, 20))));
+ session.pageFlow(page -> {
+ page.addParagraph("See page").addPageReference("drawn");
+ page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200));
+ for (int row = 1; row <= 19; row++) {
+ table.rowCells(DocumentTableCell.text("Row " + row));
+ }
+ });
+ page.add(drawings("drawn"));
+ page.addParagraph("After the drawings");
+ });
+ assertThat(session.layoutGraph().nodes()).as("the premise: its shape at page 2's foot, its circle on 3")
+ .filteredOn(node -> node.path().endsWith("/SectionNode[3]"))
+ .singleElement()
+ .satisfies(node -> assertThat(new int[]{node.startPage(), node.endPage()}).containsExactly(1, 2));
+ docx = session.export(new DocxSemanticBackend());
+ }
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+
+ XWPFParagraph holder = placeMarked(document, "drawn");
+ assertThat(holder.getBody()).as("in a row of the table").isInstanceOf(XWPFTableCell.class);
+ assertThat(document.getTables()).as("the premise: the table split in two").hasSize(2);
+ assertThat(((XWPFTableCell) holder.getBody()).getTableRow().getTable())
+ .as("the row went to the second part").isSameAs(document.getTables().get(1));
+ assertThat(pageReferences(document)).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
+ }
+
+ @Test
+ void aBlocksNameIsGivenAfterTheBodyNamedItsAnchors() throws Exception {
+ // Word reads a bookmark's name without case. The block's bookmark is written once its section's
+ // drawings are anchored, after the paragraph's: the paragraph keeps the name its anchor cleans to.
+ Exported exported = export(page -> {
+ lines(page, "Top", 1);
+ page.add(drawings("intro"));
+ page.addParagraph(p -> p.text("Here").anchor("Intro"));
+ page.addParagraph(p -> p.text("Go there").linkTo("Intro"));
+ });
+
+ assertThat(paragraphWith(exported.document(), "Here").getCTP().getBookmarkStartList())
+ .extracting(CTBookmark::getName).containsExactly("Intro");
+ assertThat(placeMarked(exported.document(), "intro_2").getText()).isEqualTo("Top 1");
+ assertThat(internalLinks(exported.document())).containsExactly("Intro");
+ }
+
+ @Test
+ void aBlockRunningOntoTheNextPageIsMarkedOnThePageItStartsOn() throws Exception {
+ // The layout starts it at the foot of page 1, its shape there and its circle on page 2.
+ Exported exported = export(page -> {
+ referTo("drawn").accept(page);
+ lines(page, "Filler", 27);
+ page.add(drawings("drawn"));
+ lines(page, "Next page", 2);
+ });
+
+ assertThat(placeMarked(exported.document(), "drawn").getText()).isEqualTo("Filler 27");
+ assertThat(drawingsIn(paragraphWith(exported.document(), "Next page 1")))
+ .as("the premise: its circle is on page 2").isPositive();
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF drawn \\h", "1"));
+ }
+
+ @Test
+ void aBlockOpeningItsPageIsMarkedInTheParagraphUnderIt() throws Exception {
+ Exported exported = export(page -> {
+ referTo("drawn").accept(page);
+ page.addPageBreak(b -> { });
+ page.add(drawings("drawn"));
+ page.addParagraph("After the drawings");
+ });
+
+ assertThat(placeMarked(exported.document(), "drawn").getText()).isEqualTo("After the drawings");
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
+ }
+
+ @Test
+ void aBlockAloneOnItsPageIsMarkedInTheParagraphClosingTheSection() throws Exception {
+ // No paragraph is written on its page: the one closing the section carries its drawings.
+ Exported exported = export(page -> {
+ referTo("drawn").accept(page);
+ page.addPageBreak(b -> { });
+ page.add(drawings("drawn"));
+ });
+
+ List body = exported.document().getBodyElements();
+ XWPFParagraph holder = placeMarked(exported.document(), "drawn");
+ assertThat(holder.getText()).isEmpty();
+ assertThat(body.get(body.size() - 1)).as("the section's closing paragraph").isSameAs(holder);
+ assertThat(drawingsIn(holder)).isEqualTo(2);
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
+ }
+
+ @Test
+ void aBlockInAPanelIsMarkedInThePanelsCell() throws Exception {
+ Exported exported = export(page -> {
+ referTo("inpanel").accept(page);
+ page.addSection(panel -> panel.fillColor(DocumentColor.rgb(238, 242, 247)).padding(DocumentInsets.of(10))
+ .addParagraph("Inside the panel")
+ .add(drawings("inpanel"))
+ .addParagraph("Under the drawings"));
+ });
+
+ XWPFParagraph holder = placeMarked(exported.document(), "inpanel");
+ assertThat(holder.getBody()).as("in the panel's cell").isInstanceOf(XWPFTableCell.class);
+ assertThat(holder.getText()).isEqualTo("Inside the panel");
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF inpanel \\h", "1"));
+ }
+
+ @Test
+ void anAnchorNamedWithSpacesIsOneNameForItsBookmarkItsLinkAndItsReference() throws Exception {
+ // Word keeps 40 characters of a name, letters, digits and underscores: the name is made once
+ // for the anchor, so the field and the link name the bookmark it is written under.
+ String spaced = "Terms and conditions of the agreement between both parties";
+ String name = "Terms_and_conditions_of_the_agreement_be";
+ for (boolean drawn : new boolean[]{true, false}) {
+ Exported exported = export(page -> {
+ referTo(spaced).accept(page);
+ page.add(drawn ? drawings(spaced)
+ : new SectionBuilder().anchor(spaced).addParagraph("Anchored text").build());
+ });
+
+ assertThat(name).hasSize(40);
+ assertThat(bookmarkNames(exported.document())).as(drawn ? "drawings alone" : "text").containsExactly(name);
+ assertThat(internalLinks(exported.document())).containsExactly(name);
+ assertThat(pageReferences(exported.document())).containsExactly(Map.entry("PAGEREF " + name + " \\h", "1"));
+ }
+ }
+
+ @Test
+ void twoBlocksMarkedInOneParagraphKeepAMarkEach() throws Exception {
+ // Both open their page, and the paragraph under them carries the drawings of each.
+ Exported exported = export(page -> {
+ page.addParagraph("See page").addPageReference("one").addPageReference("two");
+ page.addPageBreak(b -> { });
+ page.add(drawings("one"));
+ page.add(drawings("two"));
+ page.addParagraph("After the drawings");
+ });
+
+ XWPFParagraph holder = paragraphWith(exported.document(), "After the drawings");
+ assertThat(placeMarksIn(holder)).containsExactlyInAnyOrder("one", "two");
+ assertThat(pageReferences(exported.document())).containsExactly(
+ Map.entry("PAGEREF one \\h", "2"), Map.entry("PAGEREF two \\h", "2"));
+ }
+
+ @Test
+ void anotherNodeDeclaringTheAnchorAfterTheBlockKeepsTheBookmark() throws Exception {
+ // A link to an anchor two nodes declare goes, on the page, to the last; the file keeps the
+ // last bookmark it holds of the name, the paragraph's, and names the anchor.
+ Exported exported = export(page -> {
+ lines(page, "Top", 1);
+ page.add(drawings("target"));
+ page.addParagraph(p -> p.text("Here").anchor("target"));
+ });
+
+ assertThat(bookmarkNames(exported.document())).containsExactly("target");
+ assertThat(paragraphWith(exported.document(), "Here").getCTP().getBookmarkStartList())
+ .extracting(CTBookmark::getName).containsExactly("target");
+ assertThat(placeMarksIn(paragraphWith(exported.document(), "Top 1"))).as("the block's taken out").isEmpty();
+ assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("anchor")).singleElement()
+ .satisfies(note -> assertThat(note.detail()).startsWith("\"target\" is the anchor of 2 nodes"));
+ }
+
+ @Test
+ void aBlockThatDrawsNothingIsNotNamedWhereALaterBlockOfDrawingsMarksItsAnchor() throws Exception {
+ // The empty one is written first; whether the anchor is marked is known once every block of
+ // drawings has its mark.
+ Exported exported = export(page -> {
+ lines(page, "Top", 1);
+ page.add(new SectionBuilder().anchor("target").build());
+ page.add(drawings("target"));
+ page.addParagraph("After the drawings");
+ });
+
+ assertThat(bookmarkNames(exported.document())).containsExactly("target");
+ assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("SectionNode")).isEmpty();
+ }
+
+ private static DocumentNode drawings(String anchor) {
+ SectionBuilder section = new SectionBuilder()
+ .addShape(120, 30, DocumentColor.rgb(26, 86, 148))
+ .addCircle(24, DocumentColor.rgb(200, 60, 60));
+ if (anchor != null) {
+ section.anchor(anchor);
+ }
+ return section.build();
+ }
+
+ private static Consumer referTo(String anchor) {
+ return page -> page
+ .addParagraph(p -> p.text("See page"))
+ .addPageReference(anchor)
+ .addParagraph(p -> p.text("Go to the anchor").linkTo(anchor));
+ }
+
+ private static void lines(PageFlowBuilder page, String prefix, int count) {
+ for (int line = 1; line <= count; line++) {
+ page.addParagraph(prefix + " " + line);
+ }
+ }
+
+ 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());
+ }
+
+ /** The paragraph whose place marks hold the bookmark of a name, which must be there and only there. */
+ private static XWPFParagraph placeMarked(XWPFDocument document, String name) {
+ List holders = paragraphs(document).stream()
+ .filter(paragraph -> placeMarksIn(paragraph).contains(name)).toList();
+ assertThat(holders).as("one paragraph marked " + name).hasSize(1);
+ assertThat(bookmarkNames(document)).as("no other bookmark of the name").containsOnlyOnce(name);
+ return holders.get(0);
+ }
+
+ /**
+ * The names of the bookmarks a paragraph opens with that mark a place: each start followed by its
+ * own end, before any of the paragraph's runs.
+ */
+ private static List placeMarksIn(XWPFParagraph paragraph) {
+ List names = new ArrayList<>();
+ try (XmlCursor cursor = paragraph.getCTP().newCursor()) {
+ if (!cursor.toFirstChild()) {
+ return names;
+ }
+ do {
+ String element = cursor.getName().getLocalPart();
+ if (element.equals("pPr")) {
+ continue;
+ }
+ if (!element.equals("bookmarkStart")) {
+ break;
+ }
+ CTBookmark start = (CTBookmark) cursor.getObject();
+ String name = start.getName();
+ java.math.BigInteger id = start.getId();
+ if (!cursor.toNextSibling() || !cursor.getName().getLocalPart().equals("bookmarkEnd")
+ || !id.equals(((CTMarkupRange) cursor.getObject()).getId())) {
+ break;
+ }
+ names.add(name);
+ } while (cursor.toNextSibling());
+ }
+ return names;
+ }
+
+ private static int drawingsIn(XWPFParagraph paragraph) {
+ return paragraph.getCTP().xmlText().split(" paragraph.getText().equals(text)).findFirst()
+ .orElseThrow(() -> new AssertionError("no paragraph " + text));
+ }
+
+ 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/DocxLayerColumnBookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnBookmarkTest.java
index 86c1f039e..9999a4045 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
@@ -213,36 +213,36 @@ void anUnanchoredColumnWritesNoBookmark() throws Exception {
}
@Test
- void aColumnOfDrawingsAloneIsNamedAndAReferenceToItStaysItsNumber() throws Exception {
- // Its drawing stands in the paragraph beside it: the column writes none to put a bookmark round.
+ void aColumnOfDrawingsAloneIsBookmarkedWhereItsDrawingIsAnchored() throws Exception {
+ // Its drawing stands in the paragraph beside it: the column writes none to put a bookmark round,
+ // and its bookmark is a place at the start of the paragraph its drawing is anchored in.
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");
+ Marked marked = marked(exported.document(), "drawn");
+ assertThat(marked.start()).as("opened and closed in one paragraph").isSameAs(marked.end());
+ assertThat(marked.start().getCTP().xmlText()).as("the paragraph carries the column's drawing")
+ .contains(" note.detail().contains("bookmark"));
}
@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"))));
+ // The column has a note of its own already — its outline entry — and the anchor joins it. It
+ // draws nothing either: no paragraph carries a drawing of it.
+ DocumentNode empty = new SectionBuilder().anchor("empty").bookmark(new DocumentBookmarkOptions("Empty"))
+ .margin(LEFT_BAND).build();
+ Exported exported = export(page -> page.add(columns(empty, column(null, RIGHT_BAND, "Right one"))));
+ assertThat(bookmarkNames(exported.document())).isEmpty();
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");
+ + "stack; its outline entry is not written; it writes no paragraph, and no paragraph carries the "
+ + "first drawing of its own, so its anchor \"empty\" 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
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 96bcc501b..e261916b4 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,11 @@ 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: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",
+ "anchor:REPORTED:where it writes no paragraph and no paragraph carries the first drawing of its "
+ + "own — it draws nothing, its first drawing stands on a page nothing is written on, or it is "
+ + "composed in a table's cell — which has no bookmark; otherwise, in the flow or as a layer "
+ + "stack's column, it is bookmarked round what it writes, or, of drawings alone, where its first "
+ + "drawing is anchored",
"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 +479,11 @@ 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: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",
+ "anchor:REPORTED:where it writes no paragraph and no paragraph carries the first drawing of its "
+ + "own — it draws nothing, its first drawing stands on a page nothing is written on, or it is "
+ + "composed in a table's cell — which has no bookmark; otherwise, in the flow or as a layer "
+ + "stack's column, it is bookmarked round what it writes, or, of drawings alone, where its first "
+ + "drawing is anchored",
"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/DocxPanelOverPagesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
index 309ca1702..0cae93531 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelOverPagesTest.java
@@ -508,8 +508,8 @@ void aCardCutNowhereLeavesThePagesItStartsInsideNamed() throws Exception {
@Test
void aPageReferenceSettledAsItsNumberLetsItsPageBeCutWhereTheMarginsDoNotVary() throws Exception {
- // Its anchor marks a block of drawing only, which gets no bookmark: once the document is written
- // the field is taken back to its number, and the cut, decided then, sees no field in the rows.
+ // Its anchor is a drawn shape's, which gets no bookmark: once the document is written the field
+ // is taken back to its number, and the cut, decided then, sees no field in the rows.
Exported exported = export(PAGE_HEIGHT, List.of(), cardReferringToADrawing());
assertThat(exported.pages()).as("the premise: three pages").hasSize(3);
@@ -574,13 +574,13 @@ void aPanelOfASectionCountingItsOwnPagesIsDecidedOnceTheDocumentIsWritten() thro
}
/**
- * A card whose table's row 15 holds a page reference to the anchor of a block of drawing only,
- * which the export writes no bookmark for.
+ * A card whose table's row 15 holds a page reference to a drawn shape's own anchor, which the
+ * export writes no bookmark for.
*/
private static Consumer cardReferringToADrawing() {
return page -> page
- .addSection(drawn -> drawn.anchor("drawn").add(new com.demcha.compose.document.dsl.ShapeBuilder()
- .size(120, 20).fillColor(DocumentColor.LIGHT_GRAY).build()))
+ .add(new com.demcha.compose.document.dsl.ShapeBuilder()
+ .size(120, 20).fillColor(DocumentColor.LIGHT_GRAY).anchor("drawn").build())
.addSection(card -> card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1))
.padding(DocumentInsets.of(6))
.addTable(table -> {
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 44d047423..a320cfbe1 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,8 +169,9 @@ 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 or a block writing no paragraph, named on that "
- + "node; an anchor several nodes declare or a repeated header row holds, named");
+ + "none, drawn, in a page zone or a block writing no paragraph whose first drawing no "
+ + "paragraph carries as its own, 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; "
+ "where an item stands against the page's column is the list's note",
From 55792764ea2fe3bc88a9512c9fb8d9deae2fa4e8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 26 Oct 2026 12:36:38 +0000
Subject: [PATCH 2/3] fix(docx): leave a header row's bookmark out of the copy
a table split makes
A table is split for its pages' margins, or a panel cut at a page, under a
copy of its header rows. The split is refused where a header row holds a
bookmark when it is decided, but a block of drawings alone is now marked
after that, in the paragraph holding its first drawing, before the split is
made. A bookmark copied with its name and id would leave two of one id, and
keeping the last bookmark of each name would take both out, leaving a page
reference on a bookmark the file lacks.
DocxTableSplit.split and splitInItsPanel now copy the header rows without
their bookmark starts and ends, as a panel's cut already copied them
without their drawings; the first part keeps the bookmark, on the table's
first page.
Also: an end-to-end test of a block whose first drawing fills a page
nothing is written on, which is named rather than marked on a later page,
and of a block above a table split for its pages' margins, marked once.
---
docs/recipes/docx-export.md | 5 +-
.../semantic/docx/DocxDrawingAnchors.java | 4 ++
.../backend/semantic/docx/DocxTableSplit.java | 24 ++++++--
.../docx/DocxAnchorWithoutABookmarkTest.java | 17 ++++++
.../docx/DocxDrawnBlockBookmarkTest.java | 38 ++++++++++++
.../semantic/docx/DocxTableSplitTest.java | 59 +++++++++++++++++++
6 files changed, 141 insertions(+), 6 deletions(-)
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index e0107f818..43a4e5a83 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -224,7 +224,10 @@ A page that starts inside any other block — the lines of a paragraph, a panel
does not start a section. Nor does a page that starts at a table's row where a cell spans rows
across it, where
only the header rows stand above it, or where a header row holds a bookmark or a drawing, which
-the file can hold only once, or a numbered list item, which its copy would number on. Nor where a header row or a row from that one on holds a page
+the file can hold only once, or a numbered list item, which its copy would number on. A bookmark
+the export puts in a header row once the split is decided — a block of drawings alone, marked in
+the paragraph holding its first drawing — stays in the first part, and the copy of the row leaves
+it out. Nor where a header row or a row from that one on holds a page
reference, which the export settles in the paragraph it wrote it in once the body is written. A
page after one whose content reaches its foot, under a table or in its rows, starts none either,
as the line that would end the section has no room. Neither does a page of a
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
index 00c195cae..558a1561b 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
@@ -164,6 +164,10 @@ private record Carried(XWPFParagraph paragraph, int page, double top) {
* Word anchors a shape in a paragraph on its page and keeps it there, whatever an edit
* does to the text above: the paragraph's page is the shape's.
*
+ * The paragraph is the one the shape was anchored in, as its section's end found it: asked
+ * then, before the section's pages are given their margins, it is in the file. A table split
+ * for those margins later takes the rows it moves out of the part it read them from.
+ *
* @param from the number of the first shape, as {@link #drawnSoFar} read before they were drawn
* @param to the number after the last
*/
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
index 63b69b031..9760f585b 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplit.java
@@ -38,6 +38,11 @@ final class DocxTableSplit {
// (splitInItsPanel).
private static final List DRAWINGS = List.of("drawing", "pict", "object");
+ // A bookmark, whose name marks one place: a copy of the header rows leaves it out (split,
+ // splitInItsPanel), its end with it. A bookmark that closes in a header row opened in one: a
+ // block holding the table closes at its last row or after it.
+ private static final List BOOKMARKS = List.of("bookmarkStart", "bookmarkEnd");
+
// What a header row's copy cannot hold again: a bookmark's name marks one place, a drawing's id
// names one drawing, and a list item's number is counted on from the item before it.
private static final List NOT_REPEATED =
@@ -103,6 +108,12 @@ static int headerRows(XWPFTable table) {
* table of their own after it, under copies of its header rows, and a paragraph is put between
* the two.
*
+ * The copy of the header rows holds no bookmark. Where the split is decided, they hold none
+ * ({@link #rowStarting}); one the export puts in them after that, at their section's end — a
+ * block of drawings alone, marked in the paragraph its first drawing is anchored in — marks the
+ * table's first page, and stays in the first part. Copied with its name and its id, both copies
+ * went when the export kept one bookmark of each name.
+ *
* @param document the document the table is in the body of
* @param table the table
* @param row the row the second part starts with, after the header rows
@@ -112,6 +123,7 @@ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) {
XWPFTable second = copyAfter(document, table);
keepTheRowsBefore(table, row);
keepTheRowsFrom(second, row);
+ drop(second, headerRows(second), BOOKMARKS);
try (XmlCursor before = second.getCTTbl().newCursor()) {
return document.insertNewParagraph(before);
}
@@ -130,7 +142,8 @@ static XWPFParagraph split(XWPFDocument document, XWPFTable table, int row) {
* ({@link #rowStarting}); one the export anchors in them after that, at their section's end — a
* shape of the table's first page standing beside a header's text, or one a page with no other
* paragraph to carry it leaves in its first cell's — is that page's, and stays in the first
- * part.
+ * part. So does a bookmark the export puts there, for a block of drawings alone, as where a
+ * table of the body is split ({@link #split}).
*
* @param document the document the panel is in the body of
* @param panel the panel
@@ -157,7 +170,8 @@ static PanelPart splitInItsPanel(XWPFDocument document, XWPFTable panel, XWPFTab
remove(continued, after.get(index));
}
keepTheRowsFrom(rest, row);
- dropTheDrawings(rest, headerRows(rest));
+ drop(rest, headerRows(rest), DRAWINGS);
+ drop(rest, headerRows(rest), BOOKMARKS);
try (XmlCursor before = second.getCTTbl().newCursor()) {
XWPFParagraph between = document.insertNewParagraph(before);
between.setPageBreak(true);
@@ -206,10 +220,10 @@ private static void keepTheRowsFrom(XWPFTable table, int row) {
}
}
- /** Takes every drawing out of a table's first rows. */
- private static void dropTheDrawings(XWPFTable table, int rows) {
+ /** Takes every element of the given names out of a table's first rows. */
+ private static void drop(XWPFTable table, int rows, List elements) {
for (int row = 0; row < rows; row++) {
- for (String element : DRAWINGS) {
+ for (String element : elements) {
for (org.apache.xmlbeans.XmlObject found : table.getRow(row).getCtRow().selectPath(W + ".//w:" + element)) {
try (XmlCursor cursor = found.newCursor()) {
cursor.removeXml();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
index d13de32ee..68cfcb0f9 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
@@ -62,6 +62,23 @@ void aContainerThatDrawsNothingNamesItsAnchor() throws Exception {
assertThat(notesOn(exported.report(), "ContainerNode")).singleElement().isEqualTo(WHY + LOST);
}
+ @Test
+ void aBlockWhoseFirstDrawingNothingCarriesNamesItsAnchor() throws Exception {
+ // Its tall shape fills page 2, which no paragraph is written on; its circle goes to page 3,
+ // under the paragraph there. Marked there, a reference would read 3 where the page prints 2.
+ Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target"))
+ .addPageBreak(b -> { })
+ .add(new SectionBuilder().anchor("target")
+ .addShape(120, 550, DocumentColor.LIGHT_GRAY)
+ .addCircle(24, DocumentColor.LIGHT_GRAY).build())
+ .addParagraph("After"));
+
+ assertThat(exported.report().notes()).as("the premise: its shape on a page nothing carries")
+ .anyMatch(note -> note.severity() == DocxExportReport.Severity.DROPPED);
+ assertThat(exported.document().getDocument().xmlText()).doesNotContain("w:name=\"target\"");
+ assertThat(notesOn(exported.report(), "SectionNode")).singleElement().isEqualTo(WHY + LOST);
+ }
+
@Test
void aSectionWithANoteOfItsOwnNamesItsAnchorThere() throws Exception {
// One node, one note: its outline entry and its anchor together.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
index 2bb3a6606..1b4376e9a 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
@@ -132,6 +132,44 @@ void aBlockCarriedByARowATableIsSplitAtForItsPagesMarginsIsMarkedInThatRow() thr
assertThat(pageReferences(document)).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
}
+ @Test
+ void aBlockAboveATableSplitForItsPagesMarginsIsMarkedOnce() throws Exception {
+ // The block opens page 2 above a table with a repeated header row; page 3 has other margins,
+ // so the table is split where it starts, under a copy of the header row. The paragraph holding
+ // the block's room above the table carries its drawings and its bookmark, written once.
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 300).margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageMargins(List.of(PageMarginRule.from(3, new DocumentInsets(45, 20, 34, 20))));
+ session.pageFlow(page -> {
+ referTo("drawn").accept(page);
+ page.addPageBreak(b -> { });
+ page.add(drawings("drawn"));
+ page.addTable(table -> {
+ table.columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(100))
+ .header("Item", "Amount").repeatHeader();
+ for (int row = 1; row <= 20; row++) {
+ table.rowCells(DocumentTableCell.text("Row " + row), DocumentTableCell.text(String.valueOf(row)));
+ }
+ });
+ page.addParagraph("After the table");
+ });
+ docx = session.export(new DocxSemanticBackend());
+ }
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+
+ assertThat(document.getTables()).as("the premise: the table split where page 3 starts").hasSize(2);
+ XWPFParagraph holder = placeMarked(document, "drawn");
+ List body = document.getBodyElements();
+ assertThat(body.get(body.indexOf(holder) + 1)).as("the paragraph right above the table")
+ .isSameAs(document.getTables().get(0));
+ assertThat(drawingsIn(holder)).isEqualTo(2);
+ for (XWPFTable part : document.getTables()) {
+ assertThat(part.getRow(0).getCtRow().xmlText()).as("each part's header row").doesNotContain("bookmark");
+ }
+ assertThat(pageReferences(document)).containsExactly(Map.entry("PAGEREF drawn \\h", "2"));
+ }
+
@Test
void aBlocksNameIsGivenAfterTheBodyNamedItsAnchors() throws Exception {
// Word reads a bookmark's name without case. The block's bookmark is written once its section's
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
index 606cb9267..e3754e4ba 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableSplitTest.java
@@ -123,6 +123,65 @@ void aPanelsPartAfterACutCopiesItsHeaderRowsWithoutTheirDrawings() throws Except
}
}
+ @Test
+ void aTablesPartAfterASplitCopiesItsHeaderRowsWithoutTheirBookmarks() throws Exception {
+ // A block of drawings alone the export marks in a header row once a split is decided marks the
+ // table's first page: the first part keeps it, and the copy of the row has none. Copied with its
+ // id, both copies went when the export kept the last bookmark of the name.
+ try (XWPFDocument document = new XWPFDocument()) {
+ XWPFTable table = table(document, 1, 4);
+ mark(table.getRow(0));
+
+ DocxTableSplit.split(document, table, 3);
+
+ XWPFTable second = document.getTables().get(1);
+ assertThat(texts(second)).containsExactly("Header", "Row 3", "Row 4");
+ assertThat(bookmarks(second.getRow(0))).as("the copy of the header row").isZero();
+ assertThat(bookmarks(table.getRow(0))).as("the first part's header row, its start and its end")
+ .isEqualTo(2);
+ }
+ }
+
+ @Test
+ void aPanelsPartAfterACutCopiesItsHeaderRowsWithoutTheirBookmarks() throws Exception {
+ try (XWPFDocument written = new XWPFDocument()) {
+ XWPFTable items = table(written, 1, 4);
+ mark(items.getRow(0));
+ XWPFTable outer = written.createTable(1, 1);
+ try (XmlCursor at = outer.getRow(0).getCell(0).getParagraphArray(0).getCTP().newCursor();
+ XmlCursor table = items.getCTTbl().newCursor()) {
+ table.moveXml(at);
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(bytes(written)))) {
+ XWPFTable panel = document.getTables().get(0);
+ XWPFTable inner = panel.getRow(0).getCell(0).getTables().get(0);
+
+ DocxTableSplit.PanelPart part = DocxTableSplit.splitInItsPanel(document, panel, inner, 3);
+
+ XWPFTable rest = part.second().getRow(0).getCell(0).getTables().get(0);
+ assertThat(bookmarks(rest.getRow(0))).as("the copy of the header row").isZero();
+ assertThat(bookmarks(inner.getRow(0))).as("the first part's header row").isEqualTo(2);
+ }
+ }
+ }
+
+ /** Marks a row's first paragraph with a bookmark opened and closed at its start. */
+ private static void mark(XWPFTableRow row) {
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP paragraph =
+ row.getCell(0).getParagraphs().get(0).getCTP();
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark start = paragraph.addNewBookmarkStart();
+ start.setId(java.math.BigInteger.valueOf(7));
+ start.setName("drawn");
+ paragraph.addNewBookmarkEnd().setId(java.math.BigInteger.valueOf(7));
+ }
+
+ /** How many bookmark starts and ends a row holds. */
+ private static int bookmarks(XWPFTableRow row) {
+ String w = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' ";
+ return row.getCtRow().selectPath(w + ".//w:bookmarkStart").length
+ + row.getCtRow().selectPath(w + ".//w:bookmarkEnd").length;
+ }
+
private static int drawings(XWPFTableRow row) {
return row.getCtRow().selectPath(
"declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' .//w:drawing").length;
From 98b41b2b20135599aabb2374c2fa2d5b164335a6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 26 Oct 2026 12:57:16 +0000
Subject: [PATCH 3/3] test(docx): pin that a block is named after every
section's blocks are marked
A block that writes no paragraph and draws nothing is named only where no
other node marks its anchor, which is known once every section's blocks of
drawings alone are marked. The test pinning it held one section; a second
holds the empty block in the first section and the block of drawings in the
second.
The Javadoc, the test class's and the recipe now say which block is named:
one whose first drawing no paragraph carries as its own. A block whose first
drawing stands on a page nothing is written on may have a later drawing
carried, and is named all the same.
---
docs/recipes/docx-export.md | 6 ++---
.../semantic/docx/DocxSemanticBackend.java | 6 ++---
.../docx/DocxAnchorWithoutABookmarkTest.java | 9 ++++----
.../docx/DocxDrawnBlockBookmarkTest.java | 23 +++++++++++++++++++
4 files changed, 34 insertions(+), 10 deletions(-)
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 43a4e5a83..92ee1e311 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -126,9 +126,9 @@ The export does not set `w:updateFields`. It would make Word ask, on every open,
update fields — to recompute numbers that already read correctly.
A page reference to an anchor the export writes no bookmark for — a drawn shape's, or a
-block's that writes no paragraph and draws nothing a paragraph carries as its own, as a
-section that draws nothing or one composed in a table's cell (see the links and anchors row
-above) — is written as the text the page prints, a number no edit updates, and its report note
+block's that writes no paragraph and whose first drawing no paragraph carries as its own, as a
+section that draws nothing, one whose first drawing stands on a page nothing is written on, or
+one composed in a table's cell (see the links and anchors row above) — is written as the text the page prints, a number no edit updates, and its report note
says so: Word turns a `PAGEREF` to a missing bookmark into "Error! Bookmark not defined." the
first time it updates. Each field is named after the bookmark its anchor was written under,
or taken back to its text, once the body is written, so a reference before its anchor is
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 a76ef3eae..c1617a97f 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
@@ -11496,9 +11496,9 @@ private void markTheBlocksOfDrawingsAlone() {
* to it points at none, and a page reference to it is settled as its number
* ({@link #settleThePageReferences}).
*
- * Such a block draws nothing a paragraph carries as its own: nothing at all, a first drawing
- * on a page no paragraph is written on, or drawings composed in a table's cell, which its table
- * draws. Where another node's bookmark holds the name, a link goes there, and the anchor two nodes
+ *
No paragraph carries such a block's first drawing as its own: it draws nothing at all, its
+ * first drawing stands on a page no paragraph is written on, or its drawings are composed in a
+ * table's cell, which its table draws. Where another node's bookmark holds the name, a link goes there, and the anchor two nodes
* declare is named as such ({@link #keepTheLastBookmarkOfEachAnchor}): it is named once every
* block of drawings alone, in every section, is marked.
*
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
index 68cfcb0f9..85e341112 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
@@ -21,13 +21,14 @@
import static org.assertj.core.api.Assertions.assertThat;
/**
- * A block that writes no paragraph, and draws nothing a paragraph carries as its own, names the
- * anchor it declares, where no bookmark marks it.
+ * A block that writes no paragraph, and whose first drawing no paragraph carries as its own, names
+ * the anchor it declares, where no bookmark marks it.
*
* A bookmark goes round the paragraphs a block writes, or, for a block of drawings alone, at the
* start of the paragraph its first drawing is anchored in ({@code DocxDrawnBlockBookmarkTest}). A
- * block that draws nothing has neither, and nor has one composed in a table's cell, whose drawings its
- * table draws as its own. A link to its anchor is written all the same, to a bookmark the file lacks:
+ * block that draws nothing has neither, nor has one whose first drawing stands on a page nothing is
+ * written on, nor one composed in a table's cell, whose drawings its table draws as its own. A link
+ * to its anchor is written all the same, to a bookmark the file lacks:
* measured in Word 16 and LibreOffice on Windows, the page's link went to the block's place, and in
* both editors' PDFs the text carried no link at all, with nothing in the report. A page reference to
* it was already named, as the number it is written as.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
index 1b4376e9a..a19a82324 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawnBlockBookmarkTest.java
@@ -319,6 +319,29 @@ void aBlockThatDrawsNothingIsNotNamedWhereALaterBlockOfDrawingsMarksItsAnchor()
assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("SectionNode")).isEmpty();
}
+ @Test
+ void aBlockThatDrawsNothingIsNotNamedWhereALaterSectionsBlockOfDrawingsMarksItsAnchor() throws Exception {
+ // Named once every section's blocks of drawings alone are marked, not at its own section's end.
+ List notes = new ArrayList<>();
+ byte[] docx;
+ DocumentSession first = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(30)).create();
+ first.pageFlow(page -> page.add(new SectionBuilder().anchor("target").build()).addParagraph("Cover"));
+ DocumentSession second = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(30)).create();
+ second.pageFlow(page -> {
+ lines(page, "Top", 1);
+ page.add(drawings("target"));
+ page.addParagraph("After the drawings");
+ });
+ try (com.demcha.compose.document.api.MultiSectionDocument sections =
+ GraphCompose.documents().section(first).section(second).create()) {
+ docx = sections.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes()))
+ .build());
+ }
+
+ assertThat(bookmarkNames(new XWPFDocument(new ByteArrayInputStream(docx)))).containsExactly("target");
+ assertThat(notes).filteredOn(note -> note.subject().equals("SectionNode")).isEmpty();
+ }
+
private static DocumentNode drawings(String anchor) {
SectionBuilder section = new SectionBuilder()
.addShape(120, 30, DocumentColor.rgb(26, 86, 148))