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

Reply via email to