diff --git a/internal/meta/testdata/xmp/README.md b/internal/meta/testdata/xmp/README.md index 01f15a03a..d48911fe6 100644 --- a/internal/meta/testdata/xmp/README.md +++ b/internal/meta/testdata/xmp/README.md @@ -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` (`` 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 `` instead of ``. 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 `` blocks each declaring a different namespace. Tests that XPath walks all blocks. | -| `alt-edge-cases.xmp` | `` 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` (`` 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 `` instead of ``. 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 `` blocks each declaring a different namespace. Tests that XPath walks all blocks. | +| `alt-edge-cases.xmp` | `` shapes that real writers don't always produce: no `x-default` (only `de`/`en`), missing `xml:lang`, duplicate `x-default`. | ## Source Notes diff --git a/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp index cbfbcb9a9..190bdcc53 100644 --- a/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp +++ b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp @@ -9,7 +9,7 @@ SyntheticLens Co. SL 50mm f/1.4 SL-50-987654 - SC1-BODY-123456 + SC1-BODY-123456 800 2 800 diff --git a/internal/meta/testdata/xmp/synthetic/tiff-fields.exiftool.txt b/internal/meta/testdata/xmp/synthetic/tiff-fields.exiftool.txt new file mode 100644 index 000000000..573f88df7 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/tiff-fields.exiftool.txt @@ -0,0 +1,27 @@ + + + + + 13.50 + tiff-fields.xmp + internal/meta/testdata/xmp/synthetic + 651 bytes + 2026:07:07 10:13:16+00:00 + 2026:07:07 10:13:16+00:00 + 2026:07:07 10:13:16+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + TiffSoftware 1.0 + A tiff image description + Copyright 2026 Example Org + + diff --git a/internal/meta/testdata/xmp/synthetic/tiff-fields.xmp b/internal/meta/testdata/xmp/synthetic/tiff-fields.xmp new file mode 100644 index 000000000..69ada0e9a --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/tiff-fields.xmp @@ -0,0 +1,19 @@ + + + + + TiffSoftware 1.0 + + + A tiff image description + + + + + Copyright 2026 Example Org + + + + + diff --git a/internal/meta/xmp_document.go b/internal/meta/xmp_document.go index 6e96e5be6..85e61f3af 100644 --- a/internal/meta/xmp_document.go +++ b/internal/meta/xmp_document.go @@ -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)) } diff --git a/internal/meta/xmp_document_test.go b/internal/meta/xmp_document_test.go index ff59daca9..98c733e97 100644 --- a/internal/meta/xmp_document_test.go +++ b/internal/meta/xmp_document_test.go @@ -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()) })