Metadata: Map XMP dc:subject to Subject field, not Keywords #2075 #2260

dc:subject is Adobe's "Keywords" panel, but PhotoPrism's Keywords,
Labels, and Subject fields serve distinct purposes. Route dc:subject to
the descriptive Details.Subject field only (matching the embedded/
ExifTool path, where data.Subject comes from the dc:subject-backed
Subject tag and data.Keywords from IPTC Keywords), never the Keywords
field. Multi-word entries keep their spaces; the value stays searchable
via IndexKeywords and still drives label matching.
This commit is contained in:
Michael Mayer 2026-06-30 10:12:45 +00:00
parent 38b50724f5
commit 6dc3ecbf25
7 changed files with 52 additions and 46 deletions

View file

@ -1,6 +1,6 @@
## PhotoPrism — Metadata Pipeline
**Last Updated:** May 6, 2026
**Last Updated:** June 30, 2026
### Overview
@ -37,6 +37,7 @@ The `.xmp` sidecar reader (`xmp.go` + `xmp_document.go`) is XPath-based on `antc
- **Element-or-attribute helper.** RDF/XML allows scalar properties to be expressed as either child elements or attributes on `rdf:Description`. The `elemOrAttr(qname)` helper builds a union XPath that matches both — required because digiKam emits `xmpMM:*`/`exif:*`/`tiff:*` as attributes while Adobe writes them as child elements.
- **Adding an accessor.** Declare a `chainXPath` at package init using `mustCompile` (or `elemOrAttr` for scalar fields), document the priority chain in a one-line comment, then add the accessor that calls `firstNonEmpty` (for scalars) or `queryAll` (for `rdf:Bag`/`rdf:Seq`). Wire the new field into `xmp.go` with the existing "set only when non-empty" pattern.
- **Source priority.** Sidecar values are tagged `SrcXmp` (priority 32), which outranks `SrcMeta` (priority 16) at the entity layer. Re-indexing a photo after the sidecar has been added overwrites previously-`SrcMeta` values without a database wipe.
- **Keywords vs. Subject.** `dc:subject` (Adobe's "Keywords" panel) maps to the descriptive `Details.Subject` field — never the `Details.Keywords` field — matching the embedded/ExifTool path where `data.Subject` comes from the `dc:subject`-backed `Subject` tag and `data.Keywords` from IPTC `Keywords`. The XMP path adds only the derived `flash`/`panorama`/`hdr` keywords. Hierarchical-label and face-region parsing (Labels, people Subjects) is a planned extension tracked under epic [#2260](https://github.com/photoprism/photoprism/issues/2260).
- **Coverage.** The fixture corpus under `testdata/xmp/{adobe,darktable,digikam,synthetic}/` documents the full set of supported tags and their per-fixture provenance.
### Motion Photos & Embedded Media

View file

@ -179,12 +179,11 @@ func (data *Data) XMP(fileName string) (err error) {
}
}
if v := doc.Keywords(); len(v) != 0 {
data.AddKeywords(v)
}
// Subject mirrors the ExifTool Subject cascade (dc:subject usually wins,
// so Subject normally equals the keyword list) so XMP-sidecar photos get
// the same details.Subject the embedded/ExifTool JSON path would produce.
// dc:subject populates the descriptive Subject field only, never the
// keyword list: dc:subject is Adobe's "Keywords" panel, but PhotoPrism's
// Keywords, Labels, and Subject fields serve distinct purposes. This
// matches the embedded/ExifTool path, where data.Subject comes from the
// dc:subject-backed Subject tag and data.Keywords from IPTC Keywords.
if v := doc.Subject(); v != "" {
data.Subject = SanitizeMeta(v)
}

View file

@ -264,11 +264,12 @@ var (
mustCompile("//exif:UserComment[not(rdf:Alt)]"),
}
// xmpKeywordsBag / xmpKeywordsSeq: dc:subject containers. Adobe,
// Darktable and digiKam emit Bag; Apple Photos and the previous
// reader emit Seq.
xmpKeywordsBag = mustCompile("//dc:subject/rdf:Bag/rdf:li")
xmpKeywordsSeq = mustCompile("//dc:subject/rdf:Seq/rdf:li")
// xmpSubjectBag / xmpSubjectSeq: dc:subject containers. dc:subject is
// Adobe's "Keywords" panel, but PhotoPrism maps it to the descriptive
// Details.Subject field, not the keyword list. Adobe, Darktable and
// digiKam emit Bag; Apple Photos and the previous reader emit Seq.
xmpSubjectBag = mustCompile("//dc:subject/rdf:Bag/rdf:li")
xmpSubjectSeq = mustCompile("//dc:subject/rdf:Seq/rdf:li")
// xmpPersonBag / xmpPersonSeq: Iptc4xmpExt:PersonInImage containers
// (names of people depicted) — a Subject cascade fallback.
@ -557,20 +558,18 @@ func (doc *XmpDocument) joinBagOrSeq(bag, seq *xpath.Expr) string {
return strings.Join(queryAll(doc.doc, seq), ", ")
}
// Keywords returns dc:subject entries joined with ", ".
// Priority: dc:subject/rdf:Bag (Adobe/Darktable/digiKam) → dc:subject/rdf:Seq
// (Apple, older writers). Bag wins when both are present.
func (doc *XmpDocument) Keywords() string {
return doc.joinBagOrSeq(xmpKeywordsBag, xmpKeywordsSeq)
}
// Subject returns descriptive subject text, mirroring the ExifTool Subject
// cascade so the XMP sidecar path fills meta.Data.Subject identically to the
// embedded/ExifTool JSON path. Priority: dc:subject (same source as Keywords,
// present in virtually all tagged files) → Iptc4xmpExt:PersonInImage →
// lr:hierarchicalSubject. The first non-empty container wins.
// Subject returns descriptive subject text for the Details.Subject field,
// matching the ExifTool Subject cascade so the XMP and embedded/ExifTool JSON
// paths fill meta.Data.Subject identically. Priority: dc:subject (Adobe's
// "Keywords" panel, present in virtually all tagged files) →
// Iptc4xmpExt:PersonInImage → lr:hierarchicalSubject. The first non-empty
// container wins, joined with ", "; entries keep their spaces.
//
// The PersonInImage and hierarchicalSubject fallbacks are interim sources for
// the free-text Subject field; advanced parsing will route them to dedicated
// meta.Data.Subjects (people) and meta.Data.Labels containers.
func (doc *XmpDocument) Subject() string {
if v := doc.Keywords(); v != "" {
if v := doc.joinBagOrSeq(xmpSubjectBag, xmpSubjectSeq); v != "" {
return v
}
if v := doc.joinBagOrSeq(xmpPersonBag, xmpPersonSeq); v != "" {

View file

@ -246,12 +246,12 @@ func TestXmpDocument_TitleAltLanguageFallback(t *testing.T) {
})
}
func TestXmpDocument_KeywordsBagAndSeq(t *testing.T) {
func TestXmpDocument_SubjectBagAndSeq(t *testing.T) {
t.Run("BagForm", func(t *testing.T) {
// digikam fixture writes dc:subject as <rdf:Bag>, which the old
// reader silently dropped. Keywords() must now read it.
// reader silently dropped. Subject() must now read it.
doc := loadXmp(t, "testdata/xmp/digikam/aurora.jpg.xmp")
got := doc.Keywords()
got := doc.Subject()
assert.Contains(t, got, "Nature")
assert.Contains(t, got, "Iceland")
assert.Contains(t, got, "Aurora")
@ -259,20 +259,19 @@ func TestXmpDocument_KeywordsBagAndSeq(t *testing.T) {
t.Run("SeqForm", func(t *testing.T) {
// Synthetic Seq fixture confirms the legacy form still works.
doc := loadXmp(t, "testdata/xmp/synthetic/subject-seq.xmp")
assert.Equal(t, "Sequenced, Keywords, Should, Also, Parse", doc.Keywords())
assert.Equal(t, "Sequenced, Keywords, Should, Also, Parse", doc.Subject())
})
t.Run("EmptyForMissingSubject", func(t *testing.T) {
doc := loadXmp(t, "testdata/fstop-favorite.xmp")
assert.Equal(t, "", doc.Keywords())
assert.Equal(t, "", doc.Subject())
})
}
func TestXmpDocument_Subject(t *testing.T) {
t.Run("MirrorsKeywordsFromDcSubject", func(t *testing.T) {
// dc:subject is first in the ExifTool Subject cascade and present in
// most tagged files, so Subject equals the keyword list.
t.Run("ReadsDcSubject", func(t *testing.T) {
// dc:subject is the primary Subject source and present in most tagged
// files. It feeds Details.Subject, not the keyword list.
doc := loadXmp(t, "testdata/xmp/darktable/aurora.jpg.xmp")
assert.Equal(t, doc.Keywords(), doc.Subject())
assert.Contains(t, doc.Subject(), "Aurora")
})
t.Run("PersonInImageFallback", func(t *testing.T) {
@ -290,7 +289,6 @@ func TestXmpDocument_Subject(t *testing.T) {
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`)
assert.Equal(t, "", doc.Keywords())
assert.Equal(t, "Alice, Bob", doc.Subject())
})
t.Run("HierarchicalSubjectFallback", func(t *testing.T) {
@ -839,11 +837,11 @@ func TestXmpDocument_DarktableFixture(t *testing.T) {
assert.Equal(t, "PhotoPrism", doc.Artist())
assert.Equal(t, "CC-BY-SA 4.0", doc.Copyright())
})
t.Run("BagFormKeywords", func(t *testing.T) {
t.Run("BagFormSubject", func(t *testing.T) {
// Darktable writes <dc:subject><rdf:Bag>. The old reader
// dropped this entirely (it only handled <rdf:Seq>); the new
// reader must produce a non-empty list.
got := doc.Keywords()
// reader must produce a non-empty Subject list.
got := doc.Subject()
assert.Contains(t, got, "Aurora")
assert.Contains(t, got, "Iceland")
assert.Contains(t, got, "Nature")

View file

@ -68,7 +68,8 @@ func TestXMP_FillsPreviouslyEmptyFields(t *testing.T) {
assert.Equal(t, "xmp.iid:de0fb44d-bdc8-4cc3-aa50-9aefe4992b34", data.InstanceID)
assert.Equal(t, "(C) 2026 PhotoPrism — Test fixture", data.Copyright)
assert.Equal(t, "sRGB IEC61966-2.1", data.ColorProfile)
assert.NotEmpty(t, data.Keywords, "dc:subject Bag must populate Keywords")
assert.NotEmpty(t, data.Subject, "dc:subject Bag must populate Subject")
assert.Empty(t, data.Keywords, "dc:subject must not populate Keywords")
}
// TestXMP_GpsCompositionEndToEnd asserts that a 2-component Adobe-form

View file

@ -26,7 +26,10 @@ func TestXMP(t *testing.T) {
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, "2021-03-24 13:07:29", data.TakenAtLocal.Format("2006-01-02 15:04:05"))
assert.Equal(t, "Tulpen am See", data.Caption)
assert.Equal(t, Keywords{"blume", "krokus", "schöne", "wiese"}, data.Keywords)
// dc:subject feeds the descriptive Subject field, not Keywords, and
// multi-word entries keep their spaces ("Schöne Wiese" stays intact).
assert.Equal(t, "Krokus, Blume, Schöne Wiese", data.Subject)
assert.Empty(t, data.Keywords)
// Apple GPS — pure-decimal value with separate *Ref.
assert.InDelta(t, 52.525082, data.Lat, 1e-4)
assert.InDelta(t, 13.369367, data.Lng, 1e-4)
@ -50,9 +53,10 @@ func TestXMP(t *testing.T) {
assert.Equal(t, "Michael Mayer", data.Artist)
assert.Equal(t, "Example file for development", data.Caption)
assert.Equal(t, "This is an (edited) legal notice", data.Copyright)
// dc:subject mirrors into Subject for ExifTool parity (the cascade's
// first source), so XMP-sidecar photos get the same details.Subject.
// dc:subject feeds the descriptive Subject field only (matching the
// embedded/ExifTool path's data.Subject source), never Keywords.
assert.Equal(t, "desk, coffee, computer", data.Subject)
assert.Empty(t, data.Keywords)
assert.Equal(t, "HUAWEI", data.CameraMake)
assert.Equal(t, "ELE-L29", data.CameraModel)
assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel)

View file

@ -133,10 +133,14 @@ func TestIndexRelated(t *testing.T) {
} else {
assert.Equal(t, "Botanischer Garten", photo.PhotoTitle)
assert.Equal(t, "Tulpen am See", photo.PhotoCaption)
assert.Contains(t, photo.Details.Keywords, "krokus")
assert.Contains(t, photo.Details.Keywords, "blume")
assert.Contains(t, photo.Details.Keywords, "schöne")
assert.Contains(t, photo.Details.Keywords, "wiese")
// dc:subject feeds the descriptive Subject field (entries keep
// their spaces), not the Keywords field. Filename/location keywords
// still populate Keywords, but the dc:subject values must not.
assert.Equal(t, "Krokus, Blume, Schöne Wiese", photo.Details.Subject)
assert.NotContains(t, photo.Details.Keywords, "krokus")
assert.NotContains(t, photo.Details.Keywords, "blume")
assert.NotContains(t, photo.Details.Keywords, "schöne")
assert.NotContains(t, photo.Details.Keywords, "wiese")
assert.Equal(t, "2021-03-24 12:07:29 +0000 UTC", photo.TakenAt.String())
assert.Equal(t, "xmp", photo.TakenSrc)
}