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

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 4a4ab9ded8 TIKA-5037: Parse Jupyter notebooks, with the thumbnail 
nbsphinx would pick (#3531)
4a4ab9ded8 is described below

commit 4a4ab9ded8d84255425c0c422b8c9628f93102b2
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Nov 5 18:52:55 2026 +0100

    TIKA-5037: Parse Jupyter notebooks, with the thumbnail nbsphinx would pick 
(#3531)
    
    * TIKA-5037: parse Jupyter notebooks, with the thumbnail nbsphinx would pick
---
 CHANGES.txt                                        |   8 +
 docs/modules/ROOT/pages/formats.adoc               |   7 +
 docs/modules/ROOT/partials/supported-formats.adoc  |   2 +
 .../java/org/apache/tika/metadata/Jupyter.java     |  44 +++
 .../org/apache/tika/mime/tika-mimetypes.xml        |   7 +
 .../java/org/apache/tika/TikaDetectionTest.java    |   1 +
 .../apache/tika/metadata/metadata-key-fields.json  |   3 +
 .../org/apache/tika/metadata/metadata-keys.json    |   3 +
 .../apache/tika/parser/jupyter/JupyterParser.java  | 206 ++++++++++++
 .../tika/parser/jupyter/NbsphinxThumbnail.java     | 141 +++++++++
 .../org/apache/tika/parser/jupyter/Notebook.java   | 352 +++++++++++++++++++++
 .../tika/parser/jupyter/JupyterParserTest.java     | 286 +++++++++++++++++
 .../resources/test-documents/testJupyter.ipynb     | 117 +++++++
 13 files changed, 1177 insertions(+)

diff --git a/CHANGES.txt b/CHANGES.txt
index 493556d4e7..b08cda5512 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -81,6 +81,14 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * Jupyter notebooks (.ipynb, new type application/x-ipynb+json) are parsed: 
cell
+     sources and plain-text outputs as text, kernel, language and format 
version as
+     jupyter:kernel, jupyter:language and jupyter:nbformat, and the image 
outputs as
+     embedded documents, as are the images markdown cells carry as 
attachments. The
+     thumbnail is picked as nbsphinx picks it: the output a cell marked
+     nbsphinx-thumbnail names, else the last image in the notebook, given as 
PNG or
+     JPEG where the output also holds them as SVG (TIKA-5037).
+     
    * FictionBook marks the cover image named in <coverpage> as a THUMBNAIL 
embedded
      resource, so the thumbnail preset serves it (TIKA-5035).
      
diff --git a/docs/modules/ROOT/pages/formats.adoc 
b/docs/modules/ROOT/pages/formats.adoc
index 195c4b3e7b..24efe102f4 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -305,6 +305,13 @@ 
link:{api}/org/apache/tika/parser/code/SourceCodeParser.html[SourceCodeParser]
 handles a number of source code formats, including Java, C, C++ and Groovy,
 providing a formatted form of the code along with simple metadata.
 
+link:{api}/org/apache/tika/parser/jupyter/JupyterParser.html[JupyterParser] 
reads Jupyter
+notebooks (`.ipynb`): the cell sources and plain-text outputs as text and the 
image
+outputs and cell attachments as embedded documents. The thumbnail is chosen 
the way
+https://nbsphinx.readthedocs.io/en/latest/subdir/gallery.html[nbsphinx] 
chooses a gallery
+thumbnail: the output of a cell marked `nbsphinx-thumbnail`, otherwise the 
last image in
+the notebook.
+
 == Mail formats
 
 link:{api}/org/apache/tika/parser/mbox/MboxParser.html[MboxParser] extracts
diff --git a/docs/modules/ROOT/partials/supported-formats.adoc 
b/docs/modules/ROOT/partials/supported-formats.adoc
index b578c1d78a..cbeb833c5f 100644
--- a/docs/modules/ROOT/partials/supported-formats.adoc
+++ b/docs/modules/ROOT/partials/supported-formats.adoc
@@ -168,6 +168,8 @@
 ** `application/vnd.apple.keynote.18`
 ** `application/vnd.apple.numbers.18`
 ** `application/vnd.apple.pages.18`
+* `org.apache.tika.parser.jupyter.` 
link:{tika-javadoc-url}/org/apache/tika/parser/jupyter/JupyterParser.html[JupyterParser]
+** `application/x-ipynb+json`
 * `org.apache.tika.parser.mail.` 
link:{tika-javadoc-url}/org/apache/tika/parser/mail/RFC822Parser.html[RFC822Parser]
 ** `message/rfc822`
 ** `message/x-emlx`
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Jupyter.java 
b/tika-core/src/main/java/org/apache/tika/metadata/Jupyter.java
new file mode 100644
index 0000000000..40ef943e1f
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Jupyter.java
@@ -0,0 +1,44 @@
+/*
+ * 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.metadata;
+
+/**
+ * Properties of a Jupyter notebook, read from its top-level metadata.
+ *
+ * @since Apache Tika 4.3.0
+ */
+public interface Jupyter {
+
+    String JUPYTER_PREFIX = "jupyter" + 
TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
+
+    /**
+     * The kernel the notebook was saved with, {@code 
metadata.kernelspec.name},
+     * e.g. &quot;python3&quot;.
+     */
+    Property KERNEL = Property.externalText(JUPYTER_PREFIX + "kernel");
+
+    /**
+     * The programming language of the notebook, {@code 
metadata.language_info.name},
+     * else {@code metadata.kernelspec.language}.
+     */
+    Property LANGUAGE = Property.externalText(JUPYTER_PREFIX + "language");
+
+    /**
+     * The notebook format version as major.minor, e.g. &quot;4.5&quot;.
+     */
+    Property NBFORMAT = Property.externalText(JUPYTER_PREFIX + "nbformat");
+}
diff --git 
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml 
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index e10fd3a91a..6d2ca59ed6 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -1413,6 +1413,13 @@
     <_comment>frictionless data package zip package</_comment>
     <sub-class-of type="application/zip"/>
   </mime-type>
+  <mime-type type="application/x-ipynb+json">
+    <_comment>Jupyter Notebook</_comment>
+    
<tika:link>https://nbformat.readthedocs.io/en/latest/format_description.html</tika:link>
+    <sub-class-of type="application/json"/>
+    <glob pattern="*.ipynb"/>
+  </mime-type>
+
   <mime-type type="application/x-vnd.datapackage+json">
     <_comment>frictionless data package - standalong 
datapackage.json</_comment>
     <sub-class-of type="application/json"/>
diff --git a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java 
b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
index b19de2b0f1..517f32fd26 100644
--- a/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
+++ b/tika-core/src/test/java/org/apache/tika/TikaDetectionTest.java
@@ -60,6 +60,7 @@ public class TikaDetectionTest {
         assertEquals("text/javascript", tika.detect("x.js"));
         assertEquals("text/javascript", tika.detect("x.mjs"));
         assertEquals("application/json", tika.detect("x.json"));
+        assertEquals("application/x-ipynb+json", tika.detect("x.ipynb"));
         assertEquals("application/lost+xml", tika.detect("x.lostxml"));
         assertEquals("application/mac-binhex40", tika.detect("x.hqx"));
         assertEquals("application/mac-compactpro", tika.detect("x.cpt"));
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index 855d2e3360..896ab17a4d 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -237,6 +237,9 @@
   
{"class":"org.apache.tika.metadata.Icon","field":"HOTSPOT_Y","key":"icon:hotspot-y"},
   
{"class":"org.apache.tika.metadata.Icon","field":"IMAGES","key":"icon:images"},
   
{"class":"org.apache.tika.metadata.Icon","field":"IMAGE_COUNT","key":"icon:image-count"},
+  
{"class":"org.apache.tika.metadata.Jupyter","field":"KERNEL","key":"jupyter:kernel"},
+  
{"class":"org.apache.tika.metadata.Jupyter","field":"LANGUAGE","key":"jupyter:language"},
+  
{"class":"org.apache.tika.metadata.Jupyter","field":"NBFORMAT","key":"jupyter:nbformat"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_CONTENT_ID","key":"mapi:attach:content-id"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_CONTENT_LOCATION","key":"mapi:attach:content-location"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_DISPLAY_NAME","key":"mapi:attach:display-name"},
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index 99d7095bbe..2b70c42c5b 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -257,6 +257,9 @@
   
{"key":"iso19115:use-constraints","namespace":"iso19115","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
   
{"key":"iwork:build-version-history","namespace":"iwork","valueType":"TEXT","cardinality":"BAG","module":"tika-parser-apple-module"},
   
{"key":"iwork:document-id","namespace":"iwork","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-apple-module"},
+  
{"key":"jupyter:kernel","namespace":"jupyter","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"jupyter:language","namespace":"jupyter","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"jupyter:nbformat","namespace":"jupyter","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"keynote:slides-height","namespace":"keynote","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-apple-module"},
   
{"key":"keynote:slides-width","namespace":"keynote","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-apple-module"},
   
{"key":"machine:architectureBits","namespace":"machine","valueType":"CLOSED_CHOICE","cardinality":"SIMPLE","module":"tika-core"},
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/JupyterParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/JupyterParser.java
new file mode 100644
index 0000000000..4d6a6b8ddb
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/JupyterParser.java
@@ -0,0 +1,206 @@
+/*
+ * 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.jupyter;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.JacksonException;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.annotation.TikaComponent;
+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.HttpHeaders;
+import org.apache.tika.metadata.Jupyter;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Parser for Jupyter notebooks (nbformat 4). The text is the cell sources, a
+ * markdown or raw cell as a paragraph and a code cell as preformatted text,
+ * followed by its plain-text outputs (streams, results, errors). The image
+ * outputs are embedded documents; the one {@link NbsphinxThumbnail} picks is 
the
+ * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}, the others are
+ * {@link TikaCoreProperties.EmbeddedResourceType#INLINE}, as are the images a
+ * markdown or raw cell carries as attachments. An output without PNG, JPEG or 
SVG
+ * gives its GIF, WebP or BMP image, which nbsphinx does not consider for the
+ * thumbnail. Kernel, language and
+ * format version go to the {@link Jupyter} keys.
+ */
+@TikaComponent
+public class JupyterParser implements Parser {
+
+    private static final long serialVersionUID = 6620473093245861874L;
+
+    static final MediaType NOTEBOOK = MediaType.application("x-ipynb+json");
+    private static final Set<MediaType> SUPPORTED_TYPES = Set.of(NOTEBOOK);
+
+    /** The notebook is read into memory up to this size; large ones are a few 
tens of MB. */
+    static final int MAX_DOCUMENT_LENGTH = 256 * 1024 * 1024;
+    /** One string, a cell's source or an encoded image, is read up to this 
length. */
+    static final int MAX_STRING_LENGTH = 64 * 1024 * 1024;
+
+    /** Images an output or attachment may hold besides the thumbnail types, 
in this order. */
+    private static final List<String> OTHER_IMAGES = List.of("image/gif", 
"image/webp", "image/bmp");
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        Notebook notebook;
+        try {
+            notebook = Notebook.read(tis, MAX_DOCUMENT_LENGTH, 
MAX_STRING_LENGTH);
+        } catch (JacksonException e) {
+            throw new TikaException("Unreadable notebook", e);
+        }
+        set(metadata, Jupyter.KERNEL, notebook.kernel());
+        set(metadata, Jupyter.LANGUAGE, notebook.language());
+        set(metadata, Jupyter.NBFORMAT, notebook.nbformat());
+
+        NbsphinxThumbnail.Choice thumbnail = 
NbsphinxThumbnail.choose(notebook.cells());
+        if (thumbnail.problem() != null) {
+            EmbeddedDocumentUtil.recordException(new 
TikaException(thumbnail.problem()),
+                    metadata, context);
+        }
+
+        EmbeddedDocumentExtractor extractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
+        xhtml.startDocument();
+        int inlineImages = 0;
+        List<Notebook.Cell> cells = notebook.cells();
+        for (int i = 0; i < cells.size(); i++) {
+            Notebook.Cell cell = cells.get(i);
+            if (cell.source() != null && !cell.source().isEmpty()) {
+                xhtml.element("code".equals(cell.type()) ? "pre" : "p", 
cell.source());
+            }
+            for (var attachment : cell.attachments().entrySet()) {
+                String imageType = anyImageType(attachment.getValue());
+                if (imageType != null) {
+                    emitImage(attachment.getValue().get(imageType), imageType, 
false,
+                            inlineImages++, attachment.getKey(), extractor, 
xhtml, metadata,
+                            context);
+                }
+            }
+            List<Notebook.Output> outputs = cell.outputs();
+            for (int j = 0; j < outputs.size(); j++) {
+                Notebook.Output output = outputs.get(j);
+                String text = output.text() != null ? output.text()
+                        : output.data().get("text/plain");
+                if (text != null && !text.isEmpty()) {
+                    xhtml.element("pre", text);
+                }
+                String imageType = anyImageType(output.data());
+                if (imageType != null) {
+                    boolean isThumbnail = thumbnail.isAt(i, j);
+                    emitImage(output.data().get(imageType), imageType, 
isThumbnail,
+                            isThumbnail ? 0 : inlineImages++, null, extractor, 
xhtml, metadata,
+                            context);
+                }
+            }
+        }
+        xhtml.endDocument();
+    }
+
+    private static String anyImageType(Map<String, String> data) {
+        for (String mimeType : NbsphinxThumbnail.IMAGE_PRIORITY) {
+            if (data.get(mimeType) != null) {
+                return mimeType;
+            }
+        }
+        for (String mimeType : OTHER_IMAGES) {
+            if (data.get(mimeType) != null) {
+                return mimeType;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Notebooks break base64 into lines; the strict decoder refuses what is 
not base64, where
+     * the MIME one would skip it and decode the rest into a broken image.
+     */
+    private static String withoutWhitespace(String data) {
+        StringBuilder sb = new StringBuilder(data.length());
+        for (int i = 0; i < data.length(); i++) {
+            char c = data.charAt(i);
+            if (!Character.isWhitespace(c)) {
+                sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static void emitImage(String data, String mimeType, boolean 
thumbnail, int index,
+                                  String name, EmbeddedDocumentExtractor 
extractor,
+                                  XHTMLContentHandler xhtml, Metadata 
parentMetadata,
+                                  ParseContext context) throws IOException, 
SAXException {
+        byte[] bytes;
+        if ("image/svg+xml".equals(mimeType)) {
+            bytes = data.getBytes(StandardCharsets.UTF_8);
+        } else {
+            try {
+                bytes = Base64.getDecoder().decode(withoutWhitespace(data));
+            } catch (IllegalArgumentException e) {
+                EmbeddedDocumentUtil.recordEmbeddedStreamException(e, 
parentMetadata, context);
+                return;
+            }
+        }
+        Metadata imageMetadata = Metadata.newInstance(context);
+        imageMetadata.set(HttpHeaders.CONTENT_TYPE, mimeType);
+        imageMetadata.set(HttpHeaders.CONTENT_LENGTH, 
Integer.toString(bytes.length));
+        imageMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, thumbnail
+                ? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString()
+                : TikaCoreProperties.EmbeddedResourceType.INLINE.toString());
+        if (name != null && !name.isEmpty()) {
+            imageMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name);
+        } else {
+            EmbeddedDocumentUtil.setGeneratedResourceName(imageMetadata, 
thumbnail
+                    ? EmbeddedDocumentUtil.EmbeddedResourcePrefix.THUMBNAIL
+                    : EmbeddedDocumentUtil.EmbeddedResourcePrefix.IMAGE, 
index, mimeType);
+        }
+        if (!extractor.shouldParseEmbedded(imageMetadata, context)) {
+            return;
+        }
+        try (TikaInputStream image = TikaInputStream.get(bytes)) {
+            extractor.parseEmbedded(image, xhtml, imageMetadata, context, 
true);
+        }
+    }
+
+    private static void set(Metadata metadata, Property property, String 
value) {
+        if (value != null && !value.isEmpty()) {
+            metadata.set(property, value);
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/NbsphinxThumbnail.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/NbsphinxThumbnail.java
new file mode 100644
index 0000000000..490433c8ba
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/NbsphinxThumbnail.java
@@ -0,0 +1,141 @@
+/*
+ * 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.jupyter;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Picks a notebook's thumbnail the way nbsphinx does, so a notebook shows the
+ * same preview here as in an nbsphinx gallery. This follows nbsphinx's
+ * {@code Exporter.from_notebook_node} and {@code _extract_thumbnail}
+ * (src/nbsphinx/__init__.py, spatialaudio/nbsphinx, as of commit 0d01427):
+ * <ol>
+ *   <li>A code cell marked with {@code nbsphinx-thumbnail}, as cell metadata 
(an
+ *   object with an optional zero-based {@code output-index} and a {@code 
tooltip})
+ *   or as a tag, names the thumbnail: the output at {@code output-index}, 
else the
+ *   cell's last output. The metadata key is read before the tag.</li>
+ *   <li>A marker that cannot be honoured makes the thumbnail broken, and 
nbsphinx
+ *   then does not fall back to the implicit choice: other keys in the metadata
+ *   object, a marker on a cell that is not a code cell, a second marked cell, 
a
+ *   cell without outputs, an index outside the outputs, an output that is not
+ *   display_data or execute_result, an output without an image. Here a broken
+ *   marker means no thumbnail and a warning.</li>
+ *   <li>Without a marker, the last image in the notebook: code cells from the
+ *   last, their outputs from the last, the first output that holds an 
image.</li>
+ * </ol>
+ * An output counts as an image when it holds SVG, PNG or JPEG, nbsphinx's
+ * {@code THUMBNAIL_MIME_TYPES}, so the cell and output chosen are nbsphinx's. 
Where
+ * an output holds the same figure in several of them, nbsphinx takes SVG 
first; here
+ * PNG, then JPEG, then SVG are taken, as not every consumer of a thumbnail 
renders SVG.
+ *
+ * @see <a 
href="https://nbsphinx.readthedocs.io/en/latest/subdir/gallery.html";>nbsphinx
+ * thumbnail galleries</a>
+ */
+final class NbsphinxThumbnail {
+
+    static final List<String> IMAGE_PRIORITY = List.of("image/png", 
"image/jpeg", "image/svg+xml");
+
+    private static final Set<String> IMAGE_OUTPUT_TYPES = 
Set.of("display_data", "execute_result");
+
+    /** The chosen image, or the reason a marker could not be honoured; both 
null for none. */
+    record Choice(int cell, int output, String mimeType, String problem) {
+
+        static final Choice NONE = new Choice(-1, -1, null, null);
+
+        boolean found() {
+            return mimeType != null;
+        }
+
+        boolean isAt(int cellIndex, int outputIndex) {
+            return found() && cell == cellIndex && output == outputIndex;
+        }
+    }
+
+    private NbsphinxThumbnail() {
+    }
+
+    static Choice choose(List<Notebook.Cell> cells) {
+        Choice explicit = null;
+        for (int i = 0; i < cells.size(); i++) {
+            Notebook.Cell cell = cells.get(i);
+            Notebook.Marker marker = cell.marker();
+            if (marker == null) {
+                continue;
+            }
+            if (marker.invalid()) {
+                return broken(i, "invalid key(s), only output-index and 
tooltip are allowed");
+            }
+            if (!"code".equals(cell.type())) {
+                return broken(i, "only allowed in code cells, not \"" + 
cell.type() + "\"");
+            }
+            if (explicit != null) {
+                return broken(i, "only allowed once per notebook");
+            }
+            int outputIndex = marker.outputIndex() == null
+                    ? cell.outputs().size() - 1 : marker.outputIndex();
+            if (cell.outputs().isEmpty()) {
+                return broken(i, "no outputs");
+            }
+            if (outputIndex < 0 || outputIndex >= cell.outputs().size()) {
+                return broken(i, "invalid output-index " + outputIndex);
+            }
+            Notebook.Output output = cell.outputs().get(outputIndex);
+            if (!IMAGE_OUTPUT_TYPES.contains(output.type())) {
+                return broken(i, "unsupported output type \"" + output.type() 
+ "\" in output "
+                        + outputIndex);
+            }
+            String mimeType = imageType(output);
+            if (mimeType == null) {
+                return broken(i, "no image in output " + outputIndex);
+            }
+            explicit = new Choice(i, outputIndex, mimeType, null);
+        }
+        if (explicit != null) {
+            return explicit;
+        }
+        for (int i = cells.size() - 1; i >= 0; i--) {
+            Notebook.Cell cell = cells.get(i);
+            if (!"code".equals(cell.type())) {
+                continue;
+            }
+            for (int j = cell.outputs().size() - 1; j >= 0; j--) {
+                Notebook.Output output = cell.outputs().get(j);
+                String mimeType = IMAGE_OUTPUT_TYPES.contains(output.type()) ? 
imageType(output)
+                        : null;
+                if (mimeType != null) {
+                    return new Choice(i, j, mimeType, null);
+                }
+            }
+        }
+        return Choice.NONE;
+    }
+
+    static String imageType(Notebook.Output output) {
+        for (String mimeType : IMAGE_PRIORITY) {
+            if (output.data().get(mimeType) != null) {
+                return mimeType;
+            }
+        }
+        return null;
+    }
+
+    private static Choice broken(int cell, String reason) {
+        return new Choice(-1, -1, null,
+                "\"" + Notebook.THUMBNAIL_MARKER + "\" in cell " + cell + ": " 
+ reason);
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/Notebook.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/Notebook.java
new file mode 100644
index 0000000000..02c31c3a7c
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/jupyter/Notebook.java
@@ -0,0 +1,352 @@
+/*
+ * 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.jupyter;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.core.StreamReadConstraints;
+
+/**
+ * The parts of an nbformat 4 notebook the parser uses: cell sources, the text
+ * and image outputs, the {@code nbsphinx-thumbnail} markers and the kernel. 
Other
+ * output data (HTML, widgets, JSON) is skipped while reading, so the model 
holds
+ * little beyond the images.
+ *
+ * @see <a 
href="https://nbformat.readthedocs.io/en/latest/format_description.html";>The
+ * notebook file format</a>
+ */
+final class Notebook {
+
+    static final String THUMBNAIL_MARKER = "nbsphinx-thumbnail";
+
+    /** Output data kept: the image types a thumbnail can be, other images, 
and plain text. */
+    static final Set<String> KEPT_DATA = Set.of("image/svg+xml", "image/png", 
"image/jpeg",
+            "image/gif", "image/webp", "image/bmp", "text/plain");
+
+    private final List<Cell> cells = new ArrayList<>();
+    private String kernel;
+    private String kernelLanguage;
+    private String language;
+    private Integer nbformat;
+    private Integer nbformatMinor;
+
+    /**
+     * How a cell asks to be the thumbnail. {@code outputIndex} is null when 
the
+     * marker names none; {@code invalid} is set when the metadata marker is 
not an
+     * object or carries keys other than {@code output-index} and {@code 
tooltip}.
+     */
+    record Marker(Integer outputIndex, boolean invalid) {
+    }
+
+    record Output(String type, Map<String, String> data, String text) {
+    }
+
+    /**
+     * {@code attachments} are the files a markdown or raw cell carries, by 
name, each
+     * with its data by MIME type.
+     */
+    record Cell(String type, String source, Marker marker, List<Output> 
outputs,
+                Map<String, Map<String, String>> attachments) {
+    }
+
+    List<Cell> cells() {
+        return cells;
+    }
+
+    String kernel() {
+        return kernel;
+    }
+
+    String language() {
+        return language != null ? language : kernelLanguage;
+    }
+
+    String nbformat() {
+        if (nbformat == null) {
+            return null;
+        }
+        return nbformatMinor == null ? nbformat.toString() : nbformat + "." + 
nbformatMinor;
+    }
+
+    static Notebook read(InputStream stream, int maxDocumentLength, int 
maxStringLength)
+            throws IOException {
+        JsonFactory factory = JsonFactory.builder()
+                .streamReadConstraints(StreamReadConstraints.builder()
+                        .maxDocumentLength(maxDocumentLength)
+                        .maxStringLength(maxStringLength)
+                        .build())
+                .build();
+        Notebook notebook = new Notebook();
+        try (JsonParser p = factory.createParser(stream)) {
+            expect(p.nextToken(), JsonToken.START_OBJECT, p);
+            while (p.nextToken() == JsonToken.FIELD_NAME) {
+                String name = p.currentName();
+                JsonToken value = p.nextToken();
+                switch (name) {
+                    case "cells" -> notebook.readCells(p, value);
+                    case "metadata" -> notebook.readMetadata(p, value);
+                    case "nbformat" -> notebook.nbformat = intValue(p, value);
+                    case "nbformat_minor" -> notebook.nbformatMinor = 
intValue(p, value);
+                    default -> p.skipChildren();
+                }
+            }
+        }
+        return notebook;
+    }
+
+    private void readCells(JsonParser p, JsonToken value) throws IOException {
+        if (value != JsonToken.START_ARRAY) {
+            p.skipChildren();
+            return;
+        }
+        while (p.nextToken() == JsonToken.START_OBJECT) {
+            String type = null;
+            String source = null;
+            Marker marker = null;
+            List<Output> outputs = List.of();
+            Map<String, Map<String, String>> attachments = Map.of();
+            while (p.nextToken() == JsonToken.FIELD_NAME) {
+                String name = p.currentName();
+                JsonToken token = p.nextToken();
+                switch (name) {
+                    case "cell_type" -> type = textValue(p, token);
+                    case "source" -> source = textValue(p, token);
+                    case "metadata" -> marker = readMarker(p, token);
+                    case "outputs" -> outputs = readOutputs(p, token);
+                    case "attachments" -> attachments = readAttachments(p, 
token);
+                    default -> p.skipChildren();
+                }
+            }
+            cells.add(new Cell(type, source, marker, outputs, attachments));
+        }
+    }
+
+    /**
+     * The marker as nbsphinx reads it: the {@code nbsphinx-thumbnail} key in 
the
+     * cell metadata wins over the {@code nbsphinx-thumbnail} tag.
+     */
+    private static Marker readMarker(JsonParser p, JsonToken value) throws 
IOException {
+        if (value != JsonToken.START_OBJECT) {
+            p.skipChildren();
+            return null;
+        }
+        Marker fromMetadata = null;
+        boolean tagged = false;
+        while (p.nextToken() == JsonToken.FIELD_NAME) {
+            String name = p.currentName();
+            JsonToken token = p.nextToken();
+            if (THUMBNAIL_MARKER.equals(name)) {
+                fromMetadata = readMarkerObject(p, token);
+            } else if ("tags".equals(name) && token == JsonToken.START_ARRAY) {
+                while (p.nextToken() != JsonToken.END_ARRAY) {
+                    if (p.currentToken() == JsonToken.VALUE_STRING
+                            && THUMBNAIL_MARKER.equals(p.getText())) {
+                        tagged = true;
+                    }
+                    p.skipChildren();
+                }
+            } else {
+                p.skipChildren();
+            }
+        }
+        if (fromMetadata != null) {
+            return fromMetadata;
+        }
+        return tagged ? new Marker(null, false) : null;
+    }
+
+    private static Marker readMarkerObject(JsonParser p, JsonToken value) 
throws IOException {
+        if (value != JsonToken.START_OBJECT) {
+            p.skipChildren();
+            return new Marker(null, true);
+        }
+        Integer outputIndex = null;
+        boolean invalid = false;
+        while (p.nextToken() == JsonToken.FIELD_NAME) {
+            String name = p.currentName();
+            JsonToken token = p.nextToken();
+            if ("output-index".equals(name)) {
+                // anything but an integer is an index that names no output
+                outputIndex = token == JsonToken.VALUE_NULL ? null
+                        : token == JsonToken.VALUE_NUMBER_INT ? 
p.getIntValue() : -1;
+            } else if (!"tooltip".equals(name)) {
+                invalid = true;
+            }
+            p.skipChildren();
+        }
+        return new Marker(outputIndex, invalid);
+    }
+
+    private static List<Output> readOutputs(JsonParser p, JsonToken value) 
throws IOException {
+        List<Output> outputs = new ArrayList<>();
+        if (value != JsonToken.START_ARRAY) {
+            p.skipChildren();
+            return outputs;
+        }
+        while (p.nextToken() != JsonToken.END_ARRAY) {
+            if (p.currentToken() != JsonToken.START_OBJECT) {
+                p.skipChildren();
+                outputs.add(new Output(null, Map.of(), null));
+                continue;
+            }
+            String type = null;
+            String text = null;
+            String ename = null;
+            String evalue = null;
+            Map<String, String> data = new LinkedHashMap<>();
+            while (p.nextToken() == JsonToken.FIELD_NAME) {
+                String name = p.currentName();
+                JsonToken token = p.nextToken();
+                switch (name) {
+                    case "output_type" -> type = textValue(p, token);
+                    case "text" -> text = textValue(p, token);
+                    case "ename" -> {
+                        String part = textValue(p, token);
+                        ename = ename != null ? ename : part;
+                    }
+                    case "evalue" -> {
+                        String part = textValue(p, token);
+                        evalue = evalue != null ? evalue : part;
+                    }
+                    case "data" -> readData(p, token, data);
+                    default -> p.skipChildren();
+                }
+            }
+            if (text == null && (ename != null || evalue != null)) {
+                text = ename == null ? evalue : evalue == null ? ename : ename 
+ ": " + evalue;
+            }
+            outputs.add(new Output(type, data, text));
+        }
+        return outputs;
+    }
+
+    private static Map<String, Map<String, String>> readAttachments(JsonParser 
p, JsonToken value)
+            throws IOException {
+        Map<String, Map<String, String>> attachments = new LinkedHashMap<>();
+        if (value != JsonToken.START_OBJECT) {
+            p.skipChildren();
+            return attachments;
+        }
+        while (p.nextToken() == JsonToken.FIELD_NAME) {
+            String name = p.currentName();
+            Map<String, String> data = new LinkedHashMap<>();
+            readData(p, p.nextToken(), data);
+            attachments.put(name, data);
+        }
+        return attachments;
+    }
+
+    private static void readData(JsonParser p, JsonToken value, Map<String, 
String> data)
+            throws IOException {
+        if (value != JsonToken.START_OBJECT) {
+            p.skipChildren();
+            return;
+        }
+        while (p.nextToken() == JsonToken.FIELD_NAME) {
+            String mimeType = p.currentName();
+            JsonToken token = p.nextToken();
+            if (KEPT_DATA.contains(mimeType)) {
+                String text = textValue(p, token);
+                if (text != null) {
+                    data.put(mimeType, text);
+                }
+            } else {
+                p.skipChildren();
+            }
+        }
+    }
+
+    private void readMetadata(JsonParser p, JsonToken value) throws 
IOException {
+        if (value != JsonToken.START_OBJECT) {
+            p.skipChildren();
+            return;
+        }
+        while (p.nextToken() == JsonToken.FIELD_NAME) {
+            String name = p.currentName();
+            JsonToken token = p.nextToken();
+            if ("kernelspec".equals(name) && token == JsonToken.START_OBJECT) {
+                while (p.nextToken() == JsonToken.FIELD_NAME) {
+                    String key = p.currentName();
+                    JsonToken t = p.nextToken();
+                    switch (key) {
+                        case "name" -> kernel = textValue(p, t);
+                        case "language" -> kernelLanguage = textValue(p, t);
+                        default -> p.skipChildren();
+                    }
+                }
+            } else if ("language_info".equals(name) && token == 
JsonToken.START_OBJECT) {
+                while (p.nextToken() == JsonToken.FIELD_NAME) {
+                    String key = p.currentName();
+                    JsonToken t = p.nextToken();
+                    if ("name".equals(key)) {
+                        language = textValue(p, t);
+                    } else {
+                        p.skipChildren();
+                    }
+                }
+            } else {
+                p.skipChildren();
+            }
+        }
+    }
+
+    /** nbformat stores multi-line text as a string or as a list of lines. */
+    private static String textValue(JsonParser p, JsonToken value) throws 
IOException {
+        if (value == JsonToken.VALUE_STRING) {
+            return p.getText();
+        }
+        if (value == JsonToken.START_ARRAY) {
+            StringBuilder sb = new StringBuilder();
+            while (p.nextToken() != JsonToken.END_ARRAY) {
+                if (p.currentToken() == JsonToken.VALUE_STRING) {
+                    sb.append(p.getText());
+                } else {
+                    p.skipChildren();
+                }
+            }
+            return sb.toString();
+        }
+        p.skipChildren();
+        return null;
+    }
+
+    private static Integer intValue(JsonParser p, JsonToken value) throws 
IOException {
+        if (value == JsonToken.VALUE_NUMBER_INT) {
+            return p.getIntValue();
+        }
+        p.skipChildren();
+        return null;
+    }
+
+    private static void expect(JsonToken actual, JsonToken expected, 
JsonParser p)
+            throws IOException {
+        if (actual != expected) {
+            throw new JsonParseException(p,
+                    "a notebook is a JSON object, found " + actual);
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/jupyter/JupyterParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/jupyter/JupyterParserTest.java
new file mode 100644
index 0000000000..0d05d4ab7c
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/jupyter/JupyterParserTest.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.jupyter;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import com.fasterxml.jackson.core.JacksonException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Jupyter;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+public class JupyterParserTest extends TikaTest {
+
+    private static final String THUMBNAIL =
+            TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString();
+    private static final String INLINE = 
TikaCoreProperties.EmbeddedResourceType.INLINE.toString();
+    private static final String PNG = "\"image/png\": \"iVBORw0KGgo=\"";
+    private static final String GIF = "\"image/gif\": 
\"R0lGODlhAQABAAAAACw=\"";
+    private static final String SVG = "\"image/svg+xml\": \"<svg 
xmlns='http://www.w3.org/2000/svg'/>\"";
+
+    @Test
+    public void testNotebook() throws Exception {
+        List<Metadata> metadataList = getRecursiveMetadata("testJupyter.ipynb",
+                new JupyterParser(), new Metadata(), new ParseContext(), 
false);
+        Metadata notebook = metadataList.get(0);
+        assertEquals("python3", notebook.get(Jupyter.KERNEL));
+        assertEquals("python", notebook.get(Jupyter.LANGUAGE));
+        assertEquals("4.5", notebook.get(Jupyter.NBFORMAT));
+        String content = notebook.get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains("Orbit plots", content);
+        assertContains("print('loaded 3 bodies')", content);
+        assertContains("loaded 3 bodies", content);
+        assertContains("42", content);
+        assertContains("ZeroDivisionError: division by zero", content);
+
+        assertEquals(3, metadataList.size());
+        assertEquals(INLINE, 
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals(THUMBNAIL, 
metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("image/png", 
metadataList.get(2).get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testLastImageWithoutMarker() throws Exception {
+        assertEquals(List.of(INLINE, THUMBNAIL, INLINE), imageTypes(notebook(
+                code("{}", image(PNG)),
+                code("{}", image(PNG), stream()),
+                markdown("{}"),
+                code("{}", stream(), error()),
+                code("{}", "{\"output_type\": \"stream\", \"text\": \"x\"}"),
+                raw(image(PNG)))), "the last image of a code cell, outputs 
read backwards");
+    }
+
+    @Test
+    public void testTagWins() throws Exception {
+        assertEquals(List.of(THUMBNAIL, INLINE), imageTypes(notebook(
+                code("{\"tags\": [\"other\", \"nbsphinx-thumbnail\"]}", 
image(PNG)),
+                code("{}", image(PNG)))));
+    }
+
+    @Test
+    public void testMetadataOutputIndex() throws Exception {
+        assertEquals(List.of(THUMBNAIL, INLINE, INLINE), imageTypes(notebook(
+                code("{\"nbsphinx-thumbnail\": {\"output-index\": 0, 
\"tooltip\": \"t\"}}",
+                        image(PNG), image(PNG)),
+                code("{}", image(PNG)))));
+    }
+
+    @Test
+    public void testMarkedCellDefaultsToItsLastOutput() throws Exception {
+        assertEquals(List.of(INLINE, THUMBNAIL, INLINE), imageTypes(notebook(
+                code("{\"nbsphinx-thumbnail\": {}}", image(PNG), image(PNG)),
+                code("{}", image(PNG)))));
+    }
+
+    @Test
+    public void testRasterBeforeSvg() throws Exception {
+        List<Metadata> list = parse(notebook(code("{}", image(SVG + ", " + 
PNG))));
+        assertEquals(2, list.size(), "one figure, one image");
+        assertEquals("image/png", list.get(1).get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(THUMBNAIL, 
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    @Test
+    public void testSvgOnly() throws Exception {
+        List<Metadata> list = parse(notebook(code("{}", image(SVG))));
+        assertEquals("image/svg+xml", 
list.get(1).get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(THUMBNAIL, 
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    @Test
+    public void testOtherImageTypesAreNeverTheThumbnail() throws Exception {
+        List<Metadata> list = parse(notebook(code("{}", image(PNG)),
+                code("{}", image(GIF))));
+        assertEquals(THUMBNAIL, 
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals("image/gif", list.get(2).get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(INLINE, 
list.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    @Test
+    public void testAttachments() throws Exception {
+        String cell = "{\"cell_type\": \"markdown\", \"metadata\": {}, "
+                + "\"attachments\": {\"diagram.png\": {" + PNG + "}, "
+                + "\"notes.txt\": {\"text/plain\": \"n\"}}, "
+                + "\"source\": \"![d](attachment:diagram.png)\"}";
+        List<Metadata> list = parse(notebook(cell, code("{}", image(PNG))));
+        assertEquals(3, list.size());
+        assertEquals("diagram.png", 
list.get(1).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals(INLINE, 
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertEquals(THUMBNAIL, 
list.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    @Test
+    public void testBrokenMarkerMeansNoThumbnail() throws Exception {
+        String[][] cases = {
+                {notebook(markdownMarked(), code("{}", image(PNG))), "only 
allowed in code cells"},
+                {notebook(code(tag(), image(PNG)), code(tag(), image(PNG))), 
"only allowed once"},
+                {notebook(code("{\"nbsphinx-thumbnail\": {\"size\": 2}}", 
image(PNG))),
+                        "invalid key(s)"},
+                {notebook(code("{\"nbsphinx-thumbnail\": true}", image(PNG))), 
"invalid key(s)"},
+                {notebook(code("{\"nbsphinx-thumbnail\": {\"output-index\": 
3}}", image(PNG))),
+                        "invalid output-index 3"},
+                {notebook(code(tag()), code("{}", image(PNG))), "no outputs"},
+                {notebook(code(tag(), image(PNG), stream())), "unsupported 
output type"},
+                {notebook(code(tag(), "{\"output_type\": \"display_data\", 
\"data\": "
+                        + "{\"text/html\": \"<b>x</b>\"}}"), code("{}", 
image(PNG))),
+                        "no image in output 0"},
+        };
+        for (String[] c : cases) {
+            List<Metadata> list = parse(c[0]);
+            for (Metadata m : list.subList(1, list.size())) {
+                assertEquals(INLINE, 
m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE), c[1]);
+            }
+            assertContains(c[1], 
list.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+        }
+    }
+
+    @Test
+    public void testNoImages() throws Exception {
+        List<Metadata> list = parse(notebook(code("{}", stream())));
+        assertEquals(1, list.size());
+        
assertNull(list.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testNotAnObject() {
+        assertThrows(TikaException.class, () -> new JupyterParser().parse(
+                TikaInputStream.get("[1, 2]".getBytes(UTF_8)), new 
DefaultHandler(),
+                new Metadata(), new ParseContext()));
+    }
+
+    /**
+     * An error output with its ename and evalue repeated: JSON parsers accept 
duplicate keys,
+     * and the first of each is kept, so the work stays linear.
+     */
+    @Test
+    @Timeout(30)
+    public void testRepeatedErrorKeys() throws Exception {
+        StringBuilder error = new StringBuilder("{\"output_type\": \"error\"");
+        for (int i = 0; i < 100_000; i++) {
+            error.append(", \"ename\": \"ValueError").append(i).append('"');
+        }
+        error.append(", \"evalue\": \"bad input\", \"evalue\": \"ignored\"}");
+        String content = parse(notebook(code("{}", error.toString()))).get(0)
+                .get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains("ValueError0: bad input", content);
+        assertNotContained("ValueError1", content);
+        assertNotContained("ignored", content);
+    }
+
+    /** An image that is not base64 is recorded and skipped; the next one 
still comes out. */
+    @Test
+    public void testBadBase64() throws Exception {
+        List<Metadata> list = parse(notebook(code("{}",
+                image("\"image/png\": \"not base64 at all!\""),
+                image("\"image/png\": [\"iVBORw0K\\n\", \"Ggo=\\n\"]"))));
+        assertEquals(2, list.size());
+        assertEquals(THUMBNAIL, 
list.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        assertContains("IllegalArgumentException",
+                
list.get(0).get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM));
+    }
+
+    /**
+     * The document and string limits: a notebook above either fails to read, 
which the
+     * parser reports as a TikaException, as it does any unreadable notebook.
+     */
+    @Test
+    public void testLimits() throws Exception {
+        String cell = markdown("{}").replace("\"m\"", "\"" + "m".repeat(2_000) 
+ "\"");
+        String[] cells = new String[50];
+        Arrays.fill(cells, cell);
+        byte[] notebook = notebook(cells).getBytes(UTF_8);
+        assertEquals(50, Notebook.read(new ByteArrayInputStream(notebook), 
notebook.length,
+                10_000).cells().size());
+        assertThrows(JacksonException.class, () -> Notebook.read(
+                new ByteArrayInputStream(notebook), 20_000, 10_000));
+        assertThrows(JacksonException.class, () -> Notebook.read(
+                new ByteArrayInputStream(notebook), notebook.length, 1_000));
+    }
+
+    private List<String> imageTypes(String notebook) throws Exception {
+        List<String> types = new ArrayList<>();
+        List<Metadata> list = parse(notebook);
+        for (Metadata m : list.subList(1, list.size())) {
+            types.add(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        }
+        return types;
+    }
+
+    private List<Metadata> parse(String notebook) throws Exception {
+        try (TikaInputStream tis = 
TikaInputStream.get(notebook.getBytes(UTF_8))) {
+            return getRecursiveMetadata(tis, new JupyterParser(), new 
Metadata(),
+                    new ParseContext(), false);
+        }
+    }
+
+    private static String notebook(String... cells) {
+        return "{\"cells\": [" + String.join(", ", cells) + "], \"metadata\": 
{}, "
+                + "\"nbformat\": 4, \"nbformat_minor\": 5}";
+    }
+
+    private static String code(String metadata, String... outputs) {
+        return "{\"cell_type\": \"code\", \"metadata\": " + metadata + ", 
\"outputs\": ["
+                + String.join(", ", outputs) + "], \"source\": \"x\"}";
+    }
+
+    private static String markdown(String metadata) {
+        return "{\"cell_type\": \"markdown\", \"metadata\": " + metadata + ", 
\"source\": \"m\"}";
+    }
+
+    private static String markdownMarked() {
+        return markdown("{\"tags\": [\"nbsphinx-thumbnail\"]}");
+    }
+
+    /** A raw cell with an image output, which is not a code cell and never 
the thumbnail. */
+    private static String raw(String output) {
+        return "{\"cell_type\": \"raw\", \"metadata\": {}, \"outputs\": [" + 
output
+                + "], \"source\": \"r\"}";
+    }
+
+    private static String tag() {
+        return "{\"tags\": [\"nbsphinx-thumbnail\"]}";
+    }
+
+    private static String image(String data) {
+        return "{\"output_type\": \"display_data\", \"metadata\": {}, 
\"data\": {" + data + "}}";
+    }
+
+    private static String stream() {
+        return "{\"output_type\": \"stream\", \"name\": \"stdout\", \"text\": 
[\"out\\n\"]}";
+    }
+
+    private static String error() {
+        return "{\"output_type\": \"error\", \"ename\": \"E\", \"evalue\": 
\"v\", \"traceback\": []}";
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testJupyter.ipynb
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testJupyter.ipynb
new file mode 100644
index 0000000000..0009ffe727
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/resources/test-documents/testJupyter.ipynb
@@ -0,0 +1,117 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "c0",
+   "metadata": {},
+   "source": [
+    "# Orbit plots\n",
+    "A small notebook with two figures."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "c1",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "loaded 3 bodies\n"
+     ]
+    }
+   ],
+   "source": [
+    "import math\n",
+    "print('loaded 3 bodies')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "c2",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": 
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAGCAIAAABrW6giAAAAEElEQVR4nGP8z4AATAwUcgBO1QELCC85FwAAAABJRU5ErkJggg==",
+      "text/plain": [
+       "<Figure size 4x6>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plot_overview()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "c3",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "42"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "6 * 7"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "c4",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": 
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAADCAIAAAAhqtkfAAAAFElEQVR4nGNkYPjPgA0wYRXFJwEASOQBBRuwRqkAAAAASUVORK5CYII=",
+      "text/plain": [
+       "<Figure size 8x3>"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "ename": "ZeroDivisionError",
+     "evalue": "division by zero",
+     "output_type": "error",
+     "traceback": [
+      "..."
+     ]
+    }
+   ],
+   "source": [
+    "plot_result()\n",
+    "1 / 0"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "name": "python",
+   "version": "3.12.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

Reply via email to