Skip to content

Commit d33bca4

Browse files
committed
Metadata: Fix XMP body serial and add tiff: field fallbacks photoprism#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).
1 parent b7e758c commit d33bca4

6 files changed

Lines changed: 127 additions & 32 deletions

File tree

internal/meta/testdata/xmp/README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,21 @@ After editing a fixture:
2323

2424
## Synthetic Fixture Index
2525

26-
| File | Purpose |
27-
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
28-
| `software-only.xmp` | `xmp:CreatorTool` (Software). Single-tag fixture for the simplest accessor. |
29-
| `gps-time-combined.xmp` | `exif:GPSTimeStamp` as a single combined ISO 8601 / RFC 3339 datetime — the spec-canonical encoding for `TakenGps`. |
30-
| `gps-time-split.xmp` | Legacy split form: `exif:GPSDateStamp` + `exif:GPSTimeStamp`. Some older writers emit this; secondary `TakenGps` fallback. |
31-
| `time-offsets-subsec.xmp` | `exif:OffsetTimeOriginal/OffsetTime/OffsetTimeDigitized` cascade plus `exif:SubSecTimeOriginal` joined into `TakenAt`. |
32-
| `notes-usercomment.xmp` | `exif:UserComment` as `lang-alt` (`<rdf:Alt>` with `x-default` + `en` + `de`). Confirms reader prefers `x-default`. |
33-
| `xmpdm-creationdate.xmp` | `xmpDM:CreationDate` — secondary fallback for `CreatedAt` (rare; emitted by Adobe Premiere/After Effects). |
34-
| `subject-seq.xmp` | `dc:subject` as `<rdf:Seq>` instead of `<rdf:Bag>`. Confirms reader handles both list types. |
35-
| `aux-only.xmp` | Adobe `aux:` namespace — `OwnerName`, `Lens`, `LensID`, `LensSerialNumber`, `SerialNumber`, `Firmware`. Legacy Lightroom/ACR. |
36-
| `exifex-camera-lens.xmp` | EXIF 2.3 for XMP `exifEX:` namespace — `LensMake`, `LensModel`, `SerialNumber` (= EXIF BodySerialNumber), `PhotographicSensitivity`, etc. |
37-
| `gpano-360.xmp` | Google Photo Sphere — `GPano:ProjectionType = equirectangular` plus required dimension fields. |
38-
| `multi-rdf-description.xmp` | Bug 2 demonstration: four sibling `<rdf:Description>` blocks each declaring a different namespace. Tests that XPath walks all blocks. |
39-
| `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`. |
26+
| File | Purpose |
27+
|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
28+
| `software-only.xmp` | `xmp:CreatorTool` (Software). Single-tag fixture for the simplest accessor. |
29+
| `gps-time-combined.xmp` | `exif:GPSTimeStamp` as a single combined ISO 8601 / RFC 3339 datetime — the spec-canonical encoding for `TakenGps`. |
30+
| `gps-time-split.xmp` | Legacy split form: `exif:GPSDateStamp` + `exif:GPSTimeStamp`. Some older writers emit this; secondary `TakenGps` fallback. |
31+
| `time-offsets-subsec.xmp` | `exif:OffsetTimeOriginal/OffsetTime/OffsetTimeDigitized` cascade plus `exif:SubSecTimeOriginal` joined into `TakenAt`. |
32+
| `notes-usercomment.xmp` | `exif:UserComment` as `lang-alt` (`<rdf:Alt>` with `x-default` + `en` + `de`). Confirms reader prefers `x-default`. |
33+
| `xmpdm-creationdate.xmp` | `xmpDM:CreationDate` — secondary fallback for `CreatedAt` (rare; emitted by Adobe Premiere/After Effects). |
34+
| `subject-seq.xmp` | `dc:subject` as `<rdf:Seq>` instead of `<rdf:Bag>`. Confirms reader handles both list types. |
35+
| `aux-only.xmp` | Adobe `aux:` namespace — `OwnerName`, `Lens`, `LensID`, `LensSerialNumber`, `SerialNumber`, `Firmware`. Legacy Lightroom/ACR. |
36+
| `exifex-camera-lens.xmp` | EXIF 2.3 for XMP `exifEX:` namespace — `LensMake`, `LensModel`, `BodySerialNumber`, `PhotographicSensitivity`, etc. |
37+
| `tiff-fields.xmp` | Deprecated TIFF-schema fallbacks — `tiff:Software`, `tiff:ImageDescription` (lang-alt), `tiff:Copyright` (lang-alt). |
38+
| `gpano-360.xmp` | Google Photo Sphere — `GPano:ProjectionType = equirectangular` plus required dimension fields. |
39+
| `multi-rdf-description.xmp` | Bug 2 demonstration: four sibling `<rdf:Description>` blocks each declaring a different namespace. Tests that XPath walks all blocks. |
40+
| `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`. |
4041

4142
## Source Notes
4243

internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<exifEX:LensMake>SyntheticLens Co.</exifEX:LensMake>
1010
<exifEX:LensModel>SL 50mm f/1.4</exifEX:LensModel>
1111
<exifEX:LensSerialNumber>SL-50-987654</exifEX:LensSerialNumber>
12-
<exifEX:SerialNumber>SC1-BODY-123456</exifEX:SerialNumber>
12+
<exifEX:BodySerialNumber>SC1-BODY-123456</exifEX:BodySerialNumber>
1313
<exifEX:PhotographicSensitivity>800</exifEX:PhotographicSensitivity>
1414
<exifEX:SensitivityType>2</exifEX:SensitivityType>
1515
<exifEX:RecommendedExposureIndex>800</exifEX:RecommendedExposureIndex>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
3+
4+
<rdf:Description rdf:about='internal/meta/testdata/xmp/synthetic/tiff-fields.xmp'
5+
xmlns:et='http://ns.exiftool.org/1.0/' et:toolkit='Image::ExifTool 13.50'
6+
xmlns:ExifTool='http://ns.exiftool.org/ExifTool/1.0/'
7+
xmlns:System='http://ns.exiftool.org/File/System/1.0/'
8+
xmlns:File='http://ns.exiftool.org/File/1.0/'
9+
xmlns:XMP-x='http://ns.exiftool.org/XMP/XMP-x/1.0/'
10+
xmlns:XMP-tiff='http://ns.exiftool.org/XMP/XMP-tiff/1.0/'>
11+
<ExifTool:ExifToolVersion>13.50</ExifTool:ExifToolVersion>
12+
<System:FileName>tiff-fields.xmp</System:FileName>
13+
<System:Directory>internal/meta/testdata/xmp/synthetic</System:Directory>
14+
<System:FileSize>651 bytes</System:FileSize>
15+
<System:FileModifyDate>2026:07:07 10:13:16+00:00</System:FileModifyDate>
16+
<System:FileAccessDate>2026:07:07 10:13:16+00:00</System:FileAccessDate>
17+
<System:FileInodeChangeDate>2026:07:07 10:13:16+00:00</System:FileInodeChangeDate>
18+
<System:FilePermissions>-rw-r--r--</System:FilePermissions>
19+
<File:FileType>XMP</File:FileType>
20+
<File:FileTypeExtension>xmp</File:FileTypeExtension>
21+
<File:MIMEType>application/rdf+xml</File:MIMEType>
22+
<XMP-x:XMPToolkit>PhotoPrism Synthetic Fixture</XMP-x:XMPToolkit>
23+
<XMP-tiff:Software>TiffSoftware 1.0</XMP-tiff:Software>
24+
<XMP-tiff:ImageDescription>A tiff image description</XMP-tiff:ImageDescription>
25+
<XMP-tiff:Copyright>Copyright 2026 Example Org</XMP-tiff:Copyright>
26+
</rdf:Description>
27+
</rdf:RDF>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="PhotoPrism Synthetic Fixture">
3+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
4+
<rdf:Description rdf:about=""
5+
xmlns:tiff="http://ns.adobe.com/tiff/1.0/">
6+
<tiff:Software>TiffSoftware 1.0</tiff:Software>
7+
<tiff:ImageDescription>
8+
<rdf:Alt>
9+
<rdf:li xml:lang="x-default">A tiff image description</rdf:li>
10+
</rdf:Alt>
11+
</tiff:ImageDescription>
12+
<tiff:Copyright>
13+
<rdf:Alt>
14+
<rdf:li xml:lang="x-default">Copyright 2026 Example Org</rdf:li>
15+
</rdf:Alt>
16+
</tiff:Copyright>
17+
</rdf:Description>
18+
</rdf:RDF>
19+
</x:xmpmeta>

internal/meta/xmp_document.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,20 +120,29 @@ var (
120120
mustCompile("//photoshop:Headline"),
121121
}
122122

123-
// xmpDescriptionChain: dc:description Alt-language fallback.
123+
// xmpDescriptionChain: dc:description → tiff:ImageDescription, each with
124+
// the lang-alt fallback. tiff:ImageDescription is the deprecated TIFF-schema
125+
// equivalent still emitted by some writers.
124126
xmpDescriptionChain = chainXPath{
125127
mustCompile("//dc:description/rdf:Alt/rdf:li[@xml:lang='x-default']"),
126128
mustCompile("(//dc:description/rdf:Alt/rdf:li)[1]"),
127129
mustCompile("//dc:description[not(rdf:Alt)]"),
130+
mustCompile("//tiff:ImageDescription/rdf:Alt/rdf:li[@xml:lang='x-default']"),
131+
mustCompile("(//tiff:ImageDescription/rdf:Alt/rdf:li)[1]"),
132+
mustCompile("//tiff:ImageDescription[not(rdf:Alt)]"),
128133
}
129134

130-
// xmpRightsChain: dc:rights Alt-language → xmpRights:WebStatement.
131-
// WebStatement is a URL, not free text, but the embedded path uses
132-
// the same fallback.
135+
// xmpRightsChain: dc:rights Alt-language → tiff:Copyright Alt-language →
136+
// xmpRights:WebStatement. tiff:Copyright is the deprecated TIFF-schema
137+
// copyright text; WebStatement is a URL, not free text, but the embedded
138+
// path uses the same fallback.
133139
xmpRightsChain = chainXPath{
134140
mustCompile("//dc:rights/rdf:Alt/rdf:li[@xml:lang='x-default']"),
135141
mustCompile("(//dc:rights/rdf:Alt/rdf:li)[1]"),
136142
mustCompile("//dc:rights[not(rdf:Alt)]"),
143+
mustCompile("//tiff:Copyright/rdf:Alt/rdf:li[@xml:lang='x-default']"),
144+
mustCompile("(//tiff:Copyright/rdf:Alt/rdf:li)[1]"),
145+
mustCompile("//tiff:Copyright[not(rdf:Alt)]"),
137146
mustCompile("//xmpRights:WebStatement"),
138147
}
139148

@@ -144,8 +153,12 @@ var (
144153
mustCompile("//xmpRights:UsageTerms[not(rdf:Alt)]"),
145154
}
146155

147-
// xmpSoftwareChain: xmp:CreatorTool.
148-
xmpSoftwareChain = chainXPath{elemOrAttr("xmp:CreatorTool")}
156+
// xmpSoftwareChain: xmp:CreatorTool → tiff:Software (deprecated TIFF-schema
157+
// equivalent still emitted by some writers).
158+
xmpSoftwareChain = chainXPath{
159+
elemOrAttr("xmp:CreatorTool"),
160+
elemOrAttr("tiff:Software"),
161+
}
149162

150163
// xmpDocumentIDChain: xmpMM:OriginalDocumentID (asset-stable) →
151164
// xmpMM:DocumentID (per-derivative) → dc:identifier (legacy).
@@ -203,10 +216,10 @@ var (
203216
elemOrAttr("xmpDM:CreationDate"),
204217
}
205218

206-
// xmpCameraSerialChain: exifEX:SerialNumber (= EXIF
207-
// BodySerialNumber) → aux:SerialNumber.
219+
// xmpCameraSerialChain: exifEX:BodySerialNumber (EXIF 0xA431, the property
220+
// ExifTool and Adobe emit) → aux:SerialNumber (legacy Adobe).
208221
xmpCameraSerialChain = chainXPath{
209-
elemOrAttr("exifEX:SerialNumber"),
222+
elemOrAttr("exifEX:BodySerialNumber"),
210223
elemOrAttr("aux:SerialNumber"),
211224
}
212225

@@ -366,13 +379,13 @@ func (doc *XmpDocument) Title() string {
366379
}
367380

368381
// Description returns the caption / image description.
369-
// Priority: dc:description (Alt/x-default → first rdf:Alt entry → bare text).
382+
// Priority: dc:description (Alt/x-default → first rdf:Alt entry → bare text) → tiff:ImageDescription.
370383
func (doc *XmpDocument) Description() string {
371384
return SanitizeCaption(xmpDescriptionChain.firstNonEmpty(doc.doc))
372385
}
373386

374387
// Copyright returns the rights statement.
375-
// Priority: dc:rights (Alt/x-default → first rdf:Alt entry → bare text) → xmpRights:WebStatement.
388+
// Priority: dc:rights (Alt/x-default → first rdf:Alt entry → bare text) → tiff:Copyright → xmpRights:WebStatement.
376389
// WebStatement is a URL approximation of the rights text but matches the embedded path's behavior.
377390
func (doc *XmpDocument) Copyright() string {
378391
return SanitizeString(xmpRightsChain.firstNonEmpty(doc.doc))
@@ -456,7 +469,7 @@ func (doc *XmpDocument) TimeOffset() string {
456469
}
457470

458471
// CameraSerial returns the camera body serial number.
459-
// Priority: exifEX:SerialNumber (= EXIF BodySerialNumber 0xA431) →
472+
// Priority: exifEX:BodySerialNumber (EXIF 0xA431) →
460473
// aux:SerialNumber (legacy Adobe, still emitted by Lightroom).
461474
func (doc *XmpDocument) CameraSerial() string {
462475
return SanitizeString(xmpCameraSerialChain.firstNonEmpty(doc.doc))
@@ -597,7 +610,7 @@ func (doc *XmpDocument) License() string {
597610
}
598611

599612
// Software returns the application that wrote the file.
600-
// Priority: xmp:CreatorTool (single-link).
613+
// Priority: xmp:CreatorTool → tiff:Software.
601614
func (doc *XmpDocument) Software() string {
602615
return SanitizeString(xmpSoftwareChain.firstNonEmpty(doc.doc))
603616
}

internal/meta/xmp_document_test.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,41 @@ func TestXmpDocument_Software(t *testing.T) {
363363
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
364364
assert.Equal(t, "PhotoPrism Synthetic Fixture 1.0.0", doc.Software())
365365
})
366+
t.Run("FallsBackToTiffSoftware", func(t *testing.T) {
367+
// tiff-fields.xmp carries only tiff:Software (no xmp:CreatorTool).
368+
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
369+
assert.Equal(t, "TiffSoftware 1.0", doc.Software())
370+
})
366371
t.Run("EmptyWhenAbsent", func(t *testing.T) {
367372
doc := loadXmp(t, "testdata/fstop-favorite.xmp")
368373
assert.Equal(t, "", doc.Software())
369374
})
370375
}
371376

377+
func TestXmpDocument_Description(t *testing.T) {
378+
t.Run("FallsBackToTiffImageDescription", func(t *testing.T) {
379+
// tiff-fields.xmp carries only tiff:ImageDescription (no dc:description).
380+
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
381+
assert.Equal(t, "A tiff image description", doc.Description())
382+
})
383+
t.Run("EmptyWhenAbsent", func(t *testing.T) {
384+
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
385+
assert.Equal(t, "", doc.Description())
386+
})
387+
}
388+
389+
func TestXmpDocument_Copyright(t *testing.T) {
390+
t.Run("FallsBackToTiffCopyright", func(t *testing.T) {
391+
// tiff-fields.xmp carries only tiff:Copyright (no dc:rights).
392+
doc := loadXmp(t, "testdata/xmp/synthetic/tiff-fields.xmp")
393+
assert.Equal(t, "Copyright 2026 Example Org", doc.Copyright())
394+
})
395+
t.Run("EmptyWhenAbsent", func(t *testing.T) {
396+
doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp")
397+
assert.Equal(t, "", doc.Copyright())
398+
})
399+
}
400+
372401
func TestXmpDocument_DocumentID(t *testing.T) {
373402
t.Run("DigikamPrefersOriginalDocumentID", func(t *testing.T) {
374403
// digiKam writes all three IDs; the chain must pick
@@ -592,14 +621,20 @@ func TestApexToSeconds(t *testing.T) {
592621
}
593622

594623
func TestXmpDocument_CameraSerial(t *testing.T) {
595-
t.Run("PrefersExifEXSerialNumber", func(t *testing.T) {
596-
// Synthetic exifEX fixture writes both exifEX:SerialNumber and
597-
// (no aux:SerialNumber). Modern fallback wins.
624+
t.Run("PrefersExifEXBodySerialNumber", func(t *testing.T) {
625+
// Synthetic exifEX fixture writes exifEX:BodySerialNumber and no
626+
// aux:SerialNumber. Modern property wins.
598627
doc := loadXmp(t, "testdata/xmp/synthetic/exifex-camera-lens.xmp")
599628
assert.Equal(t, "SC1-BODY-123456", doc.CameraSerial())
600629
})
630+
t.Run("ReadsRealExifToolBodySerialNumber", func(t *testing.T) {
631+
// canon_eos_6d.xmp is an ExifTool-written sidecar carrying the
632+
// standard exifEX:BodySerialNumber property.
633+
doc := loadXmp(t, "testdata/canon_eos_6d.xmp")
634+
assert.Equal(t, "033024001432", doc.CameraSerial())
635+
})
601636
t.Run("FallsBackToAuxSerialNumber", func(t *testing.T) {
602-
// canon_eos_6d has aux:SerialNumber but no exifEX:SerialNumber.
637+
// aux-only.xmp has aux:SerialNumber but no exifEX:BodySerialNumber.
603638
doc := loadXmp(t, "testdata/xmp/synthetic/aux-only.xmp")
604639
assert.Equal(t, "BODY-SN-123456", doc.CameraSerial())
605640
})

0 commit comments

Comments
 (0)