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. "python3".
+ */
+ 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. "4.5".
+ */
+ 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\": \"\"}";
+ 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
+}