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 52aaa53b3f TIKA-5032: emit the 3MF package thumbnail (#3545)
52aaa53b3f is described below
commit 52aaa53b3ff94677757ff326b669f05c57357240
Author: Dominik Schmidt <[email protected]>
AuthorDate: Fri Nov 6 17:45:48 2026 +0100
TIKA-5032: emit the 3MF package thumbnail (#3545)
* TIKA-5032: emit the 3MF package thumbnail
A 3MF package names its thumbnail by the OPC thumbnail relationship in
_rels/.rels; that entry is emitted as the THUMBNAIL. Slicers do not all use
Metadata/thumbnail.png (Bambu Studio and Creality Print name
Metadata/plate_1.png), and a model part's own thumbnail is not the package's.
* TIKA-5032: name the relationship constants after OPC
* TIKA-5032: resolve the 3MF thumbnail target through ZipEntryPaths
The relationship target is a URI reference: escapes are decoded, '../' and
fragments resolved, and a target with a scheme or above the package root is
refused.
---
CHANGES.txt | 4 +
docs/modules/ROOT/pages/formats.adoc | 3 +-
.../ROOT/pages/using-tika/server/index.adoc | 10 +--
.../org/apache/tika/parser/pkg/ZipParserTest.java | 85 ++++++++++++++++++++++
.../org/apache/tika/parser/pkg/ZipPreview.java | 55 +++++++++++++-
5 files changed, 150 insertions(+), 7 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index d96cda2cb8..70246f11e1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -162,6 +162,10 @@ Release 4.3.0 - ???
* 3MF packages are parsed as zip packages instead of failing as XML: the
type is a
sub-type of application/zip, and the streaming zip detector recognizes it
(TIKA-5031).
+ * 3MF packages have the image their package thumbnail relationship names
emitted as the
+ THUMBNAIL, usually Metadata/thumbnail.png; a model part's own thumbnail
is not taken
+ (TIKA-5032).
+
* New SubtitleParser for WebVTT (.vtt) and SubRip (.srt). Each cue becomes
<div class="cue"> with data-start/data-end (HH:MM:SS.mmm) and data-id,
its text
cleaned of markup; WebVTT NOTE blocks become <div class="note"> and a
Language
diff --git a/docs/modules/ROOT/pages/formats.adoc
b/docs/modules/ROOT/pages/formats.adoc
index 149a172eb4..cd377eb9bc 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -122,7 +122,8 @@ supports the XML-based FictionBook (`.fb2`) publishing
format.
Comic book archives (`.cbz`) are read by the zip parser, which emits the
first page in name order as the cover thumbnail. The zip parser also emits the
stored preview of Krita (`.kra`), OpenRaster (`.ora`), FreeCAD (`.FCStd`),
-Procreate (`.procreate`) and Sketch (`.sketch`) files as their thumbnail.
+Procreate (`.procreate`) and Sketch (`.sketch`) files as their thumbnail, and
of 3MF
+(`.3mf`) packages the image their thumbnail relationship names.
link:{api}/org/apache/tika/parser/mobi/MobiParser.html[MobiParser] reads
Mobipocket
books and their Kindle descendants (`.mobi`, `.prc`, `.azw`, and the KF8
generation
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 82de082d12..29b59c9cd0 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -201,11 +201,11 @@ renders, so the presets do not make them. Both name the
standard parsers, so the
`/unpack/preset/thumbnail` — "just give me the thumbnail". The zip holds at
most one raster
image per request and nothing else: the stored thumbnail (Office docProps,
audio cover art, raw
-camera previews, epub, Mobipocket, PSD, FictionBook, Blender, SketchUp, iWork,
the cover of a cbz, the
-preview of Krita and other zip-based formats), the rasterized EMF/WMF
thumbnail, or the PDF
-page; `204` when the document has none. An image file is its own thumbnail: it
has no embedded
-documents, so it answers `204` too. Text is read from the first PDF page only,
no OCR or
-inference runs, and embedded documents are parsed two levels deep at most, so
the parse is
+camera previews, epub, Mobipocket, PSD, FictionBook, Blender, SketchUp, iWork,
the cover of a
+cbz, the preview of Krita, 3MF and other zip-based formats), the rasterized
EMF/WMF thumbnail,
+or the PDF page; `204` when the document has none. An image file is its own
thumbnail: it has
+no embedded documents, so it answers `204` too. Text is read from the first
PDF page only, no
+OCR or inference runs, and embedded documents are parsed two levels deep at
most, so the parse is
cheap.
`/rmeta/preset/render-thumbnail` — "give me everything, thumbnail included". A
full parse
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
index 4211d59e1b..a3b7f56902 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
@@ -241,6 +241,91 @@ public class ZipParserTest extends AbstractPkgTest {
assertNull(entries.get("001.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
}
+ /**
+ * 3MF names its thumbnail by the package's thumbnail relationship,
wherever it lies; a
+ * model part's own thumbnail is not the package's.
+ */
+ @Test
+ public void test3MFPackageThumbnail() throws Exception {
+ Map<String, Metadata> entries =
entriesByName(threeMF("/Metadata/thumbnail.png",
+ "Metadata/thumbnail.png", "Thumbnails/box.png"), "box.3mf");
+ assertEquals("model/3mf",
entries.get("").get(HttpHeaders.CONTENT_TYPE));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
entries.get("Metadata/thumbnail.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+
assertNull(entries.get("Thumbnails/box.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+
+ entries = entriesByName(threeMF("Previews/front.png",
"Previews/front.png"), "a.3mf");
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
entries.get("Previews/front.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
+ "a relative target counts from the package root");
+
+ entries = entriesByName(threeMF("/Metadata/plate%201.png",
"Metadata/plate 1.png"),
+ "a.3mf");
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+ entries.get("Metadata/plate
1.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
+ "the target is a URI, its escapes decoded");
+ }
+
+ @Test
+ public void test3MFWithoutThumbnailRelationship() throws Exception {
+ Map<String, Metadata> entries =
+ entriesByName(threeMF(null, "Metadata/thumbnail.png"),
"box.3mf");
+ assertEquals("model/3mf",
entries.get("").get(HttpHeaders.CONTENT_TYPE));
+
assertNull(entries.get("Metadata/thumbnail.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
+ "the usual path alone is not the thumbnail");
+
+ entries = entriesByName(threeMF("/Metadata/missing.png",
"Metadata/thumbnail.png"),
+ "box.3mf");
+
assertNull(entries.get("Metadata/thumbnail.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+
assertNull(entries.get("").get(TikaCoreProperties.CONTAINER_EXCEPTION));
+ }
+
+ /**
+ * A 3MF package: content types, the package relationships (the model and,
if given, the
+ * thumbnail), the model with a relationship to a thumbnail of its own,
and the images.
+ */
+ private static byte[] threeMF(String thumbnailTarget, String... images)
throws IOException {
+ String types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ + "<Types
xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">"
+ + "<Default Extension=\"rels\" "
+ +
"ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>"
+ + "<Default Extension=\"model\" "
+ +
"ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>"
+ + "<Default Extension=\"png\"
ContentType=\"image/png\"/></Types>";
+ String rels = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ + "<Relationships
xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ + "<Relationship
Type=\"http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel\" "
+ + "Target=\"/3D/3dmodel.model\" Id=\"rel0\"/>"
+ + (thumbnailTarget == null ? "" : "<Relationship
Type=\"http://schemas.openxmlformats"
+ + ".org/package/2006/relationships/metadata/thumbnail\"
Target=\"" + thumbnailTarget
+ + "\" Id=\"rel1\"/>")
+ + "</Relationships>";
+ String modelRels = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ + "<Relationships
xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
+ + "<Relationship
Type=\"http://schemas.openxmlformats.org/package/2006/relationships"
+ + "/metadata/thumbnail\" Target=\"/Thumbnails/box.png\"
Id=\"rel2\"/></Relationships>";
+ String model = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ + "<model unit=\"millimeter\"
xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\">"
+ + "<resources/><build/></model>";
+ byte[] png = png();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(bos)) {
+ String[][] parts = {{"[Content_Types].xml", types},
{"_rels/.rels", rels},
+ {"3D/3dmodel.model", model},
{"3D/_rels/3dmodel.model.rels", modelRels}};
+ for (String[] part : parts) {
+ zos.putNextEntry(new ZipEntry(part[0]));
+ zos.write(part[1].getBytes(UTF_8));
+ zos.closeEntry();
+ }
+ for (String image : images) {
+ zos.putNextEntry(new ZipEntry(image));
+ zos.write(png);
+ zos.closeEntry();
+ }
+ }
+ return bos.toByteArray();
+ }
+
private Map<String, Metadata> entriesByName(byte[] zip, String fileName)
throws Exception {
Metadata metadata = new Metadata();
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
index 4cc8464f7c..7abc728e84 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
@@ -16,6 +16,8 @@
*/
package org.apache.tika.parser.pkg;
+import java.io.IOException;
+import java.io.InputStream;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Map;
@@ -24,10 +26,18 @@ import java.util.function.Function;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.BoundedInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.utils.XMLReaderUtils;
+import org.apache.tika.zip.utils.ZipEntryPaths;
/**
* Picks the entry of a zip-based format that is its preview image, so
@@ -47,7 +57,16 @@ final class ZipPreview {
MediaType.image("openraster"), entry("Thumbnails/thumbnail.png"),
MediaType.application("x-extension-fcstd"),
entry("thumbnails/Thumbnail.png"),
MediaType.application("x-procreate"),
entry("QuickLook/Thumbnail.png"),
- MediaType.application("x-sketch"), entry("previews/preview.png"));
+ MediaType.application("x-sketch"), entry("previews/preview.png"),
+ MediaType.parse("model/3mf"), ZipPreview::opcPackageThumbnail);
+
+ private static final String OPC_PACKAGE_RELATIONSHIPS = "_rels/.rels";
+
+ private static final String OPC_THUMBNAIL_RELATIONSHIP =
+
"http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail";
+
+ /** An OPC package's relationships are a few hundred bytes; this bounds a
bad one. */
+ static final int MAX_OPC_RELATIONSHIPS_LENGTH = 1024 * 1024;
private ZipPreview() {
}
@@ -69,6 +88,40 @@ final class ZipPreview {
return zipFile -> zipFile.getEntry(name) == null ? null : name;
}
+ /**
+ * The target of the thumbnail relationship of an OPC package (the
container of 3MF, as
+ * of OOXML), as 3MF names its thumbnail; a part may carry thumbnails of
its own, which
+ * are not the package's.
+ */
+ private static String opcPackageThumbnail(ZipFile zipFile) {
+ ZipArchiveEntry relationships =
zipFile.getEntry(OPC_PACKAGE_RELATIONSHIPS);
+ if (relationships == null) {
+ return null;
+ }
+ OpcThumbnailTarget handler = new OpcThumbnailTarget();
+ try (InputStream in = new
BoundedInputStream(MAX_OPC_RELATIONSHIPS_LENGTH,
+ zipFile.getInputStream(relationships))) {
+ XMLReaderUtils.parseSAX(in, handler, new ParseContext());
+ } catch (IOException | SAXException | TikaException e) {
+ return null;
+ }
+ String name = ZipEntryPaths.resolve("", handler.target);
+ return name == null || zipFile.getEntry(name) == null ? null : name;
+ }
+
+ private static final class OpcThumbnailTarget extends DefaultHandler {
+ private String target;
+
+ @Override
+ public void startElement(String uri, String localName, String qName,
Attributes attrs) {
+ if (target == null && "Relationship".equals(localName)
+ &&
OPC_THUMBNAIL_RELATIONSHIP.equals(attrs.getValue("Type"))
+ && !"External".equals(attrs.getValue("TargetMode"))) {
+ target = attrs.getValue("Target");
+ }
+ }
+ }
+
/**
* A comic's cover is its first page, and the pages are ordered by name.
*/