mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
38b50724f5
commit
6dc3ecbf25
7 changed files with 52 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue