This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new d857a8e6d0 TIKA-4967: link REF/PAGEREF/NOTEREF and HYPERLINK \l fields
to bookmarks in docx (#3317)
d857a8e6d0 is described below
commit d857a8e6d0dd27d2bb47a3a984526b482bea1934
Author: Tim Allison <[email protected]>
AuthorDate: Sun Oct 18 17:48:18 2026 +0100
TIKA-4967: link REF/PAGEREF/NOTEREF and HYPERLINK \l fields to bookmarks in
docx (#3317)
---
CHANGES.txt | 7 ++
.../parser/microsoft/ooxml/FieldCodeParser.java | 34 ++++++
.../ooxml/OOXMLWordAndPowerPointTextHandler.java | 114 +++++++++++++++------
.../parser/microsoft/ooxml/OOXMLDocxSAXTest.java | 26 +++++
.../test-documents/testWORD_refFields.docx | Bin 0 -> 1261 bytes
.../test-documents/testWORD_unclosedFields.docx | Bin 0 -> 1072 bytes
6 files changed, 152 insertions(+), 29 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index e81aa603bb..1b5aaf91e5 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,12 @@
Release 4.2.0 - unreleased
+ * The docx parser links REF, PAGEREF and NOTEREF fields, and in-document
+ HYPERLINK \l fields, to their bookmarks (<a href="#bookmark">). Fields
are now
+ tracked as a stack, so a field nested in a HYPERLINK field no longer ends
the
+ enclosing link early. Simple fields (w:fldSimple) are now parsed as well,
so
+ HYPERLINK, INCLUDEPICTURE and similar fields written that way are linked
or
+ reported like their complex-field equivalents (TIKA-4967).
+
* The docx parser now applies bold and italics set by a run's character
style
(e.g. Word's built-in Strong and Emphasis), following basedOn chains;
previously
only direct formatting was emitted. The new OfficeParserConfig option
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java
index d71cbdc7f9..2c51ae174b 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java
@@ -44,9 +44,43 @@ public class FieldCodeParser {
"LINK\\s{1,100}[\\w.]{1,50}\\s{1,100}\"([^\"]{1,10000})\"",
Pattern.CASE_INSENSITIVE);
+ // REF/PAGEREF/NOTEREF <bookmark>, optionally quoted; Word bookmark names
are at most
+ // 40 characters
+ private static final Pattern BOOKMARK_REF_PATTERN =
+ Pattern.compile("^(?:REF|PAGEREF|NOTEREF)\\s{1,100}"
+ +
"(?:\"([\\p{L}\\p{N}_]{1,40})\"|([\\p{L}\\p{N}_]{1,40}))(?:\\s|$)",
+ Pattern.CASE_INSENSITIVE);
+ // HYPERLINK \l "<bookmark>": a link within the document
+ private static final Pattern HYPERLINK_ANCHOR_PATTERN =
+
Pattern.compile("^HYPERLINK\\s{1,100}\\\\l\\s{1,100}\"([^\"]{1,255})\"",
+ Pattern.CASE_INSENSITIVE);
+
private FieldCodeParser() {
}
+ /**
+ * Parses the target bookmark from a REF, PAGEREF or NOTEREF field code,
+ * e.g. {@code REF _Ref12345 \h}, or from an in-document {@code HYPERLINK
\l "_Toc1"}.
+ *
+ * @param instrText the accumulated instrText content
+ * @return the bookmark name if found, or null
+ */
+ public static String parseBookmarkRefFromInstrText(String instrText) {
+ if (instrText == null || instrText.isEmpty()) {
+ return null;
+ }
+ String trimmed = instrText.trim();
+ Matcher m = BOOKMARK_REF_PATTERN.matcher(trimmed);
+ if (m.find()) {
+ return m.group(1) != null ? m.group(1) : m.group(2);
+ }
+ m = HYPERLINK_ANCHOR_PATTERN.matcher(trimmed);
+ if (m.find()) {
+ return m.group(1);
+ }
+ return null;
+ }
+
/**
* Parses a HYPERLINK URL from instrText field code content.
* Field codes like: {@code HYPERLINK "https://example.com"}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java
index f1411245fd..281c2ed186 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java
@@ -17,7 +17,9 @@
package org.apache.tika.parser.microsoft.ooxml;
+import java.util.ArrayDeque;
import java.util.Date;
+import java.util.Deque;
import java.util.Map;
import org.xml.sax.Attributes;
@@ -115,6 +117,11 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
private static final String TXBX = "txbx"; // DrawingML text box (wps:txbx
in mc:Choice)
private final static String FLD_CHAR = "fldChar";
private final static String INSTR_TEXT = "instrText";
+ private final static String FLD_SIMPLE = "fldSimple";
+ private final static String INSTR = "instr";
+ // bounds the field stack; past it the outermost frame is dropped so that
+ // unclosed fields cannot hide later ones
+ private final static int MAX_FIELD_DEPTH = 64;
private final static String FLD_CHAR_TYPE = "fldCharType";
// DrawingML hyperlinks on shapes/pictures
private final static String HLINK_HOVER = "hlinkHover";
@@ -167,11 +174,10 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
// pPr elements nested inside other elements (e.g., <a:pPr> inside <a:fld>)
// must not be treated as paragraph-level properties.
private boolean inParagraphLevelPPr = false;
- // Field code tracking for instrText-based hyperlinks
- private boolean inField = false;
+ // Open fields, innermost last; fields nest, e.g. PAGEREF inside a TOC's
HYPERLINK
+ private final Deque<FieldFrame> fieldStack = new ArrayDeque<>();
+ private int hyperlinkElementDepth = 0;
private boolean inInstrText = false;
- private boolean inFieldHyperlink = false;
- private final StringBuilder instrTextBuffer = new StringBuilder();
private EditType editType =
EditType.NONE;
@@ -335,6 +341,7 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
String id = atts.getValue(W_NS, "id");
bodyContentsHandler.endBookmark(id);
} else if (HYPERLINK.equals(localName)) { //docx hyperlink
+ hyperlinkElementDepth++;
String hyperlinkId = atts.getValue(OFFICE_DOC_RELATIONSHIP_NS,
"id");
String hyperlink = null;
if (hyperlinkId != null) {
@@ -430,31 +437,19 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
} else if (FLD_CHAR.equals(localName)) {
String fldCharType = atts.getValue(W_NS, FLD_CHAR_TYPE);
if ("begin".equals(fldCharType)) {
- inField = true;
- instrTextBuffer.setLength(0);
+ beginField();
} else if ("separate".equals(fldCharType)) {
- // Parse instrText for HYPERLINK
- String url =
FieldCodeParser.parseHyperlinkFromInstrText(instrTextBuffer.toString());
- if (url != null) {
- bodyContentsHandler.fieldCodeHyperlinkStart(url);
- inFieldHyperlink = true;
- } else {
- // Check for external reference fields (INCLUDEPICTURE,
INCLUDETEXT, etc.)
- StringBuilder fieldType = new StringBuilder();
- String extUrl =
FieldCodeParser.parseExternalRefFromInstrText(
- instrTextBuffer.toString(), fieldType);
- if (extUrl != null) {
- bodyContentsHandler.externalRef(fieldType.toString(),
extUrl);
- }
- }
+ separateField();
} else if ("end".equals(fldCharType)) {
- if (inFieldHyperlink) {
- bodyContentsHandler.hyperlinkEnd();
- inFieldHyperlink = false;
- }
- inField = false;
- instrTextBuffer.setLength(0);
+ endField();
}
+ } else if (FLD_SIMPLE.equals(localName) && W_NS.equals(uri)) {
+ beginField();
+ String instr = atts.getValue(W_NS, INSTR);
+ if (instr != null) {
+ fieldStack.peekLast().instr.append(instr);
+ }
+ separateField();
} else if (INSTR_TEXT.equals(localName)) {
inInstrText = true;
} else if (HLINK_HOVER.equals(localName)) {
@@ -606,7 +601,10 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
MOVE_FROM.equals(localName)) {
editType = EditType.NONE;
} else if (HYPERLINK.equals(localName)) {
+ hyperlinkElementDepth--;
bodyContentsHandler.hyperlinkEnd();
+ } else if (FLD_SIMPLE.equals(localName) && W_NS.equals(uri)) {
+ endField();
} else if (PICT.equals(localName)) {
pictureTracker.endPicture();
} else if (V.equals(localName) && C_NS.equals(uri)) { // in value in a
chart
@@ -657,12 +655,70 @@ public class OOXMLWordAndPowerPointTextHandler extends
DefaultHandler {
} else if (inV) {
appendToBuffer(ch, start, length);
appendToBuffer(TAB_CHAR, 0, 1);
- } else if (inInstrText && inField) {
- // Accumulate instrText content for field code parsing (e.g.,
HYPERLINK)
- instrTextBuffer.append(ch, start, length);
+ } else if (inInstrText && !fieldStack.isEmpty()) {
+ fieldStack.peekLast().instr.append(ch, start, length);
+ }
+ }
+
+ private void beginField() {
+ if (fieldStack.size() >= MAX_FIELD_DEPTH) {
+ fieldStack.pollFirst();
+ }
+ fieldStack.addLast(new FieldFrame());
+ }
+
+ private void separateField() throws SAXException {
+ FieldFrame frame = fieldStack.peekLast();
+ if (frame == null || frame.separated) {
+ return;
+ }
+ frame.separated = true;
+ String instr = frame.instr.toString();
+ String url = FieldCodeParser.parseHyperlinkFromInstrText(instr);
+ if (url != null) {
+ bodyContentsHandler.fieldCodeHyperlinkStart(url);
+ frame.linkOpened = true;
+ return;
+ }
+ String bookmark = FieldCodeParser.parseBookmarkRefFromInstrText(instr);
+ if (bookmark != null) {
+ // a reference inside a link keeps the enclosing link
+ if (hyperlinkElementDepth == 0 && !isFieldLinkOpen()) {
+ bodyContentsHandler.hyperlinkStart("#" + bookmark);
+ frame.linkOpened = true;
+ }
+ return;
+ }
+ // external reference fields (INCLUDEPICTURE, INCLUDETEXT, etc.)
+ StringBuilder fieldType = new StringBuilder();
+ String extUrl = FieldCodeParser.parseExternalRefFromInstrText(instr,
fieldType);
+ if (extUrl != null) {
+ bodyContentsHandler.externalRef(fieldType.toString(), extUrl);
+ }
+ }
+
+ private void endField() throws SAXException {
+ FieldFrame frame = fieldStack.pollLast();
+ if (frame != null && frame.linkOpened) {
+ bodyContentsHandler.hyperlinkEnd();
}
}
+ private boolean isFieldLinkOpen() {
+ for (FieldFrame f : fieldStack) {
+ if (f.linkOpened) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static class FieldFrame {
+ final StringBuilder instr = new StringBuilder();
+ boolean separated = false;
+ boolean linkOpened = false;
+ }
+
@Override
public void ignorableWhitespace(char[] ch, int start, int length) throws
SAXException {
if (inSkippedAlternateContent()) {
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java
index 08767ac8f5..80590ec5e4 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.parser.microsoft.ooxml;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -467,6 +468,31 @@ public class OOXMLDocxSAXTest extends TikaTest {
assertContains("Access Document(s)", xml);
}
+ @Test
+ public void testBookmarkRefFieldLinks() throws Exception {
+ XMLResult r = getXML("testWORD_refFields.docx");
+ String xml = r.xml;
+ assertContains("<a name=\"_Ref100\" />Target heading", xml);
+ assertContains("<p>see <a href=\"#_Ref100\">Target heading</a>
above</p>", xml);
+ // PAGEREF nested in an in-document HYPERLINK keeps the one enclosing
link
+ assertContains("<p>toc <a href=\"#_Ref100\">Target heading 7</a>
end</p>", xml);
+ assertContains("<p>simple <a href=\"#_Ref100\">1</a> done</p>", xml);
+ assertContains("<p>wrapped <a href=\"#_Ref100\">inner tail</a></p>",
xml);
+ assertContains("<p>noref 5</p>", xml);
+ assertContains("<p>quoted <a href=\"#_Ref100\">qref</a></p>", xml);
+ assertNull(r.metadata.get(Office.HAS_FIELD_HYPERLINKS));
+ }
+
+ @Test
+ public void testUnclosedFieldsDoNotHideLaterFields() throws Exception {
+ // 70 fields that begin and never end, then a HYPERLINK and an
INCLUDEPICTURE
+ XMLResult r = getXML("testWORD_unclosedFields.docx");
+ assertContains("<a href=\"http://example.com/after\">afterLink</a>",
r.xml);
+ assertContains("<a class=\"external-ref-INCLUDEPICTURE\" "
+ + "href=\"http://example.com/pic.png\" />", r.xml);
+ assertEquals("true", r.metadata.get(Office.HAS_FIELD_HYPERLINKS));
+ }
+
@Test
public void testExternalRefFieldCodes() throws Exception {
List<Metadata> metadataList =
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_refFields.docx
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_refFields.docx
new file mode 100644
index 0000000000..3c4cc7776d
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_refFields.docx
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_unclosedFields.docx
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_unclosedFields.docx
new file mode 100644
index 0000000000..96dc81a0ce
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_unclosedFields.docx
differ