diff --git a/CHANGES.txt b/CHANGES.txt index 92606e27fdd..e38825e22fe 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -28,6 +28,9 @@ Release 4.0.0-beta-1 - 6/29/2026 false, so an out-of-the-box tika-grpc server no longer accepts per-request configuration or runtime store mutations (TIKA-4764). + * Unified XMP extraction across containers; adds HEIF/HEIC and WebP XMP, + including Samsung/Google Motion Photo (TIKA-4775). + OTHER CHANGES * Release artifacts are now channel-specific. Maven Central gets slim diff --git a/tika-bundles/tika-bundle-standard/pom.xml b/tika-bundles/tika-bundle-standard/pom.xml index 5991c75d87e..168e127f356 100644 --- a/tika-bundles/tika-bundle-standard/pom.xml +++ b/tika-bundles/tika-bundle-standard/pom.xml @@ -113,8 +113,6 @@ pdfbox| pdfbox-tools| fontbox| - jempbox| - xmpbox| bcjmail-jdk18on| bcprov-jdk18on| bcpkix-jdk18on| diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Google.java b/tika-core/src/main/java/org/apache/tika/metadata/Google.java new file mode 100644 index 00000000000..61c20938677 --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/metadata/Google.java @@ -0,0 +1,52 @@ +/* + * 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 from the Google Photos XMP namespaces used by Motion Photos and the legacy + * MicroVideo format. See + * the Motion Photo + * format. + *

+ * Files use the {@code Camera} (current) or {@code GCamera} (legacy) prefix, but the namespace + * URI is the same; the image XMP handler maps by URI, so these keys are stable regardless of + * prefix. + */ +public interface Google { + + String CAMERA_NS = "http://ns.google.com/photos/1.0/camera/"; + + String CONTAINER_NS = "http://ns.google.com/photos/1.0/container/"; + + String ITEM_NS = "http://ns.google.com/photos/1.0/container/item/"; + + Property MOTION_PHOTO = Property.externalText("Camera:MotionPhoto"); + + Property MOTION_PHOTO_VERSION = Property.externalText("Camera:MotionPhotoVersion"); + + Property MOTION_PHOTO_PRESENTATION_TIMESTAMP_US = + Property.externalText("Camera:MotionPhotoPresentationTimestampUs"); + + Property MICRO_VIDEO = Property.externalText("Camera:MicroVideo"); + + Property MICRO_VIDEO_VERSION = Property.externalText("Camera:MicroVideoVersion"); + + Property MICRO_VIDEO_OFFSET = Property.externalText("Camera:MicroVideoOffset"); + + Property MICRO_VIDEO_PRESENTATION_TIMESTAMP_US = + Property.externalText("Camera:MicroVideoPresentationTimestampUs"); +} diff --git a/tika-core/src/main/java/org/apache/tika/metadata/PDF.java b/tika-core/src/main/java/org/apache/tika/metadata/PDF.java index 51451e71dfa..1f6d9c3284c 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/PDF.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/PDF.java @@ -38,6 +38,9 @@ public interface PDF { */ Property EOF_OFFSETS = Property.externalRealSeq(PDF_PREFIX + "eofOffsets"); + /** Trapped flag from the XMP {@code pdf:} schema; docinfo counterpart {@link #DOC_INFO_TRAPPED}. */ + Property TRAPPED = Property.internalText(PDF_PREFIX + "Trapped"); + /** * Prefix to be used for properties that record what was stored * in the docinfo section (as opposed to XMP) diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java b/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java index af4ababb08a..be142f81dd4 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/Photoshop.java @@ -47,6 +47,9 @@ public interface Photoshop { PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ColorMode", _COLOR_MODE_CHOICES_INDEXED); + Property ICC_PROFILE = Property.internalText( + PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ICCProfile"); + Property CAPTION_WRITER = Property.internalText( PREFIX_PHOTOSHOP + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "CaptionWriter"); diff --git a/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java b/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java index fe5fd0ec39c..f3de5ec8b64 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/TIFF.java @@ -126,4 +126,16 @@ public interface TIFF { Property ORIGINAL_DATE = Property.internalDate("exif:DateTimeOriginal"); Property EXIF_PAGE_COUNT = Property.externalInteger("exif:PageCount"); + + /** Camera-body serial number. */ + Property SERIAL_NUMBER = Property.internalText("aux:SerialNumber"); + + /** Human-readable lens description, e.g. "70-200mm f/2.8". */ + Property LENS = Property.internalText("aux:Lens"); + + /** Lens spec: min/max focal length and aperture. */ + Property LENS_INFO = Property.internalText("aux:LensInfo"); + + /** Numeric lens identifier. */ + Property LENS_ID = Property.internalText("aux:LensID"); } diff --git a/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java b/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java index 2a81fa254fd..66bad542385 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/XMPMM.java @@ -92,6 +92,12 @@ public interface XMPMM { */ Property HISTORY_SOFTWARE_AGENT = Property.externalTextBag(PREFIX_ + "History:SoftwareAgent"); + /** Part of the resource changed by the action (XMPMM history section). */ + Property HISTORY_CHANGED = Property.externalTextBag(PREFIX_ + "History:Changed"); + + /** Additional parameters describing the action (XMPMM history section). */ + Property HISTORY_PARAMETERS = Property.externalTextBag(PREFIX_ + "History:Parameters"); + /** * Document id for the document that this document * was derived from @@ -104,4 +110,12 @@ public interface XMPMM { */ Property DERIVED_FROM_INSTANCEID = Property.externalText(PREFIX_ + "DerivedFrom:InstanceID"); + /** Original document id of the source this document was derived from. */ + Property DERIVED_FROM_ORIGINAL_DOCUMENTID = + Property.externalText(PREFIX_ + "DerivedFrom:OriginalDocumentID"); + + /** Rendition class of the source this document was derived from. */ + Property DERIVED_FROM_RENDITION_CLASS = + Property.externalText(PREFIX_ + "DerivedFrom:RenditionClass"); + } diff --git a/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java b/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java new file mode 100644 index 00000000000..a90b2054a17 --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/metadata/XMPTIFF.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.metadata; + +/** + * Metadata keys for values that derive strictly from the XMP {@code tiff:} / {@code exif:} + * schemas, mirroring {@link TIFF}. Tika folds these into the canonical {@link TIFF} keys, where + * the same value can also arrive from binary EXIF; these parallel keys preserve provenance so a + * consumer can distinguish an XMP-sourced value from a binary one. + *

+ * Same pattern as {@link XMPDC}. Deliberately not implemented by {@link Metadata}; reference it + * directly. + */ +public interface XMPTIFF { + + String PREFIX_TIFF = "xmp" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "tiff"; + String PREFIX_EXIF = "xmp" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "exif"; + + Property EQUIPMENT_MAKE = Property.internalText( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Make"); + + Property EQUIPMENT_MODEL = Property.internalText( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Model"); + + Property SOFTWARE = Property.internalText( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Software"); + + Property IMAGE_WIDTH = Property.internalInteger( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ImageWidth"); + + Property IMAGE_LENGTH = Property.internalInteger( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "ImageLength"); + + Property BITS_PER_SAMPLE = Property.internalIntegerSequence( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "BitsPerSample"); + + Property SAMPLES_PER_PIXEL = Property.internalInteger( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "SamplesPerPixel"); + + Property ORIENTATION = Property.internalText( + PREFIX_TIFF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "Orientation"); + + Property ORIGINAL_DATE = Property.internalDate( + PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "DateTimeOriginal"); + + Property ISO_SPEED_RATINGS = Property.internalIntegerSequence( + PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "IsoSpeedRatings"); + + /** Valid image width; folded into canonical {@link TIFF#IMAGE_WIDTH}. */ + Property PIXEL_X_DIMENSION = Property.internalInteger( + PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "PixelXDimension"); + + /** Valid image height; folded into canonical {@link TIFF#IMAGE_LENGTH}. */ + Property PIXEL_Y_DIMENSION = Property.internalInteger( + PREFIX_EXIF + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "PixelYDimension"); +} diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml index eb541e18752..8625495c0da 100644 --- a/tika-parent/pom.xml +++ b/tika-parent/pom.xml @@ -381,7 +381,6 @@ 3.0.5 1.82 2.0.6.1 - 1.8.17 12.1.11 12.1.11 2.0.0 diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java index 33ee3a49800..a7439f42566 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/BPGParser.java @@ -165,7 +165,7 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata metadataExtractor.parseRawExif(stream, extensionLength, true); break; case EXTENSION_TAG_XMP: - handleXMP(stream, extensionLength, metadataExtractor); + handleXMP(stream, extensionLength, metadata, parseContext); break; default: IOUtils.skipFully(stream, extensionLength); @@ -186,7 +186,8 @@ public int getMaxRecordLength() { return this.maxRecordLength; } - protected void handleXMP(InputStream stream, int xmpLength, ImageMetadataExtractor extractor) + protected void handleXMP(InputStream stream, int xmpLength, Metadata metadata, + ParseContext context) throws IOException, TikaException, SAXException { if (xmpLength < 0) { throw new TikaException("xmp length must be >= 0"); @@ -199,6 +200,6 @@ protected void handleXMP(InputStream stream, int xmpLength, ImageMetadataExtract } byte[] xmp = new byte[xmpLength]; IOUtils.readFully(stream, xmp); - extractor.parseRawXMP(xmp); + ImageXmp.extractRaw(xmp, metadata, context); } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java new file mode 100644 index 00000000000..faedf073395 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ExtendedXmp.java @@ -0,0 +1,125 @@ +/* + * 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.image; + +import static java.nio.charset.StandardCharsets.US_ASCII; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.drew.lang.SequentialByteArrayReader; +import com.drew.lang.SequentialReader; + +/** + * Reassembles a large XMP packet split across JPEG APP1 segments ("Extended XMP", XMP Part 3). + * Chunk stitching is borrowed almost verbatim from metadata-extractor's {@code XmpReader} + * (Apache 2.0, Drew Noakes); we find the GUID by scanning the standard packet since Tika parses + * the XMP itself. + */ +final class ExtendedXmp { + + private static final String XMP_JPEG_PREAMBLE = "http://ns.adobe.com/xap/1.0/\u0000"; + private static final String XMP_EXTENSION_JPEG_PREAMBLE = + "http://ns.adobe.com/xmp/extension/\u0000"; + private static final int EXTENDED_XMP_GUID_LENGTH = 32; + private static final int EXTENDED_XMP_INT_LENGTH = 4; + // Tika: cap the declared length so a hostile chunk can't drive a huge allocation (not upstream). + private static final int MAX_EXTENDED = 64 * 1024 * 1024; + // allow any run of attribute/element syntax (=, quotes, '>', whitespace, newlines) before the GUID + private static final Pattern HAS_EXTENDED = + Pattern.compile("HasExtendedXMP[\"'>=\\s]*([A-Fa-f0-9]{32})"); + + private ExtendedXmp() { + } + + /** XMP packets (standard + reassembled extended, if any) from a JPEG's APP1 segment payloads. */ + static List assemble(Iterable segments) { + // adapted from metadata-extractor XmpReader.readJpegSegments + final int preambleLength = XMP_JPEG_PREAMBLE.length(); + final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length(); + String guid = null; + byte[] standard = null; + byte[] extendedBuffer = null; + + for (byte[] segmentBytes : segments) { + if (segmentBytes.length >= preambleLength && XMP_JPEG_PREAMBLE + .equalsIgnoreCase(new String(segmentBytes, 0, preambleLength, US_ASCII))) { + standard = new byte[segmentBytes.length - preambleLength]; + System.arraycopy(segmentBytes, preambleLength, standard, 0, standard.length); + guid = findGuid(standard); + extendedBuffer = null; // a new standard packet starts a fresh extended assembly + } else if (guid != null && segmentBytes.length >= extensionPreambleLength + && XMP_EXTENSION_JPEG_PREAMBLE.equalsIgnoreCase( + new String(segmentBytes, 0, extensionPreambleLength, US_ASCII))) { + extendedBuffer = processExtendedXMPChunk(segmentBytes, guid, extendedBuffer); + } + } + + List packets = new ArrayList<>(); + if (standard != null) { + packets.add(standard); + } + if (extendedBuffer != null) { + packets.add(extendedBuffer); + } + return packets; + } + + private static String findGuid(byte[] standard) { + Matcher m = HAS_EXTENDED.matcher(new String(standard, US_ASCII)); + return m.find() ? m.group(1) : null; + } + + // Borrowed almost verbatim from metadata-extractor XmpReader.processExtendedXMPChunk (Apache 2.0). + private static byte[] processExtendedXMPChunk(byte[] segmentBytes, String extendedXMPGUID, + byte[] extendedXMPBuffer) { + final int extensionPreambleLength = XMP_EXTENSION_JPEG_PREAMBLE.length(); + final int segmentLength = segmentBytes.length; + final int totalOffset = extensionPreambleLength + EXTENDED_XMP_GUID_LENGTH + + EXTENDED_XMP_INT_LENGTH + EXTENDED_XMP_INT_LENGTH; + if (segmentLength >= totalOffset) { + try { + final SequentialReader reader = new SequentialByteArrayReader(segmentBytes); + reader.skip(extensionPreambleLength); + final String segmentGUID = reader.getString(EXTENDED_XMP_GUID_LENGTH); + if (extendedXMPGUID.equals(segmentGUID)) { + final int fullLength = (int) reader.getUInt32(); + final int chunkOffset = (int) reader.getUInt32(); + if (extendedXMPBuffer == null) { + if (fullLength <= 0 || fullLength > MAX_EXTENDED) { // Tika OOM guard + return null; + } + extendedXMPBuffer = new byte[fullLength]; + } + if (extendedXMPBuffer.length == fullLength) { + int copyLength = segmentLength - totalOffset; + if (chunkOffset >= 0 && chunkOffset <= extendedXMPBuffer.length - copyLength) { + System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset, + copyLength); + } + } + } + } catch (IOException ex) { + // best effort: keep whatever chunks were already assembled + } + } + return extendedXMPBuffer; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java index 9e2088c2562..f49f5947632 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifParser.java @@ -16,8 +16,10 @@ */ package org.apache.tika.parser.image; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -27,6 +29,8 @@ import org.apache.tika.annotation.TikaComponent; import org.apache.tika.exception.TikaException; +import org.apache.tika.io.TemporaryResources; +import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; @@ -48,7 +52,23 @@ public Set getSupportedTypes(ParseContext context) { void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata metadata, ParseContext parseContext) throws IOException, SAXException, TikaException { - new ImageMetadataExtractor(metadata).parseHeif(stream); + TemporaryResources tmp = new TemporaryResources(); + try { + TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata); + File file = tis.getFile(); // spool so the file can be re-read below + // XMP first so it is canonical; metadata-extractor (EXIF/GPS) fills gaps. + // Locate the XMP item precisely via meta/iinf/iloc first, so an embedded resource's XMP + // (e.g. a motion-photo video in mdat) can't be attributed to the image; fall back to the + // byte scanner for packets not reachable through the boxes. + if (!HeifXmp.extract(file, metadata, parseContext)) { + ImageXmp.scanAndExtract(tis, metadata, parseContext); + } + try (InputStream heif = Files.newInputStream(file.toPath())) { + new ImageMetadataExtractor(metadata).parseHeif(heif); + } + } finally { + tmp.dispose(); + } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java new file mode 100644 index 00000000000..3461b412657 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/HeifXmp.java @@ -0,0 +1,196 @@ +/* + * 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.image; + +import static java.nio.charset.StandardCharsets.US_ASCII; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.drew.lang.SequentialByteArrayReader; +import com.drew.metadata.heif.boxes.Box; +import com.drew.metadata.heif.boxes.ItemInfoBox; +import com.drew.metadata.heif.boxes.ItemInfoBox.ItemInfoEntry; +import com.drew.metadata.heif.boxes.ItemLocationBox; +import com.drew.metadata.heif.boxes.ItemLocationBox.Extent; +import org.xml.sax.SAXException; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentUtil; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.xmp.XmpExtractor; + +/** + * HEIF/HEIC XMP fallback for packets that are not {@code }-wrapped (the byte + * scanner {@link ImageXmp#scanAndExtract} can't find those). XMP is a + * {@code mime}/{@code application/rdf+xml} item located via the ISO-BMFF + * {@code meta}/{@code iinf}/{@code iloc} boxes, parsed with metadata-extractor's box classes; only + * the top-level box walk is ours. No content-type getter exists, so a mime item is confirmed by + * sniffing its bytes for XMP markers. + */ +final class HeifXmp { + + private HeifXmp() { + } + + // 64 MB: guards against a corrupt extent length triggering a huge allocation. + private static final long MAX_ITEM = 64L * 1024 * 1024; + + /** Locate the rdf+xml item and parse it; returns false if none was found. */ + static boolean extract(File file, Metadata metadata, ParseContext context) { + byte[] xmp = locateQuietly(file); + if (xmp == null) { + return false; + } + try { + new XmpExtractor().extract(xmp, metadata, context); + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); // bad XMP must not fail the parse + } + return true; + } + + private static byte[] locateQuietly(File file) { + try { + return locate(file); + } catch (SecurityException e) { + throw e; + } catch (IOException | RuntimeException e) { + return null; // malformed / unreadable box structure -> no XMP, let EXIF proceed + } + } + + private static byte[] locate(File file) throws IOException { + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + long len = raf.length(); + long[] meta = box(raf, 0, len, "meta"); + if (meta == null) { + return null; + } + // meta is a FullBox: skip its 4-byte version/flags to reach the child boxes. + long childStart = meta[1] + 4; + long[] iinfAt = box(raf, childStart, meta[2], "iinf"); + long[] ilocAt = box(raf, childStart, meta[2], "iloc"); + if (iinfAt == null || ilocAt == null) { + return null; + } + ItemInfoBox iinf = parseIinf(readBytes(raf, iinfAt[0], (int) (iinfAt[2] - iinfAt[0]))); + ItemLocationBox iloc = parseIloc(readBytes(raf, ilocAt[0], (int) (ilocAt[2] - ilocAt[0]))); + return readXmpItem(raf, iinf, iloc, len); + } + } + + /** First box of {@code type} within [start,end); returns {boxStart, payloadStart, boxEnd}. */ + private static long[] box(RandomAccessFile raf, long start, long end, String type) + throws IOException { + long p = start; + while (p + 8 <= end) { + raf.seek(p); + long size = raf.readInt() & 0xffffffffL; + byte[] t = new byte[4]; + raf.readFully(t); + long headerLen = 8; + if (size == 1) { + size = raf.readLong(); + headerLen = 16; + } else if (size == 0) { + size = end - p; + } + if (size < headerLen || p + size > end) { + return null; // corrupt/truncated + } + if (type.equals(new String(t, US_ASCII))) { + return new long[]{p, p + headerLen, p + size}; + } + p += size; + } + return null; + } + + private static ItemInfoBox parseIinf(byte[] bytes) throws IOException { + SequentialByteArrayReader r = new SequentialByteArrayReader(bytes); + return new ItemInfoBox(r, new Box(r)); + } + + private static ItemLocationBox parseIloc(byte[] bytes) throws IOException { + SequentialByteArrayReader r = new SequentialByteArrayReader(bytes); + return new ItemLocationBox(r, new Box(r)); + } + + private static byte[] readXmpItem(RandomAccessFile raf, ItemInfoBox iinf, ItemLocationBox iloc, + long fileLen) throws IOException { + Map> byItem = new LinkedHashMap<>(); + for (Extent e : iloc.getExtents()) { + byItem.computeIfAbsent(e.getItemId(), k -> new ArrayList<>()).add(e); + } + for (Map.Entry> item : byItem.entrySet()) { + ItemInfoEntry info = iinf.getEntry(item.getKey()); + if (info == null || !"mime".equals(info.getItemType())) { + continue; + } + byte[] data = readExtents(raf, item.getValue(), fileLen); + if (data != null && looksLikeXmp(data)) { + return data; + } + } + return null; + } + + private static byte[] readExtents(RandomAccessFile raf, List extents, long fileLen) + throws IOException { + // Extent.getOffset() is an absolute file offset (iloc construction_method 0, which every real + // XMP item uses). Methods 1/2 aren't resolved -- like metadata-extractor's Exif reader -- but + // that's a safe miss: a wrong offset fails the bounds check or yields bytes looksLikeXmp() + // rejects, so the item is skipped. Resolve the base offset if a real method-1/2 file appears. + long total = 0; + for (Extent e : extents) { + if (e.getOffset() < 0 || e.getLength() < 0 || e.getOffset() + e.getLength() > fileLen) { + return null; + } + total += e.getLength(); + if (total > MAX_ITEM) { + return null; + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream((int) total); + for (Extent e : extents) { + out.write(readBytes(raf, e.getOffset(), (int) e.getLength())); + } + return out.toByteArray(); + } + + private static byte[] readBytes(RandomAccessFile raf, long pos, int len) throws IOException { + byte[] b = new byte[len]; + raf.seek(pos); + raf.readFully(b); + return b; + } + + private static boolean looksLikeXmp(byte[] data) { + String head = new String(data, 0, Math.min(data.length, 8192), US_ASCII); + return head.contains("xmpmeta") || head.contains("rdf:RDF") || head.contains("xpacket"); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java index 900c4d3f558..12527c47345 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageMetadataExtractor.java @@ -22,19 +22,19 @@ import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Date; import java.util.Iterator; +import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; -import com.adobe.internal.xmp.XMPException; -import com.adobe.internal.xmp.XMPMetaFactory; import com.drew.imaging.heif.HeifMetadataReader; import com.drew.imaging.jpeg.JpegMetadataReader; import com.drew.imaging.jpeg.JpegProcessingException; +import com.drew.imaging.jpeg.JpegSegmentMetadataReader; import com.drew.imaging.riff.RiffProcessingException; import com.drew.imaging.tiff.TiffMetadataReader; import com.drew.imaging.tiff.TiffProcessingException; @@ -45,6 +45,7 @@ import com.drew.metadata.Directory; import com.drew.metadata.MetadataException; import com.drew.metadata.Tag; +import com.drew.metadata.adobe.AdobeJpegReader; import com.drew.metadata.exif.ExifDirectoryBase; import com.drew.metadata.exif.ExifIFD0Directory; import com.drew.metadata.exif.ExifReader; @@ -52,14 +53,20 @@ import com.drew.metadata.exif.ExifThumbnailDirectory; import com.drew.metadata.exif.GpsDirectory; import com.drew.metadata.icc.IccDirectory; +import com.drew.metadata.icc.IccReader; import com.drew.metadata.iptc.IptcDirectory; +import com.drew.metadata.iptc.IptcReader; +import com.drew.metadata.jfif.JfifReader; +import com.drew.metadata.jfxx.JfxxReader; import com.drew.metadata.jpeg.JpegCommentDirectory; +import com.drew.metadata.jpeg.JpegCommentReader; +import com.drew.metadata.jpeg.JpegDhtReader; import com.drew.metadata.jpeg.JpegDirectory; -import com.drew.metadata.xmp.XmpDirectory; +import com.drew.metadata.jpeg.JpegDnlReader; +import com.drew.metadata.jpeg.JpegReader; +import com.drew.metadata.photoshop.DuckyReader; +import com.drew.metadata.photoshop.PhotoshopReader; import org.apache.commons.io.IOUtils; -import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream; -import org.apache.jempbox.xmp.XMPMetadata; -import org.w3c.dom.Document; import org.xml.sax.SAXException; import org.apache.tika.exception.TikaException; @@ -69,9 +76,6 @@ import org.apache.tika.metadata.Property; import org.apache.tika.metadata.TIFF; import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.xmp.JempboxExtractor; -import org.apache.tika.utils.XMLReaderUtils; /** * Uses the Metadata Extractor library @@ -83,7 +87,6 @@ public class ImageMetadataExtractor { //TODO: add this to the signatures from the actual parse - private static final ParseContext EMPTY_PARSE_CONTEXT = new ParseContext(); private static final String GEO_DECIMAL_FORMAT_STRING = "#.######"; // 6 dp seems to be reasonable @@ -99,7 +102,7 @@ public class ImageMetadataExtractor { public ImageMetadataExtractor(Metadata metadata) { this(metadata, new CopyUnknownFieldsHandler(), new TiffPageNumberHandler(), new JpegCommentHandler(), new ExifHandler(), new DimensionsHandler(), - new GeotagHandler(), new IptcHandler(), new XmpHandler()); + new GeotagHandler(), new IptcHandler()); } /** @@ -121,9 +124,16 @@ private static String trimPixels(String s) { return s; } + // ALL_READERS minus XmpReader: Tika parses XMP itself, avoiding a double XMP parse. + private static final List JPEG_READERS_NO_XMP = Arrays.asList( + new JpegReader(), new JpegCommentReader(), new JfifReader(), new JfxxReader(), + new ExifReader(), new IccReader(), new PhotoshopReader(), new DuckyReader(), + new IptcReader(), new AdobeJpegReader(), new JpegDhtReader(), new JpegDnlReader()); + public void parseJpeg(File file) throws IOException, SAXException, TikaException { try { - com.drew.metadata.Metadata jpegMetadata = JpegMetadataReader.readMetadata(file); + com.drew.metadata.Metadata jpegMetadata = + JpegMetadataReader.readMetadata(file, JPEG_READERS_NO_XMP); handle(jpegMetadata); } catch (JpegProcessingException | MetadataException e) { throw new TikaException("Can't read JPEG metadata", e); @@ -191,22 +201,6 @@ public void parseRawExif(byte[] exifData) throws IOException, SAXException, Tika } } - public void parseRawXMP(byte[] xmpData) throws IOException, SAXException, TikaException { - XMPMetadata xmp = null; - try (InputStream decoded = UnsynchronizedByteArrayInputStream.builder().setByteArray(xmpData).get()) { - Document dom = XMLReaderUtils.buildDOM(decoded, EMPTY_PARSE_CONTEXT); - if (dom != null) { - xmp = new XMPMetadata(dom); - } - } catch (IOException | SAXException e) { - // - } - if (xmp != null) { - JempboxExtractor.extractDublinCore(xmp, metadata); - JempboxExtractor.extractXMPMM(xmp, metadata); - } - - } /** * Copies extracted tags to tika metadata using registered handlers. @@ -310,55 +304,6 @@ public void handle(Directory directory, Metadata metadata) throws MetadataExcept } } - /** - * Copies the XMP properties parsed by Metadata Extractor into the metadata, - * keyed by their {@code prefix:name} path. The other handlers copy a - * directory's tags, but XMP keeps its properties in a separate map - * ({@link XmpDirectory#getXmpProperties()}), so without this they are lost. - * A property is skipped when its key is already set or matches a known Tika - * field, so normalized values from other handlers are not overwritten. - */ - static class XmpHandler implements DirectoryHandler { - - static { - // XMPCore's namespace registry is process-global and keeps the first - // prefix it sees for a URI. Pin canonical prefixes so keys stay stable - // (files use both Camera and GCamera for the Google photo namespace). - // https://developer.android.com/media/platform/motion-photo-format - try { - XMPMetaFactory.getSchemaRegistry() - .registerNamespace("http://ns.google.com/photos/1.0/camera/", "Camera"); - XMPMetaFactory.getSchemaRegistry() - .registerNamespace("http://ns.google.com/photos/1.0/container/", "Container"); - XMPMetaFactory.getSchemaRegistry() - .registerNamespace("http://ns.google.com/photos/1.0/container/item/", "Item"); - } catch (XMPException e) { - // Constant, valid URIs, so this cannot throw. A broken registration - // would make the keys non-deterministic (parse-order dependent), which - // MotionPhotoXmpTest catches in CI; rethrowing from a static initializer - // would break all image parsing, so it is swallowed. - } - } - - public boolean supports(Class directoryType) { - return XmpDirectory.class.isAssignableFrom(directoryType); - } - - public void handle(Directory directory, Metadata metadata) throws MetadataException { - Map properties = ((XmpDirectory) directory).getXmpProperties(); - if (properties == null) { - return; - } - for (Map.Entry property : properties.entrySet()) { - String name = property.getKey(); - String value = property.getValue(); - if (value != null && metadata.get(name) == null - && !MetadataFields.isMetadataField(name)) { - metadata.set(name, value); - } - } - } - } static class TiffPageNumberHandler implements DirectoryHandler { public boolean supports(Class directoryType) { @@ -651,19 +596,25 @@ public void handle(Directory directory, Metadata metadata) throws MetadataExcept metadata.add(TikaCoreProperties.SUBJECT, k); } } - if (directory.containsTag(IptcDirectory.TAG_HEADLINE)) { - metadata.set(TikaCoreProperties.TITLE, - directory.getString(IptcDirectory.TAG_HEADLINE)); - } else if (directory.containsTag(IptcDirectory.TAG_OBJECT_NAME)) { - metadata.set(TikaCoreProperties.TITLE, - directory.getString(IptcDirectory.TAG_OBJECT_NAME)); + // IPTC fallback: XMP is canonical, so only fill what XMP left unset (XMP wins). + if (metadata.get(TikaCoreProperties.TITLE) == null) { + if (directory.containsTag(IptcDirectory.TAG_HEADLINE)) { + metadata.set(TikaCoreProperties.TITLE, + directory.getString(IptcDirectory.TAG_HEADLINE)); + } else if (directory.containsTag(IptcDirectory.TAG_OBJECT_NAME)) { + metadata.set(TikaCoreProperties.TITLE, + directory.getString(IptcDirectory.TAG_OBJECT_NAME)); + } } if (directory.containsTag(IptcDirectory.TAG_BY_LINE)) { - metadata.set(TikaCoreProperties.CREATOR, - directory.getString(IptcDirectory.TAG_BY_LINE)); + if (metadata.get(TikaCoreProperties.CREATOR) == null) { + metadata.set(TikaCoreProperties.CREATOR, + directory.getString(IptcDirectory.TAG_BY_LINE)); + } metadata.set(IPTC.CREATOR, directory.getString(IptcDirectory.TAG_BY_LINE)); } - if (directory.containsTag(IptcDirectory.TAG_CAPTION)) { + if (directory.containsTag(IptcDirectory.TAG_CAPTION) + && metadata.get(TikaCoreProperties.DESCRIPTION) == null) { metadata.set(TikaCoreProperties.DESCRIPTION, // Looks like metadata extractor returns IPTC newlines // as a single carriage return, diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java new file mode 100644 index 00000000000..af88c136592 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageXmp.java @@ -0,0 +1,151 @@ +/* + * 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.image; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import com.drew.imaging.jpeg.JpegProcessingException; +import com.drew.imaging.jpeg.JpegSegmentData; +import com.drew.imaging.jpeg.JpegSegmentReader; +import com.drew.imaging.jpeg.JpegSegmentType; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; +import org.xml.sax.SAXException; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentUtil; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.xmp.XMPPacketScanner; +import org.apache.tika.parser.xmp.XmpExtractor; + +/** + * Image XMP via the shared {@link XmpExtractor}. XMP is auxiliary: a bad packet is recorded and + * swallowed so it never fails the image parse (matches the PDF module). + */ +final class ImageXmp { + + private ImageXmp() { + } + + /** + * Scan an {@code }-wrapped packet out of a stream (TIFF/JXL/PSD/HEIF) and parse + * it. Returns true if a packet was found, so callers can fall back to another locator. + */ + static boolean scanAndExtract(InputStream stream, Metadata metadata, ParseContext context) { + try { + UnsynchronizedByteArrayOutputStream out = + UnsynchronizedByteArrayOutputStream.builder().get(); + if (new XMPPacketScanner().parse(stream, out)) { + try (InputStream packet = out.toInputStream()) { + new XmpExtractor().extract(packet, metadata, context); + } + return true; + } + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); + } + return false; + } + + /** Parse an already-isolated raw XMP packet (BPG). */ + static void extractRaw(byte[] xmp, Metadata metadata, ParseContext context) { + try { + new XmpExtractor().extract(xmp, metadata, context); + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); + } + } + + /** JPEG: read APP1 segments, reassemble Extended XMP, parse each resulting packet. */ + static void extractJpeg(File file, Metadata metadata, ParseContext context) { + try { + Iterable app1; + try { + JpegSegmentData data = JpegSegmentReader.readSegments(file, + Collections.singletonList(JpegSegmentType.APP1)); + app1 = data.getSegments(JpegSegmentType.APP1); + } catch (JpegProcessingException e) { + return; // not a decodable jpeg segment structure + } + if (app1 == null) { + return; + } + for (byte[] packet : ExtendedXmp.assemble(app1)) { + new XmpExtractor().extract(packet, metadata, context); + } + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); + } + } + + /** WebP: pull the raw packet out of the RIFF {@code "XMP "} chunk and parse it. */ + static void extractWebp(File file, Metadata metadata, ParseContext context) { + try { + byte[] xmp = readRiffChunk(file, "XMP "); + if (xmp != null) { + new XmpExtractor().extract(xmp, metadata, context); + } + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); + } + } + + // 64 MB: guards against a corrupt chunk size triggering a huge allocation. + private static final long MAX_CHUNK = 64L * 1024 * 1024; + + /** Return the payload of the first top-level RIFF chunk with the given FourCC, or null. */ + private static byte[] readRiffChunk(File file, String fourCC) throws IOException { + try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { + byte[] head = new byte[12]; + if (IOUtils.read(in, head, 0, 12) < 12 || head[0] != 'R' || head[1] != 'I' || + head[2] != 'F' || head[3] != 'F' || head[8] != 'W' || head[9] != 'E' || + head[10] != 'B' || head[11] != 'P') { + return null; + } + byte[] ch = new byte[8]; + while (IOUtils.read(in, ch, 0, 8) == 8) { + long size = (ch[4] & 0xffL) | (ch[5] & 0xffL) << 8 | + (ch[6] & 0xffL) << 16 | (ch[7] & 0xffL) << 24; + if (fourCC.equals(new String(ch, 0, 4, StandardCharsets.US_ASCII))) { + if (size > MAX_CHUNK) { + return null; // target chunk too large to allocate + } + byte[] data = new byte[(int) size]; + return IOUtils.read(in, data, 0, data.length) == data.length ? data : null; + } + // a large foreign chunk before "XMP " must be skipped, not abort the scan + IOUtils.skipFully(in, size + (size & 1L)); // RIFF pads chunks to even length + } + } + return null; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java index 1cb8d1d3c9a..e9d75088c97 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JXLParser.java @@ -30,7 +30,6 @@ import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; -import org.apache.tika.parser.xmp.JempboxExtractor; /** * Tries to scrape XMP out of JXL @@ -49,7 +48,6 @@ public Set getSupportedTypes(ParseContext context) { @Override public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata, ParseContext context) throws IOException, SAXException, TikaException { - JempboxExtractor jempboxExtractor = new JempboxExtractor(metadata); - jempboxExtractor.parse(tis); + ImageXmp.scanAndExtract(tis, metadata, context); } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java index 2f3cc714a5e..a96311857f5 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/JpegParser.java @@ -31,7 +31,6 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.xmp.JempboxExtractor; @TikaComponent public class JpegParser extends AbstractImageParser { @@ -55,8 +54,9 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata TemporaryResources tmp = new TemporaryResources(); try { TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata); + // XMP first so it is canonical; the metadata-extractor handlers (IPTC/EXIF) fill gaps. + ImageXmp.extractJpeg(tis.getFile(), metadata, parseContext); new ImageMetadataExtractor(metadata).parseJpeg(tis.getFile()); - new JempboxExtractor(metadata).parse(tis); } finally { tmp.dispose(); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java index 61527b77de2..3ca28df23fb 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/PSDParser.java @@ -44,7 +44,6 @@ import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; -import org.apache.tika.parser.xmp.JempboxExtractor; import org.apache.tika.sax.XHTMLContentHandler; /** @@ -159,8 +158,9 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata } else if (rb.id == ResourceBlock.ID_XMP) { //if there are multiple xmps in a file, this will //overwrite the data from the earlier xmp - JempboxExtractor ex = new JempboxExtractor(metadata); - ex.parse(UnsynchronizedByteArrayInputStream.builder().setByteArray(rb.data).get()); + ImageXmp.scanAndExtract( + UnsynchronizedByteArrayInputStream.builder().setByteArray(rb.data).get(), + metadata, context); } blocks++; } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java index 2ac2e629757..65664bff10f 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/TiffParser.java @@ -31,7 +31,6 @@ import org.apache.tika.metadata.Metadata; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.xmp.JempboxExtractor; @TikaComponent public class TiffParser extends AbstractImageParser { @@ -55,8 +54,10 @@ void extractMetadata(InputStream stream, ContentHandler contentHandler, Metadata TemporaryResources tmp = new TemporaryResources(); try { TikaInputStream tis = TikaInputStream.get(stream, tmp, metadata); + tis.getFile(); // spool so tis is fully re-readable below + // XMP first so it is canonical; metadata-extractor (IPTC/EXIF) fills gaps. + ImageXmp.scanAndExtract(tis, metadata, parseContext); new ImageMetadataExtractor(metadata).parseTiff(tis.getFile()); - new JempboxExtractor(metadata).parse(tis); } finally { tmp.dispose(); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java index 64e9ed64136..1af3b5e5a34 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/WebPParser.java @@ -49,6 +49,8 @@ public Set getSupportedTypes(ParseContext context) { public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata, ParseContext context) throws IOException, SAXException, TikaException { + // XMP first (canonical), then EXIF/etc. from metadata-extractor as fallback. + ImageXmp.extractWebp(tis.getFile(), metadata, context); new ImageMetadataExtractor(metadata).parseWebP(tis.getFile()); XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java new file mode 100644 index 00000000000..7cdb7b6edae --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ExtendedXmpTest.java @@ -0,0 +1,76 @@ +/* + * 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.image; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class ExtendedXmpTest { + + private static final String STD = "http://ns.adobe.com/xap/1.0/\u0000"; + private static final String EXT = "http://ns.adobe.com/xmp/extension/\u0000"; + private static final String GUID = "0123456789ABCDEF0123456789ABCDEF"; + + private static byte[] concat(byte[]... parts) throws Exception { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + for (byte[] p : parts) { + b.write(p); + } + return b.toByteArray(); + } + + private static byte[] be(int v) { + return new byte[]{(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + /** Standard packet + two out-of-order extension chunks reassemble to the full extended packet. */ + @Test + public void testReassemblesExtended() throws Exception { + byte[] std = ("").getBytes(US_ASCII); + byte[] full = "EXTENDED-XMP-PAYLOAD-DATA-1234567890".getBytes(US_ASCII); + int split = 20; + byte[] c1 = Arrays.copyOfRange(full, 0, split); + byte[] c2 = Arrays.copyOfRange(full, split, full.length); + + byte[] stdSeg = concat(STD.getBytes(US_ASCII), std); + byte[] ext1 = concat(EXT.getBytes(US_ASCII), GUID.getBytes(US_ASCII), be(full.length), be(0), c1); + byte[] ext2 = + concat(EXT.getBytes(US_ASCII), GUID.getBytes(US_ASCII), be(full.length), be(split), c2); + + // feed the extension chunks out of order to prove offset-based placement + List packets = ExtendedXmp.assemble(Arrays.asList(stdSeg, ext2, ext1)); + assertEquals(2, packets.size()); + assertArrayEquals(std, packets.get(0)); + assertArrayEquals(full, packets.get(1)); + } + + /** No HasExtendedXMP GUID -> just the standard packet, no extended part. */ + @Test + public void testNoExtended() throws Exception { + byte[] std = "plain".getBytes(US_ASCII); + List packets = ExtendedXmp.assemble(Arrays.asList(concat(STD.getBytes(US_ASCII), std))); + assertEquals(1, packets.size()); + assertArrayEquals(std, packets.get(0)); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java new file mode 100644 index 00000000000..ba6d805b6ff --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/HeicXmpTest.java @@ -0,0 +1,183 @@ +/* + * 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.image; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.xml.sax.helpers.DefaultHandler; + +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; + +/** + * HEIF/HEIC XMP was always dropped — metadata-extractor never read it. Stored as an + * {@code application/rdf+xml} item but usually {@code }-wrapped, so the shared packet + * scanner catches it (same path as TIFF/PSD/JXL). Fixture is a hand-built HEIC, no corpus binary. + */ +public class HeicXmpTest { + + private static final String XMP = + "" + + "" + + "" + + "" + + "HEIC XMP Title" + + "" + + "Jane Photographer" + + ""; + + /** Same content but with NO xpacket wrapper -> the scanner misses it, the item locator wins. */ + private static final String BARE_XMP = + "" + + "" + + "" + + "Bare HEIC Title" + + "" + + "Jane Photographer" + + ""; + + private static byte[] u32(int v) { + return new byte[]{(byte) (v >>> 24), (byte) (v >>> 16), (byte) (v >>> 8), (byte) v}; + } + + private static byte[] u16(int v) { + return new byte[]{(byte) (v >>> 8), (byte) v}; + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + for (byte[] p : parts) { + b.writeBytes(p); + } + return b.toByteArray(); + } + + private static byte[] box(String type, byte[] payload) { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + b.writeBytes(u32(8 + payload.length)); + b.writeBytes(type.getBytes(US_ASCII)); + b.writeBytes(payload); + return b.toByteArray(); + } + + private static byte[] fullBox(String type, byte[] payload) { + return fullBox(type, 0, payload); + } + + private static byte[] fullBox(String type, int version, byte[] payload) { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + b.write(version); + b.writeBytes(new byte[3]); // flags + b.writeBytes(payload); + return box(type, b.toByteArray()); + } + + /** ftyp(heic) + minimal meta(hdlr=pict) + mdat holding the XMP packet. */ + private static byte[] heicWithXmp() { + ByteArrayOutputStream ftypPayload = new ByteArrayOutputStream(); + ftypPayload.writeBytes("heic".getBytes(US_ASCII)); // major brand + ftypPayload.writeBytes(u32(0)); // minor version + ftypPayload.writeBytes("mif1".getBytes(US_ASCII)); // compatible brands + ftypPayload.writeBytes("heic".getBytes(US_ASCII)); + + ByteArrayOutputStream hdlrPayload = new ByteArrayOutputStream(); + hdlrPayload.writeBytes(u32(0)); // pre_defined + hdlrPayload.writeBytes("pict".getBytes(US_ASCII)); // handler_type + hdlrPayload.writeBytes(new byte[13]); // reserved[3] + empty name + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.writeBytes(box("ftyp", ftypPayload.toByteArray())); + out.writeBytes(fullBox("meta", fullBox("hdlr", hdlrPayload.toByteArray()))); + out.writeBytes(box("mdat", XMP.getBytes(UTF_8))); + return out.toByteArray(); + } + + /** + * ftyp(heic) + meta(hdlr, iinf declaring a mime/application/rdf+xml item, iloc pointing at + * mdat) + mdat holding a bare (non-xpacket) XMP packet. Exercises the iinf/iloc item locator. + */ + private static byte[] bareItemHeic() { + byte[] xmp = BARE_XMP.getBytes(UTF_8); + byte[] ftyp = box("ftyp", concat("heic".getBytes(US_ASCII), u32(0), + "mif1".getBytes(US_ASCII), "heic".getBytes(US_ASCII))); + byte[] hdlr = fullBox("hdlr", concat(u32(0), "pict".getBytes(US_ASCII), new byte[13])); + // infe v2: item_ID=1, protection=0, type=mime, empty name, content_type + null terminator + byte[] infe = fullBox("infe", 2, concat(u16(1), u16(0), "mime".getBytes(US_ASCII), + new byte[]{0}, "application/rdf+xml".getBytes(US_ASCII), new byte[]{0})); + byte[] iinf = fullBox("iinf", concat(u16(1), infe)); + + // The extent offset is a fixed-width field, so meta's size does not depend on its value: + // build once with a placeholder to learn meta's length, then again with the real offset. + int metaLen = fullBox("meta", concat(hdlr, iinf, iloc(0, xmp.length))).length; + int xmpOffset = ftyp.length + metaLen + 8; // + mdat header + byte[] meta = fullBox("meta", concat(hdlr, iinf, iloc(xmpOffset, xmp.length))); + return concat(ftyp, meta, box("mdat", xmp)); + } + + /** iloc v0: offset/length size 4, no base offset; one item, one extent. */ + private static byte[] iloc(int offset, int length) { + return fullBox("iloc", concat( + new byte[]{0x44, 0x00}, // offsetSize=4, lengthSize=4; baseOffsetSize=0 + u16(1), // item_count + u16(1), u16(0), u16(1), // item_ID, data_reference_index, extent_count + u32(offset), u32(length))); + } + + @Test + public void testHeicXmpIsExtracted(@TempDir Path tmp) throws Exception { + Path file = tmp.resolve("xmp.heic"); + Files.write(file, heicWithXmp()); + + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/heic"); + try (TikaInputStream tis = TikaInputStream.get(file)) { + new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + + assertEquals("HEIC XMP Title", metadata.get(TikaCoreProperties.TITLE)); + assertEquals("HEIC XMP Title", metadata.get("dc:title")); + assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR)); + assertEquals("Jane Photographer", metadata.get("dc:creator")); + } + + /** XMP with no xpacket wrapper is found by locating the rdf+xml item via meta/iinf/iloc. */ + @Test + public void testBareRdfItemXmpIsExtracted(@TempDir Path tmp) throws Exception { + Path file = tmp.resolve("bare.heic"); + Files.write(file, bareItemHeic()); + + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/heic"); + try (TikaInputStream tis = TikaInputStream.get(file)) { + new HeifParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + + assertEquals("Bare HEIC Title", metadata.get(TikaCoreProperties.TITLE)); + assertEquals("Bare HEIC Title", metadata.get("dc:title")); + assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR)); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java index 1b92fbb00d0..ece85fffe59 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/JpegParserTest.java @@ -203,8 +203,9 @@ public void testJPEGTitleAndDescription() throws Exception { assertEquals("2.8", metadata.get(Metadata.F_NUMBER)); assertEquals("4.6", metadata.get(Metadata.FOCAL_LENGTH)); assertEquals("114", metadata.get(Metadata.ISO_SPEED_RATINGS)); - assertEquals(null, metadata.get(Metadata.EQUIPMENT_MAKE)); - assertEquals(null, metadata.get(Metadata.EQUIPMENT_MODEL)); + // Make/Model now come from the promoted XMP tiff: schema; this file's binary EXIF has none. + assertEquals("Nokia", metadata.get(Metadata.EQUIPMENT_MAKE)); + assertEquals("N78", metadata.get(Metadata.EQUIPMENT_MODEL)); assertEquals(null, metadata.get(Metadata.SOFTWARE)); assertEquals("1", metadata.get(Metadata.ORIENTATION)); // Not present assertEquals("300.0", metadata.get(Metadata.RESOLUTION_HORIZONTAL)); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java index dd98659c148..13ab1e86dde 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/MotionPhotoXmpTest.java @@ -24,6 +24,7 @@ import org.apache.tika.TikaTest; import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Google; import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.ParseContext; @@ -47,10 +48,14 @@ public void testMotionPhotoXmpIsExposed() throws Exception { assertEquals("1", metadata.get("Camera:MotionPhoto")); assertEquals("1", metadata.get("Camera:MotionPhotoVersion")); assertEquals("500000", metadata.get("Camera:MotionPhotoPresentationTimestampUs")); + // also reachable through the declared, typed property + assertEquals("1", metadata.get(Google.MOTION_PHOTO)); // The embedded video item (its byte length lets a client range-fetch the // video without downloading the whole file) is exposed too. - assertEquals("MotionPhoto", metadata.get("Container:Directory[2]/Item:Semantic")); - assertEquals("122562", metadata.get("Container:Directory[2]/Item:Length")); + assertEquals("MotionPhoto", + metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic")); + assertEquals("122562", + metadata.get("xmp-raw:Container:Directory[2]/Container:Item/Item:Length")); } /** Keys use the canonical prefix even when the file declares another (GCamera). */ diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java new file mode 100644 index 00000000000..30b03a3c1b5 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/WebPXmpTest.java @@ -0,0 +1,122 @@ +/* + * 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.image; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.xml.sax.helpers.DefaultHandler; + +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; + +/** + * WebP stores XMP in a RIFF {@code "XMP "} chunk. Tika parses it with the shared + * {@link org.apache.tika.parser.xmp.XmpExtractor}; regression guard for TIKA where the + * WebP path dropped XMP entirely (no in-repo WebP carries an XMP packet). + */ +public class WebPXmpTest { + + private static final String XMP = + "" + + "" + + "" + + "" + + "WebP XMP Title" + + "" + + "Jane Photographer" + + ""; + + private static void chunk(ByteArrayOutputStream riff, String fourCC, byte[] data) { + riff.writeBytes(fourCC.getBytes(US_ASCII)); + riff.writeBytes(le(data.length)); + riff.writeBytes(data); + if ((data.length & 1) == 1) { + riff.write(0); // RIFF pads chunks to even length + } + } + + private static byte[] le(int v) { + return new byte[]{(byte) v, (byte) (v >>> 8), (byte) (v >>> 16), (byte) (v >>> 24)}; + } + + private static byte[] webpWithXmp() { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.writeBytes("WEBP".getBytes(US_ASCII)); + // VP8X extended-format header with the XMP flag (0x04) set + chunk(body, "VP8X", new byte[]{0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + chunk(body, "VP8 ", new byte[16]); // dummy bitstream; content is irrelevant to metadata + chunk(body, "XMP ", XMP.getBytes(UTF_8)); + ByteArrayOutputStream riff = new ByteArrayOutputStream(); + riff.writeBytes("RIFF".getBytes(US_ASCII)); + riff.writeBytes(le(body.size())); + riff.writeBytes(body.toByteArray()); + return riff.toByteArray(); + } + + @Test + public void testWebpXmpIsExtracted(@TempDir Path tmp) throws Exception { + Path file = tmp.resolve("xmp.webp"); + Files.write(file, webpWithXmp()); + + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/webp"); + try (TikaInputStream tis = TikaInputStream.get(file)) { + new WebPParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + + assertEquals("WebP XMP Title", metadata.get(TikaCoreProperties.TITLE)); + assertEquals("WebP XMP Title", metadata.get("dc:title")); + assertEquals("Jane Photographer", metadata.get(TikaCoreProperties.CREATOR)); + assertEquals("Jane Photographer", metadata.get("dc:creator")); + } + + /** A malformed XMP packet is recorded, not thrown: the rest of the parse still succeeds. */ + @Test + public void testMalformedXmpIsNonFatal(@TempDir Path tmp) throws Exception { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write("WEBP".getBytes(US_ASCII)); + chunk(body, "VP8X", new byte[]{0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + chunk(body, "VP8 ", new byte[16]); + chunk(body, "XMP ", "unclosed".getBytes(UTF_8)); + ByteArrayOutputStream riff = new ByteArrayOutputStream(); + riff.write("RIFF".getBytes(US_ASCII)); + riff.writeBytes(le(body.size())); + riff.writeBytes(body.toByteArray()); + Path file = tmp.resolve("bad.webp"); + Files.write(file, riff.toByteArray()); + + Metadata metadata = new Metadata(); + metadata.set(Metadata.CONTENT_TYPE, "image/webp"); + try (TikaInputStream tis = TikaInputStream.get(file)) { + new WebPParser().parse(tis, new DefaultHandler(), metadata, new ParseContext()); + } + assertNull(metadata.get(TikaCoreProperties.TITLE)); + assertNotNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java index 10261e90548..168471672c0 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/indesign/IDMLParser.java @@ -33,13 +33,14 @@ import org.apache.tika.annotation.TikaComponent; import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentUtil; import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Office; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; -import org.apache.tika.parser.xmp.XMPMetadataExtractor; +import org.apache.tika.parser.xmp.XmpExtractor; import org.apache.tika.sax.EndDocumentShieldingContentHandler; import org.apache.tika.sax.XHTMLContentHandler; @@ -166,7 +167,13 @@ private void handleZipEntry(ZipEntry entry, InputStream zip, Metadata metadata, String type = IOUtils.toString(zip, UTF_8); metadata.set(Metadata.CONTENT_TYPE, type); } else if (entry.getName().equals("META-INF/metadata.xml")) { - XMPMetadataExtractor.parse(zip, metadata); + try { + new XmpExtractor().extract(zip, metadata, context); + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); // malformed XMP must not fail the IDML + } } else if (entry.getName().contains("MasterSpreads")) { Metadata embeddedMeta = Metadata.newInstance(context); ContentAndMetadataExtractor.extract(zip, handler, embeddedMeta, context); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml index 18918f43e04..b4e27e101ff 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/pom.xml @@ -52,11 +52,6 @@ - - org.apache.pdfbox - jempbox - ${jempbox.version} - diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java index 1d2b5adf061..a2ccb629e62 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java @@ -81,7 +81,6 @@ import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate; import org.apache.tika.parser.pdf.updates.StartXRefOffset; import org.apache.tika.parser.pdf.updates.StartXRefScanner; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaIllustrator; import org.apache.tika.renderer.PageRangeRequest; import org.apache.tika.renderer.RenderResult; import org.apache.tika.renderer.RenderResults; @@ -374,7 +373,7 @@ private void checkIllustrator(final PDDocument pdfDocument, Metadata metadata) { if (privateDict == null) { return; } - metadata.set(Metadata.CONTENT_TYPE, XMPSchemaIllustrator.ILLUSTRATOR); + metadata.set(Metadata.CONTENT_TYPE, MediaType.application("illustrator").toString()); //TODO -- consider parsing the metadata //COSStream aiMetaData = privateDict.getCOSStream(COSName.AI_META_DATA); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java index ffe54748999..b49ccde4bcf 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDMetadataExtractor.java @@ -19,344 +19,73 @@ import java.io.IOException; import java.io.InputStream; import java.util.Calendar; -import java.util.List; import java.util.Locale; import org.apache.commons.io.IOUtils; -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.apache.jempbox.xmp.XMPSchemaBasic; -import org.apache.jempbox.xmp.XMPSchemaDublinCore; -import org.apache.jempbox.xmp.XMPSchemaPDF; -import org.apache.jempbox.xmp.pdfa.XMPSchemaPDFAId; import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.common.PDMetadata; -import org.w3c.dom.Document; import org.xml.sax.SAXException; import org.apache.tika.exception.TikaException; import org.apache.tika.extractor.EmbeddedDocumentUtil; -import org.apache.tika.metadata.DublinCore; import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.Office; import org.apache.tika.metadata.PDF; import org.apache.tika.metadata.Property; -import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.metadata.XMP; -import org.apache.tika.metadata.XMPDC; -import org.apache.tika.metadata.XMPPDF; import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaIllustrator; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFUA; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFVT; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFX; -import org.apache.tika.parser.pdf.xmpschemas.XMPSchemaPDFXId; -import org.apache.tika.parser.xmp.JempboxExtractor; -import org.apache.tika.utils.ExceptionUtils; +import org.apache.tika.parser.xmp.XmpExtractor; import org.apache.tika.utils.StringUtils; -import org.apache.tika.utils.XMLReaderUtils; public class PDMetadataExtractor { + /** Parse a PDF's XMP packet via the shared {@link XmpExtractor}. */ public static void extract(PDMetadata pdMetadata, Metadata metadata, ParseContext context) { if (pdMetadata == null) { metadata.set(PDF.HAS_XMP, "false"); return; } - //this file has XMP... - //whether or not it is readable or throws an exception is another story... metadata.set(PDF.HAS_XMP, "true"); - //now go for the XMP - Document dom = loadDOM(pdMetadata, metadata, context); - if (dom == null) { - return; - } - XMPMetadata xmp = new XMPMetadata(dom); - extract(xmp, metadata, context); - } - - public static void extract(XMPMetadata xmp, Metadata metadata, ParseContext context) { - extractBasic(xmp, metadata); - extractPDF(xmp, metadata); - extractDublinCore(xmp, metadata); - JempboxExtractor.extractXMPMM(xmp, metadata); - extractPDFA(xmp, metadata); - extractPDFX(xmp, metadata); - extractPDFVT(xmp, metadata); - extractPDFUA(xmp, metadata); - extractIllustrator(xmp, metadata); - } - - private static void extractIllustrator(XMPMetadata xmp, Metadata metadata) { - xmp.addXMLNSMapping(XMPSchemaIllustrator.NAMESPACE_URI, XMPSchemaIllustrator.class); - XMPSchemaIllustrator schema = null; - try { - schema = (XMPSchemaIllustrator) xmp.getSchemaByClass(XMPSchemaIllustrator.class); - } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } - - if (schema == null) { - return; - } - String type = schema.getType(); - if (! StringUtils.isBlank(type)) { - metadata.set(PDF.ILLUSTRATOR_TYPE, type); - } - } - - private static void extractDublinCore(XMPMetadata xmp, Metadata metadata) { - XMPSchemaDublinCore dcSchema = null; - try { - dcSchema = xmp.getDublinCoreSchema(); - } catch (IOException e) { - //swallow - } - if (dcSchema == null) { - return; - } - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.CONTRIBUTOR.getName(), TikaCoreProperties.CONTRIBUTOR, XMPDC.CONTRIBUTOR); - extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.COVERAGE.getName(), TikaCoreProperties.COVERAGE, XMPDC.COVERAGE); - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.CREATOR.getName(), TikaCoreProperties.CREATOR, XMPDC.CREATOR); - - extractDublinCoreListItems(metadata, dcSchema, XMPDC.DATE.getName(), XMPDC.DATE); - extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.DESCRIPTION.getName(), TikaCoreProperties.DESCRIPTION, XMPDC.DESCRIPTION); - extractDublinCoreListItems(metadata, dcSchema, XMPDC.FORMAT.getName(), XMPDC.FORMAT); - extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.IDENTIFIER.getName(), TikaCoreProperties.IDENTIFIER, XMPDC.IDENTIFIER); - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.LANGUAGE.getName(), TikaCoreProperties.LANGUAGE, XMPDC.LANGUAGE); - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.PUBLISHER.getName(), TikaCoreProperties.PUBLISHER, XMPDC.PUBLISHER); - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.RELATION.getName(), TikaCoreProperties.RELATION, XMPDC.RELATION); - extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.RIGHTS.getName(), TikaCoreProperties.RIGHTS, XMPDC.RIGHTS); - extractDublinCoreSimpleItem(metadata, dcSchema, TikaCoreProperties.SOURCE.getName(), TikaCoreProperties.SOURCE, XMPDC.SOURCE); - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.SUBJECT.getName(), TikaCoreProperties.SUBJECT, XMPDC.SUBJECT); - extractMultilingualItems(metadata, dcSchema, TikaCoreProperties.TITLE.getName(), TikaCoreProperties.TITLE, XMPDC.TITLE); - // finds only the first one?! - extractDublinCoreListItems(metadata, dcSchema, TikaCoreProperties.TYPE.getName(), TikaCoreProperties.TYPE, XMPDC.TYPE); - - - } - - private static void extractPDFVT(XMPMetadata xmp, Metadata metadata) { - xmp.addXMLNSMapping(XMPSchemaPDFVT.NAMESPACE_URI, XMPSchemaPDFVT.class); - XMPSchemaPDFVT schema = null; + InputStream is; try { - schema = (XMPSchemaPDFVT) xmp.getSchemaByClass(XMPSchemaPDFVT.class); + is = pdMetadata.exportXMPMetadata(); } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } - - if (schema == null) { + EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata); return; } - String version = schema.getPDFVTVersion(); - if (! StringUtils.isBlank(version)) { - metadata.set(PDF.PDFVT_VERSION, version); - } - try { - Calendar modified = schema.getPDFVTModified(); - metadata.set(PDF.PDFVT_MODIFIED, modified); - } catch (IOException ex) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - "bad date in vt modified"); - } - } - - private static void extractPDFX(XMPMetadata xmp, Metadata metadata) { - xmp.addXMLNSMapping(XMPSchemaPDFXId.NAMESPACE_URI, XMPSchemaPDFXId.class); - xmp.addXMLNSMapping(XMPSchemaPDFX.NAMESPACE_URI, XMPSchemaPDFX.class); - try { - XMPSchemaPDFXId - XMPSchemaPDFXId = (XMPSchemaPDFXId) xmp.getSchemaByClass(XMPSchemaPDFXId.class); - if (XMPSchemaPDFXId != null) { - String version = XMPSchemaPDFXId.getPDFXVersion(); - if (!StringUtils.isBlank(version)) { - metadata.set(PDF.PDFXID_VERSION, version); - } - } - } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } try { - XMPSchemaPDFX XMPSchemaPDFX = (XMPSchemaPDFX) xmp.getSchemaByClass(XMPSchemaPDFX.class); - if (XMPSchemaPDFX != null) { - String version = XMPSchemaPDFX.getPDFXVersion(); - if (!StringUtils.isBlank(version)) { - metadata.set(PDF.PDFX_VERSION, version); - } - String conformance = XMPSchemaPDFX.getPDFXConformance(); - if (!StringUtils.isBlank(conformance)) { - metadata.set(PDF.PDFX_CONFORMANCE, conformance); - } - } - } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } - - } - - - private static void extractPDFUA(XMPMetadata xmp, Metadata metadata) { - xmp.addXMLNSMapping(XMPSchemaPDFUA.NAMESPACE_URI, XMPSchemaPDFUA.class); - XMPSchemaPDFUA schema = null; - try { - schema = (XMPSchemaPDFUA) xmp.getSchemaByClass(XMPSchemaPDFUA.class); - } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } - - if (schema == null) { - return; - } - try { - Integer part = schema.getPart(); - if (schema.getPart() != null) { - metadata.set(PDF.PDFUAID_PART, part.intValue()); - } - } catch (NumberFormatException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - "expected integer " + "part"); - } - - } - - private static void extractPDFA(XMPMetadata xmp, Metadata metadata) { - xmp.addXMLNSMapping(XMPSchemaPDFAId.NAMESPACE, XMPSchemaPDFAId.class); - XMPSchemaPDFAId schema = null; - try { - schema = (XMPSchemaPDFAId) xmp.getSchemaByClass(XMPSchemaPDFAId.class); - } catch (IOException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - ExceptionUtils.getStackTrace(e)); - } - - if (schema == null) { - return; - } - String partString = "UNKNOWN"; - try { - Integer part = schema.getPart(); - if (part != null) { - partString = Integer.toString(part); - metadata.set(PDF.PDFAID_PART, part.intValue()); - } - } catch (NumberFormatException e) { - metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, - "expected integer " + "part"); - } - if (schema.getConformance() != null) { - metadata.set(PDF.PDFAID_CONFORMANCE, schema.getConformance()); - String version = "A-" + partString + schema.getConformance().toLowerCase(Locale.ROOT); - metadata.set(PDF.PDFA_VERSION, version); - } - } - - private static void extractPDF(XMPMetadata xmp, Metadata metadata) { - if (xmp == null) { - return; - } - - XMPSchemaPDF pdf = null; - try { - pdf = xmp.getPDFSchema(); - } catch (IOException e) { - return; - } - if (pdf == null) { - return; + extract(is, metadata, context); + } finally { + IOUtils.closeQuietly(is); } - setNotNull(pdf.getProducer(), metadata, PDF.PRODUCER, XMPPDF.PRODUCER); - setNotNull(pdf.getKeywords(), metadata, Office.KEYWORDS, XMPPDF.KEY_WORDS); - setNotNull(pdf.getPDFVersion(), metadata, PDF.PDF_VERSION, XMPPDF.PDF_VERSION); } - private static void extractBasic(XMPMetadata xmp, Metadata metadata) { - if (xmp == null) { - return; - } - - XMPSchemaBasic basic = null; - try { - basic = xmp.getBasicSchema(); - } catch (IOException e) { - return; - } - if (basic == null) { - return; - } - //add the elements from the basic schema - setNotNull(basic.getCreatorTool(), metadata, XMP.CREATOR_TOOL); - setNotNull(basic.getTitle(), metadata, DublinCore.TITLE, XMP.TITLE); - setNotNull(basic.getAbout(), metadata, XMP.ABOUT); - setNotNull(basic.getLabel(), metadata, XMP.LABEL); + /** Shared XmpExtractor + PDF-specific fixups (octal-BOM decode, derived PDF/A version). */ + public static void extract(InputStream xmp, Metadata metadata, ParseContext context) { try { - setNotNull(XMP.CREATE_DATE, basic.getCreateDate(), metadata); - } catch (IOException e) { - //swallow - } - try { - setNotNull(XMP.MODIFY_DATE, basic.getModifyDate(), metadata); - } catch (IOException e) { - //swallow - } - try { - setNotNull(XMP.METADATA_DATE, basic.getMetadataDate(), metadata); - } catch (IOException e) { - //swallow - } - - List identifiers = basic.getIdentifiers(); - if (identifiers != null) { - for (String identifier : identifiers) { - metadata.add(XMP.IDENTIFIER, identifier); - } + // PDF octal-BOM decode is the one XMP value fixup that is container-specific. + new XmpExtractor(PDMetadataExtractor::decode).extract(xmp, metadata, context); + } catch (SecurityException e) { + throw e; + } catch (IOException | SAXException | TikaException | RuntimeException e) { + EmbeddedDocumentUtil.recordException(e, metadata); // malformed XMP must not fail the PDF } - List advisories = basic.getAdvisories(); - if (advisories != null) { - for (String advisory : advisories) { - metadata.add(XMP.ADVISORY, advisory); - } - } - setNotNull(basic.getNickname(), metadata, XMP.NICKNAME); - try { - setNotNull(XMP.RATING, basic.getRating(), metadata); - } catch (NumberFormatException e) { - //swallow TIKA-4401 - } - //TODO: find an example where basic.getThumbNail is not null - //and figure out how to add that info + derivePDFAVersion(metadata); } - private static void setNotNull(String value, Metadata metadata, Property ... properties) { - if (value == null || value.isBlank()) { + /** PDF/A version is derived from the pdfaid part + conformance, e.g. {@code A-1b}. */ + private static void derivePDFAVersion(Metadata metadata) { + String conformance = metadata.get(PDF.PDFAID_CONFORMANCE); + if (conformance == null) { return; } - String decoded = decode(value); - for (Property property : properties) { - metadata.set(property, decoded); - } + String part = metadata.get(PDF.PDFAID_PART); + String partString = (part == null) ? "UNKNOWN" : part; + metadata.set(PDF.PDFA_VERSION, "A-" + partString + conformance.toLowerCase(Locale.ROOT)); } - private static void setNotNull(Property property, Calendar value, Metadata metadata) { - if (metadata.get(property) == null && value != null) { - metadata.set(property, value); - } - } - - private static void setNotNull(Property property, Integer value, Metadata metadata) { - if (metadata.get(property) == null && value != null) { - metadata.set(property, value); - } - } - - static void addNotNull(String value, Metadata metadata, Property ... properties) { + static void addNotNull(String value, Metadata metadata, Property... properties) { if (StringUtils.isBlank(value)) { return; } @@ -365,104 +94,9 @@ static void addNotNull(String value, Metadata metadata, Property ... properties) } } - /** - * As of this writing, XMPSchema can contain bags or sequence lists - * for some attributes...despite standards documentation. - * JempBox expects one or the other for specific attributes. - * Until more flexibility is added to JempBox, Tika will have to handle both. - * - * @param schema - * @param name - * @return list of values or null - */ - static List getXMPBagOrSeqList(XMPSchema schema, String name) { - List ret = schema.getBagList(name); - if (ret == null) { - ret = schema.getSequenceList(name); - } - return ret; - } - - /** - * Try to extract all multilingual items from the XMPSchema - *

- * This relies on the property having a valid xmp getName() - *

- * For now, this only extracts the first language if the property does not allow multiple - * values (see TIKA-1295) - * - * @param metadata - * @param schema schema - must be non-null - * @param dcName dublin core name for the property to select from the xmp schema - * @param properties property names to set to this value - */ - private static void extractMultilingualItems(Metadata metadata, XMPSchema schema, String dcName, Property ... properties) { - - for (Property property : properties) { - for (String lang : schema.getLanguagePropertyLanguages(dcName)) { - String value = schema.getLanguageProperty(dcName, lang); - if (value != null && ! value.isBlank()) { - addMetadata(metadata, property, value); - addMetadata(metadata, property.getName() + ":" + lang, value); - } - } - } - } - - - /** - * This tries to read a list from a particular property in - * XMPSchemaDublinCore. - *

- * Until PDFBOX-1803/TIKA-1233 are fixed, do not call this - * on dates! - *

- * This relies on the property having a DublinCore compliant getName() - * - * @param metadata - * @param dc schema - must be non-null - * @param dcName -- name of the dc property to read from the dc schema - * @param properties -- property to set for this value in the metadata object - */ - private static void extractDublinCoreListItems(Metadata metadata, - XMPSchemaDublinCore dc, String dcName, Property ... properties) { - - List items = getXMPBagOrSeqList(dc, dcName); - if (items == null) { - return; - } - for (Property property : properties) { - for (String item : items) { - addMetadata(metadata, property, item); - } - } - } - - /** - * This tries to read a string from a particular property in XMPSchemaDublinCore. - *

- * This relies on the property having a DublinCore compliant getName() - * - * @param metadata - * @param dc schema - must be non-null - * @param dcName -- name of the dc property to read from the dc schema - * @param properties -- property to set for this value in the metadata object - */ - private static void extractDublinCoreSimpleItem(Metadata metadata, - XMPSchemaDublinCore dc, String dcName, Property ... properties) { - - String textProperty = dc.getTextProperty(dcName); - for (Property property : properties) { - addMetadata(metadata, property, textProperty); - } - } - /** * Add non-null, non-empty and unique values to the Metadata object. If the property * does not allow multiple values, silently fail to add values after the first. - * @param metadata - * @param property - * @param value */ static void addMetadata(Metadata metadata, Property property, String value) { if (value == null || value.isBlank()) { @@ -506,31 +140,6 @@ static String decode(String value) { return value; } - //can return null! - private static Document loadDOM(PDMetadata pdMetadata, Metadata metadata, - ParseContext context) { - if (pdMetadata == null) { - return null; - } - - InputStream is = null; - try { - try { - is = pdMetadata.exportXMPMetadata(); - } catch (IOException e) { - EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata); - return null; - } - return XMLReaderUtils.buildDOM(is, context); - } catch (IOException | SAXException | TikaException e) { - EmbeddedDocumentUtil.recordException(e, metadata); - } finally { - IOUtils.closeQuietly(is); - } - return null; - - } - static void addMetadata(Metadata metadata, Property property, Calendar value) { if (value != null) { metadata.set(property, value); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java deleted file mode 100644 index 9b823fe55a4..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaIllustrator.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.pdf.xmpschemas; - -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.w3c.dom.Element; - -import org.apache.tika.mime.MediaType; - -public class XMPSchemaIllustrator extends XMPSchema { - public static final String NAMESPACE_URI = "http://ns.adobe.com/illustrator/1.0/"; - public static final String NAMESPACE = "illustrator"; - - public static final String ILLUSTRATOR = MediaType.application("illustrator").toString(); - - public XMPSchemaIllustrator(XMPMetadata parent) { - super(parent, NAMESPACE, NAMESPACE_URI); - } - - public XMPSchemaIllustrator(Element element, String prefix) { - super(element, prefix); - } - - public String getType() { - return this.getTextProperty(this.prefix + ":Type"); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java deleted file mode 100644 index f788daf46a4..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFUA.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.pdf.xmpschemas; - -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.w3c.dom.Element; - -public class XMPSchemaPDFUA extends XMPSchema { - public static final String NAMESPACE_URI = "http://www.aiim.org/pdfua/ns/id/"; - public static final String NAMESPACE = "pdfuaid"; - - public XMPSchemaPDFUA(XMPMetadata parent) { - super(parent, NAMESPACE, NAMESPACE_URI); - } - - public XMPSchemaPDFUA(Element element, String prefix) { - super(element, prefix); - } - - public Integer getPart() { - return this.getIntegerProperty(this.prefix + ":part"); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java deleted file mode 100644 index 22b54a9dcaa..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFVT.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.pdf.xmpschemas; - -import java.io.IOException; -import java.util.Calendar; - -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.w3c.dom.Element; - -public class XMPSchemaPDFVT extends XMPSchema { - public static final String NAMESPACE_URI = "http://www.npes.org/pdfvt/ns/id/"; - public static final String NAMESPACE = "pdfvtid"; - - private Calendar vTModified; - public XMPSchemaPDFVT(XMPMetadata parent) { - super(parent, NAMESPACE, NAMESPACE_URI); - } - - public XMPSchemaPDFVT(Element element, String prefix) { - super(element, prefix); - } - - public String getPDFVTVersion() { - return this.getTextProperty(this.prefix + ":GTS_PDFVTVersion"); - } - - public Calendar getPDFVTModified() throws IOException { - return this.getDateProperty(this.prefix + ":GTS_PDFVTModDate"); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java deleted file mode 100644 index 612784159fb..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFX.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.pdf.xmpschemas; - -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.w3c.dom.Element; - -/** - * This is somewhat of a hack to handle the older pdfx: - * See also the more modern {@link XMPSchemaPDFXId} - */ -public class XMPSchemaPDFX extends XMPSchema { - public static final String NAMESPACE_URI = "http://ns.adobe.com/pdfx/1.3/"; - public static final String NAMESPACE = "pdfx"; - - public XMPSchemaPDFX(XMPMetadata parent) { - super(parent, NAMESPACE, NAMESPACE_URI); - } - - public XMPSchemaPDFX(Element element, String prefix) { - super(element, prefix); - } - - public String getPDFXVersion() { - return this.getTextProperty(this.prefix + ":GTS_PDFXVersion"); - } - - public String getPDFXConformance() { - return this.getTextProperty(this.prefix + ":GTS_PDFXConformance"); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java deleted file mode 100644 index 02e14ddf692..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/xmpschemas/XMPSchemaPDFXId.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.pdf.xmpschemas; - -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchema; -import org.w3c.dom.Element; - -public class XMPSchemaPDFXId extends XMPSchema { - public static final String NAMESPACE_URI = "http://www.npes.org/pdfx/ns/id/"; - public static final String NAMESPACE = "pdfxid"; - - public XMPSchemaPDFXId(XMPMetadata parent) { - super(parent, NAMESPACE, NAMESPACE_URI); - } - - public XMPSchemaPDFXId(Element element, String prefix) { - super(element, prefix); - } - - public String getPDFXVersion() { - return this.getTextProperty(this.prefix + ":GTS_PDFXVersion"); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java index 3008251dec1..ecd328446fe 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/CustomTikaXMPTest.java @@ -17,12 +17,13 @@ package org.apache.tika.parser.pdf; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.util.Arrays; +import java.util.List; -import org.apache.jempbox.xmp.XMPMetadata; import org.junit.jupiter.api.Test; -import org.w3c.dom.Document; import org.xml.sax.SAXException; import org.apache.tika.TikaTest; @@ -33,7 +34,6 @@ import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.metadata.XMP; import org.apache.tika.parser.ParseContext; -import org.apache.tika.utils.XMLReaderUtils; /** * This tests our custom schemas and PDF/A @@ -60,9 +60,11 @@ public void testPDFX() throws Exception { public void testPDFUA() throws Exception { Metadata metadata = extract("testPDFUA.xmp"); assertEquals(1, metadata.getInt(PDF.PDFUAID_PART)); - String[] subjects = metadata.getValues(TikaCoreProperties.SUBJECT); - assertEquals("keywords", subjects[0]); - assertEquals("subject", subjects[1]); + // keywords and subject both land in SUBJECT; presence is the contract, order is not. + List subjects = Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); + assertEquals(2, subjects.size()); + assertTrue(subjects.contains("keywords"), "keywords in SUBJECT"); + assertTrue(subjects.contains("subject"), "subject in SUBJECT"); assertEquals("1234567890", metadata.get(XMP.IDENTIFIER)); assertEquals("Advisory", metadata.get(XMP.ADVISORY)); } @@ -109,11 +111,8 @@ public void testDublinCore() throws Exception { private Metadata extract(String xmpFileName) throws IOException, TikaException, SAXException { try (TikaInputStream tis = getResourceAsStream("/test-documents/xmp/" + xmpFileName)) { - Document doc = XMLReaderUtils.buildDOM(tis); - XMPMetadata xmp = new XMPMetadata(doc); - ParseContext context = new ParseContext(); Metadata metadata = new Metadata(); - PDMetadataExtractor.extract(xmp, metadata, context); + PDMetadataExtractor.extract(tis, metadata, new ParseContext()); return metadata; } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java index 987d2c70836..26eab218dc7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java @@ -1570,10 +1570,14 @@ public void testMetadataKeyPrecision() throws Exception { assertEquals("xmp-xmpmm-documentid", m.get(XMPMM.DOCUMENTID)); assertEquals("13", m.get(PagedText.N_PAGES)); + // keywords/subject from XMP and doc-info merge into SUBJECT; presence is the contract, order is not. String[] expectedSubjectVals = new String[]{ "xmp-pdf-keywords", "xmp-dc-subject", "pdf-keywords", "pdf-subject" }; - assertArrayEquals(expectedSubjectVals, m.getValues(TikaCoreProperties.SUBJECT)); + String[] actualSubjectVals = m.getValues(TikaCoreProperties.SUBJECT); + assertEquals(expectedSubjectVals.length, actualSubjectVals.length); + assertEquals(new HashSet<>(Arrays.asList(expectedSubjectVals)), + new HashSet<>(Arrays.asList(actualSubjectVals))); } @Test diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml index 7ccc27297fe..ef174b07da7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/pom.xml @@ -31,14 +31,12 @@ + org.apache.pdfbox - jempbox - ${jempbox.version} - - - org.apache.pdfbox - xmpbox + pdfbox ${pdfbox.version} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java deleted file mode 100644 index e231a3190f5..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/JempboxExtractor.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * 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.xmp; - -import static java.nio.charset.StandardCharsets.UTF_8; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Calendar; -import java.util.List; -import java.util.StringJoiner; - -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.apache.jempbox.xmp.ResourceEvent; -import org.apache.jempbox.xmp.ResourceRef; -import org.apache.jempbox.xmp.XMPMetadata; -import org.apache.jempbox.xmp.XMPSchemaDublinCore; -import org.apache.jempbox.xmp.XMPSchemaMediaManagement; -import org.w3c.dom.Document; -import org.xml.sax.SAXException; - -import org.apache.tika.exception.TikaException; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.Property; -import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.metadata.XMPMM; -import org.apache.tika.parser.ParseContext; -import org.apache.tika.utils.DateUtils; -import org.apache.tika.utils.XMLReaderUtils; - -public class JempboxExtractor { - - //TODO: change signature to require parsecontext from parse - private static final ParseContext EMPTY_PARSE_CONTEXT = new ParseContext(); - // The XMP spec says it must be unicode, but for most file formats it specifies - // "must be encoded in UTF-8" - private static final String DEFAULT_XMP_CHARSET = UTF_8.name(); - private static volatile int MAX_EVENT_HISTORY_IN_XMPMM = 1024; - private XMPPacketScanner scanner = new XMPPacketScanner(); - private Metadata metadata; - - public JempboxExtractor(Metadata metadata) { - this.metadata = metadata; - } - - /** - * Tries to extract Dublin Core schema from XMP. If XMPMetadata is null - * or if the DC schema is null, this will return without throwing an exception. - * - * @param xmpMetadata XMPMetadata to process - * @param metadata Tika's metadata to write to - */ - public static void extractDublinCore(XMPMetadata xmpMetadata, Metadata metadata) { - if (xmpMetadata == null) { - return; - } - XMPSchemaDublinCore dc = null; - try { - dc = xmpMetadata.getDublinCoreSchema(); - } catch (IOException e) { - //swallow - } - if (dc == null) { - return; - } - if (dc.getTitle() != null) { - metadata.set(TikaCoreProperties.TITLE, dc.getTitle()); - } - if (dc.getDescription() != null) { - metadata.set(TikaCoreProperties.DESCRIPTION, dc.getDescription()); - } - if (dc.getCreators() != null && dc.getCreators().size() > 0) { - metadata.set(TikaCoreProperties.CREATOR, joinCreators(dc.getCreators())); - } - if (dc.getSubjects() != null && dc.getSubjects().size() > 0) { - for (String keyword : dc.getSubjects()) { - metadata.add(TikaCoreProperties.SUBJECT, keyword); - } - // TODO should we set SUBJECT too? - // All tested photo managers set the same in Iptc.Application2.Keywords - // and Xmp.dc.subject - } - } - - protected static String joinCreators(List creators) { - if (creators == null || creators.size() == 0) { - return ""; - } - if (creators.size() == 1) { - return creators.get(0); - } - StringJoiner stringJoiner = new StringJoiner(", "); - for (String s : creators) { - stringJoiner.add(s); - } - return stringJoiner.toString(); - } - - /** - * Extracts Media Management metadata from XMP. - *

- * Silently swallows exceptions. - * - * @param xmp - * @param metadata - */ - public static void extractXMPMM(XMPMetadata xmp, Metadata metadata) { - if (xmp == null) { - return; - } - XMPSchemaMediaManagement mmSchema = null; - try { - mmSchema = xmp.getMediaManagementSchema(); - } catch (IOException e) { - //swallow - return; - } - if (mmSchema != null) { - addMetadata(metadata, XMPMM.DOCUMENTID, mmSchema.getDocumentID()); - // not currently supported by JempBox... - // but might be in 1.8.18 if ever released, see PDFBOX-6116 - // until then use workaround (won't work if non standard prefix is used) - metadata.set(XMPMM.INSTANCEID, mmSchema.getTextProperty("xmpMM:InstanceID" )); - - ResourceRef derivedFrom = mmSchema.getDerivedFrom(); - if (derivedFrom != null) { - try { - addMetadata(metadata, XMPMM.DERIVED_FROM_DOCUMENTID, - derivedFrom.getDocumentID()); - } catch (NullPointerException e) { - //swallow - // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released - } - - try { - addMetadata(metadata, XMPMM.DERIVED_FROM_INSTANCEID, - derivedFrom.getInstanceID()); - } catch (NullPointerException e) { - //swallow - // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released - } - - //TODO: not yet supported by XMPBox...extract OriginalDocumentID - //in DerivedFrom section - } - if (mmSchema.getHistory() != null) { - int eventsAdded = 0; - for (ResourceEvent stevt : mmSchema.getHistory()) { - if (eventsAdded >= MAX_EVENT_HISTORY_IN_XMPMM) { - break; - } - String instanceId = null; - String action = null; - Calendar when = null; - String softwareAgent = null; - try { - instanceId = stevt.getInstanceID(); - action = stevt.getAction(); - when = stevt.getWhen(); - softwareAgent = stevt.getSoftwareAgent(); - - //instanceid can throw npe; getWhen can throw IOException - } catch (NullPointerException | IOException e) { - //swallow - // NPE fixed in PDFBOX-5984; NPE catch can be removed if Jempbox 1.8.18 is released - } - if (instanceId != null && !instanceId.isBlank()) { - //for absent data elements, pass in empty strings so - //that parallel arrays will have matching offsets - //for absent data - - action = (action == null) ? "" : action; - String dateString = (when == null) ? "" : DateUtils.formatDate(when); - softwareAgent = (softwareAgent == null) ? "" : softwareAgent; - - metadata.add(XMPMM.HISTORY_EVENT_INSTANCEID, instanceId); - metadata.add(XMPMM.HISTORY_ACTION, action); - metadata.add(XMPMM.HISTORY_WHEN, dateString); - metadata.add(XMPMM.HISTORY_SOFTWARE_AGENT, softwareAgent); - eventsAdded++; - } - } - } - } - } - - private static void addMetadata(Metadata m, Property p, String value) { - if (value != null) { - if (p.isMultiValuePermitted() || m.get(p) == null) { - m.add(p, value); - } - } - } - - /** - * @return maximum number of events to extract from the XMPMM history. - */ - public static int getMaxXMPMMHistory() { - return MAX_EVENT_HISTORY_IN_XMPMM; - } - - /** - * Maximum number of events to extract from the - * event history in the XMP Media Management (XMPMM) section. - * The extractor will silently stop adding events after it - * has reached this threshold. - *

- * The default is 1024. - */ - public static void setMaxXMPMMHistory(int maxEvents) { - MAX_EVENT_HISTORY_IN_XMPMM = maxEvents; - } - - public void parse(InputStream file) throws IOException, TikaException { - UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get(); - if (!scanner.parse(file, xmpraw)) { - return; - } - - XMPMetadata xmp = null; - try (InputStream decoded = xmpraw.toInputStream()) { - Document dom = XMLReaderUtils.buildDOM(decoded, EMPTY_PARSE_CONTEXT); - if (dom != null) { - xmp = new XMPMetadata(dom); - } - } catch (IOException | SAXException e) { - // - } - extractDublinCore(xmp, metadata); - extractXMPMM(xmp, metadata); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java deleted file mode 100644 index d9b9b38c34a..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPMetadataExtractor.java +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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.xmp; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Calendar; -import java.util.List; - -import org.apache.commons.io.input.CloseShieldInputStream; -import org.apache.xmpbox.XMPMetadata; -import org.apache.xmpbox.schema.DublinCoreSchema; -import org.apache.xmpbox.schema.XMPBasicSchema; -import org.apache.xmpbox.schema.XMPMediaManagementSchema; -import org.apache.xmpbox.type.AbstractField; -import org.apache.xmpbox.type.ArrayProperty; -import org.apache.xmpbox.type.BadFieldValueException; -import org.apache.xmpbox.type.ResourceEventType; -import org.apache.xmpbox.type.ResourceRefType; -import org.apache.xmpbox.xml.DomXmpParser; - -import org.apache.tika.exception.TikaException; -import org.apache.tika.metadata.DublinCore; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.Property; -import org.apache.tika.metadata.XMP; -import org.apache.tika.metadata.XMPMM; -import org.apache.tika.utils.DateUtils; - -/** - * XMP Metadata Extractor based on Apache XmpBox. - */ -public class XMPMetadataExtractor { - - private static volatile int MAX_EVENT_HISTORY_IN_XMPMM = 1024; - - /** - * Parse the XMP Packets. - * - * @param stream the stream to parser. - * @param metadata the metadata collection to update - * @throws IOException on any IO error. - * @throws TikaException on any Tika error. - */ - public static void parse(InputStream stream, Metadata metadata) throws IOException, TikaException { - XMPMetadata xmp; - try { - DomXmpParser xmpParser = new DomXmpParser(); - xmpParser.setStrictParsing(false); - xmp = xmpParser.parse(CloseShieldInputStream.wrap(stream)); - } catch (Throwable ex) { - //swallow - return; - } - extractDublinCoreSchema(xmp, metadata); - extractXMPBasicSchema(xmp, metadata); - extractXMPMM(xmp, metadata); - } - - /** - * Extracts Dublin Core. - * - * Silently swallows exceptions. - * @param xmp the XMP Metadata object. - * @param metadata the metadata map - * @throws IOException - */ - public static void extractDublinCoreSchema(XMPMetadata xmp, Metadata metadata) throws IOException { - if (xmp == null) { - return; - } - DublinCoreSchema schemaDublinCore = xmp.getDublinCoreSchema(); - if (schemaDublinCore != null) { - try { - addMetadata(metadata, DublinCore.TITLE, schemaDublinCore.getTitle()); - addMetadata(metadata, DublinCore.FORMAT, schemaDublinCore.getFormat()); - addMetadata(metadata, DublinCore.DESCRIPTION, schemaDublinCore.getDescription()); - addMetadata(metadata, DublinCore.CREATOR, schemaDublinCore.getCreators()); - addMetadata(metadata, DublinCore.SUBJECT, schemaDublinCore.getSubjects()); - } - catch (BadFieldValueException ex) { - throw new IOException(ex); - } - } - } - - /** - * Extracts basic schema metadata from XMP. - * - * Silently swallows exceptions. - * @param xmp the XMP Metadata object. - * @param metadata the metadata map - * @throws IOException - */ - public static void extractXMPBasicSchema(XMPMetadata xmp, Metadata metadata) throws IOException { - if (xmp == null) { - return; - } - XMPBasicSchema schemaBasic = xmp.getXMPBasicSchema(); - if (schemaBasic != null) { - addMetadata(metadata, XMP.CREATOR_TOOL, schemaBasic.getCreatorTool()); - addMetadata(metadata, XMP.CREATE_DATE, schemaBasic.getCreateDate()); - addMetadata(metadata, XMP.MODIFY_DATE, schemaBasic.getModifyDate()); - addMetadata(metadata, XMP.METADATA_DATE, schemaBasic.getModifyDate()); - addMetadata(metadata, XMP.RATING, schemaBasic.getRating()); - } - } - - /** - * @return maximum number of events to extract from the XMPMM history. - */ - public static int getMaxXMPMMHistory() { - return MAX_EVENT_HISTORY_IN_XMPMM; - } - - /** - * Maximum number of events to extract from the - * event history in the XMP Media Management (XMPMM) section. - * The extractor will silently stop adding events after it - * has reached this threshold. - *

- * The default is 1024. - * @param maxEvents - */ - public static void setMaxXMPMMHistory(int maxEvents) { - MAX_EVENT_HISTORY_IN_XMPMM = maxEvents; - } - - /** - * Extracts Media Management metadata from XMP. - *

- * Silently swallows exceptions. - * - * @param xmp - * @param metadata - */ - public static void extractXMPMM(XMPMetadata xmp, Metadata metadata) { - if (xmp == null) { - return; - } - XMPMediaManagementSchema mmSchema = xmp.getXMPMediaManagementSchema(); - if (mmSchema != null) { - addMetadata(metadata, XMPMM.DOCUMENTID, mmSchema.getDocumentID()); - metadata.set(XMPMM.INSTANCEID, mmSchema.getInstanceID()); - metadata.set(XMPMM.ORIGINAL_DOCUMENTID, mmSchema.getOriginalDocumentID()); - - ResourceRefType derivedFrom = mmSchema.getDerivedFromProperty(); - - if (derivedFrom != null) { - addMetadata(metadata, XMPMM.DERIVED_FROM_DOCUMENTID, derivedFrom.getDocumentID()); - addMetadata(metadata, XMPMM.DERIVED_FROM_INSTANCEID, derivedFrom.getInstanceID()); - } - ArrayProperty historyProperty = mmSchema.getHistoryProperty(); - if (historyProperty != null) { - int eventsAdded = 0; - for (AbstractField af : historyProperty.getAllProperties()) { - if (eventsAdded >= MAX_EVENT_HISTORY_IN_XMPMM) { - break; - } - if (!(af instanceof ResourceEventType)) - { - continue; - } - ResourceEventType stevt = (ResourceEventType) af; - String instanceId = stevt.getInstanceID(); - String action = stevt.getAction(); - Calendar when = stevt.getWhen(); - String softwareAgent = stevt.getSoftwareAgent(); - if (instanceId != null && !instanceId.isBlank()) - { - // for absent data elements, pass in empty strings so - // that parallel arrays will have matching offsets for absent data - action = action == null ? "" : action; - String dateString = when == null ? "" : DateUtils.formatDate(when); - softwareAgent = softwareAgent == null ? "" : softwareAgent; - - metadata.add(XMPMM.HISTORY_EVENT_INSTANCEID, instanceId); - metadata.add(XMPMM.HISTORY_ACTION, action); - metadata.add(XMPMM.HISTORY_WHEN, dateString); - metadata.add(XMPMM.HISTORY_SOFTWARE_AGENT, softwareAgent); - eventsAdded++; - } - } - } - } - } - - /** - * Add list to the metadata map. - * - * @param metadata the metadata map to update. - * @param property the property to add. - * @param values the values to add. - */ - private static void addMetadata(Metadata metadata, Property property, List values) { - if (values != null) { - for (String value : values) { - addMetadata(metadata, property, value); - } - } - } - - /** - * Add value to the metadata map. - * - * @param metadata the metadata map to update. - * @param property the property to add. - * @param value the value to add. - */ - private static void addMetadata(Metadata metadata, Property property, String value) { - if (value != null) { - if (property.isMultiValuePermitted()) { - metadata.add(property, value); - } else { - metadata.set(property, value); - } - } - } - - /** - * Add value to the metadata map. - * - * @param metadata the metadata map to update. - * @param property the property to add. - * @param value the value to add. - */ - private static void addMetadata(Metadata metadata, Property property, Integer value) { - if (value != null) { - if (property.isMultiValuePermitted()) { - metadata.add(property, value); - } else { - metadata.set(property, value); - } - } - } - - /** - * Add value to the metadata map. - * - * @param metadata the metadata map to update. - * @param property the property to add. - * @param value the value to add. - */ - private static void addMetadata(Metadata metadata, Property property, Calendar value) { - if (value != null) { - metadata.set(property, value); - } - } - -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java index 7964f059be7..867338a2f0e 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XMPPacketScanner.java @@ -50,10 +50,14 @@ private static boolean skipAfter(InputStream in, byte[] match) throws IOExceptio return skipAfter(in, match, null); } + // Cap the captured packet so a crafted stream can't read an unbounded xpacket into memory. + private static final long MAX_PACKET = 64L * 1024 * 1024; + private static boolean skipAfter(InputStream in, byte[] match, OutputStream out) throws IOException { int found = 0; int len = match.length; + long written = 0; int b; while ((b = in.read()) >= 0) { if (b == match[found]) { @@ -65,8 +69,12 @@ private static boolean skipAfter(InputStream in, byte[] match, OutputStream out) if (out != null) { if (found > 0) { out.write(match, 0, found); + written += found; } out.write(b); + if (++written > MAX_PACKET) { + throw new IOException("XMP packet exceeds " + MAX_PACKET + " bytes"); + } } found = 0; } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java new file mode 100644 index 00000000000..92f5f6ace2f --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpDates.java @@ -0,0 +1,60 @@ +/* + * 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.xmp; + +import java.util.Calendar; +import java.util.Date; + +import org.apache.pdfbox.util.DateConverter; + +import org.apache.tika.utils.DateUtils; + +/** XMP date string -> canonical ISO-8601 UTC (seconds), or null if unrecognizable. */ +public final class XmpDates { + + private XmpDates() { + } + + public static String normalize(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + if (s.isEmpty()) { + return null; + } + // Known gap: a partial date (YYYY, YYYY-MM) inflates to a full timestamp -- preserving it + // breaks Metadata.getDate() (can't re-parse year/month-only). Deferred to the + // value-representation ticket. PDFBox handles PDF D: dates, producer formats, robust TZ. + try { + Calendar c = DateConverter.toCalendar(s); + if (c != null) { + return DateUtils.formatDate(c); + } + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + // fall through + } + // DateUtils fallback catches EXIF yyyy:MM:dd; not thread-safe, so new instance. + Date d = new DateUtils().tryToParse(s); + if (d != null) { + return DateUtils.formatDate(d); + } + return null; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java new file mode 100644 index 00000000000..9ae8b4b325f --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpExtractor.java @@ -0,0 +1,452 @@ +/* + * 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.xmp; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.UnaryOperator; +import java.util.regex.Pattern; + +import org.xml.sax.SAXException; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.DublinCore; +import org.apache.tika.metadata.Google; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Office; +import org.apache.tika.metadata.PDF; +import org.apache.tika.metadata.PagedText; +import org.apache.tika.metadata.Photoshop; +import org.apache.tika.metadata.Property; +import org.apache.tika.metadata.TIFF; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.metadata.XMP; +import org.apache.tika.metadata.XMPDC; +import org.apache.tika.metadata.XMPMM; +import org.apache.tika.metadata.XMPPDF; +import org.apache.tika.metadata.XMPRights; +import org.apache.tika.metadata.XMPTIFF; +import org.apache.tika.parser.ParseContext; + +/** + * Container-agnostic XMP extractor: SAX-flatten a packet, map to canonical Tika + * properties, normalize dates. Same mapping regardless of source container. + */ +public class XmpExtractor { + + private static final String RESOURCE_EVENT_NS = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"; + private static final String RESOURCE_REF_NS = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#"; + + // (namespaceURI, localName) -> Tika properties (canonical + xmp-namespaced, Option A double-keying) + private static final Map TABLE = new HashMap<>(); + // ResourceEvent field -> xmpMM:History parallel bag + private static final Map HISTORY = new HashMap<>(); + // ResourceRef field (within DerivedFrom) -> property + private static final Map DERIVED_FROM = new HashMap<>(); + // Google camera localName -> property + private static final Map GOOGLE_CAMERA = new HashMap<>(); + // "uri localName" -> canonical date filled set-if-absent (docinfo/EXIF still win) + private static final Map FILL_IF_ABSENT = new HashMap<>(); + // "uri localName" keys that are neither mapped nor passed through as raw (known junk/bloat) + private static final Set DROP = new HashSet<>(); + // Unmapped XMP goes under this prefix so raw, untrusted keys can't shadow a known Tika or + // X-TIKA: field. Best-effort discovery surface: keys use the document's prefix (not the URI) + // and are non-contractual -- promote a field into TABLE when it needs a stable key. + static final String RAW_PREFIX = "xmp-raw:"; + // strip a trailing array index so a raw bag/seq is one multi-valued key, not foo:Bag[1], foo:Bag[2] + private static final Pattern TRAILING_INDEX = Pattern.compile("\\[\\d+\\]$"); + + // History parallel bags, fixed order; emitHistory pads absent fields so bag[i] stays aligned. + private static final Property[] HISTORY_PROPS = { + XMPMM.HISTORY_ACTION, XMPMM.HISTORY_WHEN, XMPMM.HISTORY_EVENT_INSTANCEID, + XMPMM.HISTORY_SOFTWARE_AGENT, XMPMM.HISTORY_CHANGED, XMPMM.HISTORY_PARAMETERS, + }; + // hard cap on history events exposed, so a hostile packet can't inflate metadata without bound + private static final int MAX_HISTORY_EVENTS = 1024; + + private static void put(String uri, String localName, Property... props) { + TABLE.put(uri + " " + localName, props); + } + + static { + String dc = DublinCore.NAMESPACE_URI_DC; + put(dc, "title", TikaCoreProperties.TITLE, XMPDC.TITLE); + put(dc, "creator", TikaCoreProperties.CREATOR, XMPDC.CREATOR); + put(dc, "subject", TikaCoreProperties.SUBJECT, XMPDC.SUBJECT); + put(dc, "description", TikaCoreProperties.DESCRIPTION, XMPDC.DESCRIPTION); + put(dc, "publisher", DublinCore.PUBLISHER, XMPDC.PUBLISHER); + put(dc, "contributor", DublinCore.CONTRIBUTOR, XMPDC.CONTRIBUTOR); + put(dc, "type", DublinCore.TYPE, XMPDC.TYPE); + // dc:format/dc:date -> xmp key only: canonical FORMAT is the real MIME type, dc:date is + // ambiguous; the unambiguous xmp:CreateDate/ModifyDate fill canonical created/modified below. + put(dc, "format", XMPDC.FORMAT); + put(dc, "identifier", DublinCore.IDENTIFIER, XMPDC.IDENTIFIER); + put(dc, "language", DublinCore.LANGUAGE, XMPDC.LANGUAGE); + put(dc, "relation", DublinCore.RELATION, XMPDC.RELATION); + put(dc, "source", DublinCore.SOURCE, XMPDC.SOURCE); + put(dc, "coverage", DublinCore.COVERAGE, XMPDC.COVERAGE); + put(dc, "rights", DublinCore.RIGHTS, XMPDC.RIGHTS); + put(dc, "date", XMPDC.DATE); + + String xmp = XMP.NAMESPACE_URI; + put(xmp, "Title", TikaCoreProperties.TITLE, XMP.TITLE); + put(xmp, "CreateDate", XMP.CREATE_DATE); + put(xmp, "ModifyDate", XMP.MODIFY_DATE); + put(xmp, "MetadataDate", XMP.METADATA_DATE); + put(xmp, "CreatorTool", XMP.CREATOR_TOOL); + put(xmp, "Rating", XMP.RATING); + put(xmp, "Label", XMP.LABEL); + put(xmp, "Nickname", XMP.NICKNAME); + put(xmp, "Identifier", XMP.IDENTIFIER); + put(xmp, "Advisory", XMP.ADVISORY); + // rdf:about (the packet's document URI) -> xmp:About; flattener emits it only when non-empty + put(XmpSaxFlattener.RDF, "about", XMP.ABOUT); + + String mm = XMPMM.NAMESPACE_URI; + put(mm, "DocumentID", XMPMM.DOCUMENTID); + put(mm, "InstanceID", XMPMM.INSTANCEID); + put(mm, "OriginalDocumentID", XMPMM.ORIGINAL_DOCUMENTID); + put(mm, "RenditionClass", XMPMM.RENDITION_CLASS); + put(mm, "RenditionParams", XMPMM.RENDITION_PARAMS); + + String pdf = "http://ns.adobe.com/pdf/1.3/"; + put(pdf, "Producer", PDF.PRODUCER, XMPPDF.PRODUCER); + put(pdf, "Keywords", Office.KEYWORDS, XMPPDF.KEY_WORDS); + put(pdf, "PDFVersion", PDF.PDF_VERSION, XMPPDF.PDF_VERSION); + + String rights = XMPRights.NAMESPACE_URI_XMP_RIGHTS; + put(rights, "Marked", XMPRights.MARKED); + put(rights, "WebStatement", XMPRights.WEB_STATEMENT); + put(rights, "UsageTerms", XMPRights.USAGE_TERMS); + put(rights, "Owner", XMPRights.OWNER); + put(rights, "Certificate", XMPRights.CERTIFICATE); + + // Promoted from raw passthrough; these namespaces are XMP-exclusive, so one canonical key suffices. + String ps = Photoshop.NAMESPACE_URI_PHOTOSHOP; + put(ps, "ColorMode", Photoshop.COLOR_MODE); + put(ps, "ICCProfile", Photoshop.ICC_PROFILE); + put(ps, "DateCreated", Photoshop.DATE_CREATED); + put("http://ns.adobe.com/xap/1.0/t/pg/", "NPages", PagedText.N_PAGES); + put(pdf, "Trapped", PDF.TRAPPED); + + // Camera/EXIF/TIFF -> shared TIFF interface. tiff:/exif: double-keyed: the canonical key may + // also come from binary EXIF (extracted later, so it wins); the XMPTIFF key keeps XMP + // provenance. aux: is XMP-only -> single key. Rationals/ResolutionUnit/Flash stay raw pending + // value normalization. + String aux = "http://ns.adobe.com/exif/1.0/aux/"; + put(aux, "SerialNumber", TIFF.SERIAL_NUMBER); + put(aux, "Lens", TIFF.LENS); + put(aux, "LensInfo", TIFF.LENS_INFO); + put(aux, "LensID", TIFF.LENS_ID); + String tiff = "http://ns.adobe.com/tiff/1.0/"; + put(tiff, "Make", TIFF.EQUIPMENT_MAKE, XMPTIFF.EQUIPMENT_MAKE); + put(tiff, "Model", TIFF.EQUIPMENT_MODEL, XMPTIFF.EQUIPMENT_MODEL); + put(tiff, "Software", TIFF.SOFTWARE, XMPTIFF.SOFTWARE); + put(tiff, "ImageWidth", TIFF.IMAGE_WIDTH, XMPTIFF.IMAGE_WIDTH); + put(tiff, "ImageLength", TIFF.IMAGE_LENGTH, XMPTIFF.IMAGE_LENGTH); + put(tiff, "BitsPerSample", TIFF.BITS_PER_SAMPLE, XMPTIFF.BITS_PER_SAMPLE); + put(tiff, "SamplesPerPixel", TIFF.SAMPLES_PER_PIXEL, XMPTIFF.SAMPLES_PER_PIXEL); + put(tiff, "Orientation", TIFF.ORIENTATION, XMPTIFF.ORIENTATION); + String exif = "http://ns.adobe.com/exif/1.0/"; + put(exif, "DateTimeOriginal", TIFF.ORIGINAL_DATE, XMPTIFF.ORIGINAL_DATE); + put(exif, "ISOSpeedRatings", TIFF.ISO_SPEED_RATINGS, XMPTIFF.ISO_SPEED_RATINGS); + // exif:Pixel*Dimension -> same IMAGE_WIDTH/LENGTH as binary EXIF (integers, no format question). + put(exif, "PixelXDimension", TIFF.IMAGE_WIDTH, XMPTIFF.PIXEL_X_DIMENSION); + put(exif, "PixelYDimension", TIFF.IMAGE_LENGTH, XMPTIFF.PIXEL_Y_DIMENSION); + + // PDF-only namespaces: single canonical home in PDF.*, no dc-style duplicate-key question. + put("http://www.aiim.org/pdfa/ns/id/", "part", PDF.PDFAID_PART); + put("http://www.aiim.org/pdfa/ns/id/", "conformance", PDF.PDFAID_CONFORMANCE); + put("http://www.aiim.org/pdfua/ns/id/", "part", PDF.PDFUAID_PART); + put("http://ns.adobe.com/pdfx/1.3/", "GTS_PDFXVersion", PDF.PDFX_VERSION); + put("http://ns.adobe.com/pdfx/1.3/", "GTS_PDFXConformance", PDF.PDFX_CONFORMANCE); + put("http://www.npes.org/pdfx/ns/id/", "GTS_PDFXVersion", PDF.PDFXID_VERSION); + put("http://www.npes.org/pdfvt/ns/id/", "GTS_PDFVTVersion", PDF.PDFVT_VERSION); + put("http://www.npes.org/pdfvt/ns/id/", "GTS_PDFVTModDate", PDF.PDFVT_MODIFIED); + put("http://ns.adobe.com/illustrator/1.0/", "Type", PDF.ILLUSTRATOR_TYPE); + + HISTORY.put("action", XMPMM.HISTORY_ACTION); + HISTORY.put("when", XMPMM.HISTORY_WHEN); + HISTORY.put("instanceID", XMPMM.HISTORY_EVENT_INSTANCEID); + HISTORY.put("softwareAgent", XMPMM.HISTORY_SOFTWARE_AGENT); + HISTORY.put("changed", XMPMM.HISTORY_CHANGED); + HISTORY.put("parameters", XMPMM.HISTORY_PARAMETERS); + + DERIVED_FROM.put("documentID", XMPMM.DERIVED_FROM_DOCUMENTID); + DERIVED_FROM.put("instanceID", XMPMM.DERIVED_FROM_INSTANCEID); + DERIVED_FROM.put("originalDocumentID", XMPMM.DERIVED_FROM_ORIGINAL_DOCUMENTID); + DERIVED_FROM.put("renditionClass", XMPMM.DERIVED_FROM_RENDITION_CLASS); + + GOOGLE_CAMERA.put("MotionPhoto", Google.MOTION_PHOTO); + GOOGLE_CAMERA.put("MotionPhotoVersion", Google.MOTION_PHOTO_VERSION); + GOOGLE_CAMERA.put("MotionPhotoPresentationTimestampUs", Google.MOTION_PHOTO_PRESENTATION_TIMESTAMP_US); + GOOGLE_CAMERA.put("MicroVideo", Google.MICRO_VIDEO); + GOOGLE_CAMERA.put("MicroVideoVersion", Google.MICRO_VIDEO_VERSION); + GOOGLE_CAMERA.put("MicroVideoOffset", Google.MICRO_VIDEO_OFFSET); + GOOGLE_CAMERA.put("MicroVideoPresentationTimestampUs", Google.MICRO_VIDEO_PRESENTATION_TIMESTAMP_US); + + // Standard XMP dates fill dcterms:created/modified set-if-absent, so an XMP-only file (no + // docinfo or binary EXIF date) isn't left dateless; photoshop:/exif: dates are fallbacks. + FILL_IF_ABSENT.put(xmp + " CreateDate", TikaCoreProperties.CREATED); + FILL_IF_ABSENT.put(xmp + " ModifyDate", TikaCoreProperties.MODIFIED); + FILL_IF_ABSENT.put(ps + " DateCreated", TikaCoreProperties.CREATED); // fallback creation date + FILL_IF_ABSENT.put(exif + " DateTimeOriginal", TikaCoreProperties.CREATED); + + // NativeDigest is Adobe's XMP<->legacy-EXIF/TIFF sync check (tag-ids + MD5); useless to consumers. + DROP.add("http://ns.adobe.com/tiff/1.0/ NativeDigest"); + DROP.add("http://ns.adobe.com/exif/1.0/ NativeDigest"); + // TODO: base64 JPEG thumbnail -> route through embedded-doc extraction. For now drop the blob + // (it bloats every dump) but keep the xmpGImg:width/height/format siblings so it stays visible. + DROP.add("http://ns.adobe.com/xap/1.0/g/img/ image"); + } + + private final XmpSaxFlattener flattener = new XmpSaxFlattener(); + // container-specific value fixup (e.g. PDF octal-BOM decode); identity for XMP that needs none + private final UnaryOperator valueDecoder; + + public XmpExtractor() { + this(UnaryOperator.identity()); + } + + public XmpExtractor(UnaryOperator valueDecoder) { + this.valueDecoder = valueDecoder == null ? UnaryOperator.identity() : valueDecoder; + } + + public void extract(byte[] packet, Metadata metadata) + throws IOException, TikaException, SAXException { + extract(packet, metadata, new ParseContext()); + } + + public void extract(byte[] packet, Metadata metadata, ParseContext context) + throws IOException, TikaException, SAXException { + map(flattener.flatten(packet, context), metadata); + } + + public void extract(InputStream packet, Metadata metadata, ParseContext context) + throws IOException, TikaException, SAXException { + map(flattener.flatten(packet, context), metadata); + } + + private void map(List props, Metadata metadata) { + // History and rdf:Alt lang sets are handled as groups (aligned bags; Alt canonical = x-default); + // everything else streams one leaf at a time. + emitHistory(props, metadata); + emitLangAlternatives(props, metadata); + for (XmpProperty p : props) { + if (!isMappedHistoryEvent(p) && !isLangAlternative(p)) { + map(p, metadata); + } + } + } + + private boolean isMappedHistoryEvent(XmpProperty p) { + return RESOURCE_EVENT_NS.equals(p.namespaceURI) && p.path.contains("History[") + && HISTORY.containsKey(p.localName()); + } + + /** A mapped, top-level, language-tagged leaf is an rdf:Alt alternative (dc:title, ...). */ + private boolean isLangAlternative(XmpProperty p) { + return p.lang != null && !p.lang.isEmpty() && p.path.indexOf('/') < 0 + && TABLE.containsKey(p.namespaceURI + " " + p.localName()); + } + + private static int historyIndex(String path) { + int i = path.indexOf("History["); + if (i < 0) { + return -1; + } + int start = i + "History[".length(); + int end = path.indexOf(']', start); + if (end < 0) { + return -1; + } + try { + return Integer.parseInt(path.substring(start, end)); + } catch (NumberFormatException e) { + return -1; + } + } + + /** Emit the xmpMM:History parallel bags per event, index-aligned, capped, dates normalized. */ + private void emitHistory(List props, Metadata metadata) { + TreeMap> events = new TreeMap<>(); + for (XmpProperty p : props) { + if (!isMappedHistoryEvent(p)) { + continue; + } + int idx = historyIndex(p.path); + String value = valueDecoder.apply(p.value); + if (idx < 0 || value == null || value.isEmpty()) { + continue; + } + if ("when".equals(p.localName())) { // a text bag that holds a date -> normalize it + String n = XmpDates.normalize(value); + if (n != null) { + value = n; + } + } + events.computeIfAbsent(idx, k -> new HashMap<>()).put(HISTORY.get(p.localName()), value); + } + // only emit the bags that actually occur, but keep those index-aligned across events + Set present = new LinkedHashSet<>(); + for (Map ev : events.values()) { + present.addAll(ev.keySet()); + } + int count = 0; + for (Map ev : events.values()) { + if (++count > MAX_HISTORY_EVENTS) { + break; + } + for (Property hp : HISTORY_PROPS) { + if (present.contains(hp)) { + metadata.add(hp, ev.getOrDefault(hp, "")); + } + } + } + } + + /** rdf:Alt language sets: the canonical value is x-default (else first); every lang keeps a key. */ + private void emitLangAlternatives(List props, Metadata metadata) { + Map> byKey = new LinkedHashMap<>(); + for (XmpProperty p : props) { + if (isLangAlternative(p)) { + byKey.computeIfAbsent(p.namespaceURI + " " + p.localName(), + k -> new ArrayList<>()).add(p); + } + } + for (Map.Entry> e : byKey.entrySet()) { + List alts = e.getValue(); + XmpProperty primary = alts.get(0); + for (XmpProperty a : alts) { + if ("x-default".equals(a.lang)) { + primary = a; + break; + } + } + String primaryValue = valueDecoder.apply(primary.value); + for (Property prop : TABLE.get(e.getKey())) { + if (prop.isMultiValuePermitted()) { + // a text bag keeps every language on the canonical key (TIKA-1295 / TIKA-4466), + // but x-default leads so metadata.get(prop) -- which returns values[0] -- yields + // the default, not whatever language the packet happened to list first. + if (primaryValue != null && !primaryValue.isEmpty()) { + emit(metadata, prop, primaryValue); + } + for (XmpProperty a : alts) { + if (a == primary) { + continue; + } + String v = valueDecoder.apply(a.value); + if (v != null && !v.isEmpty()) { + emit(metadata, prop, v); + } + } + } else if (primaryValue != null && !primaryValue.isEmpty()) { + emit(metadata, prop, primaryValue); // single-valued: x-default, not last-wins + } + for (XmpProperty a : alts) { // every language variant keeps its own key + String v = valueDecoder.apply(a.value); + if (v != null && !v.isEmpty()) { + metadata.add(prop.getName() + ":" + a.lang, valueFor(prop, v)); + } + } + } + } + } + + private void map(XmpProperty p, Metadata metadata) { + String uri = p.namespaceURI; + String ln = p.localName(); + + if (XmpSaxFlattener.XMLNS.equals(uri)) { + return; // xml:lang qualifier leaf — structural, not content + } + if (DROP.contains(uri + " " + ln)) { + return; // Adobe-internal digest or base64 thumbnail blob: neither mapped nor raw + } + String value = valueDecoder.apply(p.value); // container-specific fixup (e.g. PDF octal-BOM) + if (value == null || value.isEmpty()) { + return; + } + // xmpMM:History is handled as a group in emitHistory() (parallel bags stay aligned). + if (RESOURCE_REF_NS.equals(uri) && p.path.contains("DerivedFrom")) { + Property dp = DERIVED_FROM.get(ln); + if (dp != null) { + emit(metadata, dp, value); + return; + } // unmapped ref field (renditionClass, ...) -> passthrough below + } + if (Google.CAMERA_NS.equals(uri)) { + Property g = GOOGLE_CAMERA.get(ln); + if (g != null) { + if (metadata.get(g) == null) { + metadata.set(g, value); + } + return; + } + } + // Map (uri, localName) only at the top level: a property nested in a struct (e.g. xmpMM:InstanceID + // inside xmpMM:Pantry) must not overwrite the document-level one. Struct segments join with '/', + // array indices don't, so top-level has no '/'; nested falls through to raw passthrough. + boolean topLevel = p.path.indexOf('/') < 0; + Property fill = FILL_IF_ABSENT.get(uri + " " + ln); + if (topLevel && fill != null && metadata.get(fill) == null) { + metadata.set(fill, valueFor(fill, value)); // canonical date, only if none yet + } + // Language alternatives (leaves with an xml:lang) are handled in emitLangAlternatives(). + Property[] props = TABLE.get(uri + " " + ln); + if (topLevel && props != null) { + for (Property prop : props) { + emit(metadata, prop, value); + } + return; + } + // unmapped: namespaced raw passthrough; a trailing array index collapses to a multi-valued key + metadata.add(RAW_PREFIX + TRAILING_INDEX.matcher(p.path).replaceFirst(""), value); + } + + private void emit(Metadata metadata, Property prop, String value) { + if (prop == null) { + return; + } + String v = valueFor(prop, value); + if (prop.isMultiValuePermitted()) { + metadata.add(prop, v); + } else { + metadata.set(prop, v); + } + } + + private static String valueFor(Property prop, String value) { + if (prop.getValueType() == Property.ValueType.DATE) { + String norm = XmpDates.normalize(value); + if (norm != null) { + return norm; + } + } + return value; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java new file mode 100644 index 00000000000..2267b462c22 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpProperty.java @@ -0,0 +1,51 @@ +/* + * 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.xmp; + +/** One flattened XMP leaf: namespace URI, xmpcore-style path (prefix:name with [i] indices), value, xml:lang. */ +public final class XmpProperty { + + public final String namespaceURI; + public final String path; + public final String value; + public final String lang; // xml:lang qualifier of a lang-alt item, or null + + public XmpProperty(String namespaceURI, String path, String value) { + this(namespaceURI, path, value, null); + } + + public XmpProperty(String namespaceURI, String path, String value, String lang) { + this.namespaceURI = namespaceURI == null ? "" : namespaceURI; + this.path = path; + this.value = value; + this.lang = lang; + } + + /** Local name of the leaf, e.g. {@code dc:creator[1]} -> {@code creator}. */ + public String localName() { + int slash = path.lastIndexOf('/'); + String last = slash < 0 ? path : path.substring(slash + 1); + last = last.replaceAll("\\[\\d+\\]$", ""); // drop trailing array index + int colon = last.lastIndexOf(':'); + return colon < 0 ? last : last.substring(colon + 1); + } + + @Override + public String toString() { + return path + "=" + value; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java new file mode 100644 index 00000000000..f0e1506b23e --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/main/java/org/apache/tika/parser/xmp/XmpSaxFlattener.java @@ -0,0 +1,231 @@ +/* + * 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.xmp; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.commons.io.input.CloseShieldInputStream; +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.parser.ParseContext; +import org.apache.tika.utils.XMLReaderUtils; + +/** SAX-flattens an RDF/XML XMP packet to xmpcore-style (uri, path, value) leaves. */ +public final class XmpSaxFlattener { + + static final String RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + static final String XMLNS = "http://www.w3.org/XML/1998/namespace"; + static final String META = "adobe:ns:meta/"; // x:xmpmeta/x:xapmeta wrapper + x:xmptk + + public List flatten(byte[] packet) throws IOException, TikaException, SAXException { + return flatten(packet, new ParseContext()); + } + + public List flatten(byte[] packet, ParseContext context) + throws IOException, TikaException, SAXException { + try (InputStream is = new ByteArrayInputStream(packet)) { + return flatten(is, context); + } + } + + public List flatten(InputStream packet, ParseContext context) + throws IOException, TikaException, SAXException { + Handler h = new Handler(); + // hardened pooled SAX (secure factory + entity cap + OfflineContentHandler); CloseShield the stream + XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(packet), h, context); + return h.out; + } + + private static final class Handler extends DefaultHandler { + // Caps so a hostile packet can't inflate metadata: leaf count, key/path length, value length. + static final int MAX_LEAVES = 50_000; + static final int MAX_PATH = 512; + static final int MAX_VALUE = 1 << 20; // 1 MB + final List out = new ArrayList<>(); + final ArrayDeque segs = new ArrayDeque<>(); + final ArrayDeque uris = new ArrayDeque<>(); // leaf/property namespace per frame + final ArrayDeque liCount = new ArrayDeque<>(); + final ArrayDeque text = new ArrayDeque<>(); + final ArrayDeque childCount = new ArrayDeque<>(); + final ArrayDeque langs = new ArrayDeque<>(); // xml:lang per frame (holder so it can be set after push) + final ArrayDeque hasValue = new ArrayDeque<>(); // frame carries an explicit rdf:value + + static boolean isContainer(String u, String l) { + return RDF.equals(u) && (l.equals("Bag") || l.equals("Seq") || l.equals("Alt")); + } + + void add(XmpProperty p) { + if (out.size() >= MAX_LEAVES || p.path.length() > MAX_PATH + || (p.value != null && p.value.length() > MAX_VALUE)) { + return; // drop over-cap leaves: count / absurd key / bloated value + } + out.add(p); + } + + String path() { + StringBuilder p = new StringBuilder(); + Iterator it = segs.descendingIterator(); + while (it.hasNext()) { + String s = it.next(); + if (s.startsWith("[")) { + p.append(s); + } else { + if (p.length() > 0) { + p.append('/'); + } + p.append(s); + } + } + return p.toString(); + } + + void pushFrame(String seg, String uri) { + if (!childCount.isEmpty()) { + childCount.peek()[0]++; + } + segs.push(seg); + uris.push(uri == null ? "" : uri); + text.push(new StringBuilder()); + childCount.push(new int[]{0}); + langs.push(new String[]{null}); + hasValue.push(new boolean[]{false}); + } + + @Override + public void startElement(String u, String l, String qn, Attributes a) { + if (META.equals(u)) { + return; + } + boolean rdf = RDF.equals(u); + if (rdf && l.equals("RDF")) { + return; + } + if (rdf && l.equals("Description")) { + emitAttrs(a, false); + return; + } + if (isContainer(u, l)) { + liCount.push(new int[]{0}); + return; + } + if (rdf && l.equals("li")) { + // a bare rdf:li outside a Bag/Seq/Alt (malformed) has no counter -> treat as [1] + int idx = liCount.isEmpty() ? 1 : ++liCount.peek()[0]; + pushFrame("[" + idx + "]", uris.isEmpty() ? "" : uris.peek()); + } else if (rdf && l.equals("value")) { + // rdf:value holds the frame's value even when qualifier siblings are present + if (!hasValue.isEmpty()) { + hasValue.peek()[0] = true; + } + return; + } else if (!rdf) { + pushFrame(qn, u); + } else { + return; + } + emitAttrs(a, true); + } + + void emitAttrs(Attributes a, boolean valueFrame) { + String base = path(); + for (int i = 0; i < a.getLength(); i++) { + String au = a.getURI(i); + String al = a.getLocalName(i); + String aq = a.getQName(i); + String v = a.getValue(i); + if (v == null) { + continue; + } + v = v.trim(); + if (v.isEmpty()) { + continue; + } + if (XMLNS.equals(au)) { + if (al.equals("lang")) { + if (valueFrame) { + langs.peek()[0] = v; // attach to this value's frame + } + if (!base.isEmpty()) { + add(new XmpProperty(XMLNS, base + "/xml:lang", v)); + } + } + continue; + } + if (RDF.equals(au)) { + if (al.equals("resource")) { + add(new XmpProperty(uris.isEmpty() ? "" : uris.peek(), base, v)); + } else if (al.equals("about")) { // empty rdf:about already skipped above + add(new XmpProperty(RDF, base.isEmpty() ? "rdf:about" : base + "/rdf:about", v)); + } + continue; + } + if (au == null || au.isEmpty()) { + continue; + } + add(new XmpProperty(au, base.isEmpty() ? aq : base + "/" + aq, v)); + } + } + + @Override + public void characters(char[] c, int s, int len) { + if (!text.isEmpty()) { + text.peek().append(c, s, len); + } + } + + @Override + public void endElement(String u, String l, String qn) { + if (META.equals(u)) { + return; + } + boolean rdf = RDF.equals(u); + if (rdf && l.equals("RDF")) { + return; + } + if (rdf && l.equals("Description")) { + return; + } + if (isContainer(u, l)) { + liCount.pop(); + return; + } + if (rdf && !l.equals("li")) { + return; + } + String t = text.peek().toString().trim(); + // a frame is a leaf when it has no child elements, or when an rdf:value gave it a value + if (!t.isEmpty() && (childCount.peek()[0] == 0 || hasValue.peek()[0])) { + add(new XmpProperty(uris.peek(), path(), t, langs.peek()[0])); + } + segs.pop(); + uris.pop(); + text.pop(); + childCount.pop(); + langs.pop(); + hasValue.pop(); + } + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java deleted file mode 100644 index 976d9e601f3..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/JempboxExtractorTest.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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.xmp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; - -import org.junit.jupiter.api.Test; - -import org.apache.tika.TikaTest; -import org.apache.tika.exception.TikaException; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.metadata.XMPMM; - -public class JempboxExtractorTest extends TikaTest { - - @Test - public void testParseJpeg() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testJPEG_commented.jpg")) { - // set some values before extraction to see that they are overridden - metadata.set(TikaCoreProperties.TITLE, "old title"); - metadata.set(TikaCoreProperties.DESCRIPTION, "old description"); - metadata.set(TikaCoreProperties.CREATOR, "previous author"); - // ... or kept in case the field is multi-value - metadata.add(TikaCoreProperties.SUBJECT, "oldkeyword"); - - JempboxExtractor extractor = new JempboxExtractor(metadata); - extractor.parse(tis); - - // DublinCore fields - assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE)); - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("oldkeyword")); - assertTrue(keywords.contains("grazelands")); - assertTrue(keywords.contains("nature reserve")); - assertTrue(keywords.contains("bird watching")); - assertTrue(keywords.contains("coast")); - } - } - - @Test - public void testParseJpegPhotoshop() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream( - "/test-documents/testJPEG_commented_pspcs2mac.jpg")) { - JempboxExtractor extractor = new JempboxExtractor(metadata); - extractor.parse(tis); - - // DublinCore fields - assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE)); - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("bird watching")); - assertTrue(keywords.contains("coast")); - } - } - - @Test - public void testParseJpegXnviewmp() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream( - "/test-documents/testJPEG_commented_xnviewmp026.jpg")) { - JempboxExtractor extractor = new JempboxExtractor(metadata); - extractor.parse(tis); - - // XnViewMp fields not understood by Jempbox - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("coast")); - assertTrue(keywords.contains("nature reserve")); - } - } - - @Test - public void testJoinCreators() { - assertEquals("Mr B", new JempboxExtractor(null).joinCreators(Collections.singletonList("Mr B"))); - // TODO use multi-value property instead? - assertEquals("Mr B, Mr A", - new JempboxExtractor(null).joinCreators(Arrays.asList("Mr B", "Mr A"))); - } - - @Test - public void testMaxXMPMMHistory() throws Exception { - int maxHistory = JempboxExtractor.getMaxXMPMMHistory(); - try { - Metadata m = new Metadata(); - JempboxExtractor ex = new JempboxExtractor(m); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) { - ex.parse(tis); - } - assertEquals(7, m.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length); - - JempboxExtractor.setMaxXMPMMHistory(5); - m = new Metadata(); - ex = new JempboxExtractor(m); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) { - ex.parse(tis); - } - assertEquals(5, m.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length); - } finally { - //if something goes wrong, make sure to set this back to what it was - JempboxExtractor.setMaxXMPMMHistory(maxHistory); - } - } - - @Test - public void testModifiedTZ() throws Exception { - Metadata m = new Metadata(); - JempboxExtractor ex = new JempboxExtractor(m); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) { - ex.parse(tis); - } - assertEquals("2014-03-04T22:50:41Z", m.get(XMPMM.HISTORY_WHEN)); - } - - @Test - public void testXMPMMMisc() throws Exception { - Metadata m = new Metadata(); - JempboxExtractor ex = new JempboxExtractor(m); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) { - ex.parse(tis); - } - assertEquals("uuid:cccee1fc-51b3-4b52-ac86-672af3974d25", m.getValues(XMPMM.DOCUMENTID)[0]); - assertEquals("uuid:afa71b09-7cc5-48ac-8664-ac6dcf8b5ab4", m.getValues(XMPMM.INSTANCEID)[0]); - } -} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java new file mode 100644 index 00000000000..e13fa2b4bfe --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpDatesTest.java @@ -0,0 +1,74 @@ +/* + * 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.xmp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +public class XmpDatesTest { + + /** Timezone-bearing dates have a deterministic UTC instant. */ + @Test + public void testOffsetDatesNormalizeToUtc() { + assertEquals("2013-10-21T08:29:53Z", XmpDates.normalize("2013-10-21T10:29:53+02:00")); + assertEquals("2014-05-01T22:27:00Z", XmpDates.normalize("2014-05-01T15:27-07:00")); + assertEquals("2006-03-15T15:20:00Z", XmpDates.normalize("2006-03-15T15:20Z")); + } + + /** Minute-precision-with-offset (no seconds): jempbox silently corrupts this; PDFBox is correct. */ + @Test + public void testMinutePrecisionWithOffset() { + assertEquals("2016-06-22T09:59:00Z", XmpDates.normalize("2016-06-22T17:59+08:00")); + assertEquals("2010-03-12T06:09:00Z", XmpDates.normalize("2010-03-12T11:09+05:00")); + } + + /** Fractional seconds parse (jempbox drops them) and truncate to seconds. */ + @Test + public void testFractionalSeconds() { + assertNotNull(XmpDates.normalize("2017-12-18T17:28:56.425+00:00")); + assertEquals("2018-11-04T09:29:04Z", XmpDates.normalize("2018-11-04T10:29:04.3218973+01:00")); + } + + /** PDF D: form and date-only forms are recognized. */ + @Test + public void testOtherRecognizedForms() { + assertNotNull(XmpDates.normalize("D:20030101120000+05'30'")); + assertNotNull(XmpDates.normalize("2015-06-12")); + assertNotNull(XmpDates.normalize("2015:06:12")); // EXIF date-only, via DateUtils fallback + } + + /** Non-dates degrade to null so the caller can pass the raw value through. */ + @Test + public void testGarbageReturnsNull() { + assertNull(XmpDates.normalize("CPY Document Creation Date")); + assertNull(XmpDates.normalize("")); + assertNull(XmpDates.normalize(null)); + } + + /** + * A partial date (YYYY, YYYY-MM) inflates to a full timestamp; exact-equality (not assertNotNull) + * pins that fabricated precision so the value-representation fix trips this test. + */ + @Test + public void testPartialDatesInflateToFullTimestamp() { + assertEquals("2019-01-01T00:00:00Z", XmpDates.normalize("2019")); + assertEquals("2019-06-01T00:00:00Z", XmpDates.normalize("2019-06")); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java new file mode 100644 index 00000000000..edb5e3c4578 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpExtractorTest.java @@ -0,0 +1,483 @@ +/* + * 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.xmp; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.PDF; +import org.apache.tika.metadata.PagedText; +import org.apache.tika.metadata.Photoshop; +import org.apache.tika.metadata.TIFF; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.metadata.XMP; +import org.apache.tika.metadata.XMPDC; +import org.apache.tika.metadata.XMPMM; +import org.apache.tika.metadata.XMPTIFF; + +public class XmpExtractorTest { + + private static final String PACKET = + "" + + "" + + "" + + " 2020-01-02T03:04:05Z" + + " AliceBob" + + " Hello" + + " " + + " docid-123" + + " orig-999" + + " proof:pdf" + + " " + + " " + + " created" + + " /metadata" + + " from application/pdf" + + " saved" + + " " + + ""; + + private Metadata metadata; + + @BeforeEach + public void setUp() throws Exception { + metadata = new Metadata(); + new XmpExtractor().extract(PACKET.getBytes(UTF_8), metadata); + } + + @Test + public void testScalarsAndDates() { + assertEquals("Hello", metadata.get(TikaCoreProperties.TITLE)); + assertEquals("2020-01-02T03:04:05Z", metadata.get(XMP.CREATE_DATE)); + // xmp:CreateDate also fills the canonical created date when nothing else set it + assertEquals("2020-01-02T03:04:05Z", metadata.get(TikaCoreProperties.CREATED)); + } + + /** Canonical created is filled set-if-absent: a doc date (docinfo/EXIF) already present wins. */ + @Test + public void testCanonicalCreatedNotOverwritten() throws Exception { + Metadata md = new Metadata(); + md.set(TikaCoreProperties.CREATED, "1999-12-31T00:00:00Z"); // e.g. docinfo/EXIF got here first + new XmpExtractor().extract(PACKET.getBytes(UTF_8), md); + assertEquals("1999-12-31T00:00:00Z", md.get(TikaCoreProperties.CREATED)); // unchanged + assertEquals("2020-01-02T03:04:05Z", md.get(XMP.CREATE_DATE)); // xmp key still set + } + + @Test + public void testMultiValued() { + assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(TikaCoreProperties.CREATOR)); + } + + @Test + public void testStructs() { + assertEquals("docid-123", metadata.get(XMPMM.DERIVED_FROM_DOCUMENTID)); + assertArrayEquals(new String[]{"created", "saved"}, metadata.getValues(XMPMM.HISTORY_ACTION)); + } + + /** History/DerivedFrom subfields beyond action/when/documentID map to dedicated properties. */ + @Test + public void testHistoryAndDerivedFromSubfields() { + assertEquals("/metadata", metadata.get(XMPMM.HISTORY_CHANGED)); + assertEquals("from application/pdf", metadata.get(XMPMM.HISTORY_PARAMETERS)); + assertEquals("orig-999", metadata.get(XMPMM.DERIVED_FROM_ORIGINAL_DOCUMENTID)); + assertEquals("proof:pdf", metadata.get(XMPMM.DERIVED_FROM_RENDITION_CLASS)); + } + + @Test + public void testUnmappedPassthrough() { + assertEquals("25", metadata.get("xmp-raw:crs:Contrast")); // still-unmapped -> namespaced raw + assertEquals("Canon", metadata.get(TIFF.EQUIPMENT_MAKE)); // tiff:Make is now promoted + } + + /** Group-1 + Group-2(clean) + aux promotions: top raw keys map to first-class properties. */ + @Test + public void testPromotedKeys() throws Exception { + String packet = "" + + "" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + + // group 1 + assertEquals("3", md.get(Photoshop.COLOR_MODE)); + assertEquals("sRGB IEC61966-2.1", md.get(Photoshop.ICC_PROFILE)); + assertEquals("7", md.get(PagedText.N_PAGES)); + assertEquals("False", md.get(PDF.TRAPPED)); + assertNotNull(md.get(Photoshop.DATE_CREATED)); // date-typed, normalized + assertNotNull(md.get(TikaCoreProperties.CREATED)); // DateCreated fills created + // group 2 (clean exif/tiff) + aux -> the shared TIFF interface + assertEquals("Canon", md.get(TIFF.EQUIPMENT_MAKE)); + assertEquals("EOS 5D", md.get(TIFF.EQUIPMENT_MODEL)); + assertEquals("1", md.get(TIFF.ORIENTATION)); + assertNotNull(md.get(TIFF.ORIGINAL_DATE)); // exif:DateTimeOriginal, normalized + assertEquals("64", md.get(TIFF.ISO_SPEED_RATINGS)); + assertEquals("142026001685", md.get(TIFF.SERIAL_NUMBER)); + assertEquals("iPhone 5s back camera 4.12mm f/2.2", md.get(TIFF.LENS)); + // exif:Pixel*Dimension folds into the canonical IMAGE_WIDTH/LENGTH (+ xmp-marked variant) + assertEquals("600", md.get(TIFF.IMAGE_WIDTH)); + assertEquals("734", md.get(TIFF.IMAGE_LENGTH)); + assertEquals("600", md.get(XMPTIFF.PIXEL_X_DIMENSION)); + // tiff:/exif: are double-keyed: the XMP-marked variant preserves provenance + assertEquals("Canon", md.get(XMPTIFF.EQUIPMENT_MAKE)); + assertEquals("EOS 5D", md.get(XMPTIFF.EQUIPMENT_MODEL)); + assertNotNull(md.get(XMPTIFF.ORIGINAL_DATE)); + // aux: is single-key (no binary source) -> no xmp-marked variant + assertNull(md.get("xmp:aux:Lens")); + // no longer raw + assertNull(md.get("xmp-raw:photoshop:ColorMode")); + assertNull(md.get("xmp-raw:tiff:Make")); + assertNull(md.get("xmp-raw:aux:Lens")); + } + + /** A bare rdf:li outside a Bag/Seq/Alt (malformed) must not NPE / crash the parse. */ + @Test + public void testBareRdfLiIsNotFatal() throws Exception { + String packet = "" + + "" + + "" + + "lonely" // rdf:li with no container + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); // must not throw + assertEquals("lonely", md.get(TikaCoreProperties.SUBJECT)); + } + + /** A property nested in a struct must not overwrite the document-level one (falls to raw). */ + @Test + public void testNestedPropertyDoesNotClobberDocumentLevel() throws Exception { + String packet = "" + + "" + + "" + + " " + + " pantry-ingredient" + + " " + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + assertEquals("doc-level", md.get(XMPMM.INSTANCEID)); // not clobbered by the nested one + assertNotNull(md.get("xmp-raw:xmpMM:Pantry[1]/xmpMM:InstanceID")); // nested falls to raw + } + + /** rdf:value carries the property's value even when qualifier siblings are present. */ + @Test + public void testRdfValueWithQualifier() throws Exception { + String packet = "" + + "" + + "" + + " " + + " the-id" + + " myscheme" + + " " + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + assertEquals("the-id", md.get(XMP.IDENTIFIER)); // value kept despite the qualifier sibling + } + + /** dc:title is a text bag: every language accumulates on the canonical key (TIKA-1295/4466). */ + @Test + public void testMultiLangTitleAccumulates() throws Exception { + String packet = "" + + "" + + "" + + "" + + "Bonjour" + + "Hello" + + "Hola" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + // x-default ("Hello") leads the bag even though it is listed second in the packet, so + // metadata.get(TITLE) (== values[0]) is the default; the rest follow in document order. + assertArrayEquals(new String[]{"Hello", "Bonjour", "Hola"}, md.getValues(TikaCoreProperties.TITLE)); + assertEquals("Hello", md.get(TikaCoreProperties.TITLE)); + assertEquals("Bonjour", md.get("dc:title:fr")); + assertEquals("Hello", md.get("dc:title:x-default")); + assertEquals("Hola", md.get("dc:title:es")); + } + + /** A single-valued Alt property (xmp:Title -> XMP.TITLE) takes x-default, not the last li. */ + @Test + public void testSingleValuedLangAltIsXDefault() throws Exception { + String packet = "" + + "" + + "" + + "" + + "Titre" + + "Title" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + assertEquals("Title", md.get(XMP.TITLE)); // x-default, not the last alternative (Titre) + } + + /** xmpMM:History parallel bags stay index-aligned: a missing field is padded so ACTION[i]~WHEN[i]. */ + @Test + public void testHistoryParallelArraysStayAligned() throws Exception { + String packet = "" + + "" + + "" + + "" + + "created" + + " 2020-01-01T00:00:00Z" + + "saved" + + "printed" + + " 2021-02-02T00:00:00Z" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + assertArrayEquals(new String[]{"created", "saved", "printed"}, + md.getValues(XMPMM.HISTORY_ACTION)); + // the second event ("saved") has no when, so WHEN[1] is padded to keep the arrays aligned + assertArrayEquals(new String[]{"2020-01-01T00:00:00Z", "", "2021-02-02T00:00:00Z"}, + md.getValues(XMPMM.HISTORY_WHEN)); + } + + /** Adobe-internal digests and the base64 thumbnail blob are dropped, not exposed even as raw. */ + @Test + public void testJunkKeysDropped() throws Exception { + String packet = "" + + "" + + "" + + " 256,257,258;A1B2C3" + + " 36864,40960;123456" + + " " + + " /9j/4AAQSkZJRgABAgEBLAEsAAD" + + " 256" + + " JPEG" + + " " + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + + boolean nativeDigest = false; + boolean blob = false; + boolean widthKept = false; + for (String n : md.names()) { + if (n.contains("NativeDigest")) { + nativeDigest = true; + } + if (md.get(n).startsWith("/9j/")) { + blob = true; + } + if (n.endsWith("xmpGImg:width")) { + widthKept = true; + } + } + assertFalse(nativeDigest, "tiff/exif:NativeDigest must be dropped"); + assertFalse(blob, "base64 thumbnail blob must be dropped"); + assertTrue(widthKept, "harmless thumbnail siblings are kept"); + } + + /** Non-ASCII UTF-8 values must survive the SAX flatten/extract path intact. */ + @Test + public void testNonAsciiUtf8() throws Exception { + String xmp = + "" + + "" + + "" + + "" + + "Tosteberga Ängar" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(xmp.getBytes(java.nio.charset.StandardCharsets.UTF_8), md); + assertEquals("Tosteberga Ängar", md.get(TikaCoreProperties.TITLE)); + } + + /** Raw passthrough is namespaced (xmp-raw:) so untrusted XMP can never shadow a known Tika field. */ + @Test + public void testPassthroughCannotShadowKnownFields() throws Exception { + String evil = + "" + + " table miss -> raw + + " xmlns:ev='http://evil.example/2'>" + + "" + + " injected" + + " ok" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(evil.getBytes(UTF_8), md); + assertNull(md.get(TikaCoreProperties.TITLE), "spoofed dc:title must not write the known field"); + assertNull(md.get("dc:title")); + assertEquals("injected", md.get("xmp-raw:dc:title")); // it lands under the raw namespace instead + assertEquals("ok", md.get("xmp-raw:ev:harmless")); + } + + /** + * A raw (unmapped) bag collapses to one multi-valued xmp-raw key; interior indices in an + * array-of-structs are kept so element fields stay distinguishable. + */ + @Test + public void testRawArrayKeys() throws Exception { + String packet = + "" + + "" + + "" + + "" + + "alphabetagamma" + + "" + + "" + + "e1" + + "e2" + + "" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + // bag -> one multi-valued key, no per-index keys + assertArrayEquals(new String[]{"alpha", "beta", "gamma"}, md.getValues("xmp-raw:foo:Tags")); + assertNull(md.get("xmp-raw:foo:Tags[1]")); + // array-of-structs -> interior indices preserved so e1/e2 stay separate + assertEquals("e1", md.get("xmp-raw:foo:Events[1]/foo:name")); + assertEquals("e2", md.get("xmp-raw:foo:Events[2]/foo:name")); + } + + /** Option A: dc values land in both the canonical and the xmp-namespaced property. */ + @Test + public void testDoubleKeying() { + assertEquals("Hello", metadata.get(XMPDC.TITLE)); + assertArrayEquals(new String[]{"Alice", "Bob"}, metadata.getValues(XMPDC.CREATOR)); + } + + /** Language-alt items are exposed under key:lang, for both canonical and xmp-namespaced keys. */ + @Test + public void testLanguageVariants() throws Exception { + String ml = + "" + + "" + + "" + + " Hello World" + + " Bonjour World" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(ml.getBytes(UTF_8), md); + assertEquals("Hello World", md.get("dc:title:x-default")); + assertEquals("Bonjour World", md.get("dc:title:fr-ca")); + assertEquals("Bonjour World", md.get("xmp:dc:title:fr-ca")); // Option A: xmp-namespaced too + } + + /** xmpMM:History is capped (MAX_HISTORY_EVENTS) so a hostile packet can't inflate metadata. */ + @Test + public void testHistoryEventsAreCapped() throws Exception { + StringBuilder sb = new StringBuilder( + "" + + "" + + ""); + for (int i = 0; i < 1100; i++) { + sb.append("saved"); + } + sb.append(""); + Metadata md = new Metadata(); + new XmpExtractor().extract(sb.toString().getBytes(UTF_8), md); + assertEquals(1024, md.getValues(XMPMM.HISTORY_ACTION).length); // capped, not 1100 + } + + /** rdf:about is populated to xmp:About when non-empty, and skipped when empty. */ + @Test + public void testRdfAboutMapsToXmpAbout() throws Exception { + String withAbout = + "" + + "" + + "" + + "application/pdf" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(withAbout.getBytes(UTF_8), md); + assertEquals("uuid:doc-42", md.get(XMP.ABOUT)); + + Metadata md2 = new Metadata(); + new XmpExtractor().extract(withAbout.replace("uuid:doc-42", "").getBytes(UTF_8), md2); + assertNull(md2.get(XMP.ABOUT), "empty rdf:about must not be recorded"); + } + + /** Two rdf:RDF blocks in one packet are both processed. */ + @Test + public void testMultipleRdfBlocks() throws Exception { + String packet = + "" + + "" + + "" + + "First Block" + + "" + + "" + + "" + + "Second Block" + + ""; + Metadata md = new Metadata(); + new XmpExtractor().extract(packet.getBytes(UTF_8), md); + assertEquals("First Block", md.get(TikaCoreProperties.TITLE)); + assertEquals("Second Block", md.get(TikaCoreProperties.CREATOR)); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java new file mode 100644 index 00000000000..42eac27cab7 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpSaxFlattenerTest.java @@ -0,0 +1,155 @@ +/* + * 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.xmp; + +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.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class XmpSaxFlattenerTest { + + private static final String PACKET = + "" + + "" + + "" + + " 2020-01-02T03:04:05Z" + + " AliceBob" + + " Hello" + + " " + + " docid-123" + + " " + + " " + + " created" + + " saved" + + " " + + ""; + + private List flatten() throws Exception { + return new XmpSaxFlattener().flatten(PACKET.getBytes(UTF_8)); + } + + private static String get(List l, String path) { + List v = new ArrayList<>(); + for (XmpProperty p : l) { + if (p.path.equals(path)) { + v.add(p.value); + } + } + return v.isEmpty() ? null : String.join("|", v); + } + + @Test + public void testSimpleAndCompact() throws Exception { + List l = flatten(); + assertEquals("2020-01-02T03:04:05Z", get(l, "xmp:CreateDate")); + assertEquals("Canon", get(l, "tiff:Make")); // compact attribute form + } + + @Test + public void testArrayIndices() throws Exception { + List l = flatten(); + assertEquals("Alice", get(l, "dc:creator[1]")); + assertEquals("Bob", get(l, "dc:creator[2]")); + } + + @Test + public void testLangAlt() throws Exception { + List l = flatten(); + assertEquals("Hello", get(l, "dc:title[1]")); + assertEquals("x-default", get(l, "dc:title[1]/xml:lang")); + } + + @Test + public void testStructAndHistory() throws Exception { + List l = flatten(); + assertEquals("docid-123", get(l, "xmpMM:DerivedFrom/stRef:documentID")); + assertEquals("created", get(l, "xmpMM:History[1]/stEvt:action")); + assertEquals("saved", get(l, "xmpMM:History[2]/stEvt:action")); + } + + @Test + public void testNamespaceUriTracked() throws Exception { + List l = flatten(); + for (XmpProperty p : l) { + if (p.path.equals("xmp:CreateDate")) { + assertEquals("http://ns.adobe.com/xap/1.0/", p.namespaceURI); + } + if (p.path.startsWith("dc:creator")) { + assertEquals("http://purl.org/dc/elements/1.1/", p.namespaceURI); + } + } + } + + @Test + public void testToolkitAndAboutDropped() throws Exception { + List l = flatten(); + // x:xmptk (adobe:ns:meta/) and the empty rdf:about are not emitted as leaves + assertNull(get(l, "x:xmptk")); + for (XmpProperty p : l) { + assertTrue(!p.path.contains("xmptk") && !p.path.contains("about"), + "unexpected leaf: " + p); + } + } + + /** Over-cap leaves are dropped so a hostile packet can't produce a giant key or value. */ + @Test + public void testOverlongPathAndValueDropped() throws Exception { + String longName = "a".repeat(600); // path > MAX_PATH (512) + String bigValue = "v".repeat((1 << 20) + 64); // value > MAX_VALUE (1 MB) + String packet = + "" + + "" + + "x" + + "" + bigValue + "" + + "fine" + + ""; + List leaves = new XmpSaxFlattener().flatten(packet.getBytes(UTF_8)); + assertEquals("fine", get(leaves, "foo:ok")); // normal leaf survives + assertNull(get(leaves, "foo:blob")); // bloated value dropped + for (XmpProperty p : leaves) { + assertTrue(p.path.length() <= 512, "overlong path leaked"); + } + } + + /** The flattener caps its leaf list (MAX_LEAVES) so a hostile packet can't inflate metadata. */ + @Test + public void testLeafListIsCapped() throws Exception { + StringBuilder sb = new StringBuilder( + "" + + ""); + for (int i = 0; i < 60000; i++) { + sb.append("v"); + } + sb.append(""); + List leaves = new XmpSaxFlattener().flatten(sb.toString().getBytes(UTF_8)); + assertEquals(50000, leaves.size()); // capped at MAX_LEAVES, not 60000 + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java deleted file mode 100644 index 6aff54ffdd1..00000000000 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-xmp-commons/src/test/java/org/apache/tika/parser/xmp/XmpboxExtractorTest.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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.xmp; - - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collection; - -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.junit.jupiter.api.Test; - -import org.apache.tika.TikaTest; -import org.apache.tika.exception.TikaException; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.TikaCoreProperties; -import org.apache.tika.metadata.XMPMM; - -/** - * - * @author Tilman Hausherr - */ -public class XmpboxExtractorTest extends TikaTest { - - private final XMPPacketScanner scanner = new XMPPacketScanner(); - - @Test // parsing fails because of bad date "2010-07-28T11:02:12.000CEST" = UTC+02:00 - public void testParseJpeg() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream("/test-documents/testJPEG_commented.jpg")) { - UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get(); - boolean parsed = scanner.parse(tis, xmpraw); - assertTrue(parsed); - - // set some values before extraction to see that they are overridden - - //TODO this doesn't work here because this extractor uses addMetadata() which works - // differently than metadata.set(). We may want to fix one or the other. -// metadata.set(TikaCoreProperties.TITLE, "old title"); -// metadata.set(TikaCoreProperties.DESCRIPTION, "old description"); -// metadata.set(TikaCoreProperties.CREATOR, "previous author"); - // ... or kept in case the field is multi-value - metadata.add(TikaCoreProperties.SUBJECT, "oldkeyword"); - - // xmpbox fails parsing on bad dates - String s = xmpraw.toString(StandardCharsets.UTF_8); - s = s.replace("CEST\"", "+02:00\""); - - XMPMetadataExtractor.parse(new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)), metadata); - - // DublinCore fields - assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE)); - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("oldkeyword")); - assertTrue(keywords.contains("grazelands")); - assertTrue(keywords.contains("nature reserve")); - assertTrue(keywords.contains("bird watching")); - assertTrue(keywords.contains("coast")); - } - } - - @Test - public void testParseJpegPhotoshop() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream( - "/test-documents/testJPEG_commented_pspcs2mac.jpg")) { - UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get(); - boolean parsed = scanner.parse(tis, xmpraw); - assertTrue(parsed); - - try (InputStream is = xmpraw.toInputStream()) { - XMPMetadataExtractor.parse(is, metadata); - } - - // DublinCore fields - assertEquals("Tosteberga \u00C4ngar", metadata.get(TikaCoreProperties.TITLE)); - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - assertEquals("Some Tourist", metadata.get(TikaCoreProperties.CREATOR)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("bird watching")); - assertTrue(keywords.contains("coast")); - } - } - - @Test - public void testParseJpegXnviewmp() throws IOException, TikaException { - Metadata metadata = new Metadata(); - try (TikaInputStream tis = getResourceAsStream( - "/test-documents/testJPEG_commented_xnviewmp026.jpg")) { - UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get(); - boolean parsed = scanner.parse(tis, xmpraw); - assertTrue(parsed); - - try (InputStream is = xmpraw.toInputStream()) { - XMPMetadataExtractor.parse(is, metadata); - } - - assertEquals("Bird site in north eastern Sk\u00E5ne, Sweden.\n(new line)", - metadata.get(TikaCoreProperties.DESCRIPTION)); - Collection keywords = - Arrays.asList(metadata.getValues(TikaCoreProperties.SUBJECT)); - assertTrue(keywords.contains("coast")); - assertTrue(keywords.contains("nature reserve")); - } - } - - @Test - public void testMaxXMPMMHistory() throws Exception { - Metadata metadata = new Metadata(); - int maxHistory = XMPMetadataExtractor.getMaxXMPMMHistory(); - try { - try (TikaInputStream tis = getResourceAsStream("/test-documents/testXMP.xmp")) { - UnsynchronizedByteArrayOutputStream xmpraw = UnsynchronizedByteArrayOutputStream.builder().get(); - boolean parsed = scanner.parse(tis, xmpraw); - assertTrue(parsed); - - try (InputStream is = xmpraw.toInputStream()) { - XMPMetadataExtractor.parse(is, metadata); - } - - assertEquals(7, metadata.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length); - - XMPMetadataExtractor.setMaxXMPMMHistory(5); - metadata = new Metadata(); - try (InputStream is = xmpraw.toInputStream()) { - XMPMetadataExtractor.parse(is, metadata); - } - - assertEquals(5, metadata.getValues(XMPMM.HISTORY_EVENT_INSTANCEID).length); - } - } - finally { - //if something goes wrong, make sure to set this back to what it was - XMPMetadataExtractor.setMaxXMPMMHistory(maxHistory); - } - } - -}