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.
      */

Reply via email to