From 1354131e5edde7a5a70ea42f2bd26cbb9e5aa4ad Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 14:11:34 +0100 Subject: [PATCH] feat(render-docx): export a link as a link, and an anchor as a bookmark Every link the document carried was dropped. A reader opened an export and found the text of a link with nothing behind it, and a reference to another section that went nowhere -- which is most of the point of handing a document over editable rather than as a PDF. A linkTarget becomes a w:hyperlink: a relationship carrying the address for an external one, w:anchor for one of the document's own anchors. A run's own link wins over the paragraph's, because a sentence with one linked phrase in it is the ordinary case and the paragraph's link is the fallback for the rest of that sentence. An anchor becomes a w:bookmarkStart / w:bookmarkEnd pair wrapping the paragraph's text -- opened and closed in two calls, since both elements append to the end of the paragraph and doing it in one leaves a bookmark around nothing and a reader landing above the text rather than on it. Word takes far less of a name than a document gives: letters, digits and underscores, starting with a letter, forty characters. A name it dislikes is not a broken link but a file it refuses to open. Names are therefore cleaned in one place and remembered -- a link and the bookmark it points at are written at different times by different parts of the export and have to agree -- and two anchors that clean to one name stay two bookmarks rather than collapsing onto each other. Forward references need nothing extra: neither side resolves anything at write time. The outline is deliberately not written. bookmarkOptions builds a PDF outline tree; Word's Navigation Pane comes from heading styles, and promoting an anchored paragraph to a heading would restyle the document. Measured by opening the export in Word 16.0: its own PDF carries two live links, one to the address and one jumping within the document -- behaviour, not the presence of the XML. Tests: eight over the exported package -- a paragraph link and its relationship, one linked phrase among plain runs, an internal link matching the bookmark its anchor became, a forward reference, the bookmark wrapping the text, two anchors cleaning to one name staying apart, an anchor starting with a digit, and a plain paragraph carrying neither. Verification: ./mvnw -B -ntp clean verify over the seven-module gate, 1784 tests, BUILD SUCCESS; render-docx 182 to 190; the examples module separately, 93 tests, BUILD SUCCESS. --- CHANGELOG.md | 22 +++ .../architecture/backend-capability-matrix.md | 6 +- docs/recipes/docx-export.md | 1 + .../semantic/docx/DocxBookmarkNames.java | 87 +++++++++ .../semantic/docx/DocxSemanticBackend.java | 91 ++++++++- .../backend/semantic/docx/DocxLinkTest.java | 173 ++++++++++++++++++ 6 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9521c1b82..80838205d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A link in the document is a link in the exported Word file.** Every one was dropped: a + reader opened an export, found the text of a link with nothing behind it, and a reference + to another section that went nowhere — which is most of the point of handing a document + over editable rather than as a PDF. A `linkTarget` now becomes a `w:hyperlink`: a + relationship carrying the address for an external one, `w:anchor` for one of the + document's own anchors. A run's own link wins over the paragraph's, so a sentence with one + linked phrase in it exports as one. An `anchor(...)` becomes a `w:bookmarkStart` / + `w:bookmarkEnd` pair wrapping that paragraph's text rather than sitting before it, so a + reader following the link lands on the paragraph. +

+ Word takes far less of a name than a document gives: letters, digits and underscores, + starting with a letter, forty characters. A name it dislikes is not a broken link but a + file it refuses to open, so names are cleaned once and remembered — `Terms & conditions` + becomes `Terms_conditions` on both sides — and two anchors that clean to one name stay two + bookmarks rather than collapsing onto each other. Forward references need nothing extra: + neither side resolves anything at write time. +

+ Measured by opening the export in Word 16.0: its own PDF carries two live links, one to + the address and one jumping within the document. What is **not** written is the outline — + `bookmarkOptions` builds a PDF outline tree, and Word's Navigation Pane comes from heading + styles, so promoting an anchored paragraph to a heading would restyle the document. + - **A composed table cell carries whatever it was built from.** `DocumentTableCell.node(...)` lets a cell hold anything the document can hold, and the export wrote paragraphs out of it and nothing else: a cell built from an image, a list or a table came out empty — content diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index eed93a854..202a31a79 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -54,7 +54,7 @@ Payload records live in `core` under | Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; `linkTarget` is still dropped | +| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | @@ -75,8 +75,8 @@ Payload records live in `core` under | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | -| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ | -| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ | +| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | +| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ the outline is not written. Word builds its Navigation Pane from heading styles, and promoting an anchored paragraph to a heading would restyle the document; a `linkTo(...)` anchor navigates instead — see the DOCX recipe | | Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ❌ | | Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) | | Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 8bf0e9a70..9c3040809 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -46,6 +46,7 @@ PDF never pull POI. | Tables | Word tables, one cell per cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" | | 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, and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Images | Embedded pictures at the node's declared size | +| 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. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmarkOptions` outline entry is **not** written: Word's Navigation Pane comes from heading styles, and promoting a paragraph to a heading would restyle the document | | Rows | A one-row table spanning the content width, 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; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) | | Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below | | Spacers | Empty paragraphs carrying the vertical gap as spacing-after | diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java new file mode 100644 index 000000000..8d5ef0e11 --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxBookmarkNames.java @@ -0,0 +1,87 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * The name Word will accept for one of the document's own anchors. + * + *

A document names its anchors to suit itself — {@code "Terms & conditions"}, + * {@code "section-2"}, a heading's whole sentence. Word accepts far less: a bookmark name + * starts with a letter, carries only letters, digits and underscores, and stops at 40 + * characters. Written through unchanged, a name Word dislikes is not a broken link but a + * file Word refuses to open, and a name truncated twice in two places is a link that points + * at nothing.

+ * + *

So the mapping is made once and remembered: the same anchor asked for twice gets the + * same name, and two different anchors that clean to the same text get different ones. That + * is the whole reason this is an object rather than a static method — a link and the + * bookmark it points at are written at different times, by different parts of the export, + * and they have to agree.

+ * + * @author Artem Demchyshyn + */ +final class DocxBookmarkNames { + + /** {@code ST_String} caps a bookmark name here; Word enforces it on open. */ + private static final int MAX_LENGTH = 40; + + private final Map byAnchor = new HashMap<>(); + private final Map collisions = new HashMap<>(); + private int nextId; + + /** + * The Word name for an anchor, the same one every time it is asked for. + * + * @param anchor the document's own anchor name + * @return a name Word accepts, or null when the anchor is empty + */ + String nameFor(String anchor) { + if (anchor == null || anchor.isBlank()) { + return null; + } + return byAnchor.computeIfAbsent(anchor, this::mint); + } + + /** @return the next bookmark id; Word wants them distinct within the document */ + int nextId() { + return nextId++; + } + + private String mint(String anchor) { + StringBuilder cleaned = new StringBuilder(anchor.length()); + for (int index = 0; index < anchor.length() && cleaned.length() < MAX_LENGTH; index++) { + char character = anchor.charAt(index); + if (Character.isLetterOrDigit(character) || character == '_') { + cleaned.append(character); + } else if (cleaned.length() > 0 && cleaned.charAt(cleaned.length() - 1) != '_') { + // One separator for a run of them, so "Terms & conditions" does not become + // "Terms___conditions" and spend its 40 characters on punctuation. + cleaned.append('_'); + } + } + while (cleaned.length() > 0 && cleaned.charAt(cleaned.length() - 1) == '_') { + cleaned.setLength(cleaned.length() - 1); + } + if (cleaned.length() == 0 || !Character.isLetter(cleaned.charAt(0))) { + // A name starting with a digit or emptied by cleaning is not a name Word takes. + cleaned.insert(0, "anchor_"); + } + String base = cleaned.length() > MAX_LENGTH ? cleaned.substring(0, MAX_LENGTH) : cleaned.toString(); + + // Two anchors that clean to the same text are still two anchors. Without this the + // second one's bookmark would sit on the first one's name and every link to either + // would land in the same place. + String key = base.toLowerCase(Locale.ROOT); + int seen = collisions.merge(key, 1, Integer::sum); + if (seen == 1) { + return base; + } + String suffix = "_" + seen; + String trimmed = base.length() + suffix.length() > MAX_LENGTH + ? base.substring(0, MAX_LENGTH - suffix.length()) + : base; + return trimmed + suffix; + } +} 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 3060c0576..b74059e9a 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 @@ -16,16 +16,20 @@ import com.demcha.compose.document.node.ContainerNode; import com.demcha.compose.document.output.DocumentMetadata; import com.demcha.compose.document.output.DocumentOutputOptions; +import com.demcha.compose.document.node.DocumentLinkTarget; import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ExternalLinkTarget; import com.demcha.compose.document.node.ImageNode; import com.demcha.compose.document.node.PageBreakNode; import com.demcha.compose.document.node.InlineHighlightRun; import com.demcha.compose.document.node.InlineRun; import com.demcha.compose.document.node.InlineTextRun; +import com.demcha.compose.document.node.InternalLinkTarget; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.TextDirection; import com.demcha.compose.document.node.RowArrangement; import com.demcha.compose.document.node.RowNode; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHyperlink; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import com.demcha.compose.document.node.SectionNode; import com.demcha.compose.document.node.ShapeContainerNode; @@ -74,6 +78,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBookmark; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; @@ -184,6 +189,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // for it: building it costs a list, and deciding later that nobody wanted it is not // something the writers can do halfway through. private DocxExportReport.Builder report = new DocxExportReport.Builder(); + // The Word names this export gave the document's anchors, so a link and the bookmark + // it points at agree. + private DocxBookmarkNames bookmarkNames = new DocxBookmarkNames(); // Where the finished report goes, when the caller configured somewhere for it to go. private final java.util.function.Consumer reportSink; @@ -262,6 +270,7 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws containerPaint.clear(); listNumbering.clear(); report = new DocxExportReport.Builder(); + bookmarkNames = new DocxBookmarkNames(); wordFamilies = DocxFontTable.familiesByName(context.customFontFamilies()); documentDefaultStyle = dominantTextStyle(graph); layout = DocxLayoutMetrics.of(graph, context.layoutGraph()); @@ -1266,7 +1275,9 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node) private void writeParagraph(XWPFDocument document, ParagraphNode node) { XWPFParagraph para = newBodyParagraph(document); boolean rightToLeft = applyParagraphProperties(para, node); + int anchor = openAnchor(para, node.anchor()); writeParagraphRuns(para, node, rightToLeft); + closeAnchor(para, anchor); } /** @@ -1397,20 +1408,96 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean warnDroppedInlineRuns(node); List runs = node.inlineTextRuns(); if (runs.isEmpty()) { - XWPFRun docRun = para.createRun(); + XWPFRun docRun = newRun(para, node.linkTarget()); applyStyle(docRun, node.textStyle()); applyRunDirection(docRun, rightToLeft); docRun.setText(node.text() == null ? "" : node.text()); return; } for (InlineTextRun run : runs) { - XWPFRun docRun = para.createRun(); + // A run's own link wins over the paragraph's: a sentence with one linked phrase + // in it is the ordinary case, and the paragraph's link is the fallback for the + // rest of that sentence rather than something the phrase overrides away. + DocumentLinkTarget target = run.linkTarget() != null ? run.linkTarget() : node.linkTarget(); + XWPFRun docRun = newRun(para, target); applyStyle(docRun, run.textStyle() == null ? node.textStyle() : run.textStyle()); applyRunDirection(docRun, rightToLeft); docRun.setText(run.text() == null ? "" : run.text()); } } + /** + * A run, inside a hyperlink when the thing being written is one. + * + *

Every link the document carried was dropped: a reader opened an exported document + * and found the text of a link with nothing behind it, and a reference to another + * section that went nowhere. Word owns both — {@code w:hyperlink} with a relationship + * for an address, or with {@code w:anchor} for a bookmark in the same document — so + * this is a mapping rather than an approximation.

+ * + *

An external address goes through POI's own {@code createHyperlinkRun}, which makes + * the external relationship the part needs. An internal one is built here: POI has no + * helper for an anchor, and the run it would hand back is registered in a list this + * export never reads — what matters is the XML, and reading the file back gives POI's + * own hyperlink run either way.

+ * + * @param para the paragraph being filled + * @param target the link this run carries, or null for ordinary text + * @return the run to write text into + */ + private XWPFRun newRun(XWPFParagraph para, DocumentLinkTarget target) { + if (target instanceof ExternalLinkTarget external + && external.options() != null && external.options().uri() != null + && !external.options().uri().isBlank()) { + return para.createHyperlinkRun(external.options().uri()); + } + if (target instanceof InternalLinkTarget internal) { + String name = bookmarkNames.nameFor(internal.anchor()); + if (name != null) { + CTHyperlink link = para.getCTP().addNewHyperlink(); + link.setAnchor(name); + return new XWPFRun(link.addNewR(), para); + } + } + return para.createRun(); + } + + /** + * Marks the anchor a node declares, so a link can point at it. + * + *

Written as a bookmark around the paragraph rather than as an empty one before it: + * a reader following the link lands on the text, and Word's own "go to bookmark" shows + * the paragraph rather than an insertion point above it.

+ * + *

A bookmark is not an outline entry. Word builds its Navigation Pane from heading + * styles, and nothing here promotes an anchored paragraph to one — an anchor says where + * 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) { + return -1; + } + int id = bookmarkNames.nextId(); + CTBookmark start = para.getCTP().addNewBookmarkStart(); + start.setId(BigInteger.valueOf(id)); + start.setName(name); + return id; + } + + /** + * Closes the bookmark {@link #openAnchor} opened, after the paragraph's runs. + * + *

Opened and closed in two calls on purpose: both elements append to the end of the + * paragraph, so opening and closing in one leaves a bookmark wrapping nothing, and a + * reader following the link lands before the text rather than on it.

+ */ + private static void closeAnchor(XWPFParagraph para, int id) { + if (id >= 0) { + para.getCTP().addNewBookmarkEnd().setId(BigInteger.valueOf(id)); + } + } + /** * Embeds an image at the size the node asks for, in the shape its fit mode asks for. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java new file mode 100644 index 000000000..b3d72997e --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinkTest.java @@ -0,0 +1,173 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.DocumentLinkTarget; +import org.apache.poi.openxml4j.opc.PackageRelationship; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFHyperlinkRun; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A link in the document is a link in the file. + * + *

Every one was dropped: a reader opened an exported document, found the text of a link + * with nothing behind it, and a reference to another section that went nowhere. For a + * document handed over to be edited rather than read, that is most of the point of handing + * it over.

+ * + *

Word owns both kinds — {@code w:hyperlink} with a relationship for an address, or with + * {@code w:anchor} for a bookmark in the same document — so what these pin is a mapping, + * not an approximation.

+ * + * @author Artem Demchyshyn + */ +class DocxLinkTest { + + @Test + void aParagraphLinkBecomesAHyperlinkWithItsAddress() throws Exception { + try (XWPFDocument document = exported(page -> page.addParagraph(p -> p + .text("The licence") + .linkTarget(DocumentLinkTarget.external("https://example.org/licence"))))) { + + XWPFHyperlinkRun run = onlyHyperlink(document); + assertThat(run.getText(0)).isEqualTo("The licence"); + assertThat(addressOf(document, run)) + .as("the address the document asked for, through a real relationship") + .isEqualTo("https://example.org/licence"); + } + } + + @Test + void oneLinkedPhraseLeavesTheRestOfTheSentenceAlone() throws Exception { + try (XWPFDocument document = exported(page -> page.addParagraph(p -> p + .inlineText("See ") + .inlineLink("the licence", + new com.demcha.compose.document.node.DocumentLinkOptions("https://example.org/licence")) + .inlineText(" for the terms.")))) { + + XWPFParagraph para = document.getParagraphs().get(0); + assertThat(para.getText()).isEqualTo("See the licence for the terms."); + List links = para.getRuns().stream() + .filter(XWPFHyperlinkRun.class::isInstance) + .map(XWPFHyperlinkRun.class::cast) + .toList(); + assertThat(links).hasSize(1); + assertThat(links.get(0).getText(0)).isEqualTo("the licence"); + } + } + + @Test + void anInternalLinkPointsAtTheBookmarkItsAnchorBecame() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Back to terms") + .linkTarget(DocumentLinkTarget.anchor("Terms & conditions"))) + .addParagraph(p -> p.text("Terms and conditions") + .anchor("Terms & conditions")))) { + + String anchor = onlyHyperlink(document).getAnchor(); + assertThat(anchor) + .as("cleaned to what Word accepts: letters, digits and underscores") + .isEqualTo("Terms_conditions"); + assertThat(bookmarkNames(document)) + .as("and the bookmark carries the same name, or the link goes nowhere") + .containsExactly(anchor); + } + } + + @Test + void aForwardReferenceResolvesJustAsWell() throws Exception { + // The link is written before the anchor it points at exists in the file. Nothing + // resolves anything at write time — both sides go through the same naming — so the + // order the document happens to be in does not matter. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Jump ahead").linkTarget(DocumentLinkTarget.anchor("appendix"))) + .addParagraph(p -> p.text("Appendix").anchor("appendix")))) { + + assertThat(onlyHyperlink(document).getAnchor()).isEqualTo("appendix"); + assertThat(bookmarkNames(document)).containsExactly("appendix"); + } + } + + @Test + void theBookmarkWrapsTheTextRatherThanSittingBeforeIt() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Appendix").anchor("appendix")))) { + + String xml = document.getParagraphs().get(0).getCTP().xmlText(); + assertThat(xml.indexOf("bookmarkStart")) + .as("opened before the text") + .isLessThan(xml.indexOf("")); + assertThat(xml.indexOf("bookmarkEnd")) + .as("and closed after it, so the reader lands on the paragraph") + .isGreaterThan(xml.indexOf("")); + } + } + + @Test + void twoAnchorsThatCleanToTheSameTextStayApart() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("One").anchor("Section 1")) + .addParagraph(p -> p.text("Two").anchor("Section-1")))) { + + assertThat(bookmarkNames(document)) + .as("two anchors are two bookmarks, whatever they clean to") + .doesNotHaveDuplicates() + .hasSize(2); + } + } + + @Test + void anAnchorStartingWithADigitIsStillAName() throws Exception { + // Word will not take a bookmark whose name starts with anything but a letter, and + // refuses the file rather than the bookmark. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Clause").anchor("2024-terms")))) { + + assertThat(bookmarkNames(document)).hasSize(1); + assertThat(bookmarkNames(document).get(0)).matches("^[A-Za-z][A-Za-z0-9_]*$"); + } + } + + @Test + void aParagraphWithNoLinkCarriesNoHyperlink() throws Exception { + try (XWPFDocument document = exported(page -> page.addParagraph(p -> p.text("Plain")))) { + assertThat(document.getParagraphs().get(0).getCTP().sizeOfHyperlinkArray()).isZero(); + assertThat(bookmarkNames(document)).isEmpty(); + } + } + + private static XWPFHyperlinkRun onlyHyperlink(XWPFDocument document) { + List links = document.getParagraphs().stream() + .flatMap(p -> p.getRuns().stream()) + .filter(XWPFHyperlinkRun.class::isInstance) + .map(XWPFHyperlinkRun.class::cast) + .toList(); + assertThat(links).hasSize(1); + return links.get(0); + } + + /** The address behind a hyperlink run, read through the part's own relationship. */ + private static String addressOf(XWPFDocument document, XWPFHyperlinkRun run) { + PackageRelationship relationship = document.getPackagePart() + .getRelationship(run.getHyperlinkId()); + assertThat(relationship).as("the run points at a relationship that exists").isNotNull(); + return relationship.getTargetURI().toString(); + } + + private static List bookmarkNames(XWPFDocument document) { + return document.getParagraphs().stream() + .flatMap(p -> p.getCTP().getBookmarkStartList().stream()) + .map(bookmark -> bookmark.getName()) + .toList(); + } + + private static XWPFDocument exported(Consumer content) throws Exception { + return DocxExports.withLayout(400, 600, 20, content); + } +}