mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
b7e758c607
commit
d33bca4f48
6 changed files with 127 additions and 32 deletions
29
internal/meta/testdata/xmp/README.md
vendored
29
internal/meta/testdata/xmp/README.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
internal/meta/testdata/xmp/synthetic/tiff-fields.exiftool.txt
vendored
Normal file
27
internal/meta/testdata/xmp/synthetic/tiff-fields.exiftool.txt
vendored
Normal 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>
|
||||
19
internal/meta/testdata/xmp/synthetic/tiff-fields.xmp
vendored
Normal file
19
internal/meta/testdata/xmp/synthetic/tiff-fields.xmp
vendored
Normal 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>
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue