Skip to content

TIKA-4775 - saxify xmp#2946

Open
tballison wants to merge 6 commits into
mainfrom
TIKA-4775-saxify-xmp
Open

TIKA-4775 - saxify xmp#2946
tballison wants to merge 6 commits into
mainfrom
TIKA-4775-saxify-xmp

Conversation

@tballison

Copy link
Copy Markdown
Contributor

This is a major refactoring of xmp parsing. This tries to unify how we handle xmp across the different container formats. I've tried to lessen our dependence on xmp-core, which still comes in through metadata-extractor. I've tried to unify xmp namespaces/keys for unknown xmp keys.

@tballison

tballison commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@dschmidt if you want to aim your agents at this, I'd appreciate feedback.

I mean... all feedback is welcomed! 😄

@tballison tballison marked this pull request as draft July 10, 2026 21:00
@dschmidt

Copy link
Copy Markdown
Contributor

This is what my agent says:

Overview: the direction is right and the architecture is sound — one hardened
SAX flattener (XmpSaxFlattener -> (uri, path, value, lang) leaves) feeding a
single (uri, localName) property table in XmpExtractor, replacing the three
DOM-based paths. XXE is handled correctly (XMLReaderUtils.parseSAX +
OfflineContentHandler, verified), the five PDF custom schemas are migrated
1:1 (verified against the table), docinfo-vs-XMP precedence is preserved,
the URI-keyed Google Camera mapping is prefix-proof without xmpcore's global
schema registry, and HEIF/WebP XMP are genuinely new capabilities. But there
are six correctness findings, two of which are crash regressions.

BUGS
1. Bare rdf:li outside Bag/Seq/Alt -> NPE (XmpSaxFlattener:121, liCount.peek()
   on an empty deque). PDMetadataExtractor catches only IO/SAX/TikaException,
   IDMLParser now propagates where the deleted code swallowed Throwable, so
   malformed XMP kills the whole PDF/IDML parse. The old paths recorded and
   continued.
2. rdf:Alt handling: for single-valued properties the LAST alternative wins
   (emit() does set per li), where the old path effectively kept x-default;
   for multi-valued ones (dc:title is a text bag) every translation
   accumulates as a separate value. testLanguageVariants pointedly never
   asserts TITLE after a multi-lang alt.
3. The property table lookup is nesting-blind: xmpMM:InstanceID inside
   xmpMM:Pantry/Ingredients (standard InDesign/Illustrator output) overwrites
   the document-level xmpMM:InstanceID.
4. The rdf:value + qualifier pattern drops the value: the qualifier bumps
   childCount, so the parent is no longer treated as a leaf.
5. xmpMM:History parallel arrays lose alignment: absent action/when/
   softwareAgent used to be padded with "", now they are skipped, so
   HISTORY_ACTION[i] no longer matches HISTORY_WHEN[i].
6. WebP: a foreign RIFF chunk > 64MB before the XMP chunk aborts the whole
   scan (ImageXmp.readRiffChunk returns null) instead of skipping it; the cap
   should only gate the target chunk's allocation.

RISKS
- The MAX_EVENT_HISTORY_IN_XMPMM=1024 cap did not survive the rewrite and the
  flattener's leaf list is unbounded — a hostile packet inflates metadata
  without limit.
- xmp-raw:* keys are built from the document's *prefix*, not the namespace
  URI: the same property yields different keys across files and two
  namespaces bound to one prefix collide.
- Extended XMP: no MD5 verification of the reassembled packet against the
  GUID (XMP Part 3 defines the GUID as that digest); missing chunks leave
  zero-filled gaps; the HasExtendedXMP regex misses multi-space/newline
  attribute forms; a second standard packet keeps an extended buffer paired
  to the previous GUID.
- HEIF: scanAndExtract takes the first <?xpacket anywhere in the stream, so
  an embedded resource's XMP (e.g. a motion-photo video inside mdat) can be
  attributed to the image.
- Is dropping xmp:About intentional? rdf:about is skipped with the other RDF
  attributes and nothing replaces the old mapping.

NITS
- xmp-commons now pulls in full pdfbox (plus fontbox transitively) solely for
  DateConverter, which lands on standalone image/IDML consumers.
- jempbox.version in tika-parent and the jempbox|/xmpbox| Embed-Dependency
  patterns in tika-bundle-standard are dead after this PR.

Nice touches worth keeping: date normalization is TZ-deterministic (verified
empirically under two machine TZs), the JPEG double-parse is cleanly killed
via JPEG_READERS_NO_XMP, and the iloc/box walks in HeifXmp are properly
bounds-checked with sane caps.

Test gaps mirroring the bugs: no coverage for malformed bare rdf:li, the
nested-struct collision, rdf:value qualifiers, history alignment, caps,
Alt-winner on the canonical key, multiple rdf:RDF blocks, or equality
assertions for partial dates (YYYY / YYYY-MM currently only assertNotNull —
they inflate to fabricated full timestamps).

@dschmidt

Copy link
Copy Markdown
Contributor

This gets us Samsung/HEIC Support for Motion Photos afaict, quite nice!

@tballison

tballison commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Doing some more work on this. Then I'll have my agent fix the above. Thank you!

For a follow up ticket...

Any recommendations/preferences on dealing with fractions vs decimals in image metadata? Should we normalize or just keep as strings to reflect how the data is stored in exif vs xmp?

│ Tika key │ from XMP (verbatim │ from binary EXIF (metadata-extractor │
│ │ fraction) │ Rational.doubleValue()) │
├───────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
│ exif:ExposureTime │ 10/600 │ 0.0166… (e.g. 1.0E-6 for 1/1000000) │
│ (TIFF.EXPOSURE_TIME) │ │ │
├───────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
│ exif:FNumber (TIFF.F_NUMBER) │ 32/10 │ 3.2 │
├───────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
│ exif:FocalLength (TIFF.FOCAL_LENGTH) │ 249/10 │ 4.6 │
├───────────────────────────────────────┼──────────────────────┼─────────────────────────────────────────────┤
│ tiff:XResolution │ 720000/10000 │ 300.0 │
│ (TIFF.RESOLUTION_HORIZONTAL) │ │ │
└───────────────────────────────────────┴──────────────────────┴─────────────────────────────────────────────┘

Agent summarizes the conflict:

Verified empirically — and yes, definitively decimals for the binary-EXIF path:

  property type of EXPOSURE_TIME: RATIONAL
  exif:ExposureTime  stored value = 1.0E-6
  exif:FNumber       stored value = 2.8
  exif:FocalLength   stored value = 4.6
  tiff:XResolution   stored value = 300.0
  tiff:YResolution   stored value = 300.0

  So to answer directly: yes, they're stored as decimal-form strings. Two things worth pinning down:

  1. Everything in Tika metadata is a string (Map<String,String[]>). "Decimal" vs "fraction" is about which string
  gets written, not a numeric type.
  2. The property is typed RATIONAL, but the stored value is a decimal — because ImageMetadataExtractor calls
  Rational.doubleValue() and stores that ("300.0", "1.0E-6"). So the internalRational type is effectively
  advisory/misleading here; the on-disk reality is a decimal string.

  Meanwhile the XMP path writes the fraction verbatim — 720000/10000, 10/600 — with no conversion. So the same key
  (tiff:XResolution) reads 300.0 from binary EXIF but 720000/10000 from XMP. That's the inconsistency, now proven
  from the consumer side rather than my reading of a test.

  The implication for the deferred #4 fix: I'd key the normalizer on getValueType() == RATIONAL and convert the XMP
  fraction with the same doubleValue() math (720000/10000 → 300.0), so a promoted XMP value lands identically to
  the binary one. Good that you asked — the surprise is that a "RATIONAL" property has been holding decimals all
  along, which is exactly what the normalizer needs to match.

@tballison

Copy link
Copy Markdown
Contributor Author

On the point about pdfbox core in the xmp module, I struggled with the right option. I did a bakeoff with 15k xmps, and pdfbox's date parser won over jempbox. We could copy/paste the date utils from pdfbox?

Agent summarizes:

The observation is correct, but this was a
  deliberate, discussed decision — pdfbox's DateConverter beat jempbox 99.8% vs 99.2% (and 224 wrong) in a 15k-date
  bake-off, and you explicitly chose "just add pdfbox-core." If we want to trim it later, extracting only
  DateConverter is an option, but it's an accepted tradeoff, not a bug

@THausherr any recommendations? The issue is that we're adding a dependency on pdfbox to xmp-commons only for the date parser. It basically doesn't matter for tika-app or tika-server or grpc because they all bring in tika-parsers-standard which already has pdfbox. The only effect it has is on users who want to subselect a few of the standard parser modules that don't include pdf parsing. I'd guess this user base is tiny, but I don't know.

@tballison tballison marked this pull request as ready for review July 13, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants