Skip to content

feat(docx): export a link as a link, and an anchor as a bookmark - #707

Merged
DemchaAV merged 1 commit into
2.5-devfrom
feature/docx-links
Sep 22, 2026
Merged

DemchaAV merged 1 commit into
2.5-devfrom
feature/docx-links

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

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. For
a document handed over to be edited rather than read, that is most of the reason for handing
it over at all — a PDF at least keeps its links.

What changed

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. An external link goes
through POI's createHyperlinkRun, which makes the relationship the part needs; POI has no
helper for an anchor, so that one is built here.

A run's own link wins over the paragraph's. A sentence with one linked phrase in it is
the ordinary case: the paragraph's link is the fallback for the rest of the sentence rather
than something the phrase overrides away.

An anchor(...) becomes a w:bookmarkStart / w:bookmarkEnd pair wrapping the
paragraph's text.
Opened and closed in two calls, because both elements append to the end
of the paragraph — doing it in one leaves a bookmark around nothing and a reader landing
above the text instead of on it.

Names are cleaned once and remembered (DocxBookmarkNames). 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. A link
and the bookmark it points at are written at different times by different parts of the
export, so both go through one mapping — 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.

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. The capability matrix and the DOCX recipe say so rather
than leaving it to be discovered.

Verification

./mvnw -B -ntp clean verify over the seven-module gate → BUILD SUCCESS, 1784 tests.
./mvnw -B -ntp test -f examples/pom.xml → 93 tests, BUILD SUCCESS; no committed preview
drifted.

render-docx goes from 182 to 190 tests. DocxLinkTest covers a paragraph link and the
relationship behind it, one linked phrase among plain runs, an internal link matching the
bookmark its anchor became, a forward reference, the bookmark wrapping the text rather than
sitting before it, two anchors that clean to one name staying apart, an anchor starting with
a digit, and a plain paragraph carrying neither.

Checked by opening the export in Word 16.0 and reading its own PDF back: two live links,
one to the address and one jumping within the document. Behaviour, not the presence of the
XML — the same standard the font work was held to.

Lane: canonical — document.backend.semantic.docx only; no engine, layout or pagination
internals are touched.

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.
if (name != null) {
CTHyperlink link = para.getCTP().addNewHyperlink();
link.setAnchor(name);
return new XWPFRun(link.addNewR(), para);
@DemchaAV
DemchaAV merged commit 7a6933d into 2.5-dev Sep 22, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-links branch September 22, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants