Metadata: Fix XMP body serial and add tiff: field fallbacks #2260

Read the standard exifEX:BodySerialNumber property, which ExifTool and
Adobe emit; the previous exifEX:SerialNumber matched no compliant XMP
sidecar and silently fell through to aux:SerialNumber.

Add deprecated TIFF-schema fallbacks still emitted by some writers:
tiff:Software (Software), tiff:ImageDescription (Description), and
tiff:Copyright (Copyright).
This commit is contained in:
Michael Mayer 2026-07-07 11:44:33 +00:00
parent b7e758c607
commit d33bca4f48
6 changed files with 127 additions and 32 deletions

View file

@ -23,20 +23,21 @@ After editing a fixture:
## Synthetic Fixture Index
| File | Purpose |
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| `software-only.xmp` | `xmp:CreatorTool` (Software). Single-tag fixture for the simplest accessor. |
| `gps-time-combined.xmp` | `exif:GPSTimeStamp` as a single combined ISO 8601 / RFC 3339 datetime — the spec-canonical encoding for `TakenGps`. |
| `gps-time-split.xmp` | Legacy split form: `exif:GPSDateStamp` + `exif:GPSTimeStamp`. Some older writers emit this; secondary `TakenGps` fallback. |
| `time-offsets-subsec.xmp` | `exif:OffsetTimeOriginal/OffsetTime/OffsetTimeDigitized` cascade plus `exif:SubSecTimeOriginal` joined into `TakenAt`. |
| `notes-usercomment.xmp` | `exif:UserComment` as `lang-alt` (`<rdf:Alt>` with `x-default` + `en` + `de`). Confirms reader prefers `x-default`. |
| `xmpdm-creationdate.xmp` | `xmpDM:CreationDate` — secondary fallback for `CreatedAt` (rare; emitted by Adobe Premiere/After Effects). |
| `subject-seq.xmp` | `dc:subject` as `<rdf:Seq>` instead of `<rdf:Bag>`. Confirms reader handles both list types. |
| `aux-only.xmp` | Adobe `aux:` namespace — `OwnerName`, `Lens`, `LensID`, `LensSerialNumber`, `SerialNumber`, `Firmware`. Legacy Lightroom/ACR. |
| `exifex-camera-lens.xmp` | EXIF 2.3 for XMP `exifEX:` namespace — `LensMake`, `LensModel`, `SerialNumber` (= EXIF BodySerialNumber), `PhotographicSensitivity`, etc. |
| `gpano-360.xmp` | Google Photo Sphere — `GPano:ProjectionType = equirectangular` plus required dimension fields. |
| `multi-rdf-description.xmp` | Bug 2 demonstration: four sibling `<rdf:Description>` blocks each declaring a different namespace. Tests that XPath walks all blocks. |
| `alt-edge-cases.xmp` | `<rdf:Alt>` shapes that real writers don't always produce: no `x-default` (only `de`/`en`), missing `xml:lang`, duplicate `x-default`. |
| File | Purpose |
|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
| `software-only.xmp` | `xmp:CreatorTool` (Software). Single-tag fixture for the simplest accessor. |
| `gps-time-combined.xmp` | `exif:GPSTimeStamp` as a single combined ISO 8601 / RFC 3339 datetime — the spec-canonical encoding for `TakenGps`. |
| `gps-time-split.xmp` | Legacy split form: `exif:GPSDateStamp` + `exif:GPSTimeStamp`. Some older writers emit this; secondary `TakenGps` fallback. |
| `time-offsets-subsec.xmp` | `exif:OffsetTimeOriginal/OffsetTime/OffsetTimeDigitized` cascade plus `exif:SubSecTimeOriginal` joined into `TakenAt`. |
| `notes-usercomment.xmp` | `exif:UserComment` as `lang-alt` (`<rdf:Alt>` with `x-default` + `en` + `de`). Confirms reader prefers `x-default`. |
| `xmpdm-creationdate.xmp` | `xmpDM:CreationDate` — secondary fallback for `CreatedAt` (rare; emitted by Adobe Premiere/After Effects). |
| `subject-seq.xmp` | `dc:subject` as `<rdf:Seq>` instead of `<rdf:Bag>`. Confirms reader handles both list types. |
| `aux-only.xmp` | Adobe `aux:` namespace — `OwnerName`, `Lens`, `LensID`, `LensSerialNumber`, `SerialNumber`, `Firmware`. Legacy Lightroom/ACR. |
| `exifex-camera-lens.xmp` | EXIF 2.3 for XMP `exifEX:` namespace — `LensMake`, `LensModel`, `BodySerialNumber`, `PhotographicSensitivity`, etc. |
| `tiff-fields.xmp` | Deprecated TIFF-schema fallbacks — `tiff:Software`, `tiff:ImageDescription` (lang-alt), `tiff:Copyright` (lang-alt). |
| `gpano-360.xmp` | Google Photo Sphere — `GPano:ProjectionType = equirectangular` plus required dimension fields. |
| `multi-rdf-description.xmp` | Bug 2 demonstration: four sibling `<rdf:Description>` blocks each declaring a different namespace. Tests that XPath walks all blocks. |
| `alt-edge-cases.xmp` | `<rdf:Alt>` shapes that real writers don't always produce: no `x-default` (only `de`/`en`), missing `xml:lang`, duplicate `x-default`. |
## Source Notes

View file

@ -9,7 +9,7 @@
<exifEX:LensMake>SyntheticLens Co.</exifEX:LensMake>
<exifEX:LensModel>SL 50mm f/1.4</exifEX:LensModel>
<exifEX:LensSerialNumber>SL-50-987654</exifEX:LensSerialNumber>
<exifEX:SerialNumber>SC1-BODY-123456</exifEX:SerialNumber>
<exifEX:BodySerialNumber>SC1-BODY-123456</exifEX:BodySerialNumber>
<exifEX:PhotographicSensitivity>800</exifEX:PhotographicSensitivity>
<exifEX:SensitivityType>2</exifEX:SensitivityType>
<exifEX:RecommendedExposureIndex>800</exifEX:RecommendedExposureIndex>

View file

@ -0,0 +1,27 @@
<?xml version='1.0' encoding='UTF-8'?>
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
<rdf:Description rdf:about='internal/meta/testdata/xmp/synthetic/tiff-fields.xmp'
xmlns:et='http://ns.exiftool.org/1.0/' et:toolkit='Image::ExifTool 13.50'
xmlns:ExifTool='http://ns.exiftool.org/ExifTool/1.0/'
xmlns:System='http://ns.exiftool.org/File/System/1.0/'
xmlns:File='http://ns.exiftool.org/File/1.0/'
xmlns:XMP-x='http://ns.exiftool.org/XMP/XMP-x/1.0/'
xmlns:XMP-tiff='http://ns.exiftool.org/XMP/XMP-tiff/1.0/'>
<ExifTool:ExifToolVersion>13.50</ExifTool:ExifToolVersion>
<System:FileName>tiff-fields.xmp</System:FileName>
<System:Directory>internal/meta/testdata/xmp/synthetic</System:Directory>
<System:FileSize>651 bytes</System:FileSize>
<System:FileModifyDate>2026:07:07 10:13:16+00:00</System:FileModifyDate>
<System:FileAccessDate>2026:07:07 10:13:16+00:00</System:FileAccessDate>
<System:FileInodeChangeDate>2026:07:07 10:13:16+00:00</System:FileInodeChangeDate>
<System:FilePermissions>-rw-r--r--</System:FilePermissions>
<File:FileType>XMP</File:FileType>
<File:FileTypeExtension>xmp</File:FileTypeExtension>
<File:MIMEType>application/rdf+xml</File:MIMEType>
<XMP-x:XMPToolkit>PhotoPrism Synthetic Fixture</XMP-x:XMPToolkit>
<XMP-tiff:Software>TiffSoftware 1.0</XMP-tiff:Software>
<XMP-tiff:ImageDescription>A tiff image description</XMP-tiff:ImageDescription>
<XMP-tiff:Copyright>Copyright 2026 Example Org</XMP-tiff:Copyright>
</rdf:Description>
</rdf:RDF>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="PhotoPrism Synthetic Fixture">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:tiff="http://ns.adobe.com/tiff/1.0/">
<tiff:Software>TiffSoftware 1.0</tiff:Software>
<tiff:ImageDescription>
<rdf:Alt>
<rdf:li xml:lang="x-default">A tiff image description</rdf:li>
</rdf:Alt>
</tiff:ImageDescription>
<tiff:Copyright>
<rdf:Alt>
<rdf:li xml:lang="x-default">Copyright 2026 Example Org</rdf:li>
</rdf:Alt>
</tiff:Copyright>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View file

@ -120,20 +120,29 @@ var (
mustCompile("//photoshop:Headline"),
}
// xmpDescriptionChain: dc:description Alt-language fallback.
// xmpDescriptionChain: dc:description → tiff:ImageDescription, each with
// the lang-alt fallback. tiff:ImageDescription is the deprecated TIFF-schema
// equivalent still emitted by some writers.
xmpDescriptionChain = chainXPath{
mustCompile("//dc:description/rdf:Alt/rdf:li[@xml:lang='x-default']"),
mustCompile("(//dc:description/rdf:Alt/rdf:li)[1]"),
mustCompile("//dc:description[not(rdf:Alt)]"),
mustCompile("//tiff:ImageDescription/rdf:Alt/rdf:li[@xml:lang='x-default']"),
mustCompile("(//tiff:ImageDescription/rdf:Alt/rdf:li)[1]"),
mustCompile("//tiff:ImageDescription[not(rdf:Alt)]"),
}
// xmpRightsChain: dc:rights Alt-language → xmpRights:WebStatement.
// WebStatement is a URL, not free text, but the embedded path uses
// the same fallback.
// xmpRightsChain: dc:rights Alt-language → tiff:Copyright Alt-language →
// xmpRights:WebStatement. tiff:Copyright is the deprecated TIFF-schema
// copyright text; WebStatement is a URL, not free text, but the embedded
// path uses the same fallback.
xmpRightsChain = chainXPath{
mustCompile("//dc:rights/rdf:Alt/rdf:li[@xml:lang='x-default']"),
mustCompile("(//dc:rights/rdf:Alt/rdf:li)[1]"),
mustCompile("//dc:rights[not(rdf:Alt)]"),
mustCompile("//tiff:Copyright/rdf:Alt/rdf:li[@xml:lang='x-default']"),
mustCompile("(//tiff:Copyright/rdf:Alt/rdf:li)[1]"),
mustCompile("//tiff:Copyright[not(rdf:Alt)]"),
mustCompile("//xmpRights:WebStatement"),
}
@ -144,8 +153,12 @@ var (
mustCompile("//xmpRights:UsageTerms[not(rdf:Alt)]"),
}
// xmpSoftwareChain: xmp:CreatorTool.
xmpSoftwareChain = chainXPath{elemOrAttr("xmp:CreatorTool")}
// xmpSoftwareChain: xmp:CreatorTool → tiff:Software (deprecated TIFF-schema
// equivalent still emitted by some writers).
xmpSoftwareChain = chainXPath{
elemOrAttr("xmp:CreatorTool"),
elemOrAttr("tiff:Software"),
}
// xmpDocumentIDChain: xmpMM:OriginalDocumentID (asset-stable) →
// xmpMM:DocumentID (per-derivative) → dc:identifier (legacy).
@ -203,10 +216,10 @@ var (
elemOrAttr("xmpDM:CreationDate"),
}
// xmpCameraSerialChain: exifEX:SerialNumber (= EXIF
// BodySerialNumber) → aux:SerialNumber.
// xmpCameraSerialChain: exifEX:BodySerialNumber (EXIF 0xA431, the property
// ExifTool and Adobe emit) → aux:SerialNumber (legacy Adobe).
xmpCameraSerialChain = chainXPath{
elemOrAttr("exifEX:SerialNumber"),
elemOrAttr("exifEX:BodySerialNumber"),
elemOrAttr("aux:SerialNumber"),
}
@ -366,13 +379,13 @@ func (doc *XmpDocument) Title() string {
}
// Description returns the caption / image description.
// Priority: dc:description (Alt/x-default → first rdf:Alt entry → bare text).
// Priority: dc:description (Alt/x-default → first rdf:Alt entry → bare text) → tiff:ImageDescription.
func (doc *XmpDocument) Description() string {
return SanitizeCaption(xmpDescriptionChain.firstNonEmpty(doc.doc))
}
// Copyright returns the rights statement.
// Priority: dc:rights (Alt/x-default → first rdf:Alt entry → bare text) → xmpRights:WebStatement.
// Priority: dc:rights (Alt/x-default → first rdf:Alt entry → bare text) → tiff:Copyright → xmpRights:WebStatement.
// WebStatement is a URL approximation of the rights text but matches the embedded path's behavior.
func (doc *XmpDocument) Copyright() string {
return SanitizeString(xmpRightsChain.firstNonEmpty(doc.doc))
@ -456,7 +469,7 @@ func (doc *XmpDocument) TimeOffset() string {
}
// CameraSerial returns the camera body serial number.
// Priority: exifEX:SerialNumber (= EXIF BodySerialNumber 0xA431) →
// Priority: exifEX:BodySerialNumber (EXIF 0xA431) →
// aux:SerialNumber (legacy Adobe, still emitted by Lightroom).
func (doc *XmpDocument) CameraSerial() string {
return SanitizeString(xmpCameraSerialChain.firstNonEmpty(doc.doc))
@ -597,7 +610,7 @@ func (doc *XmpDocument) License() string {
}
// Software returns the application that wrote the file.
// Priority: xmp:CreatorTool (single-link).
// Priority: xmp:CreatorTool → tiff:Software.
func (doc *XmpDocument) Software() string {
return SanitizeString(xmpSoftwareChain.firstNonEmpty(doc.doc))
}

View file

@ -363,12 +363,41 @@ func TestXmpDocument_Software(t *testing.T) {
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
assert.Equal(t, "PhotoPrism Synthetic Fixture 1.0.0", doc.Software())
})
t.Run("FallsBackToTiffSoftware", func(t *testing.T) {
// tiff-fields.xmp carries only tiff:Software (no xmp:CreatorTool).
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
assert.Equal(t, "TiffSoftware 1.0", doc.Software())
})
t.Run("EmptyWhenAbsent", func(t *testing.T) {
doc := loadXmp(t, "testdata/fstop-favorite.xmp")
assert.Equal(t, "", doc.Software())
})
}
func TestXmpDocument_Description(t *testing.T) {
t.Run("FallsBackToTiffImageDescription", func(t *testing.T) {
// tiff-fields.xmp carries only tiff:ImageDescription (no dc:description).
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
assert.Equal(t, "A tiff image description", doc.Description())
})
t.Run("EmptyWhenAbsent", func(t *testing.T) {
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
assert.Equal(t, "", doc.Description())
})
}
func TestXmpDocument_Copyright(t *testing.T) {
t.Run("FallsBackToTiffCopyright", func(t *testing.T) {
// tiff-fields.xmp carries only tiff:Copyright (no dc:rights).
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
assert.Equal(t, "Copyright 2026 Example Org", doc.Copyright())
})
t.Run("EmptyWhenAbsent", func(t *testing.T) {
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
assert.Equal(t, "", doc.Copyright())
})
}
func TestXmpDocument_DocumentID(t *testing.T) {
t.Run("DigikamPrefersOriginalDocumentID", func(t *testing.T) {
// digiKam writes all three IDs; the chain must pick
@ -592,14 +621,20 @@ func TestApexToSeconds(t *testing.T) {
}
func TestXmpDocument_CameraSerial(t *testing.T) {
t.Run("PrefersExifEXSerialNumber", func(t *testing.T) {
// Synthetic exifEX fixture writes both exifEX:SerialNumber and
// (no aux:SerialNumber). Modern fallback wins.
t.Run("PrefersExifEXBodySerialNumber", func(t *testing.T) {
// Synthetic exifEX fixture writes exifEX:BodySerialNumber and no
// aux:SerialNumber. Modern property wins.
doc := loadXmp(t, "testdata/xmp/synthetic/exifex-camera-lens.xmp")
assert.Equal(t, "SC1-BODY-123456", doc.CameraSerial())
})
t.Run("ReadsRealExifToolBodySerialNumber", func(t *testing.T) {
// canon_eos_6d.xmp is an ExifTool-written sidecar carrying the
// standard exifEX:BodySerialNumber property.
doc := loadXmp(t, "testdata/canon_eos_6d.xmp")
assert.Equal(t, "033024001432", doc.CameraSerial())
})
t.Run("FallsBackToAuxSerialNumber", func(t *testing.T) {
// canon_eos_6d has aux:SerialNumber but no exifEX:SerialNumber.
// aux-only.xmp has aux:SerialNumber but no exifEX:BodySerialNumber.
doc := loadXmp(t, "testdata/xmp/synthetic/aux-only.xmp")
assert.Equal(t, "BODY-SN-123456", doc.CameraSerial())
})