diff --git a/CHANGELOG.md b/CHANGELOG.md index 70274f3c9..57e145403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names a block that writes no paragraph for its anchor's bookmark.** A bookmark + goes round the paragraphs a block writes, and a section or a container 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 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. + - The report now names such a block and its anchor: a link to it points at none, and a page + reference to it is a fixed number. A block with a note of its own has it added there. Where + another node's bookmark carries the same anchor, a link goes there, and the block is not named. + - The file is unchanged: none of the corpus's 62 DOCX changes, and none of its 1246 notes. - **A DOCX export keeps the space above a list, a line pair, and a table, a row or a panel opening a band, on the page the layout moves them to.** Word drops the space above a paragraph at the top of a page. A paragraph, a picture and a spacer there already hold theirs in a line diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 726fd5e11..28b8de6de 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -103,7 +103,7 @@ Payload records live in `core` under | Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| | External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ⚠️ `DocxSemanticBackend.newRun` — a paragraph's, a run's, a picture-in-a-line's and a page zone picture's link is a `w:hyperlink` with an external relationship, a run's own link winning over its paragraph's. A block written as a picture, a drawing or a rule — an image, a barcode, a shape, an ellipse, a line — and a table carry none, and the report names the link | -| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ⚠️ `DocxSemanticBackend` — an internal `linkTarget` is a `w:hyperlink` with `w:anchor`, on what an external one is written on, and every anchor the export writes is a bookmark: a paragraph's around its text, a section's, container's, table's, image's or barcode's around everything the block wrote, a rule's around its paragraph (`bookmarkAround`). A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a stack's layer written as a column, and a page zone's paragraph or picture 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 rule's around its paragraph (`bookmarkAround`). A drawing floated over the text — a shape, an ellipse, a line not written as a rule — a stack's layer written as a column, a page zone's paragraph or picture, and a block that writes no paragraph — a section or a container of drawings alone — hold no bookmark: the report names the anchor, a link to it points at no bookmark, and a page reference to it is written as the number the layout found | | 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 86fd007e7..e19a0719d 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. 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 block that writes no paragraph — a section or a container of drawings alone, whose drawings stand in the paragraph beside them — has no bookmark, and the report names its anchor: a link to it points at none (measured: the page's link went to the section's place, and Word's and LibreOffice's PDFs carried no link at all), and a page reference to it is written as the number it reads. Two anchors that clean to one name, or to a name a suffix gave another, stay two bookmarks; one anchor two nodes declare is one bookmark, the last the file writes one for — the node the page sends a link to and reads a page reference's page from, where that node writes a paragraph to mark — and the report names the anchor, the nodes counted off the document, two cells of one table two of them, and a node of a kind of the caller's own counted where the layout registers it. An anchor in a table's header row the page repeats is drawn again on each page it repeats on, and the page sends a link to the last of them; the file holds the row once, where the table starts, which a link goes to and a page reference reads once Word updates it (measured: the bookmark on page 1, a reference written as 5 reading 1), and the report names it. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. A row composed in a table cell (`DocumentTableCell.node(...)`), which the layout places nowhere, that the page divides by what its children measure — an arrangement other than `START`, a spacer that grows, an auto column — takes its columns from where its paragraphs' lines start, the table having laid them out, a spacer where the page spreads the room between them; one holding a child those lines do not place — a picture, a shape, or a spacer in a row of stated columns, which gives it a slot of its own — or written inside a panel or another table in its cell has its columns sized by Word to their text, and the report names it. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line. A row that paints its box — `fillColor`, `stroke`, `borders` — is a panel holding its columns (see "What a panel keeps and loses" below): a table of one cell carrying the fill and borders, its margins the row's padding as a panel's are, and in it the columns as a table of their own, without the padding; a corner radius squares, and is named (`corner radius`), and what of its borders above and below Word draws past its box is taken, or named, as a panel's is. A row composed in a table cell, in a page zone's line, in a band of overlapping layers or where a stack's column measures the space round it to its text, with a padding below zero or a column hanging into it, or with a margin below zero above or below it or at a side in a cell, is written as its columns alone, its paint in the report (`row paint`); in the body a row bleeding past the margin is its panel | | Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). 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 | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java index d25ca15d6..67c676735 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java @@ -173,6 +173,29 @@ void replace(int from, Severity severity, String subject, String path, String de } } + /** + * Adds a phrase to the note on a node among those added while it was written, after what + * that note says already, or adds a note of its own where the node has none: one node, one + * note. A node with no path is not told from the others, and has a note of its own. No note + * moves: the places of those a later step puts in or replaces stay where they were counted. + * + * @param from the first note that may be the node's + * @param to the note after the last that may be: a later one with its path may be a later + * section's node, as paths are a section's own + * @param phrase what the node also loses, added to its note + * @param alone the whole note, where the node has none + */ + void addTo(int from, int to, Severity severity, String subject, String path, String phrase, String alone) { + for (int index = from; path != null && index < Math.min(to, notes.size()); index++) { + Note note = notes.get(index); + if (note.subject().equals(subject) && path.equals(note.path())) { + notes.set(index, new Note(note.severity(), subject, path, note.detail() + "; " + phrase)); + return; + } + } + add(severity, subject, path, alone); + } + DocxExportReport build() { return notes.isEmpty() ? EMPTY : new DocxExportReport(notes); } 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 e94858e4a..248097978 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 @@ -869,6 +869,9 @@ 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 + // (nameTheBlocksNoBookmarkMarks). + private final List blocksUnmarked = new ArrayList<>(); // How many nodes declare each anchor, a bookmark written or not (DocxAnchorDeclarations), and // under how many paths the layouts register it, which counts a kind the export does not know. private final java.util.Map anchorsDeclared = new java.util.LinkedHashMap<>(); @@ -1186,6 +1189,7 @@ private byte[] write(List sections, Path outputFile) throws Exc bookmarkNames = new DocxBookmarkNames(); bookmarksWritten.clear(); bookmarksOfAnchors.clear(); + blocksUnmarked.clear(); anchorsDeclared.clear(); anchorPaths.clear(); anchorsInARepeatedHeader.clear(); @@ -1269,6 +1273,7 @@ private byte[] write(List sections, Path outputFile) throws Exc index == sections.size() - 1)); } // Every bookmark is written now, those a reference on an earlier page reads included. + nameTheBlocksNoBookmarkMarks(); keepTheLastBookmarkOfEachAnchor(document); settleThePageReferences(); hideTheClosingMark(document); @@ -3625,6 +3630,7 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex int first = elements.get().size(); // The paragraph closing a table above, if the block takes it over, is the block's. XWPFParagraph closer = cellEndsWithItsTableCloser() ? tableCloser : null; + int notesBefore = report.size(); writeNodeContent(document, node); if (closer != null && tableCloser != closer) { first--; @@ -3633,12 +3639,19 @@ private void writePlacedNode(XWPFDocument document, DocumentNode node) throws Ex if (keepTogether || keepWithNext) { keepOnOnePage(written, keepWithNext); } - if (anchor != null) { - bookmarkAround(written, anchor); - } + // A section or a container that writes no paragraph — drawings alone, standing in the + // paragraph beside them — has nothing for a bookmark to go round, and a link to its anchor is + // written to a bookmark the file lacks: Word's and LibreOffice's PDFs carry no link there. + // Whether another node marks the anchor is known once all are written + // (nameTheBlocksNoBookmarkMarks). + boolean unmarked = anchor != null && !bookmarkAround(written, anchor); if (panelBreak != null) { reportAKeepThePanelBreaks(node, panelBreak, written); } + if (unmarked) { + blocksUnmarked.add(new UnmarkedBlock(notesBefore, report.size(), node.nodeKind(), layout.pathOf(node), + anchor)); + } } /** How a block the layout moves to a new page inside a panel may be broken there in Word. */ @@ -3789,8 +3802,11 @@ private static String blockAnchorOf(DocumentNode node, boolean inFlow) { * at the end of the last — the first and last cell's, for a block that begins or ends * with a table — so a link lands on the block's first line and a page reference counts * the page it starts on. A block that wrote nothing has nothing to mark.

+ * + * @return whether a bookmark marks what the block wrote: not where it wrote no paragraph or + * table for one to go round */ - private void bookmarkAround(List written, String anchor) { + private boolean bookmarkAround(List written, String anchor) { XWPFParagraph first = null; XWPFParagraph last = null; for (IBodyElement element : written) { @@ -3807,9 +3823,11 @@ private void bookmarkAround(List written, String anchor) { last = closing; } } + // 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) { - return; + return false; } int id = bookmarkNames.nextId(); CTBookmark start = first.getCTP().addNewBookmarkStart(); @@ -3819,6 +3837,7 @@ private void bookmarkAround(List written, String anchor) { bookmarksOfAnchors.computeIfAbsent(anchor, key -> new ArrayList<>()).add(id); moveToParagraphStart(first.getCTP(), start); last.getCTP().addNewBookmarkEnd().setId(BigInteger.valueOf(id)); + return true; } /** @@ -10375,6 +10394,42 @@ private record FieldReference(XWPFParagraph paragraph, String subject, String path, String asText, int noteAt, boolean noted) { } + /** + * A block that wrote no paragraph for its anchor's bookmark to go round. + * + * @param notesFrom how many notes the report held before the block was written + * @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 + */ + private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String path, String anchor) { + } + + /** + * 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}). + * + *

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 + * here would move it. A block whose path the layout does not register, composed in a table's + * cell, is not told from the nodes inside it, and has a note of its own.

+ */ + private void nameTheBlocksNoBookmarkMarks() { + for (UnmarkedBlock block : blocksUnmarked) { + if (bookmarksWritten.containsKey(block.anchor())) { + continue; + } + 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"; + report.addTo(block.notesFrom(), block.notesTo(), DocxExportReport.Severity.APPROXIMATED, block.subject(), + block.path(), "it writes no paragraph, so " + lost, "writes no paragraph, so " + lost); + } + blocksUnmarked.clear(); + } + /** * Keeps one bookmark of an anchor two nodes declare: the last one's. * 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 new file mode 100644 index 000000000..36c51f0d4 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java @@ -0,0 +1,226 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableCell; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +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 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.

+ * + * @author Artem Demchyshyn + */ +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"; + + @Test + void aSectionOfDrawingsAloneNamesItsAnchor() throws Exception { + Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target")) + .addSection(section -> section.anchor("target").add(drawing())) + .addParagraph("After")); + + assertThat(notesOn(exported.report(), "SectionNode")).singleElement() + .isEqualTo("writes no paragraph, so " + 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 { + 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, + DocumentInsets.zero(), DocumentInsets.zero(), null, null, null, null, "target")) + .addParagraph("After")); + + assertThat(notesOn(exported.report(), "ContainerNode")).singleElement() + .isEqualTo("writes no paragraph, so " + 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()))); + + assertThat(notesOn(exported.report(), "SectionNode")).singleElement() + .isEqualTo("written as its contents; its outline entry is not written; it writes no paragraph, so " + + LOST); + } + + @Test + void aSectionInAPanelsCellNamesItsAnchor() throws Exception { + // What it writes lands in the panel's cell, which holds the panel's paragraph and none of its own. + 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()))); + + assertThat(notesOn(exported.report(), "SectionNode")).contains("writes no paragraph, so " + LOST); + } + + @Test + void aSectionInATablesComposedCellHasANoteOfItsOwn() throws Exception { + // Nothing in a composed cell has a path of its own to tell it by: the section inside, with its + // outline entry, keeps its note, and the anchored one round it has its own. + DocumentNode inner = new SectionBuilder().bookmark(new DocumentBookmarkOptions("Entry")).add(drawing()).build(); + 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").add(inner).build())))); + + List notes = notesOn(exported.report(), "SectionNode"); + assertThat(notes).contains("writes no paragraph, so " + 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\"")); + } + + @Test + 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())) + .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"); + } + + @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())) + .addParagraph(p -> p.text("Here").anchor("target"))); + + assertThat(exported.report().bySubject()).doesNotContainKey("SectionNode"); + assertThat(notesOn(exported.report(), "anchor")).singleElement().asString() + .startsWith("\"target\" is the anchor of 2 nodes"); + } + + @Test + void aSectionThatWritesAParagraphIsBookmarkedAndNamesNothing() throws Exception { + Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target")) + .addSection(section -> section.anchor("target").add(drawing()).addParagraph("Caption")) + .addParagraph("After")); + + assertThat(exported.document().getDocument().xmlText()).contains("w:name=\"target\""); + assertThat(exported.report().bySubject()).doesNotContainKey("SectionNode"); + } + + @Test + 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()))); + + assertThat(notesOn(exported.report(), "SectionNode")).singleElement() + .isEqualTo("writes no paragraph, so " + LOST); + } + + @Test + 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.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\"")); + } + + @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()))); + + 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\"")); + } + + @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())) + .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(exported.report().notes()).extracting(DocxExportReport.Note::subject) + .as("each where its node was written, the block's own after them") + .containsExactly("ShapeNode", "PageReferenceNode", "SectionNode"); + } + + /** The notes of a document of two sections, each a page of its own content. */ + @SafeVarargs + private static List exportSections(Consumer... contents) throws Exception { + List notes = new java.util.ArrayList<>(); + var documents = GraphCompose.documents(); + for (Consumer content : contents) { + DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create(); + session.pageFlow(content::accept); + documents = documents.section(session); + } + try (com.demcha.compose.document.api.MultiSectionDocument document = documents.create()) { + document.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()); + } + return notes; + } + + private static DocumentNode drawing() { + return new ShapeBuilder().size(120, 30).fillColor(DocumentColor.LIGHT_GRAY).build(); + } + + private static List notesOn(DocxExportReport report, String subject) { + return report.notes().stream().filter(note -> note.subject().equals(subject)) + .map(DocxExportReport.Note::detail).toList(); + } + + 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, 600).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content::accept); + docx = session.export(new DocxSemanticBackend(report::set)); + } + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get()); + } +} 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 f35da2c77..c6f08468e 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 @@ -178,7 +178,8 @@ private record Entry(Fate fate, String note) { node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", "margin:REPORTED:" + PULL, "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_BORDERS, "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_BORDERS, - "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", + "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + + "which has no bookmark; otherwise, in the flow, it is bookmarked", "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:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", @@ -283,7 +284,8 @@ private record Entry(Fate fate, String note) { "margin:REPORTED:" + PULL, "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_BORDERS, "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_BORDERS, "keepTogether:WRITTEN", - "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", + "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + + "which has no bookmark; otherwise, in the flow, it is bookmarked", "bleed:REPORTED:of a panel the page bleeds, in the flow it pages", "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column");