You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tika.apache.org by ta...@apache.org on 2026/03/24 01:26:36 UTC

(tika) branch main updated: TIKA-4692 improve ooxml sax parsers (#2700)

This is an automated email from the ASF dual-hosted git repository.

tallison 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 cfa6becdc0 TIKA-4692 improve ooxml sax parsers (#2700)
cfa6becdc0 is described below

commit cfa6becdc0794c88d279e791fe98c2eaff011314
Author: Tim Allison <ta...@apache.org>
AuthorDate: Mon Mar 23 21:26:31 2026 -0400

    TIKA-4692 improve ooxml sax parsers (#2700)
---
 .../main/java/org/apache/tika/metadata/Office.java |    4 +
 .../org/apache/tika/sax/XHTMLContentHandler.java   |    1 -
 .../main/java/org/apache/tika/utils/DateUtils.java |    3 +
 .../tika/parser/microsoft/AbstractListManager.java |    2 +-
 .../tika/parser/microsoft/OfficeParserConfig.java  |   28 +
 ...aragraphProperties.java => CommentAuthors.java} |   59 +-
 .../{xwpf/XWPFNumberingShim.java => EditType.java} |   21 +-
 .../parser/microsoft/ooxml/FieldCodeParser.java    |  109 ++
 .../parser/microsoft/ooxml/InlineTagManager.java   |  196 ++++
 .../microsoft/ooxml/OOXMLInlineBodyPartMap.java    |   82 ++
 .../microsoft/ooxml/OOXMLPartContentCollector.java |  239 ++++
 .../microsoft/ooxml/OOXMLPictureTracker.java       |   99 ++
 .../microsoft/ooxml/OOXMLTikaBodyPartHandler.java  |  390 ++++---
 .../ooxml/OOXMLWordAndPowerPointTextHandler.java   |  333 ++----
 .../microsoft/ooxml/ParagraphProperties.java       |    9 +
 .../parser/microsoft/ooxml/PlaceHolderSkipper.java |   72 ++
 .../microsoft/ooxml/SAXBasedMetadataExtractor.java |  457 ++++++++
 .../ooxml/SXSLFPowerPointExtractorDecorator.java   |  401 +++----
 .../ooxml/SXWPFWordExtractorDecorator.java         |  243 +++-
 .../microsoft/ooxml/XSLFCommentAuthorHandler.java  |   54 +
 .../microsoft/ooxml/XSLFCommentsHandler.java       |   88 ++
 .../microsoft/ooxml/XWPFBodyContentsHandler.java   |  124 +++
 .../parser/microsoft/ooxml/XWPFListManager.java    |  122 +--
 .../ooxml/XWPFWordExtractorDecorator.java          |   24 +-
 .../xslf/XSLFEventBasedPowerPointExtractor.java    |   14 +-
 .../ooxml/xwpf/XWPFEventBasedWordExtractor.java    |   48 +-
 .../microsoft/ooxml/xwpf/XWPFFeatureExtractor.java |    8 +-
 .../microsoft/ooxml/xwpf/XWPFNumberingShim.java    |  268 ++++-
 ...tractorTest.java => AbstractOOXMLDocxTest.java} |  862 +++++----------
 ...tractorTest.java => AbstractOOXMLPptxTest.java} |  343 +-----
 .../parser/microsoft/ooxml/OOXMLDocxDOMTest.java   |  133 +++
 .../parser/microsoft/ooxml/OOXMLDocxSAXTest.java   |  366 +++++++
 .../parser/microsoft/ooxml/OOXMLParserTest.java    | 1155 --------------------
 .../parser/microsoft/ooxml/OOXMLPptxDOMTest.java   |  204 ++++
 .../parser/microsoft/ooxml/OOXMLPptxSAXTest.java   |  272 +++++
 .../resources/test-documents/testWORD_2006ml.docx  |  Bin 165566 -> 151733 bytes
 .../ooxml/AbstractOOXMLDocxPackageTest.java        |   51 +
 .../ooxml/AbstractOOXMLPptxPackageTest.java}       |   46 +-
 ...actorTest.java => OOXMLDocxDOMPackageTest.java} |   38 +-
 ...actorTest.java => OOXMLDocxSAXPackageTest.java} |   35 +-
 .../parser/microsoft/ooxml/OOXMLParserTest.java    |  106 +-
 .../microsoft/ooxml/OOXMLPptxDOMPackageTest.java}  |   21 +-
 .../microsoft/ooxml/OOXMLPptxSAXPackageTest.java}  |   26 +-
 43 files changed, 4102 insertions(+), 3054 deletions(-)

diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Office.java b/tika-core/src/main/java/org/apache/tika/metadata/Office.java
index cf717b38cc..393e3d5004 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Office.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Office.java
@@ -195,6 +195,10 @@ public interface Office {
 
     Property HAS_ANIMATIONS = Property.internalBoolean("msoffice:ppt:has-animations");
 
+    Property NUM_UNLISTED_SLIDES = Property.internalInteger("msoffice:ppt:num-unlisted-slides");
+
+    Property UNLISTED_SLIDE_NAMES = Property.internalTextBag("msoffice:ppt:unlisted-slide-names");
+
     //w:vanish or isVanish or isFldVanish
     Property HAS_HIDDEN_TEXT = Property.internalBoolean("msoffice:doc:has-hidden-text");
 
diff --git a/tika-core/src/main/java/org/apache/tika/sax/XHTMLContentHandler.java b/tika-core/src/main/java/org/apache/tika/sax/XHTMLContentHandler.java
index bae8c4b885..3fd7766d03 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/XHTMLContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/XHTMLContentHandler.java
@@ -94,7 +94,6 @@ public class XHTMLContentHandler extends SafeContentHandler {
     private boolean headStarted = false;
     private boolean headEnded = false;
     private boolean useFrameset = false;
-
     public XHTMLContentHandler(ContentHandler handler, Metadata metadata) {
         this(handler, metadata, null);
     }
diff --git a/tika-core/src/main/java/org/apache/tika/utils/DateUtils.java b/tika-core/src/main/java/org/apache/tika/utils/DateUtils.java
index a6a68fef6e..699ecad50b 100644
--- a/tika-core/src/main/java/org/apache/tika/utils/DateUtils.java
+++ b/tika-core/src/main/java/org/apache/tika/utils/DateUtils.java
@@ -148,6 +148,9 @@ public class DateUtils {
      * @return
      */
     public Date tryToParse(String dateString) {
+        if (dateString == null || dateString.length() < 6) {
+            return null;
+        }
         // Java doesn't like timezones in the form ss+hh:mm
         // It only likes the hhmm form, without the colon
         int n = dateString.length();
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractListManager.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractListManager.java
index 2e5f8f21c4..12d806d09d 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractListManager.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractListManager.java
@@ -248,7 +248,7 @@ public abstract class AbstractListManager {
         }
     }
 
-    protected static class LevelTuple {
+    public static class LevelTuple {
         private final int start;
         private final int restart;
         private final String lvlText;
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java
index 7bba9647b1..db6d4e78e9 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java
@@ -35,6 +35,8 @@ public class OfficeParserConfig implements Serializable {
     private boolean useSAXDocxExtractor = false;
     private boolean useSAXPptxExtractor = false;
 
+    private boolean preferAlternateContentChoice = true;
+
     private boolean writeSelectHeadersInBody = false;
 
     private boolean extractAllAlternativesFromMSG = false;
@@ -167,6 +169,32 @@ public class OfficeParserConfig implements Serializable {
         this.useSAXPptxExtractor = useSAXPptxExtractor;
     }
 
+    /**
+     * In OOXML, {@code mc:AlternateContent} wraps {@code mc:Choice} (newer/richer
+     * rendering, e.g. DrawingML text boxes) and {@code mc:Fallback} (degraded VML
+     * for older consumers). When {@code true} (default), the SAX parser processes
+     * the Choice branch and skips Fallback. When {@code false}, it processes
+     * Fallback and skips Choice (legacy behavior prior to Tika 4.x).
+     * <p>
+     * For text extraction, Choice typically contains equal or more content than
+     * Fallback.
+     * <p>
+     * Default: {@code true}
+     *
+     * @return whether to prefer mc:Choice over mc:Fallback
+     */
+    public boolean isPreferAlternateContentChoice() {
+        return preferAlternateContentChoice;
+    }
+
+    /**
+     * @param preferAlternateContentChoice whether to prefer mc:Choice over mc:Fallback
+     * @see #isPreferAlternateContentChoice()
+     */
+    public void setPreferAlternateContentChoice(boolean preferAlternateContentChoice) {
+        this.preferAlternateContentChoice = preferAlternateContentChoice;
+    }
+
     public boolean isConcatenatePhoneticRuns() {
         return concatenatePhoneticRuns;
     }
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/ParagraphProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/CommentAuthors.java
similarity index 52%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/CommentAuthors.java
index 80c004bea2..654eceee23 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/CommentAuthors.java
@@ -16,40 +16,39 @@
  */
 package org.apache.tika.parser.microsoft.ooxml;
 
+import java.util.HashMap;
+import java.util.Map;
 
-public class ParagraphProperties {
-
-    private String styleId;
-    private int ilvl = -1;
-    private int numId = -1;
-
-    public String getStyleID() {
-        return styleId;
-    }
-
-    public void setStyleID(String styleId) {
-        this.styleId = styleId;
-    }
-
-    public void reset() {
-        styleId = null;
-        ilvl = -1;
-        numId = -1;
-    }
-
-    public int getIlvl() {
-        return ilvl;
-    }
-
-    public void setIlvl(int ilvl) {
-        this.ilvl = ilvl;
+/**
+ * Stores comment author names and initials by ID for PPTX comment handling.
+ */
+class CommentAuthors {
+    final Map<String, String> nameMap = new HashMap<>();
+    final Map<String, String> initialMap = new HashMap<>();
+
+    void add(String id, String name, String initials) {
+        if (id == null) {
+            return;
+        }
+        if (name != null) {
+            nameMap.put(id, name);
+        }
+        if (initials != null) {
+            initialMap.put(id, initials);
+        }
     }
 
-    public int getNumId() {
-        return numId;
+    String getName(String id) {
+        if (id == null) {
+            return null;
+        }
+        return nameMap.get(id);
     }
 
-    public void setNumId(int numId) {
-        this.numId = numId;
+    String getInitials(String id) {
+        if (id == null) {
+            return null;
+        }
+        return initialMap.get(id);
     }
 }
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/xwpf/XWPFNumberingShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/EditType.java
similarity index 60%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/EditType.java
index f563223964..cbc9d1ae86 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/EditType.java
@@ -14,23 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.parser.microsoft.ooxml.xwpf;
-
-import java.io.IOException;
-
-import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
-import org.apache.poi.openxml4j.opc.PackagePart;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
-
-/**
- * Stub class of POI's XWPFNumbering because onDocumentRead() is protected
- */
-public class XWPFNumberingShim extends XWPFNumbering {
-
-    public XWPFNumberingShim(PackagePart part) throws IOException, OpenXML4JException {
-        super(part);
-        onDocumentRead();
-    }
-
+package org.apache.tika.parser.microsoft.ooxml;
 
+public enum EditType {
+    NONE, INSERT, DELETE, MOVE_TO, MOVE_FROM
 }
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
new file mode 100644
index 0000000000..d71cbdc7f9
--- /dev/null
+++ 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
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Parses OOXML field codes (instrText) to extract URLs from HYPERLINK,
+ * INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields.
+ * <p>
+ * This class has no Tika dependencies and could be contributed to POI.
+ */
+public class FieldCodeParser {
+
+    private static final Pattern HYPERLINK_PATTERN =
+            Pattern.compile("HYPERLINK\\s{1,100}\"([^\"]{1,10000})\"",
+                    Pattern.CASE_INSENSITIVE);
+    private static final Pattern INCLUDEPICTURE_PATTERN =
+            Pattern.compile("INCLUDEPICTURE\\s{1,100}\"([^\"]{1,10000})\"",
+                    Pattern.CASE_INSENSITIVE);
+    private static final Pattern INCLUDETEXT_PATTERN =
+            Pattern.compile("INCLUDETEXT\\s{1,100}\"([^\"]{1,10000})\"",
+                    Pattern.CASE_INSENSITIVE);
+    private static final Pattern IMPORT_PATTERN =
+            Pattern.compile("IMPORT\\s{1,100}\"([^\"]{1,10000})\"",
+                    Pattern.CASE_INSENSITIVE);
+    private static final Pattern LINK_PATTERN =
+            Pattern.compile(
+                    "LINK\\s{1,100}[\\w.]{1,50}\\s{1,100}\"([^\"]{1,10000})\"",
+                    Pattern.CASE_INSENSITIVE);
+
+    private FieldCodeParser() {
+    }
+
+    /**
+     * Parses a HYPERLINK URL from instrText field code content.
+     * Field codes like: {@code HYPERLINK "https://example.com"}
+     *
+     * @param instrText the accumulated instrText content
+     * @return the URL if found, or null
+     */
+    public static String parseHyperlinkFromInstrText(String instrText) {
+        if (instrText == null || instrText.isEmpty()) {
+            return null;
+        }
+        Matcher m = HYPERLINK_PATTERN.matcher(instrText.trim());
+        if (m.find()) {
+            return m.group(1);
+        }
+        return null;
+    }
+
+    /**
+     * Parses URLs from instrText field codes that reference external resources.
+     * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields.
+     *
+     * @param instrText the accumulated instrText content
+     * @param fieldType output parameter - will contain the field type if found
+     * @return the URL if found, or null
+     */
+    public static String parseExternalRefFromInstrText(String instrText,
+            StringBuilder fieldType) {
+        if (instrText == null || instrText.isEmpty()) {
+            return null;
+        }
+        String trimmed = instrText.trim();
+
+        Matcher m = INCLUDEPICTURE_PATTERN.matcher(trimmed);
+        if (m.find()) {
+            fieldType.append("INCLUDEPICTURE");
+            return m.group(1);
+        }
+
+        m = INCLUDETEXT_PATTERN.matcher(trimmed);
+        if (m.find()) {
+            fieldType.append("INCLUDETEXT");
+            return m.group(1);
+        }
+
+        m = IMPORT_PATTERN.matcher(trimmed);
+        if (m.find()) {
+            fieldType.append("IMPORT");
+            return m.group(1);
+        }
+
+        m = LINK_PATTERN.matcher(trimmed);
+        if (m.find()) {
+            fieldType.append("LINK");
+            return m.group(1);
+        }
+
+        return null;
+    }
+}
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/InlineTagManager.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/InlineTagManager.java
new file mode 100644
index 0000000000..45eee33b57
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/InlineTagManager.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import org.apache.poi.xwpf.usermodel.UnderlinePatterns;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Manages all inline XHTML elements (hyperlinks and formatting tags) as a
+ * unified state machine, ensuring proper nesting.  The nesting order from
+ * outermost to innermost is:
+ * <pre>
+ *   {@code <a> <b> <i> <s> <u> text </u> </s> </i> </b> </a>}
+ * </pre>
+ * <p>
+ * When a change occurs to an outer element, all inner elements are closed
+ * first, the change is applied, then inner elements are reopened as needed.
+ * This prevents generating malformed XHTML with overlapping or unbalanced tags.
+ * <p>
+ * This class replaces the separate {@code FormattingTagManager} and the
+ * {@code wroteHyperlinkStart} boolean that were previously tracked independently
+ * in {@link OOXMLTikaBodyPartHandler}.
+ */
+class InlineTagManager {
+
+    private final XHTMLContentHandler xhtml;
+
+    private boolean hyperlinkOpen = false;
+    private boolean isBold = false;
+    private boolean isItalics = false;
+    private boolean isStrikeThrough = false;
+    private boolean isUnderline = false;
+
+    InlineTagManager(XHTMLContentHandler xhtml) {
+        this.xhtml = xhtml;
+    }
+
+    /**
+     * Opens a hyperlink.  Since {@code <a>} is the outermost inline element,
+     * any existing inline elements (including a prior hyperlink) are closed
+     * first.
+     *
+     * @param href the link target; if {@code null} this is a no-op
+     */
+    void openHyperlink(String href) throws SAXException {
+        if (href == null) {
+            return;
+        }
+        // Close everything — formatting then any existing hyperlink
+        closeAll();
+        xhtml.startElement("a", "href", href);
+        hyperlinkOpen = true;
+    }
+
+    /**
+     * Closes the current hyperlink and all formatting inside it.
+     * No-op if no hyperlink is open.
+     */
+    void closeHyperlink() throws SAXException {
+        if (!hyperlinkOpen) {
+            return;
+        }
+        closeFormatting();
+        xhtml.endElement("a");
+        hyperlinkOpen = false;
+    }
+
+    /**
+     * Returns {@code true} if a hyperlink is currently open.
+     */
+    boolean isHyperlinkOpen() {
+        return hyperlinkOpen;
+    }
+
+    /**
+     * Reconciles the current formatting state with the given run properties,
+     * opening and closing XHTML tags as needed to maintain proper nesting.
+     * The nesting order for formatting is: {@code <b> <i> <s> <u>}.
+     */
+    void applyFormatting(RunProperties runProperties) throws SAXException {
+        if (runProperties.isBold() != isBold) {
+            // Bold is outermost formatting — close everything inside it
+            if (isUnderline) {
+                xhtml.endElement("u");
+                isUnderline = false;
+            }
+            if (isStrikeThrough) {
+                xhtml.endElement("s");
+                isStrikeThrough = false;
+            }
+            if (isItalics) {
+                xhtml.endElement("i");
+                isItalics = false;
+            }
+            if (runProperties.isBold()) {
+                xhtml.startElement("b");
+            } else {
+                xhtml.endElement("b");
+            }
+            isBold = runProperties.isBold();
+        }
+
+        if (runProperties.isItalics() != isItalics) {
+            if (isUnderline) {
+                xhtml.endElement("u");
+                isUnderline = false;
+            }
+            if (isStrikeThrough) {
+                xhtml.endElement("s");
+                isStrikeThrough = false;
+            }
+            if (runProperties.isItalics()) {
+                xhtml.startElement("i");
+            } else {
+                xhtml.endElement("i");
+            }
+            isItalics = runProperties.isItalics();
+        }
+
+        if (runProperties.isStrikeThrough() != isStrikeThrough) {
+            if (isUnderline) {
+                xhtml.endElement("u");
+                isUnderline = false;
+            }
+            if (runProperties.isStrikeThrough()) {
+                xhtml.startElement("s");
+            } else {
+                xhtml.endElement("s");
+            }
+            isStrikeThrough = runProperties.isStrikeThrough();
+        }
+
+        boolean runIsUnderlined = runProperties.getUnderline() != UnderlinePatterns.NONE;
+        if (runIsUnderlined != isUnderline) {
+            if (runIsUnderlined) {
+                xhtml.startElement("u");
+            } else {
+                xhtml.endElement("u");
+            }
+            isUnderline = runIsUnderlined;
+        }
+    }
+
+    /**
+     * Closes all currently open formatting tags in proper nesting order
+     * (innermost first: u, s, i, b).  Does NOT close the hyperlink.
+     */
+    void closeFormatting() throws SAXException {
+        if (isUnderline) {
+            xhtml.endElement("u");
+            isUnderline = false;
+        }
+        if (isStrikeThrough) {
+            xhtml.endElement("s");
+            isStrikeThrough = false;
+        }
+        if (isItalics) {
+            xhtml.endElement("i");
+            isItalics = false;
+        }
+        if (isBold) {
+            xhtml.endElement("b");
+            isBold = false;
+        }
+    }
+
+    /**
+     * Closes ALL open inline elements — formatting first, then hyperlink.
+     * This is the primary safety mechanism: call at every structural boundary
+     * (end of paragraph, table cell, table row, table, etc.) to guarantee
+     * well-formed XHTML.
+     */
+    void closeAll() throws SAXException {
+        closeFormatting();
+        if (hyperlinkOpen) {
+            xhtml.endElement("a");
+            hyperlinkOpen = false;
+        }
+    }
+}
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/OOXMLInlineBodyPartMap.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLInlineBodyPartMap.java
new file mode 100644
index 0000000000..0738e9939b
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLInlineBodyPartMap.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Holds pre-parsed XML content fragments for OOXML document parts that are
+ * referenced inline from the main document body. Each map stores
+ * ID → raw XML bytes for a specific part type.
+ * <p>
+ * Used for footnotes, endnotes, and comments so that their content can be
+ * inlined at the point of reference rather than dumped at the end.
+ */
+class OOXMLInlineBodyPartMap {
+
+    static final OOXMLInlineBodyPartMap EMPTY = new OOXMLInlineBodyPartMap(
+            Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(),
+            Collections.emptyMap());
+
+    private final Map<String, byte[]> footnotes;
+    private final Map<String, byte[]> endnotes;
+    private final Map<String, byte[]> comments;
+    private final Map<String, String> linkedRelationships;
+
+    OOXMLInlineBodyPartMap(Map<String, byte[]> footnotes,
+            Map<String, byte[]> endnotes,
+            Map<String, byte[]> comments,
+            Map<String, String> linkedRelationships) {
+        this.footnotes = footnotes;
+        this.endnotes = endnotes;
+        this.comments = comments;
+        this.linkedRelationships = linkedRelationships;
+    }
+
+    Map<String, String> getLinkedRelationships() {
+        return linkedRelationships;
+    }
+
+    byte[] getFootnote(String id) {
+        return footnotes.get(id);
+    }
+
+    byte[] getEndnote(String id) {
+        return endnotes.get(id);
+    }
+
+    byte[] getComment(String id) {
+        return comments.get(id);
+    }
+
+    boolean hasFootnotes() {
+        return !footnotes.isEmpty();
+    }
+
+    boolean hasEndnotes() {
+        return !endnotes.isEmpty();
+    }
+
+    boolean hasComments() {
+        return !comments.isEmpty();
+    }
+
+    Iterable<Map.Entry<String, byte[]>> getCommentEntries() {
+        return comments.entrySet();
+    }
+}
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/OOXMLPartContentCollector.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPartContentCollector.java
new file mode 100644
index 0000000000..4b21831638
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPartContentCollector.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * Generic SAX handler that collects raw XML content by ID from OOXML part files.
+ * Works with any part that contains wrapper elements with {@code w:id} attributes
+ * containing body content (paragraphs, tables, formatting, etc.).
+ * <p>
+ * Used for:
+ * <ul>
+ *   <li>footnotes.xml — wrapper element "footnote"</li>
+ *   <li>endnotes.xml — wrapper element "endnote"</li>
+ *   <li>comments.xml — wrapper element "comment"</li>
+ * </ul>
+ * <p>
+ * IDs "0" and "-1" are skipped (these are separator/continuation elements in
+ * footnotes/endnotes).
+ */
+class OOXMLPartContentCollector extends DefaultHandler {
+
+    private static final String W_NS =
+            "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+
+    private final Set<String> wrapperElementNames;
+    private final Set<String> skipIds;
+    private final Map<String, byte[]> contentMap = new HashMap<>();
+    private final Map<String, String> namespaceMappings = new HashMap<>();
+
+    private String currentId = null;
+    private ByteArrayOutputStream buffer = null;
+    private int depth = 0;
+    // Prefix mappings that fired since the last startElement — need to be
+    // emitted as xmlns declarations on the next element inside a collected fragment.
+    private final java.util.List<String[]> pendingPrefixMappings = new java.util.ArrayList<>();
+
+    /**
+     * @param wrapperElementNames local names of wrapper elements to collect
+     *                            (e.g., "footnote", "endnote", "comment")
+     */
+    OOXMLPartContentCollector(Set<String> wrapperElementNames) {
+        this(wrapperElementNames, Set.of("0", "-1"));
+    }
+
+    /**
+     * @param wrapperElementNames local names of wrapper elements to collect
+     * @param skipIds             IDs to skip (e.g., "0", "-1" for footnote
+     *                            separator/continuation elements)
+     */
+    OOXMLPartContentCollector(Set<String> wrapperElementNames, Set<String> skipIds) {
+        this.wrapperElementNames = wrapperElementNames;
+        this.skipIds = skipIds;
+    }
+
+    @Override
+    public void startPrefixMapping(String prefix, String uri) {
+        namespaceMappings.put(prefix, uri);
+        // Track prefix mappings that fire within a collected fragment —
+        // these need to be emitted as xmlns declarations on the next
+        // startElement so that re-parsed fragments have valid namespace bindings.
+        if (currentId != null) {
+            pendingPrefixMappings.add(new String[]{prefix, uri});
+        }
+    }
+
+    Map<String, byte[]> getContentMap() {
+        return contentMap;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName,
+            Attributes atts) throws SAXException {
+        if (currentId != null) {
+            depth++;
+            appendStartTag(localName, qName, atts);
+            return;
+        }
+
+        if (wrapperElementNames.contains(localName)) {
+            String id = atts.getValue(W_NS, "id");
+            if (id != null && !skipIds.contains(id)) {
+                currentId = id;
+                buffer = new ByteArrayOutputStream();
+                writeString(buildWrapperOpenTag());
+                depth = 0;
+            }
+        }
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName)
+            throws SAXException {
+        if (currentId == null) {
+            return;
+        }
+
+        if (depth == 0) {
+            writeString("</w:body>");
+            contentMap.put(currentId, buffer.toByteArray());
+            currentId = null;
+            buffer = null;
+            return;
+        }
+
+        depth--;
+        if (qName != null && !qName.isEmpty()) {
+            writeString("</" + qName + ">");
+        } else {
+            writeString("</" + localName + ">");
+        }
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length) throws SAXException {
+        if (currentId != null) {
+            writeString(escape(new String(ch, start, length)));
+        }
+    }
+
+    private String buildWrapperOpenTag() {
+        StringBuilder sb = new StringBuilder("<w:body");
+        // include all namespace declarations from the source document
+        for (Map.Entry<String, String> entry : namespaceMappings.entrySet()) {
+            String prefix = entry.getKey();
+            String nsUri = entry.getValue();
+            if (prefix == null || prefix.isEmpty()) {
+                sb.append(" xmlns=\"").append(escape(nsUri)).append("\"");
+            } else {
+                sb.append(" xmlns:").append(prefix).append("=\"")
+                        .append(escape(nsUri)).append("\"");
+            }
+        }
+        // ensure w namespace is present
+        if (!namespaceMappings.containsKey("w")) {
+            sb.append(" xmlns:w=\"").append(W_NS).append("\"");
+        }
+        sb.append(">");
+        return sb.toString();
+    }
+
+    private void appendStartTag(String localName, String qName, Attributes atts) {
+        String tagName = (qName != null && !qName.isEmpty()) ? qName : localName;
+        StringBuilder sb = new StringBuilder();
+        sb.append('<').append(tagName);
+        // Emit any namespace declarations that fired since the last element.
+        // In namespace-aware SAX, xmlns:prefix attributes are reported as
+        // startPrefixMapping events, NOT as attributes — so they must be
+        // re-serialized explicitly for the fragment to be re-parseable.
+        if (!pendingPrefixMappings.isEmpty()) {
+            for (String[] mapping : pendingPrefixMappings) {
+                String prefix = mapping[0];
+                String nsUri = mapping[1];
+                if (prefix == null || prefix.isEmpty()) {
+                    sb.append(" xmlns=\"").append(escape(nsUri)).append("\"");
+                } else {
+                    sb.append(" xmlns:").append(prefix).append("=\"")
+                            .append(escape(nsUri)).append("\"");
+                }
+            }
+            pendingPrefixMappings.clear();
+        }
+        for (int i = 0; i < atts.getLength(); i++) {
+            String attName = atts.getQName(i);
+            if (attName == null || attName.isEmpty()) {
+                attName = atts.getLocalName(i);
+            }
+            sb.append(' ').append(attName).append("=\"");
+            sb.append(escape(atts.getValue(i)));
+            sb.append('"');
+        }
+        sb.append('>');
+        writeString(sb.toString());
+    }
+
+    private void writeString(String s) {
+        byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+        buffer.write(bytes, 0, bytes.length);
+    }
+
+    static String escape(String s) {
+        if (s == null) {
+            return "";
+        }
+        StringBuilder sb = null;
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            String replacement = null;
+            switch (c) {
+                case '&':
+                    replacement = "&amp;";
+                    break;
+                case '<':
+                    replacement = "&lt;";
+                    break;
+                case '>':
+                    replacement = "&gt;";
+                    break;
+                case '"':
+                    replacement = "&quot;";
+                    break;
+                default:
+                    if (sb != null) {
+                        sb.append(c);
+                    }
+                    continue;
+            }
+            if (sb == null) {
+                sb = new StringBuilder(s.length() + 16);
+                sb.append(s, 0, i);
+            }
+            sb.append(replacement);
+        }
+        return sb != null ? sb.toString() : s;
+    }
+}
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/OOXMLPictureTracker.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPictureTracker.java
new file mode 100644
index 0000000000..f6a98ecacf
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPictureTracker.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.util.Map;
+
+import org.xml.sax.SAXException;
+
+/**
+ * Tracks the lifecycle of picture elements (PIC, PICT, BLIP, IMAGEDATA, cNvPr)
+ * during OOXML SAX parsing and emits embeddedPicRef callbacks when the picture
+ * scope closes.
+ * <p>
+ * This class has no Tika dependencies and could be contributed to POI.
+ */
+class OOXMLPictureTracker {
+
+    private final Map<String, String> linkedRelationships;
+    private final XWPFBodyContentsHandler bodyContentsHandler;
+
+    private boolean inPic = false;
+    private boolean inPict = false;
+    private String picDescription = null;
+    private String picRId = null;
+    private String lastImageDataRId = null;
+
+    OOXMLPictureTracker(Map<String, String> linkedRelationships,
+            XWPFBodyContentsHandler bodyContentsHandler) {
+        this.linkedRelationships = linkedRelationships;
+        this.bodyContentsHandler = bodyContentsHandler;
+    }
+
+    boolean isInPic() {
+        return inPic;
+    }
+
+    boolean isInPict() {
+        return inPict;
+    }
+
+    void startPic() {
+        inPic = true;
+    }
+
+    void startPict() {
+        inPict = true;
+    }
+
+    void setBlipRId(String rId) {
+        picRId = rId;
+    }
+
+    void setDescription(String description) {
+        picDescription = description;
+    }
+
+    void setImageDataRId(String rId) {
+        picRId = rId;
+        lastImageDataRId = rId;
+    }
+
+    String getImageDataRId() {
+        return lastImageDataRId;
+    }
+
+    void setImageDataDescription(String description) {
+        picDescription = description;
+    }
+
+    /**
+     * Called at end of PIC or PICT element. Resolves the filename from
+     * the relationship map and emits the embeddedPicRef callback.
+     */
+    void endPicture() throws SAXException {
+        String picFileName = null;
+        if (picRId != null) {
+            picFileName = linkedRelationships.get(picRId);
+        }
+        bodyContentsHandler.embeddedPicRef(picFileName, picDescription);
+        picDescription = null;
+        picRId = null;
+        inPic = false;
+        inPict = false;
+    }
+}
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/OOXMLTikaBodyPartHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java
index 4bc445fb5e..ac46090d1c 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java
@@ -17,20 +17,29 @@
 package org.apache.tika.parser.microsoft.ooxml;
 
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.math.BigInteger;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
 
-import org.apache.poi.xwpf.usermodel.UnderlinePatterns;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.AttributesImpl;
 
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.microsoft.OfficeParserConfig;
 import org.apache.tika.parser.microsoft.WordExtractor;
 import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFStylesShim;
+import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
+import org.apache.tika.utils.XMLReaderUtils;
 
 public class OOXMLTikaBodyPartHandler
-        implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler {
+        implements XWPFBodyContentsHandler {
 
     private static final String P = "p";
 
@@ -41,15 +50,12 @@ public class OOXMLTikaBodyPartHandler
     private final boolean includeDeletedText;
     private final boolean includeMoveFromText;
     private final XWPFStylesShim styles;
+    private final Metadata metadata;
 
     private int pDepth = 0; //paragraph depth
     private int tableDepth = 0;//table depth
     private int sdtDepth = 0;//
-    private boolean isItalics = false;
-    private boolean isBold = false;
-    private boolean isUnderline = false;
-    private boolean isStrikeThrough = false;
-    private boolean wroteHyperlinkStart = false;
+    private final InlineTagManager inlineTags;
 
     //TODO: fix this
     //pWithinCell should be an array/stack of given cell depths
@@ -62,9 +68,26 @@ public class OOXMLTikaBodyPartHandler
     //will need to replace this with a stack
     //if we're marking more that the first level <p/> element
     private String paragraphTag = null;
+    private boolean pendingParagraph = false;
+    private boolean paragraphTagOpen = false;
+    private ParagraphProperties pendingParagraphProperties = null;
+    private String pendingHyperlinkHref = null;
+
+    private OOXMLInlineBodyPartMap inlinePartMap = OOXMLInlineBodyPartMap.EMPTY;
+    private ParseContext parseContext = null;
+    private final java.util.List<String[]> pendingNoteIds = new java.util.ArrayList<>();
+    private final java.util.List<String> pendingCommentIds = new java.util.ArrayList<>();
+    private final java.util.Set<String> emittedCommentIds = new java.util.HashSet<>();
+    private final Map<String, EmbeddedPartMetadata> embeddedPartMetadataMap = new HashMap<>();
 
     public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml) {
+        this(xhtml, null);
+    }
+
+    public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, Metadata metadata) {
         this.xhtml = xhtml;
+        this.metadata = metadata;
+        this.inlineTags = new InlineTagManager(xhtml);
         this.styles = XWPFStylesShim.EMPTY_STYLES;
         this.listManager = XWPFListManager.EMPTY_LIST;
         this.includeDeletedText = false;
@@ -72,115 +95,130 @@ public class OOXMLTikaBodyPartHandler
     }
 
     public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles,
-                                    XWPFListManager listManager, OfficeParserConfig parserConfig) {
+                                    XWPFListManager listManager,
+                                    OfficeParserConfig parserConfig) {
+        this(xhtml, styles, listManager, parserConfig, null);
+    }
+
+    public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles,
+                                    XWPFListManager listManager,
+                                    OfficeParserConfig parserConfig, Metadata metadata) {
         this.xhtml = xhtml;
+        this.metadata = metadata;
+        this.inlineTags = new InlineTagManager(xhtml);
         this.styles = styles;
         this.listManager = listManager;
         this.includeDeletedText = parserConfig.isIncludeDeletedContent();
         this.includeMoveFromText = parserConfig.isIncludeMoveFromContent();
     }
 
+    /**
+     * Sets pre-parsed inline body part content (footnotes, endnotes, comments)
+     * so that references encountered during main document parsing can be
+     * resolved inline.
+     */
+    public void setInlineBodyPartMap(OOXMLInlineBodyPartMap inlinePartMap,
+            ParseContext parseContext) {
+        this.inlinePartMap = inlinePartMap != null ? inlinePartMap : OOXMLInlineBodyPartMap.EMPTY;
+        this.parseContext = parseContext;
+    }
+
     @Override
     public void run(RunProperties runProperties, String contents) throws SAXException {
-
-        // True if we are currently in the named style tag:
-        if (runProperties.isBold() != isBold) {
-            if (isStrikeThrough) {
-                xhtml.endElement("strike");
-                isStrikeThrough = false;
-            }
-            if (isUnderline) {
-                xhtml.endElement("u");
-                isUnderline = false;
-            }
-            if (isItalics) {
-                xhtml.endElement("i");
-                isItalics = false;
-            }
-            if (runProperties.isBold()) {
-                xhtml.startElement("b");
-            } else {
-                xhtml.endElement("b");
-            }
-            isBold = runProperties.isBold();
-        }
-
-        if (runProperties.isItalics() != isItalics) {
-            if (isStrikeThrough) {
-                xhtml.endElement("strike");
-                isStrikeThrough = false;
-            }
-            if (isUnderline) {
-                xhtml.endElement("u");
-                isUnderline = false;
-            }
-            if (runProperties.isItalics()) {
-                xhtml.startElement("i");
-            } else {
-                xhtml.endElement("i");
-            }
-            isItalics = runProperties.isItalics();
-        }
-
-        if (runProperties.isStrikeThrough() != isStrikeThrough) {
-            if (isUnderline) {
-                xhtml.endElement("u");
-                isUnderline = false;
-            }
-            if (runProperties.isStrikeThrough()) {
-                xhtml.startElement("strike");
-            } else {
-                xhtml.endElement("strike");
-            }
-            isStrikeThrough = runProperties.isStrikeThrough();
-        }
-
-        boolean runIsUnderlined = runProperties.getUnderline() != UnderlinePatterns.NONE;
-        if (runIsUnderlined != isUnderline) {
-            if (runIsUnderlined) {
-                xhtml.startElement("u");
-            } else {
-                xhtml.endElement("u");
-            }
-            isUnderline = runIsUnderlined;
-        }
-
+        ensureParagraphOpen();
+        flushPendingHyperlink();
+        inlineTags.applyFormatting(runProperties);
         xhtml.characters(contents);
+    }
 
+    private void flushPendingHyperlink() throws SAXException {
+        if (pendingHyperlinkHref != null) {
+            inlineTags.openHyperlink(pendingHyperlinkHref);
+            pendingHyperlinkHref = null;
+        }
     }
 
     @Override
     public void hyperlinkStart(String link) throws SAXException {
-        if (link != null) {
-            xhtml.startElement("a", "href", link);
-            wroteHyperlinkStart = true;
+        // Defer hyperlink opening if no paragraph is open yet.
+        // Shape-level hyperlinks (cNvPr/hlinkClick) fire before any <p>,
+        // so we store the link and open it when the paragraph opens.
+        if (pendingParagraph || pDepth == 0) {
+            pendingHyperlinkHref = link;
+        } else {
+            inlineTags.openHyperlink(link);
         }
     }
 
     @Override
     public void hyperlinkEnd() throws SAXException {
-        if (wroteHyperlinkStart) {
-            closeStyleTags();
-            wroteHyperlinkStart = false;
-            xhtml.endElement("a");
+        if (pendingHyperlinkHref != null) {
+            pendingHyperlinkHref = null;
+        } else {
+            inlineTags.closeHyperlink();
         }
     }
 
+    /**
+     * Closes any open inline elements (hyperlinks, formatting tags) in
+     * the correct nesting order.  Called before closing any structural
+     * element (paragraph, table cell, table row, table, etc.) to ensure
+     * well-formed XHTML.
+     */
+    void closeInlineElements() throws SAXException {
+        inlineTags.closeAll();
+    }
+
+
     @Override
     public void startParagraph(ParagraphProperties paragraphProperties) throws SAXException {
-
         //if you're in a table cell and your after the first paragraph
         //make sure to prepend a \n
         if (tableCellDepth > 0 && pWithinCell > 0) {
             xhtml.characters(NEWLINE, 0, 1);
         }
+        // If we're about to nest a paragraph (e.g. inside a text box / shape),
+        // force-open the outer paragraph first so that inner content ends up
+        // inside the outer <p> tag rather than floating as raw text.
+        if (pendingParagraph && pDepth > 0) {
+            ensureParagraphOpen();
+        }
+        // Record the paragraph as pending — don't emit <p> yet.
+        // We defer opening until the first content arrives (via ensureParagraphOpen)
+        // so that style info from pPr is available.
+        pendingParagraph = true;
+        pendingParagraphProperties = paragraphProperties;
+        pDepth++;
+    }
+
+    @Override
+    public void setParagraphProperties(ParagraphProperties paragraphProperties)
+            throws SAXException {
+        // Copy the properties — the caller may reset the object after this call.
+        // The <p> tag hasn't been emitted yet, so this style will be applied when it opens.
+        if (pendingParagraph) {
+            pendingParagraphProperties = new ParagraphProperties(paragraphProperties);
+        }
+    }
 
-        if (pDepth == 0 && tableDepth == 0 && sdtDepth == 0) {
+    /**
+     * Ensures the current paragraph's XHTML tag is open.  Called before any
+     * content is written (runs, hyperlinks, etc.) so that the deferred
+     * {@code <p>} tag is emitted with the correct style.
+     */
+    private void ensureParagraphOpen() throws SAXException {
+        if (!pendingParagraph) {
+            return;
+        }
+        pendingParagraph = false;
+
+        if (pDepth == 1 && tableDepth == 0 && sdtDepth == 0) {
             paragraphTag = P;
             String styleClass = null;
+            ParagraphProperties pp = pendingParagraphProperties;
             //TIKA-2144 check that styles is not null
-            if (paragraphProperties.getStyleID() != null && styles != null) {
-                String styleName = styles.getStyleName(paragraphProperties.getStyleID());
+            if (pp != null && pp.getStyleID() != null && styles != null) {
+                String styleName = styles.getStyleName(pp.getStyleID());
                 if (styleName != null) {
                     WordExtractor.TagAndStyle tas =
                             WordExtractor.buildParagraphTagAndStyle(styleName, false);
@@ -189,40 +227,98 @@ public class OOXMLTikaBodyPartHandler
                 }
             }
 
-
             if (styleClass == null) {
                 xhtml.startElement(paragraphTag);
             } else {
                 xhtml.startElement(paragraphTag, "class", styleClass);
             }
+            paragraphTagOpen = true;
         }
 
-        writeParagraphNumber(paragraphProperties.getNumId(), paragraphProperties.getIlvl(),
-                listManager, xhtml);
-        pDepth++;
+        if (pendingParagraphProperties != null) {
+            writeParagraphNumber(pendingParagraphProperties.getNumId(),
+                    pendingParagraphProperties.getIlvl(), listManager, xhtml);
+        }
+        pendingParagraphProperties = null;
     }
 
-
     @Override
     public void endParagraph() throws SAXException {
-        closeStyleTags();
-        if (pDepth == 1 && tableDepth == 0) {
+        ensureParagraphOpen();
+        closeInlineElements();
+        if (paragraphTagOpen) {
             xhtml.endElement(paragraphTag);
+            paragraphTagOpen = false;
         } else if (tableCellDepth > 0 && pWithinCell > 0) {
             xhtml.characters(NEWLINE, 0, 1);
         } else if (tableCellDepth == 0) {
             xhtml.characters(NEWLINE, 0, 1);
         }
 
+        // Emit any pending footnote/endnote and comment content after the
+        // paragraph closes.  Inlining mid-paragraph would create <div> inside
+        // <p>, and the inner handler's endParagraph() would close the outer
+        // <p> tag, corrupting state.
+        emitPendingNotes();
+        emitPendingComments();
+
         if (tableCellDepth > 0) {
             pWithinCell++;
         }
         pDepth--;
     }
 
+    private void emitPendingNotes() throws SAXException {
+        if (pendingNoteIds.isEmpty()) {
+            return;
+        }
+        for (String[] noteTypeAndId : pendingNoteIds) {
+            String noteType = noteTypeAndId[0];
+            String id = noteTypeAndId[1];
+            byte[] xml = "footnote".equals(noteType)
+                    ? inlinePartMap.getFootnote(id)
+                    : inlinePartMap.getEndnote(id);
+            if (xml != null) {
+                inlineNoteContent(xml, noteType);
+            } else {
+                xhtml.characters("[");
+                xhtml.characters(id);
+                xhtml.characters("]");
+            }
+        }
+        pendingNoteIds.clear();
+    }
+
+    private void emitPendingComments() throws SAXException {
+        if (pendingCommentIds.isEmpty()) {
+            return;
+        }
+        for (String id : pendingCommentIds) {
+            byte[] xml = inlinePartMap.getComment(id);
+            if (xml != null) {
+                inlineNoteContent(xml, "comment");
+                emittedCommentIds.add(id);
+            }
+        }
+        pendingCommentIds.clear();
+    }
+
+    /**
+     * Returns the set of comment IDs that were inlined during parsing.
+     * Used by the decorator to skip these when dumping remaining comments.
+     */
+    public java.util.Set<String> getEmittedCommentIds() {
+        return emittedCommentIds;
+    }
+
     @Override
     public void startTable() throws SAXException {
-
+        // Close any open paragraph — <table> can't nest inside <p> in XHTML
+        closeInlineElements();
+        if (paragraphTagOpen) {
+            xhtml.endElement(paragraphTag);
+            paragraphTagOpen = false;
+        }
         xhtml.startElement("table");
         tableDepth++;
 
@@ -230,7 +326,7 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void endTable() throws SAXException {
-
+        closeInlineElements();
         xhtml.endElement("table");
         tableDepth--;
 
@@ -243,6 +339,7 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void endTableRow() throws SAXException {
+        closeInlineElements();
         xhtml.endElement("tr");
     }
 
@@ -254,6 +351,7 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void endTableCell() throws SAXException {
+        closeInlineElements();
         xhtml.endElement("td");
         pWithinCell = 0;
         tableCellDepth--;
@@ -261,7 +359,7 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void startSDT() throws SAXException {
-        closeStyleTags();
+        inlineTags.closeAll();
         sdtDepth++;
     }
 
@@ -272,7 +370,7 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void startEditedSection(String editor, Date date,
-                                   OOXMLWordAndPowerPointTextHandler.EditType editType) {
+                                   EditType editType) {
         //no-op
     }
 
@@ -288,32 +386,73 @@ public class OOXMLTikaBodyPartHandler
 
     @Override
     public void footnoteReference(String id) throws SAXException {
-        if (id != null) {
-            xhtml.characters("[");
-            xhtml.characters(id);
-            xhtml.characters("]");
+        if (id == null) {
+            return;
         }
+        // Defer footnote emission to after the paragraph closes.
+        // Inlining mid-paragraph creates <div> inside <p>, and the inner
+        // handler's endParagraph() closes the outer <p> tag, corrupting state.
+        pendingNoteIds.add(new String[]{"footnote", id});
     }
 
     @Override
     public void endnoteReference(String id) throws SAXException {
+        if (id == null) {
+            return;
+        }
+        pendingNoteIds.add(new String[]{"endnote", id});
+    }
+
+    @Override
+    public void commentReference(String id) throws SAXException {
         if (id != null) {
-            xhtml.characters("[");
-            xhtml.characters(id);
-            xhtml.characters("]");
+            pendingCommentIds.add(id);
         }
     }
 
+    private void inlineNoteContent(byte[] xml, String cssClass) throws SAXException {
+        // Close any open inline elements before inlining note content
+        // to ensure the <div> nests correctly
+        closeInlineElements();
+        // Use the inline part map's relationship map which includes relationships
+        // from the footnote/endnote parts (needed for picture resolution)
+        Map<String, String> noteRelationships = inlinePartMap.getLinkedRelationships();
+        xhtml.startElement("div", "class", cssClass);
+        OOXMLTikaBodyPartHandler innerHandler = new OOXMLTikaBodyPartHandler(xhtml);
+        try {
+            XMLReaderUtils.parseSAX(new ByteArrayInputStream(xml),
+                    new EmbeddedContentHandler(
+                            new OOXMLWordAndPowerPointTextHandler(
+                                    innerHandler,
+                                    noteRelationships)),
+                    parseContext);
+        } catch (TikaException | IOException e) {
+            xhtml.characters("[" + cssClass + " parse error]");
+        } finally {
+            innerHandler.closeInlineElements();
+        }
+        xhtml.endElement("div");
+    }
+
     @Override
     public boolean isIncludeMoveFromText() {
         return includeMoveFromText;
     }
 
     @Override
-    public void embeddedOLERef(String relId) throws SAXException {
+    public void embeddedOLERef(String relId, String progId, String emfImageRId)
+            throws SAXException {
         if (relId == null) {
             return;
         }
+        if ((progId != null && !progId.isEmpty()) ||
+                (emfImageRId != null && !emfImageRId.isEmpty())) {
+            EmbeddedPartMetadata epm = new EmbeddedPartMetadata(emfImageRId);
+            if (progId != null && !progId.isEmpty()) {
+                epm.setProgId(progId);
+            }
+            embeddedPartMetadataMap.put(relId, epm);
+        }
         AttributesImpl attributes = new AttributesImpl();
         attributes.addAttribute("", "class", "class", "CDATA", "embedded");
         attributes.addAttribute("", "id", "id", "CDATA", relId);
@@ -321,11 +460,18 @@ public class OOXMLTikaBodyPartHandler
         xhtml.endElement("div");
     }
 
+    public Map<String, EmbeddedPartMetadata> getEmbeddedPartMetadataMap() {
+        return embeddedPartMetadataMap;
+    }
+
     @Override
     public void linkedOLERef(String relId) throws SAXException {
         if (relId == null) {
             return;
         }
+        if (metadata != null) {
+            metadata.set(Office.HAS_LINKED_OLE_OBJECTS, true);
+        }
         // Emit as an external reference anchor - linked OLE objects reference external files
         AttributesImpl attributes = new AttributesImpl();
         attributes.addAttribute("", "class", "class", "CDATA", "external-ref-linkedOle");
@@ -351,11 +497,28 @@ public class OOXMLTikaBodyPartHandler
 
     }
 
+    @Override
+    public void fieldCodeHyperlinkStart(String link) throws SAXException {
+        if (metadata != null) {
+            metadata.set(Office.HAS_FIELD_HYPERLINKS, true);
+        }
+        hyperlinkStart(link);
+    }
+
     @Override
     public void externalRef(String fieldType, String url) throws SAXException {
         if (url == null || url.isEmpty()) {
             return;
         }
+        if (metadata != null) {
+            if ("hlinkHover".equals(fieldType)) {
+                metadata.set(Office.HAS_HOVER_HYPERLINKS, true);
+            } else if ("vml-shape-href".equals(fieldType)) {
+                metadata.set(Office.HAS_VML_HYPERLINKS, true);
+            } else {
+                metadata.set(Office.HAS_FIELD_HYPERLINKS, true);
+            }
+        }
         AttributesImpl attr = new AttributesImpl();
         attr.addAttribute("", "class", "class", "CDATA", "external-ref-" + fieldType);
         attr.addAttribute("", "href", "href", "CDATA", url);
@@ -366,7 +529,7 @@ public class OOXMLTikaBodyPartHandler
     @Override
     public void startBookmark(String id, String name) throws SAXException {
         //skip bookmarks within hyperlinks
-        if (name != null && !wroteHyperlinkStart) {
+        if (name != null && !inlineTags.isHyperlinkOpen()) {
             xhtml.startElement("a", "name", name);
             xhtml.endElement("a");
         }
@@ -377,29 +540,6 @@ public class OOXMLTikaBodyPartHandler
         //no-op
     }
 
-    private void closeStyleTags() throws SAXException {
-
-        if (isStrikeThrough) {
-            xhtml.endElement("strike");
-            isStrikeThrough = false;
-        }
-
-        if (isUnderline) {
-            xhtml.endElement("u");
-            isUnderline = false;
-        }
-
-        if (isItalics) {
-            xhtml.endElement("i");
-            isItalics = false;
-        }
-
-        if (isBold) {
-            xhtml.endElement("b");
-            isBold = false;
-        }
-    }
-
     private void writeParagraphNumber(int numId, int ilvl, XWPFListManager listManager,
                                       XHTMLContentHandler xhtml) throws SAXException {
 
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 3569398a28..00f1ff6c4a 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
@@ -19,16 +19,12 @@ package org.apache.tika.parser.microsoft.ooxml;
 
 import java.util.Date;
 import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 import org.apache.poi.xwpf.usermodel.UnderlinePatterns;
 import org.xml.sax.Attributes;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.DefaultHandler;
 
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.Office;
 import org.apache.tika.utils.DateUtils;
 
 /**
@@ -90,6 +86,7 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     private static final String VAL = "val";
     private static final String SLIDE = "sld";
     private static final String SHOW = "show";
+    private static final String TIMING = "timing"; // p:timing — slide animations
     private final static String MC_NS =
             "http://schemas.openxmlformats.org/markup-compatibility/2006";
     private final static String O_NS = "urn:schemas-microsoft-com:office:office";
@@ -111,7 +108,13 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     private final static String MOVE_FROM = "moveFrom";
     private final static String MOVE_TO = "moveTo";
     private final static String ENDNOTE_REFERENCE = "endnoteReference";
+    private final static String COMMENT_REFERENCE = "commentReference";
     private static final String TEXTBOX = "textbox";
+    private static final String TXBX = "txbx"; // DrawingML text box (wps:txbx in mc:Choice)
+    private static final String SDT = "sdt";
+    private static final String SDT_PR = "sdtPr";
+    private static final String SDT_CONTENT = "sdtContent";
+    private static final String SHOWING_PLCHDR = "showingPlcHdr";
     private final static String FLD_CHAR = "fldChar";
     private final static String INSTR_TEXT = "instrText";
     private final static String FLD_CHAR_TYPE = "fldCharType";
@@ -122,24 +125,14 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     private final static String SHAPE = "shape";
     private final static String HREF = "href";
 
-    // Patterns for extracting URLs from field codes
-    private static final Pattern HYPERLINK_PATTERN =
-            Pattern.compile("HYPERLINK\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE);
-    private static final Pattern INCLUDEPICTURE_PATTERN =
-            Pattern.compile("INCLUDEPICTURE\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE);
-    private static final Pattern INCLUDETEXT_PATTERN =
-            Pattern.compile("INCLUDETEXT\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE);
-    private static final Pattern IMPORT_PATTERN =
-            Pattern.compile("IMPORT\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE);
-    private static final Pattern LINK_PATTERN =
-            Pattern.compile("LINK\\s{1,100}[\\w.]{1,50}\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE);
     private final XWPFBodyContentsHandler bodyContentsHandler;
     private final Map<String, String> linkedRelationships;
+    private final OOXMLPictureTracker pictureTracker;
     private final RunProperties currRunProperties = new RunProperties();
     private final ParagraphProperties currPProperties = new ParagraphProperties();
     private final boolean includeTextBox;
     private final boolean concatenatePhoneticRuns;
-    private final Metadata metadata;
+    private final boolean preferACChoice;
     private final StringBuilder runBuffer = new StringBuilder();
     private final StringBuilder rubyBuffer = new StringBuilder();
     private boolean inR = false;
@@ -148,22 +141,12 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     private boolean inRPr = false;
     private boolean inNumPr = false;
     private boolean inRt = false;
-    private boolean inPic = false;
-    private boolean inPict = false;
-    private String picDescription = null;
-    private String picRId = null;
-    private String picFilename = null;
-    //mechanism used to determine when to
-    //signal the start of the p, and still
-    //handle p with pPr and those without
-    private boolean lastStartElementWasP = false;
-    //have we signaled the start of a p?
-    //pPr can happen multiple times within a p
-    //<p><pPr/><r><t>text</t></r><pPr></p>
-    private boolean pStarted = false;
+    private boolean inPPr = false;
     //alternate content can be embedded in itself.
     //need to track depth.
-    //if in alternate, choose fallback, maybe make this configurable?
+    //preferACChoice controls which branch is processed:
+    //  true  -> process Choice, skip Fallback (richer content)
+    //  false -> process Fallback, skip Choice (legacy behavior)
     private int inACChoiceDepth = 0;
     private int inACFallbackDepth = 0;
     private boolean inDelText = false;
@@ -176,31 +159,38 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     private boolean inInstrText = false;
     private boolean inFieldHyperlink = false;
     private final StringBuilder instrTextBuffer = new StringBuilder();
-    private OOXMLWordAndPowerPointTextHandler.EditType editType =
-            OOXMLWordAndPowerPointTextHandler.EditType.NONE;
+    private EditType editType =
+            EditType.NONE;
     private DateUtils dateUtils = new DateUtils();
 
     private boolean hiddenSlide = false;
+    // SDT (structured document tag) placeholder tracking
+    private boolean inSdtPr = false;
+    private boolean sdtIsPlaceholder = false;
+    private int sdtPlaceholderDepth = 0;
+    private boolean hasAnimations = false;
 
     public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler,
                                              Map<String, String> hyperlinks) {
-        this(bodyContentsHandler, hyperlinks, true, true, null);
+        this(bodyContentsHandler, hyperlinks, true, true, true);
     }
 
     public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler,
                                              Map<String, String> hyperlinks, boolean includeTextBox,
                                              boolean concatenatePhoneticRuns) {
-        this(bodyContentsHandler, hyperlinks, includeTextBox, concatenatePhoneticRuns, null);
+        this(bodyContentsHandler, hyperlinks, includeTextBox, concatenatePhoneticRuns, true);
     }
 
     public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler,
                                              Map<String, String> hyperlinks, boolean includeTextBox,
-                                             boolean concatenatePhoneticRuns, Metadata metadata) {
+                                             boolean concatenatePhoneticRuns,
+                                             boolean preferACChoice) {
         this.bodyContentsHandler = bodyContentsHandler;
         this.linkedRelationships = hyperlinks;
+        this.pictureTracker = new OOXMLPictureTracker(hyperlinks, bodyContentsHandler);
         this.includeTextBox = includeTextBox;
         this.concatenatePhoneticRuns = concatenatePhoneticRuns;
-        this.metadata = metadata;
+        this.preferACChoice = preferACChoice;
     }
 
     @Override
@@ -219,17 +209,23 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
     public void endPrefixMapping(String prefix) throws SAXException {
     }
 
+    /**
+     * Returns true if content should be skipped due to AlternateContent handling.
+     * When preferACChoice is true, skip Fallback; when false, skip Choice.
+     */
+    private boolean inSkippedAlternateContent() {
+        if (preferACChoice) {
+            return inACFallbackDepth > 0;
+        } else {
+            return inACChoiceDepth > 0;
+        }
+    }
+
     @Override
     public void startElement(String uri, String localName, String qName, Attributes atts)
             throws SAXException {
         //TODO: checkBox, textBox, sym, headerReference, footerReference, commentRangeEnd
 
-        if (lastStartElementWasP && !PPR.equals(localName)) {
-            bodyContentsHandler.startParagraph(currPProperties);
-        }
-
-        lastStartElementWasP = false;
-
         if (uri != null && uri.equals(MC_NS)) {
             if (CHOICE.equals(localName)) {
                 inACChoiceDepth++;
@@ -238,11 +234,11 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
             }
         }
 
-        if (inACChoiceDepth > 0) {
+        if (inSkippedAlternateContent()) {
             return;
         }
 
-        if (!includeTextBox && localName.equals(TEXTBOX)) {
+        if (!includeTextBox && (localName.equals(TEXTBOX) || localName.equals(TXBX))) {
             inTextBox = true;
             return;
         }
@@ -258,7 +254,9 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         } else if (TAB.equals(localName)) {
             runBuffer.append(TAB_CHAR);
         } else if (P.equals(localName)) {
-            lastStartElementWasP = true;
+            bodyContentsHandler.startParagraph(currPProperties);
+        } else if (PPR.equals(localName)) {
+            inPPr = true;
         } else if (B.equals(localName)) { //TODO: add bCs
             if (inR && inRPr) {
                 currRunProperties.setBold(true);
@@ -326,18 +324,30 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         } else if (TBL.equals(localName)) {
             bodyContentsHandler.startTable();
         } else if (BLIP.equals(localName)) { //check for DRAWING_NS
-            picRId = atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "embed");
+            pictureTracker.setBlipRId(atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "embed"));
         } else if ("cNvPr".equals(localName)) { //check for PIC_NS?
-            picDescription = atts.getValue("", "descr");
+            pictureTracker.setDescription(atts.getValue("", "descr"));
         } else if (PIC.equals(localName)) {
-            inPic = true; //check for PIC_NS?
-        } //TODO: add sdt, sdtPr, sdtContent goes here statistically
-        else if (FOOTNOTE_REFERENCE.equals(localName)) {
+            pictureTracker.startPic(); //check for PIC_NS?
+        } else if (SDT.equals(localName)) {
+            // SDTs can nest; only track placeholder at outermost level
+            if (sdtPlaceholderDepth == 0) {
+                sdtIsPlaceholder = false;
+            }
+        } else if (SDT_PR.equals(localName)) {
+            inSdtPr = true;
+        } else if (SHOWING_PLCHDR.equals(localName) && inSdtPr) {
+            sdtIsPlaceholder = true;
+        } else if (SDT_CONTENT.equals(localName)) {
+            if (sdtIsPlaceholder) {
+                sdtPlaceholderDepth++;
+            }
+        } else if (FOOTNOTE_REFERENCE.equals(localName)) {
             String id = atts.getValue(W_NS, "id");
             bodyContentsHandler.footnoteReference(id);
         } else if (IMAGEDATA.equals(localName)) {
-            picRId = atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "id");
-            picDescription = atts.getValue(O_NS, "title");
+            pictureTracker.setImageDataRId(atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "id"));
+            pictureTracker.setImageDataDescription(atts.getValue(O_NS, "title"));
         } else if (INS.equals(localName)) {
             startEditedSection(editType.INSERT, atts);
         } else if (DEL_TEXT.equals(localName)) {
@@ -351,7 +361,7 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         } else if (OLE_OBJECT.equals(localName)) { //check for O_NS?
             String type = null;
             String refId = null;
-            //TODO: clean this up and ...want to get ProgID?
+            String progId = null;
             for (int i = 0; i < atts.getLength(); i++) {
                 String attLocalName = atts.getLocalName(i);
                 String attValue = atts.getValue(i);
@@ -360,22 +370,24 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
                 } else if (OFFICE_DOC_RELATIONSHIP_NS.equals(atts.getURI(i)) &&
                         attLocalName.equals("id")) {
                     refId = attValue;
+                } else if ("ProgID".equals(attLocalName)) {
+                    progId = attValue;
                 }
             }
             if ("Embed".equals(type)) {
-                bodyContentsHandler.embeddedOLERef(refId);
+                String emfRId = pictureTracker.getImageDataRId();
+                bodyContentsHandler.embeddedOLERef(refId, progId, emfRId);
             } else if ("Link".equals(type)) {
-                // Linked OLE object - references external file
                 bodyContentsHandler.linkedOLERef(refId);
-                if (metadata != null) {
-                    metadata.set(Office.HAS_LINKED_OLE_OBJECTS, true);
-                }
             }
         } else if (CR.equals(localName)) {
             runBuffer.append(NEWLINE);
         } else if (ENDNOTE_REFERENCE.equals(localName)) {
             String id = atts.getValue(W_NS, "id");
             bodyContentsHandler.endnoteReference(id);
+        } else if (COMMENT_REFERENCE.equals(localName)) {
+            String id = atts.getValue(W_NS, "id");
+            bodyContentsHandler.commentReference(id);
         } else if (V.equals(localName) && C_NS.equals(uri)) { // in value in a chart
             inV = true;
         } else if (RT.equals(localName)) {
@@ -385,6 +397,8 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
             if ("0".equals(val) || "false".equals(val)) {
                 hiddenSlide = true;
             }
+        } else if (TIMING.equals(localName)) {
+            hasAnimations = true;
         } else if (FLD_CHAR.equals(localName)) {
             String fldCharType = atts.getValue(W_NS, FLD_CHAR_TYPE);
             if ("begin".equals(fldCharType)) {
@@ -392,22 +406,17 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
                 instrTextBuffer.setLength(0);
             } else if ("separate".equals(fldCharType)) {
                 // Parse instrText for HYPERLINK
-                String url = parseHyperlinkFromInstrText(instrTextBuffer.toString());
+                String url = FieldCodeParser.parseHyperlinkFromInstrText(instrTextBuffer.toString());
                 if (url != null) {
-                    bodyContentsHandler.hyperlinkStart(url);
+                    bodyContentsHandler.fieldCodeHyperlinkStart(url);
                     inFieldHyperlink = true;
-                    if (metadata != null) {
-                        metadata.set(Office.HAS_FIELD_HYPERLINKS, true);
-                    }
                 } else {
                     // Check for external reference fields (INCLUDEPICTURE, INCLUDETEXT, etc.)
                     StringBuilder fieldType = new StringBuilder();
-                    String extUrl = parseExternalRefFromInstrText(instrTextBuffer.toString(), fieldType);
+                    String extUrl = FieldCodeParser.parseExternalRefFromInstrText(
+                            instrTextBuffer.toString(), fieldType);
                     if (extUrl != null) {
                         bodyContentsHandler.externalRef(fieldType.toString(), extUrl);
-                        if (metadata != null) {
-                            metadata.set(Office.HAS_FIELD_HYPERLINKS, true);
-                        }
                     }
                 }
             } else if ("end".equals(fldCharType)) {
@@ -427,9 +436,6 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
                 String hyperlink = linkedRelationships.get(hyperlinkId);
                 if (hyperlink != null) {
                     bodyContentsHandler.externalRef("hlinkHover", hyperlink);
-                    if (metadata != null) {
-                        metadata.set(Office.HAS_HOVER_HYPERLINKS, true);
-                    }
                 }
             }
         } else if (SHAPE.equals(localName) && V_NS.equals(uri)) {
@@ -440,9 +446,6 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
             }
             if (href != null && !href.isEmpty()) {
                 bodyContentsHandler.externalRef("vml-shape-href", href);
-                if (metadata != null) {
-                    metadata.set(Office.HAS_VML_HYPERLINKS, true);
-                }
             }
         }
 
@@ -479,65 +482,6 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         return -1;
     }
 
-    /**
-     * Parses a HYPERLINK URL from instrText field code content.
-     * Field codes like: HYPERLINK "https://example.com"
-     *
-     * @param instrText the accumulated instrText content
-     * @return the URL if found, or null
-     */
-    private String parseHyperlinkFromInstrText(String instrText) {
-        if (instrText == null || instrText.isEmpty()) {
-            return null;
-        }
-        Matcher m = HYPERLINK_PATTERN.matcher(instrText.trim());
-        if (m.find()) {
-            return m.group(1);
-        }
-        return null;
-    }
-
-    /**
-     * Parses URLs from instrText field codes that reference external resources.
-     * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields.
-     *
-     * @param instrText the accumulated instrText content
-     * @param fieldType output parameter - will contain the field type if found
-     * @return the URL if found, or null
-     */
-    private String parseExternalRefFromInstrText(String instrText, StringBuilder fieldType) {
-        if (instrText == null || instrText.isEmpty()) {
-            return null;
-        }
-        String trimmed = instrText.trim();
-
-        Matcher m = INCLUDEPICTURE_PATTERN.matcher(trimmed);
-        if (m.find()) {
-            fieldType.append("INCLUDEPICTURE");
-            return m.group(1);
-        }
-
-        m = INCLUDETEXT_PATTERN.matcher(trimmed);
-        if (m.find()) {
-            fieldType.append("INCLUDETEXT");
-            return m.group(1);
-        }
-
-        m = IMPORT_PATTERN.matcher(trimmed);
-        if (m.find()) {
-            fieldType.append("IMPORT");
-            return m.group(1);
-        }
-
-        m = LINK_PATTERN.matcher(trimmed);
-        if (m.find()) {
-            fieldType.append("LINK");
-            return m.group(1);
-        }
-
-        return null;
-    }
-
     @Override
     public void endElement(String uri, String localName, String qName) throws SAXException {
 
@@ -546,17 +490,20 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         } else if (FALLBACK.equals(localName)) {
             inACFallbackDepth--;
         }
-        if (inACChoiceDepth > 0) {
+        if (inSkippedAlternateContent()) {
             return;
         }
 
-        if (!includeTextBox && localName.equals(TEXTBOX)) {
+        if (!includeTextBox && (localName.equals(TEXTBOX) || localName.equals(TXBX))) {
             inTextBox = false;
             return;
         }
         if (PIC.equals(localName)) { //PIC_NS
-            handlePict();
-            inPic = false;
+            pictureTracker.endPicture();
+            if (inHlinkClick) {
+                bodyContentsHandler.hyperlinkEnd();
+                inHlinkClick = false;
+            }
             return;
         } else if (RPR.equals(localName)) {
             inRPr = false;
@@ -565,10 +512,8 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         } else if (T.equals(localName)) {
             inT = false;
         } else if (PPR.equals(localName)) {
-            if (!pStarted) {
-                bodyContentsHandler.startParagraph(currPProperties);
-                pStarted = true;
-            }
+            inPPr = false;
+            bodyContentsHandler.setParagraphProperties(currPProperties);
             currPProperties.reset();
         } else if (P.equals(localName)) {
             if (runBuffer.length() > 0) {
@@ -577,7 +522,6 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
                 bodyContentsHandler.run(currRunProperties, runBuffer.toString());
                 runBuffer.setLength(0);
             }
-            pStarted = false;
             bodyContentsHandler.endParagraph();
         } else if (TC.equals(localName)) {
             bodyContentsHandler.endTableCell();
@@ -594,8 +538,16 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
             editType = EditType.NONE;
         } else if (HYPERLINK.equals(localName)) {
             bodyContentsHandler.hyperlinkEnd();
+        } else if (SDT_PR.equals(localName)) {
+            inSdtPr = false;
+        } else if (SDT_CONTENT.equals(localName)) {
+            if (sdtPlaceholderDepth > 0) {
+                sdtPlaceholderDepth--;
+            }
+        } else if (SDT.equals(localName)) {
+            sdtIsPlaceholder = false;
         } else if (PICT.equals(localName)) {
-            handlePict();
+            pictureTracker.endPicture();
         } else if (V.equals(localName) && C_NS.equals(uri)) { // in value in a chart
             inV = false;
             handleEndOfRun();
@@ -631,24 +583,15 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         currRunProperties.setUnderline(UnderlinePatterns.NONE.name());
     }
 
-    private void handlePict() throws SAXException {
-        String picFileName = null;
-        if (picRId != null) {
-            picFileName = linkedRelationships.get(picRId);
-        }
-        bodyContentsHandler.embeddedPicRef(picFileName, picDescription);
-        picDescription = null;
-        picRId = null;
-        inPic = false;
-    }
-
     @Override
     public void characters(char[] ch, int start, int length) throws SAXException {
 
-        if (inACChoiceDepth > 0) {
+        if (inSkippedAlternateContent()) {
             return;
         } else if (!includeTextBox && inTextBox) {
             return;
+        } else if (sdtPlaceholderDepth > 0) {
+            return;
         }
 
         if (editType.equals(EditType.MOVE_FROM) && inT) {
@@ -670,10 +613,12 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
 
     @Override
     public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
-        if (inACChoiceDepth > 0) {
+        if (inSkippedAlternateContent()) {
             return;
         } else if (!includeTextBox && inTextBox) {
             return;
+        } else if (sdtPlaceholderDepth > 0) {
+            return;
         }
 
         if (inT) {
@@ -691,81 +636,11 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler {
         }
     }
 
-    public enum EditType {
-        NONE, INSERT, DELETE, MOVE_TO, MOVE_FROM
-    }
-
-    public interface XWPFBodyContentsHandler {
-
-        void run(RunProperties runProperties, String contents) throws SAXException;
-
-        /**
-         * @param link the link; can be null
-         */
-        void hyperlinkStart(String link) throws SAXException;
-
-        void hyperlinkEnd() throws SAXException;
-
-        void startParagraph(ParagraphProperties paragraphProperties) throws SAXException;
-
-        void endParagraph() throws SAXException;
-
-        void startTable() throws SAXException;
-
-        void endTable() throws SAXException;
-
-        void startTableRow() throws SAXException;
-
-        void endTableRow() throws SAXException;
-
-        void startTableCell() throws SAXException;
-
-        void endTableCell() throws SAXException;
-
-        void startSDT() throws SAXException;
-
-        void endSDT() throws SAXException;
-
-        void startEditedSection(String editor, Date date, EditType editType) throws SAXException;
-
-        void endEditedSection() throws SAXException;
-
-        boolean isIncludeDeletedText() throws SAXException;
-
-        void footnoteReference(String id) throws SAXException;
-
-        void endnoteReference(String id) throws SAXException;
-
-        boolean isIncludeMoveFromText() throws SAXException;
-
-        void embeddedOLERef(String refId) throws SAXException;
-
-        /**
-         * Called when a linked (vs embedded) OLE object is found.
-         * These reference external files and are a security concern.
-         */
-        void linkedOLERef(String refId) throws SAXException;
-
-        void embeddedPicRef(String picFileName, String picDescription) throws SAXException;
-
-        void startBookmark(String id, String name) throws SAXException;
-
-        void endBookmark(String id) throws SAXException;
-
-        /**
-         * Called when an external reference URL is found in a field code.
-         * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK fields,
-         * and DrawingML/VML hyperlinks on shapes.
-         *
-         * @param fieldType the type of field (e.g., "INCLUDEPICTURE", "hlinkHover", "vml-href")
-         * @param url the external URL
-         */
-        default void externalRef(String fieldType, String url) throws SAXException {
-            // Default no-op implementation for backward compatibility
-        }
-    }
-
     public boolean isHiddenSlide() {
         return hiddenSlide;
     }
+
+    public boolean hasAnimations() {
+        return hasAnimations;
+    }
 }
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/ParagraphProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
index 80c004bea2..45fe1dfd05 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
@@ -23,6 +23,15 @@ public class ParagraphProperties {
     private int ilvl = -1;
     private int numId = -1;
 
+    public ParagraphProperties() {
+    }
+
+    public ParagraphProperties(ParagraphProperties other) {
+        this.styleId = other.styleId;
+        this.ilvl = other.ilvl;
+        this.numId = other.numId;
+    }
+
     public String getStyleID() {
         return styleId;
     }
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/PlaceHolderSkipper.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/PlaceHolderSkipper.java
new file mode 100644
index 0000000000..bad784967b
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/PlaceHolderSkipper.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * SAX handler wrapper that skips content inside placeholder ({@code <ph>})
+ * elements in PPTX slide masters and layouts. Delegates all non-placeholder
+ * events to the wrapped handler.
+ */
+class PlaceHolderSkipper extends DefaultHandler {
+
+    private final ContentHandler wrappedHandler;
+    private boolean inPH = false;
+
+    PlaceHolderSkipper(ContentHandler wrappedHandler) {
+        this.wrappedHandler = wrappedHandler;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName, Attributes atts)
+            throws SAXException {
+        if ("ph".equals(localName)) {
+            inPH = true;
+        }
+        if (!inPH) {
+            wrappedHandler.startElement(uri, localName, qName, atts);
+        }
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName) throws SAXException {
+        if (!inPH) {
+            wrappedHandler.endElement(uri, localName, qName);
+        }
+        if ("sp".equals(localName)) {
+            inPH = false;
+        }
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length) throws SAXException {
+        if (!inPH) {
+            wrappedHandler.characters(ch, start, length);
+        }
+    }
+
+    @Override
+    public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
+        if (!inPH) {
+            wrappedHandler.characters(ch, start, length);
+        }
+    }
+}
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/SAXBasedMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractor.java
new file mode 100644
index 0000000000..7307e35437
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractor.java
@@ -0,0 +1,457 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.Optional;
+
+import org.apache.poi.openxml4j.opc.OPCPackage;
+import org.apache.poi.openxml4j.opc.PackagePart;
+import org.apache.poi.openxml4j.opc.PackageProperties;
+import org.apache.poi.openxml4j.opc.PackageRelationship;
+import org.apache.poi.openxml4j.opc.PackageRelationshipCollection;
+import org.xml.sax.Attributes;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.DublinCore;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.OfficeOpenXMLCore;
+import org.apache.tika.metadata.OfficeOpenXMLExtended;
+import org.apache.tika.metadata.PagedText;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.SummaryExtractor;
+import org.apache.tika.utils.XMLReaderUtils;
+
+/**
+ * SAX-based metadata extractor for OOXML documents that reads document properties
+ * directly from the OPC package without needing POIXMLProperties or ooxml-lite schemas.
+ * <p>
+ * Core properties are read from {@link PackagePropertiesPart} (OPC level).
+ * Extended properties (app.xml) and custom properties (custom.xml) are parsed with SAX.
+ */
+class SAXBasedMetadataExtractor extends MetadataExtractor {
+
+    private static final String EXTENDED_PROPERTIES_REL =
+            "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
+    private static final String CUSTOM_PROPERTIES_REL =
+            "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
+
+    private final OPCPackage opcPackage;
+    private final ParseContext parseContext;
+
+    SAXBasedMetadataExtractor(OPCPackage opcPackage, ParseContext parseContext) {
+        super(null);
+        this.opcPackage = opcPackage;
+        this.parseContext = parseContext;
+    }
+
+    @Override
+    public void extract(Metadata metadata) throws TikaException {
+        extractCoreProperties(metadata);
+        extractExtendedProperties(metadata);
+        extractCustomProperties(metadata);
+    }
+
+    private void extractCoreProperties(Metadata metadata) {
+        try {
+            PackageProperties props = opcPackage.getPackageProperties();
+            if (props == null) {
+                return;
+            }
+            setProperty(metadata, OfficeOpenXMLCore.CATEGORY, props.getCategoryProperty());
+            setProperty(metadata, OfficeOpenXMLCore.CONTENT_STATUS,
+                    props.getContentStatusProperty());
+            setProperty(metadata, TikaCoreProperties.CREATED, props.getCreatedProperty());
+            addMultiProperty(metadata, TikaCoreProperties.CREATOR, props.getCreatorProperty());
+            setProperty(metadata, TikaCoreProperties.DESCRIPTION,
+                    props.getDescriptionProperty());
+            setProperty(metadata, TikaCoreProperties.IDENTIFIER, props.getIdentifierProperty());
+            addProperty(metadata, DublinCore.SUBJECT, props.getSubjectProperty());
+            addProperty(metadata, Office.KEYWORDS, props.getKeywordsProperty());
+            setProperty(metadata, TikaCoreProperties.LANGUAGE, props.getLanguageProperty());
+            setProperty(metadata, TikaCoreProperties.MODIFIER,
+                    props.getLastModifiedByProperty());
+            setProperty(metadata, TikaCoreProperties.PRINT_DATE,
+                    props.getLastPrintedProperty());
+            setProperty(metadata, TikaCoreProperties.MODIFIED, props.getModifiedProperty());
+            setProperty(metadata, OfficeOpenXMLCore.REVISION, props.getRevisionProperty());
+            setProperty(metadata, TikaCoreProperties.TITLE, props.getTitleProperty());
+            setProperty(metadata, OfficeOpenXMLCore.VERSION, props.getVersionProperty());
+        } catch (Exception e) {
+            //swallow
+        }
+    }
+
+    private void extractExtendedProperties(Metadata metadata) {
+        try {
+            PackagePart extPart = getRelatedPart(EXTENDED_PROPERTIES_REL);
+            if (extPart == null) {
+                return;
+            }
+            ExtendedPropertiesHandler handler = new ExtendedPropertiesHandler();
+            try (InputStream is = extPart.getInputStream()) {
+                XMLReaderUtils.parseSAX(is, handler, parseContext);
+            }
+            handler.applyTo(metadata);
+        } catch (Exception e) {
+            //swallow
+        }
+    }
+
+    private void extractCustomProperties(Metadata metadata) {
+        try {
+            PackagePart custPart = getRelatedPart(CUSTOM_PROPERTIES_REL);
+            if (custPart == null) {
+                return;
+            }
+            CustomPropertiesHandler handler = new CustomPropertiesHandler();
+            try (InputStream is = custPart.getInputStream()) {
+                XMLReaderUtils.parseSAX(is, handler, parseContext);
+            }
+            handler.applyTo(metadata);
+        } catch (Exception e) {
+            //swallow
+        }
+    }
+
+    private PackagePart getRelatedPart(String relationshipType) {
+        try {
+            PackageRelationshipCollection rels =
+                    opcPackage.getRelationshipsByType(relationshipType);
+            if (rels == null || rels.size() == 0) {
+                return null;
+            }
+            PackageRelationship rel = rels.getRelationship(0);
+            if (rel == null) {
+                return null;
+            }
+            return opcPackage.getPart(rel);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private <T> void setProperty(Metadata metadata, Property property,
+                                 Optional<T> optionalValue) {
+        if (!optionalValue.isPresent()) {
+            return;
+        }
+        T value = optionalValue.get();
+        if (value instanceof Date) {
+            metadata.set(property, (Date) value);
+        } else if (value instanceof String) {
+            metadata.set(property, (String) value);
+        } else if (value instanceof Integer) {
+            metadata.set(property, (Integer) value);
+        } else if (value instanceof Double) {
+            metadata.set(property, (Double) value);
+        }
+    }
+
+    private <T> void addProperty(Metadata metadata, Property property,
+                                 Optional<T> optionalValue) {
+        if (!optionalValue.isPresent()) {
+            return;
+        }
+        T value = optionalValue.get();
+        if (value instanceof String) {
+            metadata.add(property, (String) value);
+        }
+    }
+
+    private void addMultiProperty(Metadata metadata, Property property,
+                                  Optional<String> value) {
+        if (!value.isPresent()) {
+            return;
+        }
+        SummaryExtractor.addMulti(metadata, property, value.get());
+    }
+
+    /**
+     * SAX handler for docProps/app.xml (extended properties).
+     */
+    private static class ExtendedPropertiesHandler extends DefaultHandler {
+
+        private String application;
+        private String appVersion;
+        private String company;
+        private String manager;
+        private String notes;
+        private String presentationFormat;
+        private String template;
+        private int totalTime;
+        private int docSecurity;
+        private int pages;
+        private int slides;
+        private int paragraphs;
+        private int lines;
+        private int words;
+        private int characters;
+        private int charactersWithSpaces;
+
+        private String currentElement;
+        private final StringBuilder textBuffer = new StringBuilder();
+
+        @Override
+        public void startElement(String uri, String localName, String qName, Attributes atts) {
+            currentElement = localName;
+            textBuffer.setLength(0);
+        }
+
+        @Override
+        public void characters(char[] ch, int start, int length) {
+            textBuffer.append(ch, start, length);
+        }
+
+        @Override
+        public void endElement(String uri, String localName, String qName) {
+            if (!localName.equals(currentElement)) {
+                return;
+            }
+            String val = textBuffer.toString().trim();
+            if (val.isEmpty()) {
+                currentElement = null;
+                return;
+            }
+            switch (localName) {
+                case "Application":
+                    application = val;
+                    break;
+                case "AppVersion":
+                    appVersion = val;
+                    break;
+                case "Company":
+                    company = val;
+                    break;
+                case "Manager":
+                    manager = val;
+                    break;
+                case "Notes":
+                    notes = val;
+                    break;
+                case "PresentationFormat":
+                    presentationFormat = val;
+                    break;
+                case "Template":
+                    template = val;
+                    break;
+                case "TotalTime":
+                    totalTime = safeParseInt(val);
+                    break;
+                case "DocSecurity":
+                    docSecurity = safeParseInt(val);
+                    break;
+                case "Pages":
+                    pages = safeParseInt(val);
+                    break;
+                case "Slides":
+                    slides = safeParseInt(val);
+                    break;
+                case "Paragraphs":
+                    paragraphs = safeParseInt(val);
+                    break;
+                case "Lines":
+                    lines = safeParseInt(val);
+                    break;
+                case "Words":
+                    words = safeParseInt(val);
+                    break;
+                case "Characters":
+                    characters = safeParseInt(val);
+                    break;
+                case "CharactersWithSpaces":
+                    charactersWithSpaces = safeParseInt(val);
+                    break;
+                default:
+                    break;
+            }
+            currentElement = null;
+        }
+
+        private int safeParseInt(String val) {
+            try {
+                // Handle unsigned int overflow (TIKA-2055)
+                long l = Long.parseLong(val);
+                if (l > Integer.MAX_VALUE || l < 0) {
+                    return 0;
+                }
+                return (int) l;
+            } catch (NumberFormatException e) {
+                return 0;
+            }
+        }
+
+        void applyTo(Metadata metadata) {
+            setIfNotNull(metadata, OfficeOpenXMLExtended.APPLICATION, application);
+            setIfNotNull(metadata, OfficeOpenXMLExtended.APP_VERSION, appVersion);
+            setIfNotNull(metadata, TikaCoreProperties.PUBLISHER, company);
+            setIfNotNull(metadata, OfficeOpenXMLExtended.COMPANY, company);
+            if (manager != null) {
+                SummaryExtractor.addMulti(metadata, OfficeOpenXMLExtended.MANAGER, manager);
+            }
+            setIfNotNull(metadata, OfficeOpenXMLExtended.NOTES, notes);
+            setIfNotNull(metadata, OfficeOpenXMLExtended.PRESENTATION_FORMAT, presentationFormat);
+            setIfNotNull(metadata, OfficeOpenXMLExtended.TEMPLATE, template);
+            setIfPositive(metadata, OfficeOpenXMLExtended.TOTAL_TIME, totalTime);
+            setIfPositive(metadata, OfficeOpenXMLExtended.DOC_SECURITY, docSecurity);
+            metadata.set(OfficeOpenXMLExtended.DOC_SECURITY_STRING,
+                    getDocSecurityString(docSecurity));
+
+            if (pages > 0) {
+                metadata.set(PagedText.N_PAGES, pages);
+            } else if (slides > 0) {
+                metadata.set(PagedText.N_PAGES, slides);
+            }
+
+            setIfPositive(metadata, Office.PAGE_COUNT, pages);
+            setIfPositive(metadata, Office.SLIDE_COUNT, slides);
+            setIfPositive(metadata, Office.PARAGRAPH_COUNT, paragraphs);
+            setIfPositive(metadata, Office.LINE_COUNT, lines);
+            setIfPositive(metadata, Office.WORD_COUNT, words);
+            setIfPositive(metadata, Office.CHARACTER_COUNT, characters);
+            setIfPositive(metadata, Office.CHARACTER_COUNT_WITH_SPACES, charactersWithSpaces);
+        }
+
+        private void setIfNotNull(Metadata metadata, Property property, String value) {
+            if (value != null) {
+                metadata.set(property, value);
+            }
+        }
+
+        private void setIfPositive(Metadata metadata, Property property, int value) {
+            if (value > 0) {
+                metadata.set(property, value);
+            }
+        }
+
+        private String getDocSecurityString(int flag) {
+            switch (flag) {
+                case 0:
+                    return OfficeOpenXMLExtended.SECURITY_NONE;
+                case 1:
+                    return OfficeOpenXMLExtended.SECURITY_PASSWORD_PROTECTED;
+                case 2:
+                    return OfficeOpenXMLExtended.SECURITY_READ_ONLY_RECOMMENDED;
+                case 4:
+                    return OfficeOpenXMLExtended.SECURITY_READ_ONLY_ENFORCED;
+                case 8:
+                    return OfficeOpenXMLExtended.SECURITY_LOCKED_FOR_ANNOTATIONS;
+                default:
+                    return OfficeOpenXMLExtended.SECURITY_UNKNOWN;
+            }
+        }
+    }
+
+    /**
+     * SAX handler for docProps/custom.xml (custom properties).
+     */
+    private static class CustomPropertiesHandler extends DefaultHandler {
+
+        private static final String VT_NS =
+                "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
+
+        private final Metadata customMetadata = new Metadata();
+        private String currentPropertyName;
+        private String currentValueType;
+        private final StringBuilder textBuffer = new StringBuilder();
+
+        @Override
+        public void startElement(String uri, String localName, String qName, Attributes atts) {
+            if ("property".equals(localName)) {
+                currentPropertyName = atts.getValue("name");
+                currentValueType = null;
+            } else if (VT_NS.equals(uri) && currentPropertyName != null) {
+                currentValueType = localName;
+                textBuffer.setLength(0);
+            }
+        }
+
+        @Override
+        public void characters(char[] ch, int start, int length) {
+            textBuffer.append(ch, start, length);
+        }
+
+        @Override
+        public void endElement(String uri, String localName, String qName) {
+            if (VT_NS.equals(uri) && currentValueType != null &&
+                    localName.equals(currentValueType) && currentPropertyName != null) {
+                String val = textBuffer.toString().trim();
+                String propName = "custom:" + currentPropertyName;
+                switch (currentValueType) {
+                    case "lpwstr":
+                    case "lpstr":
+                    case "bstr":
+                        customMetadata.set(propName, val);
+                        break;
+                    case "filetime":
+                    case "date":
+                        Property tikaProp = Property.externalDate(propName);
+                        customMetadata.set(tikaProp, val);
+                        break;
+                    case "bool":
+                        customMetadata.set(propName, val);
+                        break;
+                    case "i1":
+                    case "i2":
+                    case "i4":
+                    case "int":
+                    case "ui1":
+                    case "ui2":
+                        customMetadata.set(propName, val);
+                        break;
+                    case "i8":
+                    case "ui4":
+                    case "ui8":
+                    case "uint":
+                        customMetadata.set(propName, val);
+                        break;
+                    case "r4":
+                    case "r8":
+                        customMetadata.set(propName, val);
+                        break;
+                    case "decimal":
+                        try {
+                            BigDecimal d = new BigDecimal(val);
+                            customMetadata.set(propName, d.toPlainString());
+                        } catch (NumberFormatException e) {
+                            //swallow
+                        }
+                        break;
+                    default:
+                        break;
+                }
+                currentValueType = null;
+            } else if ("property".equals(localName)) {
+                currentPropertyName = null;
+            }
+        }
+
+        void applyTo(Metadata metadata) {
+            for (String name : customMetadata.names()) {
+                for (String value : customMetadata.getValues(name)) {
+                    metadata.add(name, value);
+                }
+            }
+        }
+    }
+}
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/SXSLFPowerPointExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
index 45f4657f75..c16b75fd8c 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
@@ -20,6 +20,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.zip.ZipException;
@@ -34,7 +35,6 @@ import org.apache.poi.openxml4j.opc.PackagingURIHelper;
 import org.apache.poi.openxml4j.opc.TargetMode;
 import org.apache.poi.xslf.usermodel.XSLFRelation;
 import org.xml.sax.Attributes;
-import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.DefaultHandler;
 
@@ -99,39 +99,27 @@ public class SXSLFPowerPointExtractorDecorator extends AbstractOOXMLExtractor {
     protected void buildXHTML(XHTMLContentHandler xhtml) throws SAXException, IOException {
 
         loadCommentAuthors();
+        addCommentAuthorMetadata();
 
+        List<PackagePart> orderedSlides = getOrderedSlideParts();
 
-        PackageRelationshipCollection slidesPRC = null;
-        try {
-            slidesPRC = mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
-        } catch (InvalidFormatException e) {
-            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                    ExceptionUtils.getStackTrace(e));
-        }
-
-        if (slidesPRC != null && slidesPRC.size() > 0) {
-            for (int i = 0; i < slidesPRC.size(); i++) {
-                try {
-                    handleSlidePart(mainDocument.getRelatedPart(slidesPRC.getRelationship(i)),
-                            xhtml);
-                } catch (InvalidFormatException | ZipException e) {
-                    metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                            ExceptionUtils.getStackTrace(e));
-                }
+        int hiddenSlideCount = 0;
+        for (PackagePart slidePart : orderedSlides) {
+            try {
+                hiddenSlideCount += handleSlidePart(slidePart, xhtml);
+            } catch (ZipException e) {
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        ExceptionUtils.getStackTrace(e));
             }
         }
+        if (hiddenSlideCount > 0) {
+            metadata.set(Office.NUM_HIDDEN_SLIDES, hiddenSlideCount);
+        }
 
         if (config.isIncludeSlideMasterContent()) {
-            handleGeneralTextContainingPart(XSLFRelation.SLIDE_MASTER.getRelation(), "slide-master",
-                    mainDocument, metadata, new PlaceHolderSkipper(
-                            new OOXMLWordAndPowerPointTextHandler(
-                                    new OOXMLTikaBodyPartHandler(xhtml),
-                                    new HashMap<>())));
-
-            handleGeneralTextContainingPart(HANDOUT_MASTER, "slide-handout-master", mainDocument,
-                    metadata,
-                    new OOXMLWordAndPowerPointTextHandler(new OOXMLTikaBodyPartHandler(xhtml),
-                            new HashMap<>()));
+            // Handout master is presentation-level, not per-slide
+            handleTextPartWithCleanup(HANDOUT_MASTER, "slide-handout-master", mainDocument,
+                    xhtml, new HashMap<>(), false);
         }
     }
 
@@ -160,7 +148,7 @@ public class SXSLFPowerPointExtractorDecorator extends AbstractOOXMLExtractor {
             }
             try (InputStream stream = commentAuthorsPart.getInputStream()) {
                 XMLReaderUtils.parseSAX(stream,
-                        new XSLFCommentAuthorHandler(), context);
+                        new XSLFCommentAuthorHandler(commentAuthors), context);
 
             } catch (TikaException | SAXException | IOException e) {
                 metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
@@ -170,58 +158,194 @@ public class SXSLFPowerPointExtractorDecorator extends AbstractOOXMLExtractor {
 
     }
 
-    private void handleSlidePart(PackagePart slidePart, XHTMLContentHandler xhtml)
+    private void addCommentAuthorMetadata() {
+        for (String name : commentAuthors.nameMap.values()) {
+            if (name != null && !name.isBlank()) {
+                metadata.add(Office.COMMENT_PERSONS, name);
+            }
+        }
+    }
+
+    /**
+     * Returns the first related part for the given relationship type,
+     * or null if none found.
+     */
+    private PackagePart getRelatedPartByType(PackagePart source, String relationType) {
+        try {
+            PackageRelationshipCollection prc = source.getRelationshipsByType(relationType);
+            if (prc != null && prc.size() > 0) {
+                return source.getRelatedPart(prc.getRelationship(0));
+            }
+        } catch (InvalidFormatException | IllegalArgumentException e) {
+            // missing part
+        }
+        return null;
+    }
+
+    /**
+     * Returns slide parts in presentation order by parsing the sldIdLst
+     * from presentation.xml.  Any slides found in .rels but not in
+     * the sldIdLst are appended at the end.
+     */
+    private List<PackagePart> getOrderedSlideParts() {
+        // Step 1: parse presentation.xml to get ordered rIds from sldIdLst
+        List<String> orderedRIds = new ArrayList<>();
+        try (InputStream is = mainDocument.getInputStream()) {
+            XMLReaderUtils.parseSAX(is, new DefaultHandler() {
+                private boolean inSldIdLst = false;
+
+                @Override
+                public void startElement(String uri, String localName, String qName,
+                                         Attributes atts) {
+                    if ("sldIdLst".equals(localName)) {
+                        inSldIdLst = true;
+                    } else if (inSldIdLst && "sldId".equals(localName)) {
+                        String rId = atts.getValue(
+                                "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+                                "id");
+                        if (rId != null) {
+                            orderedRIds.add(rId);
+                        }
+                    }
+                }
+
+                @Override
+                public void endElement(String uri, String localName, String qName) {
+                    if ("sldIdLst".equals(localName)) {
+                        inSldIdLst = false;
+                    }
+                }
+            }, context);
+        } catch (Exception e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    ExceptionUtils.getStackTrace(e));
+        }
+
+        // Step 2: build rId -> PackagePart map from relationships
+        Map<String, PackagePart> rIdToSlide = new LinkedHashMap<>();
+        try {
+            PackageRelationshipCollection slidesPRC =
+                    mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
+            if (slidesPRC != null) {
+                for (int i = 0; i < slidesPRC.size(); i++) {
+                    PackageRelationship rel = slidesPRC.getRelationship(i);
+                    try {
+                        PackagePart part = mainDocument.getRelatedPart(rel);
+                        if (part != null) {
+                            rIdToSlide.put(rel.getId(), part);
+                        }
+                    } catch (InvalidFormatException | IllegalArgumentException e) {
+                        // skip missing parts
+                    }
+                }
+            }
+        } catch (InvalidFormatException e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    ExceptionUtils.getStackTrace(e));
+        }
+
+        // Step 3: assemble in presentation order, then append orphans
+        List<PackagePart> result = new ArrayList<>();
+        for (String rId : orderedRIds) {
+            PackagePart part = rIdToSlide.remove(rId);
+            if (part != null) {
+                result.add(part);
+            }
+        }
+        // append any slides in .rels but not in sldIdLst
+        if (!rIdToSlide.isEmpty()) {
+            metadata.set(Office.NUM_UNLISTED_SLIDES, rIdToSlide.size());
+            for (PackagePart part : rIdToSlide.values()) {
+                metadata.add(Office.UNLISTED_SLIDE_NAMES, part.getPartName().getName());
+            }
+            result.addAll(rIdToSlide.values());
+        }
+        return result;
+    }
+
+    /**
+     * @return 1 if the slide is hidden, 0 otherwise
+     */
+    private int handleSlidePart(PackagePart slidePart, XHTMLContentHandler xhtml)
             throws IOException, SAXException {
         Map<String, String> linkedRelationships =
                 loadLinkedRelationships(slidePart, false, metadata);
 
-//        Map<String, String> hyperlinks = loadHyperlinkRelationships(packagePart);
+        int hidden = 0;
         xhtml.startElement("div", "class", "slide-content");
+        OOXMLTikaBodyPartHandler bodyHandler = new OOXMLTikaBodyPartHandler(xhtml, metadata);
         try (InputStream stream = slidePart.getInputStream()) {
             OOXMLWordAndPowerPointTextHandler wordAndPPTHandler = new OOXMLWordAndPowerPointTextHandler(
-                    new OOXMLTikaBodyPartHandler(xhtml), linkedRelationships);
+                    bodyHandler, linkedRelationships);
             XMLReaderUtils.parseSAX(stream,
                     new EmbeddedContentHandler(wordAndPPTHandler), context);
             if (wordAndPPTHandler.isHiddenSlide()) {
                 metadata.set(Office.HAS_HIDDEN_SLIDES, true);
+                hidden = 1;
+            }
+            if (wordAndPPTHandler.hasAnimations()) {
+                metadata.set(Office.HAS_ANIMATIONS, true);
             }
         } catch (TikaException | IOException e) {
             metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
                     ExceptionUtils.getStackTrace(e));
         }
-
+        bodyHandler.closeInlineElements();
         xhtml.endElement("div");
 
         if (config.isIncludeSlideMasterContent()) {
-            handleGeneralTextContainingPart(XSLFRelation.SLIDE_LAYOUT.getRelation(),
-                    "slide-master-content", slidePart, metadata, new PlaceHolderSkipper(
-                            new OOXMLWordAndPowerPointTextHandler(
-                                    new OOXMLTikaBodyPartHandler(xhtml), linkedRelationships)));
+            // Extract the slide layout (per-slide)
+            PackagePart layoutPart = getRelatedPartByType(slidePart,
+                    XSLFRelation.SLIDE_LAYOUT.getRelation());
+            if (layoutPart != null) {
+                handleTextPartWithCleanup(XSLFRelation.SLIDE_LAYOUT.getRelation(),
+                        "slide-master-content", slidePart, xhtml, linkedRelationships, true);
+                // Follow layout → slide master chain
+                handleTextPartWithCleanup(XSLFRelation.SLIDE_MASTER.getRelation(),
+                        "slide-master-content", layoutPart, xhtml, linkedRelationships, true);
+            }
         }
         if (config.isIncludeSlideNotes()) {
-            handleGeneralTextContainingPart(XSLFRelation.NOTES.getRelation(), "slide-notes",
-                    slidePart, metadata,
-                    new OOXMLWordAndPowerPointTextHandler(new OOXMLTikaBodyPartHandler(xhtml),
-                            linkedRelationships));
+            handleTextPartWithCleanup(XSLFRelation.NOTES.getRelation(), "slide-notes",
+                    slidePart, xhtml, linkedRelationships, false);
             if (config.isIncludeSlideMasterContent()) {
-                handleGeneralTextContainingPart(XSLFRelation.NOTES_MASTER.getRelation(),
-                        "slide-notes-master", slidePart, metadata,
-                        new OOXMLWordAndPowerPointTextHandler(new OOXMLTikaBodyPartHandler(xhtml),
-                                linkedRelationships));
-
+                handleTextPartWithCleanup(XSLFRelation.NOTES_MASTER.getRelation(),
+                        "slide-notes-master", slidePart, xhtml, linkedRelationships, false);
             }
         }
         handleGeneralTextContainingPart(XSLFRelation.COMMENTS.getRelation(), null, slidePart,
-                metadata, new XSLFCommentsHandler(xhtml));
+                metadata, new XSLFCommentsHandler(xhtml, commentAuthors));
 
-        handleGeneralTextContainingPart(AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA,
-                "diagram-data", slidePart, metadata,
-                new OOXMLWordAndPowerPointTextHandler(new OOXMLTikaBodyPartHandler(xhtml),
-                        linkedRelationships));
+        handleTextPartWithCleanup(AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA,
+                "diagram-data", slidePart, xhtml, linkedRelationships, false);
 
-        handleGeneralTextContainingPart(XSLFRelation.CHART.getRelation(), "chart", slidePart,
-                metadata, new OOXMLWordAndPowerPointTextHandler(new OOXMLTikaBodyPartHandler(xhtml),
-                        linkedRelationships));
+        handleTextPartWithCleanup(XSLFRelation.CHART.getRelation(), "chart", slidePart,
+                xhtml, linkedRelationships, false);
+        return hidden;
+    }
+
+    /**
+     * Handles a text-containing part with guaranteed inline element cleanup.
+     * Creates an OOXMLTikaBodyPartHandler, parses the part, then calls
+     * closeInlineElements() to ensure no unclosed tags leak into subsequent output.
+     *
+     * @param usePlaceholderSkipper if true, wraps the handler in a PlaceHolderSkipper
+     */
+    private void handleTextPartWithCleanup(String contentType, String xhtmlClassLabel,
+                                           PackagePart parentPart, XHTMLContentHandler xhtml,
+                                           Map<String, String> linkedRelationships,
+                                           boolean usePlaceholderSkipper) throws SAXException {
+        OOXMLTikaBodyPartHandler bodyHandler = new OOXMLTikaBodyPartHandler(xhtml);
+        OOXMLWordAndPowerPointTextHandler textHandler =
+                new OOXMLWordAndPowerPointTextHandler(bodyHandler, linkedRelationships);
+        DefaultHandler handler = usePlaceholderSkipper
+                ? new PlaceHolderSkipper(textHandler) : textHandler;
+        try {
+            handleGeneralTextContainingPart(contentType, xhtmlClassLabel, parentPart,
+                    metadata, handler);
+        } finally {
+            bodyHandler.closeInlineElements();
+        }
     }
 
     /**
@@ -287,7 +411,8 @@ public class SXSLFPowerPointExtractorDecorator extends AbstractOOXMLExtractor {
 
         for (String relation : new String[]{XSLFRelation.VML_DRAWING.getRelation(),
                 XSLFRelation.SLIDE_LAYOUT.getRelation(), XSLFRelation.NOTES_MASTER.getRelation(),
-                XSLFRelation.NOTES.getRelation()}) {
+                XSLFRelation.NOTES.getRelation(), XSLFRelation.CHART.getRelation(),
+                XSLFRelation.DIAGRAM_DRAWING.getRelation()}) {
             PackageRelationshipCollection prc = null;
             try {
                 prc = slidePart.getRelationshipsByType(relation);
@@ -318,170 +443,4 @@ public class SXSLFPowerPointExtractorDecorator extends AbstractOOXMLExtractor {
 
     }
 
-    private static class PlaceHolderSkipper extends DefaultHandler {
-
-        private final ContentHandler wrappedHandler;
-        boolean inPH = false;
-
-        PlaceHolderSkipper(ContentHandler wrappedHandler) {
-            this.wrappedHandler = wrappedHandler;
-        }
-
-        @Override
-        public void startElement(String uri, String localName, String qName, Attributes atts)
-                throws SAXException {
-            if ("ph".equals(localName)) {
-                inPH = true;
-            }
-            if (!inPH) {
-                wrappedHandler.startElement(uri, localName, qName, atts);
-            }
-        }
-
-        @Override
-        public void endElement(String uri, String localName, String qName) throws SAXException {
-
-            if (!inPH) {
-                wrappedHandler.endElement(uri, localName, qName);
-            }
-            if ("sp".equals(localName)) {
-                inPH = false;
-            }
-        }
-
-        @Override
-        public void characters(char[] ch, int start, int length) throws SAXException {
-            if (!inPH) {
-                wrappedHandler.characters(ch, start, length);
-            }
-        }
-
-        @Override
-        public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
-            if (!inPH) {
-                wrappedHandler.characters(ch, start, length);
-            }
-        }
-
-
-    }
-
-    private class XSLFCommentsHandler extends DefaultHandler {
-
-        private String commentAuthorId = null;
-        private StringBuilder commentBuffer = new StringBuilder();
-        private XHTMLContentHandler xhtml;
-
-        XSLFCommentsHandler(XHTMLContentHandler xhtml) {
-            this.xhtml = xhtml;
-        }
-
-        @Override
-        public void startElement(String uri, String localName, String qName, Attributes atts)
-                throws SAXException {
-            if ("cm".equals(localName)) {
-                commentAuthorId = atts.getValue("", "authorId");
-                //get date (dt)?
-            }
-        }
-
-        @Override
-        public void characters(char[] ch, int start, int length) throws SAXException {
-            //TODO: require that we're in <p:text>?
-            commentBuffer.append(ch, start, length);
-        }
-
-        @Override
-        public void endElement(String uri, String localName, String qName) throws SAXException {
-            if ("cm".equals(localName)) {
-
-                xhtml.startElement("p", "class", "slide-comment");
-
-                String authorString = commentAuthors.getName(commentAuthorId);
-                String authorInitials = commentAuthors.getInitials(commentAuthorId);
-                if (authorString != null || authorInitials != null) {
-                    xhtml.startElement("b");
-                    boolean authorExists = false;
-                    if (authorString != null) {
-                        xhtml.characters(authorString);
-                        authorExists = true;
-                    }
-                    if (authorExists && authorInitials != null) {
-                        xhtml.characters(" (");
-                    }
-                    if (authorInitials != null) {
-                        xhtml.characters(authorInitials);
-                    }
-                    if (authorExists && authorInitials != null) {
-                        xhtml.characters(")");
-                    }
-                    xhtml.endElement("b");
-                }
-                xhtml.characters(commentBuffer.toString());
-                xhtml.endElement("p");
-
-                commentBuffer.setLength(0);
-                commentAuthorId = null;
-            }
-        }
-    }
-
-    private class XSLFCommentAuthorHandler extends DefaultHandler {
-        String id = null;
-        String name = null;
-        String initials = null;
-
-        @Override
-        public void startElement(String uri, String localName, String qName, Attributes atts)
-                throws SAXException {
-            if ("cmAuthor".equals(localName)) {
-                for (int i = 0; i < atts.getLength(); i++) {
-                    if ("id".equals(atts.getLocalName(i))) {
-                        id = atts.getValue(i);
-                    } else if ("name".equals(atts.getLocalName(i))) {
-                        name = atts.getValue(i);
-                    } else if ("initials".equals(atts.getLocalName(i))) {
-                        initials = atts.getValue(i);
-                    }
-                }
-                commentAuthors.add(id, name, initials);
-                //clear out
-                id = null;
-                name = null;
-                initials = null;
-            }
-        }
-
-    }
-
-    private static class CommentAuthors {
-        Map<String, String> nameMap = new HashMap<>();
-        Map<String, String> initialMap = new HashMap<>();
-
-        void add(String id, String name, String initials) {
-            if (id == null) {
-                return;
-            }
-            if (name != null) {
-                nameMap.put(id, name);
-            }
-            if (initials != null) {
-                initialMap.put(id, initials);
-            }
-        }
-
-        String getName(String id) {
-            if (id == null) {
-                return null;
-            }
-            return nameMap.get(id);
-        }
-
-        String getInitials(String id) {
-            if (id == null) {
-                return null;
-            }
-            return initialMap.get(id);
-        }
-    }
 }
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/SXWPFWordExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java
index 60eb91ec94..5fc7d9a846 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java
@@ -19,19 +19,20 @@ package org.apache.tika.parser.microsoft.ooxml;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.zip.ZipException;
 
 import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
-import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
 import org.apache.poi.openxml4j.opc.OPCPackage;
 import org.apache.poi.openxml4j.opc.PackagePart;
 import org.apache.poi.openxml4j.opc.PackageRelationship;
 import org.apache.poi.openxml4j.opc.PackageRelationshipCollection;
 import org.apache.poi.openxml4j.opc.TargetMode;
 import org.apache.poi.xssf.usermodel.XSSFRelation;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
 import org.apache.poi.xwpf.usermodel.XWPFRelation;
 import org.apache.xmlbeans.XmlException;
 import org.xml.sax.Attributes;
@@ -39,11 +40,14 @@ import org.xml.sax.SAXException;
 import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.EMFParser;
 import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFEventBasedWordExtractor;
+import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFFeatureExtractor;
 import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFNumberingShim;
 import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFStylesShim;
 import org.apache.tika.sax.EmbeddedContentHandler;
@@ -70,7 +74,8 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
             new String[]{XWPFRelation.HEADER.getRelation(), XWPFRelation.FOOTER.getRelation(),
                     XWPFRelation.FOOTNOTE.getRelation(),
                     "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
-                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"};
+                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
+                    AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA};
 
     // Relationship types for Word settings
     private static final String SETTINGS_RELATION =
@@ -94,6 +99,7 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
     private final OPCPackage opcPackage;
     private final ParseContext context;
     private final Metadata metadata;
+    private final Map<String, EmbeddedPartMetadata> embeddedPartMetadataMap = new HashMap<>();
 
 
     public SXWPFWordExtractorDecorator(Metadata metadata, ParseContext context,
@@ -104,6 +110,10 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
         this.opcPackage = extractor.getPackage();
     }
 
+    @Override
+    public MetadataExtractor getMetadataExtractor() {
+        return new SAXBasedMetadataExtractor(opcPackage, context);
+    }
 
     @Override
     protected void buildXHTML(XHTMLContentHandler xhtml)
@@ -143,6 +153,9 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
      * subdocuments, and framesets.
      */
     private void detectSecurityFeatures(PackagePart documentPart, XHTMLContentHandler xhtml) {
+        // Extract document features (hidden text, track changes, comments, comment persons)
+        new XWPFFeatureExtractor().process(documentPart, metadata, context);
+
         // Check for attached template (external template reference)
         try {
             PackageRelationshipCollection templateRels =
@@ -180,7 +193,7 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
             PackageRelationshipCollection settingsRels =
                     documentPart.getRelationshipsByType(SETTINGS_RELATION);
             if (settingsRels != null && settingsRels.size() > 0) {
-                PackagePart settingsPart = documentPart.getRelatedPart(settingsRels.getRelationship(0));
+                PackagePart settingsPart = safeGetRelatedPart(documentPart, settingsRels.getRelationship(0));
                 if (settingsPart != null) {
                     try (InputStream is = settingsPart.getInputStream()) {
                         WordSettingsHandler handler = new WordSettingsHandler(xhtml);
@@ -200,7 +213,7 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
             PackageRelationshipCollection webSettingsRels =
                     documentPart.getRelationshipsByType(WEB_SETTINGS_RELATION);
             if (webSettingsRels != null && webSettingsRels.size() > 0) {
-                PackagePart webSettingsPart = documentPart.getRelatedPart(webSettingsRels.getRelationship(0));
+                PackagePart webSettingsPart = safeGetRelatedPart(documentPart, webSettingsRels.getRelationship(0));
                 if (webSettingsPart != null) {
                     try (InputStream is = webSettingsPart.getInputStream()) {
                         WebSettingsHandler handler = new WebSettingsHandler(xhtml);
@@ -211,8 +224,23 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                     }
                 }
             }
-        } catch (InvalidFormatException | IOException | TikaException | SAXException e) {
-            // swallow
+        } catch (InvalidFormatException | IOException | TikaException | SAXException |
+                 IllegalArgumentException e) {
+            // swallow -- POI throws IllegalArgumentException when
+            // a relationship references a part missing from the package
+        }
+    }
+
+    /**
+     * Safely resolves a related part from a relationship.  Returns {@code null}
+     * instead of throwing {@link IllegalArgumentException} when the target
+     * part is missing from the package (e.g. truncated / salvaged zips).
+     */
+    private static PackagePart safeGetRelatedPart(PackagePart source, PackageRelationship rel) {
+        try {
+            return source.getRelatedPart(rel);
+        } catch (InvalidFormatException | IllegalArgumentException e) {
+            return null;
         }
     }
 
@@ -234,8 +262,9 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
     private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xhtml)
             throws IOException, SAXException {
         //load the numbering/list manager and styles from the main document part
-        XWPFNumbering numbering = loadNumbering(documentPart);
-        XWPFListManager listManager = new XWPFListManager(numbering);
+        XWPFNumberingShim numbering = loadNumbering(documentPart);
+        XWPFListManager listManager = new XWPFListManager(
+                numbering != null ? numbering : XWPFNumberingShim.EMPTY);
         XWPFStylesShim styles = null;
         try {
             styles = loadStyles(documentPart);
@@ -247,6 +276,9 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
         }
 
         if (config.isIncludeHeadersAndFooters()) {
+            //TODO: the DOM extractor handles per-section headers/footers by detecting
+            // sectPr within paragraphs. We extract all headers/footers at the document level,
+            // which is fine for text extraction since OOXML is flow-based, not page-based.
             //headers
             try {
                 PackageRelationshipCollection headersPRC =
@@ -254,8 +286,11 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                 if (headersPRC != null) {
                     for (int i = 0; i < headersPRC.size(); i++) {
                         PackagePart header =
-                                documentPart.getRelatedPart(headersPRC.getRelationship(i));
-                        handlePart(header, styles, listManager, xhtml);
+                                safeGetRelatedPart(documentPart, headersPRC.getRelationship(i));
+                        if (header != null) {
+                            handlePart(header, styles, listManager, xhtml,
+                                    OOXMLInlineBodyPartMap.EMPTY);
+                        }
                     }
                 }
             } catch (InvalidFormatException | ZipException e) {
@@ -264,18 +299,24 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
             }
         }
 
-        //main document
+        // Pre-collect footnotes, endnotes, and comments so they can be
+        // inlined at the point of reference in the main document
+        OOXMLInlineBodyPartMap inlinePartMap = collectInlineParts(documentPart);
+
+        //main document — keep reference to body handler for emitted comment tracking
+        java.util.Set<String> emittedCommentIds = java.util.Collections.emptySet();
         try {
-            handlePart(documentPart, styles, listManager, xhtml);
+            OOXMLTikaBodyPartHandler mainBodyHandler =
+                    handlePart(documentPart, styles, listManager, xhtml, inlinePartMap);
+            emittedCommentIds = mainBodyHandler.getEmittedCommentIds();
         } catch (ZipException e) {
             metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
                     ExceptionUtils.getStackTrace(e));
         }
-        //for now, just dump other components at end
+        //dump remaining components at end (diagrams, charts, footers)
         for (String rel : new String[]{AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA,
-                XSSFRelation.CHART.getRelation(), XWPFRelation.FOOTNOTE.getRelation(),
-                XWPFRelation.COMMENT.getRelation(), XWPFRelation.FOOTER.getRelation(),
-                XWPFRelation.ENDNOTE.getRelation(),}) {
+                XSSFRelation.CHART.getRelation(),
+                XWPFRelation.FOOTER.getRelation()}) {
             //skip footers if we shouldn't extract them
             if (!config.isIncludeHeadersAndFooters() &&
                     rel.equals(XWPFRelation.FOOTER.getRelation())) {
@@ -286,8 +327,11 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                 if (prc != null) {
                     for (int i = 0; i < prc.size(); i++) {
                         PackagePart packagePart =
-                                documentPart.getRelatedPart(prc.getRelationship(i));
-                        handlePart(packagePart, styles, listManager, xhtml);
+                                safeGetRelatedPart(documentPart, prc.getRelationship(i));
+                        if (packagePart != null) {
+                            handlePart(packagePart, styles, listManager, xhtml,
+                                    OOXMLInlineBodyPartMap.EMPTY);
+                        }
                     }
                 }
             } catch (InvalidFormatException | ZipException e) {
@@ -295,25 +339,160 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                         ExceptionUtils.getStackTrace(e));
             }
         }
+        //dump any comments that were NOT inlined via commentReference
+        handleUnreferencedComments(documentPart, styles, listManager, xhtml,
+                inlinePartMap, emittedCommentIds);
     }
 
-    private void handlePart(PackagePart packagePart, XWPFStylesShim styles,
-                            XWPFListManager listManager, XHTMLContentHandler xhtml)
+    private void handleUnreferencedComments(PackagePart documentPart,
+            XWPFStylesShim styles, XWPFListManager listManager,
+            XHTMLContentHandler xhtml, OOXMLInlineBodyPartMap inlinePartMap,
+            java.util.Set<String> emittedCommentIds) {
+        if (!inlinePartMap.hasComments()) {
+            return;
+        }
+        Map<String, String> linkedRelationships = inlinePartMap.getLinkedRelationships();
+        for (Map.Entry<String, byte[]> entry :
+                inlinePartMap.getCommentEntries()) {
+            if (emittedCommentIds.contains(entry.getKey())) {
+                continue;
+            }
+            try {
+                xhtml.startElement("div", "class", "comment");
+                XMLReaderUtils.parseSAX(
+                        new java.io.ByteArrayInputStream(entry.getValue()),
+                        new EmbeddedContentHandler(
+                                new OOXMLWordAndPowerPointTextHandler(
+                                        new OOXMLTikaBodyPartHandler(xhtml),
+                                        linkedRelationships)),
+                        context);
+                xhtml.endElement("div");
+            } catch (TikaException | IOException | SAXException e) {
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        ExceptionUtils.getStackTrace(e));
+            }
+        }
+    }
+
+    private OOXMLTikaBodyPartHandler handlePart(PackagePart packagePart,
+                            XWPFStylesShim styles,
+                            XWPFListManager listManager, XHTMLContentHandler xhtml,
+                            OOXMLInlineBodyPartMap inlinePartMap)
             throws IOException, SAXException {
 
         Map<String, String> linkedRelationships =
                 loadLinkedRelationships(packagePart, true, metadata);
+        OOXMLTikaBodyPartHandler bodyHandler =
+                new OOXMLTikaBodyPartHandler(xhtml, styles, listManager, config, metadata);
+        bodyHandler.setInlineBodyPartMap(inlinePartMap, context);
         try (InputStream stream = packagePart.getInputStream()) {
             XMLReaderUtils.parseSAX(stream,
                     new EmbeddedContentHandler(new OOXMLWordAndPowerPointTextHandler(
-                            new OOXMLTikaBodyPartHandler(xhtml, styles, listManager, config),
+                            bodyHandler,
                             linkedRelationships, config.isIncludeShapeBasedContent(),
-                            config.isConcatenatePhoneticRuns(), metadata)), context);
-        } catch (TikaException | IOException e) {
+                            config.isConcatenatePhoneticRuns(),
+                            config.isPreferAlternateContentChoice())), context);
+        } catch (TikaException | IOException | SAXException e) {
             metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
                     ExceptionUtils.getStackTrace(e));
         }
+        bodyHandler.closeInlineElements();
+        Map<String, EmbeddedPartMetadata> partMetadata = bodyHandler.getEmbeddedPartMetadataMap();
+        resolveEmfNames(packagePart, partMetadata);
+        embeddedPartMetadataMap.putAll(partMetadata);
+        return bodyHandler;
+    }
 
+    private void resolveEmfNames(PackagePart documentPart,
+                                 Map<String, EmbeddedPartMetadata> metadataMap) {
+        for (EmbeddedPartMetadata epm : metadataMap.values()) {
+            String emfRId = epm.getEmfRelationshipId();
+            if (emfRId == null || emfRId.isEmpty()) {
+                continue;
+            }
+            try {
+                PackagePart emfPart = documentPart.getRelatedPart(
+                        documentPart.getRelationship(emfRId));
+                if (emfPart == null || emfPart.getContentType() == null) {
+                    continue;
+                }
+                if ("image/x-emf".equals(emfPart.getContentType())) {
+                    try (TikaInputStream tis = TikaInputStream.get(emfPart.getInputStream())) {
+                        EMFParser p = new EMFParser();
+                        Metadata m = Metadata.newInstance(context);
+                        p.parse(tis, new org.apache.tika.sax.ToTextContentHandler(), m, context);
+                        epm.setFullName(m.get(EMFParser.EMF_ICON_STRING));
+                    }
+                }
+            } catch (Exception e) {
+                //swallow
+            }
+        }
+    }
+
+    @Override
+    protected Map<String, EmbeddedPartMetadata> getEmbeddedPartMetadataMap() {
+        return embeddedPartMetadataMap;
+    }
+
+    private OOXMLInlineBodyPartMap collectInlineParts(PackagePart documentPart) {
+        Map<String, String> allRelationships = new java.util.HashMap<>();
+        Map<String, byte[]> footnoteMap = collectPartContent(documentPart,
+                XWPFRelation.FOOTNOTE.getRelation(), Set.of("footnote"),
+                allRelationships);
+        String endnoteRel =
+                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
+        Map<String, byte[]> endnoteMap = collectPartContent(documentPart,
+                endnoteRel, Set.of("endnote"), allRelationships);
+        String commentsRel =
+                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
+        Map<String, byte[]> commentMap = collectPartContent(documentPart,
+                commentsRel, Set.of("comment"),
+                allRelationships, Collections.emptySet());
+        return new OOXMLInlineBodyPartMap(footnoteMap, endnoteMap,
+                commentMap, allRelationships);
+    }
+
+    private Map<String, byte[]> collectPartContent(PackagePart documentPart,
+            String relationshipType, Set<String> wrapperElements,
+            Map<String, String> allRelationships) {
+        return collectPartContent(documentPart, relationshipType, wrapperElements,
+                allRelationships, Set.of("0", "-1"));
+    }
+
+    private Map<String, byte[]> collectPartContent(PackagePart documentPart,
+            String relationshipType, Set<String> wrapperElements,
+            Map<String, String> allRelationships, Set<String> skipIds) {
+        try {
+            PackageRelationshipCollection prc =
+                    documentPart.getRelationshipsByType(relationshipType);
+            if (prc == null || prc.size() == 0) {
+                return Collections.emptyMap();
+            }
+            OOXMLPartContentCollector collector =
+                    new OOXMLPartContentCollector(wrapperElements, skipIds);
+            for (int i = 0; i < prc.size(); i++) {
+                try {
+                    PackagePart part = documentPart.getRelatedPart(prc.getRelationship(i));
+                    // collect the part's linked relationships (for picture resolution)
+                    Map<String, String> partRels =
+                            loadLinkedRelationships(part, true, metadata);
+                    allRelationships.putAll(partRels);
+                    try (InputStream stream = part.getInputStream()) {
+                        XMLReaderUtils.parseSAX(stream, collector, context);
+                    }
+                } catch (InvalidFormatException | IOException | TikaException |
+                         SAXException e) {
+                    metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                            ExceptionUtils.getStackTrace(e));
+                }
+            }
+            return collector.getContentMap();
+        } catch (InvalidFormatException e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    ExceptionUtils.getStackTrace(e));
+            return Collections.emptyMap();
+        }
     }
 
 
@@ -326,7 +505,7 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
             if (stylesRelationShip == null) {
                 return null;
             }
-            PackagePart stylesPart = packagePart.getRelatedPart(stylesRelationShip);
+            PackagePart stylesPart = safeGetRelatedPart(packagePart, stylesRelationShip);
             if (stylesPart == null) {
                 return null;
             }
@@ -337,7 +516,7 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
 
     }
 
-    private XWPFNumbering loadNumbering(PackagePart packagePart) {
+    private XWPFNumberingShim loadNumbering(PackagePart packagePart) {
         try {
             PackageRelationshipCollection numberingParts =
                     packagePart.getRelationshipsByType(XWPFRelation.NUMBERING.getRelation());
@@ -346,13 +525,13 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                 if (numberingRelationShip == null) {
                     return null;
                 }
-                PackagePart numberingPart = packagePart.getRelatedPart(numberingRelationShip);
+                PackagePart numberingPart = safeGetRelatedPart(packagePart, numberingRelationShip);
                 if (numberingPart == null) {
                     return null;
                 }
-                return new XWPFNumberingShim(numberingPart);
+                return new XWPFNumberingShim(numberingPart, context);
             }
-        } catch (IOException | OpenXML4JException e) {
+        } catch (Exception e) {
             //swallow
         }
         return null;
@@ -387,8 +566,10 @@ public class SXWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
                 if (prc != null) {
                     for (int i = 0; i < prc.size(); i++) {
                         PackagePart packagePart =
-                                documentPart.getRelatedPart(prc.getRelationship(i));
-                        relatedParts.add(packagePart);
+                                safeGetRelatedPart(documentPart, prc.getRelationship(i));
+                        if (packagePart != null) {
+                            relatedParts.add(packagePart);
+                        }
                     }
                 }
             } catch (InvalidFormatException e) {
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/XSLFCommentAuthorHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSLFCommentAuthorHandler.java
new file mode 100644
index 0000000000..5b0d055472
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSLFCommentAuthorHandler.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * SAX handler that parses PPTX commentAuthors.xml and populates
+ * a {@link CommentAuthors} map with author names and initials by ID.
+ */
+class XSLFCommentAuthorHandler extends DefaultHandler {
+
+    private final CommentAuthors commentAuthors;
+
+    XSLFCommentAuthorHandler(CommentAuthors commentAuthors) {
+        this.commentAuthors = commentAuthors;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName, Attributes atts)
+            throws SAXException {
+        if ("cmAuthor".equals(localName)) {
+            String id = null;
+            String name = null;
+            String initials = null;
+            for (int i = 0; i < atts.getLength(); i++) {
+                if ("id".equals(atts.getLocalName(i))) {
+                    id = atts.getValue(i);
+                } else if ("name".equals(atts.getLocalName(i))) {
+                    name = atts.getValue(i);
+                } else if ("initials".equals(atts.getLocalName(i))) {
+                    initials = atts.getValue(i);
+                }
+            }
+            commentAuthors.add(id, name, initials);
+        }
+    }
+}
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/XSLFCommentsHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSLFCommentsHandler.java
new file mode 100644
index 0000000000..51a3d4c862
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSLFCommentsHandler.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * SAX handler that parses PPTX comments XML and emits comment content
+ * with author attribution to an XHTML stream.
+ */
+class XSLFCommentsHandler extends DefaultHandler {
+
+    private final XHTMLContentHandler xhtml;
+    private final CommentAuthors commentAuthors;
+
+    private String commentAuthorId = null;
+    private final StringBuilder commentBuffer = new StringBuilder();
+
+    XSLFCommentsHandler(XHTMLContentHandler xhtml, CommentAuthors commentAuthors) {
+        this.xhtml = xhtml;
+        this.commentAuthors = commentAuthors;
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String qName, Attributes atts)
+            throws SAXException {
+        if ("cm".equals(localName)) {
+            commentAuthorId = atts.getValue("", "authorId");
+        }
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length) throws SAXException {
+        //TODO: require that we're in <p:text>?
+        commentBuffer.append(ch, start, length);
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String qName) throws SAXException {
+        if ("cm".equals(localName)) {
+            xhtml.startElement("p", "class", "slide-comment");
+
+            String authorString = commentAuthors.getName(commentAuthorId);
+            String authorInitials = commentAuthors.getInitials(commentAuthorId);
+            if (authorString != null || authorInitials != null) {
+                xhtml.startElement("b");
+                boolean authorExists = false;
+                if (authorString != null) {
+                    xhtml.characters(authorString);
+                    authorExists = true;
+                }
+                if (authorExists && authorInitials != null) {
+                    xhtml.characters(" (");
+                }
+                if (authorInitials != null) {
+                    xhtml.characters(authorInitials);
+                }
+                if (authorExists && authorInitials != null) {
+                    xhtml.characters(")");
+                }
+                xhtml.endElement("b");
+            }
+            xhtml.characters(commentBuffer.toString());
+            xhtml.endElement("p");
+
+            commentBuffer.setLength(0);
+            commentAuthorId = null;
+        }
+    }
+}
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/XWPFBodyContentsHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFBodyContentsHandler.java
new file mode 100644
index 0000000000..dbd6996886
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFBodyContentsHandler.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import java.util.Date;
+
+import org.xml.sax.SAXException;
+
+/**
+ * Callback interface for receiving structured document events from the
+ * OOXML SAX dispatcher. Implementations convert these events into output
+ * formats (e.g., XHTML, Markdown, plain text).
+ */
+public interface XWPFBodyContentsHandler {
+
+    void run(RunProperties runProperties, String contents) throws SAXException;
+
+    /**
+     * @param link the link; can be null
+     */
+    void hyperlinkStart(String link) throws SAXException;
+
+    /**
+     * Called when a hyperlink is found via a field code (instrText HYPERLINK).
+     * Distinct from relationship-based hyperlinks for security tracking purposes.
+     *
+     * @param link the link URL
+     */
+    default void fieldCodeHyperlinkStart(String link) throws SAXException {
+        hyperlinkStart(link);
+    }
+
+    void hyperlinkEnd() throws SAXException;
+
+    void startParagraph(ParagraphProperties paragraphProperties) throws SAXException;
+
+    /**
+     * Updates the properties (style, numbering) for the current pending paragraph.
+     * Called when {@code </pPr>} is encountered, after {@link #startParagraph} but
+     * before any content.  The body handler defers opening the XHTML {@code <p>}
+     * tag until the first content arrives, so this style info will be available.
+     */
+    default void setParagraphProperties(ParagraphProperties paragraphProperties)
+            throws SAXException {
+        // Default no-op for backward compatibility
+    }
+
+    void endParagraph() throws SAXException;
+
+    void startTable() throws SAXException;
+
+    void endTable() throws SAXException;
+
+    void startTableRow() throws SAXException;
+
+    void endTableRow() throws SAXException;
+
+    void startTableCell() throws SAXException;
+
+    void endTableCell() throws SAXException;
+
+    void startSDT() throws SAXException;
+
+    void endSDT() throws SAXException;
+
+    void startEditedSection(String editor, Date date, EditType editType) throws SAXException;
+
+    void endEditedSection() throws SAXException;
+
+    boolean isIncludeDeletedText() throws SAXException;
+
+    void footnoteReference(String id) throws SAXException;
+
+    void endnoteReference(String id) throws SAXException;
+
+    /**
+     * Called when a comment reference is encountered in the document body.
+     *
+     * @param id the comment ID
+     */
+    void commentReference(String id) throws SAXException;
+
+    boolean isIncludeMoveFromText() throws SAXException;
+
+    void embeddedOLERef(String refId, String progId, String emfImageRId) throws SAXException;
+
+    /**
+     * Called when a linked (vs embedded) OLE object is found.
+     * These reference external files and are a security concern.
+     */
+    void linkedOLERef(String refId) throws SAXException;
+
+    void embeddedPicRef(String picFileName, String picDescription) throws SAXException;
+
+    void startBookmark(String id, String name) throws SAXException;
+
+    void endBookmark(String id) throws SAXException;
+
+    /**
+     * Called when an external reference URL is found in a field code.
+     * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK fields,
+     * and DrawingML/VML hyperlinks on shapes.
+     *
+     * @param fieldType the type of field (e.g., "INCLUDEPICTURE", "hlinkHover", "vml-href")
+     * @param url the external URL
+     */
+    default void externalRef(String fieldType, String url) throws SAXException {
+        // Default no-op implementation for backward compatibility
+    }
+}
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/XWPFListManager.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFListManager.java
index 383c31f0f7..b397a20333 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFListManager.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFListManager.java
@@ -18,17 +18,10 @@ package org.apache.tika.parser.microsoft.ooxml;
 
 import java.math.BigInteger;
 
-import org.apache.poi.xwpf.usermodel.XWPFAbstractNum;
-import org.apache.poi.xwpf.usermodel.XWPFNum;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
 import org.apache.poi.xwpf.usermodel.XWPFParagraph;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTDecimalNumber;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTNum;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTNumLvl;
 
 import org.apache.tika.parser.microsoft.AbstractListManager;
+import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFNumberingShim;
 
 
 public class XWPFListManager extends AbstractListManager {
@@ -38,26 +31,10 @@ public class XWPFListManager extends AbstractListManager {
      * Always returns empty string.
      */
     public final static XWPFListManager EMPTY_LIST = new EmptyListManager();
-    private final static boolean OVERRIDE_AVAILABLE;
-    private final static String SKIP_FORMAT = Character.toString((char) 61623);
-//if this shows up as the lvlText, don't show a number
-
-    static {
-        boolean b = false;
-        try {
-            Class.forName("org.openxmlformats.schemas.wordprocessingml.x2006.main.CTNumLvl");
-            b = true;
-        } catch (ClassNotFoundException e) {
-            //swallow
-        }
-        b = OVERRIDE_AVAILABLE = false;
-
-    }
 
-    private final XWPFNumbering numbering;
+    private final XWPFNumberingShim numbering;
 
-    //map of numId (which paragraph series is this a member of?), levelcounts
-    public XWPFListManager(XWPFNumbering numbering) {
+    public XWPFListManager(XWPFNumberingShim numbering) {
         this.numbering = numbering;
     }
 
@@ -76,23 +53,21 @@ public class XWPFListManager extends AbstractListManager {
         }
 
         int currNumId = numId.intValue();
-
-        XWPFNum xwpfNum = numbering.getNum(numId);
-
-        if (xwpfNum == null) {
+        int currAbNumId = numbering.getAbstractNumId(currNumId);
+        if (currAbNumId < 0) {
             return "";
         }
-        CTNum ctNum = xwpfNum.getCTNum();
-        CTDecimalNumber abNum = ctNum.getAbstractNumId();
-        int currAbNumId = abNum.getVal().intValue();
 
         ParagraphLevelCounter lc = listLevelMap.get(currAbNumId);
         LevelTuple[] overrideTuples = overrideTupleMap.get(currNumId);
         if (lc == null) {
-            lc = loadLevelTuples(abNum);
+            lc = loadLevelTuples(currAbNumId);
+            if (lc == null) {
+                return "";
+            }
         }
         if (overrideTuples == null) {
-            overrideTuples = loadOverrideTuples(ctNum, lc.getNumberOfLevels());
+            overrideTuples = numbering.getOverrideLevels(currNumId, lc.getNumberOfLevels());
         }
 
         String formattedString = lc.incrementLevel(iLvl, overrideTuples);
@@ -104,85 +79,14 @@ public class XWPFListManager extends AbstractListManager {
 
     }
 
-    private LevelTuple[] loadOverrideTuples(CTNum ctNum, int length) {
-        LevelTuple[] levelTuples = new LevelTuple[length];
-        int overrideLength = ctNum.sizeOfLvlOverrideArray();
-        if (overrideLength == 0) {
+    private ParagraphLevelCounter loadLevelTuples(int abstractNumId) {
+        LevelTuple[] levels = numbering.getAbstractNumLevels(abstractNumId);
+        if (levels == null) {
             return null;
         }
-        for (int i = 0; i < length; i++) {
-            LevelTuple tuple;
-            if (i >= overrideLength) {
-                tuple = new LevelTuple("%" + i + ".");
-            } else {
-                CTNumLvl ctNumLvl = ctNum.getLvlOverrideArray(i);
-                if (ctNumLvl != null) {
-                    tuple = buildTuple(i, ctNumLvl.getLvl());
-                } else {
-                    tuple = new LevelTuple("%" + i + ".");
-                }
-            }
-            levelTuples[i] = tuple;
-        }
-        return levelTuples;
-    }
-
-
-    private ParagraphLevelCounter loadLevelTuples(CTDecimalNumber abNum) {
-        //Unfortunately, we need to go this far into the underlying structure
-        //to get the abstract num information for the edge case where
-        //someone skips a level and the format is not context-free, e.g. "1.B.i".
-        XWPFAbstractNum abstractNum = numbering.getAbstractNum(abNum.getVal());
-        CTAbstractNum ctAbstractNum = abstractNum.getCTAbstractNum();
-
-        LevelTuple[] levels = new LevelTuple[ctAbstractNum.sizeOfLvlArray()];
-        for (int i = 0; i < levels.length; i++) {
-            levels[i] = buildTuple(i, ctAbstractNum.getLvlArray(i));
-        }
         return new ParagraphLevelCounter(levels);
     }
 
-    private LevelTuple buildTuple(int level, CTLvl ctLvl) {
-        boolean isLegal = false;
-        int start = 1;
-        int restart = -1;
-        String lvlText = "%" + level + ".";
-        String numFmt = "decimal";
-
-
-        if (ctLvl != null && ctLvl.getIsLgl() != null) {
-            isLegal = true;
-        }
-
-        if (ctLvl != null && ctLvl.getNumFmt() != null && ctLvl.getNumFmt().getVal() != null) {
-            numFmt = ctLvl.getNumFmt().getVal().toString();
-        }
-        if (ctLvl != null && ctLvl.getLvlRestart() != null &&
-                ctLvl.getLvlRestart().getVal() != null) {
-            restart = ctLvl.getLvlRestart().getVal().intValue();
-        }
-        if (ctLvl != null && ctLvl.getStart() != null && ctLvl.getStart().getVal() != null) {
-            start = ctLvl.getStart().getVal().intValue();
-        } else {
-
-            //this is a hack. Currently, this gets the lowest possible
-            //start for a given numFmt.  We should probably try to grab the
-            //restartNumberingAfterBreak value in
-            //e.g. <w:abstractNum w:abstractNumId="12" w15:restartNumberingAfterBreak="0">???
-            if ("decimal".equals(numFmt) || "ordinal".equals(numFmt) ||
-                    "decimalZero".equals(numFmt)) {
-                start = 0;
-            } else {
-                start = 1;
-            }
-        }
-        if (ctLvl != null && ctLvl.getLvlText() != null && ctLvl.getLvlText().getVal() != null) {
-            lvlText = ctLvl.getLvlText().getVal();
-        }
-        return new LevelTuple(start, restart, lvlText, numFmt, isLegal);
-    }
-
-
     private static class EmptyListManager extends XWPFListManager {
         EmptyListManager() {
             super(null);
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/XWPFWordExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFWordExtractorDecorator.java
index f40ee517e2..2dd8af7afc 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFWordExtractorDecorator.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFWordExtractorDecorator.java
@@ -29,6 +29,7 @@ import com.microsoft.schemas.vml.impl.CTShapeImpl;
 import org.apache.poi.ooxml.POIXMLDocumentPart;
 import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
 import org.apache.poi.openxml4j.opc.PackagePart;
+import org.apache.poi.openxml4j.opc.PackageRelationship;
 import org.apache.poi.openxml4j.opc.PackageRelationshipCollection;
 import org.apache.poi.xssf.usermodel.XSSFRelation;
 import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
@@ -81,6 +82,7 @@ import org.apache.tika.parser.microsoft.FormattingUtils;
 import org.apache.tika.parser.microsoft.WordExtractor;
 import org.apache.tika.parser.microsoft.WordExtractor.TagAndStyle;
 import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFFeatureExtractor;
+import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFNumberingShim;
 import org.apache.tika.sax.ToTextContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.StringUtils;
@@ -124,7 +126,7 @@ public class XWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
     protected void buildXHTML(XHTMLContentHandler xhtml)
             throws SAXException, XmlException, IOException {
         XWPFHeaderFooterPolicy hfPolicy = document.getHeaderFooterPolicy();
-        XWPFListManager listManager = new XWPFListManager(document.getNumbering());
+        XWPFListManager listManager = new XWPFListManager(loadNumbering());
         // headers
         if (hfPolicy != null && config.isIncludeHeadersAndFooters()) {
             extractHeaders(xhtml, hfPolicy, listManager);
@@ -153,6 +155,26 @@ public class XWPFWordExtractorDecorator extends AbstractOOXMLExtractor {
         }
     }
 
+    private XWPFNumberingShim loadNumbering() {
+        try {
+            PackageRelationshipCollection numberingParts =
+                    document.getPackagePart().getRelationshipsByType(
+                            XWPFRelation.NUMBERING.getRelation());
+            if (numberingParts.size() > 0) {
+                PackageRelationship rel = numberingParts.getRelationship(0);
+                if (rel != null) {
+                    PackagePart numberingPart = document.getPackagePart().getRelatedPart(rel);
+                    if (numberingPart != null) {
+                        return new XWPFNumberingShim(numberingPart, getParseContext());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            //swallow
+        }
+        return XWPFNumberingShim.EMPTY;
+    }
+
     private void extractFeatures(XWPFDocument document, Metadata metadata) {
         XWPFFeatureExtractor ex = new XWPFFeatureExtractor();
         ex.process(document, metadata, getParseContext());
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/xslf/XSLFEventBasedPowerPointExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java
index 2950e46be3..193f649a4e 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java
@@ -27,9 +27,10 @@ import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
 import org.apache.poi.openxml4j.opc.OPCPackage;
 import org.apache.xmlbeans.XmlException;
 
-import org.apache.tika.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler;
+import org.apache.tika.parser.microsoft.ooxml.EditType;
 import org.apache.tika.parser.microsoft.ooxml.ParagraphProperties;
 import org.apache.tika.parser.microsoft.ooxml.RunProperties;
+import org.apache.tika.parser.microsoft.ooxml.XWPFBodyContentsHandler;
 
 public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor {
 
@@ -92,7 +93,7 @@ public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor {
     }
 
     private static class XSLFToTextContentHandler
-            implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler {
+            implements XWPFBodyContentsHandler {
         private final StringBuilder buffer;
 
         public XSLFToTextContentHandler(StringBuilder buffer) {
@@ -166,7 +167,7 @@ public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor {
 
         @Override
         public void startEditedSection(String editor, Date date,
-                                       OOXMLWordAndPowerPointTextHandler.EditType editType) {
+                                       EditType editType) {
 
         }
 
@@ -190,6 +191,11 @@ public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor {
 
         }
 
+        @Override
+        public void commentReference(String id) {
+
+        }
+
         @Override
         public boolean isIncludeMoveFromText() {
             return false;
@@ -197,7 +203,7 @@ public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor {
 
 
         @Override
-        public void embeddedOLERef(String refId) {
+        public void embeddedOLERef(String refId, String progId, String emfImageRId) {
             //no-op
         }
 
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/xwpf/XWPFEventBasedWordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java
index 8056f26bfb..2bb53a3c69 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java
@@ -33,7 +33,6 @@ import org.apache.poi.openxml4j.opc.OPCPackage;
 import org.apache.poi.openxml4j.opc.PackagePart;
 import org.apache.poi.openxml4j.opc.PackageRelationship;
 import org.apache.poi.openxml4j.opc.PackageRelationshipCollection;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
 import org.apache.poi.xwpf.usermodel.XWPFRelation;
 import org.apache.xmlbeans.XmlException;
 import org.slf4j.Logger;
@@ -44,9 +43,11 @@ import org.apache.tika.exception.RuntimeSAXException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.ooxml.EditType;
 import org.apache.tika.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler;
 import org.apache.tika.parser.microsoft.ooxml.ParagraphProperties;
 import org.apache.tika.parser.microsoft.ooxml.RunProperties;
+import org.apache.tika.parser.microsoft.ooxml.XWPFBodyContentsHandler;
 import org.apache.tika.parser.microsoft.ooxml.XWPFListManager;
 import org.apache.tika.utils.XMLReaderUtils;
 
@@ -65,7 +66,19 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
     public XWPFEventBasedWordExtractor(OPCPackage container)
             throws XmlException, OpenXML4JException, IOException {
         this.container = container;
-        this.properties = new POIXMLProperties(container);
+        // Properties are lazily initialized to avoid requiring ooxml-lite
+        // when SAXBasedMetadataExtractor is used instead
+    }
+
+    private POIXMLProperties getOrCreateProperties() {
+        if (properties == null) {
+            try {
+                properties = new POIXMLProperties(container);
+            } catch (Exception e) {
+                LOG.warn("Couldn't load properties", e);
+            }
+        }
+        return properties;
     }
 
     public OPCPackage getPackage() {
@@ -73,15 +86,18 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
     }
 
     public POIXMLProperties.CoreProperties getCoreProperties() {
-        return this.properties.getCoreProperties();
+        POIXMLProperties props = getOrCreateProperties();
+        return props != null ? props.getCoreProperties() : null;
     }
 
     public POIXMLProperties.ExtendedProperties getExtendedProperties() {
-        return this.properties.getExtendedProperties();
+        POIXMLProperties props = getOrCreateProperties();
+        return props != null ? props.getExtendedProperties() : null;
     }
 
     public POIXMLProperties.CustomProperties getCustomProperties() {
-        return this.properties.getCustomProperties();
+        POIXMLProperties props = getOrCreateProperties();
+        return props != null ? props.getCustomProperties() : null;
     }
 
     @Override
@@ -158,8 +174,9 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
     private void handleDocumentPart(PackagePart documentPart, StringBuilder sb)
             throws IOException, SAXException, TikaException {
         //load the numbering/list manager and styles from the main document part
-        XWPFNumbering numbering = loadNumbering(documentPart);
-        XWPFListManager xwpfListManager = new XWPFListManager(numbering);
+        XWPFNumberingShim numbering = loadNumbering(documentPart);
+        XWPFListManager xwpfListManager = new XWPFListManager(
+                numbering != null ? numbering : XWPFNumberingShim.EMPTY);
         //TODO: XWPFStyles styles = loadStyles(documentPart);
 
         //headers
@@ -232,7 +249,7 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
         return hyperlinks;
     }
 
-    private XWPFNumbering loadNumbering(PackagePart packagePart) throws IOException {
+    private XWPFNumberingShim loadNumbering(PackagePart packagePart) {
         try {
             PackageRelationshipCollection numberingParts =
                     packagePart.getRelationshipsByType(XWPFRelation.NUMBERING.getRelation());
@@ -245,16 +262,16 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
                 if (numberingPart == null) {
                     return null;
                 }
-                return new XWPFNumbering(numberingPart);
+                return new XWPFNumberingShim(numberingPart, new ParseContext());
             }
-        } catch (OpenXML4JException e) {
+        } catch (Exception e) {
             LOG.warn("Couldn't load numbering", e);
         }
         return null;
     }
 
     private static class XWPFToTextContentHandler
-            implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler {
+            implements XWPFBodyContentsHandler {
         private final StringBuilder buffer;
 
         public XWPFToTextContentHandler(StringBuilder buffer) {
@@ -328,7 +345,7 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
 
         @Override
         public void startEditedSection(String editor, Date date,
-                                       OOXMLWordAndPowerPointTextHandler.EditType editType) {
+                                       EditType editType) {
 
         }
 
@@ -352,13 +369,18 @@ public class XWPFEventBasedWordExtractor implements POIXMLTextExtractor {
 
         }
 
+        @Override
+        public void commentReference(String id) {
+
+        }
+
         @Override
         public boolean isIncludeMoveFromText() {
             return false;
         }
 
         @Override
-        public void embeddedOLERef(String refId) {
+        public void embeddedOLERef(String refId, String progId, String emfImageRId) {
             //no-op
         }
 
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/xwpf/XWPFFeatureExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFFeatureExtractor.java
index 06e45afb72..3cb1935cc6 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFFeatureExtractor.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFFeatureExtractor.java
@@ -21,6 +21,7 @@ import java.io.InputStream;
 import java.util.HashSet;
 import java.util.Set;
 
+import org.apache.poi.openxml4j.opc.PackagePart;
 import org.apache.poi.xwpf.usermodel.XWPFDocument;
 import org.xml.sax.Attributes;
 import org.xml.sax.SAXException;
@@ -42,8 +43,11 @@ import org.apache.tika.utils.XMLReaderUtils;
 public class XWPFFeatureExtractor {
 
     public void process(XWPFDocument xwpfDocument, Metadata metadata, ParseContext parseContext) {
-        try (InputStream is = xwpfDocument.getPackagePart()
-                                          .getInputStream()) {
+        process(xwpfDocument.getPackagePart(), metadata, parseContext);
+    }
+
+    public void process(PackagePart packagePart, Metadata metadata, ParseContext parseContext) {
+        try (InputStream is = packagePart.getInputStream()) {
             FeatureHandler featureHandler = new FeatureHandler();
             XMLReaderUtils.parseSAX(is, featureHandler, parseContext);
             if (featureHandler.hasComments) {
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/xwpf/XWPFNumberingShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
index f563223964..7d8d15fc7f 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
@@ -17,20 +17,274 @@
 package org.apache.tika.parser.microsoft.ooxml.xwpf;
 
 import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
 
-import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
 import org.apache.poi.openxml4j.opc.PackagePart;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.AbstractListManager.LevelTuple;
+import org.apache.tika.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler;
+import org.apache.tika.utils.XMLReaderUtils;
 
 /**
- * Stub class of POI's XWPFNumbering because onDocumentRead() is protected
+ * SAX-based parser for numbering.xml that replaces the XMLBeans-dependent
+ * POI XWPFNumbering. This eliminates the need for ooxml-lite schema classes
+ * in the SAX parsing chain.
+ * <p>
+ * Follows the same pattern as {@link XWPFStylesShim}.
  */
-public class XWPFNumberingShim extends XWPFNumbering {
+public class XWPFNumberingShim {
+
+    public static final XWPFNumberingShim EMPTY = new EmptyNumberingShim();
 
-    public XWPFNumberingShim(PackagePart part) throws IOException, OpenXML4JException {
-        super(part);
-        onDocumentRead();
+    // abstractNumId -> list of LevelTuples (indexed by ilvl)
+    private final Map<Integer, LevelTuple[]> abstractNumLevels = new HashMap<>();
+    // numId -> abstractNumId
+    private final Map<Integer, Integer> numToAbstractNum = new HashMap<>();
+    // numId -> override LevelTuples (indexed by ilvl), null entries for non-overridden levels
+    private final Map<Integer, Map<Integer, LevelTuple>> overrideLevels = new HashMap<>();
+
+    private XWPFNumberingShim() {
     }
 
+    public XWPFNumberingShim(PackagePart part, ParseContext parseContext)
+            throws IOException, TikaException, SAXException {
+        try (InputStream is = part.getInputStream()) {
+            XMLReaderUtils.parseSAX(is, new NumberingHandler(), parseContext);
+        }
+    }
 
+    /**
+     * @return the abstractNumId for the given numId, or -1 if not found
+     */
+    public int getAbstractNumId(int numId) {
+        Integer id = numToAbstractNum.get(numId);
+        return id != null ? id : -1;
+    }
+
+    /**
+     * @return the level tuples for the given abstractNumId, or null if not found
+     */
+    public LevelTuple[] getAbstractNumLevels(int abstractNumId) {
+        return abstractNumLevels.get(abstractNumId);
+    }
+
+    /**
+     * Build override level tuples array for a given numId with the specified length.
+     * Returns null if there are no overrides for this numId.
+     */
+    public LevelTuple[] getOverrideLevels(int numId, int length) {
+        Map<Integer, LevelTuple> overrides = overrideLevels.get(numId);
+        if (overrides == null || overrides.isEmpty()) {
+            return null;
+        }
+        LevelTuple[] result = new LevelTuple[length];
+        for (int i = 0; i < length; i++) {
+            LevelTuple override = overrides.get(i);
+            if (override != null) {
+                result[i] = override;
+            } else {
+                result[i] = new LevelTuple("%" + i + ".");
+            }
+        }
+        return result;
+    }
+
+    private static class EmptyNumberingShim extends XWPFNumberingShim {
+        @Override
+        public int getAbstractNumId(int numId) {
+            return -1;
+        }
+
+        @Override
+        public LevelTuple[] getAbstractNumLevels(int abstractNumId) {
+            return null;
+        }
+
+        @Override
+        public LevelTuple[] getOverrideLevels(int numId, int length) {
+            return null;
+        }
+    }
+
+    private class NumberingHandler extends DefaultHandler {
+
+        private static final String W_NS = OOXMLWordAndPowerPointTextHandler.W_NS;
+
+        // Current context
+        private boolean inAbstractNum = false;
+        private int currentAbstractNumId = -1;
+        private boolean inNum = false;
+        private int currentNumId = -1;
+        private boolean inLvl = false;
+        private boolean inLvlOverride = false;
+        private int currentIlvl = -1;
+
+        // Level accumulators (reset for each lvl element)
+        private int lvlStart = -1;
+        private int lvlRestart = -1;
+        private String lvlText = null;
+        private String lvlNumFmt = null;
+        private boolean lvlIsLegal = false;
+
+        // Collecting levels for current abstractNum
+        private final Map<Integer, LevelTuple> currentAbstractLevels = new HashMap<>();
+        // Collecting overrides for current num
+        private final Map<Integer, LevelTuple> currentOverrides = new HashMap<>();
+
+        @Override
+        public void startElement(String uri, String localName, String qName, Attributes atts)
+                throws SAXException {
+            if (!W_NS.equals(uri)) {
+                return;
+            }
+
+            switch (localName) {
+                case "abstractNum":
+                    inAbstractNum = true;
+                    currentAbstractNumId = getIntAttr(atts, W_NS, "abstractNumId", -1);
+                    currentAbstractLevels.clear();
+                    break;
+                case "num":
+                    inNum = true;
+                    currentNumId = getIntAttr(atts, W_NS, "numId", -1);
+                    currentOverrides.clear();
+                    break;
+                case "lvlOverride":
+                    if (inNum) {
+                        inLvlOverride = true;
+                        currentIlvl = getIntAttr(atts, W_NS, "ilvl", -1);
+                    }
+                    break;
+                case "lvl":
+                    inLvl = true;
+                    currentIlvl = getIntAttr(atts, W_NS, "ilvl", -1);
+                    // Reset accumulators
+                    lvlStart = -1;
+                    lvlRestart = -1;
+                    lvlText = null;
+                    lvlNumFmt = null;
+                    lvlIsLegal = false;
+                    break;
+                case "start":
+                    if (inLvl) {
+                        lvlStart = getIntAttr(atts, W_NS, "val", -1);
+                    }
+                    break;
+                case "numFmt":
+                    if (inLvl) {
+                        lvlNumFmt = atts.getValue(W_NS, "val");
+                    }
+                    break;
+                case "lvlText":
+                    if (inLvl) {
+                        lvlText = atts.getValue(W_NS, "val");
+                    }
+                    break;
+                case "lvlRestart":
+                    if (inLvl) {
+                        lvlRestart = getIntAttr(atts, W_NS, "val", -1);
+                    }
+                    break;
+                case "isLgl":
+                    if (inLvl) {
+                        lvlIsLegal = true;
+                    }
+                    break;
+                case "abstractNumId":
+                    if (inNum && !inLvl) {
+                        int absId = getIntAttr(atts, W_NS, "val", -1);
+                        if (currentNumId >= 0 && absId >= 0) {
+                            numToAbstractNum.put(currentNumId, absId);
+                        }
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        @Override
+        public void endElement(String uri, String localName, String qName) throws SAXException {
+            if (!W_NS.equals(uri)) {
+                return;
+            }
+            switch (localName) {
+                case "lvl":
+                    if (inLvl && currentIlvl >= 0) {
+                        LevelTuple tuple = buildLevelTuple(currentIlvl);
+                        if (inLvlOverride && inNum) {
+                            currentOverrides.put(currentIlvl, tuple);
+                        } else if (inAbstractNum) {
+                            currentAbstractLevels.put(currentIlvl, tuple);
+                        }
+                    }
+                    inLvl = false;
+                    break;
+                case "lvlOverride":
+                    inLvlOverride = false;
+                    break;
+                case "abstractNum":
+                    if (inAbstractNum && currentAbstractNumId >= 0 &&
+                            !currentAbstractLevels.isEmpty()) {
+                        int maxLevel = currentAbstractLevels.keySet().stream()
+                                .mapToInt(Integer::intValue).max().orElse(-1);
+                        LevelTuple[] levels = new LevelTuple[maxLevel + 1];
+                        for (int i = 0; i <= maxLevel; i++) {
+                            LevelTuple t = currentAbstractLevels.get(i);
+                            levels[i] = t != null ? t : new LevelTuple("%" + i + ".");
+                        }
+                        abstractNumLevels.put(currentAbstractNumId, levels);
+                    }
+                    inAbstractNum = false;
+                    currentAbstractNumId = -1;
+                    break;
+                case "num":
+                    if (inNum && currentNumId >= 0 && !currentOverrides.isEmpty()) {
+                        overrideLevels.put(currentNumId, new HashMap<>(currentOverrides));
+                    }
+                    inNum = false;
+                    currentNumId = -1;
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        private LevelTuple buildLevelTuple(int level) {
+            int start = lvlStart;
+            int restart = lvlRestart;
+            String text = lvlText != null ? lvlText : "%" + level + ".";
+            String numFmt = lvlNumFmt != null ? lvlNumFmt : "decimal";
+
+            if (start < 0) {
+                // Same hack as XWPFListManager.buildTuple
+                if ("decimal".equals(numFmt) || "ordinal".equals(numFmt) ||
+                        "decimalZero".equals(numFmt)) {
+                    start = 0;
+                } else {
+                    start = 1;
+                }
+            }
+            return new LevelTuple(start, restart, text, numFmt, lvlIsLegal);
+        }
+
+        private int getIntAttr(Attributes atts, String ns, String localName, int defaultVal) {
+            String val = atts.getValue(ns, localName);
+            if (val == null) {
+                return defaultVal;
+            }
+            try {
+                return Integer.parseInt(val);
+            } catch (NumberFormatException e) {
+                return defaultVal;
+            }
+        }
+    }
 }
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/SXWPFExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxTest.java
similarity index 50%
rename from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
rename to tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxTest.java
index 1ec8be3242..7407e169c6 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxTest.java
@@ -19,167 +19,35 @@ 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.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.ByteArrayOutputStream;
 import java.io.PrintStream;
 import java.util.Arrays;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
 
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import org.apache.tika.TikaTest;
-import org.apache.tika.config.loader.TikaLoader;
-import org.apache.tika.exception.EncryptedDocumentException;
 import org.apache.tika.metadata.DublinCore;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
-import org.apache.tika.metadata.OfficeOpenXMLCore;
 import org.apache.tika.metadata.OfficeOpenXMLExtended;
 import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.PasswordProvider;
 import org.apache.tika.parser.microsoft.OfficeParserConfig;
 
+public abstract class AbstractOOXMLDocxTest extends TikaTest {
 
-public class SXWPFExtractorTest extends TikaTest {
-
-    private ParseContext parseContext;
-
-    @BeforeEach
-    public void setUp() {
-        parseContext = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
-
-    }
-
-    @Test
-    public void basicTest() throws Exception {
-
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_2006ml.docx", parseContext);
-
-        assertEquals(8, metadataList.size());
-        Metadata m = metadataList.get(0);
-
-        assertEquals("2016-11-29T00:58:00Z", m.get(TikaCoreProperties.CREATED));
-        assertEquals("2016-11-29T17:54:00Z", m.get(TikaCoreProperties.MODIFIED));
-        assertEquals("My Document Title", m.get(TikaCoreProperties.TITLE));
-        assertEquals("This is the Author", m.get(TikaCoreProperties.CREATOR));
-        assertEquals("3", m.get(OfficeOpenXMLCore.REVISION));
-        assertEquals("Allison, Timothy B.", m.get(TikaCoreProperties.MODIFIER));
-        //assertEquals("0", m.get(OfficeOpenXMLExtended.DOC_SECURITY));
-        assertEquals("260", m.get(Office.WORD_COUNT));
-        assertEquals("3", m.get(Office.PARAGRAPH_COUNT));
-        assertEquals("1742", m.get(Office.CHARACTER_COUNT_WITH_SPACES));
-        assertEquals("12", m.get(Office.LINE_COUNT));
-        assertEquals("16.0000", m.get(OfficeOpenXMLExtended.APP_VERSION));
-
-
-        String content = m.get(TikaCoreProperties.TIKA_CONTENT);
-
-        assertContainsCount("engaging title page", content, 1);
-        //need \n to differentiate from metadata values
-        assertContainsCount("This is the Author\n", content, 1);
-        assertContainsCount("This is an engaging title page", content, 1);
-
-        assertContains("My Document Title", content);
-        assertContains("My Document Subtitle", content);
-
-        assertContains("<p class=\"toc_1\">\t<a href=\"#_Toc467647605\">Heading1\t3</a></p>",
-                content);
-
-
-        assertContains("2. Really basic 2.", content);
-
-        assertContainsCount("This is a text box", content, 1);
-
-        assertContains("<p>This is a hyperlink: <a href=\"http://tika.apache.org\">tika</a></p>",
-                content);
-
-        assertContains(
-                "<p>This is a link to a local file: <a href=\"file:///C:/data/test.png\">test.png</a></p>",
-                content);
-
-        assertContains("<p>This is          10 spaces</p>", content);
-
-        //caption
-        assertContains(
-                "<p class=\"table_of_figures\">\t<a href=\"#_Toc467647797\">Table 1: Table1 Caption\t2</a></p>",
-                content);
-
-        //embedded table
-        //TODO: figure out how to handle embedded tables in html
-        assertContains("<td>Embedded table r1c1", content);
-
-        //shape
-        assertContainsCount("<p>This is text within a shape", content, 1);
-
-        //sdt rich text
-        assertContains("<p>Rich text content control", content);
-
-        //sdt simple text
-        assertContains("<p>Simple text content control", content);
-
-        //sdt repeating
-        assertContains("Repeating content", content);
-
-        //sdt dropdown
-        //TODO: get options for dropdown
-        assertContains("Drop down1", content);
-
-        //sdt date
-        assertContains("<p>11/16/2016</p>", content);
-
-        //test that <tab/> works
-        assertContains("tab\ttab", content);
-
-        assertContainsCount("serious word art", content, 1);
-        assertContainsCount("Wordartr1c1", content, 1);
-
-        //glossary document contents
-        assertContains("Click or tap to enter a date", content);
-
-        //basic b/i tags...make sure not to overlap!
-        assertContains("<p>The <i>quick</i> brown <b>fox </b>j<i>um</i><b><i>ped</i></b> over",
-                content);
-
-        assertContains("This is a comment", content);
-
-        assertContains("This is an endnote", content);
-
-        assertContains("this is the footnote", content);
-
-        assertContains("First page header", content);
-
-        assertContains("Even page header", content);
-
-        assertContains("Odd page header", content);
-
-        assertContains("First page footer", content);
-
-        assertContains("Even page footer", content);
-
-        assertContains("Odd page footer", content);
-
-        //test default does not include deleted
-        assertNotContained("frog", content);
-
-        assertContains("Mattmann", content);
-
-        //TODO: extract chart text
-//        assertContains("This is the chart title", content);
-
-        //TODO: add chart parsing
-//        assertContains("This is the chart", content);
+    abstract ParseContext getParseContext();
 
+    protected OfficeParserConfig getOrCreateOfficeParserConfig(ParseContext parseContext) {
+        OfficeParserConfig config = parseContext.get(OfficeParserConfig.class);
+        if (config == null) {
+            config = new OfficeParserConfig();
+            parseContext.set(OfficeParserConfig.class, config);
+        }
+        return config;
     }
 
     /**
@@ -190,7 +58,7 @@ public class SXWPFExtractorTest extends TikaTest {
     @Test
     public void testWord() throws Exception {
 
-        XMLResult xmlResult = getXML("testWORD.docx", parseContext);
+        XMLResult xmlResult = getXML("testWORD.docx", getParseContext());
         assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                 xmlResult.metadata.get(Metadata.CONTENT_TYPE));
         assertEquals("Sample Word Document", xmlResult.metadata.get(TikaCoreProperties.TITLE));
@@ -206,16 +74,22 @@ public class SXWPFExtractorTest extends TikaTest {
      */
     @Test
     public void testWordFootnote() throws Exception {
-        XMLResult xmlResult = getXML("footnotes.docx", parseContext);
+        XMLResult xmlResult = getXML("footnotes.docx", getParseContext());
         assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                 xmlResult.metadata.get(Metadata.CONTENT_TYPE));
         assertTrue(xmlResult.xml.contains("snoska"));
+        //TIKA-4657 -- footnote content should be in a div with class "footnote"
+        // and should not be nested inside the paragraph
+        assertContains("<div class=\"footnote\">", xmlResult.xml);
+        assertNotContained("<p><div class=\"footnote\">", xmlResult.xml);
     }
 
     @Test
     public void testEndnoteWithTable() throws Exception {
-        XMLResult xmlResult = getXML("testWORD_endnote_table.docx", parseContext);
+        XMLResult xmlResult = getXML("testWORD_endnote_table.docx", getParseContext());
         assertContains("Cat Property Act", xmlResult.xml);
+        //TIKA-4657 -- endnote content should be in a div with class "endnote"
+        assertContains("<div class=\"endnote\">", xmlResult.xml);
     }
 
     /**
@@ -224,7 +98,7 @@ public class SXWPFExtractorTest extends TikaTest {
      */
     @Test
     public void testWordHTML() throws Exception {
-        XMLResult result = getXML("testWORD.docx", parseContext);
+        XMLResult result = getXML("testWORD.docx", getParseContext());
         String xml = result.xml;
         Metadata metadata = result.metadata;
         assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -239,9 +113,9 @@ public class SXWPFExtractorTest extends TikaTest {
         assertContains("<h1>Heading Level 1</h1>", xml);
         assertTrue(xml.contains("<h2>Heading Level 2</h2>"));
         // Headings with anchor tags in them
-        //TODO: still not getting bookmarks
-        assertTrue(xml.contains("<h3>Heading Level 3<a name=\"OnLevel3\" /></h3>"));
-//        assertTrue(xml.contains("<h3>Heading Level 3</h3>"));
+        // Bookmark position relative to text differs between DOM and SAX
+        assertContains("Heading Level 3", xml);
+        assertContains("<a name=\"OnLevel3\" />", xml);
         // Bold and italic
         assertTrue(xml.contains("<b>BOLD</b>"));
         assertTrue(xml.contains("<i>ITALIC</i>"));
@@ -255,7 +129,7 @@ public class SXWPFExtractorTest extends TikaTest {
         // Paragraphs with other styles
         assertTrue(xml.contains("<p class=\"signature\">This one"));
 
-        result = getXML("testWORD_3imgs.docx", parseContext);
+        result = getXML("testWORD_3imgs.docx", getParseContext());
         xml = result.xml;
 
         // Images 2-4 (there is no 1!)
@@ -270,33 +144,12 @@ public class SXWPFExtractorTest extends TikaTest {
         assertTrue(xml.contains("<p>The end!</p>"));
     }
 
-    @Test
-    public void testContiguousHTMLFormatting() throws Exception {
-        // TIKA-692: test document containing multiple
-        // character runs within a bold tag:
-        String xml = getXML("testWORD_bold_character_runs.docx", parseContext).xml;
-
-        // Make sure bold text arrived as single
-        // contiguous string even though Word parser
-        // handled this as 3 character runs
-        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"), "Bold text wasn't contiguous: " + xml);
-
-        // TIKA-692: test document containing multiple
-        // character runs within a bold tag:
-        xml = getXML("testWORD_bold_character_runs2.docx", parseContext).xml;
-
-        // Make sure bold text arrived as single
-        // contiguous string even though Word parser
-        // handled this as 3 character runs
-        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"), "Bold text wasn't contiguous: " + xml);
-    }
-
     /**
      * Test that we can extract image from docx header
      */
     @Test
     public void testWordPicturesInHeader() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("headerPic.docx", parseContext);
+        List<Metadata> metadataList = getRecursiveMetadata("headerPic.docx", getParseContext());
         assertEquals(2, metadataList.size());
         Metadata m = metadataList.get(0);
         String mainContent = m.get(TikaCoreProperties.TIKA_CONTENT);
@@ -306,108 +159,89 @@ public class SXWPFExtractorTest extends TikaTest {
         assertTrue(mainContent.contains("<img"));
     }
 
-    @Test
-    public void testPicturesInVariousPlaces() throws Exception {
-        //test that images are actually extracted from
-        //headers, footers, comments, endnotes, footnotes
-        List<Metadata> metadataList =
-                getRecursiveMetadata("testWORD_embedded_pics.docx", parseContext);
-
-        //only process embedded resources once
-        assertEquals(3, metadataList.size());
-        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-        for (int i = 1; i < 4; i++) {
-            assertContains("header" + i + "_pic", content);
-            assertContains("footer" + i + "_pic", content);
-        }
-        assertContains("body_pic.jpg", content);
-        assertContains("sdt_pic.jpg", content);
-        assertContains("deeply_embedded_pic", content);
-        assertContains("deleted_pic", content);//TODO: don't extract this
-        assertContains("footnotes_pic", content);
-        assertContains("comments_pic", content);
-        assertContains("endnotes_pic", content);
-//        assertContains("sdt2_pic.jpg", content);//name of file is not stored in image-sdt
-
-        assertContainsCount("<img src=", content, 14);
-    }
-
     /**
      * Test docx without headers
      * TIKA-633
      */
     @Test
     public void testNullHeaders() throws Exception {
-        XMLResult xmlResult = getXML("NullHeader.docx", parseContext);
+        XMLResult xmlResult = getXML("NullHeader.docx", getParseContext());
         assertEquals(false, xmlResult.xml.isEmpty(), "Should have found some text");
     }
 
+    /**
+     * Test for missing text described in
+     * <a href="https://issues.apache.org/jira/browse/TIKA-1130">TIKA-1130</a>.
+     * and TIKA-1317
+     */
     @Test
-    public void testVarious() throws Exception {
-        Metadata metadata = new Metadata();
-        String content = getText("testWORD_various.docx", metadata, parseContext);
-        //content = content.replaceAll("\\s+"," ");
-        assertContains("Footnote appears here", content);
-        assertContains("This is a footnote.", content);
-        assertContains("This is the header text.", content);
-        assertContains("This is the footer text.", content);
-        assertContains("Here is a text box", content);
-        assertContains("Bold", content);
-        assertContains("italic", content);
-        assertContains("underline", content);
-        assertContains("superscript", content);
-        assertContains("subscript", content);
-        assertContains("Here is a citation:", content);
-        assertContains("Figure 1 This is a caption for Figure 1", content);
-        assertContains("(Kramer)", content);
-        assertContains("Row 1 Col 1 Row 1 Col 2 Row 1 Col 3 Row 2 Col 1 Row 2 Col 2 Row 2 Col 3",
-                content.replaceAll("\\s+", " "));
-        assertContains("Row 1 column 1 Row 2 column 1 Row 1 column 2 Row 2 column 2",
-                content.replaceAll("\\s+", " "));
-        assertContains("This is a hyperlink", content);
-        assertContains("Here is a list:", content);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains("·\tBullet " + row, content);
-            //assertContains("\u00b7\tBullet " + row, content);
-            assertContains("Bullet " + row, content);
-        }
-        assertContains("Here is a numbered list:", content);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains(row + ")\tNumber bullet " + row, content);
-            //assertContains(row + ") Number bullet " + row, content);
-            // TODO: OOXMLExtractor fails to number the bullets:
-            assertContains("Number bullet " + row, content);
-        }
+    public void testMissingText() throws Exception {
 
-        for (int row = 1; row <= 2; row++) {
-            for (int col = 1; col <= 3; col++) {
-                assertContains("Row " + row + " Col " + col, content);
-            }
-        }
+        XMLResult xmlResult = getXML("testWORD_missing_text.docx", getParseContext());
+        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+                xmlResult.metadata.get(Metadata.CONTENT_TYPE));
+        assertContains("BigCompany", xmlResult.xml);
+        assertContains("Seasoned", xmlResult.xml);
+        assertContains("Rich_text_in_cell", xmlResult.xml);
 
-        assertContains("Keyword1 Keyword2", content);
-        assertEquals("Keyword1 Keyword2", metadata.get(Office.KEYWORDS));
+    }
 
-        assertContains("Subject is here", content);
-        assertEquals("Subject is here", metadata.get(DublinCore.SUBJECT));
+    @Test
+    public void testBoldHyperlink() throws Exception {
+        //TIKA-1255
+        String xml = getXML("testWORD_boldHyperlink.docx", getParseContext()).xml;
+        xml = xml.replaceAll("\\s+", " ");
+        assertContains("<a href=\"http://tika.apache.org/\">hyper <b>link</b></a>", xml);
+        assertContains("<a href=\"http://tika.apache.org/\"><b>hyper</b> link</a>; bold", xml);
+    }
 
-        assertContains("Suddenly some Japanese text:", content);
-        // Special version of (GHQ)
-        assertContains("\uff08\uff27\uff28\uff31\uff09", content);
-        // 6 other characters
-        assertContains("\u30be\u30eb\u30b2\u3068\u5c3e\u5d0e\u3001\u6de1\u3005\u3068\u6700\u671f",
-                content);
+    @Test
+    public void testLongForIntExceptionInSummaryDetails() throws Exception {
+        //TIKA-2055
+        assertContains("bold",
+                getXML("testWORD_totalTimeOutOfRange.docx", getParseContext()).xml);
+    }
 
-        assertContains("And then some Gothic text:", content);
-        assertContains("\uD800\uDF32\uD800\uDF3f\uD800\uDF44\uD800\uDF39\uD800\uDF43\uD800\uDF3A",
-                content);
+    @Test
+    public void testMultiAuthorsManagers() throws Exception {
+        XMLResult r = getXML("testWORD_multi_authors.docx", getParseContext());
+        String[] authors = r.metadata.getValues(TikaCoreProperties.CREATOR);
+        assertEquals(3, authors.length);
+        assertEquals("author2", authors[1]);
+
+        String[] managers = r.metadata.getValues(OfficeOpenXMLExtended.MANAGER);
+        assertEquals(2, managers.length);
+        assertEquals("manager1", managers[0]);
+        assertEquals("manager2", managers[1]);
+    }
+
+    @Test
+    public void testOrigSourcePath() throws Exception {
+        Metadata embed1_zip_metadata =
+                getRecursiveMetadata("test_recursive_embedded.docx", getParseContext()).get(2);
+        assertContains("C:\\Users\\tallison\\AppData\\Local\\Temp\\embed1.zip", Arrays.asList(
+                embed1_zip_metadata.getValues(TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
+        assertContains("C:\\Users\\tallison\\Desktop\\tmp\\New folder (2)\\embed1.zip",
+                Arrays.asList(
+                        embed1_zip_metadata.getValues(
+                                TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
+    }
+
+    @Test
+    public void testDOCXThumbnail() throws Exception {
+        String xml = getXML("testDOCX_Thumbnail.docx", getParseContext()).xml;
+        int a = xml.indexOf("This file contains a thumbnail");
+        int b = xml.indexOf("<div class=\"embedded\" id=\"/docProps/thumbnail.emf\" />");
+        assertTrue(a != -1);
+        assertTrue(b != -1);
+        assertTrue(a < b);
     }
 
     @Test
     public void testWordCustomProperties() throws Exception {
         ParseContext context = new ParseContext();
         context.set(Locale.class, Locale.US);
-        Metadata metadata = getXML("testWORD_custom_props.docx", parseContext).metadata;
+        Metadata metadata = getXML("testWORD_custom_props.docx", getParseContext()).metadata;
 
         assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                 metadata.get(Metadata.CONTENT_TYPE));
@@ -420,7 +254,8 @@ public class SXWPFExtractorTest extends TikaTest {
         assertEquals("2", metadata.get(Office.WORD_COUNT));
         assertEquals("My Title", metadata.get(TikaCoreProperties.TITLE));
         assertEquals("My Keyword", metadata.get(Office.KEYWORDS));
-        assertContains("My Keyword", Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
+        assertContains("My Keyword",
+                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
         assertEquals("Normal.dotm", metadata.get(OfficeOpenXMLExtended.TEMPLATE));
         assertEquals("My subject", metadata.get(DublinCore.SUBJECT));
         assertEquals("EDF-DIT", metadata.get(TikaCoreProperties.PUBLISHER));
@@ -434,7 +269,7 @@ public class SXWPFExtractorTest extends TikaTest {
     // TIKA-989:
     @Test
     public void testEmbeddedPDF() throws Exception {
-        String xml = getXML("testWORD_embedded_pdf.docx", parseContext).xml;
+        String xml = getXML("testWORD_embedded_pdf.docx", getParseContext()).xml;
         int i = xml.indexOf("Here is the pdf file:");
         int j = xml.indexOf("<div class=\"embedded\" id=\"rId5\" />");
         int k = xml.indexOf("Bye Bye");
@@ -454,7 +289,7 @@ public class SXWPFExtractorTest extends TikaTest {
     // TIKA-1006
     @Test
     public void testWordNullStyle() throws Exception {
-        String xml = getXML("testWORD_null_style.docx").xml;
+        String xml = getXML("testWORD_null_style.docx", getParseContext()).xml;
         assertContains("Test av styrt dokument", xml);
     }
 
@@ -465,160 +300,30 @@ public class SXWPFExtractorTest extends TikaTest {
     @Test
     public void testNoFormat() throws Exception {
         assertContains("This is a piece of text that causes an exception",
-                getXML("testWORD_no_format.docx", parseContext).xml);
-    }
-
-    @Test
-    public void testSkipDeleted() throws Exception {
-        ParseContext pc = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setIncludeDeletedContent(true);
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        officeParserConfig.setIncludeMoveFromContent(true);
-        pc.set(OfficeParserConfig.class, officeParserConfig);
-
-        XMLResult r = getXML("testWORD_2006ml.docx", pc);
-        assertContains("frog", r.xml);
-        assertContainsCount("Second paragraph", r.xml, 2);
-
+                getXML("testWORD_no_format.docx", getParseContext()).xml);
     }
 
     // TIKA-1005:
     @Test
     public void testTextInsideTextBox() throws Exception {
-        String xml = getXML("testWORD_text_box.docx", parseContext).xml;
+        String xml = getXML("testWORD_text_box.docx", getParseContext()).xml;
         assertContains("This text is directly in the body of the document.", xml);
         assertContains("This text is inside of a text box in the body of the document.", xml);
         assertContains("This text is inside of a text box in the header of the document.", xml);
         assertContains("This text is inside of a text box in the footer of the document.", xml);
     }
 
-    //TIKA-2346
-    @Test
-    public void testTurningOffTextBoxExtraction() throws Exception {
-        ParseContext pc = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setIncludeShapeBasedContent(false);
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        pc.set(OfficeParserConfig.class, officeParserConfig);
-        String xml = getXML("testWORD_text_box.docx", pc).xml;
-        assertContains("This text is directly in the body of the document.", xml);
-        assertNotContained("This text is inside of a text box in the body of the document.", xml);
-        assertNotContained("This text is inside of a text box in the header of the document.", xml);
-        assertNotContained("This text is inside of a text box in the footer of the document.", xml);
-    }
-
-    /**
-     * Test for missing text described in
-     * <a href="https://issues.apache.org/jira/browse/TIKA-1130">TIKA-1130</a>.
-     * and TIKA-1317
-     */
-    @Test
-    public void testMissingText() throws Exception {
-
-        XMLResult xmlResult = getXML("testWORD_missing_text.docx", parseContext);
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                xmlResult.metadata.get(Metadata.CONTENT_TYPE));
-        assertContains("BigCompany", xmlResult.xml);
-        assertContains("Seasoned", xmlResult.xml);
-        assertContains("Rich_text_in_cell", xmlResult.xml);
-
-    }
-
-    //TIKA-792; with room for future missing bean tests
-    @Test
-    public void testWordMissingOOXMLBeans() throws Exception {
-        //If a bean is missing, POI prints stack trace to stderr
-        String[] fileNames = new String[]{"testWORD_missing_ooxml_bean1.docx",//TIKA-792
-        };
-        PrintStream origErr = System.err;
-        for (String fileName : fileNames) {
-
-            //grab stderr
-            ByteArrayOutputStream errContent = new ByteArrayOutputStream();
-            System.setErr(new PrintStream(errContent, true, UTF_8.name()));
-            getXML(fileName, parseContext);
-
-            //return stderr
-            System.setErr(origErr);
-
-            String err = errContent.toString(UTF_8.name());
-            assertTrue(err.isEmpty(), "expected no error msg, but got >" +
-                    err + "<");
-        }
-    }
-
-    @Test
-    public void testDOCXThumbnail() throws Exception {
-        String xml = getXML("testDOCX_Thumbnail.docx", parseContext).xml;
-        int a = xml.indexOf("This file contains a thumbnail");
-        int b = xml.indexOf("<div class=\"embedded\" id=\"/docProps/thumbnail.emf\" />");
-        assertTrue(a != -1);
-        assertTrue(b != -1);
-        assertTrue(a < b);
-    }
-
-    @Test
-    public void testEncrypted() throws Exception {
-        Map<String, String> tests = new HashMap<>();
-        tests.put("testWORD_protected_passtika.docx", "This is an encrypted Word 2007 File");
-
-        Metadata m = new Metadata();
-        PasswordProvider passwordProvider = metadata -> "tika";
-
-        OfficeParserConfig opc = new OfficeParserConfig();
-        opc.setUseSAXDocxExtractor(true);
-        ParseContext passwordContext = new ParseContext();
-        passwordContext.set(org.apache.tika.parser.PasswordProvider.class, passwordProvider);
-        passwordContext.set(OfficeParserConfig.class, opc);
-        for (Map.Entry<String, String> e : tests.entrySet()) {
-            assertContains(e.getValue(), getXML(e.getKey(), passwordContext).xml);
-        }
-
-        //now try with no password
-        for (Map.Entry<String, String> e : tests.entrySet()) {
-            boolean exc = false;
-            try {
-                getXML(e.getKey(), parseContext);
-            } catch (EncryptedDocumentException ex) {
-                exc = true;
-            }
-            assertTrue(exc);
-        }
-
-    }
-
+    //TIKA-2807
     @Test
-    public void testDOCXParagraphNumbering() throws Exception {
-        String xml = getXML("testWORD_numbered_list.docx", parseContext).xml;
-        //SAX parser is getting this.  DOM parser is not!
-        assertContains("add a list here", xml);
-        assertContains("1) This", xml);
-        assertContains("a) Is", xml);
-        assertContains("i) A multi", xml);
-        assertContains("ii) Level", xml);
-        assertContains("1. Within cell 1", xml);
-        assertContains("b. Cell b", xml);
-        assertContains("iii) List", xml);
-        assertContains("2) foo", xml);
-        assertContains("ii) baz", xml);
-        assertContains("ii) foo", xml);
-        assertContains("II. bar", xml);
-        assertContains("6. six", xml);
-        assertContains("7. seven", xml);
-        assertContains("a. seven a", xml);
-        assertContains("e. seven e", xml);
-        assertContains("2. A ii 2", xml);
-        assertContains("3. page break list 3", xml);
-        assertContains("Some-1-CrazyFormat Greek numbering with crazy format - alpha", xml);
-        assertContains("1.1.1. 1.1.1", xml);
-        assertContains("1.1. 1.2-&gt;1.1  //set the value", xml);
-
+    public void testSDTInTextBox() throws Exception {
+        String xml = getXML("testWORD_sdtInTextBox.docx", getParseContext()).xml;
+        assertContains("rich-text-content-control_inside-text-box", xml);
+        assertContainsCount("inside-text", xml, 1);
     }
 
     @Test
     public void testDOCXOverrideParagraphNumbering() throws Exception {
-        String xml = getXML("testWORD_override_list_numbering.docx").xml;
+        String xml = getXML("testWORD_override_list_numbering.docx", getParseContext()).xml;
 
         //Test 1
         assertContains("<p>1.1.1.1...1 1.1.1.1...1</p>", xml);
@@ -660,197 +365,97 @@ public class SXWPFExtractorTest extends TikaTest {
         assertContains(">02 02", xml);
     }
 
-    @Test
-    public void testMultiAuthorsManagers() throws Exception {
-        XMLResult r = getXML("testWORD_multi_authors.docx", parseContext);
-        String[] authors = r.metadata.getValues(TikaCoreProperties.CREATOR);
-        assertEquals(3, authors.length);
-        assertEquals("author2", authors[1]);
-
-        String[] managers = r.metadata.getValues(OfficeOpenXMLExtended.MANAGER);
-        assertEquals(2, managers.length);
-        assertEquals("manager1", managers[0]);
-        assertEquals("manager2", managers[1]);
-    }
-
-    @Test
-    public void testOrigSourcePath() throws Exception {
-        Metadata embed1_zip_metadata =
-                getRecursiveMetadata("test_recursive_embedded.docx", parseContext).get(2);
-        assertContains("C:\\Users\\tallison\\AppData\\Local\\Temp\\embed1.zip", Arrays.asList(
-                embed1_zip_metadata.getValues(TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
-        assertContains("C:\\Users\\tallison\\Desktop\\tmp\\New folder (2)\\embed1.zip",
-                Arrays.asList(
-                        embed1_zip_metadata.getValues(TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
-    }
-
-    @Test
-    public void testBoldHyperlink() throws Exception {
-        //TIKA-1255
-        String xml = getXML("testWORD_boldHyperlink.docx", parseContext).xml;
-        xml = xml.replaceAll("\\s+", " ");
-        assertContains("<a href=\"http://tika.apache.org/\">hyper <b>link</b></a>", xml);
-        assertContains("<a href=\"http://tika.apache.org/\"><b>hyper</b> link</a>; bold", xml);
-    }
-
-    @Test
-    public void testLongForIntExceptionInSummaryDetails() throws Exception {
-        //TIKA-2055
-        assertContains("bold", getXML("testWORD_totalTimeOutOfRange.docx", parseContext).xml);
-    }
-
-    @Test
-    public void testMacrosInDocm() throws Exception {
-
-        Metadata parsedBy = new Metadata();
-        parsedBy.add(TikaCoreProperties.TIKA_PARSED_BY,
-                "org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFEventBasedWordExtractor");
-
-        //test default is "don't extract macros"
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_macros.docm", parseContext);
-        for (Metadata metadata : metadataList) {
-            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
-                fail("Shouldn't have extracted macros as default");
-            }
-        }
-        assertContainsAtLeast(parsedBy, metadataList);
-
-        //now test that they were extracted
-        ParseContext context = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setExtractMacros(true);
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        context.set(OfficeParserConfig.class, officeParserConfig);
-
-        metadataList = getRecursiveMetadata("testWORD_macros.docm", context);
-        //check that content came out of the .docm file
-        assertContains("quick", metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT));
-        assertContainsAtLeast(parsedBy, metadataList);
-
-
-        Metadata minExpected = new Metadata();
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
-        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
-        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
-
-        assertContainsAtLeast(minExpected, metadataList);
-        assertContainsAtLeast(parsedBy, metadataList);
-
-        //test configuring via config file
-        AutoDetectParser parser = (AutoDetectParser) TikaLoader.load(
-                getConfigPath(SXWPFExtractorTest.class, "tika-config-sax-macros.json"))
-                .loadAutoDetectParser();
-        metadataList = getRecursiveMetadata("testWORD_macros.docm", parser);
-        assertContainsAtLeast(minExpected, metadataList);
-        assertContainsAtLeast(parsedBy, metadataList);
-    }
-
-    @Test
-    public void testEmbedded() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_embeded.docx", parseContext);
-        Metadata main = metadataList.get(0);
-        String content = main.get(TikaCoreProperties.TIKA_CONTENT);
-        //make sure mark up is there
-        assertContains("<img src=\"embedded:image2.jpeg\" alt=\"A description...\" />", content);
-
-        assertContains("<div class=\"embedded\" id=\"rId8\" />", content);
-
-        assertEquals(16, metadataList.size());
-    }
-
-    @Test
-    public void testDotx() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_template.docx", parseContext);
-        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-        assertContains("Metallica", content);
-        assertContains("Hetfield", content);
-        assertContains("one eye open", content);
-        assertContains("Getting the perfect", content);
-        //from glossary document
-        assertContains("table rows", content);
-
-        metadataList = getRecursiveMetadata("testWORD_template.dotx", parseContext);
-        content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-        //from glossary document
-        assertContainsCount("ready to write", content, 2);
-    }
-
     @Test
     public void testDiagramData() throws Exception {
-        assertContains("From here", getXML("testWORD_diagramData.docx", parseContext).xml);
+        assertContains("From here",
+                getXML("testWORD_diagramData.docx", getParseContext()).xml);
     }
 
     @Test
     public void testDOCXChartData() throws Exception {
-        String xml = getXML("testWORD_charts.docx", parseContext).xml;
+        String xml = getXML("testWORD_charts.docx", getParseContext()).xml;
         assertContains("peach", xml);
         assertContains("March\tApril", xml);
         assertNotContained("chartSpace", xml);
     }
 
-    @Test
-    public void testHeaderFooterNotExtraction() throws Exception {
-        ParseContext parseContext = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setIncludeHeadersAndFooters(false);
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
-        String xml = getXML("testWORD_various.docx", parseContext).xml;
-        assertNotContained("This is the header text.", xml);
-        assertNotContained("This is the footer text.", xml);
-    }
-
     @Test
     public void testDOCXPhoneticStrings() throws Exception {
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setUseSAXDocxExtractor(true);
-        ParseContext parseContext = new ParseContext();
-        parseContext.set(OfficeParserConfig.class, config);
         assertContains("\u6771\u4EAC (\u3068\u3046\u304D\u3087\u3046)",
-                getXML("testWORD_phonetic.docx", parseContext).xml);
-
+                getXML("testWORD_phonetic.docx", getParseContext()).xml);
 
+        OfficeParserConfig config = new OfficeParserConfig();
         config.setConcatenatePhoneticRuns(false);
+        ParseContext parseContext = getParseContext();
+        parseContext.set(OfficeParserConfig.class, config);
         String xml = getXML("testWORD_phonetic.docx", parseContext).xml;
         assertContains("\u6771\u4EAC", xml);
         assertNotContained("\u3068", xml);
     }
 
     @Test
-    public void testTextDecoration() throws Exception {
-        String xml = getXML("testWORD_various.docx", parseContext).xml;
-
-        assertContains("<b>Bold</b>", xml);
-        assertContains("<i>italic</i>", xml);
-        assertContains("<u>underline</u>", xml);
-        assertContains("<strike>strikethrough</strike>", xml);
-    }
+    public void testVarious() throws Exception {
+        Metadata metadata = new Metadata();
+        String content = getText("testWORD_various.docx", metadata, getParseContext());
+        //content = content.replaceAll("\\s+"," ");
+        assertContains("Footnote appears here", content);
+        assertContains("This is a footnote.", content);
+        assertContains("This is the header text.", content);
+        assertContains("This is the footer text.", content);
+        assertContains("Here is a text box", content);
+        assertContains("Bold", content);
+        assertContains("italic", content);
+        assertContains("underline", content);
+        assertContains("superscript", content);
+        assertContains("subscript", content);
+        assertContains("Here is a citation:", content);
+        assertContains("Figure 1 This is a caption for Figure 1", content);
+        assertContains("(Kramer)", content);
+        assertContains("Row 1 Col 1 Row 1 Col 2 Row 1 Col 3 Row 2 Col 1 Row 2 Col 2 Row 2 Col 3",
+                content.replaceAll("\\s+", " "));
+        assertContains("Row 1 column 1 Row 2 column 1 Row 1 column 2 Row 2 column 2",
+                content.replaceAll("\\s+", " "));
+        assertContains("This is a hyperlink", content);
+        assertContains("Here is a list:", content);
+        for (int row = 1; row <= 3; row++) {
+            //assertContains("·\tBullet " + row, content);
+            //assertContains("\u00b7\tBullet " + row, content);
+            assertContains("Bullet " + row, content);
+        }
+        assertContains("Here is a numbered list:", content);
+        for (int row = 1; row <= 3; row++) {
+            //assertContains(row + ")\tNumber bullet " + row, content);
+            //assertContains(row + ") Number bullet " + row, content);
+            // TODO: OOXMLExtractor fails to number the bullets:
+            assertContains("Number bullet " + row, content);
+        }
 
-    @Test
-    public void testTextDecorationNested() throws Exception {
-        String xml = getXML("testWORD_various.docx", parseContext).xml;
+        for (int row = 1; row <= 2; row++) {
+            for (int col = 1; col <= 3; col++) {
+                assertContains("Row " + row + " Col " + col, content);
+            }
+        }
 
-        assertContains("<i>ita<strike>li</strike>c</i>", xml);
-        assertContains("<i>ita<strike>l<u>i</u></strike>c</i>", xml);
-        assertContains("<i><u>unde</u><strike><u>r</u></strike><u>line</u></i>", xml);
+        assertContains("Keyword1 Keyword2", content);
+        assertEquals("Keyword1 Keyword2", metadata.get(Office.KEYWORDS));
+        assertContains("Keyword1 Keyword2",
+                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
 
-        //confirm that spaces aren't added for <strike/> and <u/>
-        String txt = getText("testWORD_various.docx", new Metadata(), parseContext);
-        assertContainsCount("italic", txt, 3);
-        assertNotContained("ita ", txt);
+        assertContains("Subject is here", content);
+        assertEquals("Subject is here", metadata.get(DublinCore.SUBJECT));
+        assertContains("Subject is here",
+                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
 
-        assertContainsCount("underline", txt, 2);
-        assertNotContained("unde ", txt);
-    }
+        assertContains("Suddenly some Japanese text:", content);
+        // Special version of (GHQ)
+        assertContains("\uff08\uff27\uff28\uff31\uff09", content);
+        // 6 other characters
+        assertContains("\u30be\u30eb\u30b2\u3068\u5c3e\u5d0e\u3001\u6de1\u3005\u3068\u6700\u671f",
+                content);
 
-    //TIKA-2807
-    @Test
-    public void testSDTInTextBox() throws Exception {
-        String xml = getXML("testWORD_sdtInTextBox.docx", parseContext).xml;
-        assertContains("rich-text-content-control_inside-text-box", xml);
-        assertContainsCount("inside-text", xml, 1);
+        assertContains("And then some Gothic text:", content);
+        assertContains("\uD800\uDF32\uD800\uDF3f\uD800\uDF44\uD800\uDF39\uD800\uDF43\uD800\uDF3A",
+                content);
     }
 
     /**
@@ -859,24 +464,26 @@ public class SXWPFExtractorTest extends TikaTest {
      */
     @Test
     public void testInstrTextHyperlink() throws Exception {
-        String xml = getXML("testInstrLink.docx", parseContext).xml;
+        String xml = getXML("testInstrLink.docx", getParseContext()).xml;
         // The document contains a HYPERLINK field code in instrText
         assertContains("<a href=\"https://exmaple.com/file\">", xml);
         assertContains("Access Document(s)", xml);
     }
 
     /**
-     * Test extraction of external reference field codes (INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK).
+     * Test extraction of external reference field codes (INCLUDEPICTURE, INCLUDETEXT, IMPORT,
+     * LINK).
      * These can be used to hide malicious URLs in documents.
      */
     @Test
     public void testExternalRefFieldCodes() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testExternalRefs.docx", parseContext);
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testExternalRefs.docx", getParseContext());
         Metadata m = metadataList.get(0);
         // Check metadata flag is set
         assertEquals("true", m.get(Office.HAS_FIELD_HYPERLINKS));
 
-        String xml = getXML("testExternalRefs.docx", parseContext).xml;
+        String xml = getXML("testExternalRefs.docx", getParseContext()).xml;
         // Test INCLUDEPICTURE field code
         assertContains("class=\"external-ref-INCLUDEPICTURE\"", xml);
         assertContains("http://example.com/tracking.png", xml);
@@ -891,75 +498,104 @@ public class SXWPFExtractorTest extends TikaTest {
         assertContains("http://test.invalid/cmd.docx", xml);
     }
 
-    /**
-     * Test extraction of hlinkHover (hover hyperlinks) and VML shape hrefs.
-     * These are sneaky ways to hide malicious URLs.
-     */
     @Test
-    public void testHoverAndVmlHyperlinks() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testHoverAndVml.docx", parseContext);
-        Metadata m = metadataList.get(0);
-        // Check metadata flags are set
-        assertEquals("true", m.get(Office.HAS_HOVER_HYPERLINKS));
-        assertEquals("true", m.get(Office.HAS_VML_HYPERLINKS));
+    public void testTextDecoration() throws Exception {
+        String xml = getXML("testWORD_various.docx", getParseContext()).xml;
 
-        String xml = getXML("testHoverAndVml.docx", parseContext).xml;
-        // Test hlinkHover (activates on mouse hover, not click)
-        assertContains("class=\"external-ref-hlinkHover\"", xml);
-        assertContains("http://hover.example.com/phishing", xml);
-        // Test VML shape href
-        assertContains("class=\"external-ref-vml-shape-href\"", xml);
-        assertContains("http://vml.example.org/shape-link", xml);
+        assertContains("<b>Bold</b>", xml);
+        assertContains("<i>italic</i>", xml);
+        assertContains("<u>underline</u>", xml);
+        assertContains("<s>strikethrough</s>", xml);
     }
 
-    /**
-     * Test detection of mail merge in Word documents.
-     * Mail merge can reference external data sources.
-     */
     @Test
-    public void testMailMerge() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testMailMerge.docx", parseContext);
-        Metadata m = metadataList.get(0);
-        assertEquals("true", m.get(Office.HAS_MAIL_MERGE));
+    public void testTextDecorationNested() throws Exception {
+        String xml = getXML("testWORD_various.docx", getParseContext()).xml;
+
+        assertContains("<i>ita<s>li</s>c</i>", xml);
+        assertContains("<i>ita<s>l<u>i</u></s>c</i>", xml);
+
+        String txt = getText("testWORD_various.docx", new Metadata(), getParseContext());
+        assertContainsCount("italic", txt, 3);
+        assertNotContained("ita ", txt);
+
+        assertContainsCount("underline", txt, 2);
+        assertNotContained("unde ", txt);
     }
 
-    /**
-     * Test detection of attached external template.
-     * Templates can be fetched from malicious URLs.
-     */
+    // TIKA-692
     @Test
-    public void testAttachedTemplate() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testAttachedTemplate.docx", parseContext);
-        Metadata m = metadataList.get(0);
-        assertEquals("true", m.get(Office.HAS_ATTACHED_TEMPLATE));
+    public void testContiguousHTMLFormatting() throws Exception {
+        String xml = getXML("testWORD_bold_character_runs.docx", getParseContext()).xml;
+        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"),
+                "Bold text wasn't contiguous: " + xml);
 
-        String xml = getXML("testAttachedTemplate.docx", parseContext).xml;
-        assertContains("class=\"external-ref-attachedTemplate\"", xml);
-        assertContains("example.com/templates", xml);
+        xml = getXML("testWORD_bold_character_runs2.docx", getParseContext()).xml;
+        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"),
+                "Bold text wasn't contiguous: " + xml);
     }
 
-    /**
-     * Test detection of subdocuments (master document linking external docs).
-     */
+    //TIKA-2346
     @Test
-    public void testSubdocument() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testSubdocument.docx", parseContext);
-        Metadata m = metadataList.get(0);
-        assertEquals("true", m.get(Office.HAS_SUBDOCUMENTS));
+    public void testTurningOffTextBoxExtraction() throws Exception {
+        ParseContext pc = getParseContext();
+        OfficeParserConfig config = getOrCreateOfficeParserConfig(pc);
+        config.setIncludeShapeBasedContent(false);
+        String xml = getXML("testWORD_text_box.docx", pc).xml;
+        assertContains("This text is directly in the body of the document.", xml);
+        assertNotContained(
+                "This text is inside of a text box in the body of the document.", xml);
+        assertNotContained(
+                "This text is inside of a text box in the header of the document.", xml);
+        assertNotContained(
+                "This text is inside of a text box in the footer of the document.", xml);
+    }
+
+    @Test
+    public void testHeaderFooterNotExtraction() throws Exception {
+        ParseContext pc = getParseContext();
+        OfficeParserConfig config = getOrCreateOfficeParserConfig(pc);
+        config.setIncludeHeadersAndFooters(false);
+        String xml = getXML("testWORD_various.docx", pc).xml;
+        assertNotContained("This is the header text.", xml);
+        assertNotContained("This is the footer text.", xml);
+    }
 
-        String xml = getXML("testSubdocument.docx", parseContext).xml;
-        assertContains("class=\"external-ref-subDocument\"", xml);
-        assertContains("example.org/chapters", xml);
+    @Test
+    public void testWordMissingOOXMLBeans() throws Exception {
+        String[] fileNames = new String[]{"testWORD_missing_ooxml_bean1.docx"};
+        PrintStream origErr = System.err;
+        for (String fileName : fileNames) {
+            ByteArrayOutputStream errContent = new ByteArrayOutputStream();
+            System.setErr(new PrintStream(errContent, true, UTF_8.name()));
+            getXML(fileName, getParseContext());
+            System.setErr(origErr);
+            String err = errContent.toString(UTF_8.name());
+            assertTrue(err.isEmpty(),
+                    "expected no error msg, but got >" + err + "<");
+        }
     }
 
-    /**
-     * Test detection of framesets (HTML frames loading external URLs).
-     */
+
     @Test
-    public void testFrameset() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testFrameset.docx", parseContext);
+    public void testFeatureExtraction() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_features.docx", getParseContext());
         Metadata m = metadataList.get(0);
-        assertEquals("true", m.get(Office.HAS_FRAMESETS));
+        assertContains("Kyle Reese", Arrays.asList(m.getValues(Office.COMMENT_PERSONS)));
+        assertEquals("true", m.get(Office.HAS_HIDDEN_TEXT));
+        assertEquals("true", m.get(Office.HAS_TRACK_CHANGES));
+        assertEquals("true", m.get(Office.HAS_COMMENTS));
     }
 
+    @Test
+    public void testDotx() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_template.docx", getParseContext());
+        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains("Metallica", content);
+        assertContains("Hetfield", content);
+        assertContains("one eye open", content);
+        assertContains("Getting the perfect", content);
+    }
 }
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/SXSLFExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxTest.java
similarity index 52%
rename from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
rename to tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxTest.java
index 6d95f5404c..c2f79be3fe 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxTest.java
@@ -18,125 +18,33 @@ package org.apache.tika.parser.microsoft.ooxml;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
 
-import java.util.HashMap;
 import java.util.List;
-import java.util.Locale;
-import java.util.Map;
 
-import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.xml.sax.ContentHandler;
 
 import org.apache.tika.TikaTest;
-import org.apache.tika.config.loader.TikaLoader;
-import org.apache.tika.exception.EncryptedDocumentException;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
-import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.PasswordProvider;
 import org.apache.tika.parser.microsoft.OfficeParserConfig;
 import org.apache.tika.sax.BodyContentHandler;
 
+public abstract class AbstractOOXMLPptxTest extends TikaTest {
 
-public class SXSLFExtractorTest extends TikaTest {
+    abstract ParseContext getParseContext();
 
-    OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-    private ParseContext parseContext;
-
-    @BeforeEach
-    public void setUp() {
-        parseContext = new ParseContext();
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
-
-    }
-
-    @Test
-    public void basicTest() throws Exception {
-
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_various2.pptx", parseContext);
-
-        assertEquals(14, metadataList.size(), "right number of attachments");
-
-        String mainContent = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-
-        assertContains("This slide is hidden", mainContent);//TODO: parameterize this
-
-        //basic content
-        assertContains("FirstBullet", mainContent);
-
-        //hyperlink
-        assertContains("<a href=\"http://tika.apache.org/\">tika_hyperlink</a>", mainContent);
-        //hyperlink in cell
-        assertContains("<a href=\"http://lucene.apache.org/\">lucene_hyperlink</a>",
-                mainContent);
-
-        //text box
-        assertContains("Slide2TextBox", mainContent);
-        assertContains("<td>R1c1</td>", mainContent);
-
-        //wordArt
-        assertContains("This is some WordART", mainContent);
-
-        //notes
-        assertContains("NotesForSlide2", mainContent);
-        assertContains("Notes for slide3", mainContent);
-        assertContains("NotesMasterHeader", mainContent);
-        assertContains("NotesMasterFooter", mainContent);
-        assertContains("NotesMasterPageNumber", mainContent);
-        assertContains("NotesWordArt", mainContent);
-        assertContains("NotesWordArtPage2", mainContent);
-        assertContains("NotesTableSlide2", mainContent);
-
-        //comments
-        assertContains(
-                "<p class=\"slide-comment\"><b>Timothy Allison (TA)</b>This is a reply to the " +
-                        "initial comment</p>",
-                mainContent);
-
-        //HandoutMaster
-        assertContains("HandoutHeader1", mainContent);
-        assertContains("HandoutFooter", mainContent);
-        assertContains("HandoutDate", mainContent);
-        assertContains("TextBoxInHandOut", mainContent);
-
-        //text box in master
-        assertContains("MASTERTEXTBOX", mainContent);
-
-        //equation
-        assertContains("3/4", mainContent);
-
-        //make sure footer elements are in their own <p/>
-        assertContains("<p>12/16/2016</p>", mainContent);
-        assertContains("<p>8</p>", mainContent);
-
-
-        assertContains("<td>NotesTableSlide2", mainContent);
-
-        assertContains("MASTERFOOTERMSG", mainContent);
-
-
-        //should not include boilerplate from master
-        assertNotContained("Click to edit Master", mainContent);
-        assertNotContained("Second level", mainContent);
-
-        //TODO: chart content
-        //assertContains("SLIDE3ChartTitle", mainContent);
-        //assertContains("Category 1", mainContent);
-    }
-
-    @Test
-    public void poiBug54916Test() throws Exception {
-        String xml = getXML("testPPTX_overlappingRelations.pptx", parseContext).xml;
-        assertContains("POI cannot read this", xml);
-        assertContains("Has a relationship to another slide", xml);
-        assertContains("can read this too", xml);
+    protected OfficeParserConfig getOrCreateOfficeParserConfig(ParseContext parseContext) {
+        OfficeParserConfig config = parseContext.get(OfficeParserConfig.class);
+        if (config == null) {
+            config = new OfficeParserConfig();
+            parseContext.set(OfficeParserConfig.class, config);
+        }
+        return config;
     }
 
     /**
@@ -145,7 +53,7 @@ public class SXSLFExtractorTest extends TikaTest {
      */
     @Test
     public void testPowerPoint() throws Exception {
-        String[] extensions = new String[]{"pptx", "pptm", "ppsm", "ppsx", "potm",
+        String[] extensions = new String[]{"pptx", "pptm", "ppsm", "ppsx", "potm"
                 //"thmx", // TIKA-418: Will be supported in POI 3.7 beta 2
                 //"xps" // TIKA-418: Not yet supported by POI
         };
@@ -155,7 +63,7 @@ public class SXSLFExtractorTest extends TikaTest {
                 "application/vnd.ms-powerpoint.presentation.macroenabled.12",
                 "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
                 "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
-                "application/vnd.ms-powerpoint.template.macroenabled.12",};
+                "application/vnd.ms-powerpoint.template.macroenabled.12"};
 
         for (int i = 0; i < extensions.length; i++) {
             String extension = extensions[i];
@@ -165,7 +73,7 @@ public class SXSLFExtractorTest extends TikaTest {
             ContentHandler handler = new BodyContentHandler();
 
             try (TikaInputStream tis = getResourceAsStream("/test-documents/" + filename)) {
-                AUTO_DETECT_PARSER.parse(tis, handler, metadata, parseContext);
+                AUTO_DETECT_PARSER.parse(tis, handler, metadata, getParseContext());
 
                 assertEquals(mimeTypes[i], metadata.get(Metadata.CONTENT_TYPE),
                         "Mime-type checking for " + filename);
@@ -229,7 +137,7 @@ public class SXSLFExtractorTest extends TikaTest {
             };
 
             try (TikaInputStream tis = getResourceAsStream("/test-documents/" + filename)) {
-                AUTO_DETECT_PARSER.parse(tis, handler, metadata, parseContext);
+                AUTO_DETECT_PARSER.parse(tis, handler, metadata, getParseContext());
             }
         }
     }
@@ -254,7 +162,7 @@ public class SXSLFExtractorTest extends TikaTest {
             ContentHandler handler = new BodyContentHandler();
 
             try (TikaInputStream tis = getResourceAsStream("/test-documents/" + filename)) {
-                AUTO_DETECT_PARSER.parse(tis, handler, metadata, parseContext);
+                AUTO_DETECT_PARSER.parse(tis, handler, metadata, getParseContext());
 
                 // Should get the metadata
                 assertEquals(mimeTypes[i], metadata.get(Metadata.CONTENT_TYPE),
@@ -268,7 +176,7 @@ public class SXSLFExtractorTest extends TikaTest {
     @Test
     public void testVariousPPTX() throws Exception {
         Metadata metadata = new Metadata();
-        String xml = getXML("testPPT_various.pptx", metadata, parseContext).xml;
+        String xml = getXML("testPPT_various.pptx", metadata, getParseContext()).xml;
         assertContains("<p>Footnote appears here", xml);
         assertContains("<p>[1] This is a footnote.", xml);
         assertContains("<p>This is the header text.</p>", xml);
@@ -286,15 +194,10 @@ public class SXSLFExtractorTest extends TikaTest {
         assertContains("<p><a href=\"http://tika.apache.org/\">This is a hyperlink</a>", xml);
         assertContains("<p>Here is a list:", xml);
         for (int row = 1; row <= 3; row++) {
-            //assertContains("·\tBullet " + row, content);
-            //assertContains("\u00b7\tBullet " + row, content);
             assertContains("<p>Bullet " + row, xml);
         }
         assertContains("Here is a numbered list:", xml);
         for (int row = 1; row <= 3; row++) {
-            //assertContains(row + ")\tNumber bullet " + row, content);
-            //assertContains(row + ") Number bullet " + row, content);
-            // TODO: OOXMLExtractor fails to number the bullets:
             assertContains("<p>Number bullet " + row, xml);
         }
 
@@ -323,15 +226,14 @@ public class SXSLFExtractorTest extends TikaTest {
 
     @Test
     public void testCommentPPTX() throws Exception {
-        XMLResult r = getXML("testPPT_comment.pptx", parseContext);
+        XMLResult r = getXML("testPPT_comment.pptx", getParseContext());
         assertContains("<p class=\"slide-comment\"><b>Allison, Timothy B. (ATB)", r.xml);
     }
 
     @Test
     public void testMasterFooter() throws Exception {
-
         assertContains("Master footer is here",
-                getXML("testPPT_masterFooter.pptx", parseContext).xml);
+                getXML("testPPT_masterFooter.pptx", getParseContext()).xml);
     }
 
     @Test
@@ -341,11 +243,9 @@ public class SXSLFExtractorTest extends TikaTest {
 
         //the underlying xml has "Master footer" in
         //the actual slide's xml, not just in the master slide.
-        OfficeParserConfig config = new OfficeParserConfig();
+        ParseContext context = getParseContext();
+        OfficeParserConfig config = getOrCreateOfficeParserConfig(context);
         config.setIncludeSlideMasterContent(false);
-        config.setUseSAXPptxExtractor(true);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
         String xml = getXML("testPPT_masterFooter.pptx", context).xml;
         assertNotContained("Master footer", xml);
     }
@@ -357,90 +257,35 @@ public class SXSLFExtractorTest extends TikaTest {
     @Test
     public void testMasterText() throws Exception {
         assertContains("Text that I added to the master slide",
-                getXML("testPPT_masterText.pptx", parseContext).xml);
+                getXML("testPPT_masterText.pptx", getParseContext()).xml);
 
         //now test turning off master content
-        OfficeParserConfig config = new OfficeParserConfig();
+        ParseContext context = getParseContext();
+        OfficeParserConfig config = getOrCreateOfficeParserConfig(context);
         config.setIncludeSlideMasterContent(false);
-        config.setUseSAXPptxExtractor(true);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
 
-        assertNotContained("Text that I added", getXML("testPPT_masterText.pptx", context).xml);
+        assertNotContained("Text that I added",
+                getXML("testPPT_masterText.pptx", context).xml);
     }
 
     @Test
     public void testMasterText2() throws Exception {
         assertContains("Text that I added to the master slide",
-                getXML("testPPT_masterText2.pptx", parseContext).xml);
+                getXML("testPPT_masterText2.pptx", getParseContext()).xml);
 
         //now test turning off master content
-        OfficeParserConfig config = new OfficeParserConfig();
+        ParseContext context = getParseContext();
+        OfficeParserConfig config = getOrCreateOfficeParserConfig(context);
         config.setIncludeSlideMasterContent(false);
-        config.setUseSAXPptxExtractor(true);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
 
-        assertNotContained("Text that I added", getXML("testPPT_masterText2.pptx", context).xml);
+        assertNotContained("Text that I added",
+                getXML("testPPT_masterText2.pptx", context).xml);
     }
 
     @Test
     public void testWordArt() throws Exception {
-        assertContains("Here is some red word Art", getXML("testWordArt.pptx", parseContext).xml);
-    }
-
-    @Test
-    public void testPowerPointCustomProperties() throws Exception {
-        Metadata metadata = new Metadata();
-
-        ParseContext context = new ParseContext();
-        context.set(Locale.class, Locale.US);
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        context.set(OfficeParserConfig.class, officeParserConfig);
-
-        getXML("testPPT_custom_props.pptx", metadata, parseContext);
-        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                metadata.get(Metadata.CONTENT_TYPE));
-        assertEquals("JOUVIN ETIENNE", metadata.get(TikaCoreProperties.CREATOR));
-        assertEquals("EJ04325S", metadata.get(TikaCoreProperties.MODIFIER));
-        assertEquals("2011-08-22T13:30:53Z", metadata.get(TikaCoreProperties.CREATED));
-        assertEquals("2011-08-22T13:32:49Z", metadata.get(TikaCoreProperties.MODIFIED));
-        assertEquals("1", metadata.get(Office.SLIDE_COUNT));
-        assertEquals("3", metadata.get(Office.WORD_COUNT));
-        assertEquals("Test extraction properties pptx", metadata.get(TikaCoreProperties.TITLE));
-        assertEquals("true", metadata.get("custom:myCustomBoolean"));
-        assertEquals("3", metadata.get("custom:myCustomNumber"));
-        assertEquals("MyStringValue", metadata.get("custom:MyCustomString"));
-        assertEquals("2010-12-30T22:00:00Z", metadata.get("custom:MyCustomDate"));
-        assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
-    }
-
-    // TIKA-997:
-    @Test
-    @Disabled("TODO: add in embedded file markup")
-    public void testEmbeddedZipInPPTX() throws Exception {
-        String xml = getXML("test_embedded_zip.pptx", parseContext).xml;
-        int h = xml.indexOf("<div class=\"embedded\" id=\"slide1_rId3\" />");
-        int i = xml.indexOf("Send me a note");
-        int j = xml.indexOf("<div class=\"embedded\" id=\"slide2_rId4\" />");
-        int k = xml.indexOf("<p>No title</p>");
-        assertTrue(h != -1);
-        assertTrue(i != -1);
-        assertTrue(j != -1);
-        assertTrue(k != -1);
-        assertTrue(h < i);
-        assertTrue(i < j);
-        assertTrue(j < k);
-    }
-
-    // TIKA-1032:
-    @Test
-    @Disabled("TODO: add in embedded file markup")
-    public void testEmbeddedPPTXTwoSlides() throws Exception {
-        String xml = getXML("testPPT_embedded_two_slides.pptx", parseContext).xml;
-        assertContains("<div class=\"embedded\" id=\"slide1_rId7\" />", xml);
-        assertContains("<div class=\"embedded\" id=\"slide2_rId7\" />", xml);
+        assertContains("Here is some red word Art",
+                getXML("testWordArt.pptx", getParseContext()).xml);
     }
 
     //TIKA-817
@@ -449,14 +294,13 @@ public class SXSLFExtractorTest extends TikaTest {
         //Following POI-52368, the stored date is extracted,
         //not the auto-generated date.
 
-        XMLResult result = getXML("testPPT_autodate.pptx", parseContext);
+        XMLResult result = getXML("testPPT_autodate.pptx", getParseContext());
         assertContains("<p>Now</p>\n" + "<p>2011-12-19 10:20:04 AM</p>\n", result.xml);
-
     }
 
     @Test
     public void testPPTXThumbnail() throws Exception {
-        String xml = getXML("testPPTX_Thumbnail.pptx", parseContext).xml;
+        String xml = getXML("testPPTX_Thumbnail.pptx", getParseContext()).xml;
         int a = xml.indexOf(
                 "<body><div class=\"slide-content\"><p>This file contains an embedded thumbnail");
         int b = xml.indexOf("<div class=\"embedded\" id=\"/docProps/thumbnail.jpeg\" />");
@@ -466,120 +310,23 @@ public class SXSLFExtractorTest extends TikaTest {
     }
 
     @Test
-    public void testEncrypted() throws Exception {
-        Map<String, String> tests = new HashMap<>();
-        tests.put("testPPT_protected_passtika.pptx", "This is an encrypted PowerPoint 2007 slide.");
-
-        Metadata m = new Metadata();
-        PasswordProvider passwordProvider = new PasswordProvider() {
-            @Override
-            public String getPassword(Metadata metadata) {
-                return "tika";
-            }
-        };
-        ParseContext passwordContext = new ParseContext();
-        passwordContext.set(org.apache.tika.parser.PasswordProvider.class, passwordProvider);
-        passwordContext.set(OfficeParserConfig.class, officeParserConfig);
-
-        for (Map.Entry<String, String> e : tests.entrySet()) {
-            try (TikaInputStream tis = getResourceAsStream("/test-documents/" + e.getKey())) {
-                ContentHandler handler = new BodyContentHandler();
-                AUTO_DETECT_PARSER.parse(tis, handler, m, passwordContext);
-                assertContains(e.getValue(), handler.toString());
-            }
-        }
-
-        ParseContext context = new ParseContext();
-        //now try with no password
-        for (Map.Entry<String, String> e : tests.entrySet()) {
-            boolean exc = false;
-            try (TikaInputStream tis = getResourceAsStream("/test-documents/" + e.getKey())) {
-                ContentHandler handler = new BodyContentHandler();
-                AUTO_DETECT_PARSER.parse(tis, handler, m, context);
-            } catch (EncryptedDocumentException ex) {
-                exc = true;
-            }
-            assertTrue(exc);
-        }
-
-    }
-
-
-    @Test
-    public void testMacrosInPptm() throws Exception {
-
-        Metadata parsedBy = new Metadata();
-        parsedBy.add(TikaCoreProperties.TIKA_PARSED_BY,
-                "org.apache.tika.parser.microsoft.ooxml.xslf.XSLFEventBasedPowerPointExtractor");
-
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_macros.pptm", parseContext);
-
-        //test default is "don't extract macros"
-        for (Metadata metadata : metadataList) {
-            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
-                fail("Shouldn't have extracted macros as default");
-            }
-        }
-
-        assertContainsAtLeast(parsedBy, metadataList);
-
-        //now test that they are extracted
-        ParseContext context = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setExtractMacros(true);
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        context.set(OfficeParserConfig.class, officeParserConfig);
-
-        Metadata minExpected = new Metadata();
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
-        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
-        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
-
-        metadataList = getRecursiveMetadata("testPPT_macros.pptm", context);
-
-        assertContainsAtLeast(minExpected, metadataList);
-        assertContainsAtLeast(parsedBy, metadataList);
-
-        //test configuring via config file
-        AutoDetectParser parser = (AutoDetectParser) TikaLoader.load(
-                getConfigPath(SXSLFExtractorTest.class, "tika-config-sax-macros.json"))
-                .loadAutoDetectParser();
-        metadataList = getRecursiveMetadata("testPPT_macros.pptm", parser);
-        assertContainsAtLeast(minExpected, metadataList);
-        assertContainsAtLeast(parsedBy, metadataList);
-    }
-
-    @Test
-    public void testDiagramData() throws Exception {
-        assertContains("President", getXML("testPPT_diagramData.pptx", parseContext).xml);
+    public void testPPTXDiagramData() throws Exception {
+        assertContains("President",
+                getXML("testPPT_diagramData.pptx", getParseContext()).xml);
     }
 
     @Test
     public void testPPTXChartData() throws Exception {
-        String xml = getXML("testPPT_charts.pptx", parseContext).xml;
+        String xml = getXML("testPPT_charts.pptx", getParseContext()).xml;
         assertContains("peach", xml);
         assertContains("March\tApril", xml);
         assertNotContained("chartSpace", xml);
     }
 
-    @Test
-    public void testEmbeddedMedia() throws Exception {
-        List<Metadata> metadataList =
-                getRecursiveMetadata("testPPT_embeddedMP3.pptx", parseContext);
-        assertEquals(4, metadataList.size());
-        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                metadataList.get(0).get(Metadata.CONTENT_TYPE));
-        assertEquals("audio/mpeg", metadataList.get(1).get(Metadata.CONTENT_TYPE));
-        assertEquals("image/png", metadataList.get(2).get(Metadata.CONTENT_TYPE));
-        assertEquals("image/jpeg", metadataList.get(3).get(Metadata.CONTENT_TYPE));
-
-    }
-
     @Test
     public void testPPTXGroups() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_groups.pptx", parseContext);
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_groups.pptx", getParseContext());
         assertEquals(3, metadataList.size());
         String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
         assertContains("WordArt1", content);
@@ -591,7 +338,6 @@ public class SXSLFExtractorTest extends TikaTest {
         assertContains("Text box4", content);
         assertContains("Text box5", content);
 
-
         assertContains("href=\"http://tika.apache.org", content);
         assertContains("smart1", content);
         assertContains("MyTitle", content);
@@ -603,4 +349,15 @@ public class SXSLFExtractorTest extends TikaTest {
                 metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
     }
 
+    @Test
+    public void testEmbeddedMedia() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_embeddedMP3.pptx", getParseContext());
+        assertEquals(4, metadataList.size());
+        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
+                metadataList.get(0).get(Metadata.CONTENT_TYPE));
+        assertEquals("audio/mpeg", metadataList.get(1).get(Metadata.CONTENT_TYPE));
+        assertEquals("image/png", metadataList.get(2).get(Metadata.CONTENT_TYPE));
+        assertEquals("image/jpeg", metadataList.get(3).get(Metadata.CONTENT_TYPE));
+    }
 }
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/OOXMLDocxDOMTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxDOMTest.java
new file mode 100644
index 0000000000..5d9fd42ec6
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxDOMTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.OfficeParserConfig;
+import org.apache.tika.parser.microsoft.OfficeParserTest;
+
+/**
+ * Runs the shared DOCX tests using the DOM-based parser (default),
+ * plus DOM-specific tests.
+ */
+public class OOXMLDocxDOMTest extends AbstractOOXMLDocxTest {
+
+    @Override
+    ParseContext getParseContext() {
+        return new ParseContext();
+    }
+
+    @Test
+    public void testTextDecorationNestedUnderlineStrike() throws Exception {
+        // DOM nests s inside u: <u>unde<s>r</s>line</u>
+        String xml = getXML("testWORD_various.docx", getParseContext()).xml;
+        assertContains("<i><u>unde<s>r</s>line</u></i>", xml);
+    }
+
+    @Test
+    public void testDOCXHeaderFooterNotExtractionViaConfig() throws Exception {
+        // Test configuration via tika-config
+        AutoDetectParser configuredParser = (AutoDetectParser) TikaLoader.load(
+                getConfigPath(OfficeParserTest.class, "tika-config-headers-footers.json"))
+                .loadAutoDetectParser();
+        String xml = getXML("testWORD_various.docx", configuredParser).xml;
+        assertNotContained("This is the header text.", xml);
+        assertNotContained("This is the footer text.", xml);
+    }
+
+    @Test
+    public void testDOCXParagraphNumbering() throws Exception {
+        String xml = getXML("testWORD_numbered_list.docx", getParseContext()).xml;
+        assertContains("1) This", xml);
+        assertContains("a) Is", xml);
+        assertContains("i) A multi", xml);
+        assertContains("ii) Level", xml);
+        assertContains("1. Within cell 1", xml);
+        assertContains("b. Cell b", xml);
+        assertContains("iii) List", xml);
+        assertContains("2) foo", xml);
+        assertContains("ii) baz", xml);
+        assertContains("ii) foo", xml);
+        assertContains("II. bar", xml);
+        assertContains("6. six", xml);
+        assertContains("7. seven", xml);
+        assertContains("a. seven a", xml);
+        assertContains("e. seven e", xml);
+        assertContains("2. A ii 2", xml);
+        assertContains("3. page break list 3", xml);
+        assertContains("Some-1-CrazyFormat Greek numbering with crazy format - alpha", xml);
+        assertContains("1.1.1. 1.1.1", xml);
+        assertContains("1.1. 1.2-&gt;1.1  //set the value", xml);
+    }
+
+    @Test
+    public void testMacrosInDocm() throws Exception {
+        //test default is "don't extract macros"
+        for (Metadata metadata : getRecursiveMetadata("testWORD_macros.docm", getParseContext())) {
+            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
+                fail("Shouldn't have extracted macros as default");
+            }
+        }
+
+        //now test that they were extracted
+        ParseContext context = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setExtractMacros(true);
+        context.set(OfficeParserConfig.class, officeParserConfig);
+
+        Metadata minExpected = new Metadata();
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
+        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
+        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
+
+        assertContainsAtLeast(minExpected,
+                getRecursiveMetadata("testWORD_macros.docm", context));
+
+        //test configuring via config file
+        AutoDetectParser parser = (AutoDetectParser) TikaLoader.load(
+                getConfigPath(OOXMLParserTest.class, "tika-config-dom-macros.json"))
+                .loadAutoDetectParser();
+        assertContainsAtLeast(minExpected,
+                getRecursiveMetadata("testWORD_macros.docm", parser));
+    }
+
+    @Test
+    public void testEmbedded() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_embeded.docx", getParseContext());
+        Metadata main = metadataList.get(0);
+        String content = main.get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains(
+                "<img src=\"embedded:image2.jpeg\" alt=\"A description...\" />", content);
+        assertContains("<div class=\"embedded\" id=\"rId8\" />", content);
+        assertEquals(16, metadataList.size());
+    }
+
+}
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
new file mode 100644
index 0000000000..38f0cb080b
--- /dev/null
+++ 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
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.exception.EncryptedDocumentException;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.OfficeOpenXMLCore;
+import org.apache.tika.metadata.OfficeOpenXMLExtended;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.PasswordProvider;
+import org.apache.tika.parser.microsoft.OfficeParserConfig;
+
+/**
+ * Runs the shared DOCX tests using the SAX-based streaming parser,
+ * plus SAX-specific tests (security features, deleted content, etc.).
+ */
+public class OOXMLDocxSAXTest extends AbstractOOXMLDocxTest {
+
+    @Override
+    ParseContext getParseContext() {
+        ParseContext parseContext = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setUseSAXDocxExtractor(true);
+        parseContext.set(OfficeParserConfig.class, officeParserConfig);
+        return parseContext;
+    }
+
+    @Test
+    public void testTextDecorationNestedUnderlineStrike() throws Exception {
+        // SAX closes u before opening s (since s is outer): <u>unde</u><s><u>r</u></s><u>line</u>
+        String xml = getXML("testWORD_various.docx", getParseContext()).xml;
+        assertContains("<i><u>unde</u><s><u>r</u></s><u>line</u></i>", xml);
+    }
+
+    @Test
+    public void basicTest() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_2006ml.docx", getParseContext());
+
+        assertEquals(8, metadataList.size());
+        Metadata m = metadataList.get(0);
+
+        assertEquals("2016-11-29T00:58:00Z", m.get(TikaCoreProperties.CREATED));
+        assertEquals("2016-11-29T17:54:00Z", m.get(TikaCoreProperties.MODIFIED));
+        assertEquals("My Document Title", m.get(TikaCoreProperties.TITLE));
+        assertEquals("This is the Author", m.get(TikaCoreProperties.CREATOR));
+        assertEquals("3", m.get(OfficeOpenXMLCore.REVISION));
+        assertEquals("Allison, Timothy B.", m.get(TikaCoreProperties.MODIFIER));
+        assertEquals("260", m.get(Office.WORD_COUNT));
+        assertEquals("3", m.get(Office.PARAGRAPH_COUNT));
+        assertEquals("1742", m.get(Office.CHARACTER_COUNT_WITH_SPACES));
+        assertEquals("12", m.get(Office.LINE_COUNT));
+        assertEquals("16.0000", m.get(OfficeOpenXMLExtended.APP_VERSION));
+
+        String content = m.get(TikaCoreProperties.TIKA_CONTENT);
+
+        assertContainsCount("engaging title page", content, 1);
+        assertContainsCount("This is the Author\n", content, 1);
+        assertContainsCount("This is an engaging title page", content, 1);
+
+        assertContains("My Document Title", content);
+        assertContains("My Document Subtitle", content);
+
+        assertContains(
+                "<p class=\"toc_1\">\t<a href=\"#_Toc467647605\">Heading1\t3</a></p>",
+                content);
+
+        assertContains("2. Really basic 2.", content);
+
+        assertContainsCount("This is a text box", content, 1);
+
+        assertContains(
+                "<p>This is a hyperlink: <a href=\"http://tika.apache.org\">tika</a></p>",
+                content);
+
+        assertContains(
+                "<p>This is a link to a local file: " +
+                        "<a href=\"file:///C:/data/test.png\">test.png</a></p>",
+                content);
+
+        assertContains("<p>This is          10 spaces</p>", content);
+
+        assertContains(
+                "<p class=\"table_of_figures\">\t" +
+                        "<a href=\"#_Toc467647797\">Table 1: Table1 Caption\t2</a></p>",
+                content);
+
+        assertContains("<td>Embedded table r1c1", content);
+        assertContainsCount("This is text within a shape", content, 1);
+        assertContains("<p>Rich text content control", content);
+        assertContains("<p>Simple text content control", content);
+        assertContains("Repeating content", content);
+        assertContains("Drop down1", content);
+        assertContains("<p>11/16/2016</p>", content);
+        assertContains("tab\ttab", content);
+        assertContainsCount("serious word art", content, 1);
+        assertContainsCount("Wordartr1c1", content, 1);
+        assertContains("Click or tap to enter a date", content);
+        assertContains(
+                "<p>The <i>quick</i> brown <b>fox </b>j<i>um</i><b><i>ped</i></b> over",
+                content);
+        assertContains("This is a comment", content);
+        assertContains("This is an endnote", content);
+        assertContains("this is the footnote", content);
+        assertContains("First page header", content);
+        assertContains("Even page header", content);
+        assertContains("Odd page header", content);
+        assertContains("First page footer", content);
+        assertContains("Even page footer", content);
+        assertContains("Odd page footer", content);
+        assertNotContained("frog", content);
+        assertContains("Mattmann", content);
+    }
+
+    @Test
+    public void testPicturesInVariousPlaces() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_embedded_pics.docx", getParseContext());
+
+        assertEquals(3, metadataList.size());
+        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
+        for (int i = 1; i < 4; i++) {
+            assertContains("header" + i + "_pic", content);
+            assertContains("footer" + i + "_pic", content);
+        }
+        assertContains("body_pic.jpg", content);
+        assertContains("sdt_pic.jpg", content);
+        assertContains("deeply_embedded_pic", content);
+        assertContains("deleted_pic", content);
+        assertContains("footnotes_pic", content);
+        assertContains("comments_pic", content);
+        assertContains("endnotes_pic", content);
+
+        assertContainsCount("<img src=", content, 14);
+    }
+
+    @Test
+    public void testSkipDeleted() throws Exception {
+        ParseContext pc = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setIncludeDeletedContent(true);
+        officeParserConfig.setUseSAXDocxExtractor(true);
+        officeParserConfig.setIncludeMoveFromContent(true);
+        pc.set(OfficeParserConfig.class, officeParserConfig);
+
+        XMLResult r = getXML("testWORD_2006ml.docx", pc);
+        assertContains("frog", r.xml);
+        assertContainsCount("Second paragraph", r.xml, 2);
+    }
+
+    @Test
+    public void testEncrypted() throws Exception {
+        Map<String, String> tests = new HashMap<>();
+        tests.put("testWORD_protected_passtika.docx",
+                "This is an encrypted Word 2007 File");
+
+        PasswordProvider passwordProvider = metadata -> "tika";
+        OfficeParserConfig opc = new OfficeParserConfig();
+        opc.setUseSAXDocxExtractor(true);
+        ParseContext passwordContext = new ParseContext();
+        passwordContext.set(PasswordProvider.class, passwordProvider);
+        passwordContext.set(OfficeParserConfig.class, opc);
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            assertContains(e.getValue(), getXML(e.getKey(), passwordContext).xml);
+        }
+
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            boolean exc = false;
+            try {
+                getXML(e.getKey(), getParseContext());
+            } catch (EncryptedDocumentException ex) {
+                exc = true;
+            }
+            assertTrue(exc);
+        }
+    }
+
+    @Test
+    public void testDOCXParagraphNumbering() throws Exception {
+        String xml = getXML("testWORD_numbered_list.docx", getParseContext()).xml;
+        assertContains("add a list here", xml);
+        assertContains("1) This", xml);
+        assertContains("a) Is", xml);
+        assertContains("i) A multi", xml);
+        assertContains("ii) Level", xml);
+        assertContains("1. Within cell 1", xml);
+        assertContains("b. Cell b", xml);
+        assertContains("iii) List", xml);
+        assertContains("2) foo", xml);
+        assertContains("ii) baz", xml);
+        assertContains("ii) foo", xml);
+        assertContains("II. bar", xml);
+        assertContains("6. six", xml);
+        assertContains("7. seven", xml);
+        assertContains("a. seven a", xml);
+        assertContains("e. seven e", xml);
+        assertContains("2. A ii 2", xml);
+        assertContains("3. page break list 3", xml);
+        assertContains(
+                "Some-1-CrazyFormat Greek numbering with crazy format - alpha", xml);
+        assertContains("1.1.1. 1.1.1", xml);
+        assertContains("1.1. 1.2-&gt;1.1  //set the value", xml);
+    }
+
+    @Test
+    public void testMacrosInDocm() throws Exception {
+        Metadata parsedBy = new Metadata();
+        parsedBy.add(TikaCoreProperties.TIKA_PARSED_BY,
+                "org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFEventBasedWordExtractor");
+
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_macros.docm", getParseContext());
+        for (Metadata metadata : metadataList) {
+            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
+                fail("Shouldn't have extracted macros as default");
+            }
+        }
+        assertContainsAtLeast(parsedBy, metadataList);
+
+        ParseContext context = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setExtractMacros(true);
+        officeParserConfig.setUseSAXDocxExtractor(true);
+        context.set(OfficeParserConfig.class, officeParserConfig);
+
+        metadataList = getRecursiveMetadata("testWORD_macros.docm", context);
+        assertContains("quick",
+                metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT));
+        assertContainsAtLeast(parsedBy, metadataList);
+
+        Metadata minExpected = new Metadata();
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
+        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
+        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
+
+        assertContainsAtLeast(minExpected, metadataList);
+        assertContainsAtLeast(parsedBy, metadataList);
+
+        AutoDetectParser parser = (AutoDetectParser) TikaLoader.load(
+                getConfigPath(OOXMLDocxSAXTest.class, "tika-config-sax-macros.json"))
+                .loadAutoDetectParser();
+        metadataList = getRecursiveMetadata("testWORD_macros.docm", parser);
+        assertContainsAtLeast(minExpected, metadataList);
+        assertContainsAtLeast(parsedBy, metadataList);
+    }
+
+    @Test
+    public void testEmbedded() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_embeded.docx", getParseContext());
+        Metadata main = metadataList.get(0);
+        String content = main.get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains(
+                "<img src=\"embedded:image2.jpeg\" alt=\"A description...\" />", content);
+        assertContains("<div class=\"embedded\" id=\"rId8\" />", content);
+        assertEquals(16, metadataList.size());
+    }
+
+    @Test
+    public void testInitializationViaConfig() throws Exception {
+        //NOTE: this test relies on a bug in the DOM extractor that
+        //is passing over the title information.
+        //once we fix that, this test will no longer be meaningful!
+        AutoDetectParser p = (AutoDetectParser) TikaLoader.load(
+                getConfigPath(OOXMLDocxSAXTest.class, "tika-config-sax-docx.json"))
+                .loadAutoDetectParser();
+        XMLResult xml = getXML("testWORD_2006ml.docx", p, new Metadata());
+        assertContains("engaging title", xml.xml);
+    }
+
+    @Test
+    public void testTruncatedSAXDocx() throws Exception {
+        assertThrows(TikaException.class, () -> {
+            getRecursiveMetadata("testWORD_truncated.docx", getParseContext());
+        });
+    }
+
+    // Security feature tests
+
+    @Test
+    public void testHoverAndVmlHyperlinks() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testHoverAndVml.docx", getParseContext());
+        Metadata m = metadataList.get(0);
+        assertEquals("true", m.get(Office.HAS_HOVER_HYPERLINKS));
+        assertEquals("true", m.get(Office.HAS_VML_HYPERLINKS));
+
+        String xml = getXML("testHoverAndVml.docx", getParseContext()).xml;
+        assertContains("class=\"external-ref-hlinkHover\"", xml);
+        assertContains("http://hover.example.com/phishing", xml);
+        assertContains("class=\"external-ref-vml-shape-href\"", xml);
+        assertContains("http://vml.example.org/shape-link", xml);
+    }
+
+    @Test
+    public void testMailMerge() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testMailMerge.docx", getParseContext());
+        Metadata m = metadataList.get(0);
+        assertEquals("true", m.get(Office.HAS_MAIL_MERGE));
+    }
+
+    @Test
+    public void testAttachedTemplate() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testAttachedTemplate.docx", getParseContext());
+        Metadata m = metadataList.get(0);
+        assertEquals("true", m.get(Office.HAS_ATTACHED_TEMPLATE));
+
+        String xml = getXML("testAttachedTemplate.docx", getParseContext()).xml;
+        assertContains("class=\"external-ref-attachedTemplate\"", xml);
+        assertContains("example.com/templates", xml);
+    }
+
+    @Test
+    public void testSubdocument() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testSubdocument.docx", getParseContext());
+        Metadata m = metadataList.get(0);
+        assertEquals("true", m.get(Office.HAS_SUBDOCUMENTS));
+
+        String xml = getXML("testSubdocument.docx", getParseContext()).xml;
+        assertContains("class=\"external-ref-subDocument\"", xml);
+        assertContains("example.org/chapters", xml);
+    }
+
+    @Test
+    public void testFrameset() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testFrameset.docx", getParseContext());
+        Metadata m = metadataList.get(0);
+        assertEquals("true", m.get(Office.HAS_FRAMESETS));
+    }
+}
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/OOXMLParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
index ecbe1956ec..f8fe58b142 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
@@ -16,20 +16,11 @@
  */
 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.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.PrintStream;
-import java.io.StringWriter;
 import java.text.DecimalFormatSymbols;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -38,10 +29,6 @@ import java.util.Map;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import javax.xml.transform.OutputKeys;
-import javax.xml.transform.sax.SAXTransformerFactory;
-import javax.xml.transform.sax.TransformerHandler;
-import javax.xml.transform.stream.StreamResult;
 
 import org.apache.poi.util.LocaleUtil;
 import org.junit.jupiter.api.AfterAll;
@@ -49,22 +36,18 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
-import org.xml.sax.ContentHandler;
 
 import org.apache.tika.MultiThreadedTikaTest;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.detect.DefaultDetector;
 import org.apache.tika.detect.Detector;
 import org.apache.tika.exception.EncryptedDocumentException;
-import org.apache.tika.exception.TikaException;
 import org.apache.tika.io.TikaInputStream;
-import org.apache.tika.metadata.DublinCore;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.OfficeOpenXMLExtended;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
-import org.apache.tika.parser.EmptyParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.PasswordProvider;
@@ -72,8 +55,6 @@ import org.apache.tika.parser.RecursiveParserWrapper;
 import org.apache.tika.parser.microsoft.OfficeParser;
 import org.apache.tika.parser.microsoft.OfficeParserConfig;
 import org.apache.tika.parser.microsoft.OfficeParserTest;
-import org.apache.tika.sax.BodyContentHandler;
-import org.apache.tika.utils.XMLReaderUtils;
 
 public class OOXMLParserTest extends MultiThreadedTikaTest {
 
@@ -209,278 +190,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
 
     }
 
-    /**
-     * We have a number of different powerpoint files,
-     * such as presentation, macro-enabled etc
-     */
-    @Test
-    public void testPowerPoint() throws Exception {
-        String[] extensions = new String[]{"pptx", "pptm", "ppsm", "ppsx", "potm"
-                //"thmx", // TIKA-418: Will be supported in POI 3.7 beta 2
-                //"xps" // TIKA-418: Not yet supported by POI
-        };
-
-        String[] mimeTypes = new String[]{
-                "application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                "application/vnd.ms-powerpoint.presentation.macroenabled.12",
-                "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
-                "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
-                "application/vnd.ms-powerpoint.template.macroenabled.12"};
-
-        for (int i = 0; i < extensions.length; i++) {
-            String extension = extensions[i];
-            String filename = "testPPT." + extension;
-
-            Metadata metadata = new Metadata();
-            ContentHandler handler = new BodyContentHandler();
-            ParseContext context = new ParseContext();
-            String content = getText(filename, metadata, context);
-
-            assertEquals(mimeTypes[i], metadata.get(Metadata.CONTENT_TYPE),
-                    "Mime-type checking for " + filename);
-            assertEquals("Attachment Test", metadata.get(TikaCoreProperties.TITLE));
-            assertEquals("Rajiv", metadata.get(TikaCoreProperties.CREATOR));
-
-            // Theme files don't have the text in them
-            if (extension.equals("thmx")) {
-                assertEquals("", content);
-            } else {
-                assertTrue(content.contains("Attachment Test"),
-                        "Text missing for " + filename + "\n" + content);
-                assertTrue(content.contains("This is a test file data with the same content"),
-                        "Text missing for " + filename + "\n" + content);
-                assertTrue(content.contains("content parsing"),
-                        "Text missing for " + filename + "\n" + content);
-                assertTrue(content.contains("Different words to test against"),
-                        "Text missing for " + filename + "\n" + content);
-                assertTrue(content.contains("Mystery"),
-                        "Text missing for " + filename + "\n" + content);
-            }
-        }
-
-    }
-
-    /**
-     * Test that the metadata is already extracted when the body is processed.
-     * See TIKA-1109
-     */
-    @Test
-    public void testPowerPointMetadataEarly() throws Exception {
-        String[] extensions = new String[]{"pptx", "pptm", "ppsm", "ppsx", "potm"
-                //"thmx", // TIKA-418: Will be supported in POI 3.7 beta 2
-                //"xps" // TIKA-418: Not yet supported by POI
-        };
-
-        final String[] mimeTypes = new String[]{
-                "application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                "application/vnd.ms-powerpoint.presentation.macroenabled.12",
-                "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
-                "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
-                "application/vnd.ms-powerpoint.template.macroenabled.12"};
-
-        for (int i = 0; i < extensions.length; i++) {
-            String extension = extensions[i];
-            final String filename = "testPPT." + extension;
-            final Metadata metadata = new Metadata();
-
-            // Allow the value to be access from the inner class
-            final int currentI = i;
-            ContentHandler handler = new BodyContentHandler() {
-                @Override
-                public void startDocument() {
-                    assertEquals(mimeTypes[currentI], metadata.get(Metadata.CONTENT_TYPE),
-                            "Mime-type checking for " + filename);
-                    assertEquals("Attachment Test", metadata.get(TikaCoreProperties.TITLE));
-                    assertEquals("Rajiv", metadata.get(TikaCoreProperties.CREATOR));
-
-                }
-
-            };
-            ParseContext context = new ParseContext();
-
-            try (TikaInputStream tis = getResourceAsStream("/test-documents/" + filename)) {
-                AUTO_DETECT_PARSER.parse(tis, handler, metadata, context);
-            }
-        }
-    }
-
-    /**
-     * For the PowerPoint formats we don't currently support, ensure that
-     * we don't break either
-     */
-    @Test
-    public void testUnsupportedPowerPoint() throws Exception {
-        String[] extensions = new String[]{"xps", "thmx"};
-        String[] mimeTypes = new String[]{"application/vnd.ms-xpsdocument",
-                "application/vnd.openxmlformats-officedocument" // Is this right?
-        };
-
-        for (int i = 0; i < extensions.length; i++) {
-            String extension = extensions[i];
-            String filename = "testPPT." + extension;
-
-            Metadata metadata = new Metadata();
-            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, filename);
-            getXML(filename, metadata);
-            // Should get the metadata
-            assertEquals(mimeTypes[i], metadata.get(Metadata.CONTENT_TYPE),
-                    "Mime-type checking for " + filename);
-
-
-        }
-    }
-
-    /**
-     * Test the plain text output of the Word converter
-     *
-     * @throws Exception
-     */
-    @Test
-    public void testWord() throws Exception {
-        Metadata metadata = new Metadata();
-        String content = getText("testWORD.docx", metadata, new ParseContext());
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                metadata.get(Metadata.CONTENT_TYPE));
-        assertEquals("Sample Word Document", metadata.get(TikaCoreProperties.TITLE));
-        assertEquals("Keith Bennett", metadata.get(TikaCoreProperties.CREATOR));
-        assertTrue(content.contains("Sample Word Document"));
-
-    }
-
-    /**
-     * Test the plain text output of the Word converter
-     *
-     * @throws Exception
-     */
-    @Test
-    public void testWordFootnote() throws Exception {
-        XMLResult xmlResult = getXML("footnotes.docx");
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                xmlResult.metadata.get(Metadata.CONTENT_TYPE));
-        assertTrue(xmlResult.xml.contains("snoska"));
-        //TIKA-4657 -- footnote content should be in a div with class "footnote"
-        // and should not be nested inside the paragraph
-        assertContains("<div class=\"footnote\">", xmlResult.xml);
-        assertNotContained("<p><div class=\"footnote\">", xmlResult.xml);
-    }
-
-    @Test
-    public void testEndnoteWithTable() throws Exception {
-        XMLResult xmlResult = getXML("testWORD_endnote_table.docx");
-        assertContains("Cat Property Act", xmlResult.xml);
-        //TIKA-4657 -- endnote content should be in a div with class "endnote"
-        assertContains("<div class=\"endnote\">", xmlResult.xml);
-    }
-
-    /**
-     * Test that the word converter is able to generate the
-     * correct HTML for the document
-     */
-    @Test
-    public void testWordHTML() throws Exception {
-        XMLResult result = getXML("testWORD.docx");
-        String xml = result.xml;
-        Metadata metadata = result.metadata;
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                metadata.get(Metadata.CONTENT_TYPE));
-        assertEquals("Sample Word Document", metadata.get(TikaCoreProperties.TITLE));
-        assertEquals("Keith Bennett", metadata.get(TikaCoreProperties.CREATOR));
-        assertTrue(xml.contains("Sample Word Document"));
-
-        // Check that custom headings came through
-        assertTrue(xml.contains("<h1 class=\"title\">"));
-        // Regular headings
-        assertTrue(xml.contains("<h1>Heading Level 1</h1>"));
-        assertTrue(xml.contains("<h2>Heading Level 2</h2>"));
-        // Headings with anchor tags in them
-        assertTrue(xml.contains("<h3><a name=\"OnLevel3\" />Heading Level 3</h3>"));
-        // Bold and italic
-        assertTrue(xml.contains("<b>BOLD</b>"));
-        assertTrue(xml.contains("<i>ITALIC</i>"));
-        // Table
-        assertTrue(xml.contains("<table>"));
-        assertTrue(xml.contains("<td>"));
-        // Links
-        assertTrue(xml.contains("<a href=\"http://tika.apache.org/\">Tika</a>"));
-        // Anchor links
-        assertContains("<a href=\"#OnMainHeading\">The Main Heading Bookmark</a>", xml);
-        // Paragraphs with other styles
-        assertTrue(xml.contains("<p class=\"signature\">This one"));
-
-        result = getXML("testWORD_3imgs.docx");
-        xml = result.xml;
-
-        // Images 2-4 (there is no 1!)
-        assertTrue(xml.contains("<img src=\"embedded:image2.png\" alt=\"A description...\" />"),
-                "Image not found in:\n" + xml);
-        assertTrue(xml.contains("<img src=\"embedded:image3.jpeg\" alt=\"A description...\" />"),
-                "Image not found in:\n" + xml);
-        assertTrue(xml.contains("<img src=\"embedded:image4.png\" alt=\"A description...\" />"),
-                "Image not found in:\n" + xml);
-
-        // Text too
-        assertTrue(xml.contains("<p>The end!</p>"));
-
-        // TIKA-692: test document containing multiple
-        // character runs within a bold tag:
-        xml = getXML("testWORD_bold_character_runs.docx").xml;
-
-        // Make sure bold text arrived as single
-        // contiguous string even though Word parser
-        // handled this as 3 character runs
-        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"), "Bold text wasn't contiguous: " + xml);
-
-        // TIKA-692: test document containing multiple
-        // character runs within a bold tag:
-        xml = getXML("testWORD_bold_character_runs2.docx").xml;
-
-        // Make sure bold text arrived as single
-        // contiguous string even though Word parser
-        // handled this as 3 character runs
-        assertTrue(xml.contains("F<b>oob</b>a<b>r</b>"), "Bold text wasn't contiguous: " + xml);
-    }
-
-    /**
-     * Test that we can extract image from docx header
-     */
-    @Test
-    public void testWordPicturesInHeader() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("headerPic.docx");
-        assertEquals(2, metadataList.size());
-        Metadata m = metadataList.get(0);
-        String mainContent = m.get(TikaCoreProperties.TIKA_CONTENT);
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                m.get(Metadata.CONTENT_TYPE));
-        // Check that custom headings came through
-        assertTrue(mainContent.contains("<img"));
-    }
-
-    @Test
-    @Disabled("need to add links in xhtml")
-    public void testPicturesInVariousPlaces() throws Exception {
-        //test that images are actually extracted from
-        //headers, footers, comments, endnotes, footnotes
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_embedded_pics.docx");
-
-        //only process embedded resources once
-        assertEquals(3, metadataList.size());
-        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-        for (int i = 1; i < 4; i++) {
-            assertContains("header" + i + "_pic", content);
-            assertContains("footer" + i + "_pic", content);
-        }
-        assertContains("body_pic.jpg", content);
-        assertContains("sdt_pic.jpg", content);
-        assertContains("deeply_embedded_pic", content);
-        assertContains("deleted_pic", content);//TODO: don't extract this
-        assertContains("footnotes_pic", content);
-        assertContains("comments_pic", content);
-        assertContains("endnotes_pic", content);
-//        assertContains("sdt2_pic.jpg", content);//name of file is not stored in image-sdt
-
-        assertContainsCount("<img src=", content, 14);
-    }
-
     /**
      * Documents with some sheets are protected, but not all.
      * See TIKA-364.
@@ -513,270 +222,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertContains("Office", xmlResult.xml);
     }
 
-    /**
-     * Test docx without headers
-     * TIKA-633
-     */
-    @Test
-    public void testNullHeaders() throws Exception {
-        assertFalse(getXML("NullHeader.docx").xml.isEmpty(),
-                "Should have found some text");
-    }
-
-    @Test
-    public void testTextDecoration() throws Exception {
-        String xml = getXML("testWORD_various.docx").xml;
-
-        assertContains("<b>Bold</b>", xml);
-        assertContains("<i>italic</i>", xml);
-        assertContains("<u>underline</u>", xml);
-        assertContains("<s>strikethrough</s>", xml);
-    }
-
-    @Test
-    public void testTextDecorationNested() throws Exception {
-        String xml = getXML("testWORD_various.docx").xml;
-
-        assertContains("<i>ita<s>li</s>c</i>", xml);
-        assertContains("<i>ita<s>l<u>i</u></s>c</i>", xml);
-        assertContains("<i><u>unde<s>r</s>line</u></i>", xml);
-
-        //confirm that spaces aren't added for </s> and </u>
-        String txt = getText("testWORD_various.docx");
-        assertContainsCount("italic", txt, 3);
-        assertNotContained("ita ", txt);
-
-        assertContainsCount("underline", txt, 2);
-        assertNotContained("unde ", txt);
-    }
-
-    @Test
-    public void testVarious() throws Exception {
-        Metadata metadata = new Metadata();
-
-        String content = getText("testWORD_various.docx", metadata);
-        //content = content.replaceAll("\\s+"," ");
-        assertContains("Footnote appears here", content);
-        assertContains("This is a footnote.", content);
-        assertContains("This is the header text.", content);
-        assertContains("This is the footer text.", content);
-        assertContains("Here is a text box", content);
-        assertContains("Bold", content);
-        assertContains("italic", content);
-        assertContains("underline", content);
-        assertContains("superscript", content);
-        assertContains("subscript", content);
-        assertContains("Here is a citation:", content);
-        assertContains("Figure 1 This is a caption for Figure 1", content);
-        assertContains("(Kramer)", content);
-        assertContains("Row 1 Col 1 Row 1 Col 2 Row 1 Col 3 Row 2 Col 1 Row 2 Col 2 Row 2 Col 3",
-                content.replaceAll("\\s+", " "));
-        assertContains("Row 1 column 1 Row 2 column 1 Row 1 column 2 Row 2 column 2",
-                content.replaceAll("\\s+", " "));
-        assertContains("This is a hyperlink", content);
-        assertContains("Here is a list:", content);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains("·\tBullet " + row, content);
-            //assertContains("\u00b7\tBullet " + row, content);
-            assertContains("Bullet " + row, content);
-        }
-        assertContains("Here is a numbered list:", content);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains(row + ")\tNumber bullet " + row, content);
-            //assertContains(row + ") Number bullet " + row, content);
-            // TODO: OOXMLExtractor fails to number the bullets:
-            assertContains("Number bullet " + row, content);
-        }
-
-        for (int row = 1; row <= 2; row++) {
-            for (int col = 1; col <= 3; col++) {
-                assertContains("Row " + row + " Col " + col, content);
-            }
-        }
-
-        assertContains("Keyword1 Keyword2", content);
-        assertEquals("Keyword1 Keyword2", metadata.get(Office.KEYWORDS));
-        assertContains("Keyword1 Keyword2",
-                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
-
-
-        assertContains("Subject is here", content);
-        assertContains("Subject is here",
-                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
-
-
-        assertContains("Suddenly some Japanese text:", content);
-        // Special version of (GHQ)
-        assertContains("\uff08\uff27\uff28\uff31\uff09", content);
-        // 6 other characters
-        assertContains("\u30be\u30eb\u30b2\u3068\u5c3e\u5d0e\u3001\u6de1\u3005\u3068\u6700\u671f",
-                content);
-
-        assertContains("And then some Gothic text:", content);
-        assertContains("\uD800\uDF32\uD800\uDF3f\uD800\uDF44\uD800\uDF39\uD800\uDF43\uD800\uDF3A",
-                content);
-    }
-
-    @Test
-    public void testDOCXHeaderFooterNotExtraction() throws Exception {
-        ParseContext parseContext = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setIncludeHeadersAndFooters(false);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
-        String xml = getXML("testWORD_various.docx", parseContext).xml;
-        assertNotContained("This is the header text.", xml);
-        assertNotContained("This is the footer text.", xml);
-
-        //now test configuration via tika-config
-        Parser configuredParser = TikaLoader.load(
-                getConfigPath(OfficeParserTest.class, "tika-config-headers-footers.json"))
-                .loadAutoDetectParser();
-        xml = getXML("testWORD_various.docx", configuredParser).xml;
-        assertNotContained("This is the header text.", xml);
-        assertNotContained("This is the footer text.", xml);
-
-    }
-
-    @Test
-    public void testVariousPPTX() throws Exception {
-        Metadata metadata = new Metadata();
-        String xml = getXML("testPPT_various.pptx", metadata).xml;
-        assertContains("<p>Footnote appears here", xml);
-        assertContains("<p>[1] This is a footnote.", xml);
-        assertContains("<p>This is the header text.</p>", xml);
-        assertContains("<p>This is the footer text.</p>", xml);
-        assertContains("<p>Here is a text box</p>", xml);
-        assertContains("<p>Bold", xml);
-        assertContains("italic underline superscript subscript", xml);
-        assertContains("<p>Here is a citation:", xml);
-        assertContains("Figure 1 This is a caption for Figure 1", xml);
-        assertContains("(Kramer)", xml);
-        assertContains("<table><tr>\t<td>Row 1 Col 1</td>", xml);
-        assertContains("<td>Row 2 Col 2</td>\t<td>Row 2 Col 3</td></tr>", xml);
-        assertContains("<p>Row 1 column 1</p>", xml);
-        assertContains("<p>Row 2 column 2</p>", xml);
-        assertContains("<p><a href=\"http://tika.apache.org/\">This is a hyperlink</a>", xml);
-        assertContains("<p>Here is a list:", xml);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains("·\tBullet " + row, content);
-            //assertContains("\u00b7\tBullet " + row, content);
-            assertContains("<p>Bullet " + row, xml);
-        }
-        assertContains("Here is a numbered list:", xml);
-        for (int row = 1; row <= 3; row++) {
-            //assertContains(row + ")\tNumber bullet " + row, content);
-            //assertContains(row + ") Number bullet " + row, content);
-            // TODO: OOXMLExtractor fails to number the bullets:
-            assertContains("<p>Number bullet " + row, xml);
-        }
-
-        for (int row = 1; row <= 2; row++) {
-            for (int col = 1; col <= 3; col++) {
-                assertContains("Row " + row + " Col " + col, xml);
-            }
-        }
-
-        assertContains("Keyword1 Keyword2", xml);
-        assertEquals("Keyword1 Keyword2", metadata.get(Office.KEYWORDS));
-
-        assertContains("Subject is here", xml);
-        assertEquals("Subject is here", metadata.get(DublinCore.SUBJECT));
-
-        assertContains("Keyword1 Keyword2",
-                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
-        assertContains("Subject is here",
-                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
-
-
-        assertContains("Suddenly some Japanese text:", xml);
-        // Special version of (GHQ)
-        assertContains("\uff08\uff27\uff28\uff31\uff09", xml);
-        // 6 other characters
-        assertContains("\u30be\u30eb\u30b2\u3068\u5c3e\u5d0e\u3001\u6de1\u3005\u3068\u6700\u671f",
-                xml);
-
-        assertContains("And then some Gothic text:", xml);
-        assertContains("\uD800\uDF32\uD800\uDF3f\uD800\uDF44\uD800\uDF39\uD800\uDF43\uD800\uDF3A",
-                xml);
-    }
-
-    @Test
-    public void testSkipHeaderFooter() throws Exception {
-        //now test turning off header/footer
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setIncludeHeadersAndFooters(false);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
-        String xml = getXML("testPPT_various.pptx", context).xml;
-        assertNotContained("This is the header text", xml);
-
-    }
-
-    @Test
-    public void testCommentPPTX() throws Exception {
-        XMLResult r = getXML("testPPT_comment.pptx");
-        assertContains("<p class=\"slide-comment\"><b>Allison, Timothy B. (ATB)", r.xml);
-    }
-
-    @Test
-    public void testMasterFooter() throws Exception {
-        String content = getText("testPPT_masterFooter.pptx");
-        assertContains("Master footer is here", content);
-    }
-
-    @Test
-    @Disabled("can't tell why this isn't working")
-    public void testTurningOffMasterContent() throws Exception {
-        //now test turning off master content
-
-        //the underlying xml has "Master footer" in
-        //the actual slide's xml, not just in the master slide.
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setIncludeSlideMasterContent(false);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
-        String xml = getXML("testPPT_masterFooter.pptx", context).xml;
-        assertNotContained("Master footer", xml);
-    }
-
-    /**
-     * TIKA-712 Master Slide Text from PPT and PPTX files
-     * should be extracted too
-     */
-    @Test
-    public void testMasterText() throws Exception {
-
-        String content = getText("testPPT_masterText.pptx");
-        assertContains("Text that I added to the master slide", content);
-
-        //now test turning off master content
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setIncludeSlideMasterContent(false);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
-        content = getXML("testPPT_masterText.pptx", context).xml;
-        assertNotContained("Text that I added", content);
-    }
-
-    @Test
-    public void testMasterText2() throws Exception {
-        String content = getText("testPPT_masterText2.pptx");
-        assertContains("Text that I added to the master slide", content);
-
-        //now test turning off master content
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setIncludeSlideMasterContent(false);
-        ParseContext context = new ParseContext();
-        context.set(OfficeParserConfig.class, config);
-        content = getXML("testPPT_masterText2.pptx", context).xml;
-        assertNotContained("Text that I added", content);
-    }
-
-    @Test
-    public void testWordArt() throws Exception {
-        assertContains("Here is some red word Art", getText("testWordArt.pptx"));
-    }
-
     /**
      * Ensures that custom OOXML properties are extracted
      */
@@ -801,197 +246,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
     }
 
-    @Test
-    public void testWordCustomProperties() throws Exception {
-        Metadata metadata = new Metadata();
-
-        try (TikaInputStream tis = getResourceAsStream(
-                "/test-documents/testWORD_custom_props.docx")) {
-            ContentHandler handler = new BodyContentHandler(-1);
-            ParseContext context = new ParseContext();
-            context.set(Locale.class, Locale.US);
-            new OOXMLParser().parse(tis, handler, metadata, context);
-        }
-
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                metadata.get(Metadata.CONTENT_TYPE));
-        assertEquals("EJ04325S", metadata.get(TikaCoreProperties.CREATOR));
-        assertEquals("Etienne Jouvin", metadata.get(TikaCoreProperties.MODIFIER));
-        assertEquals("2011-07-29T16:52:00Z", metadata.get(TikaCoreProperties.CREATED));
-        assertEquals("2012-01-03T22:14:00Z", metadata.get(TikaCoreProperties.MODIFIED));
-        assertEquals("Microsoft Office Word", metadata.get(OfficeOpenXMLExtended.APPLICATION));
-        assertEquals("1", metadata.get(Office.PAGE_COUNT));
-        assertEquals("2", metadata.get(Office.WORD_COUNT));
-        assertEquals("My Title", metadata.get(TikaCoreProperties.TITLE));
-        assertEquals("My Keyword", metadata.get(Office.KEYWORDS));
-        assertContains("My Keyword", Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
-
-        assertEquals("Normal.dotm", metadata.get(OfficeOpenXMLExtended.TEMPLATE));
-        assertEquals("My subject", metadata.get(DublinCore.SUBJECT));
-        assertEquals("EDF-DIT", metadata.get(TikaCoreProperties.PUBLISHER));
-        assertEquals("true", metadata.get("custom:myCustomBoolean"));
-        assertEquals("3", metadata.get("custom:myCustomNumber"));
-        assertEquals("MyStringValue", metadata.get("custom:MyCustomString"));
-        assertEquals("2010-12-30T23:00:00Z", metadata.get("custom:MyCustomDate"));
-        assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
-    }
-
-    @Test
-    public void testPowerPointCustomProperties() throws Exception {
-        Metadata metadata = new Metadata();
-
-        try (TikaInputStream tis = getResourceAsStream("/test-documents/testPPT_custom_props.pptx")) {
-            ContentHandler handler = new BodyContentHandler(-1);
-            ParseContext context = new ParseContext();
-            context.set(Locale.class, Locale.US);
-            new OOXMLParser().parse(tis, handler, metadata, context);
-        }
-
-        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                metadata.get(Metadata.CONTENT_TYPE));
-        assertEquals("JOUVIN ETIENNE", metadata.get(TikaCoreProperties.CREATOR));
-        assertEquals("EJ04325S", metadata.get(TikaCoreProperties.MODIFIER));
-        assertEquals("2011-08-22T13:30:53Z", metadata.get(TikaCoreProperties.CREATED));
-        assertEquals("2011-08-22T13:32:49Z", metadata.get(TikaCoreProperties.MODIFIED));
-        assertEquals("1", metadata.get(Office.SLIDE_COUNT));
-        assertEquals("3", metadata.get(Office.WORD_COUNT));
-        assertEquals("Test extraction properties pptx", metadata.get(TikaCoreProperties.TITLE));
-        assertEquals("true", metadata.get("custom:myCustomBoolean"));
-        assertEquals("3", metadata.get("custom:myCustomNumber"));
-        assertEquals("MyStringValue", metadata.get("custom:MyCustomString"));
-        assertEquals("2010-12-30T22:00:00Z", metadata.get("custom:MyCustomDate"));
-        assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
-    }
-
-    // TIKA-989:
-    @Test
-    public void testEmbeddedPDF() throws Exception {
-        Metadata metadata = new Metadata();
-        StringWriter sw = new StringWriter();
-        SAXTransformerFactory factory = XMLReaderUtils.getSAXTransformerFactory();
-        TransformerHandler handler = factory.newTransformerHandler();
-        handler.getTransformer().setOutputProperty(OutputKeys.METHOD, "xml");
-        handler.getTransformer().setOutputProperty(OutputKeys.INDENT, "no");
-        handler.setResult(new StreamResult(sw));
-
-        try (TikaInputStream tis = getResourceAsStream(
-                "/test-documents/testWORD_embedded_pdf.docx")) {
-            new OOXMLParser().parse(tis, handler, metadata, new ParseContext());
-        }
-        String xml = sw.toString();
-        int i = xml.indexOf("Here is the pdf file:");
-        int j = xml.indexOf("<div class=\"embedded\" id=\"rId5\"/>");
-        int k = xml.indexOf("Bye Bye");
-        int l = xml.indexOf("<div class=\"embedded\" id=\"rId6\"/>");
-        int m = xml.indexOf("Bye for real.");
-        assertTrue(i != -1);
-        assertTrue(j != -1);
-        assertTrue(k != -1);
-        assertTrue(l != -1);
-        assertTrue(m != -1);
-        assertTrue(i < j);
-        assertTrue(j < k);
-        assertTrue(k < l);
-        assertTrue(l < m);
-    }
-
-    // TIKA-997:
-    @Test
-    public void testEmbeddedZipInPPTX() throws Exception {
-        String xml = getXML("test_embedded_zip.pptx").xml;
-        int h = xml.indexOf("<div class=\"embedded\" id=\"slide1_rId3\" />");
-        int i = xml.indexOf("Send me a note");
-        int j = xml.indexOf("<div class=\"embedded\" id=\"slide2_rId4\" />");
-        int k = xml.indexOf("<p>No title</p>");
-        assertTrue(h != -1);
-        assertTrue(i != -1);
-        assertTrue(j != -1);
-        assertTrue(k != -1);
-        assertTrue(h < i);
-        assertTrue(i < j);
-        assertTrue(j < k);
-    }
-
-    // TIKA-1006
-    @Test
-    public void testWordNullStyle() throws Exception {
-        String xml = getXML("testWORD_null_style.docx").xml;
-        assertContains("Test av styrt dokument", xml);
-    }
-
-    /**
-     * TIKA-1044 - Handle word documents where parts of the
-     * text have no formatting or styles applied to them
-     */
-    @Test
-    public void testNoFormat() throws Exception {
-        ContentHandler handler = new BodyContentHandler();
-        Metadata metadata = new Metadata();
-
-        try (TikaInputStream tis = getResourceAsStream("/test-documents/testWORD_no_format.docx")) {
-            new OOXMLParser().parse(tis, handler, metadata, new ParseContext());
-        }
-
-        String content = handler.toString();
-        assertContains("This is a piece of text that causes an exception", content);
-    }
-
-    // TIKA-1005:
-    @Test
-    public void testTextInsideTextBox() throws Exception {
-        String xml = getXML("testWORD_text_box.docx").xml;
-        assertContains("This text is directly in the body of the document.", xml);
-        assertContains("This text is inside of a text box in the body of the document.", xml);
-        assertContains("This text is inside of a text box in the header of the document.", xml);
-        assertContains("This text is inside of a text box in the footer of the document.", xml);
-    }
-
-    //TIKA-2807
-    @Test
-    public void testSDTInTextBox() throws Exception {
-        String xml = getXML("testWORD_sdtInTextBox.docx").xml;
-        assertContains("rich-text-content-control_inside-text-box", xml);
-        assertContainsCount("inside-text", xml, 1);
-    }
-
-    //TIKA-2346
-    @Test
-    public void testTurningOffTextBoxExtraction() throws Exception {
-        ParseContext pc = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setIncludeShapeBasedContent(false);
-        pc.set(OfficeParserConfig.class, officeParserConfig);
-        String xml = getXML("testWORD_text_box.docx", pc).xml;
-        assertContains("This text is directly in the body of the document.", xml);
-        assertNotContained("This text is inside of a text box in the body of the document.", xml);
-        assertNotContained("This text is inside of a text box in the header of the document.", xml);
-        assertNotContained("This text is inside of a text box in the footer of the document.", xml);
-    }
-
-    // TIKA-1032:
-    @Test
-    public void testEmbeddedPPTXTwoSlides() throws Exception {
-        String xml = getXML("testPPT_embedded_two_slides.pptx").xml;
-        assertContains("<div class=\"embedded\" id=\"slide1_rId7\" />", xml);
-        assertContains("<div class=\"embedded\" id=\"slide2_rId7\" />", xml);
-    }
-
-    /**
-     * Test for missing text described in
-     * <a href="https://issues.apache.org/jira/browse/TIKA-1130">TIKA-1130</a>.
-     * and TIKA-1317
-     */
-    @Test
-    public void testMissingText() throws Exception {
-        XMLResult xmlResult = getXML("testWORD_missing_text.docx");
-        assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                xmlResult.metadata.get(Metadata.CONTENT_TYPE));
-        assertContains("BigCompany", xmlResult.xml);
-        assertContains("Seasoned", xmlResult.xml);
-        assertContains("Rich_text_in_cell", xmlResult.xml);
-
-    }
-
     //TIKA-1100:
     @Test
     public void testExcelTextBox() throws Exception {
@@ -1011,49 +265,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertNotContained("autoshape", xml);
     }
 
-    //TIKA-792; with room for future missing bean tests
-    @Test
-    public void testWordMissingOOXMLBeans() throws Exception {
-        //If a bean is missing, POI prints stack trace to stderr 
-        String[] fileNames = new String[]{"testWORD_missing_ooxml_bean1.docx",//TIKA-792
-        };
-        PrintStream origErr = System.err;
-        for (String fileName : fileNames) {
-            //grab stderr
-            ByteArrayOutputStream errContent = new ByteArrayOutputStream();
-            System.setErr(new PrintStream(errContent, true, UTF_8.name()));
-            getXML(fileName);
-
-            //return stderr
-            System.setErr(origErr);
-
-            String err = errContent.toString(UTF_8.name());
-            assertTrue(err.isEmpty());
-        }
-    }
-
-    //TIKA-817
-    @Test
-    public void testPPTXAutodate() throws Exception {
-        //Following POI-52368, the stored date is extracted,
-        //not the auto-generated date.
-
-        XMLResult result = getXML("testPPT_autodate.pptx");
-        assertContains("<p>Now</p>\n" + "<p>2011-12-19 10:20:04 AM</p>\n", result.xml);
-
-    }
-
-    @Test
-    public void testDOCXThumbnail() throws Exception {
-        String xml = getXML("testDOCX_Thumbnail.docx").xml;
-        int a = xml.indexOf("This file contains a thumbnail");
-        int b = xml.indexOf("<div class=\"embedded\" id=\"/docProps/thumbnail.emf\" />");
-
-        assertTrue(a != -1);
-        assertTrue(b != -1);
-        assertTrue(a < b);
-    }
-
     @Test
     public void testXLSXThumbnail() throws Exception {
         String xml = getXML("testXLSX_Thumbnail.xlsx").xml;
@@ -1065,17 +276,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertTrue(a < b);
     }
 
-    @Test
-    public void testPPTXThumbnail() throws Exception {
-        String xml = getXML("testPPTX_Thumbnail.pptx").xml;
-        int a = xml.indexOf(
-                "<body><div class=\"slide-content\"><p>This file contains an embedded thumbnail");
-        int b = xml.indexOf("<div class=\"embedded\" id=\"/docProps/thumbnail.jpeg\" />");
-        assertTrue(a != -1);
-        assertTrue(b != -1);
-        assertTrue(a < b);
-    }
-
     @Test
     public void testEncrypted() throws Exception {
         Map<String, String> tests = new HashMap<>();
@@ -1116,78 +316,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         }
     }
 
-    @Test
-    public void testDOCXParagraphNumbering() throws Exception {
-        String xml = getXML("testWORD_numbered_list.docx").xml;
-        assertContains("1) This", xml);
-        assertContains("a) Is", xml);
-        assertContains("i) A multi", xml);
-        assertContains("ii) Level", xml);
-        assertContains("1. Within cell 1", xml);
-        assertContains("b. Cell b", xml);
-        assertContains("iii) List", xml);
-        assertContains("2) foo", xml);
-        assertContains("ii) baz", xml);
-        assertContains("ii) foo", xml);
-        assertContains("II. bar", xml);
-        assertContains("6. six", xml);
-        assertContains("7. seven", xml);
-        assertContains("a. seven a", xml);
-        assertContains("e. seven e", xml);
-        assertContains("2. A ii 2", xml);
-        assertContains("3. page break list 3", xml);
-        assertContains("Some-1-CrazyFormat Greek numbering with crazy format - alpha", xml);
-        assertContains("1.1.1. 1.1.1", xml);
-        assertContains("1.1. 1.2-&gt;1.1  //set the value", xml);
-
-        //TODO: comment is not being extracted!
-        //assertContains("add a list here", xml);
-    }
-
-    @Test
-    public void testDOCXOverrideParagraphNumbering() throws Exception {
-        String xml = getXML("testWORD_override_list_numbering.docx").xml;
-
-        //Test 1
-        assertContains("<p>1.1.1.1...1 1.1.1.1...1</p>", xml);
-        assertContains("1st.2.3someText 1st.2.3someText", xml);
-        assertContains("1st.2.2someOtherText.1 1st.2.2someOtherText.1", xml);
-        assertContains("5th 5th", xml);
-
-
-        //Test 2
-        assertContains("1.a.I 1.a.I", xml);
-        //test no reset because level 2 is not sufficient to reset
-        assertContains("<p>1.b.III 1.b.III</p>", xml);
-        //test restarted because of level 0's increment to 2
-        assertContains("2.a.I 2.a.I", xml);
-        //test handling of skipped level
-        assertContains("<p>2.b 2.b</p>", xml);
-
-        //Test 3
-        assertContains("(1)) (1))", xml);
-        //tests start level 1 at 17 and
-        assertContains("2.17 2.17", xml);
-        //tests that isLegal turns everything into decimal
-        assertContains("2.18.2.1 2.18.2.1", xml);
-        assertContains("<p>2 2</p>", xml);
-
-        //Test4
-        assertContains("<p>1 1</p>", xml);
-        assertContains("<p>A A</p>", xml);
-        assertContains("<p>B B</p>", xml);
-        //this tests overrides
-        assertContains("<p>C C</p>", xml);
-        assertContains("<p>4 4</p>", xml);
-
-        //Test5
-        assertContains(">00 00", xml);
-        assertContains(">01 01", xml);
-        assertContains(">01. 01.", xml);
-        assertContains(">01..1 01..1", xml);
-        assertContains(">02 02", xml);
-    }
-
     @Test
     public void testExcelHeaderAndFooterExtraction() throws Exception {
         XMLResult xml = getXML("testEXCEL_headers_footers.xlsx");
@@ -1241,19 +369,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
     }
 
 
-    @Test
-    public void testMultiAuthorsManagers() throws Exception {
-        XMLResult r = getXML("testWORD_multi_authors.docx");
-        String[] authors = r.metadata.getValues(TikaCoreProperties.CREATOR);
-        assertEquals(3, authors.length);
-        assertEquals("author2", authors[1]);
-
-        String[] managers = r.metadata.getValues(OfficeOpenXMLExtended.MANAGER);
-        assertEquals(2, managers.length);
-        assertEquals("manager1", managers[0]);
-        assertEquals("manager2", managers[1]);
-    }
-
     @Test
     public void testHyperlinksInXLSX() throws Exception {
         String xml = getXML("testEXCEL_hyperlinks.xlsx").xml;
@@ -1268,16 +383,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
     }
 
 
-    @Test
-    public void testOrigSourcePath() throws Exception {
-        Metadata embed1_zip_metadata = getRecursiveMetadata("test_recursive_embedded.docx").get(2);
-        assertContains("C:\\Users\\tallison\\AppData\\Local\\Temp\\embed1.zip", Arrays.asList(
-                embed1_zip_metadata.getValues(TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
-        assertContains("C:\\Users\\tallison\\Desktop\\tmp\\New folder (2)\\embed1.zip",
-                Arrays.asList(
-                        embed1_zip_metadata.getValues(TikaCoreProperties.ORIGINAL_RESOURCE_NAME)));
-    }
-
     @Test
     public void testBigIntegersWGeneralFormat() throws Exception {
         //TIKA-2025
@@ -1313,87 +418,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
     }
 
 
-    @Test
-    public void testBoldHyperlink() throws Exception {
-        //TIKA-1255
-        String xml = getXML("testWORD_boldHyperlink.docx").xml;
-        xml = xml.replaceAll("\\s+", " ");
-        assertContains("<a href=\"http://tika.apache.org/\">hyper <b>link</b></a>", xml);
-        assertContains("<a href=\"http://tika.apache.org/\"><b>hyper</b> link</a>; bold", xml);
-    }
-
-    @Test
-    public void testLongForIntExceptionInSummaryDetails() throws Exception {
-        //TIKA-2055
-        assertContains("bold", getXML("testWORD_totalTimeOutOfRange.docx").xml);
-    }
-
-    @Test
-    public void testMacrosInDocm() throws Exception {
-
-        //test default is "don't extract macros"
-        for (Metadata metadata : getRecursiveMetadata("testWORD_macros.docm")) {
-            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
-                fail("Shouldn't have extracted macros as default");
-            }
-        }
-
-        //now test that they were extracted
-        ParseContext context = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setExtractMacros(true);
-        context.set(OfficeParserConfig.class, officeParserConfig);
-
-
-        Metadata minExpected = new Metadata();
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
-        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
-        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
-
-        assertContainsAtLeast(minExpected, getRecursiveMetadata("testWORD_macros.docm", context));
-
-        //test configuring via config file
-        Parser parser = TikaLoader.load(
-                getConfigPath(OOXMLParserTest.class, "tika-config-dom-macros.json"))
-                .loadAutoDetectParser();
-        assertContainsAtLeast(minExpected,
-                getRecursiveMetadata("testWORD_macros.docm", parser));
-    }
-
-    @Test
-    public void testMacrosInPptm() throws Exception {
-
-        //test default is "don't extract macros"
-        for (Metadata metadata : getRecursiveMetadata("testPPT_macros.pptm")) {
-            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
-                fail("Shouldn't have extracted macros as default");
-            }
-        }
-
-        //now test that they were extracted
-        ParseContext context = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setExtractMacros(true);
-        context.set(OfficeParserConfig.class, officeParserConfig);
-
-        Metadata minExpected = new Metadata();
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
-        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
-        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
-        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
-                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
-
-        assertContainsAtLeast(minExpected, getRecursiveMetadata("testPPT_macros.pptm", context));
-
-        //test configuring via config file
-        Parser parser = TikaLoader.load(
-                getConfigPath(OOXMLParserTest.class, "tika-config-dom-macros.json"))
-                .loadAutoDetectParser();
-        assertContainsAtLeast(minExpected, getRecursiveMetadata("testPPT_macros.pptm", parser));
-    }
-
     @Test
     public void testMacroinXlsm() throws Exception {
 
@@ -1429,45 +453,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
                 getRecursiveMetadata("testEXCEL_macro.xlsm", parser));
     }
 
-    //@Test //use this for lightweight benchmarking to compare xwpf options
-    public void testBatch() throws Exception {
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        long started = System.currentTimeMillis();
-        int ex = 0;
-        for (int i = 0; i < 100; i++) {
-            for (File f : getResourceAsFile("/test-documents").listFiles()) {
-                if (!f.getName().endsWith(".docx")) {
-                    continue;
-                }
-                try (TikaInputStream tis = TikaInputStream.get(f.toPath())) {
-                    ParseContext parseContext = new ParseContext();
-                    parseContext.set(OfficeParserConfig.class, officeParserConfig);
-                    //test only the extraction of the main docx content, not embedded docs
-                    parseContext.set(Parser.class, new EmptyParser());
-                    XMLResult r = getXML(tis, AUTO_DETECT_PARSER, new Metadata(), parseContext);
-                } catch (Exception e) {
-                    ex++;
-
-                }
-            }
-        }
-        System.out.println("elapsed: " + (System.currentTimeMillis() - started) + " with " + ex +
-                " exceptions");
-    }
-
-    @Test
-    public void testInitializationViaConfig() throws Exception {
-        //NOTE: this test relies on a bug in the DOM extractor that
-        //is passing over the title information.
-        //once we fix that, this test will no longer be meaningful!
-        Parser p = TikaLoader.load(
-                getConfigPath(OfficeParserTest.class, "tika-config-sax-docx.json"))
-                .loadAutoDetectParser();
-        XMLResult xml = getXML("testWORD_2006ml.docx", p, new Metadata());
-        assertContains("engaging title", xml.xml);
-    }
-
     @Test
     public void testExcelXLSB() throws Exception {
         Detector detector = new DefaultDetector();
@@ -1599,16 +584,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertContains("SmartArt", getXML("testEXCEL_diagramData.xlsx").xml);
     }
 
-    @Test
-    public void testDOCXDiagramData() throws Exception {
-        assertContains("From here", getXML("testWORD_diagramData.docx").xml);
-    }
-
-    @Test
-    public void testPPTXDiagramData() throws Exception {
-        assertContains("President", getXML("testPPT_diagramData.pptx").xml);
-    }
-
     @Test
     public void testXLSXChartData() throws Exception {
         String xml = getXML("testEXCEL_charts.xlsx").xml;
@@ -1625,48 +600,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertNotContained("chartSpace", xml);
     }
 
-    @Test
-    public void testDOCXChartData() throws Exception {
-        String xml = getXML("testWORD_charts.docx").xml;
-        assertContains("peach", xml);
-        assertContains("March\tApril", xml);
-        assertNotContained("chartSpace", xml);
-    }
-
-    @Test
-    public void testPPTXChartData() throws Exception {
-        String xml = getXML("testPPT_charts.pptx").xml;
-        assertContains("peach", xml);
-        assertContains("March\tApril", xml);
-        assertNotContained("chartSpace", xml);
-    }
-
-    @Test
-    public void testPPTXGroups() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_groups.pptx");
-        assertEquals(3, metadataList.size());
-        String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
-        assertContains("WordArt1", content);
-        assertContains("WordArt2", content);
-        assertContainsCount("Ungrouped text box", content, 1);//should only be 1
-        assertContains("Text box1", content);
-        assertContains("Text box2", content);
-        assertContains("Text box3", content);
-        assertContains("Text box4", content);
-        assertContains("Text box5", content);
-
-
-        assertContains("href=\"http://tika.apache.org", content);
-        assertContains("smart1", content);
-        assertContains("MyTitle", content);
-
-        assertEquals("/image1.jpg",
-                metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
-
-        assertEquals("/thumbnail.jpeg",
-                metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
-    }
-
     @Test
     public void testXLSXPhoneticStrings() throws Exception {
         //This unit test and test file come from Apache POI 51519.xlsx
@@ -1693,35 +626,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
 
     }
 
-    @Test
-    public void testDOCXPhoneticStrings() throws Exception {
-
-        assertContains("\u6771\u4EAC (\u3068\u3046\u304D\u3087\u3046)",
-                getXML("testWORD_phonetic.docx").xml);
-
-        OfficeParserConfig config = new OfficeParserConfig();
-        config.setConcatenatePhoneticRuns(false);
-        ParseContext parseContext = new ParseContext();
-        parseContext.set(OfficeParserConfig.class, config);
-        String xml = getXML("testWORD_phonetic.docx", parseContext).xml;
-        assertContains("\u6771\u4EAC", xml);
-        assertNotContained("\u3068", xml);
-    }
-
-    @Test
-    public void testEmbeddedMedia() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_embeddedMP3.pptx");
-        assertEquals(4, metadataList.size());
-        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
-                metadataList.get(0).get(Metadata.CONTENT_TYPE));
-        assertEquals("audio/mpeg", metadataList.get(1).get(Metadata.CONTENT_TYPE));
-        assertEquals("image/png", metadataList.get(2).get(Metadata.CONTENT_TYPE));
-        assertEquals("image/jpeg", metadataList.get(3).get(Metadata.CONTENT_TYPE));
-        // Verify INTERNAL_PATH is set for embedded media
-        assertNotNull(metadataList.get(1).get(TikaCoreProperties.INTERNAL_PATH));
-        assertTrue(metadataList.get(1).get(TikaCoreProperties.INTERNAL_PATH).contains("/ppt/media/"));
-    }
-
     @Test
     public void testEmbeddedXLSInOLEObject() throws Exception {
         List<Metadata> metadataList = getRecursiveMetadata("testPPT_oleWorkbook.pptx");
@@ -1747,17 +651,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
 
     }
 
-    @Test
-    public void testTruncatedSAXDocx() throws Exception {
-        ParseContext pc = new ParseContext();
-        OfficeParserConfig c = new OfficeParserConfig();
-        c.setUseSAXDocxExtractor(true);
-        pc.set(OfficeParserConfig.class, c);
-        assertThrows(TikaException.class, () -> {
-            getRecursiveMetadata("testWORD_truncated.docx", pc);
-        });
-    }
-
     @Test
     public void testDateFormat() throws Exception {
         Parser p = TikaLoader.load(
@@ -1810,16 +703,6 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
 
     }
 
-    @Test
-    public void testFeatureExtraction() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_features.docx");
-        Metadata m = metadataList.get(0);
-        assertContains("Kyle Reese", Arrays.asList(m.getValues(Office.COMMENT_PERSONS)));
-        assertEquals("true", m.get(Office.HAS_HIDDEN_TEXT));
-        assertEquals("true", m.get(Office.HAS_TRACK_CHANGES));
-        assertEquals("true", m.get(Office.HAS_COMMENTS));
-    }
-
     @Test
     public void testNoRecordSizeOverflow() throws Exception {
         //TIKA-4474 -- test: files (passed as stream) no longer have limit on record size as they are spooled
@@ -1827,42 +710,4 @@ public class OOXMLParserTest extends MultiThreadedTikaTest {
         assertContains("Repetitive content pattern 3 for compression test row 1", content);
     }
 
-    /**
-     * Test extraction of field-based hyperlinks using instrText/fldChar.
-     * These are hyperlinks embedded as field codes rather than relationship-based hyperlinks.
-     * Uses the DOM-based XWPFWordExtractorDecorator.
-     */
-    @Test
-    public void testInstrTextHyperlink() throws Exception {
-        String xml = getXML("testInstrLink.docx").xml;
-        // The document contains a HYPERLINK field code in instrText
-        assertContains("<a href=\"https://exmaple.com/file\">", xml);
-        assertContains("Access Document(s)", xml);
-    }
-
-    /**
-     * Test extraction of external reference field codes (INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK).
-     * These can be used to hide malicious URLs in documents.
-     */
-    @Test
-    public void testExternalRefFieldCodes() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testExternalRefs.docx");
-        Metadata m = metadataList.get(0);
-        // Check metadata flag is set
-        assertEquals("true", m.get(Office.HAS_FIELD_HYPERLINKS));
-
-        String xml = getXML("testExternalRefs.docx").xml;
-        // Test INCLUDEPICTURE field code
-        assertContains("class=\"external-ref-INCLUDEPICTURE\"", xml);
-        assertContains("http://example.com/tracking.png", xml);
-        // Test INCLUDETEXT field code
-        assertContains("class=\"external-ref-INCLUDETEXT\"", xml);
-        assertContains("http://example.org/payload.txt", xml);
-        // Test IMPORT field code
-        assertContains("class=\"external-ref-IMPORT\"", xml);
-        assertContains("http://example.net/exploit.wmf", xml);
-        // Test LINK field code
-        assertContains("class=\"external-ref-LINK\"", xml);
-        assertContains("http://test.invalid/cmd.docx", xml);
-    }
 }
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/OOXMLPptxDOMTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxDOMTest.java
new file mode 100644
index 0000000000..8b9ca989d9
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxDOMTest.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.exception.EncryptedDocumentException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.DublinCore;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.PasswordProvider;
+import org.apache.tika.parser.microsoft.OfficeParserConfig;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Runs the shared PPTX tests using the DOM-based parser (default),
+ * plus DOM-specific tests.
+ */
+public class OOXMLPptxDOMTest extends AbstractOOXMLPptxTest {
+
+    @Override
+    ParseContext getParseContext() {
+        return new ParseContext();
+    }
+
+    @Test
+    public void testVariousPPTXDOMExtra() throws Exception {
+        // DOM version has extra assertions for DublinCore.SUBJECT and TikaCoreProperties.SUBJECT
+        Metadata metadata = new Metadata();
+        getXML("testPPT_various.pptx", metadata, getParseContext());
+
+        assertEquals("Subject is here", metadata.get(DublinCore.SUBJECT));
+
+        assertContains("Keyword1 Keyword2",
+                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
+        assertContains("Subject is here",
+                Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)));
+    }
+
+    @Test
+    public void testPowerPointCustomProperties() throws Exception {
+        Metadata metadata = new Metadata();
+
+        try (TikaInputStream tis =
+                     getResourceAsStream("/test-documents/testPPT_custom_props.pptx")) {
+            ContentHandler handler = new BodyContentHandler(-1);
+            ParseContext context = new ParseContext();
+            context.set(Locale.class, Locale.US);
+            new OOXMLParser().parse(tis, handler, metadata, context);
+        }
+
+        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
+                metadata.get(Metadata.CONTENT_TYPE));
+        assertEquals("JOUVIN ETIENNE", metadata.get(TikaCoreProperties.CREATOR));
+        assertEquals("EJ04325S", metadata.get(TikaCoreProperties.MODIFIER));
+        assertEquals("2011-08-22T13:30:53Z", metadata.get(TikaCoreProperties.CREATED));
+        assertEquals("2011-08-22T13:32:49Z", metadata.get(TikaCoreProperties.MODIFIED));
+        assertEquals("1", metadata.get(Office.SLIDE_COUNT));
+        assertEquals("3", metadata.get(Office.WORD_COUNT));
+        assertEquals("Test extraction properties pptx", metadata.get(TikaCoreProperties.TITLE));
+        assertEquals("true", metadata.get("custom:myCustomBoolean"));
+        assertEquals("3", metadata.get("custom:myCustomNumber"));
+        assertEquals("MyStringValue", metadata.get("custom:MyCustomString"));
+        assertEquals("2010-12-30T22:00:00Z", metadata.get("custom:MyCustomDate"));
+        assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
+    }
+
+    // TIKA-997:
+    @Test
+    public void testEmbeddedZipInPPTX() throws Exception {
+        String xml = getXML("test_embedded_zip.pptx", getParseContext()).xml;
+        int h = xml.indexOf("<div class=\"embedded\" id=\"slide1_rId3\" />");
+        int i = xml.indexOf("Send me a note");
+        int j = xml.indexOf("<div class=\"embedded\" id=\"slide2_rId4\" />");
+        int k = xml.indexOf("<p>No title</p>");
+        assertTrue(h != -1);
+        assertTrue(i != -1);
+        assertTrue(j != -1);
+        assertTrue(k != -1);
+        assertTrue(h < i);
+        assertTrue(i < j);
+        assertTrue(j < k);
+    }
+
+    // TIKA-1032:
+    @Test
+    public void testEmbeddedPPTXTwoSlides() throws Exception {
+        String xml = getXML("testPPT_embedded_two_slides.pptx", getParseContext()).xml;
+        assertContains("<div class=\"embedded\" id=\"slide1_rId7\" />", xml);
+        assertContains("<div class=\"embedded\" id=\"slide2_rId7\" />", xml);
+    }
+
+    @Test
+    public void testMacrosInPptm() throws Exception {
+
+        //test default is "don't extract macros"
+        for (Metadata metadata : getRecursiveMetadata("testPPT_macros.pptm", getParseContext())) {
+            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
+                fail("Shouldn't have extracted macros as default");
+            }
+        }
+
+        //now test that they were extracted
+        ParseContext context = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setExtractMacros(true);
+        context.set(OfficeParserConfig.class, officeParserConfig);
+
+        Metadata minExpected = new Metadata();
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
+        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
+        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
+
+        assertContainsAtLeast(minExpected,
+                getRecursiveMetadata("testPPT_macros.pptm", context));
+
+        //test configuring via config file
+        Parser parser = TikaLoader.load(
+                getConfigPath(OOXMLParserTest.class, "tika-config-dom-macros.json"))
+                .loadAutoDetectParser();
+        assertContainsAtLeast(minExpected,
+                getRecursiveMetadata("testPPT_macros.pptm", parser));
+    }
+
+    @Test
+    public void testEncrypted() throws Exception {
+        Map<String, String> tests = new HashMap<>();
+        tests.put("testPPT_protected_passtika.pptx",
+                "This is an encrypted PowerPoint 2007 slide.");
+
+        PasswordProvider passwordProvider = metadata -> "tika";
+        ParseContext passwordContext = new ParseContext();
+        passwordContext.set(PasswordProvider.class, passwordProvider);
+
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            assertContains(e.getValue(), getXML(e.getKey(), passwordContext).xml);
+        }
+
+        //now try with no password
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            boolean exc = false;
+            try {
+                getXML(e.getKey(), getParseContext());
+            } catch (EncryptedDocumentException ex) {
+                exc = true;
+            }
+            assertTrue(exc);
+        }
+    }
+
+    @Test
+    public void testSkipHeaderFooter() throws Exception {
+        //now test turning off header/footer
+        OfficeParserConfig config = new OfficeParserConfig();
+        config.setIncludeHeadersAndFooters(false);
+        ParseContext context = new ParseContext();
+        context.set(OfficeParserConfig.class, config);
+        String xml = getXML("testPPT_various.pptx", context).xml;
+        assertNotContained("This is the header text", xml);
+    }
+
+    @Test
+    public void testEmbeddedMediaDOMExtra() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_embeddedMP3.pptx", getParseContext());
+        // Verify INTERNAL_PATH is set for embedded media (DOM-specific)
+        assertNotNull(metadataList.get(1).get(TikaCoreProperties.INTERNAL_PATH));
+        assertTrue(metadataList.get(1).get(TikaCoreProperties.INTERNAL_PATH)
+                .contains("/ppt/media/"));
+    }
+}
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/OOXMLPptxSAXTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXTest.java
new file mode 100644
index 0000000000..c9996d0b91
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXTest.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.exception.EncryptedDocumentException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.PasswordProvider;
+import org.apache.tika.parser.microsoft.OfficeParserConfig;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Runs the shared PPTX tests using the SAX-based streaming parser,
+ * plus SAX-specific tests.
+ */
+public class OOXMLPptxSAXTest extends AbstractOOXMLPptxTest {
+
+    @Override
+    ParseContext getParseContext() {
+        ParseContext parseContext = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setUseSAXPptxExtractor(true);
+        parseContext.set(OfficeParserConfig.class, officeParserConfig);
+        return parseContext;
+    }
+
+    @Test
+    public void basicTest() throws Exception {
+
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_various2.pptx", getParseContext());
+
+        assertEquals(15, metadataList.size(), "right number of attachments");
+
+        String mainContent = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT);
+
+        assertContains("This slide is hidden", mainContent);//TODO: parameterize this
+
+        //basic content
+        assertContains("FirstBullet", mainContent);
+
+        //hyperlink
+        assertContains("<a href=\"http://tika.apache.org/\">tika_hyperlink</a>", mainContent);
+        //hyperlink in cell
+        assertContains("<a href=\"http://lucene.apache.org/\">lucene_hyperlink</a>",
+                mainContent);
+
+        //text box
+        assertContains("Slide2TextBox", mainContent);
+        assertContains("<td>R1c1</td>", mainContent);
+
+        //wordArt
+        assertContains("This is some WordART", mainContent);
+
+        //notes
+        assertContains("NotesForSlide2", mainContent);
+        assertContains("Notes for slide3", mainContent);
+        assertContains("NotesMasterHeader", mainContent);
+        assertContains("NotesMasterFooter", mainContent);
+        assertContains("NotesMasterPageNumber", mainContent);
+        assertContains("NotesWordArt", mainContent);
+        assertContains("NotesWordArtPage2", mainContent);
+        assertContains("NotesTableSlide2", mainContent);
+
+        //comments
+        assertContains(
+                "<p class=\"slide-comment\"><b>Timothy Allison (TA)</b>This is a reply to the " +
+                        "initial comment</p>",
+                mainContent);
+
+        //HandoutMaster
+        assertContains("HandoutHeader1", mainContent);
+        assertContains("HandoutFooter", mainContent);
+        assertContains("HandoutDate", mainContent);
+        assertContains("TextBoxInHandOut", mainContent);
+
+        //text box in master
+        assertContains("MASTERTEXTBOX", mainContent);
+
+        //equation
+        assertContains("3/4", mainContent);
+
+        //make sure footer elements are in their own <p/>
+        assertContains("<p>12/16/2016</p>", mainContent);
+        assertContains("<p>8</p>", mainContent);
+
+        assertContains("<td>NotesTableSlide2", mainContent);
+
+        assertContains("MASTERFOOTERMSG", mainContent);
+
+        //should not include boilerplate from master
+        assertNotContained("Click to edit Master", mainContent);
+        assertNotContained("Second level", mainContent);
+    }
+
+    @Test
+    public void poiBug54916Test() throws Exception {
+        String xml = getXML("testPPTX_overlappingRelations.pptx", getParseContext()).xml;
+        assertContains("POI cannot read this", xml);
+        assertContains("Has a relationship to another slide", xml);
+        assertContains("can read this too", xml);
+    }
+
+    @Test
+    public void testPowerPointCustomProperties() throws Exception {
+        Metadata metadata = new Metadata();
+
+        ParseContext context = new ParseContext();
+        context.set(Locale.class, Locale.US);
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setUseSAXPptxExtractor(true);
+        context.set(OfficeParserConfig.class, officeParserConfig);
+
+        getXML("testPPT_custom_props.pptx", metadata, getParseContext());
+        assertEquals("application/vnd.openxmlformats-officedocument.presentationml.presentation",
+                metadata.get(Metadata.CONTENT_TYPE));
+        assertEquals("JOUVIN ETIENNE", metadata.get(TikaCoreProperties.CREATOR));
+        assertEquals("EJ04325S", metadata.get(TikaCoreProperties.MODIFIER));
+        assertEquals("2011-08-22T13:30:53Z", metadata.get(TikaCoreProperties.CREATED));
+        assertEquals("2011-08-22T13:32:49Z", metadata.get(TikaCoreProperties.MODIFIED));
+        assertEquals("1", metadata.get(Office.SLIDE_COUNT));
+        assertEquals("3", metadata.get(Office.WORD_COUNT));
+        assertEquals("Test extraction properties pptx", metadata.get(TikaCoreProperties.TITLE));
+        assertEquals("true", metadata.get("custom:myCustomBoolean"));
+        assertEquals("3", metadata.get("custom:myCustomNumber"));
+        assertEquals("MyStringValue", metadata.get("custom:MyCustomString"));
+        assertEquals("2010-12-30T22:00:00Z", metadata.get("custom:MyCustomDate"));
+        assertEquals("2010-12-29T22:00:00Z", metadata.get("custom:myCustomSecondDate"));
+    }
+
+    // TIKA-997:
+    @Test
+    @Disabled("TODO: add in embedded file markup")
+    public void testEmbeddedZipInPPTX() throws Exception {
+        String xml = getXML("test_embedded_zip.pptx", getParseContext()).xml;
+        int h = xml.indexOf("<div class=\"embedded\" id=\"slide1_rId3\" />");
+        int i = xml.indexOf("Send me a note");
+        int j = xml.indexOf("<div class=\"embedded\" id=\"slide2_rId4\" />");
+        int k = xml.indexOf("<p>No title</p>");
+        assertTrue(h != -1);
+        assertTrue(i != -1);
+        assertTrue(j != -1);
+        assertTrue(k != -1);
+        assertTrue(h < i);
+        assertTrue(i < j);
+        assertTrue(j < k);
+    }
+
+    // TIKA-1032:
+    @Test
+    @Disabled("TODO: add in embedded file markup")
+    public void testEmbeddedPPTXTwoSlides() throws Exception {
+        String xml = getXML("testPPT_embedded_two_slides.pptx", getParseContext()).xml;
+        assertContains("<div class=\"embedded\" id=\"slide1_rId7\" />", xml);
+        assertContains("<div class=\"embedded\" id=\"slide2_rId7\" />", xml);
+    }
+
+    @Test
+    public void testMacrosInPptm() throws Exception {
+
+        Metadata parsedBy = new Metadata();
+        parsedBy.add(TikaCoreProperties.TIKA_PARSED_BY,
+                "org.apache.tika.parser.microsoft.ooxml.xslf.XSLFEventBasedPowerPointExtractor");
+
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_macros.pptm", getParseContext());
+
+        //test default is "don't extract macros"
+        for (Metadata metadata : metadataList) {
+            if (metadata.get(Metadata.CONTENT_TYPE).equals("text/x-vbasic")) {
+                fail("Shouldn't have extracted macros as default");
+            }
+        }
+
+        assertContainsAtLeast(parsedBy, metadataList);
+
+        //now test that they are extracted
+        ParseContext context = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setExtractMacros(true);
+        officeParserConfig.setUseSAXPptxExtractor(true);
+        context.set(OfficeParserConfig.class, officeParserConfig);
+
+        Metadata minExpected = new Metadata();
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Embolden()");
+        minExpected.add(TikaCoreProperties.TIKA_CONTENT.getName(), "Sub Italicize()");
+        minExpected.add(Metadata.CONTENT_TYPE, "text/x-vbasic");
+        minExpected.add(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.MACRO.toString());
+
+        metadataList = getRecursiveMetadata("testPPT_macros.pptm", context);
+
+        assertContainsAtLeast(minExpected, metadataList);
+        assertContainsAtLeast(parsedBy, metadataList);
+
+        //test configuring via config file
+        AutoDetectParser parser = (AutoDetectParser) TikaLoader.load(
+                getConfigPath(OOXMLPptxSAXTest.class, "tika-config-sax-macros.json"))
+                .loadAutoDetectParser();
+        metadataList = getRecursiveMetadata("testPPT_macros.pptm", parser);
+        assertContainsAtLeast(minExpected, metadataList);
+        assertContainsAtLeast(parsedBy, metadataList);
+    }
+
+    @Test
+    public void testEncrypted() throws Exception {
+        Map<String, String> tests = new HashMap<>();
+        tests.put("testPPT_protected_passtika.pptx",
+                "This is an encrypted PowerPoint 2007 slide.");
+
+        Metadata m = new Metadata();
+        PasswordProvider passwordProvider = metadata -> "tika";
+        ParseContext passwordContext = new ParseContext();
+        passwordContext.set(PasswordProvider.class, passwordProvider);
+        OfficeParserConfig opc = new OfficeParserConfig();
+        opc.setUseSAXPptxExtractor(true);
+        passwordContext.set(OfficeParserConfig.class, opc);
+
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            try (TikaInputStream tis = getResourceAsStream("/test-documents/" + e.getKey())) {
+                ContentHandler handler = new BodyContentHandler();
+                AUTO_DETECT_PARSER.parse(tis, handler, m, passwordContext);
+                assertContains(e.getValue(), handler.toString());
+            }
+        }
+
+        ParseContext context = new ParseContext();
+        //now try with no password
+        for (Map.Entry<String, String> e : tests.entrySet()) {
+            boolean exc = false;
+            try (TikaInputStream tis = getResourceAsStream("/test-documents/" + e.getKey())) {
+                ContentHandler handler = new BodyContentHandler();
+                AUTO_DETECT_PARSER.parse(tis, handler, m, context);
+            } catch (EncryptedDocumentException ex) {
+                exc = true;
+            }
+            assertTrue(exc);
+        }
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_2006ml.docx b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_2006ml.docx
index 682a64ff27..11c2b59076 100644
Binary files a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_2006ml.docx and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testWORD_2006ml.docx differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxPackageTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxPackageTest.java
new file mode 100644
index 0000000000..26967ab18d
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLDocxPackageTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.ooxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+public abstract class AbstractOOXMLDocxPackageTest extends TikaTest {
+
+    abstract ParseContext getParseContext();
+
+    @Test
+    public void testAltFileMHTChunk() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testAltChunkMHT.docx", getParseContext());
+        assertEquals(3, metadataList.size());
+        assertContains("Example of a table",
+                metadataList.get(2).get(TikaCoreProperties.TIKA_CONTENT));
+    }
+
+    @Test
+    public void testAltFileHTMLChunk() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testAltChunkHTML.docx", getParseContext());
+        assertEquals(2, metadataList.size());
+        assertContains("Example of a table",
+                metadataList.get(1).get(TikaCoreProperties.TIKA_CONTENT));
+    }
+}
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/ParagraphProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxPackageTest.java
similarity index 51%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxPackageTest.java
index 80c004bea2..8b844f1d28 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLPptxPackageTest.java
@@ -16,40 +16,26 @@
  */
 package org.apache.tika.parser.microsoft.ooxml;
 
+import java.util.List;
 
-public class ParagraphProperties {
+import org.junit.jupiter.api.Test;
 
-    private String styleId;
-    private int ilvl = -1;
-    private int numId = -1;
+import org.apache.tika.TikaTest;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
 
-    public String getStyleID() {
-        return styleId;
-    }
-
-    public void setStyleID(String styleId) {
-        this.styleId = styleId;
-    }
-
-    public void reset() {
-        styleId = null;
-        ilvl = -1;
-        numId = -1;
-    }
+public abstract class AbstractOOXMLPptxPackageTest extends TikaTest {
 
-    public int getIlvl() {
-        return ilvl;
-    }
-
-    public void setIlvl(int ilvl) {
-        this.ilvl = ilvl;
-    }
-
-    public int getNumId() {
-        return numId;
-    }
+    abstract ParseContext getParseContext();
 
-    public void setNumId(int numId) {
-        this.numId = numId;
+    @Test
+    public void testEmbeddedPDFInPPTX() throws Exception {
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testPPT_EmbeddedPDF.pptx", getParseContext());
+        Metadata pdfMetadata1 = metadataList.get(4);
+        assertContains("Apache Tika", pdfMetadata1.get(TikaCoreProperties.TIKA_CONTENT));
+        Metadata pdfMetadata2 = metadataList.get(5);
+        assertContains("Hello World", pdfMetadata2.get(TikaCoreProperties.TIKA_CONTENT));
     }
 }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxDOMPackageTest.java
similarity index 78%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxDOMPackageTest.java
index ef76cc641e..3f6b0f5804 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxDOMPackageTest.java
@@ -21,36 +21,29 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 
 import java.util.List;
 
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
-import org.apache.tika.TikaTest;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.microsoft.EMFParser;
-import org.apache.tika.parser.microsoft.OfficeParserConfig;
 
-public class SXWPFExtractorTest extends TikaTest {
-
-    private ParseContext parseContext;
-
-    @BeforeEach
-    public void setUp() {
-        parseContext = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setUseSAXDocxExtractor(true);
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
+/**
+ * DOM-specific docx package tests.
+ */
+public class OOXMLDocxDOMPackageTest extends AbstractOOXMLDocxPackageTest {
 
+    @Override
+    ParseContext getParseContext() {
+        return new ParseContext();
     }
+
     @Test
-    @Disabled("TODO -- implement TIKA-3968 for SXWPFExtractor")
     public void testEMFAssociatedWithAttachments() throws Exception {
         //TIKA-3968
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_EMFAndAttachments.docx", parseContext);
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_EMFAndAttachments.docx", getParseContext());
 
         assertEquals("true", metadataList.get(1).get(EMFParser.EMF_ICON_ONLY));
         assertEquals("true", metadataList.get(3).get(EMFParser.EMF_ICON_ONLY));
@@ -63,9 +56,12 @@ public class SXWPFExtractorTest extends TikaTest {
         assertEquals("AcroExch.Document.DC", metadataList.get(4).get(Office.PROG_ID));
         assertEquals("Word.Document.12", metadataList.get(6).get(Office.PROG_ID));
 
-        assertEquals("TestText.txt", metadataList.get(2).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("TestPdf.pdf", metadataList.get(4).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("testWORD123.docx", metadataList.get(6).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("TestText.txt",
+                metadataList.get(2).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("TestPdf.pdf",
+                metadataList.get(4).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("testWORD123.docx",
+                metadataList.get(6).get(TikaCoreProperties.RESOURCE_NAME_KEY));
 
         assertEquals("/TestText.txt",
                 metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
@@ -76,10 +72,8 @@ public class SXWPFExtractorTest extends TikaTest {
 
         assertContains("This is Text File",
                 metadataList.get(2).get(TikaCoreProperties.TIKA_CONTENT));
-
         assertContains("This is test PDF document for parser.",
                 metadataList.get(4).get(TikaCoreProperties.TIKA_CONTENT));
-
         assertContains("This is test word document for parser.",
                 metadataList.get(6).get(TikaCoreProperties.TIKA_CONTENT));
 
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXPackageTest.java
similarity index 83%
rename from tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
rename to tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXPackageTest.java
index ef76cc641e..33712f21ba 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXPackageTest.java
@@ -21,11 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 
 import java.util.List;
 
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
-import org.apache.tika.TikaTest;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
@@ -33,24 +30,25 @@ import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.microsoft.EMFParser;
 import org.apache.tika.parser.microsoft.OfficeParserConfig;
 
-public class SXWPFExtractorTest extends TikaTest {
-
-    private ParseContext parseContext;
+/**
+ * SAX-specific docx package tests.
+ */
+public class OOXMLDocxSAXPackageTest extends AbstractOOXMLDocxPackageTest {
 
-    @BeforeEach
-    public void setUp() {
-        parseContext = new ParseContext();
+    @Override
+    ParseContext getParseContext() {
+        ParseContext parseContext = new ParseContext();
         OfficeParserConfig officeParserConfig = new OfficeParserConfig();
         officeParserConfig.setUseSAXDocxExtractor(true);
-        officeParserConfig.setUseSAXPptxExtractor(true);
         parseContext.set(OfficeParserConfig.class, officeParserConfig);
-
+        return parseContext;
     }
+
     @Test
-    @Disabled("TODO -- implement TIKA-3968 for SXWPFExtractor")
     public void testEMFAssociatedWithAttachments() throws Exception {
         //TIKA-3968
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_EMFAndAttachments.docx", parseContext);
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testWORD_EMFAndAttachments.docx", getParseContext());
 
         assertEquals("true", metadataList.get(1).get(EMFParser.EMF_ICON_ONLY));
         assertEquals("true", metadataList.get(3).get(EMFParser.EMF_ICON_ONLY));
@@ -63,9 +61,12 @@ public class SXWPFExtractorTest extends TikaTest {
         assertEquals("AcroExch.Document.DC", metadataList.get(4).get(Office.PROG_ID));
         assertEquals("Word.Document.12", metadataList.get(6).get(Office.PROG_ID));
 
-        assertEquals("TestText.txt", metadataList.get(2).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("TestPdf.pdf", metadataList.get(4).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("testWORD123.docx", metadataList.get(6).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("TestText.txt",
+                metadataList.get(2).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("TestPdf.pdf",
+                metadataList.get(4).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("testWORD123.docx",
+                metadataList.get(6).get(TikaCoreProperties.RESOURCE_NAME_KEY));
 
         assertEquals("/TestText.txt",
                 metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
@@ -76,10 +77,8 @@ public class SXWPFExtractorTest extends TikaTest {
 
         assertContains("This is Text File",
                 metadataList.get(2).get(TikaCoreProperties.TIKA_CONTENT));
-
         assertContains("This is test PDF document for parser.",
                 metadataList.get(4).get(TikaCoreProperties.TIKA_CONTENT));
-
         assertContains("This is test word document for parser.",
                 metadataList.get(6).get(TikaCoreProperties.TIKA_CONTENT));
 
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
index 8ff5ccbb27..003f3a8ae4 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLParserTest.java
@@ -28,24 +28,12 @@ import org.junit.jupiter.api.Test;
 import org.apache.tika.TikaTest;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
-import org.apache.tika.parser.microsoft.EMFParser;
-import org.apache.tika.parser.microsoft.OfficeParserConfig;
 
 public class OOXMLParserTest extends TikaTest {
 
-    @Test
-    public void testEmbeddedPDFInPPTX() throws Exception {
-        List<Metadata> metadataList = getRecursiveMetadata("testPPT_EmbeddedPDF.pptx");
-        Metadata pdfMetadata1 = metadataList.get(4);
-        assertContains("Apache Tika", pdfMetadata1.get(TikaCoreProperties.TIKA_CONTENT));
-        Metadata pdfMetadata2 = metadataList.get(5);
-        assertContains("Hello World", pdfMetadata2.get(TikaCoreProperties.TIKA_CONTENT));
-    }
-
     @Test
     public void testEmbeddedPDFInXLSX() throws Exception {
         List<Metadata> metadataList = getRecursiveMetadata("testExcel_embeddedPDF.xlsx");
@@ -53,105 +41,21 @@ public class OOXMLParserTest extends TikaTest {
         assertContains("Hello World", pdfMetadata.get(TikaCoreProperties.TIKA_CONTENT));
     }
 
-    @Test
-    public void testEmbeddedPDFInStreamingPPTX() throws Exception {
-        ParseContext parseContext = new ParseContext();
-        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
-        officeParserConfig.setUseSAXPptxExtractor(true);
-        parseContext.set(OfficeParserConfig.class, officeParserConfig);
-
-        List<Metadata> metadataList =
-                getRecursiveMetadata("testPPT_EmbeddedPDF.pptx", parseContext);
-        Metadata pdfMetadata1 = metadataList.get(4);
-        assertContains("Apache Tika", pdfMetadata1.get(TikaCoreProperties.TIKA_CONTENT));
-        Metadata pdfMetadata2 = metadataList.get(5);
-        assertContains("Hello World", pdfMetadata2.get(TikaCoreProperties.TIKA_CONTENT));
-    }
-
-    @Test
-    public void testEMFAssociatedWithAttachments() throws Exception {
-        //TIKA-3968
-        List<Metadata> metadataList = getRecursiveMetadata("testWORD_EMFAndAttachments.docx");
-
-        assertEquals("true", metadataList.get(1).get(EMFParser.EMF_ICON_ONLY));
-        assertEquals("true", metadataList.get(3).get(EMFParser.EMF_ICON_ONLY));
-        assertEquals("true", metadataList.get(5).get(EMFParser.EMF_ICON_ONLY));
-        assertEquals("TestText.txt", metadataList.get(1).get(EMFParser.EMF_ICON_STRING));
-        assertEquals("TestPdf.pdf", metadataList.get(3).get(EMFParser.EMF_ICON_STRING));
-        assertEquals("testWORD123.docx", metadataList.get(5).get(EMFParser.EMF_ICON_STRING));
-
-        assertNull(metadataList.get(2).get(Office.PROG_ID));
-        assertEquals("AcroExch.Document.DC", metadataList.get(4).get(Office.PROG_ID));
-        assertEquals("Word.Document.12", metadataList.get(6).get(Office.PROG_ID));
-
-        assertEquals("TestText.txt", metadataList.get(2).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("TestPdf.pdf", metadataList.get(4).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-        assertEquals("testWORD123.docx", metadataList.get(6).get(TikaCoreProperties.RESOURCE_NAME_KEY));
-
-        assertEquals("/TestText.txt",
-                metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
-        assertEquals("/TestPdf.pdf",
-                metadataList.get(4).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
-        assertEquals("/testWORD123.docx",
-                metadataList.get(6).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH));
-
-        assertContains("This is Text File",
-                metadataList.get(2).get(TikaCoreProperties.TIKA_CONTENT));
-
-        assertContains("This is test PDF document for parser.",
-                metadataList.get(4).get(TikaCoreProperties.TIKA_CONTENT));
-
-        assertContains("This is test word document for parser.",
-                metadataList.get(6).get(TikaCoreProperties.TIKA_CONTENT));
-
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
-                metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
-                metadataList.get(4).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.name(),
-                metadataList.get(6).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
-                metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
-                metadataList.get(3).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-        assertEquals(TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
-                metadataList.get(5).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
-    }
-
     @Disabled("TODO figure out why this doesn't work")
-    @Test//(expected = org.apache.tika.exception.TikaException.class)
+    @Test
     public void testCorruptedZip() throws Exception {
         //TIKA_2446
         getRecursiveMetadata("testZIP_corrupted_oom.zip");
     }
 
-    @Test
-    public void testAltFileMHTChunk() throws Exception {
-        //test file with permission from:
-        // https://github.com/jgm/pandoc/files/1290782/Sample_DOCX_using_MHT_container.docx
-        List<Metadata> metadataList = getRecursiveMetadata("testAltChunkMHT.docx");
-        assertEquals(3, metadataList.size());
-        assertContains("Example of a table",
-                metadataList.get(2).get(TikaCoreProperties.TIKA_CONTENT));
-    }
-
-    @Test
-    public void testAltFileHTMLChunk() throws Exception {
-        //test file with permission from:
-        // https://github.com/jgm/pandoc/files/1290782/Sample_DOCX_using_HTML_container.docx
-        List<Metadata> metadataList = getRecursiveMetadata("testAltChunkHTML.docx");
-        assertEquals(2, metadataList.size());
-        assertContains("Example of a table",
-                metadataList.get(1).get(TikaCoreProperties.TIKA_CONTENT));
-    }
-
     @Test
     public void testDigestTranslator() throws Exception {
-        TikaLoader loader = TikaLoader.load(getConfigPath(OOXMLParserTest.class, "tika-config-digests.json"));
+        TikaLoader loader = TikaLoader.load(
+                getConfigPath(OOXMLParserTest.class, "tika-config-digests.json"));
         Parser parser = loader.loadAutoDetectParser();
         ParseContext parseContext = loader.loadParseContext();
-        List<Metadata> metadataList = getRecursiveMetadata("testMSChart-govdocs-428996.pptx", parser, parseContext);
+        List<Metadata> metadataList =
+                getRecursiveMetadata("testMSChart-govdocs-428996.pptx", parser, parseContext);
         assertEquals(4, metadataList.size());
         for (Metadata m : metadataList) {
             assertNotNull(m.get("X-TIKA:digest:SHA256:BASE32"));
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/xwpf/XWPFNumberingShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxDOMPackageTest.java
similarity index 60%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxDOMPackageTest.java
index f563223964..007e6c8531 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxDOMPackageTest.java
@@ -14,23 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.parser.microsoft.ooxml.xwpf;
+package org.apache.tika.parser.microsoft.ooxml;
 
-import java.io.IOException;
+import org.apache.tika.parser.ParseContext;
 
-import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
-import org.apache.poi.openxml4j.opc.PackagePart;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
+public class OOXMLPptxDOMPackageTest extends AbstractOOXMLPptxPackageTest {
 
-/**
- * Stub class of POI's XWPFNumbering because onDocumentRead() is protected
- */
-public class XWPFNumberingShim extends XWPFNumbering {
-
-    public XWPFNumberingShim(PackagePart part) throws IOException, OpenXML4JException {
-        super(part);
-        onDocumentRead();
+    @Override
+    ParseContext getParseContext() {
+        return new ParseContext();
     }
-
-
 }
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/xwpf/XWPFNumberingShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXPackageTest.java
similarity index 58%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXPackageTest.java
index f563223964..26b217b19e 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFNumberingShim.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-package/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXPackageTest.java
@@ -14,23 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.parser.microsoft.ooxml.xwpf;
+package org.apache.tika.parser.microsoft.ooxml;
 
-import java.io.IOException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.OfficeParserConfig;
 
-import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
-import org.apache.poi.openxml4j.opc.PackagePart;
-import org.apache.poi.xwpf.usermodel.XWPFNumbering;
+public class OOXMLPptxSAXPackageTest extends AbstractOOXMLPptxPackageTest {
 
-/**
- * Stub class of POI's XWPFNumbering because onDocumentRead() is protected
- */
-public class XWPFNumberingShim extends XWPFNumbering {
-
-    public XWPFNumberingShim(PackagePart part) throws IOException, OpenXML4JException {
-        super(part);
-        onDocumentRead();
+    @Override
+    ParseContext getParseContext() {
+        ParseContext parseContext = new ParseContext();
+        OfficeParserConfig officeParserConfig = new OfficeParserConfig();
+        officeParserConfig.setUseSAXPptxExtractor(true);
+        parseContext.set(OfficeParserConfig.class, officeParserConfig);
+        return parseContext;
     }
-
-
 }