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/08/22 01:19:19 UTC

(tika) branch TIKA-4814-for-3x created (now 9519878af1)

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

tballison pushed a change to branch TIKA-4814-for-3x
in repository https://gitbox.apache.org/repos/asf/tika.git


      at 9519878af1 TIKA-4814 -- OneNote dom-order extraction and follow-ups

This branch includes the following new commits:

     new 9519878af1 TIKA-4814 -- OneNote dom-order extraction and follow-ups

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(tika) 01/01: TIKA-4814 -- OneNote dom-order extraction and follow-ups

Posted by ta...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch TIKA-4814-for-3x
in repository https://gitbox.apache.org/repos/asf/tika.git

commit 9519878af1fc27a1137d165eed563869546bf5f3
Author: tallison <ta...@apache.org>
AuthorDate: Fri Aug 21 21:19:02 2026 -0400

    TIKA-4814 -- OneNote dom-order extraction and follow-ups
    
    Backport to 3.x of a9abca092c plus the TIKA-4814-followup-1 work,
    adapted to the 3.x APIs: onenote: metadata key strings instead of the
    4.x OneNote metadata class, the 1-arg/4-arg EmbeddedDocumentExtractor
    signatures, the 2-arg XHTMLContentHandler, and no TikaComponent or
    EmbeddedLimitReachedException.
    
    Co-authored-by: Henry Lindeman <he...@glean.com>
    Co-authored-by: nddipiazza <nd...@users.noreply.github.com>
---
 CHANGES.txt                                        |  11 +
 .../tika/parser/microsoft/onenote/FileNodePtr.java |  15 +-
 .../apache/tika/parser/microsoft/onenote/GUID.java |  40 +-
 .../parser/microsoft/onenote/OneNoteDocument.java  |   3 +
 .../parser/microsoft/onenote/OneNoteParser.java    | 150 +++-
 .../tika/parser/microsoft/onenote/OneNotePtr.java  | 183 +++-
 .../microsoft/onenote/OneNoteTreeWalker.java       | 135 ++-
 .../onenote/fsshttpb/MSOneStorePackage.java        | 999 ++++++++++++++++++---
 .../onenote/fsshttpb/MSOneStoreParser.java         | 326 +++++--
 .../property/PrtArrayOfPropertyValues.java         |  13 +-
 .../PrtFourBytesOfLengthFollowedByData.java        |   6 +
 .../onenote/fsshttpb/streamobj/DataElement.java    |   3 +-
 .../onenote/fsshttpb/streamobj/FileDataObject.java |  18 +-
 .../onenote/fsshttpb/streamobj/ObjectDataBLOB.java |  73 ++
 .../streamobj/ObjectDataBLOBDataElementData.java   |  48 +
 .../onenote/fsshttpb/streamobj/PropertySet.java    |  23 +-
 ...{FileDataObject.java => RevisionStoreCell.java} |  21 +-
 .../streamobj/RevisionStoreObjectGroup.java        |  45 +-
 .../onenote/fsshttpb/streamobj/StreamObject.java   |   3 +-
 .../onenote/fsshttpb/streamobj/basic/CellID.java   |   8 +
 .../onenote/fsshttpb/streamobj/basic/ExGuid.java   |   9 +
 .../space/ObjectSpaceObjectStreamOfContextIDs.java |   8 +-
 .../space/ObjectSpaceObjectStreamOfOIDs.java       |   8 +-
 .../space/ObjectSpaceObjectStreamOfOSIDs.java      |   6 +
 .../tika/parser/microsoft/onenote/GUIDTest.java    |  70 ++
 .../microsoft/onenote/OneNoteParserTest.java       | 337 ++++++-
 .../onenote/fsshttpb/MSOneStoreBlobTest.java       | 343 +++++++
 .../onenote/fsshttpb/MSOneStorePackageTest.java    | 846 +++++++++++++++++
 .../onenote/fsshttpb/MSOneStoreParserTest.java     | 286 ++++++
 .../PropertyDeserializationBoundsTest.java         | 184 ++++
 .../resources/test-documents/testOneNote-fuzz1.one | Bin 0 -> 6448 bytes
 .../resources/test-documents/testOneNote-fuzz2.one | Bin 0 -> 295501 bytes
 .../resources/test-documents/testOneNote-fuzz3.one | Bin 0 -> 120720 bytes
 .../test-documents/testOneNoteEmbeddedImage.one    | Bin 0 -> 52223 bytes
 34 files changed, 3918 insertions(+), 302 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 4d46e86962..64baa29f35 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -3,6 +3,17 @@ Release 3.3.3 - ???
   * MagicDetector now compiles its regular expression once, in the
     constructor, instead of recompiling it on every match (TIKA-4796).
 
+   * OneNote extraction now follows document order, omits superseded page
+     revisions, sorts author metadata, extracts embedded object BLOBs, and
+     bounds malformed-input recursion and file-derived allocations. Parse
+     warnings and embedded relationship IDs are exposed in metadata. Malformed
+     or truncated files that cannot be fully parsed, and files whose walk
+     yields no content, now fall back to the legacy string dump instead of
+     failing or returning empty output. The legacy MS-ONESTORE walker bounds
+     its recursion (depth caps plus file-node-list and fragment-chain cycle
+     guards) and now honors shouldParseEmbedded for embedded file data
+     (TIKA-4814).
+
    * PDFs whose %PDF- header is preceded by a print-composition job ticket are
      no longer detected as text/x-matlab. Up to 50 %% comment or blank lines of
      up to 150 characters, and nothing else, may now precede the header; the
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/FileNodePtr.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/FileNodePtr.java
index 2646def38b..e82706a7ad 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/FileNodePtr.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/FileNodePtr.java
@@ -56,12 +56,21 @@ class FileNodePtr {
         if (nodeListPositions.isEmpty()) {
             return null;
         }
-        if (nodeListPositions.get(0) >= document.root.children.size()) {
+        int rootPosition = nodeListPositions.get(0);
+        if (rootPosition < 0 || rootPosition >= document.root.children.size()) {
             throw new TikaException("Exceeded root child size");
         }
-        FileNode cur = document.root.children.get(nodeListPositions.get(0));
+        FileNode cur = document.root.children.get(rootPosition);
         for (int i = 1, ie = nodeListPositions.size(); i < ie; ++i) {
-            cur = cur.childFileNodeList.children.get(nodeListPositions.get(i));
+            if (cur == null || cur.childFileNodeList == null ||
+                    cur.childFileNodeList.children == null) {
+                throw new TikaException("Missing child file node list");
+            }
+            int childPosition = nodeListPositions.get(i);
+            if (childPosition < 0 || childPosition >= cur.childFileNodeList.children.size()) {
+                throw new TikaException("Exceeded child file node size");
+            }
+            cur = cur.childFileNodeList.children.get(childPosition);
         }
         return cur;
     }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
index 670824eec7..55e788bb4a 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
@@ -23,6 +23,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 
+import org.apache.tika.exception.TikaException;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.BitConverter;
 import org.apache.tika.utils.StringUtils;
 
@@ -41,17 +42,44 @@ public class GUID implements Comparable<GUID> {
      *             -EEEEEEEEEEEE}
      * @return GUID object parsed from guid bytes.
      */
-    public static GUID fromCurlyBraceUTF16Bytes(byte[] guid) {
+    public static GUID fromCurlyBraceUTF16Bytes(byte[] guid) throws TikaException {
         int[] intGuid = new int[16];
-        String utf16Str = new String(guid, StandardCharsets.UTF_16LE).replaceAll("\\{", "")
-                .replaceAll("-", "").replaceAll("}", "");
-        for (int i = 0; i < utf16Str.length(); i += 2) {
-            intGuid[i / 2] =
-                    Integer.parseUnsignedInt("" + utf16Str.charAt(i) + utf16Str.charAt(i + 1), 16);
+        String utf16Str = new String(guid, StandardCharsets.UTF_16LE);
+        if (utf16Str.length() != 38 || utf16Str.charAt(0) != '{' ||
+                utf16Str.charAt(9) != '-' || utf16Str.charAt(14) != '-' ||
+                utf16Str.charAt(19) != '-' || utf16Str.charAt(24) != '-' ||
+                utf16Str.charAt(37) != '}') {
+            throw new TikaException("Invalid GUID string");
+        }
+        String hex = utf16Str.substring(1, 37).replace("-", "");
+        if (hex.length() != 32) {
+            throw new TikaException("Invalid GUID string");
+        }
+        for (int i = 0; i < hex.length(); i += 2) {
+            int high = asciiHexDigit(hex.charAt(i));
+            int low = asciiHexDigit(hex.charAt(i + 1));
+            if (high < 0 || low < 0) {
+                throw new TikaException("Invalid GUID string");
+            }
+            intGuid[i / 2] = (high << 4) | low;
         }
         return new GUID(intGuid);
     }
 
+    // Character.digit accepts non-ASCII Unicode digits; GUIDs are ASCII hex only
+    private static int asciiHexDigit(char c) {
+        if (c >= '0' && c <= '9') {
+            return c - '0';
+        }
+        if (c >= 'a' && c <= 'f') {
+            return c - 'a' + 10;
+        }
+        if (c >= 'A' && c <= 'F') {
+            return c - 'A' + 10;
+        }
+        return -1;
+    }
+
     public static int memcmp(int[] b1, int[] b2, int sz) {
         for (int i = 0; i < sz; i++) {
             if (b1[i] != b2[i]) {
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
index 936228f25c..cd76a58a55 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
@@ -34,6 +34,9 @@ class OneNoteDocument {
     Map<ExtendedGUID, Pair<Long, ExtendedGUID>> revisionRoleMap = new HashMap<>();
     ExtendedGUID currentRevision = ExtendedGUID.nil();
     FileNodeList root = new FileNodeList();
+    // set when the root file node list could not be fully parsed (e.g. a truncated file);
+    // the header and any structure parsed so far remain usable
+    Exception structureParseException;
 
     public OneNoteDocument() {
 
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
index d4a265caee..f7e03378c0 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
@@ -19,6 +19,7 @@ package org.apache.tika.parser.microsoft.onenote;
 import java.io.IOException;
 import java.io.InputStream;
 import java.time.Instant;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -27,10 +28,13 @@ import java.util.Set;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.tuple.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.io.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
@@ -52,6 +56,7 @@ import org.apache.tika.sax.XHTMLContentHandler;
 public class OneNoteParser implements Parser {
 
     public static final String ONE_NOTE_PREFIX = "onenote:";
+    private static final Logger LOG = LoggerFactory.getLogger(OneNoteParser.class);
     private static final Map<MediaType, List<String>> TYPES_MAP = new HashMap<>();
     /**
      * Serial version UID
@@ -126,53 +131,76 @@ public class OneNoteParser implements Parser {
                 metadata.set(ONE_NOTE_PREFIX + "rgbPlaceholder",
                         "0x" + Long.toHexString(oneNoteDocument.header.rgbPlaceholder));
 
-                Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, ExtendedGUID.nil());
-                OneNoteTreeWalker oneNoteTreeWalker =
-                        new OneNoteTreeWalker(options, oneNoteDocument, oneNoteDirectFileResource,
-                                xhtml, metadata, context, roleAndContext);
+                Exception structureFailure = oneNoteDocument.structureParseException;
+                boolean walked = false;
+                if (structureFailure == null) {
+                    try {
+                        Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, ExtendedGUID.nil());
+                        OneNoteTreeWalker oneNoteTreeWalker =
+                                new OneNoteTreeWalker(options, oneNoteDocument,
+                                        oneNoteDirectFileResource, xhtml, metadata, context,
+                                        roleAndContext);
 
-                oneNoteTreeWalker.walkTree();
+                        oneNoteTreeWalker.walkTree();
 
-                if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
-                    metadata.set(TikaCoreProperties.CREATOR,
-                            oneNoteTreeWalker.getAuthors().toArray(new String[]{}));
+                        if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
+                            metadata.set(TikaCoreProperties.CREATOR,
+                                    sortedValues(oneNoteTreeWalker.getAuthors()));
+                        }
+                        if (!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
+                            metadata.set(
+                                    Property.externalTextBag(ONE_NOTE_PREFIX + "mostRecentAuthors"),
+                                    sortedValues(oneNoteTreeWalker.getMostRecentAuthors()));
+                        }
+                        if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) {
+                            metadata.set(
+                                    Property.externalTextBag(ONE_NOTE_PREFIX + "originalAuthors"),
+                                    sortedValues(oneNoteTreeWalker.getOriginalAuthors()));
+                        }
+                        if (!Instant.MAX.equals(
+                                Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
+                            metadata.set(ONE_NOTE_PREFIX + "creationTimestamp",
+                                    String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
+                        }
+                        if (!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
+                            metadata.set(ONE_NOTE_PREFIX + "lastModifiedTimestamp", String.valueOf(
+                                    oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
+                        }
+                        if (oneNoteTreeWalker.getLastModified() > Long.MIN_VALUE) {
+                            metadata.set(TikaCoreProperties.MODIFIED,
+                                    String.valueOf(oneNoteTreeWalker.getLastModified()));
+                        }
+                        walked = true;
+                    } catch (Exception e) {
+                        rethrowIfLimitReached(e);
+                        structureFailure = e;
+                    }
                 }
-                if (!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
-                    metadata.set(Property.externalTextBag(ONE_NOTE_PREFIX + "mostRecentAuthors"),
-                            oneNoteTreeWalker.getMostRecentAuthors().toArray(new String[]{}));
-                }
-                if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) {
-                    metadata.set(Property.externalTextBag(ONE_NOTE_PREFIX + "originalAuthors"),
-                            oneNoteTreeWalker.getOriginalAuthors().toArray(new String[]{}));
-                }
-                if (!Instant.MAX.equals(
-                        Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
-                    metadata.set(ONE_NOTE_PREFIX + "creationTimestamp",
-                            String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
-                }
-                if (!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
-                    metadata.set(ONE_NOTE_PREFIX + "lastModifiedTimestamp", String.valueOf(
-                            oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
-                }
-                if (oneNoteTreeWalker.getLastModified() > Long.MIN_VALUE) {
-                    metadata.set(TikaCoreProperties.MODIFIED,
-                            String.valueOf(oneNoteTreeWalker.getLastModified()));
+                if (!walked) {
+                    legacyFallbackDump("OneNote parse failed; falling back to legacy text dump: " +
+                                    failureMessage(structureFailure), structureFailure, metadata,
+                            xhtml, oneNoteDirectFileResource);
                 }
             } else if (header.isLegacyOrAlternativePackaging()) {
+                MSOneStorePackage pkg = null;
                 try {
                     AlternativePackaging alternatePackageOneStoreFile = new AlternativePackaging();
+                    //enable streaming deserialization
                     alternatePackageOneStoreFile.doDeserializeFromByteArray(oneStoreFileBytes, 0);
 
                     MSOneStoreParser onenoteParser = new MSOneStoreParser();
-                    MSOneStorePackage pkg =
-                            onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
+                    pkg = onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
 
-                    pkg.walkTree(options, metadata, xhtml);
+                    pkg.walkTree(options, metadata, xhtml, context);
                 } catch (Exception e) {
-                    OneNoteLegacyDumpStrings dumpStrings =
-                            new OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml);
-                    dumpStrings.dump();
+                    rethrowIfLimitReached(e);
+                    legacyFallbackDump(
+                            "OneNote FSSHTTPB parse failed; falling back to legacy text dump: " +
+                                    failureMessage(e), e, metadata, xhtml,
+                            oneNoteDirectFileResource);
+                    pkg = null;
                 }
+                legacyFallbackIfNoContent(pkg, metadata, xhtml, oneNoteDirectFileResource);
             } else {
                 throw new TikaException("Invalid OneStore document - could not parse headers");
             }
@@ -182,6 +210,45 @@ public class OneNoteParser implements Parser {
 
     }
 
+    private static String[] sortedValues(Set<String> values) {
+        String[] sorted = values.toArray(new String[0]);
+        Arrays.sort(sorted);
+        return sorted;
+    }
+
+    private static void rethrowIfLimitReached(Exception e) throws TikaException, SAXException {
+        WriteLimitReachedException.throwIfWriteLimitReached(e);
+    }
+
+    private static String failureMessage(Exception e) {
+        return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+    }
+
+    // the walk completed but every page dangled - without this a degraded
+    // file would yield empty output where the dump still finds its text
+    static void legacyFallbackIfNoContent(MSOneStorePackage pkg, Metadata metadata,
+                                          XHTMLContentHandler xhtml,
+                                          OneNoteDirectFileResource oneNoteDirectFileResource)
+            throws TikaException, SAXException {
+        if (pkg != null && !pkg.hasEmittedContent()) {
+            legacyFallbackDump("OneNote FSSHTTPB parse produced no content; " +
+                            "falling back to legacy text dump", null, metadata, xhtml,
+                    oneNoteDirectFileResource);
+        }
+    }
+
+    private static void legacyFallbackDump(String warning, Exception cause, Metadata metadata,
+                                           XHTMLContentHandler xhtml,
+                                           OneNoteDirectFileResource oneNoteDirectFileResource)
+            throws TikaException, SAXException {
+        LOG.warn(warning);
+        if (cause != null) {
+            LOG.debug("OneNote parse failure", cause);
+        }
+        metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        new OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml).dump();
+    }
+
     /**
      * Create a OneNoteDocument object.
      * <p>
@@ -215,7 +282,8 @@ public class OneNoteParser implements Parser {
      *                                  content.
      * @return A parsed one note document. This document does not contain any of the binary data,
      * rather it just contains
-     * the data pointers and metadata.
+     * the data pointers and metadata. A failure while parsing the root file node list is not
+     * thrown; it is recorded in the returned document's {@code structureParseException}.
      * @throws IOException Will throw IOException in typical IO issue situations.
      */
     public OneNoteDocument createOneNoteDocumentFromDirectFileResource(
@@ -227,9 +295,15 @@ public class OneNoteParser implements Parser {
 
         if (oneNoteDocument.header.isMsOneStoreFormat()) {
             // Now that we parsed the header, the "root file node list"
-            oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
-            FileNodePtr curPath = new FileNodePtr();
-            oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, curPath);
+            try {
+                oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
+                FileNodePtr curPath = new FileNodePtr();
+                oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, curPath);
+            } catch (TikaException | IOException | RuntimeException e) {
+                // a truncated or malformed root list is recorded, not thrown, so the
+                // caller can fall back to the legacy string dump
+                oneNoteDocument.structureParseException = e;
+            }
         }
         return oneNoteDocument;
     }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
index 38a9f3d9f6..916b9bbb5a 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
@@ -19,11 +19,12 @@ package org.apache.tika.parser.microsoft.onenote;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
+import java.util.Set;
 
 import org.apache.commons.codec.binary.Hex;
 import org.apache.commons.io.EndianUtils;
@@ -54,8 +55,28 @@ class OneNotePtr {
             new byte[]{60, 0, 105, 0, 102, 0, 110, 0, 100, 0, 102, 0, 62, 0};
     private static final String PACKAGE_STORAGE_FILE_FORMAT_GUID =
             "{638DE92F-A6D4-4BC1-9A36-B3FC2511A5B7}";
+    private static final int MAX_PROPERTY_VALUES = 100_000;
+    private static final int MAX_PROPERTY_SET_DEPTH = 1000;
+    // the format nests file node lists only a handful of levels deep; 100 leaves wide
+    // margin while keeping the recursion far from any stack limit
+    private static final int MAX_FILE_NODE_LIST_DEPTH = 100;
+
+    private static final class PropertyValueBudget {
+        private long remaining = MAX_PROPERTY_VALUES;
+    }
+
+    /**
+     * Recursion state for the baseType-2 file-node-list nesting, shared by every pointer
+     * copied while parsing one document.
+     */
+    private static final class FileNodeListRecursion {
+        private int depth;
+        private final Set<Long> activeListOffsets = new HashSet<>();
+    }
 
     int indentLevel = 0;
+    private PropertyValueBudget propertyValueBudget = new PropertyValueBudget();
+    private FileNodeListRecursion fileNodeListRecursion = new FileNodeListRecursion();
 
     long offset;
     long end;
@@ -77,6 +98,8 @@ class OneNotePtr {
         this.offset = oneNotePtr.offset;
         this.end = oneNotePtr.end;
         this.indentLevel = oneNotePtr.indentLevel;
+        this.propertyValueBudget = oneNotePtr.propertyValueBudget;
+        this.fileNodeListRecursion = oneNotePtr.fileNodeListRecursion;
     }
 
     public OneNoteHeader deserializeHeader() throws IOException, TikaException {
@@ -260,8 +283,11 @@ class OneNotePtr {
     public OneNotePtr internalDeserializeFileNodeList(OneNotePtr ptr, FileNodeList fileNodeList,
                                                       FileNodePtr curPath)
             throws IOException, TikaException {
-        OneNotePtr localPtr = new OneNotePtr(document, dif);
+        OneNotePtr localPtr = new OneNotePtr(ptr);
         FileNodePtrBackPush bp = new FileNodePtrBackPush(curPath);
+        // a next-fragment reference pointing at an already-seen fragment would loop forever
+        Set<Long> seenFragmentOffsets = new HashSet<>();
+        seenFragmentOffsets.add(ptr.offset);
         try {
             while (true) {
                 FileChunkReference next = FileChunkReference.nil();
@@ -269,6 +295,11 @@ class OneNotePtr {
                 if (FileChunkReference.nil().equals(next)) {
                     break;
                 }
+                if (!seenFragmentOffsets.add(next.stp)) {
+                    throw new TikaException(
+                            "OneNote file node list fragment cycle detected at offset " +
+                                    next.stp);
+                }
                 localPtr.reposition(next);
                 ptr = localPtr;
             }
@@ -279,9 +310,30 @@ class OneNotePtr {
     }
 
 
+    /**
+     * Deserializes one file-node list and its fragments. The property-value budget is reset for
+     * each list; pointers copied while parsing that list share the budget.
+     */
     public OneNotePtr deserializeFileNodeList(FileNodeList fileNodeList, FileNodePtr curPath)
             throws IOException, TikaException {
-        return internalDeserializeFileNodeList(this, fileNodeList, curPath);
+        if (fileNodeListRecursion.depth >= MAX_FILE_NODE_LIST_DEPTH) {
+            throw new TikaMemoryLimitException(
+                    "OneNote file node list nesting exceeds depth limit " +
+                            MAX_FILE_NODE_LIST_DEPTH);
+        }
+        long listOffset = offset;
+        if (!fileNodeListRecursion.activeListOffsets.add(listOffset)) {
+            throw new TikaException(
+                    "OneNote file node list cycle detected at offset " + listOffset);
+        }
+        fileNodeListRecursion.depth++;
+        try {
+            propertyValueBudget = new PropertyValueBudget();
+            return internalDeserializeFileNodeList(this, fileNodeList, curPath);
+        } finally {
+            fileNodeListRecursion.depth--;
+            fileNodeListRecursion.activeListOffsets.remove(listOffset);
+        }
     }
 
     /**
@@ -453,23 +505,22 @@ class OneNotePtr {
 
             data.subType.globalIdTableEntryFNDX.guid = deserializeGUID();
 
-            document.revisionMap.get(document.currentRevision).globalId.put(
-                    data.subType.globalIdTableEntryFNDX.index,
+            Revision currentRevision = getCurrentRevision();
+            currentRevision.globalId.put(data.subType.globalIdTableEntryFNDX.index,
                     data.subType.globalIdTableEntryFNDX.guid);
         } else if (data.id == FndStructureConstants.GlobalIdTableEntry2FNDX) {
             data.subType.globalIdTableEntry2FNDX.indexMapFrom = deserializeLittleEndianInt();
             data.subType.globalIdTableEntry2FNDX.indexMapTo = deserializeLittleEndianInt();
 
-            ExtendedGUID dependentRevision =
-                    document.revisionMap.get(document.currentRevision).dependent;
-            // Get the compactId from the revisionMap's globalId map.
-            GUID compactId = document.revisionMap.get(dependentRevision).globalId.get(
+            Revision currentRevision = getCurrentRevision();
+            Revision dependentRevision = getRevision(currentRevision.dependent);
+            GUID compactId = dependentRevision.globalId.get(
                     data.subType.globalIdTableEntry2FNDX.indexMapFrom);
             if (compactId == null) {
                 throw new TikaException("COMPACT_ID_MISSING");
             }
-            document.revisionMap.get(document.currentRevision).globalId.put(
-                    data.subType.globalIdTableEntry2FNDX.indexMapTo, compactId);
+            currentRevision.globalId.put(data.subType.globalIdTableEntry2FNDX.indexMapTo,
+                    compactId);
         } else if (data.id == FndStructureConstants.GlobalIdTableEntry3FNDX) {
             data.subType.globalIdTableEntry3FNDX.indexCopyFromStart = deserializeLittleEndianInt();
 
@@ -477,16 +528,17 @@ class OneNotePtr {
 
             data.subType.globalIdTableEntry3FNDX.indexCopyToStart = deserializeLittleEndianInt();
 
-            ExtendedGUID dependent_revision =
-                    document.revisionMap.get(document.currentRevision).dependent;
-            for (int i = 0; i < data.subType.globalIdTableEntry3FNDX.entriesToCopy; ++i) {
-                Map<Long, GUID> globalIdMap = document.revisionMap.get(dependent_revision).globalId;
-                GUID compactId = globalIdMap.get(
+            Revision currentRevision = getCurrentRevision();
+            Revision dependentRevision = getRevision(currentRevision.dependent);
+            int entriesToCopy = checkedCount(
+                    data.subType.globalIdTableEntry3FNDX.entriesToCopy, "global ID entries");
+            for (int i = 0; i < entriesToCopy; ++i) {
+                GUID compactId = dependentRevision.globalId.get(
                         data.subType.globalIdTableEntry3FNDX.indexCopyFromStart + i);
                 if (compactId == null) {
                     throw new TikaException("COMPACT_ID_MISSING");
                 }
-                document.revisionMap.get(document.currentRevision).globalId.put(
+                currentRevision.globalId.put(
                         data.subType.globalIdTableEntry3FNDX.indexCopyToStart + i, compactId);
             }
         } else if (data.id == FndStructureConstants.CanRevise.ObjectRevisionWithRefCountFNDX ||
@@ -623,9 +675,9 @@ class OneNotePtr {
                         "Data out of bounds - cch " + cch + " is > room left = " + roomLeftLong);
             }
 
-            if (cch > dif.size()) {
+            if (cch > dif.size() || cch > Integer.MAX_VALUE / 2L) {
                 throw new TikaMemoryLimitException(
-                        "CCH=" + cch + " was found that was greater" + " than file size " +
+                        "CCH=" + cch + " cannot be safely allocated for file size " +
                                 dif.size());
             }
             ByteBuffer dataSpaceBuffer = ByteBuffer.allocate((int) cch * 2);
@@ -816,7 +868,7 @@ class OneNotePtr {
         compactID.guid = ExtendedGUID.nil();
         compactID.guid.n = compactID.n;
         long index = compactID.guidIndex;
-        Map<Long, GUID> globalIdMap = document.revisionMap.get(document.currentRevision).globalId;
+        Map<Long, GUID> globalIdMap = getCurrentRevision().globalId;
         GUID guid = globalIdMap.get(index);
         if (guid != null) {
             compactID.guid.guid = guid;
@@ -976,22 +1028,69 @@ class OneNotePtr {
         }
     }
 
+    private Revision getCurrentRevision() throws TikaException {
+        Revision revision = document.revisionMap.get(document.currentRevision);
+        if (revision == null) {
+            throw new TikaException("Missing current revision");
+        }
+        return revision;
+    }
+
+    private Revision getRevision(ExtendedGUID revisionId) throws TikaException {
+        Revision revision = document.revisionMap.get(revisionId);
+        if (revision == null) {
+            throw new TikaException("Missing dependent revision");
+        }
+        return revision;
+    }
+
+    private int checkedCount(long count, String description) throws TikaException {
+        if (count < 0 || count > MAX_PROPERTY_VALUES) {
+            throw new TikaMemoryLimitException(
+                    description + " count " + count + " exceeds limit " + MAX_PROPERTY_VALUES);
+        }
+        return (int) count;
+    }
+
+    private int reservePropertyCount(long count, String description) throws TikaException {
+        int checked = checkedCount(count, description);
+        if (count > propertyValueBudget.remaining) {
+            throw new TikaMemoryLimitException(description + " count " + count +
+                    " exceeds remaining property value budget " + propertyValueBudget.remaining);
+        }
+        propertyValueBudget.remaining -= count;
+        return checked;
+    }
+
     private PropertySet deserializePropertySet(ObjectStreamCounters counters,
                                                ObjectSpaceObjectPropSet streams)
             throws IOException, TikaException {
+        return deserializePropertySet(counters, streams, 0);
+    }
+
+    private PropertySet deserializePropertySet(ObjectStreamCounters counters,
+                                               ObjectSpaceObjectPropSet streams,
+                                               int depth)
+            throws IOException, TikaException {
+        if (depth >= MAX_PROPERTY_SET_DEPTH) {
+            throw new TikaException("Property set nesting exceeds limit " +
+                    MAX_PROPERTY_SET_DEPTH);
+        }
         PropertySet data = new PropertySet();
-        long count = deserializeLittleEndianShort();
-        data.rgPridsData =
-                Stream.generate(PropertyValue::new).limit((int) count).collect(Collectors.toList());
+        // Reserve the count before allocating any PropertyValue instances.
+        int count = reservePropertyCount(deserializeLittleEndianShort(), "property");
+        data.rgPridsData = new ArrayList<>();
         for (int i = 0; i < count; ++i) {
-            data.rgPridsData.get(i).propertyId = deserializePropertyID();
-            LOG.debug("{}Property {}", getIndent(), data.rgPridsData.get(i).propertyId);
+            PropertyValue propertyValue = new PropertyValue();
+            propertyValue.propertyId = deserializePropertyID();
+            data.rgPridsData.add(propertyValue);
+            LOG.debug("{}Property {}", getIndent(), propertyValue.propertyId);
         }
         LOG.debug("{}{} elements in property set:", getIndent(), count);
         for (int i = 0; i < count; ++i) {
             data.rgPridsData.set(i,
                     deserializePropertyValueFromPropertyID(data.rgPridsData.get(i).propertyId,
-                            streams, counters));
+                            streams, counters, depth));
         }
         LOG.debug("");
         return data;
@@ -1000,8 +1099,13 @@ class OneNotePtr {
 
     private PropertyValue deserializePropertyValueFromPropertyID(OneNotePropertyId propertyID,
                                                                  ObjectSpaceObjectPropSet streams,
-                                                                 ObjectStreamCounters counters)
+                                                                 ObjectStreamCounters counters,
+                                                                 int propertySetDepth)
             throws IOException, TikaException {
+        if (propertySetDepth >= MAX_PROPERTY_SET_DEPTH) {
+            throw new TikaException("Property value nesting exceeds limit " +
+                    MAX_PROPERTY_SET_DEPTH);
+        }
         PropertyValue data = new PropertyValue();
         data.propertyId = propertyID;
         char val8;
@@ -1119,22 +1223,18 @@ class OneNotePtr {
                     val32 = deserializeLittleEndianInt();
                     OneNotePropertyId propId = deserializePropertyID();
                     LOG.debug(" UnifiedSubPropertySet {} {}", val32, propId);
-                    data.propertySet.rgPridsData =
-                            Stream.generate(PropertyValue::new).limit((int) val32)
-                                    .collect(Collectors.toList());
-                    for (int i = 0; i < val32; ++i) {
-                        try {
-                            data.propertySet.rgPridsData.set(i,
-                                    deserializePropertyValueFromPropertyID(propId, streams,
-                                            counters));
-                        } catch (IOException e) {
-                            return data;
-                        }
+                    int propertyCount = reservePropertyCount(val32, "unified property");
+                    data.propertySet.rgPridsData = new ArrayList<>();
+                    for (int i = 0; i < propertyCount; ++i) {
+                        data.propertySet.rgPridsData.add(
+                                deserializePropertyValueFromPropertyID(propId, streams, counters,
+                                        propertySetDepth + 1));
                     }
                     break;
                 case 0x11:
                     LOG.debug(" SubPropertySet");
-                    data.propertySet = deserializePropertySet(counters, streams);
+                    data.propertySet = deserializePropertySet(counters, streams,
+                            propertySetDepth + 1);
                     break;
                 default:
                     throw new TikaException("Invalid type: " + type);
@@ -1185,7 +1285,8 @@ class OneNotePtr {
                     getIndent(), data.count, data.osidsStreamNotPresent,
                     data.extendedStreamsPresent);
         }
-        for (int i = 0; i < data.count; ++i) {
+        int count = checkedCount(data.count, "object stream");
+        for (int i = 0; i < count; ++i) {
             CompactID cid;
             cid = deserializeCompactID();
             data.data.add(cid);
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
index 452b48089e..939d36bbc0 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
@@ -27,6 +27,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -35,6 +36,8 @@ import java.util.regex.Pattern;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.tuple.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.AttributesImpl;
 
@@ -44,6 +47,7 @@ import org.apache.tika.extractor.EmbeddedDocumentExtractor;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
@@ -54,6 +58,10 @@ import org.apache.tika.sax.XHTMLContentHandler;
  */
 class OneNoteTreeWalker {
 
+    private static final Logger LOG = LoggerFactory.getLogger(OneNoteTreeWalker.class);
+    private static final int MAX_CYCLE_WARNINGS = 100;
+    // low enough that the ~3 stack frames per level cannot overflow a default thread stack
+    private static final int MAX_WALK_DEPTH = 500;
     private static final String P = "p";
     /**
      * See spec MS-ONE - 2.3.1 - TIME32 - epoch of jan 1 1980 UTC.
@@ -102,6 +110,14 @@ class OneNoteTreeWalker {
      * Contains pairs of {Offset,Length} that we have added to the text stream already.
      */
     private final Set<Pair<Long, Integer>> textAlreadyFetched = new HashSet<>();
+    private final Set<FileNode> activeFileNodes =
+            Collections.newSetFromMap(new IdentityHashMap<>());
+    private final Set<FileNode> reportedCycleNodes =
+            Collections.newSetFromMap(new IdentityHashMap<>());
+    private int cycleWarningCount;
+    private boolean cycleWarningsSuppressed;
+    private int walkDepth;
+    private boolean depthWarningRecorded;
 
     /**
      * Create a one tree walker.
@@ -290,6 +306,68 @@ class OneNoteTreeWalker {
     public Map<String, Object> walkFileNode(FileNode fileNode,
                                             OneNotePropertyId parentPropertyId)
             throws IOException, TikaException, SAXException {
+        if (fileNode == null) {
+            return Collections.emptyMap();
+        }
+        if (walkDepth >= MAX_WALK_DEPTH) {
+            // the cycle guard cannot catch a long acyclic chain - cap the recursion depth
+            // so a crafted file cannot overflow the stack
+            recordDepthWarning();
+            return Collections.emptyMap();
+        }
+        if (!activeFileNodes.add(fileNode)) {
+            recordCycleWarning(fileNode);
+            return Collections.emptyMap();
+        }
+        walkDepth++;
+        try {
+            return walkFileNodeInternal(fileNode, parentPropertyId);
+        } finally {
+            walkDepth--;
+            activeFileNodes.remove(fileNode);
+        }
+    }
+
+    private void recordDepthWarning() {
+        if (depthWarningRecorded) {
+            return;
+        }
+        depthWarningRecorded = true;
+        String warning = "OneNote file-node traversal exceeded depth limit " + MAX_WALK_DEPTH;
+        LOG.warn(warning);
+        if (parentMetadata != null) {
+            parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        }
+    }
+
+    private void recordCycleWarning(FileNode fileNode) {
+        if (reportedCycleNodes.contains(fileNode)) {
+            return;
+        }
+        if (cycleWarningCount >= MAX_CYCLE_WARNINGS) {
+            if (!cycleWarningsSuppressed) {
+                cycleWarningsSuppressed = true;
+                String warning = "Additional OneNote file-node cycle warnings were suppressed " +
+                        "after " + MAX_CYCLE_WARNINGS + " distinct cycles";
+                LOG.warn(warning);
+                if (parentMetadata != null) {
+                    parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+                }
+            }
+            return;
+        }
+        reportedCycleNodes.add(fileNode);
+        cycleWarningCount++;
+        String warning = "OneNote file-node cycle detected at " + fileNode.gosid;
+        LOG.warn(warning);
+        if (parentMetadata != null) {
+            parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        }
+    }
+
+    private Map<String, Object> walkFileNodeInternal(FileNode fileNode,
+                                                      OneNotePropertyId parentPropertyId)
+            throws IOException, TikaException, SAXException {
         Map<String, Object> structure = new HashMap<>();
         structure.put("oneNoteType", "FileNode");
         structure.put("gosid", fileNode.gosid.toString());
@@ -312,7 +390,7 @@ class OneNoteTreeWalker {
         if (fileNode.subType.fileDataStoreObjectReference.ref != null && !FileChunkReference.nil()
                 .equals(fileNode.subType.fileDataStoreObjectReference.ref.fileData)) {
             structure.put("fileDataStoreObjectReference", walkFileDataStoreObjectReference(
-                    fileNode.subType.fileDataStoreObjectReference));
+                    fileNode.subType.fileDataStoreObjectReference, fileNode.gosid));
         }
         return structure;
     }
@@ -325,7 +403,7 @@ class OneNoteTreeWalker {
      * @throws IOException Can throw these when manipulating the seekable byte channel.
      */
     private Map<String, Object> walkFileDataStoreObjectReference(
-            FileDataStoreObjectReference fileDataStoreObjectReference)
+            FileDataStoreObjectReference fileDataStoreObjectReference, ExtendedGUID gosid)
             throws IOException, SAXException, TikaException {
         Map<String, Object> structure = new HashMap<>();
         OneNotePtr content = new OneNotePtr(oneNoteDocument, dif);
@@ -335,12 +413,13 @@ class OneNoteTreeWalker {
                     "File data store cb " + fileDataStoreObjectReference.ref.fileData.cb +
                             " exceeds document size: " + dif.size());
         }
-        handleEmbedded((int) fileDataStoreObjectReference.ref.fileData.cb);
+        handleEmbedded((int) fileDataStoreObjectReference.ref.fileData.cb, gosid);
         structure.put("fileDataStoreObjectMetadata", fileDataStoreObjectReference);
         return structure;
     }
 
-    private void handleEmbedded(int length) throws TikaException, IOException, SAXException {
+    private void handleEmbedded(int length, ExtendedGUID gosid)
+            throws TikaException, IOException, SAXException {
         TikaInputStream stream = null;
         ByteBuffer buf;
         try {
@@ -352,14 +431,21 @@ class OneNoteTreeWalker {
             return;
         }
         Metadata embeddedMetadata = new Metadata();
+        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString());
+        if (gosid != null && !ExtendedGUID.nil().equals(gosid)) {
+            embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID, gosid.toString());
+        }
         try {
             AttributesImpl attributes = new AttributesImpl();
             attributes.addAttribute("", "class", "class", "CDATA", "embedded");
             xhtml.startElement("div", attributes);
             xhtml.endElement("div");
-            stream = TikaInputStream.get(buf.array());
-            embeddedDocumentExtractor.parseEmbedded(stream, new EmbeddedContentHandler(xhtml),
-                    embeddedMetadata, false);
+            if (embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata)) {
+                stream = TikaInputStream.get(buf.array());
+                embeddedDocumentExtractor.parseEmbedded(stream, new EmbeddedContentHandler(xhtml),
+                        embeddedMetadata, false);
+            }
         } finally {
             IOUtils.closeQuietly(stream);
         }
@@ -471,8 +557,11 @@ class OneNoteTreeWalker {
                 propMap.put("dataUnicode16LE", new String(buf.array(), StandardCharsets.UTF_16LE));
                 if (options.getUtf16PropertiesToPrint().contains(propertyValue.propertyId.propertyEnum)) {
                     xhtml.startElement(P);
-                    xhtml.characters((String) propMap.get("dataUnicode16LE"));
-                    xhtml.endElement(P);
+                    try {
+                        xhtml.characters((String) propMap.get("dataUnicode16LE"));
+                    } finally {
+                        xhtml.endElement(P);
+                    }
                 }
             } else if (propertyValue.propertyId.propertyEnum ==
                     OneNotePropertyEnum.TextExtendedAscii) {
@@ -485,8 +574,11 @@ class OneNoteTreeWalker {
                 dif.read(buf);
                 propMap.put("dataAscii", new String(buf.array(), StandardCharsets.US_ASCII));
                 xhtml.startElement(P);
-                xhtml.characters((String) propMap.get("dataAscii"));
-                xhtml.endElement(P);
+                try {
+                    xhtml.characters((String) propMap.get("dataAscii"));
+                } finally {
+                    xhtml.endElement(P);
+                }
             } else if (!isBinary) {
                 if (content.size() > dif.size()) {
                     throw new TikaMemoryLimitException(
@@ -498,8 +590,11 @@ class OneNoteTreeWalker {
                 propMap.put("dataUnicode16LE", new String(buf.array(), StandardCharsets.UTF_16LE));
                 if (options.getUtf16PropertiesToPrint().contains(propertyValue.propertyId.propertyEnum)) {
                     xhtml.startElement(P);
-                    xhtml.characters((String) propMap.get("dataUnicode16LE"));
-                    xhtml.endElement(P);
+                    try {
+                        xhtml.characters((String) propMap.get("dataUnicode16LE"));
+                    } finally {
+                        xhtml.endElement(P);
+                    }
                 }
             } else {
                 if (content.size() > dif.size()) {
@@ -591,12 +686,18 @@ class OneNoteTreeWalker {
         Matcher m = HYPERLINK_PATTERN.matcher(txt);
         if (m.find()) {
             xhtml.startElement("a", "href", m.group(1));
-            xhtml.characters(m.group(2));
-            xhtml.endElement("a");
+            try {
+                xhtml.characters(m.group(2));
+            } finally {
+                xhtml.endElement("a");
+            }
         } else {
             xhtml.startElement(P);
-            xhtml.characters(txt);
-            xhtml.endElement(P);
+            try {
+                xhtml.characters(txt);
+            } finally {
+                xhtml.endElement(P);
+            }
         }
     }
 
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
index 3b8ad38227..f459522ba9 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
@@ -25,27 +25,44 @@ import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.xml.sax.SAXException;
+import org.xml.sax.helpers.AttributesImpl;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Property;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.microsoft.onenote.OneNotePropertyEnum;
 import org.apache.tika.parser.microsoft.onenote.OneNoteTreeWalkerOptions;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.ArrayNumber;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.EightBytesOfData;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.FourBytesOfData;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.IProperty;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtArrayOfPropertyValues;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtFourBytesOfLengthFollowedByData;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.CellManifestDataElementData;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySet;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestRootDeclare;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreCell;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObject;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObjectGroup;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexCellMapping;
@@ -59,6 +76,7 @@ import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.Propert
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyType;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.unsigned.Unsigned;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.BitConverter;
+import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 
 public class MSOneStorePackage {
@@ -76,7 +94,11 @@ public class MSOneStorePackage {
     private static final long DATETIME_EPOCH_DIFF_1601;
     private static final Pattern HYPERLINK_PATTERN =
             Pattern.compile("\uFDDFHYPERLINK\\s+\"([^\"]+)\"([^\"]+)$");
+    private static final Logger LOG = LoggerFactory.getLogger(MSOneStorePackage.class);
     private static final String P = "p";
+    private static final int MAX_OBJECT_WALK_DEPTH = 1000;
+    private static final int MAX_REFERENCE_COUNT = 100000;
+    private static final int MAX_PARSE_WARNINGS = 100;
 
     static {
         LocalDateTime time32Epoch1980 = LocalDateTime.of(1980, Month.JANUARY, 1, 0, 0);
@@ -93,6 +115,10 @@ public class MSOneStorePackage {
     private final Set<String> authors = new HashSet<>();
     private final Set<String> mostRecentAuthors = new HashSet<>();
     private final Set<String> originalAuthors = new HashSet<>();
+    /**
+     * The fully populated storage index. Set this before performing storage-index lookups; its
+     * mapping lists are indexed once and must not be mutated afterward.
+     */
     public StorageIndexDataElementData storageIndex;
     public StorageManifestDataElementData storageManifest;
     public CellManifestDataElementData headerCellCellManifest;
@@ -100,52 +126,75 @@ public class MSOneStorePackage {
     public List<RevisionManifestDataElementData> revisionManifests;
     public List<CellManifestDataElementData> cellManifests;
     public HeaderCell headerCell;
-    public List<RevisionStoreObjectGroup> dataRoot;
+    public RevisionStoreCell dataRootCell;
     public List<RevisionStoreObjectGroup> OtherFileNodeList;
-    private boolean mostRecentAuthorProp = false;
-    private boolean originalAuthorProp = false;
+    /**
+     * The content cells (object spaces, e.g. pages), each with its object groups and the
+     * root object declarations of its current revision.
+     */
+    public List<RevisionStoreCell> cells;
     private Instant lastModifiedTimestamp = Instant.MIN;
     private long creationTimestamp = Long.MAX_VALUE;
     private long lastModified = Long.MIN_VALUE;
+    private ParseContext parseContext;
+    private EmbeddedDocumentExtractor embeddedDocumentExtractor;
+    private Metadata parentMetadata;
+    private final List<String> parseWarnings = new ArrayList<>();
+    // This state intentionally spans parser construction and tree walking for one package.
+    private final Set<String> recordedParseWarningKeys = new HashSet<>();
+    private boolean parseWarningsSuppressed;
+    private boolean storageMappingsIndexed;
+    private boolean contentEmitted;
+    // flattened property actions per object; objects can be re-flattened many times during
+    // picture/resource-name resolution, which is quadratic without this cache
+    private final Map<RevisionStoreObject, List<PropertyAction>> objectActionsCache =
+            new IdentityHashMap<>();
+    private final Map<CellID, StorageIndexCellMapping> storageIndexCellMappingsById =
+            new HashMap<>();
+    private final Map<ExGuid, StorageIndexRevisionMapping> storageIndexRevisionMappingsById =
+            new HashMap<>();
 
     public MSOneStorePackage() {
         this.revisionManifests = new ArrayList<>();
         this.cellManifests = new ArrayList<>();
         this.OtherFileNodeList = new ArrayList<>();
+        this.cells = new ArrayList<>();
     }
 
     /**
      * This method is used to find the Storage Index Cell Mapping matches the Cell ID.
      *
      * @param cellID Specify the Cell ID.
-     * @return Return the specific Storage Index Cell Mapping.
+     * @return the specific Storage Index Cell Mapping, or {@code null} if it is absent.
      */
     public StorageIndexCellMapping findStorageIndexCellMapping(CellID cellID) {
-        StorageIndexCellMapping storageIndexCellMapping = null;
-        if (this.storageIndex != null) {
-            storageIndexCellMapping = this.storageIndex.storageIndexCellMappingList.stream()
-                    .filter(s -> s.cellID.equals(cellID)).findFirst()
-                    .orElse(new StorageIndexCellMapping());
-        }
-        return storageIndexCellMapping;
+        indexStorageMappings();
+        return storageIndexCellMappingsById.get(cellID);
     }
 
     /**
      * This method is used to find the Storage Index Revision Mapping that matches the Revision Mapping Extended GUID.
      *
      * @param revisionExtendedGUID Specify the Revision Mapping Extended GUID.
-     * @return Return the instance of Storage Index Revision Mapping.
+     * @return the instance of Storage Index Revision Mapping, or {@code null} if it is absent.
      */
     public StorageIndexRevisionMapping findStorageIndexRevisionMapping(
             ExGuid revisionExtendedGUID) {
-        StorageIndexRevisionMapping instance = null;
-        if (this.storageIndex != null) {
-            instance = this.storageIndex.storageIndexRevisionMappingList.stream()
-                    .filter(r -> r.revisionExGuid.equals(revisionExtendedGUID)).findFirst()
-                    .orElse(new StorageIndexRevisionMapping());
-        }
+        indexStorageMappings();
+        return storageIndexRevisionMappingsById.get(revisionExtendedGUID);
+    }
 
-        return instance;
+    private void indexStorageMappings() {
+        if (storageMappingsIndexed || storageIndex == null) {
+            return;
+        }
+        storageMappingsIndexed = true;
+        for (StorageIndexCellMapping mapping : storageIndex.storageIndexCellMappingList) {
+            storageIndexCellMappingsById.putIfAbsent(mapping.cellID, mapping);
+        }
+        for (StorageIndexRevisionMapping mapping : storageIndex.storageIndexRevisionMappingList) {
+            storageIndexRevisionMappingsById.putIfAbsent(mapping.revisionExGuid, mapping);
+        }
     }
 
     /**
@@ -160,112 +209,810 @@ public class MSOneStorePackage {
                 property == OneNotePropertyEnum.RichEditTextUnicode;
     }
 
+    /**
+     * The attribution of an Author property, determined by the property through which the
+     * author object was referenced.
+     */
+    private enum AuthorRole {
+        NONE, MOST_RECENT, ORIGINAL
+    }
+
+    private enum ParseWarningKind {
+        DEFAULT,
+        OBJECT_REFERENCE_ARRAY_UNAVAILABLE,
+        OBJECT_REFERENCE_ARRAY_CAPPED,
+        OBJECT_SPACE_REFERENCE_ARRAY_UNAVAILABLE,
+        OBJECT_SPACE_REFERENCE_ARRAY_CAPPED
+    }
+
     public void walkTree(OneNoteTreeWalkerOptions options, Metadata metadata,
-                         XHTMLContentHandler xhtml)
+                         XHTMLContentHandler xhtml, ParseContext parseContext)
             throws SAXException, TikaException, IOException {
-        for (RevisionStoreObjectGroup revisionStoreObjectGroup : OtherFileNodeList) {
-            for (RevisionStoreObject revisionStoreObject : revisionStoreObjectGroup.objects) {
-                PropertySet propertySet =
-                        revisionStoreObject.propertySet.objectSpaceObjectPropSet.body;
-                for (int i = 0; i < propertySet.rgData.size(); ++i) {
-                    IProperty property = propertySet.rgData.get(i);
-                    PropertyID propertyID = propertySet.rgPrids[i];
-                    PropertyType propertyType = PropertyType.fromIntVal(propertyID.type);
-                    OneNotePropertyEnum oneNotePropertyEnum =
-                            OneNotePropertyEnum.of(Unsigned.uint(propertyID.value).longValue());
-                    if (oneNotePropertyEnum == OneNotePropertyEnum.LastModifiedTimeStamp) {
-                        long fullval = getScalar(property);
-                        Instant instant = Instant.ofEpochSecond(
-                                fullval / 10000000 + DATETIME_EPOCH_DIFF_1601);
-                        if (instant.isAfter(lastModifiedTimestamp)) {
-                            lastModifiedTimestamp = instant;
-                        }
-                        metadata.set(ONE_NOTE_PREFIX + "lastModifiedTimestamp",
-                                String.valueOf(lastModifiedTimestamp.toEpochMilli()));
-                    } else if (oneNotePropertyEnum == OneNotePropertyEnum.CreationTimeStamp) {
-                        // add the TIME32_EPOCH_DIFF_1980 because OneNote TIME32 epoch time is per 1980, not
-                        // 1970
-                        long scalar = getScalar(property);
-                        long creationTs = scalar + TIME32_EPOCH_DIFF_1980;
-                        if (creationTs < creationTimestamp) {
-                            creationTimestamp = creationTs;
-                        }
-                        metadata.set(ONE_NOTE_PREFIX + "creationTimestamp", String.valueOf(creationTimestamp));
-                    } else if (oneNotePropertyEnum == OneNotePropertyEnum.LastModifiedTime) {
-                        // add the TIME32_EPOCH_DIFF_1980 because OneNote TIME32 epoch time is per 1980, not
-                        // 1970
-                        long scalar = getScalar(property);
-                        long lastMod = scalar + TIME32_EPOCH_DIFF_1980;
-                        if (lastMod > lastModified) {
-                            lastModified = lastMod;
-                        }
-                        metadata.set(TikaCoreProperties.MODIFIED, String.valueOf(lastModified));
-                    } else if (oneNotePropertyEnum == OneNotePropertyEnum.Author) {
-                        String author =
-                                new String(((PrtFourBytesOfLengthFollowedByData) property).data,
-                                        StandardCharsets.UTF_8);
-                        if (mostRecentAuthorProp) {
-                            mostRecentAuthors.add(author);
-                        } else if (originalAuthorProp) {
-                            originalAuthors.add(author);
-                        } else {
-                            authors.add(author);
-                        }
-                    } else if (oneNotePropertyEnum == OneNotePropertyEnum.AuthorMostRecent) {
-                        mostRecentAuthorProp = true;
-                    } else if (oneNotePropertyEnum == OneNotePropertyEnum.AuthorOriginal) {
-                        originalAuthorProp = true;
-                    } else if (propertyType == PropertyType.FourBytesOfLengthFollowedByData) {
-                        boolean isBinary = propertyIsBinary(oneNotePropertyEnum);
-                        PrtFourBytesOfLengthFollowedByData dataProperty =
-                                (PrtFourBytesOfLengthFollowedByData) property;
-                        if ((dataProperty.data.length & 1) == 0 &&
-                                oneNotePropertyEnum != OneNotePropertyEnum.TextExtendedAscii &&
-                                !isBinary) {
-                            if (options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
-                                xhtml.startElement(P);
-                                xhtml.characters(
-                                        new String(dataProperty.data, StandardCharsets.UTF_16LE));
-                                xhtml.endElement(P);
-                            }
-                        } else if (oneNotePropertyEnum == OneNotePropertyEnum.TextExtendedAscii) {
-                            xhtml.startElement(P);
-                            xhtml.characters(
-                                    new String(dataProperty.data, StandardCharsets.US_ASCII));
-                            xhtml.endElement(P);
-                        } else if (!isBinary) {
-                            if (options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
-                                xhtml.startElement(P);
-                                xhtml.characters(
-                                        new String(dataProperty.data, StandardCharsets.UTF_16LE));
-                                xhtml.endElement(P);
-                            }
-                        } else {
-                            if (oneNotePropertyEnum == OneNotePropertyEnum.RichEditTextUnicode) {
-                                handleRichEditTextUnicode(dataProperty.data, xhtml);
-                            } else {
-                                //TODO -- these seem to be somewhat broken font files and other
-                                //odds and ends...what are they and how should we process them?
-                                //handleEmbedded(content.size());
-                            }
-                        }
-                    }
+        this.parseContext = parseContext;
+        this.parentMetadata = metadata;
+        this.embeddedDocumentExtractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(parseContext);
+        for (String warning : parseWarnings) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        }
+        parseWarnings.clear();
+        if (!cells.isEmpty()) {
+            // Walk each page cell (object space) as a tree, starting from the root objects of
+            // its current revision and following the object references in property order. This
+            // emits the text in document order. The pages are walked in the order in which the
+            // section object space references them; cells that hold older versions of a page
+            // (the same object space in a different revision context) are skipped.
+            List<RevisionStoreCell> pageCells = new ArrayList<>();
+            List<RevisionStoreCell> otherCells = new ArrayList<>();
+            splitCells(pageCells, otherCells);
+            for (RevisionStoreCell cell : pageCells) {
+                xhtml.startElement("div", "class", "page");
+                try {
+                    walkCell(cell, options, metadata, xhtml);
+                } finally {
+                    xhtml.endElement("div");
+                }
+            }
+            for (RevisionStoreCell cell : otherCells) {
+                walkCell(cell, options, metadata, xhtml);
+            }
+        } else {
+            // no cell information available - walk the object groups in revision order
+            Map<ExGuid, RevisionStoreObject> objectsById = indexObjectsById(OtherFileNodeList);
+            Set<ExGuid> visited = new HashSet<>();
+            for (RevisionStoreObjectGroup objectGroup : OtherFileNodeList) {
+                for (RevisionStoreObject object : objectGroup.objects) {
+                    walkObject(object, objectsById, visited, AuthorRole.NONE, options, metadata,
+                            xhtml, 0);
                 }
             }
         }
         if (!authors.isEmpty()) {
-            metadata.set(TikaCoreProperties.CREATOR, authors.toArray(new String[]{}));
+            metadata.set(TikaCoreProperties.CREATOR, sortedValues(authors));
         }
         if (!mostRecentAuthors.isEmpty()) {
             metadata.set(Property.externalTextBag(ONE_NOTE_PREFIX + "mostRecentAuthors"),
-                    mostRecentAuthors.toArray(new String[]{}));
+                    sortedValues(mostRecentAuthors));
         }
         if (!originalAuthors.isEmpty()) {
             metadata.set(Property.externalTextBag(ONE_NOTE_PREFIX + "originalAuthors"),
-                    originalAuthors.toArray(new String[]{}));
+                    sortedValues(originalAuthors));
+        }
+    }
+
+    /**
+     * Splits the cells into page cells, ordered as the section object space references them,
+     * and the remaining cells. A cell that holds an older version of a page - the same object
+     * space referenced by a page cell, but in a different revision context - is dropped, so
+     * content is not emitted once per version snapshot.
+     */
+    private void splitCells(List<RevisionStoreCell> pageCells,
+                            List<RevisionStoreCell> otherCells) {
+        if (dataRootCell == null) {
+            // Without a data root there is no reliable page ordering information.
+            otherCells.addAll(cells);
+            return;
+        }
+        List<CellID> orderedCellIds = collectSectionReferencedCells();
+        if (orderedCellIds.isEmpty()) {
+            // no page ordering information available - process the cells in storage order
+            otherCells.addAll(cells);
+            return;
+        }
+        Map<CellID, RevisionStoreCell> remainingCells = new LinkedHashMap<>();
+        for (RevisionStoreCell cell : cells) {
+            remainingCells.put(cell.cellID, cell);
+        }
+        Set<ExGuid> coveredObjectSpaces = new HashSet<>();
+        for (CellID cellId : orderedCellIds) {
+            RevisionStoreCell cell = remainingCells.remove(cellId);
+            if (cell != null) {
+                pageCells.add(cell);
+                if (cellId.extendGUID2 != null) {
+                    coveredObjectSpaces.add(cellId.extendGUID2);
+                }
+            }
+        }
+        for (RevisionStoreCell cell : remainingCells.values()) {
+            if (!coveredObjectSpaces.contains(cell.cellID.extendGUID2)) {
+                // not an older version of one of the pages - keep it so no content is lost
+                otherCells.add(cell);
+            }
+        }
+    }
+
+    /**
+     * Walks the section object space (the data root cell) and collects the object space (cell)
+     * references in document order - this is the order of the pages in the section.
+     */
+    private List<CellID> collectSectionReferencedCells() {
+        List<CellID> orderedCellIds = new ArrayList<>();
+        if (dataRootCell == null) {
+            return orderedCellIds;
+        }
+        Map<ExGuid, RevisionStoreObject> objectsById = indexObjectsById(dataRootCell.objectGroups);
+        Set<ExGuid> visited = new HashSet<>();
+        for (RevisionManifestRootDeclare rootDeclare : dataRootCell.rootDeclares) {
+            RevisionStoreObject rootObject = objectsById.get(rootDeclare.objectExGuid);
+            if (rootObject == null) {
+                recordParseWarning("OneNote section root object " + rootDeclare.objectExGuid +
+                        " could not be resolved");
+            }
+            collectReferencedCells(rootObject, objectsById, visited, orderedCellIds, 0);
+        }
+        return orderedCellIds;
+    }
+
+    private void collectReferencedCells(RevisionStoreObject object,
+                                        Map<ExGuid, RevisionStoreObject> objectsById,
+                                        Set<ExGuid> visited, List<CellID> out, int depth) {
+        if (object == null || object.propertySet == null ||
+                object.propertySet.objectSpaceObjectPropSet == null) {
+            return;
+        }
+        if (depth >= MAX_OBJECT_WALK_DEPTH) {
+            recordParseWarning("OneNote section reference traversal exceeded depth limit " +
+                    MAX_OBJECT_WALK_DEPTH);
+            return;
+        }
+        if (object.objectID != null && !visited.add(object.objectID)) {
+            return;
+        }
+        List<PropertyAction> actions = collectObjectActions(object);
+        for (PropertyAction action : actions) {
+            if (action.spaceReference != null) {
+                out.add(action.spaceReference);
+            } else if (action.isChildReference && action.childReference != null) {
+                RevisionStoreObject child = objectsById.get(action.childReference);
+                if (child == null && !warningsSaturated()) {
+                    recordParseWarning("OneNote section object " + action.childReference +
+                            " could not be resolved");
+                }
+                collectReferencedCells(child, objectsById, visited, out, depth + 1);
+            }
+        }
+    }
+
+    private void walkCell(RevisionStoreCell cell, OneNoteTreeWalkerOptions options,
+                          Metadata metadata, XHTMLContentHandler xhtml)
+            throws SAXException, TikaException, IOException {
+        Map<ExGuid, RevisionStoreObject> objectsById = indexObjectsById(cell.objectGroups);
+        Set<ExGuid> visited = new HashSet<>();
+        // Only objects reachable from the root objects of the current revision are part of
+        // the current content. The object groups may also contain older, superseded versions
+        // of objects (under a different object ID); those are intentionally not walked.
+        // A blob-only root (no property set) cannot reach the page body, so it does not
+        // count as a resolved content root: if the content root declare dangles next to it,
+        // the walk-everything fallback must still fire or the page body is silently lost.
+        boolean resolvedContentRoot = false;
+        boolean unresolvedRoot = false;
+        for (RevisionManifestRootDeclare rootDeclare : cell.rootDeclares) {
+            RevisionStoreObject rootObject = objectsById.get(rootDeclare.objectExGuid);
+            if (rootObject == null) {
+                recordParseWarning("OneNote cell root object " + rootDeclare.objectExGuid +
+                        " could not be resolved");
+                unresolvedRoot = true;
+            } else {
+                if (rootObject.propertySet != null) {
+                    resolvedContentRoot = true;
+                }
+                walkObject(rootObject, objectsById, visited, AuthorRole.NONE, options,
+                        metadata, xhtml, 0);
+            }
+        }
+        if (cell.rootDeclares.isEmpty() || (unresolvedRoot && !resolvedContentRoot)) {
+            if (cell.rootDeclares.isEmpty()) {
+                recordParseWarning("OneNote cell has no declared root objects; walking all objects");
+            } else {
+                recordParseWarning("OneNote cell root objects could not be resolved; walking all objects");
+            }
+            for (RevisionStoreObjectGroup objectGroup : cell.objectGroups) {
+                for (RevisionStoreObject object : objectGroup.objects) {
+                    walkObject(object, objectsById, visited, AuthorRole.NONE, options, metadata,
+                            xhtml, 0);
+                }
+            }
+        }
+    }
+
+    private String[] sortedValues(Set<String> values) {
+        String[] sorted = values.toArray(new String[0]);
+        Arrays.sort(sorted);
+        return sorted;
+    }
+
+    private boolean warningsSaturated() {
+        return parseWarningsSuppressed;
+    }
+
+    void recordParseWarning(String warning) {
+        recordParseWarning(ParseWarningKind.DEFAULT, warning);
+    }
+
+    private void recordParseWarning(ParseWarningKind kind, String warning) {
+        String warningKey = kind == ParseWarningKind.DEFAULT ? warning : kind.name();
+        if (recordedParseWarningKeys.contains(warningKey)) {
+            return;
+        }
+        if (recordedParseWarningKeys.size() >= MAX_PARSE_WARNINGS) {
+            if (!parseWarningsSuppressed) {
+                parseWarningsSuppressed = true;
+                emitParseWarning("Additional OneNote parse warnings were suppressed after " +
+                        MAX_PARSE_WARNINGS + " distinct warnings");
+            }
+            return;
+        }
+        recordedParseWarningKeys.add(warningKey);
+        emitParseWarning(warning);
+    }
+
+    private void emitParseWarning(String warning) {
+        LOG.warn(warning);
+        if (parentMetadata == null) {
+            parseWarnings.add(warning);
+        } else {
+            parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        }
+    }
+
+    /**
+     * Builds a map of object ID to object. The object groups are ordered from the oldest
+     * revision to the newest, so a newer version of an object wins over an older one.
+     */
+    private Map<ExGuid, RevisionStoreObject> indexObjectsById(
+            List<RevisionStoreObjectGroup> objectGroups) {
+        Map<ExGuid, RevisionStoreObject> objectsById = new HashMap<>();
+        for (RevisionStoreObjectGroup objectGroup : objectGroups) {
+            for (RevisionStoreObject object : objectGroup.objects) {
+                if (object.objectID != null) {
+                    objectsById.put(object.objectID, object);
+                }
+            }
+        }
+        return objectsById;
+    }
+
+    private void walkObject(RevisionStoreObject object,
+                             Map<ExGuid, RevisionStoreObject> objectsById, Set<ExGuid> visited,
+                             AuthorRole authorRole, OneNoteTreeWalkerOptions options,
+                             Metadata metadata, XHTMLContentHandler xhtml, int depth)
+            throws SAXException, TikaException, IOException {
+        walkObject(object, objectsById, visited, authorRole, options, metadata, xhtml,
+                depth, null);
+    }
+
+    private void walkObject(RevisionStoreObject object,
+                             Map<ExGuid, RevisionStoreObject> objectsById, Set<ExGuid> visited,
+                             AuthorRole authorRole, OneNoteTreeWalkerOptions options,
+                             Metadata metadata, XHTMLContentHandler xhtml, int depth,
+                             EmbeddedResourceInfo inheritedResourceInfo)
+            throws SAXException, TikaException, IOException {
+        if (object == null) {
+            return;
+        }
+        if (depth >= MAX_OBJECT_WALK_DEPTH) {
+            recordParseWarning("OneNote object traversal exceeded depth limit " +
+                    MAX_OBJECT_WALK_DEPTH);
+            return;
+        }
+        if (object.objectID != null && !visited.add(object.objectID)) {
+            // one object may be referenced under several author roles (e.g. the same author
+            // as both AuthorOriginal and AuthorMostRecent) - record the role even though
+            // the subtree is not walked again
+            recordAuthors(object, authorRole);
+            return;
+        }
+        List<PropertyAction> actions = object.propertySet != null &&
+                object.propertySet.objectSpaceObjectPropSet != null ?
+                collectObjectActions(object) : Collections.emptyList();
+        EmbeddedResourceInfo resourceInfo = embeddedResourceInfo(actions);
+        if (resourceInfo == null) {
+            resourceInfo = inheritedResourceInfo;
+        }
+        if (resourceInfo == null && object.fileDataObject != null && object.objectID != null) {
+            resourceInfo = resourceInfoFromReferencingObject(object, objectsById, depth);
+        }
+        if (object.fileDataObject != null) {
+            // the object carries opaque binary data, e.g. an embedded image or file
+            handleEmbedded(object.fileDataObject.getData(), xhtml, resourceInfo, object.objectID);
+        }
+        if (object.propertySet == null ||
+                object.propertySet.objectSpaceObjectPropSet == null) {
+            return;
+        }
+        // An image node can reference the same picture twice: PictureContainer holds the
+        // canonical image data and WebPictureContainer14 holds a rendition derived from it
+        // (e.g. re-rendered when the picture was resized). Only emit the derived rendition
+        // when the canonical container is missing, so the picture is not extracted twice.
+        boolean hasPrimaryPicture = false;
+        for (PropertyAction action : actions) {
+            if (action.isChildReference && action.childReference != null &&
+                    action.oneNotePropertyEnum == OneNotePropertyEnum.PictureContainer &&
+                    hasUsablePicture(action.childReference, objectsById, new HashSet<>(), depth)) {
+                hasPrimaryPicture = true;
+                break;
+            }
+        }
+        // The title structure of a page (StructureElementChildNodes) appears above the page
+        // body on screen, but is declared after the body child nodes. Emit it first so the
+        // text comes out in visual order.
+        for (PropertyAction action : actions) {
+            if (action.oneNotePropertyEnum == OneNotePropertyEnum.StructureElementChildNodes) {
+                processAction(action, objectsById, visited, authorRole, options, metadata, xhtml,
+                        depth, resourceInfo);
+            }
+        }
+        for (PropertyAction action : actions) {
+            if (hasPrimaryPicture &&
+                    action.oneNotePropertyEnum == OneNotePropertyEnum.WebPictureContainer14) {
+                continue;
+            }
+            if (action.oneNotePropertyEnum != OneNotePropertyEnum.StructureElementChildNodes) {
+                processAction(action, objectsById, visited, authorRole, options, metadata, xhtml,
+                        depth, resourceInfo);
+            }
+        }
+    }
+
+    private boolean hasUsablePicture(ExGuid objectId,
+                                      Map<ExGuid, RevisionStoreObject> objectsById,
+                                      Set<ExGuid> visited, int depth) {
+        if (depth >= MAX_OBJECT_WALK_DEPTH || objectId == null || !visited.add(objectId)) {
+            return false;
+        }
+        RevisionStoreObject object = objectsById.get(objectId);
+        if (object == null) {
+            return false;
+        }
+        byte[] data = object.fileDataObject == null ? null : object.fileDataObject.getData();
+        if (data != null && data.length > 0) {
+            return true;
+        }
+        if (object.propertySet == null || object.propertySet.objectSpaceObjectPropSet == null) {
+            return false;
+        }
+        for (PropertyAction action : collectObjectActions(object)) {
+            if (action.isChildReference && action.childReference != null &&
+                    hasUsablePicture(action.childReference, objectsById, visited, depth + 1)) {
+                return true;
+            }
         }
+        return false;
     }
 
+    private EmbeddedResourceInfo resourceInfoFromReferencingObject(
+            RevisionStoreObject target, Map<ExGuid, RevisionStoreObject> objectsById, int depth) {
+        if (depth >= MAX_OBJECT_WALK_DEPTH) {
+            return null;
+        }
+        EmbeddedResourceInfo typeOnlyFallback = null;
+        for (RevisionStoreObject candidate : objectsById.values()) {
+            if (candidate.propertySet == null ||
+                    candidate.propertySet.objectSpaceObjectPropSet == null) {
+                continue;
+            }
+            List<PropertyAction> candidateActions = collectObjectActions(candidate);
+            for (PropertyAction action : candidateActions) {
+                if (action.isChildReference && target.objectID.equals(action.childReference) &&
+                        (action.oneNotePropertyEnum == OneNotePropertyEnum.PictureContainer ||
+                                action.oneNotePropertyEnum == OneNotePropertyEnum.EmbeddedFileContainer ||
+                                action.oneNotePropertyEnum == OneNotePropertyEnum.WebPictureContainer14)) {
+                    EmbeddedResourceInfo info = embeddedResourceInfo(candidateActions);
+                    if (info != null) {
+                        return info;
+                    }
+                    if (typeOnlyFallback == null) {
+                        typeOnlyFallback = resourceInfoForChild(action, null);
+                    }
+                }
+            }
+        }
+        return typeOnlyFallback;
+    }
+
+    private EmbeddedResourceInfo resourceInfoForChild(PropertyAction action,
+                                                       EmbeddedResourceInfo parentInfo) {
+        if (action.oneNotePropertyEnum == OneNotePropertyEnum.PictureContainer ||
+                action.oneNotePropertyEnum == OneNotePropertyEnum.WebPictureContainer14) {
+            return parentInfo == null ? new EmbeddedResourceInfo(null,
+                    TikaCoreProperties.EmbeddedResourceType.INLINE.toString()) : parentInfo;
+        }
+        if (action.oneNotePropertyEnum == OneNotePropertyEnum.EmbeddedFileContainer) {
+            return parentInfo == null ? new EmbeddedResourceInfo(null,
+                    TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString()) : parentInfo;
+        }
+        return parentInfo;
+    }
+
+    /**
+     * Metadata used when emitting an embedded resource.
+     */
+    private static final class EmbeddedResourceInfo {
+        private final String name;
+        private final String type;
+
+        private EmbeddedResourceInfo(String name, String type) {
+            this.name = name;
+            this.type = type;
+        }
+    }
+
+    /**
+     * A property of an object, together with the object reference assigned to it if it is
+     * an object reference property.
+     */
+    private static final class PropertyAction {
+        private final IProperty property;
+        private final PropertyType propertyType;
+        private final OneNotePropertyEnum oneNotePropertyEnum;
+        private final boolean isChildReference;
+        private final ExGuid childReference;
+        private final CellID spaceReference;
+
+        PropertyAction(IProperty property, PropertyType propertyType,
+                       OneNotePropertyEnum oneNotePropertyEnum, boolean isChildReference,
+                       ExGuid childReference, CellID spaceReference) {
+            this.property = property;
+            this.propertyType = propertyType;
+            this.oneNotePropertyEnum = oneNotePropertyEnum;
+            this.isChildReference = isChildReference;
+            this.childReference = childReference;
+            this.spaceReference = spaceReference;
+        }
+    }
+
+    /**
+     * Flattens the properties of an object, in order, into a list of actions. The result is
+     * cached per object; callers must not mutate the returned list.
+     */
+    private List<PropertyAction> collectObjectActions(RevisionStoreObject object) {
+        List<PropertyAction> actions = objectActionsCache.get(object);
+        if (actions != null) {
+            return actions;
+        }
+        List<ExGuid> referencedObjects =
+                object.referencedObjectID == null || object.referencedObjectID.content == null ?
+                        Collections.emptyList() : object.referencedObjectID.content;
+        List<CellID> referencedSpaces = object.referencedObjectSpacesID == null ||
+                object.referencedObjectSpacesID.content == null ? Collections.emptyList() :
+                object.referencedObjectSpacesID.content;
+        actions = new ArrayList<>();
+        collectActions(object.propertySet.objectSpaceObjectPropSet.body, referencedObjects,
+                new int[]{0}, referencedSpaces, new int[]{0}, actions, 0);
+        objectActionsCache.put(object, actions);
+        return actions;
+    }
+
+    /**
+     * Flattens the properties of a property set, in order, into a list of actions.
+     * Properties of type ObjectID or ArrayOfObjectIDs consume, in property order, the object
+     * references of the containing object, and properties of type ObjectSpaceID or
+     * ArrayOfObjectSpaceIDs consume the object space (cell) references (see MS-ONESTORE
+     * section 2.7.8), so the references must be assigned here, in property order, no matter
+     * in which order the actions are processed later.
+     */
+    private void collectActions(PropertySet propertySet, List<ExGuid> referencedObjects,
+                                int[] referenceCursor, List<CellID> referencedSpaces,
+                                int[] spaceCursor, List<PropertyAction> actions, int depth) {
+        if (propertySet == null || propertySet.rgPrids == null || propertySet.rgData == null) {
+            return;
+        }
+        if (depth >= MAX_OBJECT_WALK_DEPTH) {
+            recordParseWarning("OneNote property traversal exceeded depth limit " +
+                    MAX_OBJECT_WALK_DEPTH);
+            return;
+        }
+        for (int i = 0; i < propertySet.rgPrids.length && i < propertySet.rgData.size(); ++i) {
+            IProperty property = propertySet.rgData.get(i);
+            PropertyID propertyID = propertySet.rgPrids[i];
+            PropertyType propertyType = PropertyType.fromIntVal(propertyID.type);
+            OneNotePropertyEnum oneNotePropertyEnum =
+                    OneNotePropertyEnum.of(Unsigned.uint(propertyID.value).longValue());
+            if (propertyType == PropertyType.ObjectID) {
+                ExGuid childReference = nextReference(referencedObjects, referenceCursor);
+                if (childReference == null) {
+                    recordParseWarning("OneNote object reference slot was exhausted");
+                }
+                actions.add(new PropertyAction(property, propertyType, oneNotePropertyEnum, true,
+                        childReference, null));
+            } else if (propertyType == PropertyType.ArrayOfObjectIDs) {
+                int available = referencedObjects.size() - referenceCursor[0];
+                int requestedCount = property instanceof ArrayNumber ?
+                        Math.max(0, ((ArrayNumber) property).number) : 0;
+                int declaredCount = boundedAvailableReferenceCount(requestedCount, available);
+                int count = Math.min(declaredCount, MAX_REFERENCE_COUNT);
+                if (requestedCount > available && !warningsSaturated()) {
+                    recordParseWarning(ParseWarningKind.OBJECT_REFERENCE_ARRAY_UNAVAILABLE,
+                            "OneNote object reference array had unavailable entries " +
+                                    "(first occurrence: declared " + requestedCount +
+                                    " entries but only " + Math.max(available, 0) +
+                                    " were available)");
+                }
+                if (declaredCount > count && !warningsSaturated()) {
+                    recordParseWarning(ParseWarningKind.OBJECT_REFERENCE_ARRAY_CAPPED,
+                            "Capping OneNote object reference array at " +
+                                    MAX_REFERENCE_COUNT + " entries");
+                }
+                for (int j = 0; j < count; ++j) {
+                    actions.add(new PropertyAction(property, propertyType, oneNotePropertyEnum,
+                            true, nextReference(referencedObjects, referenceCursor), null));
+                }
+                referenceCursor[0] += declaredCount - count;
+            } else if (propertyType == PropertyType.ObjectSpaceID) {
+                CellID spaceReference = nextSpaceReference(referencedSpaces, spaceCursor);
+                if (spaceReference == null) {
+                    recordParseWarning("OneNote object-space reference slot was exhausted");
+                }
+                actions.add(new PropertyAction(property, propertyType, oneNotePropertyEnum, false,
+                        null, spaceReference));
+            } else if (propertyType == PropertyType.ArrayOfObjectSpaceIDs) {
+                int available = referencedSpaces.size() - spaceCursor[0];
+                int requestedCount = property instanceof ArrayNumber ?
+                        Math.max(0, ((ArrayNumber) property).number) : 0;
+                int declaredCount = boundedAvailableReferenceCount(requestedCount, available);
+                int count = Math.min(declaredCount, MAX_REFERENCE_COUNT);
+                if (requestedCount > available && !warningsSaturated()) {
+                    recordParseWarning(ParseWarningKind.OBJECT_SPACE_REFERENCE_ARRAY_UNAVAILABLE,
+                            "OneNote object-space reference array had unavailable entries " +
+                                    "(first occurrence: declared " + requestedCount +
+                                    " entries but only " + Math.max(available, 0) +
+                                    " were available)");
+                }
+                if (declaredCount > count && !warningsSaturated()) {
+                    recordParseWarning(ParseWarningKind.OBJECT_SPACE_REFERENCE_ARRAY_CAPPED,
+                            "Capping OneNote object-space reference array at " +
+                                    MAX_REFERENCE_COUNT + " entries");
+                }
+                for (int j = 0; j < count; ++j) {
+                    actions.add(new PropertyAction(property, propertyType, oneNotePropertyEnum,
+                            false, null, nextSpaceReference(referencedSpaces, spaceCursor)));
+                }
+                spaceCursor[0] += declaredCount - count;
+            } else if (propertyType == PropertyType.PropertySet) {
+                if (property instanceof PropertySet) {
+                    collectActions((PropertySet) property, referencedObjects, referenceCursor,
+                            referencedSpaces, spaceCursor, actions, depth + 1);
+                }
+            } else if (propertyType == PropertyType.ArrayOfPropertyValues) {
+                if (property instanceof PrtArrayOfPropertyValues &&
+                        ((PrtArrayOfPropertyValues) property).data != null) {
+                    for (PropertySet nested : ((PrtArrayOfPropertyValues) property).data) {
+                        collectActions(nested, referencedObjects, referenceCursor,
+                                referencedSpaces, spaceCursor, actions, depth + 1);
+                    }
+                }
+            } else {
+                actions.add(new PropertyAction(property, propertyType, oneNotePropertyEnum, false,
+                        null, null));
+            }
+        }
+    }
+
+    private void processAction(PropertyAction action,
+                               Map<ExGuid, RevisionStoreObject> objectsById, Set<ExGuid> visited,
+                               AuthorRole authorRole, OneNoteTreeWalkerOptions options,
+                               Metadata metadata, XHTMLContentHandler xhtml, int depth,
+                               EmbeddedResourceInfo parentResourceInfo)
+            throws SAXException, TikaException, IOException {
+        if (action.spaceReference != null) {
+            // a reference to another object space (cell) - cells are walked separately
+            return;
+        }
+        if (action.isChildReference) {
+            AuthorRole childRole = AuthorRole.NONE;
+            if (action.oneNotePropertyEnum == OneNotePropertyEnum.AuthorMostRecent) {
+                childRole = AuthorRole.MOST_RECENT;
+            } else if (action.oneNotePropertyEnum == OneNotePropertyEnum.AuthorOriginal) {
+                childRole = AuthorRole.ORIGINAL;
+            }
+            EmbeddedResourceInfo childResourceInfo = resourceInfoForChild(action,
+                    parentResourceInfo);
+            RevisionStoreObject child = action.childReference == null ? null :
+                    objectsById.get(action.childReference);
+            if (child == null && action.childReference != null && !warningsSaturated()) {
+                recordParseWarning("OneNote object " + action.childReference +
+                        " could not be resolved");
+            }
+            walkObject(child, objectsById, visited, childRole, options, metadata, xhtml,
+                    depth + 1, childResourceInfo);
+        } else {
+            processPrimitiveProperty(action.property, action.propertyType,
+                    action.oneNotePropertyEnum, authorRole, options, metadata, xhtml);
+        }
+    }
+
+    private int boundedAvailableReferenceCount(int declaredCount, int remainingReferences) {
+        if (declaredCount <= 0 || remainingReferences <= 0) {
+            return 0;
+        }
+        return Math.min(declaredCount, remainingReferences);
+    }
+
+    private ExGuid nextReference(List<ExGuid> referencedObjects, int[] referenceCursor) {
+        if (referenceCursor[0] < referencedObjects.size()) {
+            return referencedObjects.get(referenceCursor[0]++);
+        }
+        return null;
+    }
+
+    private CellID nextSpaceReference(List<CellID> referencedSpaces, int[] spaceCursor) {
+        if (spaceCursor[0] < referencedSpaces.size()) {
+            return referencedSpaces.get(spaceCursor[0]++);
+        }
+        return null;
+    }
+
+    private void processPrimitiveProperty(IProperty property, PropertyType propertyType,
+                                          OneNotePropertyEnum oneNotePropertyEnum,
+                                          AuthorRole authorRole,
+                                          OneNoteTreeWalkerOptions options, Metadata metadata,
+                                          XHTMLContentHandler xhtml)
+            throws SAXException, TikaException, IOException {
+        if (oneNotePropertyEnum == OneNotePropertyEnum.LastModifiedTimeStamp) {
+            long fullval = getScalar(property);
+            Instant instant = Instant.ofEpochSecond(
+                    fullval / 10000000 + DATETIME_EPOCH_DIFF_1601);
+            if (instant.isAfter(lastModifiedTimestamp)) {
+                lastModifiedTimestamp = instant;
+            }
+            metadata.set(ONE_NOTE_PREFIX + "lastModifiedTimestamp",
+                    String.valueOf(lastModifiedTimestamp.toEpochMilli()));
+        } else if (oneNotePropertyEnum == OneNotePropertyEnum.CreationTimeStamp) {
+            // add the TIME32_EPOCH_DIFF_1980 because OneNote TIME32 epoch time is per 1980, not
+            // 1970
+            long scalar = getScalar(property);
+            long creationTs = scalar + TIME32_EPOCH_DIFF_1980;
+            if (creationTs < creationTimestamp) {
+                creationTimestamp = creationTs;
+            }
+            metadata.set(ONE_NOTE_PREFIX + "creationTimestamp", String.valueOf(creationTimestamp));
+        } else if (oneNotePropertyEnum == OneNotePropertyEnum.LastModifiedTime) {
+            // add the TIME32_EPOCH_DIFF_1980 because OneNote TIME32 epoch time is per 1980, not
+            // 1970
+            long scalar = getScalar(property);
+            long lastMod = scalar + TIME32_EPOCH_DIFF_1980;
+            if (lastMod > lastModified) {
+                lastModified = lastMod;
+            }
+            metadata.set(TikaCoreProperties.MODIFIED, String.valueOf(lastModified));
+        } else if (oneNotePropertyEnum == OneNotePropertyEnum.Author) {
+            recordAuthor(decodeOneNoteText(
+                    ((PrtFourBytesOfLengthFollowedByData) property).data), authorRole);
+        } else if (propertyType == PropertyType.FourBytesOfLengthFollowedByData) {
+            boolean isBinary = propertyIsBinary(oneNotePropertyEnum);
+            PrtFourBytesOfLengthFollowedByData dataProperty =
+                    (PrtFourBytesOfLengthFollowedByData) property;
+            if ((dataProperty.data.length & 1) == 0 &&
+                    oneNotePropertyEnum != OneNotePropertyEnum.TextExtendedAscii && !isBinary) {
+                if (options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
+                    emitParagraph(xhtml, new String(dataProperty.data,
+                            StandardCharsets.UTF_16LE));
+                }
+            } else if (oneNotePropertyEnum == OneNotePropertyEnum.TextExtendedAscii) {
+                emitParagraph(xhtml, new String(dataProperty.data, StandardCharsets.US_ASCII));
+            } else if (!isBinary) {
+                if (options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
+                    emitParagraph(xhtml, new String(dataProperty.data,
+                            StandardCharsets.UTF_16LE));
+                }
+            } else {
+                if (oneNotePropertyEnum == OneNotePropertyEnum.RichEditTextUnicode) {
+                    handleRichEditTextUnicode(dataProperty.data, xhtml);
+                } else {
+                    //TODO -- these seem to be somewhat broken font files and other
+                    //odds and ends...what are they and how should we process them?
+                    //handleEmbedded(content.size());
+                }
+            }
+        }
+    }
+
+
+    private String decodeOneNoteText(byte[] bytes) {
+        return new String(bytes, StandardCharsets.UTF_16LE).replace("\u0000", "");
+    }
+
+    private void recordAuthor(String author, AuthorRole role) {
+        if (role == AuthorRole.MOST_RECENT) {
+            mostRecentAuthors.add(author);
+        } else if (role == AuthorRole.ORIGINAL) {
+            originalAuthors.add(author);
+            // the original authors are the creators of the content
+            authors.add(author);
+        } else {
+            authors.add(author);
+        }
+    }
+
+    /**
+     * Records the Author properties of an already-visited object under the given role.
+     */
+    private void recordAuthors(RevisionStoreObject object, AuthorRole role) {
+        // NONE also records (into authors) so the result is visit-order independent
+        if (object.propertySet == null ||
+                object.propertySet.objectSpaceObjectPropSet == null) {
+            return;
+        }
+        for (PropertyAction action : collectObjectActions(object)) {
+            if (action.oneNotePropertyEnum == OneNotePropertyEnum.Author &&
+                    action.property instanceof PrtFourBytesOfLengthFollowedByData) {
+                recordAuthor(decodeOneNoteText(
+                        ((PrtFourBytesOfLengthFollowedByData) action.property).data), role);
+            }
+        }
+    }
+
+    private EmbeddedResourceInfo embeddedResourceInfo(List<PropertyAction> actions) {
+        for (PropertyAction action : actions) {
+            if (action.property instanceof PrtFourBytesOfLengthFollowedByData &&
+                    (action.oneNotePropertyEnum == OneNotePropertyEnum.ImageFilename ||
+                            action.oneNotePropertyEnum == OneNotePropertyEnum.EmbeddedFileName)) {
+                byte[] bytes = ((PrtFourBytesOfLengthFollowedByData) action.property).data;
+                String name = sanitizeResourceName(new String(bytes, StandardCharsets.UTF_16LE)
+                        .replace("\u0000", ""));
+                if (!name.isEmpty()) {
+                    String type = action.oneNotePropertyEnum == OneNotePropertyEnum.ImageFilename ?
+                            TikaCoreProperties.EmbeddedResourceType.INLINE.toString() :
+                            TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString();
+                    return new EmbeddedResourceInfo(name, type);
+                }
+            }
+        }
+        return null;
+    }
+
+    static String sanitizeResourceName(String name) {
+        name = name.replace('\\', '/');
+        // a drive-relative name like C:pic.png has no slash, so strip the prefix separately
+        if (name.length() > 1 && name.charAt(1) == ':') {
+            name = name.substring(2);
+        }
+        int slash = name.lastIndexOf('/');
+        name = slash >= 0 ? name.substring(slash + 1) : name;
+        return ".".equals(name) || "..".equals(name) ? "" : name;
+    }
+
+    /**
+     * Hands the binary data of an embedded object (e.g. an image or an attached file) to the
+     * embedded document extractor.
+     */
+    private void handleEmbedded(byte[] data, XHTMLContentHandler xhtml,
+                                EmbeddedResourceInfo resourceInfo, ExGuid objectID)
+            throws SAXException, IOException {
+        if (data == null || data.length == 0 || embeddedDocumentExtractor == null) {
+            return;
+        }
+        Metadata embeddedMetadata = new Metadata();
+        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                resourceInfo == null ? TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString() :
+                        resourceInfo.type);
+        if (resourceInfo != null && resourceInfo.name != null) {
+            embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, resourceInfo.name);
+        }
+        String relationshipID = objectID == null ? null : "_" + objectID.value + "_" +
+                objectID.guid;
+        if (relationshipID != null) {
+            embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID, relationshipID);
+        }
+        AttributesImpl attributes = new AttributesImpl();
+        attributes.addAttribute("", "class", "class", "CDATA", "embedded");
+        if (relationshipID != null) {
+            attributes.addAttribute("", "id", "id", "CDATA", relationshipID);
+        }
+        xhtml.startElement("div", attributes);
+        xhtml.endElement("div");
+        try (TikaInputStream tis = TikaInputStream.get(data)) {
+            if (embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata)) {
+                // only counts as content when the extractor accepts it, so a declined
+                // embedded object in an otherwise-empty file still triggers the fallback
+                contentEmitted = true;
+                embeddedDocumentExtractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml),
+                        embeddedMetadata, false);
+            }
+        } catch (IOException e) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, parentMetadata);
+        }
+    }
 
     private void handleRichEditTextUnicode(byte[] arr, XHTMLContentHandler xhtml)
             throws SAXException, IOException, TikaException {
@@ -285,13 +1032,37 @@ public class MSOneStorePackage {
         Matcher m = HYPERLINK_PATTERN.matcher(txt);
         if (m.find()) {
             xhtml.startElement("a", "href", m.group(1));
-            xhtml.characters(m.group(2));
-            xhtml.endElement("a");
+            try {
+                xhtml.characters(m.group(2));
+            } finally {
+                xhtml.endElement("a");
+            }
+            contentEmitted = true;
         } else {
-            xhtml.startElement(P);
-            xhtml.characters(txt);
+            emitParagraph(xhtml, txt);
+        }
+    }
+
+    private void emitParagraph(XHTMLContentHandler xhtml, String text) throws SAXException {
+        xhtml.startElement(P);
+        try {
+            xhtml.characters(text);
+        } finally {
             xhtml.endElement(P);
         }
+        // blank text does not count as content, so it cannot suppress the legacy fallback
+        if (!text.isBlank()) {
+            contentEmitted = true;
+        }
+    }
+
+    /**
+     * Whether the tree walk emitted any content - text or an embedded object. When it did
+     * not, the caller can fall back to the legacy string dump so a degraded file still
+     * yields its text.
+     */
+    public boolean hasEmittedContent() {
+        return contentEmitted;
     }
 
     private long getScalar(IProperty property) throws TikaException, IOException {
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParser.java
index 25790ead8b..d4a41b43d0 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParser.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParser.java
@@ -17,9 +17,15 @@
 package org.apache.tika.parser.microsoft.onenote.fsshttpb;
 
 import java.io.IOException;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -31,6 +37,8 @@ import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupDa
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestDataElementData;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestObjectGroupReferences;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestRootDeclare;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreCell;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObject;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObjectGroup;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexCellMapping;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexDataElementData;
@@ -40,8 +48,14 @@ import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.CellID;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.DataElementType;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGuid;
 import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.HeaderCell;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.GuidUtil;
 
 public class MSOneStoreParser {
+    /**
+     * The root role declaration used for the encryption key of encrypted sections.
+     */
+    private static final ExGuid ENCRYPTION_KEY_ROOT_EXGUID =
+            new ExGuid(3, UUID.fromString("4A3717F8-1C14-49E7-9526-81D942DE1741"));
     private final Set<CellID> storageIndexHashTab = new HashSet<>();
     // The DataElements of Storage Index
     private List<DataElement> storageIndexDataElements;
@@ -49,15 +63,47 @@ public class MSOneStoreParser {
     private List<DataElement> storageManifestDataElements;
     // The DataElements of Cell Manifest
     private List<DataElement> cellManifestDataElements;
+    private Map<ExGuid, DataElement> cellManifestDataElementsById = new HashMap<>();
     // The DataElements of Revision Manifest
     private List<DataElement> revisionManifestDataElements;
+    private Map<ExGuid, DataElement> revisionManifestDataElementsById = new HashMap<>();
     // The DataElements of Object Group Data
     private List<DataElement> objectGroupDataElements;
-    // The DataElements of Object BLOB
-    private List<DataElement> objectBlOBElements;
+    // The DataElements of Object Group Data, keyed by their data element extended GUID
+    private Map<ExGuid, DataElement> objectGroupDataElementsById;
+    private final Map<ObjectGroupCacheKey, RevisionStoreObjectGroup> objectGroupsByDataElementId =
+            new HashMap<>();
+    // The DataElements of Object BLOB, keyed by their data element extended GUID
+    private Map<ExGuid, DataElement> objectBlobElementsById;
+
+    private static final class ObjectGroupCacheKey {
+        private final ExGuid dataElementId;
+        private final boolean encryption;
+
+        private ObjectGroupCacheKey(ExGuid dataElementId, boolean encryption) {
+            this.dataElementId = dataElementId;
+            this.encryption = encryption;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (!(other instanceof ObjectGroupCacheKey)) {
+                return false;
+            }
+            ObjectGroupCacheKey that = (ObjectGroupCacheKey) other;
+            return encryption == that.encryption && Objects.equals(dataElementId, that.dataElementId);
+        }
+
+        @Override
+        public int hashCode() {
+            return 31 * Objects.hashCode(dataElementId) + Boolean.hashCode(encryption);
+        }
+    }
 
     public MSOneStorePackage parse(DataElementPackage dataElementPackage) throws IOException {
+        storageIndexHashTab.clear();
         MSOneStorePackage msOneStorePackage = new MSOneStorePackage();
+        objectGroupsByDataElementId.clear();
 
         storageIndexDataElements = dataElementPackage.dataElements.stream()
                 .filter(d -> d.dataElementType == DataElementType.StorageIndexDataElementData)
@@ -68,20 +114,48 @@ public class MSOneStoreParser {
         cellManifestDataElements = dataElementPackage.dataElements.stream()
                 .filter(d -> d.dataElementType == DataElementType.CellManifestDataElementData)
                 .collect(Collectors.toList());
+        cellManifestDataElementsById.clear();
+        for (DataElement element : cellManifestDataElements) {
+            cellManifestDataElementsById.putIfAbsent(element.dataElementExGuid, element);
+        }
         revisionManifestDataElements = dataElementPackage.dataElements.stream()
                 .filter(d -> d.dataElementType == DataElementType.RevisionManifestDataElementData)
                 .collect(Collectors.toList());
+        revisionManifestDataElementsById.clear();
+        for (DataElement element : revisionManifestDataElements) {
+            revisionManifestDataElementsById.putIfAbsent(element.dataElementExGuid, element);
+        }
         objectGroupDataElements = dataElementPackage.dataElements.stream()
                 .filter(d -> d.dataElementType == DataElementType.ObjectGroupDataElementData)
                 .collect(Collectors.toList());
-        objectBlOBElements = dataElementPackage.dataElements.stream()
+        objectGroupDataElementsById = new HashMap<>();
+        for (DataElement objectGroupElement : objectGroupDataElements) {
+            objectGroupDataElementsById.putIfAbsent(objectGroupElement.dataElementExGuid,
+                    objectGroupElement);
+        }
+        List<DataElement> objectBlobElements = dataElementPackage.dataElements.stream()
                 .filter(d -> d.dataElementType == DataElementType.ObjectDataBLOBDataElementData)
                 .collect(Collectors.toList());
+        objectBlobElementsById = new HashMap<>();
+        for (DataElement blobElement : objectBlobElements) {
+            objectBlobElementsById.putIfAbsent(blobElement.dataElementExGuid, blobElement);
+        }
 
+        if (storageIndexDataElements.isEmpty() || storageManifestDataElements.isEmpty()) {
+            throw new IOException("Missing OneNote storage index or storage manifest");
+        }
         msOneStorePackage.storageIndex =
                 (StorageIndexDataElementData) storageIndexDataElements.get(0).data;
         msOneStorePackage.storageManifest =
                 (StorageManifestDataElementData) storageManifestDataElements.get(0).data;
+        if (msOneStorePackage.storageIndex == null ||
+                msOneStorePackage.storageManifest == null) {
+            throw new IOException("Invalid OneNote storage index or storage manifest");
+        }
+        if (msOneStorePackage.storageManifest.storageManifestRootDeclareList == null ||
+                msOneStorePackage.storageManifest.storageManifestRootDeclareList.size() < 2) {
+            throw new IOException("Missing OneNote header or data-root declaration");
+        }
 
         // Parse Header Cell
         CellID headerCellID =
@@ -93,31 +167,50 @@ public class MSOneStoreParser {
         if (headerCellStorageIndexCellMapping != null) {
             msOneStorePackage.headerCellCellManifest =
                     this.findCellManifest(headerCellStorageIndexCellMapping.cellMappingExGuid);
-            StorageIndexRevisionMapping headerCellRevisionManifestMapping =
-                    msOneStorePackage.findStorageIndexRevisionMapping(
-                            msOneStorePackage.headerCellCellManifest.cellManifestCurrentRevision
-                                    .cellManifestCurrentRevisionExGuid);
-            msOneStorePackage.headerCellRevisionManifest = this.findRevisionManifestDataElement(
-                    headerCellRevisionManifestMapping.revisionMappingExGuid);
-            msOneStorePackage.headerCell =
-                    this.parseHeaderCell(msOneStorePackage.headerCellRevisionManifest);
-
-            // Parse Data root
-            CellID dataRootCellID =
-                    msOneStorePackage.storageManifest.storageManifestRootDeclareList.get(1).cellID;
-            storageIndexHashTab.add(dataRootCellID);
-            msOneStorePackage.dataRoot = this.parseObjectGroup(dataRootCellID, msOneStorePackage);
-            // Parse other data
-            for (StorageIndexCellMapping storageIndexCellMapping : msOneStorePackage.storageIndex
-                    .storageIndexCellMappingList) {
-                if (!storageIndexHashTab.contains(storageIndexCellMapping.cellID)) {
-                    msOneStorePackage.OtherFileNodeList.addAll(
-                            this.parseObjectGroup(storageIndexCellMapping.cellID,
-                                    msOneStorePackage));
-                    storageIndexHashTab.add(storageIndexCellMapping.cellID);
+            if (msOneStorePackage.headerCellCellManifest != null &&
+                    msOneStorePackage.headerCellCellManifest.cellManifestCurrentRevision != null) {
+                StorageIndexRevisionMapping headerCellRevisionManifestMapping =
+                        msOneStorePackage.findStorageIndexRevisionMapping(
+                                msOneStorePackage.headerCellCellManifest.cellManifestCurrentRevision
+                                        .cellManifestCurrentRevisionExGuid);
+                if (headerCellRevisionManifestMapping != null) {
+                    msOneStorePackage.headerCellRevisionManifest =
+                            this.findRevisionManifestDataElement(
+                                    headerCellRevisionManifestMapping.revisionMappingExGuid);
+                    if (msOneStorePackage.headerCellRevisionManifest != null) {
+                        msOneStorePackage.headerCell =
+                                this.parseHeaderCell(msOneStorePackage.headerCellRevisionManifest);
+                    }
                 }
             }
         }
+
+        // Parse Data root independently of the header-cell metadata. A malformed header cell
+        // should not prevent valid section cells from being parsed.
+        CellID dataRootCellID =
+                msOneStorePackage.storageManifest.storageManifestRootDeclareList.get(1).cellID;
+        storageIndexHashTab.add(dataRootCellID);
+        RevisionStoreCell dataRootCell = this.parseCell(dataRootCellID, msOneStorePackage);
+        if (dataRootCell == null) {
+            throw new IOException("Could not parse the OneNote data root cell");
+        }
+        msOneStorePackage.dataRootCell = dataRootCell;
+        // Parse other data
+        for (StorageIndexCellMapping storageIndexCellMapping : msOneStorePackage.storageIndex
+                .storageIndexCellMappingList) {
+            if (!storageIndexHashTab.contains(storageIndexCellMapping.cellID)) {
+                RevisionStoreCell cell =
+                        this.parseCell(storageIndexCellMapping.cellID, msOneStorePackage);
+                // The storage index can retain a mapping for a deleted version context.
+                // Such an entry has no CellManifestDataElementData (often its mapping GUID
+                // is all zero) and therefore cannot contain current document content.
+                if (cell != null) {
+                    msOneStorePackage.OtherFileNodeList.addAll(cell.objectGroups);
+                    msOneStorePackage.cells.add(cell);
+                }
+                storageIndexHashTab.add(storageIndexCellMapping.cellID);
+            }
+        }
         return msOneStorePackage;
     }
 
@@ -125,74 +218,187 @@ public class MSOneStoreParser {
      * Find the CellManifestDataElementData
      *
      * @param cellMappingExtendedGUID The ExGuid of Cell Mapping Extended GUID.
-     * @return The CellManifestDataElementData instance.
+     * @return The CellManifestDataElementData instance, or {@code null} if it is absent.
      */
     private CellManifestDataElementData findCellManifest(ExGuid cellMappingExtendedGUID) {
-        return (CellManifestDataElementData) this.cellManifestDataElements.stream()
-                .filter(d -> d.dataElementExGuid.equals(cellMappingExtendedGUID)).findFirst()
-                .orElse(new DataElement()).data;
+        DataElement element = cellManifestDataElementsById.get(cellMappingExtendedGUID);
+        return element == null ? null : (CellManifestDataElementData) element.data;
     }
 
     /**
      * Find the Revision Manifest from Data Elements.
      *
      * @param revisionMappingExtendedGUID The Revision Mapping Extended GUID.
-     * @return Returns the instance of RevisionManifestDataElementData
+     * @return The RevisionManifestDataElementData instance, or {@code null} if it is absent.
      */
     private RevisionManifestDataElementData findRevisionManifestDataElement(
             ExGuid revisionMappingExtendedGUID) {
-        return (RevisionManifestDataElementData) this.revisionManifestDataElements.stream()
-                .filter(d -> d.dataElementExGuid.equals(revisionMappingExtendedGUID)).findFirst()
-                .orElse(new DataElement()).data;
+        DataElement element = revisionManifestDataElementsById.get(revisionMappingExtendedGUID);
+        return element == null ? null : (RevisionManifestDataElementData) element.data;
     }
 
     private HeaderCell parseHeaderCell(RevisionManifestDataElementData headerCellRevisionManifest)
             throws IOException {
+        if (headerCellRevisionManifest == null ||
+                headerCellRevisionManifest.revisionManifestObjectGroupReferences == null ||
+                headerCellRevisionManifest.revisionManifestObjectGroupReferences.isEmpty()) {
+            return null;
+        }
         ExGuid rootObjectId =
                 headerCellRevisionManifest.revisionManifestObjectGroupReferences.get(
                         0).objectGroupExtendedGUID;
 
-        DataElement element = this.objectGroupDataElements.stream()
-                .filter(d -> d.dataElementExGuid.equals(rootObjectId)).findFirst()
-                .orElse(new DataElement());
-
+        DataElement element = objectGroupDataElementsById.get(rootObjectId);
+        if (element == null) {
+            return null;
+        }
         return HeaderCell.createInstance((ObjectGroupDataElementData) element.data);
     }
 
-    private List<RevisionStoreObjectGroup> parseObjectGroup(CellID objectGroupCellID,
-                                                            MSOneStorePackage msOneStorePackage)
+    private RevisionStoreCell parseCell(CellID objectGroupCellID,
+                                        MSOneStorePackage msOneStorePackage)
             throws IOException {
         StorageIndexCellMapping storageIndexCellMapping =
                 msOneStorePackage.findStorageIndexCellMapping(objectGroupCellID);
+        if (storageIndexCellMapping == null) {
+            String warning = "Skipping OneNote cell " + objectGroupCellID +
+                    ": no storage-index cell mapping";
+            msOneStorePackage.recordParseWarning(warning);
+            return null;
+        }
         CellManifestDataElementData cellManifest =
                 this.findCellManifest(storageIndexCellMapping.cellMappingExGuid);
+        if (cellManifest == null || cellManifest.cellManifestCurrentRevision == null) {
+            String warning = "Skipping OneNote cell " + objectGroupCellID +
+                    ": no current cell manifest";
+            msOneStorePackage.recordParseWarning(warning);
+            return null;
+        }
         List<RevisionStoreObjectGroup> objectGroups = new ArrayList<>();
         msOneStorePackage.cellManifests.add(cellManifest);
         StorageIndexRevisionMapping revisionMapping =
                 msOneStorePackage.findStorageIndexRevisionMapping(
                         cellManifest.cellManifestCurrentRevision.cellManifestCurrentRevisionExGuid);
+        if (revisionMapping == null) {
+            String warning = "Skipping OneNote cell " + objectGroupCellID +
+                    ": no revision mapping";
+            msOneStorePackage.recordParseWarning(warning);
+            return null;
+        }
         RevisionManifestDataElementData revisionManifest =
                 findRevisionManifestDataElement(revisionMapping.revisionMappingExGuid);
-        msOneStorePackage.revisionManifests.add(revisionManifest);
-        RevisionManifestRootDeclare encryptionKeyRoot =
-                revisionManifest.revisionManifestRootDeclareList.stream()
-                        .filter(r -> r.rootExGuid.equals(new ExGuid(3,
-                                UUID.fromString("4A3717F8-1C14-49E7-9526-81D942DE1741"))))
-                        .findFirst().orElse(null);
-        boolean isEncryption = encryptionKeyRoot != null;
-        for (RevisionManifestObjectGroupReferences objRef :
-                revisionManifest.revisionManifestObjectGroupReferences) {
-            ObjectGroupDataElementData dataObject =
-                    (ObjectGroupDataElementData) objectGroupDataElements.stream()
-                            .filter(d -> d.dataElementExGuid.equals(objRef.objectGroupExtendedGUID))
-                            .findFirst().get().data;
-
-            RevisionStoreObjectGroup objectGroup =
-                    RevisionStoreObjectGroup.createInstance(objRef.objectGroupExtendedGUID,
-                            dataObject, isEncryption);
-            objectGroups.add(objectGroup);
-        }
-
-        return objectGroups;
+        if (revisionManifest == null || revisionManifest.revisionManifest == null) {
+            String warning = "Skipping OneNote cell " + objectGroupCellID +
+                    ": no revision manifest";
+            msOneStorePackage.recordParseWarning(warning);
+            return null;
+        }
+
+        // A revision manifest may only reference the object groups that were created or
+        // modified in that revision. The remaining object groups belong to the chain of
+        // base revisions (MS-FSSHTTPB "Base Revision ID"). Follow that chain and collect
+        // the object groups of every revision, oldest revision first, so no content is lost.
+        Deque<RevisionManifestDataElementData> revisionChain = new ArrayDeque<>();
+        Set<ExGuid> seenRevisionIds = new HashSet<>();
+        while (revisionManifest != null) {
+            ExGuid revisionId = revisionManifest.revisionManifest.revisionID;
+            if (revisionId != null && !seenRevisionIds.add(revisionId)) {
+                // cycle guard - stop if we have already visited this revision
+                break;
+            }
+            revisionChain.addFirst(revisionManifest);
+            ExGuid baseRevisionId = revisionManifest.revisionManifest.baseRevisionID;
+            if (baseRevisionId == null || baseRevisionId.guid == null ||
+                    GuidUtil.emptyGuid().equals(baseRevisionId.guid)) {
+                break;
+            }
+            StorageIndexRevisionMapping baseRevisionMapping =
+                    msOneStorePackage.findStorageIndexRevisionMapping(baseRevisionId);
+            revisionManifest = baseRevisionMapping == null ? null :
+                    findRevisionManifestDataElement(baseRevisionMapping.revisionMappingExGuid);
+        }
+
+        Set<ExGuid> seenObjectGroupIds = new HashSet<>();
+        // for each root role, the declaration made by the most recent revision wins
+        Map<ExGuid, RevisionManifestRootDeclare> effectiveRootDeclares = new LinkedHashMap<>();
+        for (RevisionManifestDataElementData manifest : revisionChain) {
+            msOneStorePackage.revisionManifests.add(manifest);
+            RevisionManifestRootDeclare encryptionKeyRoot =
+                    manifest.revisionManifestRootDeclareList.stream()
+                            .filter(r -> r.rootExGuid.equals(ENCRYPTION_KEY_ROOT_EXGUID))
+                            .findFirst().orElse(null);
+            boolean isEncryption = encryptionKeyRoot != null;
+            for (RevisionManifestRootDeclare rootDeclare :
+                    manifest.revisionManifestRootDeclareList) {
+                if (!rootDeclare.rootExGuid.equals(ENCRYPTION_KEY_ROOT_EXGUID)) {
+                    effectiveRootDeclares.put(rootDeclare.rootExGuid, rootDeclare);
+                }
+            }
+            for (RevisionManifestObjectGroupReferences objRef :
+                    manifest.revisionManifestObjectGroupReferences) {
+                if (!seenObjectGroupIds.add(objRef.objectGroupExtendedGUID)) {
+                    continue;
+                }
+                DataElement dataElement =
+                        objectGroupDataElementsById.get(objRef.objectGroupExtendedGUID);
+                if (dataElement == null) {
+                    msOneStorePackage.recordParseWarning(
+                            "OneNote object group " + objRef.objectGroupExtendedGUID +
+                                    " could not be resolved");
+                    continue;
+                }
+                ObjectGroupDataElementData dataObject =
+                        (ObjectGroupDataElementData) dataElement.data;
+
+                ObjectGroupCacheKey cacheKey =
+                        new ObjectGroupCacheKey(dataElement.dataElementExGuid, isEncryption);
+                RevisionStoreObjectGroup cachedObjectGroup = objectGroupsByDataElementId.get(cacheKey);
+                if (cachedObjectGroup == null) {
+                    cachedObjectGroup = RevisionStoreObjectGroup.createInstance(
+                            objRef.objectGroupExtendedGUID, dataObject, isEncryption,
+                            objectBlobElementsById);
+                    objectGroupsByDataElementId.put(cacheKey, cachedObjectGroup);
+                }
+                objectGroups.add(cachedObjectGroup.copy());
+            }
+        }
+
+        removeSupersededObjects(objectGroups);
+
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.cellID = objectGroupCellID;
+        cell.objectGroups = objectGroups;
+        cell.rootDeclares = new ArrayList<>(effectiveRootDeclares.values());
+        return cell;
+    }
+
+    /**
+     * An object that is modified in a later revision appears again, with the same object ID,
+     * in that revision's object group. Keep only the newest version of each object, replacing
+     * the older version in place so the original object ordering is preserved.
+     *
+     * @param objectGroups The object groups ordered from the oldest revision to the newest.
+     */
+    private void removeSupersededObjects(List<RevisionStoreObjectGroup> objectGroups) {
+        Map<ExGuid, List<RevisionStoreObject>> containingList = new HashMap<>();
+        Map<ExGuid, Integer> indexInList = new HashMap<>();
+        for (RevisionStoreObjectGroup objectGroup : objectGroups) {
+            List<RevisionStoreObject> objects = objectGroup.objects;
+            for (int i = 0; i < objects.size(); ) {
+                RevisionStoreObject object = objects.get(i);
+                ExGuid objectId = object.objectID;
+                if (objectId != null && containingList.containsKey(objectId)) {
+                    // newer version of an already seen object - replace the older one in place
+                    containingList.get(objectId).set(indexInList.get(objectId), object);
+                    objects.remove(i);
+                } else {
+                    if (objectId != null) {
+                        containingList.put(objectId, objects);
+                        indexInList.put(objectId, i);
+                    }
+                    ++i;
+                }
+            }
+        }
     }
 }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
index 68b1dab169..7220f999bf 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
@@ -41,16 +41,27 @@ public class PrtArrayOfPropertyValues implements IProperty {
      * @return
      */
     public int doDeserializeFromByteArray(byte[] byteArray, int startIndex) throws IOException {
+        return doDeserializeFromByteArray(byteArray, startIndex, 0);
+    }
+
+    public int doDeserializeFromByteArray(byte[] byteArray, int startIndex, int depth)
+            throws IOException {
         int index = startIndex;
         this.cProperties = BitConverter.toInt32(byteArray, index);
         index += 4;
         this.propertyID = new PropertyID();
         int len = this.propertyID.doDeserializeFromByteArray(byteArray, index);
         index += len;
+        // each PropertySet consumes at least its 2-byte count, so a valid count cannot
+        // exceed the remaining bytes / 2; bounds the allocation on malformed counts
+        if (this.cProperties < 0 || this.cProperties > (byteArray.length - index) / 2) {
+            throw new IOException("prtArrayOfPropertyValues count " + this.cProperties +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.data = new PropertySet[this.cProperties];
         for (int i = 0; i < this.cProperties; i++) {
             this.data[i] = new PropertySet();
-            int length = this.data[i].doDeserializeFromByteArray(byteArray, index);
+            int length = this.data[i].doDeserializeFromByteArray(byteArray, index, depth + 1);
             index += 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/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
index 8518be09b5..4eb0ca7b83 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
@@ -43,6 +43,12 @@ public class PrtFourBytesOfLengthFollowedByData implements IProperty {
         int index = startIndex;
         this.cb = (int) BitConverter.toUInt32(byteArray, startIndex);
         index += 4;
+        // copyOfRange zero-fills past the source end, so cb must be bounded by
+        // the remaining bytes or a malformed length allocates cb bytes of heap
+        if (this.cb < 0 || this.cb > byteArray.length - index) {
+            throw new IOException("prtFourBytesOfLengthFollowedByData length " + this.cb +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.data = Arrays.copyOfRange(byteArray, index, index + this.cb);
         index += this.cb;
 
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/DataElement.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/DataElement.java
index 2ae203fb2b..96e94f0e59 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/DataElement.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/DataElement.java
@@ -50,7 +50,8 @@ public class DataElement extends StreamObject {
             String className = DataElement.class.getPackage().getName() + "." + value.name();
 
             try {
-                DATA_ELEMENT_DATA_TYPE_MAPPING.put(value, Class.forName(className));
+                DATA_ELEMENT_DATA_TYPE_MAPPING.put(value,
+                        Class.forName(className, false, DataElement.class.getClassLoader()));
             } catch (ClassNotFoundException e) {
                 // This is OK, we are not pulling over every single class
             }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java
index 1211d2bf9d..d3a70780a9 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java
@@ -19,8 +19,24 @@ package org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj;
 /**
  * This class is used to represent the file data.
  */
-class FileDataObject {
+public class FileDataObject {
     public ObjectGroupObjectBLOBDataDeclaration objectDataBLOBDeclaration;
     public ObjectGroupObjectDataBLOBReference objectDataBLOBReference;
     public DataElement objectDataBLOBDataElement;
+
+    /**
+     * @return the opaque binary data of this file data object, or null if it could not be
+     * resolved.
+     */
+    public byte[] getData() {
+        if (objectDataBLOBDataElement != null &&
+                objectDataBLOBDataElement.data instanceof ObjectDataBLOBDataElementData) {
+            ObjectDataBLOBDataElementData blobData =
+                    (ObjectDataBLOBDataElementData) objectDataBLOBDataElement.data;
+            if (blobData.objectDataBLOB != null) {
+                return blobData.objectDataBLOB.getData();
+            }
+        }
+        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/onenote/fsshttpb/streamobj/ObjectDataBLOB.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
new file mode 100644
index 0000000000..2873e1d013
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
@@ -0,0 +1,73 @@
+/*
+ * 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.onenote.fsshttpb.streamobj;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.BasicObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.BinaryItem;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.ByteUtil;
+
+/**
+ * Specifies an object data BLOB stream object - the opaque binary data of an object,
+ * e.g. an embedded image or file. See MS-FSSHTTPB section 2.2.1.12.8.
+ */
+public class ObjectDataBLOB extends StreamObject {
+    public BinaryItem data;
+
+    public ObjectDataBLOB() {
+        super(StreamObjectTypeHeaderStart.ObjectDataBLOB);
+        this.data = new BinaryItem();
+    }
+
+    /**
+     * @return the opaque binary data as a byte array, or null if not present.
+     */
+    public byte[] getData() {
+        if (this.data == null || this.data.content == null) {
+            return null;
+        }
+        return ByteUtil.toByteArray(this.data.content);
+    }
+
+    @Override
+    protected void deserializeItemsFromByteArray(byte[] byteArray, AtomicInteger currentIndex,
+                                                 int lengthOfItems)
+            throws TikaException, IOException {
+        AtomicInteger index = new AtomicInteger(currentIndex.get());
+        this.data = BasicObject.parse(byteArray, index, BinaryItem.class);
+
+        if (index.get() - currentIndex.get() != lengthOfItems) {
+            throw new StreamObjectParseErrorException(currentIndex.get(), "ObjectDataBLOB",
+                    "Stream object over-parse error", null);
+        }
+
+        currentIndex.set(index.get());
+    }
+
+    @Override
+    protected int serializeItemsToByteList(List<Byte> byteList) throws IOException {
+        int startPoint = byteList.size();
+        if (this.data != null) {
+            byteList.addAll(this.data.serializeToByteList());
+        }
+        return byteList.size() - startPoint;
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
new file mode 100644
index 0000000000..ff28e9d096
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
@@ -0,0 +1,48 @@
+/*
+ * 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.onenote.fsshttpb.streamobj;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tika.exception.TikaException;
+
+/**
+ * Object data BLOB data element - carries the opaque binary data of an object, e.g. an
+ * embedded image or file. See MS-FSSHTTPB section 2.2.1.12.8.
+ */
+public class ObjectDataBLOBDataElementData extends DataElementData {
+    public ObjectDataBLOB objectDataBLOB;
+
+    public ObjectDataBLOBDataElementData() {
+        this.objectDataBLOB = new ObjectDataBLOB();
+    }
+
+    @Override
+    public int deserializeDataElementDataFromByteArray(byte[] byteArray, int startIndex)
+            throws TikaException, IOException {
+        AtomicInteger index = new AtomicInteger(startIndex);
+        this.objectDataBLOB = StreamObject.getCurrent(byteArray, index, ObjectDataBLOB.class);
+        return index.get() - startIndex;
+    }
+
+    @Override
+    public List<Byte> serializeToByteList() throws TikaException, IOException {
+        return this.objectDataBLOB.serializeToByteList();
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
index 45e1278f42..64276e5aa5 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
@@ -37,6 +37,10 @@ import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.BitConverter;
  * This class is used to represent a PropertySet.
  */
 public class PropertySet implements IProperty {
+    // a nesting level costs ~14 file bytes, so unbounded depth lets a small
+    // crafted file drive a StackOverflowError that escapes catch(Exception)
+    public static final int MAX_PROPERTY_NESTING = 100;
+
     public int cProperties;
 
     public PropertyID[] rgPrids;
@@ -72,6 +76,14 @@ public class PropertySet implements IProperty {
      * @return Return the length in byte of the PropertySet.
      */
     public int doDeserializeFromByteArray(byte[] byteArray, int startIndex) throws IOException {
+        return doDeserializeFromByteArray(byteArray, startIndex, 0);
+    }
+
+    public int doDeserializeFromByteArray(byte[] byteArray, int startIndex, int depth)
+            throws IOException {
+        if (depth > MAX_PROPERTY_NESTING) {
+            throw new IOException("PropertySet nesting exceeds " + MAX_PROPERTY_NESTING);
+        }
         int index = startIndex;
 
         this.cProperties = BitConverter.toInt16(byteArray, startIndex);
@@ -124,7 +136,16 @@ public class PropertySet implements IProperty {
                     break;
             }
             if (property != null) {
-                int len = property.doDeserializeFromByteArray(byteArray, index);
+                int len;
+                if (property instanceof PropertySet) {
+                    len = ((PropertySet) property)
+                            .doDeserializeFromByteArray(byteArray, index, depth + 1);
+                } else if (property instanceof PrtArrayOfPropertyValues) {
+                    len = ((PrtArrayOfPropertyValues) property)
+                            .doDeserializeFromByteArray(byteArray, index, depth + 1);
+                } else {
+                    len = property.doDeserializeFromByteArray(byteArray, index);
+                }
                 this.rgData.add(property);
                 index += len;
             }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreCell.java
similarity index 53%
copy from tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java
copy to tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreCell.java
index 1211d2bf9d..188a2a45de 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/FileDataObject.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreCell.java
@@ -16,11 +16,22 @@
  */
 package org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj;
 
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.CellID;
+
 /**
- * This class is used to represent the file data.
+ * The revision store content of a single cell (object space), together with the root object
+ * declarations of its current revision. The object groups are ordered from the oldest revision
+ * to the newest.
  */
-class FileDataObject {
-    public ObjectGroupObjectBLOBDataDeclaration objectDataBLOBDeclaration;
-    public ObjectGroupObjectDataBLOBReference objectDataBLOBReference;
-    public DataElement objectDataBLOBDataElement;
+public class RevisionStoreCell {
+    public CellID cellID;
+    public List<RevisionStoreObjectGroup> objectGroups = new ArrayList<>();
+    /**
+     * The effective root object declarations of the cell's current revision, i.e. for each
+     * root role the declaration made by the most recent revision in the base revision chain.
+     */
+    public List<RevisionManifestRootDeclare> rootDeclares = new ArrayList<>();
 }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
index afd2f6c067..fb16a8402a 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
@@ -18,7 +18,7 @@ package org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -38,13 +38,20 @@ public class RevisionStoreObjectGroup {
 
     public static RevisionStoreObjectGroup createInstance(ExGuid objectGroupId,
                                                           ObjectGroupDataElementData dataObject,
-                                                          boolean isEncryption) throws IOException {
+                                                          boolean isEncryption,
+                                                          Map<ExGuid, DataElement> blobElements)
+            throws IOException {
         RevisionStoreObjectGroup objectGroup = new RevisionStoreObjectGroup(objectGroupId);
-        Map<ExGuid, RevisionStoreObject> objectDict = new HashMap<>();
+        // insertion-ordered so the object list (and any degraded-file fallback walk) is
+        // deterministic
+        Map<ExGuid, RevisionStoreObject> objectDict = new LinkedHashMap<>();
         if (!isEncryption) {
             RevisionStoreObject revisionObject = null;
             for (int i = 0; i < dataObject.objectGroupDeclarations.objectDeclarationList.size();
                     i++) {
+                if (i >= dataObject.objectGroupData.objectGroupObjectDataList.size()) {
+                    throw new IOException("Missing object data for object declaration " + i);
+                }
                 ObjectGroupObjectDeclare objectDeclaration =
                         dataObject.objectGroupDeclarations.objectDeclarationList.get(i);
                 ObjectGroupObjectData objectData =
@@ -63,16 +70,20 @@ public class RevisionStoreObjectGroup {
                 } else if (objectDeclaration.objectPartitionID.getDecodedValue() == 1) {
                     revisionObject.propertySet =
                             new PropertySetObject(objectDeclaration, objectData);
-                    if (revisionObject.jcid.jcid.isFileData != 0) {
-                        revisionObject.referencedObjectID = objectData.objectExGUIDArray;
-                        revisionObject.referencedObjectSpacesID = objectData.cellIDArray;
-                    }
+                    // the object extended GUID array lists the objects referenced by this
+                    // object, in the same order as the CompactIDs in the OID stream of the
+                    // ObjectSpaceObjectPropSet - see MS-ONESTORE section 2.7.8
+                    revisionObject.referencedObjectID = objectData.objectExGUIDArray;
+                    revisionObject.referencedObjectSpacesID = objectData.cellIDArray;
                 }
             }
 
             for (int i = 0; i <
                     dataObject.objectGroupDeclarations.objectGroupObjectBLOBDataDeclarationList.size();
                     i++) {
+                if (i >= dataObject.objectGroupData.objectGroupObjectDataBLOBReferenceList.size()) {
+                    throw new IOException("Missing BLOB reference for object declaration " + i);
+                }
                 ObjectGroupObjectBLOBDataDeclaration objectGroupObjectBLOBDataDeclaration =
                         dataObject.objectGroupDeclarations.objectGroupObjectBLOBDataDeclarationList.get(
                                 i);
@@ -87,17 +98,24 @@ public class RevisionStoreObjectGroup {
                             objectDict.get(objectGroupObjectBLOBDataDeclaration.objectExGUID);
                 }
                 if (objectGroupObjectBLOBDataDeclaration.objectPartitionID.getDecodedValue() == 2) {
+                    revisionObject.objectID = objectGroupObjectBLOBDataDeclaration.objectExGUID;
+                    revisionObject.objectGroupID = objectGroupId;
                     revisionObject.fileDataObject = new FileDataObject();
                     revisionObject.fileDataObject.objectDataBLOBDeclaration =
                             objectGroupObjectBLOBDataDeclaration;
                     revisionObject.fileDataObject.objectDataBLOBReference =
                             objectGroupObjectDataBLOBReference;
+                    revisionObject.fileDataObject.objectDataBLOBDataElement = blobElements.get(
+                            objectGroupObjectDataBLOBReference.blobExtendedGUID);
                 }
             }
             objectGroup.objects.addAll(objectDict.values());
         } else {
             for (int i = 0; i < dataObject.objectGroupDeclarations.objectDeclarationList.size();
                     i++) {
+                if (i >= dataObject.objectGroupData.objectGroupObjectDataList.size()) {
+                    throw new IOException("Missing object data for object declaration " + i);
+                }
                 ObjectGroupObjectDeclare objectDeclaration =
                         dataObject.objectGroupDeclarations.objectDeclarationList.get(i);
                 ObjectGroupObjectData objectData =
@@ -114,4 +132,17 @@ public class RevisionStoreObjectGroup {
 
         return objectGroup;
     }
+
+    /**
+     * Creates a cell-local wrapper around this parsed group. The object lists are copied so
+     * revision supersession filtering cannot mutate a cached group used by another cell.
+     *
+     * @return a shallow copy with independent mutable lists
+     */
+    public RevisionStoreObjectGroup copy() {
+        RevisionStoreObjectGroup copy = new RevisionStoreObjectGroup(objectGroupID);
+        copy.objects.addAll(objects);
+        copy.encryptionObjects.addAll(encryptionObjects);
+        return copy;
+    }
 }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/StreamObject.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/StreamObject.java
index 03bb1da92c..6a250ba124 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/StreamObject.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/StreamObject.java
@@ -73,7 +73,8 @@ public abstract class StreamObject implements IFSSHTTPBSerializable {
         for (StreamObjectTypeHeaderStart value : StreamObjectTypeHeaderStart.values()) {
             String className = StreamObject.class.getPackage().getName() + "." + value.name();
             try {
-                streamObjectTypeMapping.put(value, Class.forName(className));
+                streamObjectTypeMapping.put(value,
+                        Class.forName(className, false, StreamObject.class.getClassLoader()));
             } catch (ClassNotFoundException e) {
                 // This is OK, we are not pulling over every single class
             }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/CellID.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/CellID.java
index a352dc6315..7b6dbcf886 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/CellID.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/CellID.java
@@ -105,6 +105,14 @@ public class CellID extends BasicObject {
         return this.extendGUID1.hashCode() + this.extendGUID2.hashCode();
     }
 
+    @Override
+    public String toString() {
+        return "CellID{" +
+                "extendGUID1=" + extendGUID1 +
+                ", extendGUID2=" + extendGUID2 +
+                '}';
+    }
+
     /**
      * This method is used to deserialize the CellID basic object from the specified byte array and start index.
      *
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/ExGuid.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/ExGuid.java
index 4a7e149de2..42ff354b0c 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/ExGuid.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/basic/ExGuid.java
@@ -144,6 +144,15 @@ public class ExGuid extends BasicObject {
         return this.guid.hashCode() + Integer.valueOf(this.value).hashCode();
     }
 
+    @Override
+    public String toString() {
+        return "ExGuid{" +
+                "type=" + type +
+                ", value=" + value +
+                ", guid=" + guid +
+                '}';
+    }
+
     /**
      * This method is used to deserialize the ExGuid basic object from the specified byte array and start index.
      *
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
index fe6d288330..7e1e348e7d 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
@@ -57,10 +57,16 @@ public class ObjectSpaceObjectStreamOfContextIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfContextIDs count " +
+                    this.header.count + " exceeds remaining data " + (byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
-            int count = compactID.doDeserializeFromByteArray(byteArray, startIndex);
+            int count = compactID.doDeserializeFromByteArray(byteArray, index);
             this.body[i] = compactID;
             index += count;
         }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
index 7901dd015e..53398663c3 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
@@ -58,10 +58,16 @@ public class ObjectSpaceObjectStreamOfOIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfOIDs count " + this.header.count +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
-            int count = compactID.doDeserializeFromByteArray(byteArray, startIndex);
+            int count = compactID.doDeserializeFromByteArray(byteArray, index);
             this.body[i] = compactID;
             index += count;
         }
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
index 61f7deda90..f37de26f76 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
@@ -57,6 +57,12 @@ public class ObjectSpaceObjectStreamOfOSIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfOSIDs count " + this.header.count +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
new file mode 100644
index 0000000000..0f21f861db
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.onenote;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.exception.TikaException;
+
+public class GUIDTest {
+
+    @Test
+    public void testParsesValidGuidRoundTrip() throws Exception {
+        byte[] valid = "{638DE92F-a6d4-4BC1-9A36-4AFC2511A5B7}"
+                .getBytes(StandardCharsets.UTF_16LE);
+
+        assertEquals("{638DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}",
+                GUID.fromCurlyBraceUTF16Bytes(valid).toString());
+    }
+
+    @Test
+    public void testRejectsNonAsciiUnicodeDigits() {
+        // fullwidth '6' is a Unicode digit that Character.digit(c, 16) would accept
+        byte[] malformed = "{638DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}"
+                .getBytes(StandardCharsets.UTF_16LE);
+
+        assertThrows(TikaException.class, () -> GUID.fromCurlyBraceUTF16Bytes(malformed));
+    }
+
+    @Test
+    public void testRejectsMalformedCurlyBraceGuid() {
+        byte[] malformed = "{638DE92F-A6D4-4BC1-9IJ36-4AFC2511A5B7}"
+                .getBytes(StandardCharsets.UTF_16LE);
+
+        assertThrows(TikaException.class, () -> GUID.fromCurlyBraceUTF16Bytes(malformed));
+    }
+
+    @Test
+    public void testRejectsInjectedDashes() {
+        String[] malformedGuids = {
+                "{-38DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}",
+                "{--8DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}",
+                "{---8DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}",
+                "{----E92F-A6D4-4BC1-9A36-4AFC2511A5B7}"
+        };
+        for (String malformedGuid : malformedGuids) {
+            assertThrows(TikaException.class,
+                    () -> GUID.fromCurlyBraceUTF16Bytes(
+                            malformedGuid.getBytes(StandardCharsets.UTF_16LE)));
+        }
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
index bab0a6fbcb..e2c9e410dd 100644
--- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
@@ -19,23 +19,128 @@ package org.apache.tika.parser.microsoft.onenote;
 
 import static org.apache.tika.parser.microsoft.onenote.OneNoteParser.ONE_NOTE_PREFIX;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+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 java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Collectors;
 
 import org.apache.commons.lang3.StringUtils;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.ContentHandler;
 
 import org.apache.tika.TikaTest;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.exception.TikaMemoryLimitException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.MSOneStorePackage;
+import org.apache.tika.sax.ToTextContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
 
 public class OneNoteParserTest extends TikaTest {
 
     //test recursive parser wrapper for image files
 
+    @Test
+    public void testFuzzerRegressionInputsFallBackToLegacyDump() throws Exception {
+        // a structural failure falls back to the legacy string dump; the failure is
+        // pinned in the parse-warning metadata
+        String[][] resources = {
+                {"testOneNote-fuzz1.one", "Missing dependent revision"},
+                {"testOneNote-fuzz2.one", "unified property count"},
+                {"testOneNote-fuzz3.one", "Invalid GUID string"}
+        };
+        for (String[] resource : resources) {
+            InputStream input = getClass().getResourceAsStream("/test-documents/" + resource[0]);
+            assertNotNull(input, resource[0]);
+            try (InputStream stream = input;
+                 TikaInputStream tis = TikaInputStream.get(stream)) {
+                Metadata metadata = new Metadata();
+                new OneNoteParser().parse(tis, new ToTextContentHandler(), metadata,
+                        new ParseContext());
+                assertTrue(Arrays.stream(
+                                metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                        .anyMatch(warning -> warning.contains(resource[1])
+                                && warning.contains("falling back to legacy text dump")),
+                        () -> resource[0] + ": " + Arrays.toString(metadata.getValues(
+                                TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)));
+            }
+        }
+    }
+
+    @Test
+    public void testNoContentFsshttpbWalkFallsBackToLegacyDump(@TempDir Path tempDir)
+            throws Exception {
+        Path file = tempDir.resolve("empty-walk.one");
+        // trailing newline: the dump excludes the file's final byte from scanning
+        Files.write(file,
+                "recoverable legacy dump text\n".getBytes(StandardCharsets.US_ASCII));
+        Metadata metadata = new Metadata();
+        StringWriter writer = new StringWriter();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new ToTextContentHandler(writer),
+                metadata);
+        xhtml.startDocument();
+        try (OneNoteDirectFileResource resource =
+                new OneNoteDirectFileResource(file.toFile())) {
+            // fresh package = the walk completed without emitting anything
+            OneNoteParser.legacyFallbackIfNoContent(new MSOneStorePackage(), metadata, xhtml,
+                    resource);
+            assertTrue(writer.toString().contains("recoverable legacy dump text"));
+            assertTrue(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                    .contains("produced no content"));
+
+            // pkg == null means the exception path already dumped - no second dump
+            StringWriter exceptionPath = new StringWriter();
+            XHTMLContentHandler xhtml2 = new XHTMLContentHandler(
+                    new ToTextContentHandler(exceptionPath), metadata);
+            xhtml2.startDocument();
+            OneNoteParser.legacyFallbackIfNoContent(null, metadata, xhtml2, resource);
+            xhtml2.endDocument();
+            assertTrue(exceptionPath.toString().isBlank());
+        }
+        xhtml.endDocument();
+    }
+
+    @Test
+    public void testTruncatedFileFallsBackToLegacyDump(@TempDir Path tempDir) throws Exception {
+        byte[] full;
+        try (InputStream is = getClass()
+                .getResourceAsStream("/test-documents/testOneNote1.one")) {
+            full = is.readAllBytes();
+        }
+        // keep the 1024-byte header plus a sliver of content so the root file node list
+        // is unreachable
+        Path truncated = tempDir.resolve("truncated.one");
+        Files.write(truncated, Arrays.copyOf(full, 2048));
+
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(truncated)) {
+            new OneNoteParser().parse(tis, new ToTextContentHandler(), metadata,
+                    new ParseContext());
+        }
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("falling back to legacy text dump")));
+    }
+
     /**
      * This is the sample document that is automatically created from onenote 2013.
      */
@@ -223,6 +328,217 @@ public class OneNoteParserTest extends TikaTest {
                         ml.get("Content-Type"))));
     }
 
+    @Test
+    public void testOneNoteEmbeddedImage() throws Exception {
+        List<byte[]> embedded = new ArrayList<>();
+        List<String> embeddedTypes = new ArrayList<>();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, new EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata) {
+                return true;
+            }
+
+            @Override
+            public void parseEmbedded(InputStream stream, ContentHandler handler,
+                                      Metadata metadata, boolean outputHtml) throws IOException {
+                embedded.add(stream.readAllBytes());
+                embeddedTypes.add(metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+            }
+        });
+        try (InputStream tis = getResourceAsStream("/test-documents/testOneNoteEmbeddedImage.one")) {
+            new OneNoteParser().parse(tis, new ToTextContentHandler(), new Metadata(), context);
+        }
+
+        assertEquals(1, embedded.size());
+        assertTrue(embedded.stream().anyMatch(bytes -> bytes.length > 1000),
+                () -> "embedded sizes: " + embedded.stream().map(bytes -> bytes.length).collect(Collectors.toList()));
+        assertTrue(embeddedTypes.contains("INLINE"));
+    }
+
+    @Test
+    public void testOneNoteEmbeddedImageRecursiveMetadata() throws Exception {
+        List<Metadata> metadataList = getRecursiveMetadata("testOneNoteEmbeddedImage.one");
+
+        assertEquals(2, metadataList.size());
+        Metadata embedded = metadataList.get(1);
+        assertEquals("INLINE", embedded.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertNotNull(embedded.get(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID));
+    }
+
+    @Test
+    public void testPropertyValueBudgetIsSharedAcrossCopiesAndResetsPerList(
+            @TempDir Path tempDir) throws Exception {
+        Path emptyFile = tempDir.resolve("empty");
+        Files.write(emptyFile, ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
+                .putLong(FileNodeListHeader.UNIT_MAGIC_CONSTANT).putInt(0x10).putInt(0)
+                .putLong(-1).putInt(0).putLong(OneNotePtr.FOOTER_CONST).array());
+        Method reservePropertyCount = OneNotePtr.class.getDeclaredMethod(
+                "reservePropertyCount", long.class, String.class);
+        reservePropertyCount.setAccessible(true);
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(emptyFile.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            OneNotePtr copy = new OneNotePtr(ptr);
+            reservePropertyCount.invoke(ptr, 100_000L, "test");
+            InvocationTargetException exception = assertThrows(InvocationTargetException.class,
+                    () -> reservePropertyCount.invoke(copy, 1L, "test"));
+            assertTrue(exception.getCause() instanceof TikaMemoryLimitException);
+
+            ptr.deserializeFileNodeList(new FileNodeList(), new FileNodePtr());
+            reservePropertyCount.invoke(ptr, 1L, "test");
+        }
+    }
+
+    @Test
+    public void testFileNodeCycleIsReportedAndStopsTraversal(@TempDir Path tempDir) throws Exception {
+        FileNode fileNode = new FileNode().setGosid(ExtendedGUID.nil());
+        fileNode.childFileNodeList.setFileNodeListHeader(new FileNodeListHeader(0,
+                FileNodeListHeader.UNIT_MAGIC_CONSTANT, 0x10, 0));
+        fileNode.childFileNodeList.children.add(fileNode);
+        Metadata metadata = new Metadata();
+        ParseContext parseContext = new ParseContext();
+        Path emptyFile = Files.createFile(tempDir.resolve("empty"));
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(emptyFile.toFile())) {
+            OneNoteTreeWalker walker = new OneNoteTreeWalker(new OneNoteTreeWalkerOptions(),
+                    new OneNoteDocument(), dif,
+                    new XHTMLContentHandler(new ToTextContentHandler(), metadata),
+                    metadata, parseContext, null);
+            walker.walkFileNode(fileNode, null);
+        }
+        assertEquals(1, Arrays.stream(
+                        metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .filter(warning -> warning.contains("file-node cycle detected"))
+                .count());
+    }
+
+    @Test
+    public void testDeepFileNodeChainIsDepthCapped(@TempDir Path tempDir) throws Exception {
+        // a long acyclic chain slips past the cycle guard - the depth cap must stop it
+        // before the recursion can overflow the stack
+        FileNode root = new FileNode().setGosid(ExtendedGUID.nil());
+        FileNode current = root;
+        for (int i = 0; i < 600; i++) {
+            FileNode child = new FileNode().setGosid(ExtendedGUID.nil());
+            current.childFileNodeList.setFileNodeListHeader(new FileNodeListHeader(0,
+                    FileNodeListHeader.UNIT_MAGIC_CONSTANT, 0x10, 0));
+            current.childFileNodeList.children.add(child);
+            current = child;
+        }
+        Metadata metadata = new Metadata();
+        ParseContext parseContext = new ParseContext();
+        Path emptyFile = Files.createFile(tempDir.resolve("empty"));
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(emptyFile.toFile())) {
+            OneNoteTreeWalker walker = new OneNoteTreeWalker(new OneNoteTreeWalkerOptions(),
+                    new OneNoteDocument(), dif,
+                    new XHTMLContentHandler(new ToTextContentHandler(), metadata),
+                    metadata, parseContext, null);
+            walker.walkFileNode(root, null);
+        }
+        assertEquals(1, Arrays.stream(
+                        metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .filter(warning -> warning.contains("exceeded depth limit"))
+                .count());
+    }
+
+    /**
+     * Writes one minimal 56-byte file-node-list fragment: header, a single baseType-2 node
+     * whose child list is at {childStp, childCb}, a terminator, a nil next-fragment
+     * reference and the footer.
+     */
+    private static void writeFileNodeListBlock(ByteBuffer buf, long childStp, int childCb) {
+        // id=0x10, size=16 (node header + 8-byte stp + 4-byte cb), stpFormat=0, cbFormat=0,
+        // baseType=2, reserved=1
+        int fileNodeHeader = 0x10 | (16 << 10) | (2 << 27) | (1 << 31);
+        buf.putLong(FileNodeListHeader.UNIT_MAGIC_CONSTANT).putInt(0x10).putInt(0)
+                .putInt(fileNodeHeader).putLong(childStp).putInt(childCb)
+                .putInt(0)
+                .putLong(-1).putInt(0)
+                .putLong(OneNotePtr.FOOTER_CONST);
+    }
+
+    @Test
+    public void testFileNodeListCycleFailsCleanly(@TempDir Path tempDir) throws Exception {
+        // one fragment holding a baseType-2 node whose child list points back at itself
+        ByteBuffer buf = ByteBuffer.allocate(56).order(ByteOrder.LITTLE_ENDIAN);
+        writeFileNodeListBlock(buf, 0, 56);
+        Path cyclic = tempDir.resolve("cyclic");
+        Files.write(cyclic, buf.array());
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(cyclic.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            TikaException e = assertThrows(TikaException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new FileNodePtr()));
+            assertTrue(e.getMessage().contains("cycle"), e.getMessage());
+        }
+    }
+
+    @Test
+    public void testFileNodeListNestingIsDepthCapped(@TempDir Path tempDir) throws Exception {
+        // 120 lists, each holding one baseType-2 node pointing at the next list - acyclic,
+        // so only the depth cap can stop the recursion
+        int lists = 120;
+        ByteBuffer buf = ByteBuffer.allocate(56 * lists).order(ByteOrder.LITTLE_ENDIAN);
+        for (int i = 0; i < lists; i++) {
+            long childStp = (i + 1 < lists ? i + 1 : i) * 56L;
+            writeFileNodeListBlock(buf, childStp, 56);
+        }
+        Path deep = tempDir.resolve("deep");
+        Files.write(deep, buf.array());
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(deep.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            assertThrows(TikaMemoryLimitException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new FileNodePtr()));
+        }
+    }
+
+    @Test
+    public void testFragmentChainCycleFailsCleanly(@TempDir Path tempDir) throws Exception {
+        // an empty fragment whose next-fragment reference points back at itself would
+        // previously loop forever
+        ByteBuffer buf = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
+                .putLong(FileNodeListHeader.UNIT_MAGIC_CONSTANT).putInt(0x10).putInt(0)
+                .putLong(0).putInt(36)
+                .putLong(OneNotePtr.FOOTER_CONST);
+        Path cyclic = tempDir.resolve("cyclic-fragment");
+        Files.write(cyclic, buf.array());
+        try (OneNoteDirectFileResource dif = new OneNoteDirectFileResource(cyclic.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            TikaException e = assertThrows(TikaException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new FileNodePtr()));
+            assertTrue(e.getMessage().contains("fragment cycle"), e.getMessage());
+        }
+    }
+
+    @Test
+    public void testLegacyEmbeddedExtractionHonorsShouldParseEmbedded() throws Exception {
+        List<Metadata> offered = new ArrayList<>();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, new EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata) {
+                offered.add(metadata);
+                return false;
+            }
+
+            @Override
+            public void parseEmbedded(InputStream stream, ContentHandler handler,
+                                      Metadata metadata, boolean outputHtml) {
+                throw new AssertionError("must not parse embedded when shouldParseEmbedded" +
+                        " returns false");
+            }
+        });
+        try (InputStream tis =
+                getResourceAsStream("/test-documents/testOneNoteEmbeddedWordDoc.one")) {
+            new OneNoteParser().parse(tis, new ToTextContentHandler(), new Metadata(), context);
+        }
+        assertTrue(offered.size() > 0);
+        // the legacy path offers every embedded object as an ATTACHMENT; the relationship
+        // id is set only when the object carries a non-nil gosid
+        for (Metadata offeredMetadata : offered) {
+            assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString(),
+                    offeredMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        }
+    }
+
     /**
      * Test a document pulled from Office 365 which stores the MS-ONESTORE document using the MS-FSSHTTPB
      * protocol.
@@ -232,7 +548,14 @@ public class OneNoteParserTest extends TikaTest {
         Metadata metadata = new Metadata();
         String txt = getText("testOneNoteFromOffice365.one", metadata);
 
-        assertEquals(1, metadata.getValues(ONE_NOTE_PREFIX + "mostRecentAuthors").length);
+        // only the authors of the current content count - authors that only appear in
+        // older page version snapshots are not reported
+        assertEquals(Arrays.asList("Chang Du", "Du Chang"),
+                Arrays.asList(metadata.getValues(TikaCoreProperties.CREATOR)));
+        // both authors are referenced as AuthorMostRecent by current content; one of them
+        // is first visited under another role, so its most-recent role must still register
+        assertEquals(Arrays.asList("Chang Du", "Du Chang"),
+                Arrays.asList(metadata.getValues(ONE_NOTE_PREFIX + "mostRecentAuthors")));
 
         assertEquals(Instant.ofEpochSecond(1636621406),
                 Instant.ofEpochSecond(Long.parseLong(metadata.get(ONE_NOTE_PREFIX + "creationTimestamp"))));
@@ -241,6 +564,9 @@ public class OneNoteParserTest extends TikaTest {
         assertEquals(Instant.ofEpochSecond(1636621448),
                 Instant.ofEpochSecond(Long.parseLong(metadata.get(TikaCoreProperties.MODIFIED))));
         assertContains("Section1Page1Content", txt);
+        // content from revisions other than each cell's current revision manifest
+        assertContains("Section1Page2Content", txt);
+        assertTrue(txt.indexOf("Section1Page1Content") < txt.indexOf("Section1Page2Content"));
     }
 
     /**
@@ -252,20 +578,23 @@ public class OneNoteParserTest extends TikaTest {
         Metadata metadata = new Metadata();
         String txt = getText("testOneNoteFromOffice365-2.one", metadata);
 
+        assertEquals(List.of("Robert Lucarini"),
+                Arrays.asList(metadata.getValues(TikaCoreProperties.CREATOR)));
         List<String> mostRecentAuthors = Arrays.asList(metadata.getValues(ONE_NOTE_PREFIX + "mostRecentAuthors"));
         assertContains(
-                "R\u0000o\u0000b\u0000e\u0000r\u0000t\u0000 \u0000L\u0000u\u0000c\u0000a" +
-                        "\u0000r\u0000i\u0000n\u0000i\u0000\u0000\u0000",
+                "Robert Lucarini",
                 mostRecentAuthors);
 
         assertEquals(Instant.ofEpochSecond(1591712300),
                 Instant.ofEpochSecond(Long.parseLong(metadata.get(ONE_NOTE_PREFIX + "creationTimestamp"))));
-        assertEquals(Instant.ofEpochMilli(1623252330000L),
+        assertEquals(Instant.ofEpochMilli(1623597638000L),
                 Instant.ofEpochMilli(Long.parseLong(metadata.get(ONE_NOTE_PREFIX + "lastModifiedTimestamp"))));
         assertEquals(Instant.ofEpochSecond(1623597587),
                 Instant.ofEpochSecond(Long.parseLong(metadata.get(TikaCoreProperties.MODIFIED))));
 
         assertContains("Section1Page1Content", txt);
+        // content from revisions other than each cell's current revision manifest
+        assertContains("Section1Page2Content", txt);
     }
 
     private void assertNoJunk(String txt) {
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
new file mode 100644
index 0000000000..ea5a4c986b
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
@@ -0,0 +1,343 @@
+/*
+ * 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.onenote.fsshttpb;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.onenote.OneNoteTreeWalkerOptions;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.NoData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtFourBytesOfLengthFollowedByData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.DataElement;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.FileDataObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectDataBLOB;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectDataBLOBDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupObjectBLOBDataDeclaration;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupObjectData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupObjectDataBLOBReference;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupObjectDeclare;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySet;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySetObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObjectGroup;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StreamObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StreamObjectParseErrorException;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.BinaryItem;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.DataElementType;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGUIDArray;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGuid;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyID;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyType;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectPropSet;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.ByteUtil;
+import org.apache.tika.sax.ToTextContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Tests for object data BLOBs (embedded images and files) in the MS-FSSHTTPB packaged
+ * revision store.
+ */
+public class MSOneStoreBlobTest {
+
+    private static final byte[] BLOB_BYTES =
+            "these are the bytes of an embedded image".getBytes(StandardCharsets.UTF_8);
+
+    /**
+     * An object data BLOB data element must survive a serialization round trip. Before
+     * ObjectDataBLOBDataElementData existed, deserializing a data element of this type threw,
+     * so any OneNote file with an embedded image or file lost its structure entirely.
+     */
+    @Test
+    public void testObjectDataBLOBDataElementRoundTrip() throws Exception {
+        DataElement reparsed = roundTripBlobElement();
+        assertEquals(DataElementType.ObjectDataBLOBDataElementData, reparsed.dataElementType);
+        assertArrayEquals(BLOB_BYTES,
+                ((ObjectDataBLOBDataElementData) reparsed.data).objectDataBLOB.getData());
+    }
+
+    /**
+     * A revision store object whose BLOB reference resolves to an object data BLOB element
+     * must expose the BLOB bytes and hand them to the embedded document extractor during the
+     * tree walk.
+     */
+    @Test
+    public void testBlobIsEmittedAsEmbeddedDocument() throws Exception {
+        DataElement blobElement = roundTripBlobElement();
+
+        ObjectGroupObjectBLOBDataDeclaration declaration =
+                new ObjectGroupObjectBLOBDataDeclaration();
+        declaration.objectExGUID = new ExGuid(1, UUID.randomUUID());
+        declaration.objectPartitionID.setDecodedValue(2);
+        ObjectGroupObjectDataBLOBReference blobReference =
+                new ObjectGroupObjectDataBLOBReference();
+        blobReference.blobExtendedGUID = blobElement.dataElementExGuid;
+
+        ObjectGroupDataElementData groupData = new ObjectGroupDataElementData();
+        groupData.objectGroupDeclarations.objectGroupObjectBLOBDataDeclarationList
+                .add(declaration);
+        groupData.objectGroupData.objectGroupObjectDataBLOBReferenceList.add(blobReference);
+
+        RevisionStoreObjectGroup objectGroup =
+                RevisionStoreObjectGroup.createInstance(new ExGuid(2, UUID.randomUUID()),
+                        groupData, false,
+                        Collections.singletonMap(blobElement.dataElementExGuid, blobElement));
+        assertEquals(1, objectGroup.objects.size());
+        assertArrayEquals(BLOB_BYTES, objectGroup.objects.get(0).fileDataObject.getData());
+        addImageContainer(objectGroup, objectGroup.objects.get(0), "picture.png");
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.OtherFileNodeList.add(objectGroup);
+
+        List<byte[]> embedded = new ArrayList<>();
+        List<String> embeddedNames = new ArrayList<>();
+        List<String> embeddedTypes = new ArrayList<>();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, new EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata) {
+                return true;
+            }
+
+            @Override
+            public void parseEmbedded(InputStream stream, ContentHandler handler,
+                                      Metadata metadata, boolean outputHtml) throws IOException {
+                embedded.add(stream.readAllBytes());
+                embeddedNames.add(metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+                embeddedTypes.add(metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+            }
+        });
+
+        Metadata metadata = new Metadata();
+        XHTMLContentHandler xhtml =
+                new XHTMLContentHandler(new ToTextContentHandler(new StringWriter()), metadata);
+        xhtml.startDocument();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata, xhtml, context);
+        xhtml.endDocument();
+
+        assertEquals(1, embedded.size());
+        assertArrayEquals(BLOB_BYTES, embedded.get(0));
+        assertEquals("picture.png", embeddedNames.get(0));
+        assertEquals("INLINE", embeddedTypes.get(0));
+    }
+
+    private static void addImageContainer(RevisionStoreObjectGroup objectGroup,
+                                           RevisionStoreObject blob, String name) throws IOException {
+        RevisionStoreObject parent = new RevisionStoreObject();
+        parent.objectID = new ExGuid(9, UUID.randomUUID());
+        parent.objectGroupID = objectGroup.objectGroupID;
+        parent.referencedObjectID = new ExGUIDArray();
+        parent.referencedObjectID.content = Collections.singletonList(blob.objectID);
+
+        PropertySet propertySet = new PropertySet();
+        propertySet.cProperties = 2;
+        PropertyID fileNameID = new PropertyID();
+        fileNameID.type = PropertyType.FourBytesOfLengthFollowedByData.getIntVal();
+        fileNameID.value = 0x1C001DD7;
+        PropertyID containerID = new PropertyID();
+        containerID.type = PropertyType.ObjectID.getIntVal();
+        containerID.value = 0x20001C3F;
+        propertySet.rgPrids = new PropertyID[]{fileNameID, containerID};
+        PrtFourBytesOfLengthFollowedByData fileName = new PrtFourBytesOfLengthFollowedByData();
+        fileName.data = (name + "\u0000").getBytes(StandardCharsets.UTF_16LE);
+        fileName.cb = fileName.data.length;
+        propertySet.rgData = Arrays.asList(fileName, new NoData());
+        ObjectSpaceObjectPropSet propSet = new ObjectSpaceObjectPropSet();
+        propSet.body = propertySet;
+        ObjectGroupObjectData objectData = new ObjectGroupObjectData();
+        objectData.data.content.addAll(ByteUtil.toListOfByte(new byte[]{0, 0, 0, (byte) 0x80,
+                0, 0, 0, 0}));
+        PropertySetObject propertySetObject = new PropertySetObject(null, objectData);
+        propertySetObject.objectSpaceObjectPropSet = propSet;
+        parent.propertySet = propertySetObject;
+        objectGroup.objects.add(0, parent);
+    }
+
+    @Test
+    public void testMissingBlobDataAndMalformedLengthAreHandled() throws Exception {
+        FileDataObject fileData = new FileDataObject();
+        assertNull(fileData.getData());
+
+        DataElement wrongType = new DataElement();
+        fileData.objectDataBLOBDataElement = wrongType;
+        assertNull(fileData.getData());
+
+        ObjectDataBLOBDataElementData emptyBlobData = new ObjectDataBLOBDataElementData();
+        emptyBlobData.objectDataBLOB = null;
+        wrongType.data = emptyBlobData;
+        assertNull(fileData.getData());
+
+        ObjectDataBLOB blob = new ObjectDataBLOB();
+        blob.data = null;
+        assertNull(blob.getData());
+        blob.data = new BinaryItem();
+        blob.data.content = null;
+        assertNull(blob.getData());
+        blob.data = new BinaryItem();
+        blob.data.content.add((byte) 1);
+        assertFalse(blob.serializeToByteList().isEmpty());
+
+        ObjectDataBLOB validBlob = new ObjectDataBLOB();
+        validBlob.data.content.add((byte) 1);
+        byte[] itemBytes = ByteUtil.toByteArray(validBlob.data.serializeToByteList());
+        AtomicInteger index = new AtomicInteger(0);
+        java.lang.reflect.Method deserialize = ObjectDataBLOB.class.getDeclaredMethod(
+                "deserializeItemsFromByteArray", byte[].class, AtomicInteger.class, int.class);
+        deserialize.setAccessible(true);
+        InvocationTargetException exception = assertThrows(InvocationTargetException.class,
+                () -> deserialize.invoke(new ObjectDataBLOB(), itemBytes, index,
+                        itemBytes.length + 1));
+        assertTrue(exception.getCause() instanceof StreamObjectParseErrorException);
+    }
+
+    @Test
+    public void testEmptyAndFailedEmbeddedDataAreIgnored() throws Exception {
+        MSOneStorePackage emptyPackage = packageWithFileData(new ObjectDataBLOBDataElementData());
+        walkWithExtractor(emptyPackage, new EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata) {
+                throw new AssertionError("empty data must not reach the extractor");
+            }
+
+            @Override
+            public void parseEmbedded(InputStream stream, ContentHandler handler,
+                                      Metadata metadata, boolean outputHtml) {
+            }
+        });
+
+        DataElement blobElement = roundTripBlobElement();
+        MSOneStorePackage failedPackage = packageWithFileData(
+                (ObjectDataBLOBDataElementData) blobElement.data);
+        walkWithExtractor(failedPackage, new EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata) {
+                return true;
+            }
+
+            @Override
+            public void parseEmbedded(InputStream stream, ContentHandler handler,
+                                      Metadata metadata, boolean outputHtml) throws IOException {
+                throw new IOException("synthetic embedded parse failure");
+            }
+        });
+    }
+
+    @Test
+    public void testDeclarationWithoutObjectDataFailsCleanly() {
+        ObjectGroupDataElementData mismatched = new ObjectGroupDataElementData();
+        ObjectGroupObjectDeclare declaration = new ObjectGroupObjectDeclare();
+        declaration.objectPartitionID.setDecodedValue(1);
+        mismatched.objectGroupDeclarations.objectDeclarationList.add(declaration);
+
+        IOException e = assertThrows(IOException.class,
+                () -> RevisionStoreObjectGroup.createInstance(new ExGuid(9, UUID.randomUUID()),
+                        mismatched, false, Collections.emptyMap()));
+        assertTrue(e.getMessage().contains("Missing object data"));
+    }
+
+    @Test
+    public void testEncryptedObjectGroupAndMissingBlobReference() throws Exception {
+        ObjectGroupDataElementData encryptedData = new ObjectGroupDataElementData();
+        ObjectGroupObjectDeclare declaration = new ObjectGroupObjectDeclare();
+        declaration.objectPartitionID.setDecodedValue(1);
+        ObjectGroupObjectData objectData = new ObjectGroupObjectData();
+        objectData.data.content.add((byte) 42);
+        encryptedData.objectGroupDeclarations.objectDeclarationList.add(declaration);
+        encryptedData.objectGroupData.objectGroupObjectDataList.add(objectData);
+
+        RevisionStoreObjectGroup encrypted = RevisionStoreObjectGroup.createInstance(
+                new ExGuid(3, UUID.randomUUID()), encryptedData, true, Collections.emptyMap());
+        assertEquals(1, encrypted.encryptionObjects.size());
+
+        ObjectGroupDataElementData missingBlobData = new ObjectGroupDataElementData();
+        ObjectGroupObjectBLOBDataDeclaration blobDeclaration =
+                new ObjectGroupObjectBLOBDataDeclaration();
+        blobDeclaration.objectExGUID = new ExGuid(4, UUID.randomUUID());
+        blobDeclaration.objectPartitionID.setDecodedValue(2);
+        ObjectGroupObjectDataBLOBReference reference =
+                new ObjectGroupObjectDataBLOBReference();
+        reference.blobExtendedGUID = new ExGuid(5, UUID.randomUUID());
+        missingBlobData.objectGroupDeclarations.objectGroupObjectBLOBDataDeclarationList
+                .add(blobDeclaration);
+        missingBlobData.objectGroupData.objectGroupObjectDataBLOBReferenceList.add(reference);
+        RevisionStoreObjectGroup missing = RevisionStoreObjectGroup.createInstance(
+                new ExGuid(6, UUID.randomUUID()), missingBlobData, false, Collections.emptyMap());
+        assertEquals(1, missing.objects.size());
+        assertNull(missing.objects.get(0).fileDataObject.getData());
+    }
+
+    private MSOneStorePackage packageWithFileData(ObjectDataBLOBDataElementData blobData) {
+        DataElement element = new DataElement();
+        element.data = blobData;
+        FileDataObject fileData = new FileDataObject();
+        fileData.objectDataBLOBDataElement = element;
+        RevisionStoreObject object = new RevisionStoreObject();
+        object.objectID = new ExGuid(7, UUID.randomUUID());
+        object.fileDataObject = fileData;
+        RevisionStoreObjectGroup group = new RevisionStoreObjectGroup(
+                new ExGuid(8, UUID.randomUUID()));
+        group.objects.add(object);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.OtherFileNodeList.add(group);
+        return pkg;
+    }
+
+    private void walkWithExtractor(MSOneStorePackage pkg, EmbeddedDocumentExtractor extractor)
+            throws Exception {
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, extractor);
+        Metadata metadata = new Metadata();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new ToTextContentHandler(new StringWriter()), metadata);
+        xhtml.startDocument();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata, xhtml, context);
+        xhtml.endDocument();
+    }
+
+    private DataElement roundTripBlobElement() throws Exception {
+        ObjectDataBLOBDataElementData blobData = new ObjectDataBLOBDataElementData();
+        for (byte b : BLOB_BYTES) {
+            blobData.objectDataBLOB.data.content.add(b);
+        }
+        DataElement blobElement =
+                new DataElement(DataElementType.ObjectDataBLOBDataElementData, blobData);
+        byte[] serialized = ByteUtil.toByteArray(blobElement.serializeToByteList());
+        return StreamObject.getCurrent(serialized, new AtomicInteger(0), DataElement.class);
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
new file mode 100644
index 0000000000..ab597063fc
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
@@ -0,0 +1,846 @@
+/*
+ * 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.onenote.fsshttpb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.StringWriter;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.onenote.OneNoteParser;
+import org.apache.tika.parser.microsoft.onenote.OneNoteTreeWalkerOptions;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.ArrayNumber;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.IProperty;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.NoData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtArrayOfPropertyValues;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtFourBytesOfLengthFollowedByData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.DataElement;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.FileDataObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectDataBLOB;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectDataBLOBDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupObjectData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySet;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySetObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestRootDeclare;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreCell;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObject;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreObjectGroup;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.CellID;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.DataElementType;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGuid;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyID;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyType;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectPropSet;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.util.ByteUtil;
+import org.apache.tika.sax.ToTextContentHandler;
+import org.apache.tika.sax.ToXMLContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+public class MSOneStorePackageTest {
+
+    @Test
+    public void testParseWarningsAreBoundedAcrossParserAndWalkPhases() throws Exception {
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        for (int i = 0; i < 99; i++) {
+            pkg.recordParseWarning("warning " + i);
+        }
+        pkg.recordParseWarning("duplicate warning");
+        pkg.recordParseWarning("duplicate warning");
+        RevisionStoreCell damagedCell = new RevisionStoreCell();
+        RevisionManifestRootDeclare missingRoot = new RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(1006);
+        damagedCell.rootDeclares.add(missingRoot);
+        pkg.cells.add(damagedCell);
+
+        Metadata metadata = new Metadata();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata,
+                new XHTMLContentHandler(new ToTextContentHandler(), metadata),
+                new ParseContext());
+
+        String[] warnings = metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING);
+        assertEquals(101, warnings.length);
+        assertEquals(1, Arrays.stream(warnings)
+                .filter(warning -> warning.equals("duplicate warning")).count());
+        assertTrue(Arrays.stream(warnings).anyMatch(warning -> warning.contains("suppressed")));
+    }
+
+    @Test
+    public void testReferenceArrayReportsUnavailableAndCappedWarnings() throws Exception {
+        ExGuid rootId = id(2000);
+        List<ExGuid> references = new ArrayList<>(100_001);
+        for (int i = 0; i < 100_001; i++) {
+            references.add(rootId);
+        }
+        RevisionStoreObject root = object(rootId,
+                propertySet(new PropertySpec(PropertyType.ArrayOfObjectIDs, 0x24001D5F,
+                        arrayNumber(100_002))), references, Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = rootId;
+        cell.rootDeclares.add(rootDeclare);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+
+        Metadata metadata = new Metadata();
+        walk(pkg, metadata);
+        String[] warnings = metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING);
+        assertTrue(Arrays.stream(warnings)
+                .anyMatch(warning -> warning.contains("declared 100002 entries")));
+        assertTrue(Arrays.stream(warnings)
+                .anyMatch(warning -> warning.contains("Capping OneNote object reference array")));
+    }
+
+    @Test
+    public void testPagesFollowSectionOrderAndDropOlderCellVersions() throws Exception {
+        ExGuid sectionRootId = id(1);
+        CellID pageOne = cell(10, 100);
+        CellID pageTwo = cell(20, 200);
+        CellID oldPageOne = cell(11, 100);
+        RevisionStoreCell pageTwoCell = cellWithText(pageTwo, "page two");
+        RevisionStoreCell oldPageOneCell = cellWithText(oldPageOne, "old page one");
+        RevisionStoreCell pageOneCell = cellWithText(pageOne, "page one");
+        RevisionStoreCell unrelatedCell = cellWithText(cell(30, 300), "unrelated");
+
+        RevisionStoreObject sectionRoot = object(sectionRootId,
+                propertySet(new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78,
+                                new NoData()),
+                        new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D79,
+                                new NoData())),
+                Collections.emptyList(), Arrays.asList(pageOne, pageTwo));
+        RevisionStoreCell section = new RevisionStoreCell();
+        section.objectGroups.add(group(sectionRoot));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = sectionRootId;
+        section.rootDeclares.add(rootDeclare);
+        RevisionManifestRootDeclare missingRoot = new RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(1000);
+        section.rootDeclares.add(missingRoot);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.dataRootCell = section;
+        pkg.cells.addAll(Arrays.asList(pageTwoCell, oldPageOneCell, pageOneCell, unrelatedCell));
+
+        Metadata metadata = new Metadata();
+        String text = walk(pkg, metadata);
+        assertTrue(text.indexOf("page one") >= 0);
+        assertTrue(text.indexOf("page two") >= 0);
+        assertTrue(text.indexOf("page one") < text.indexOf("page two"));
+        assertFalse(text.contains("old page one"));
+        assertTrue(text.contains("unrelated"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("could not be resolved")));
+    }
+
+    @Test
+    public void testUnresolvedRootsFallBackToAllObjects() throws Exception {
+        RevisionStoreCell cell = cellWithText(cell(1, 1), "fallback content");
+        cell.rootDeclares.clear();
+        RevisionManifestRootDeclare missingRoot = new RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(999);
+        cell.rootDeclares.add(missingRoot);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+
+        assertTrue(walk(pkg, metadata).contains("fallback content"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains(id(999).toString())));
+    }
+
+    @Test
+    public void testExhaustedObjectReferenceTriggersFallbackWarning() throws Exception {
+        RevisionStoreObject root = object(id(1004), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D78, new NoData())),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreObject fallback = object(id(1005), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("fallback after exhausted reference"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, fallback));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+        String text = walk(pkg, metadata);
+
+        assertFalse(text.contains("fallback after exhausted reference"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("reference slot was exhausted")));
+    }
+
+    @Test
+    public void testExhaustedObjectSpaceReferenceWarns() throws Exception {
+        ExGuid sectionRootID = id(1007);
+        RevisionStoreCell section = new RevisionStoreCell();
+        section.objectGroups.add(group(object(sectionRootID,
+                propertySet(new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78,
+                        new NoData())), Collections.emptyList(), Collections.emptyList())));
+        RevisionManifestRootDeclare sectionRoot = new RevisionManifestRootDeclare();
+        sectionRoot.objectExGuid = sectionRootID;
+        section.rootDeclares.add(sectionRoot);
+        RevisionStoreCell page = cellWithText(cell(1008, 1009), "page after missing space");
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.dataRootCell = section;
+        pkg.cells.add(page);
+        Metadata metadata = new Metadata();
+        String text = walk(pkg, metadata);
+
+        assertTrue(text.contains("page after missing space"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("object-space reference slot was exhausted")));
+    }
+
+    @Test
+    public void testMixedRootsDoNotTriggerAllObjectFallback() throws Exception {
+        RevisionStoreObject root = object(id(1001), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("resolved root"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreObject unrelated = object(id(1002), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("unrelated object"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, unrelated));
+        RevisionManifestRootDeclare resolvedRoot = new RevisionManifestRootDeclare();
+        resolvedRoot.objectExGuid = root.objectID;
+        cell.rootDeclares.add(resolvedRoot);
+        RevisionManifestRootDeclare missingRoot = new RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(1003);
+        cell.rootDeclares.add(missingRoot);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+        String text = walk(pkg, metadata);
+
+        assertTrue(text.contains("resolved root"));
+        assertFalse(text.contains("unrelated object"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains(id(1003).toString())));
+    }
+
+    @Test
+    public void testOriginalAuthorBecomesCreator() throws Exception {
+        ExGuid authorId = id(701);
+        RevisionStoreObject root = object(id(700), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D78, new NoData())),
+                Collections.singletonList(authorId), Collections.emptyList());
+        RevisionStoreObject author = object(authorId, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C001D75, utf16Text("Иван Петров"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, author));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+        walk(pkg, metadata);
+        assertEquals("Иван Петров", metadata.get(TikaCoreProperties.CREATOR));
+        assertEquals("Иван Петров", metadata.get(OneNoteParser.ONE_NOTE_PREFIX + "originalAuthors"));
+    }
+
+    @Test
+    public void testDualRoleAuthorRecordedForBothRoles() throws Exception {
+        ExGuid authorId = id(710);
+        // the same author object referenced as both AuthorOriginal and AuthorMostRecent
+        RevisionStoreObject root = object(id(711), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D78, new NoData()),
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D79, new NoData())),
+                Arrays.asList(authorId, authorId), Collections.emptyList());
+        RevisionStoreObject author = object(authorId, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C001D75, utf16Text("Single Author"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, author));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+        walk(pkg, metadata);
+
+        assertEquals("Single Author", metadata.get(TikaCoreProperties.CREATOR));
+        assertEquals("Single Author", metadata.get(OneNoteParser.ONE_NOTE_PREFIX + "originalAuthors"));
+        assertEquals("Single Author", metadata.get(OneNoteParser.ONE_NOTE_PREFIX + "mostRecentAuthors"));
+    }
+
+    @Test
+    public void testBlobOnlyRootWithDanglingContentRootWalksAllObjects() throws Exception {
+        RevisionStoreObject blobRoot = object(id(620), propertySet(),
+                Collections.emptyList(), Collections.emptyList());
+        blobRoot.propertySet = null;
+        blobRoot.fileDataObject = fileData("blob root");
+        RevisionStoreObject textObject = object(id(621), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("page body text"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(blobRoot, textObject));
+        RevisionManifestRootDeclare blobDeclare = new RevisionManifestRootDeclare();
+        blobDeclare.objectExGuid = blobRoot.objectID;
+        cell.rootDeclares.add(blobDeclare);
+        RevisionManifestRootDeclare danglingContentRoot = new RevisionManifestRootDeclare();
+        danglingContentRoot.objectExGuid = id(622);
+        cell.rootDeclares.add(danglingContentRoot);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+
+        // the blob root alone cannot reach the page body; the dangling content root must
+        // trigger the walk-everything fallback so the body is not lost
+        assertTrue(walk(pkg, metadata).contains("page body text"));
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("walking all objects")));
+    }
+
+    @Test
+    public void testHasEmittedContentTracksTextAndEmptyWalks() throws Exception {
+        MSOneStorePackage withText = new MSOneStorePackage();
+        withText.cells.add(cellWithText(cell(70, 71), "some text"));
+        walk(withText);
+        assertTrue(withText.hasEmittedContent());
+
+        MSOneStorePackage empty = new MSOneStorePackage();
+        RevisionStoreCell danglingCell = new RevisionStoreCell();
+        RevisionManifestRootDeclare missingRoot = new RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(72);
+        danglingCell.rootDeclares.add(missingRoot);
+        empty.cells.add(danglingCell);
+        walk(empty);
+        assertFalse(empty.hasEmittedContent());
+    }
+
+    @Test
+    public void testSanitizeResourceNameKeepsBasenameOfPathShapedNames() {
+        assertEquals("pic 1.png",
+                MSOneStorePackage.sanitizeResourceName("D:\\images\\pic 1.png"));
+        assertEquals("pic.png", MSOneStorePackage.sanitizeResourceName("C:pic.png"));
+        assertEquals("a.png", MSOneStorePackage.sanitizeResourceName("/tmp/a.png"));
+        assertEquals("plain.png", MSOneStorePackage.sanitizeResourceName("plain.png"));
+        assertEquals("", MSOneStorePackage.sanitizeResourceName(".."));
+        assertEquals("", MSOneStorePackage.sanitizeResourceName("D:\\images\\.."));
+    }
+
+    @Test
+    public void testBlobOnlyRootDoesNotFallBackToOtherObjects() throws Exception {
+        RevisionStoreObject blobRoot = object(id(610), propertySet(),
+                Collections.emptyList(), Collections.emptyList());
+        blobRoot.propertySet = null;
+        blobRoot.fileDataObject = fileData("blob root");
+        RevisionStoreObject textObject = object(id(611), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("fallback content"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(blobRoot, textObject));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = blobRoot.objectID;
+        cell.rootDeclares.add(rootDeclare);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+
+        assertFalse(walk(pkg).contains("fallback content"));
+    }
+
+    @Test
+    public void testPageMarkupIsBalancedAndPinned() throws Exception {
+        CellID pageID = cell(90, 91);
+        RevisionStoreCell page = cellWithText(pageID, "page text");
+        ExGuid sectionRootID = id(92);
+        RevisionStoreCell section = new RevisionStoreCell();
+        section.objectGroups.add(group(object(sectionRootID,
+                propertySet(new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78,
+                        new NoData())), Collections.emptyList(),
+                Collections.singletonList(pageID))));
+        RevisionManifestRootDeclare sectionRoot = new RevisionManifestRootDeclare();
+        sectionRoot.objectExGuid = sectionRootID;
+        section.rootDeclares.add(sectionRoot);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.dataRootCell = section;
+        pkg.cells.add(page);
+
+        String xml = walkXml(pkg);
+        assertEquals(1, count(xml, "<div class=\"page\">"));
+        assertEquals(1, count(xml, "</div>"));
+        assertTrue(xml.contains("page text"));
+    }
+
+    @Test
+    public void testPageMarkupClosesWhenWalkThrows() throws Exception {
+        CellID pageID = cell(95, 96);
+        RevisionStoreCell page = cellWithText(pageID, "page text");
+        ExGuid sectionRootID = id(97);
+        RevisionStoreCell section = new RevisionStoreCell();
+        section.objectGroups.add(group(object(sectionRootID,
+                propertySet(new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78,
+                        new NoData())), Collections.emptyList(),
+                Collections.singletonList(pageID))));
+        RevisionManifestRootDeclare sectionRoot = new RevisionManifestRootDeclare();
+        sectionRoot.objectExGuid = sectionRootID;
+        section.rootDeclares.add(sectionRoot);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.dataRootCell = section;
+        pkg.cells.add(page);
+        Metadata metadata = new Metadata();
+        List<String> elements = new ArrayList<>();
+        DefaultHandler recordingHandler = new DefaultHandler() {
+            private final StringBuilder text = new StringBuilder();
+
+            @Override
+            public void startElement(String uri, String localName, String qName,
+                                     org.xml.sax.Attributes atts) {
+                elements.add(qName);
+            }
+
+            @Override
+            public void endElement(String uri, String localName, String qName) {
+                elements.add("/" + qName);
+            }
+
+            @Override
+            public void characters(char[] ch, int start, int length) throws SAXException {
+                text.append(ch, start, length);
+                if (text.indexOf("page text") >= 0) {
+                    throw new SAXException("intentional test failure");
+                }
+            }
+        };
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(recordingHandler, metadata);
+        xhtml.startDocument();
+
+        assertThrows(SAXException.class, () -> pkg.walkTree(new OneNoteTreeWalkerOptions(),
+                metadata, xhtml, new ParseContext()));
+        assertTrue(elements.contains("p"));
+        assertTrue(elements.contains("/p"));
+        assertTrue(elements.contains("div"));
+        assertTrue(elements.contains("/div"));
+        assertTrue(elements.indexOf("p") < elements.indexOf("/p"));
+        assertTrue(elements.indexOf("div") < elements.indexOf("/div"));
+        assertTrue(elements.indexOf("/p") < elements.indexOf("/div"));
+    }
+
+    @Test
+    public void testRemoveSupersededObjectsKeepsNewestVersion() throws Exception {
+        ExGuid objectID = id(950);
+        RevisionStoreObject oldObject = object(objectID, propertySet(
+                new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                        0x1C003498, text("old"))), Collections.emptyList(),
+                Collections.emptyList());
+        RevisionStoreObject newObject = object(objectID, propertySet(
+                new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                        0x1C003498, text("new"))), Collections.emptyList(),
+                Collections.emptyList());
+        List<RevisionStoreObjectGroup> groups = new ArrayList<>(Arrays.asList(
+                group(oldObject), group(newObject)));
+        Method removeSupersededObjects = MSOneStoreParser.class.getDeclaredMethod(
+                "removeSupersededObjects", List.class);
+        removeSupersededObjects.setAccessible(true);
+
+        removeSupersededObjects.invoke(new MSOneStoreParser(), groups);
+
+        assertEquals(1, groups.get(0).objects.size());
+        assertSame(newObject, groups.get(0).objects.get(0));
+        assertTrue(groups.get(1).objects.isEmpty());
+    }
+
+    @Test
+    public void testArrayCountsAndRecursionDepthAreBounded() throws Exception {
+        RevisionStoreObject hugeArrayRoot = object(id(600), propertySet(
+                        new PropertySpec(PropertyType.ArrayOfObjectIDs, 0x24001D5F,
+                                arrayNumber(Integer.MAX_VALUE)),
+                        new PropertySpec(PropertyType.ArrayOfObjectIDs, 0x24001D5F,
+                                new NoData())),
+                Collections.emptyList(), Collections.emptyList());
+        Method collectActions = MSOneStorePackage.class.getDeclaredMethod("collectActions",
+                PropertySet.class, List.class, int[].class, List.class, int[].class, List.class,
+                int.class);
+        collectActions.setAccessible(true);
+        List<Object> actions = new ArrayList<>();
+        collectActions.invoke(new MSOneStorePackage(),
+                hugeArrayRoot.propertySet.objectSpaceObjectPropSet.body,
+                Collections.emptyList(), new int[]{0}, Collections.emptyList(), new int[]{0},
+                actions, 0);
+        assertTrue(actions.isEmpty());
+        actions.clear();
+        collectActions.invoke(new MSOneStorePackage(),
+                hugeArrayRoot.propertySet.objectSpaceObjectPropSet.body,
+                Collections.singletonList(id(601)), new int[]{0}, Collections.emptyList(),
+                new int[]{0}, actions, 1000);
+        assertTrue(actions.isEmpty());
+
+        Method collectReferencedCells = MSOneStorePackage.class.getDeclaredMethod(
+                "collectReferencedCells", RevisionStoreObject.class, Map.class, Set.class,
+                List.class, int.class);
+        collectReferencedCells.setAccessible(true);
+        RevisionStoreObject referencedRoot = object(id(602), propertySet(
+                        new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78, new NoData())),
+                Collections.emptyList(), Collections.singletonList(cell(603, 604)));
+        List<CellID> orderedCellIds = new ArrayList<>();
+        collectReferencedCells.invoke(new MSOneStorePackage(), referencedRoot,
+                new java.util.HashMap<>(), new java.util.HashSet<>(), orderedCellIds, 1000);
+        assertTrue(orderedCellIds.isEmpty());
+
+        PropertySet alignedSet = propertySet(
+                new PropertySpec(PropertyType.ArrayOfObjectIDs, 0x24001D5F,
+                        arrayNumber(100001)),
+                new PropertySpec(PropertyType.ObjectID, 0x24001D5F, new NoData()));
+        actions.clear();
+        collectActions.invoke(new MSOneStorePackage(), alignedSet,
+                Collections.nCopies(100001, id(601)), new int[]{0}, Collections.emptyList(),
+                new int[]{0}, actions, 0);
+        assertEquals(100001, actions.size());
+        java.lang.reflect.Field childReference = actions.get(actions.size() - 1).getClass()
+                .getDeclaredField("childReference");
+        childReference.setAccessible(true);
+        assertNull(childReference.get(actions.get(actions.size() - 1)));
+
+        Method walkObject = MSOneStorePackage.class.getDeclaredMethod("walkObject",
+                RevisionStoreObject.class, Map.class, Set.class,
+                Class.forName(MSOneStorePackage.class.getName() + "$AuthorRole"),
+                OneNoteTreeWalkerOptions.class, Metadata.class, XHTMLContentHandler.class,
+                int.class);
+        walkObject.setAccessible(true);
+        // an object that emits text when walked, so the depth-capped walk's blank
+        // output proves the cap fired rather than the setup having nothing to emit
+        RevisionStoreObject textRoot = object(id(605), propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("depth capped text"))),
+                Collections.emptyList(), Collections.emptyList());
+        Metadata metadata = new Metadata();
+        StringWriter writer = new StringWriter();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new ToTextContentHandler(writer), metadata);
+        xhtml.startDocument();
+        walkObject.invoke(new MSOneStorePackage(), textRoot,
+                new java.util.HashMap<>(), new java.util.HashSet<>(), null,
+                new OneNoteTreeWalkerOptions(), metadata, xhtml, 1000);
+        assertTrue(writer.toString().isBlank());
+        walkObject.invoke(new MSOneStorePackage(), textRoot,
+                new java.util.HashMap<>(), new java.util.HashSet<>(), null,
+                new OneNoteTreeWalkerOptions(), metadata, xhtml, 0);
+        xhtml.endDocument();
+        assertTrue(writer.toString().contains("depth capped text"));
+    }
+
+    @Test
+    public void testDanglingPrimaryPictureDoesNotSuppressWebPicture() throws Exception {
+        ExGuid missingPictureID = id(50);
+        ExGuid webPictureID = id(51);
+        RevisionStoreObject root = object(id(52), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001C3F, new NoData()),
+                        new PropertySpec(PropertyType.ObjectID, 0x200034C8, new NoData())),
+                Arrays.asList(missingPictureID, webPictureID), Collections.emptyList());
+        RevisionStoreObject webPicture = object(webPictureID, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("derived picture"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, webPicture));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+
+        String text = walk(pkg);
+        assertTrue(text.contains("derived picture"), text);
+    }
+
+    @Test
+    public void testContentlessPrimaryPictureDoesNotSuppressDerivedPicture() throws Exception {
+        ExGuid pictureID = id(40);
+        ExGuid webPictureID = id(41);
+        RevisionStoreObject root = object(id(42), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001C3F, new NoData()),
+                        new PropertySpec(PropertyType.ObjectID, 0x200034C8, new NoData())),
+                Arrays.asList(pictureID, webPictureID), Collections.emptyList());
+        RevisionStoreObject picture = object(pictureID, propertySet(),
+                Collections.emptyList(), Collections.emptyList());
+        picture.propertySet.objectSpaceObjectPropSet.body = null;
+        RevisionStoreObject webPicture = object(webPictureID, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("derived picture"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, picture, webPicture));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+
+        assertTrue(walk(pkg).contains("derived picture"));
+    }
+
+    @Test
+    public void testUsablePrimaryPictureSuppressesDerivedPicture() throws Exception {
+        ExGuid pictureID = id(80);
+        ExGuid webPictureID = id(81);
+        RevisionStoreObject root = object(id(82), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001C3F, new NoData()),
+                        new PropertySpec(PropertyType.ObjectID, 0x200034C8, new NoData())),
+                Arrays.asList(pictureID, webPictureID), Collections.emptyList());
+        RevisionStoreObject picture = object(pictureID, propertySet(),
+                Collections.emptyList(), Collections.emptyList());
+        picture.fileDataObject = fileData("primary image");
+        RevisionStoreObject webPicture = object(webPictureID, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("derived picture"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, picture, webPicture));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+
+        assertFalse(walk(pkg).contains("derived picture"));
+    }
+
+    @Test
+    public void testNestedPropertySetsAndMissingReferencesAreTraversedSafely() throws Exception {
+        ExGuid childId = id(2);
+        PropertySet nested = propertySet(new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                0x1C003498, text("nested text")));
+        PrtArrayOfPropertyValues array = new PrtArrayOfPropertyValues();
+        array.data = new PropertySet[]{propertySet(new PropertySpec(
+                PropertyType.FourBytesOfLengthFollowedByData, 0x1C003498, text("array text")))};
+        RevisionStoreObject root = object(id(1), propertySet(
+                        new PropertySpec(PropertyType.PropertySet, 0, nested),
+                        new PropertySpec(PropertyType.ArrayOfPropertyValues, 0, array),
+                        new PropertySpec(PropertyType.ArrayOfObjectIDs, 0x24001D5F,
+                                arrayNumber(2)),
+                        new PropertySpec(PropertyType.ObjectID, 0x24001D5F, new NoData()),
+                        new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D78, new NoData()),
+                        new PropertySpec(PropertyType.ObjectSpaceID, 0x20001D79, new NoData()),
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C001DD7, bytes((byte) 'u', (byte) 0, (byte) 1)),
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C001C22, bytes((byte) 'h', (byte) 0, (byte) 'i', (byte) 0,
+                                        (byte) 0, (byte) 0))),
+                Collections.singletonList(childId), Collections.singletonList(cell(50, 51)));
+        RevisionStoreObject child = object(childId, propertySet(
+                        new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("child text"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, child));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        String text = walk(pkg);
+        assertTrue(text.contains("nested text"));
+        assertTrue(text.contains("array text"));
+        assertTrue(text.contains("child text"));
+        assertTrue(text.contains("u"));
+        assertTrue(text.contains("hi"));
+    }
+
+    private static String walk(MSOneStorePackage pkg) throws Exception {
+        Metadata metadata = new Metadata();
+        return walk(pkg, metadata);
+    }
+
+    private static String walk(MSOneStorePackage pkg, Metadata metadata) throws Exception {
+        ParseContext context = new ParseContext();
+        StringWriter writer = new StringWriter();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new ToTextContentHandler(writer), metadata);
+        xhtml.startDocument();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata, xhtml, context);
+        xhtml.endDocument();
+        return writer.toString();
+    }
+
+    private static String walkXml(MSOneStorePackage pkg) throws Exception {
+        Metadata metadata = new Metadata();
+        ParseContext context = new ParseContext();
+        ToXMLContentHandler xml = new ToXMLContentHandler();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(xml, metadata);
+        xhtml.startDocument();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata, xhtml, context);
+        xhtml.endDocument();
+        return xml.toString();
+    }
+
+    private static int count(String value, String needle) {
+        return value.split(java.util.regex.Pattern.quote(needle), -1).length - 1;
+    }
+
+    private static RevisionStoreCell cellWithText(CellID cellID, String value) throws Exception {
+        RevisionStoreObject object = object(id(cellID.extendGUID1.hashCode()),
+                propertySet(new PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                        0x1C003498, text(value))), Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.cellID = cellID;
+        cell.objectGroups.add(group(object));
+        RevisionManifestRootDeclare rootDeclare = new RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = object.objectID;
+        cell.rootDeclares.add(rootDeclare);
+        return cell;
+    }
+
+    private static RevisionStoreObject object(ExGuid objectID, PropertySet body,
+                                              List<ExGuid> references, List<CellID> spaces)
+            throws Exception {
+        RevisionStoreObject object = new RevisionStoreObject();
+        object.objectID = objectID;
+        PropertySetObject propertySetObject = new PropertySetObject(null, emptyObjectData());
+        ObjectSpaceObjectPropSet propSet = new ObjectSpaceObjectPropSet();
+        propSet.body = body;
+        propertySetObject.objectSpaceObjectPropSet = propSet;
+        object.propertySet = propertySetObject;
+        if (!references.isEmpty()) {
+            object.referencedObjectID = new org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGUIDArray();
+            object.referencedObjectID.content = references;
+        }
+        if (!spaces.isEmpty()) {
+            object.referencedObjectSpacesID = new org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.CellIDArray();
+            object.referencedObjectSpacesID.content = spaces;
+        }
+        return object;
+    }
+
+    private static FileDataObject fileData(String value) {
+        ObjectDataBLOB blob = new ObjectDataBLOB();
+        blob.data.content.addAll(ByteUtil.toListOfByte(value.getBytes(StandardCharsets.UTF_8)));
+        ObjectDataBLOBDataElementData blobData = new ObjectDataBLOBDataElementData();
+        blobData.objectDataBLOB = blob;
+        DataElement element = new DataElement();
+        element.dataElementType = DataElementType.ObjectDataBLOBDataElementData;
+        element.data = blobData;
+        FileDataObject fileData = new FileDataObject();
+        fileData.objectDataBLOBDataElement = element;
+        return fileData;
+    }
+
+    private static RevisionStoreObjectGroup group(RevisionStoreObject... objects) {
+        RevisionStoreObjectGroup group = new RevisionStoreObjectGroup(id(500));
+        group.objects.addAll(Arrays.asList(objects));
+        return group;
+    }
+
+    private static PropertySet propertySet(PropertySpec... specs) {
+        PropertySet set = new PropertySet();
+        set.cProperties = specs.length;
+        set.rgPrids = new PropertyID[specs.length];
+        set.rgData = new ArrayList<>();
+        for (int i = 0; i < specs.length; i++) {
+            set.rgPrids[i] = propertyID(specs[i].type, specs[i].value);
+            set.rgData.add(specs[i].property);
+        }
+        return set;
+    }
+
+    private static PropertyID propertyID(PropertyType type, int value) {
+        PropertyID id = new PropertyID();
+        id.type = type.getIntVal();
+        id.value = value;
+        return id;
+    }
+
+    private static PrtFourBytesOfLengthFollowedByData bytes(byte... value) {
+        PrtFourBytesOfLengthFollowedByData data = new PrtFourBytesOfLengthFollowedByData();
+        data.data = value;
+        data.cb = data.data.length;
+        return data;
+    }
+
+    private static PrtFourBytesOfLengthFollowedByData text(String value) {
+        PrtFourBytesOfLengthFollowedByData data = new PrtFourBytesOfLengthFollowedByData();
+        data.data = value.getBytes(StandardCharsets.US_ASCII);
+        data.cb = data.data.length;
+        return data;
+    }
+
+    private static PrtFourBytesOfLengthFollowedByData utf16Text(String value) {
+        PrtFourBytesOfLengthFollowedByData data = new PrtFourBytesOfLengthFollowedByData();
+        data.data = (value + "\u0000").getBytes(StandardCharsets.UTF_16LE);
+        data.cb = data.data.length;
+        return data;
+    }
+
+    private static ArrayNumber arrayNumber(int number) {
+        ArrayNumber array = new ArrayNumber();
+        array.number = number;
+        return array;
+    }
+
+    private static ObjectGroupObjectData emptyObjectData() {
+        ObjectGroupObjectData data = new ObjectGroupObjectData();
+        data.data.content.addAll(ByteUtil.toListOfByte(new byte[]{0, 0, 0, (byte) 0x80,
+                0, 0, 0, 0}));
+        return data;
+    }
+
+    private static CellID cell(int first, int second) {
+        return new CellID(id(first), id(second));
+    }
+
+    private static ExGuid id(int value) {
+        return new ExGuid(value, UUID.nameUUIDFromBytes(("id-" + value).getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private static final class PropertySpec {
+        private final PropertyType type;
+        private final int value;
+        private final IProperty property;
+
+        private PropertySpec(PropertyType type, int value, IProperty property) {
+            this.type = type;
+            this.value = value;
+            this.property = property;
+        }
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParserTest.java
new file mode 100644
index 0000000000..bfbb34c8b3
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreParserTest.java
@@ -0,0 +1,286 @@
+/*
+ * 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.onenote.fsshttpb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.onenote.OneNoteTreeWalkerOptions;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.CellManifestCurrentRevision;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.CellManifestDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.DataElement;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.ObjectGroupDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionManifestObjectGroupReferences;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.RevisionStoreCell;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexCellMapping;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexDataElementData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.StorageIndexRevisionMapping;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.CellID;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.ExGuid;
+import org.apache.tika.sax.ToTextContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+public class MSOneStoreParserTest {
+
+    @Test
+    public void testStorageMappingIndexes() {
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.storageIndex = new StorageIndexDataElementData();
+        CellID cellID = cell(700);
+        StorageIndexCellMapping cellMapping = new StorageIndexCellMapping();
+        cellMapping.cellID = cellID;
+        pkg.storageIndex.storageIndexCellMappingList.add(cellMapping);
+
+        ExGuid revisionID = id(701);
+        StorageIndexRevisionMapping revisionMapping = new StorageIndexRevisionMapping();
+        revisionMapping.revisionExGuid = revisionID;
+        pkg.storageIndex.storageIndexRevisionMappingList.add(revisionMapping);
+
+        assertSame(cellMapping, pkg.findStorageIndexCellMapping(cellID));
+        assertSame(revisionMapping, pkg.findStorageIndexRevisionMapping(revisionID));
+
+        StorageIndexCellMapping addedAfterIndexing = new StorageIndexCellMapping();
+        addedAfterIndexing.cellID = cell(702);
+        pkg.storageIndex.storageIndexCellMappingList.add(addedAfterIndexing);
+        assertNull(pkg.findStorageIndexCellMapping(addedAfterIndexing.cellID));
+    }
+
+    @Test
+    public void testMissingRootsAndRevisionMappingsReturnNoCell() throws Exception {
+        CellID cellID = cell(1);
+        ExGuid cellMappingID = id(2);
+        ExGuid missingRevisionID = id(3);
+        ExGuid revisionMappingID = id(4);
+
+        MSOneStoreParser noMappingParser = parserWithEmptyIndexes();
+        assertParseWarning(noMappingParser, new MSOneStorePackage(), cellID,
+                "no storage-index cell mapping");
+
+        MSOneStoreParser noManifestParser = parserWithEmptyIndexes();
+        MSOneStorePackage noManifestPackage = packageWithCellMapping(cellID, cellMappingID);
+        assertParseWarning(noManifestParser, noManifestPackage, cellID,
+                "no current cell manifest");
+
+        MSOneStoreParser noCurrentParser = parserWithEmptyIndexes();
+        DataElement currentlessManifest = cellManifest(cellMappingID, null);
+        set(noCurrentParser, "cellManifestDataElements", Arrays.asList(currentlessManifest));
+        set(noCurrentParser, "cellManifestDataElementsById",
+                Collections.singletonMap(cellMappingID, currentlessManifest));
+        assertParseWarning(noCurrentParser,
+                packageWithCellMapping(cellID, cellMappingID), cellID,
+                "no current cell manifest");
+
+        MSOneStoreParser noRevisionMappingParser = parserWithEmptyIndexes();
+        DataElement manifest = cellManifest(cellMappingID, missingRevisionID);
+        set(noRevisionMappingParser, "cellManifestDataElements", Arrays.asList(manifest));
+        set(noRevisionMappingParser, "cellManifestDataElementsById",
+                Collections.singletonMap(cellMappingID, manifest));
+        assertParseWarning(noRevisionMappingParser,
+                packageWithCellMapping(cellID, cellMappingID), cellID, "no revision mapping");
+
+        MSOneStoreParser noRevisionManifestParser = parserWithEmptyIndexes();
+        set(noRevisionManifestParser, "cellManifestDataElements", Arrays.asList(manifest));
+        set(noRevisionManifestParser, "cellManifestDataElementsById",
+                Collections.singletonMap(cellMappingID, manifest));
+        MSOneStorePackage noRevisionManifestPackage = packageWithCellMapping(cellID,
+                cellMappingID);
+        StorageIndexRevisionMapping revisionMapping = new StorageIndexRevisionMapping();
+        revisionMapping.revisionExGuid = missingRevisionID;
+        revisionMapping.revisionMappingExGuid = revisionMappingID;
+        noRevisionManifestPackage.storageIndex.storageIndexRevisionMappingList.add(revisionMapping);
+        assertParseWarning(noRevisionManifestParser, noRevisionManifestPackage, cellID,
+                "no revision manifest");
+    }
+
+    @Test
+    public void testRevisionChainStopsCyclesAndDeduplicatesObjectGroups() throws Exception {
+        MSOneStoreParser parser = new MSOneStoreParser();
+        set(parser, "cellManifestDataElements", new java.util.ArrayList<>());
+        set(parser, "revisionManifestDataElements", new java.util.ArrayList<>());
+        set(parser, "objectGroupDataElements", new java.util.ArrayList<>());
+        set(parser, "objectGroupDataElementsById", new java.util.HashMap<>());
+        set(parser, "objectBlobElementsById", new java.util.HashMap<>());
+
+        CellID cellID = cell(20);
+        ExGuid cellMappingID = id(21);
+        ExGuid currentMappingID = id(22);
+        ExGuid oldMappingID = id(23);
+        ExGuid currentRevisionID = id(24);
+        ExGuid oldRevisionID = id(25);
+        ExGuid currentGroupID = id(26);
+        ExGuid oldGroupID = id(27);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.storageIndex = new StorageIndexDataElementData();
+        StorageIndexCellMapping cellMapping = new StorageIndexCellMapping();
+        cellMapping.cellID = cellID;
+        cellMapping.cellMappingExGuid = cellMappingID;
+        pkg.storageIndex.storageIndexCellMappingList.add(cellMapping);
+        StorageIndexRevisionMapping currentMapping = new StorageIndexRevisionMapping();
+        currentMapping.revisionMappingExGuid = currentMappingID;
+        currentMapping.revisionExGuid = currentRevisionID;
+        StorageIndexRevisionMapping oldMapping = new StorageIndexRevisionMapping();
+        oldMapping.revisionMappingExGuid = oldMappingID;
+        oldMapping.revisionExGuid = oldRevisionID;
+        pkg.storageIndex.storageIndexRevisionMappingList.add(currentMapping);
+        pkg.storageIndex.storageIndexRevisionMappingList.add(oldMapping);
+
+        DataElement cellManifestElement = new DataElement();
+        cellManifestElement.dataElementExGuid = cellMappingID;
+        CellManifestDataElementData cellManifest = new CellManifestDataElementData();
+        cellManifest.cellManifestCurrentRevision.cellManifestCurrentRevisionExGuid = currentRevisionID;
+        cellManifestElement.data = cellManifest;
+        set(parser, "cellManifestDataElements", Arrays.asList(cellManifestElement));
+        set(parser, "cellManifestDataElementsById",
+                Collections.singletonMap(cellManifestElement.dataElementExGuid,
+                        cellManifestElement));
+
+        RevisionManifestDataElementData current = revision(currentRevisionID, oldRevisionID,
+                currentGroupID, currentGroupID, id(99));
+        RevisionManifestDataElementData old = revision(oldRevisionID, currentRevisionID,
+                oldGroupID);
+        DataElement currentElement = new DataElement();
+        currentElement.dataElementExGuid = currentMappingID;
+        currentElement.data = current;
+        DataElement oldElement = new DataElement();
+        oldElement.dataElementExGuid = oldMappingID;
+        oldElement.data = old;
+        set(parser, "revisionManifestDataElements", Arrays.asList(currentElement, oldElement));
+        Map<ExGuid, DataElement> revisionManifests = new HashMap<>();
+        revisionManifests.put(currentElement.dataElementExGuid, currentElement);
+        revisionManifests.put(oldElement.dataElementExGuid, oldElement);
+        set(parser, "revisionManifestDataElementsById", revisionManifests);
+
+        DataElement currentGroup = new DataElement();
+        currentGroup.dataElementExGuid = currentGroupID;
+        currentGroup.data = new ObjectGroupDataElementData();
+        DataElement oldGroup = new DataElement();
+        oldGroup.dataElementExGuid = oldGroupID;
+        oldGroup.data = new ObjectGroupDataElementData();
+        set(parser, "objectGroupDataElements", Arrays.asList(currentGroup, oldGroup));
+        java.util.Map<ExGuid, DataElement> objectGroups = new java.util.HashMap<>();
+        objectGroups.put(currentGroupID, currentGroup);
+        objectGroups.put(oldGroupID, oldGroup);
+        set(parser, "objectGroupDataElementsById", objectGroups);
+
+        RevisionStoreCell result = parseCell(parser, cellID, pkg);
+        assertEquals(2, result.objectGroups.size());
+
+        old.revisionManifest.baseRevisionID = id(1001);
+        RevisionStoreCell missingBaseResult = parseCell(parser, cellID, pkg);
+        assertEquals(2, missingBaseResult.objectGroups.size());
+    }
+
+    private static MSOneStoreParser parserWithEmptyIndexes() throws Exception {
+        MSOneStoreParser parser = new MSOneStoreParser();
+        set(parser, "cellManifestDataElements", new java.util.ArrayList<>());
+        set(parser, "revisionManifestDataElements", new java.util.ArrayList<>());
+        set(parser, "objectGroupDataElements", new java.util.ArrayList<>());
+        set(parser, "objectGroupDataElementsById", new java.util.HashMap<>());
+        set(parser, "objectBlobElementsById", new java.util.HashMap<>());
+        return parser;
+    }
+
+    private static MSOneStorePackage packageWithCellMapping(CellID cellID, ExGuid mappingID) {
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.storageIndex = new StorageIndexDataElementData();
+        StorageIndexCellMapping mapping = new StorageIndexCellMapping();
+        mapping.cellID = cellID;
+        mapping.cellMappingExGuid = mappingID;
+        pkg.storageIndex.storageIndexCellMappingList.add(mapping);
+        return pkg;
+    }
+
+    private static DataElement cellManifest(ExGuid mappingID, ExGuid revisionID) {
+        DataElement element = new DataElement();
+        element.dataElementExGuid = mappingID;
+        CellManifestDataElementData data = new CellManifestDataElementData();
+        data.cellManifestCurrentRevision = null;
+        if (revisionID != null) {
+            data.cellManifestCurrentRevision = new CellManifestCurrentRevision();
+            data.cellManifestCurrentRevision.cellManifestCurrentRevisionExGuid = revisionID;
+        }
+        element.data = data;
+        return element;
+    }
+
+    private static void assertParseWarning(MSOneStoreParser parser, MSOneStorePackage pkg,
+                                           CellID cellID, String expected) throws Exception {
+        assertNull(parseCell(parser, cellID, pkg));
+        Metadata metadata = new Metadata();
+        ParseContext context = new ParseContext();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new ToTextContentHandler(), metadata);
+        xhtml.startDocument();
+        pkg.walkTree(new OneNoteTreeWalkerOptions(), metadata, xhtml, context);
+        xhtml.endDocument();
+        assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains(expected)), expected);
+    }
+
+    private static RevisionManifestDataElementData revision(ExGuid revisionID,
+                                                            ExGuid baseRevisionID,
+                                                            ExGuid... groups) {
+        RevisionManifestDataElementData data = new RevisionManifestDataElementData();
+        data.revisionManifest.revisionID = revisionID;
+        data.revisionManifest.baseRevisionID = baseRevisionID;
+        for (ExGuid group : groups) {
+            RevisionManifestObjectGroupReferences reference =
+                    new RevisionManifestObjectGroupReferences();
+            reference.objectGroupExtendedGUID = group;
+            data.revisionManifestObjectGroupReferences.add(reference);
+        }
+        return data;
+    }
+
+    private static RevisionStoreCell parseCell(MSOneStoreParser parser, CellID cellID,
+                                               MSOneStorePackage pkg) throws Exception {
+        Method method = MSOneStoreParser.class.getDeclaredMethod("parseCell", CellID.class,
+                MSOneStorePackage.class);
+        method.setAccessible(true);
+        return (RevisionStoreCell) method.invoke(parser, cellID, pkg);
+    }
+
+    private static void set(MSOneStoreParser parser, String field, Object value) throws Exception {
+        java.lang.reflect.Field declared = MSOneStoreParser.class.getDeclaredField(field);
+        declared.setAccessible(true);
+        declared.set(parser, value);
+    }
+
+    private static CellID cell(int value) {
+        return new CellID(id(value), id(value + 1));
+    }
+
+    private static ExGuid id(int value) {
+        return new ExGuid(value, UUID.nameUUIDFromBytes(
+                ("parser-" + value).getBytes(StandardCharsets.UTF_8)));
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java
new file mode 100644
index 0000000000..73d5ecd395
--- /dev/null
+++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java
@@ -0,0 +1,184 @@
+/*
+ * 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.onenote.fsshttpb;
+
+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 java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtArrayOfPropertyValues;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtFourBytesOfLengthFollowedByData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySet;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyID;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyType;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamHeader;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfContextIDs;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfOIDs;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfOSIDs;
+
+/**
+ * File-derived counts and nesting in the fsshttpb property deserializers must be bounded -
+ * a malformed count would otherwise allocate up to gigabytes and a deeply nested property
+ * set would overflow the stack, both Errors that escape the parser's Exception fallback.
+ */
+public class PropertyDeserializationBoundsTest {
+
+    @Test
+    public void testFourBytesOfLengthRejectsLengthBeyondRemainingData() {
+        byte[] bytes = concat(int32(1000), new byte[2]);
+        IOException e = assertThrows(IOException.class,
+                () -> new PrtFourBytesOfLengthFollowedByData()
+                        .doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("exceeds remaining data"), e.getMessage());
+    }
+
+    @Test
+    public void testFourBytesOfLengthRejectsNegativeLength() {
+        byte[] bytes = concat(int32(0xF0000000), new byte[8]);
+        assertThrows(IOException.class,
+                () -> new PrtFourBytesOfLengthFollowedByData()
+                        .doDeserializeFromByteArray(bytes, 0));
+    }
+
+    @Test
+    public void testFourBytesOfLengthAcceptsExactFit() throws IOException {
+        byte[] bytes = concat(int32(3), new byte[]{1, 2, 3});
+        PrtFourBytesOfLengthFollowedByData property = new PrtFourBytesOfLengthFollowedByData();
+        assertEquals(7, property.doDeserializeFromByteArray(bytes, 0));
+        assertEquals(3, property.data.length);
+    }
+
+    @Test
+    public void testArrayOfPropertyValuesRejectsCountBeyondRemainingData() throws IOException {
+        byte[] bytes = concat(int32(Integer.MAX_VALUE),
+                propertyId(PropertyType.PropertySet.getIntVal()));
+        IOException e = assertThrows(IOException.class,
+                () -> new PrtArrayOfPropertyValues().doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("exceeds remaining data"), e.getMessage());
+    }
+
+    @Test
+    public void testArrayOfPropertyValuesAcceptsSmallCount() throws IOException {
+        // one element: an empty PropertySet (int16 count of 0)
+        byte[] bytes = concat(int32(1), propertyId(PropertyType.PropertySet.getIntVal()),
+                new byte[]{0, 0});
+        PrtArrayOfPropertyValues array = new PrtArrayOfPropertyValues();
+        array.doDeserializeFromByteArray(bytes, 0);
+        assertEquals(1, array.data.length);
+    }
+
+    @Test
+    public void testStreamOfIdsRejectCountBeyondRemainingData() throws IOException {
+        byte[] bytes = streamHeader(0xFFFFFF);
+        assertThrows(IOException.class,
+                () -> new ObjectSpaceObjectStreamOfOIDs().doDeserializeFromByteArray(bytes, 0));
+        assertThrows(IOException.class,
+                () -> new ObjectSpaceObjectStreamOfOSIDs().doDeserializeFromByteArray(bytes, 0));
+        assertThrows(IOException.class,
+                () -> new ObjectSpaceObjectStreamOfContextIDs()
+                        .doDeserializeFromByteArray(bytes, 0));
+    }
+
+    @Test
+    public void testStreamOfOidsAcceptsSmallCount() throws IOException {
+        // one CompactID, 4 bytes
+        byte[] bytes = concat(streamHeader(1), new byte[4]);
+        ObjectSpaceObjectStreamOfOIDs stream = new ObjectSpaceObjectStreamOfOIDs();
+        stream.doDeserializeFromByteArray(bytes, 0);
+        assertEquals(1, stream.body.length);
+    }
+
+    @Test
+    public void testPropertySetNestingIsDepthCapped() throws IOException {
+        byte[] bytes = nestedPropertySet(PropertySet.MAX_PROPERTY_NESTING + 50);
+        IOException e = assertThrows(IOException.class,
+                () -> new PropertySet().doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("nesting exceeds"), e.getMessage());
+    }
+
+    @Test
+    public void testPropertySetShallowNestingParses() throws IOException {
+        PropertySet propertySet = new PropertySet();
+        propertySet.doDeserializeFromByteArray(nestedPropertySet(3), 0);
+        assertEquals(1, propertySet.rgData.size());
+    }
+
+    @Test
+    public void testNestingThroughArrayOfPropertyValuesIsDepthCapped() throws IOException {
+        // alternate PropertySet -> ArrayOfPropertyValues -> PropertySet -> ...
+        byte[] bytes = new byte[]{0, 0};
+        for (int i = 0; i < PropertySet.MAX_PROPERTY_NESTING + 50; i++) {
+            bytes = concat(new byte[]{1, 0},
+                    propertyId(PropertyType.ArrayOfPropertyValues.getIntVal()), int32(1),
+                    propertyId(PropertyType.PropertySet.getIntVal()), bytes);
+        }
+        byte[] finalBytes = bytes;
+        IOException e = assertThrows(IOException.class,
+                () -> new PropertySet().doDeserializeFromByteArray(finalBytes, 0));
+        assertTrue(e.getMessage().contains("nesting exceeds"), e.getMessage());
+    }
+
+    private static byte[] nestedPropertySet(int depth) throws IOException {
+        // innermost: empty PropertySet; each wrapper declares one PropertySet-typed property
+        byte[] bytes = new byte[]{0, 0};
+        for (int i = 0; i < depth; i++) {
+            bytes = concat(new byte[]{1, 0},
+                    propertyId(PropertyType.PropertySet.getIntVal()), bytes);
+        }
+        return bytes;
+    }
+
+    private static byte[] propertyId(int type) throws IOException {
+        PropertyID propertyID = new PropertyID();
+        propertyID.id = 1;
+        propertyID.type = type;
+        return toArray(propertyID.serializeToByteList());
+    }
+
+    private static byte[] streamHeader(int count) throws IOException {
+        ObjectSpaceObjectStreamHeader header = new ObjectSpaceObjectStreamHeader();
+        header.count = count;
+        return toArray(header.serializeToByteList());
+    }
+
+    private static byte[] int32(int value) {
+        return new byte[]{(byte) value, (byte) (value >> 8), (byte) (value >> 16),
+                (byte) (value >> 24)};
+    }
+
+    private static byte[] toArray(List<Byte> bytes) {
+        byte[] result = new byte[bytes.size()];
+        for (int i = 0; i < result.length; i++) {
+            result[i] = bytes.get(i);
+        }
+        return result;
+    }
+
+    private static byte[] concat(byte[]... parts) {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        for (byte[] part : parts) {
+            bos.writeBytes(part);
+        }
+        return bos.toByteArray();
+    }
+}
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz1.one b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz1.one
new file mode 100644
index 0000000000..857ac4f7d3
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz1.one differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz2.one b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz2.one
new file mode 100644
index 0000000000..a038f43984
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz2.one differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz3.one b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz3.one
new file mode 100644
index 0000000000..35539efef8
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNote-fuzz3.one differ
diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNoteEmbeddedImage.one b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNoteEmbeddedImage.one
new file mode 100644
index 0000000000..2e2b122952
Binary files /dev/null and b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/test-documents/testOneNoteEmbeddedImage.one differ