[frameworks/kimageformats] /: EXIF: add support for Windows Explorer tags

Mirco Miranda <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit c21b50d699bdbb43d499482dfe412f8a77697782 by Mirco Miranda, on behalf of Mirco Miranda.
Committed on 20/07/2026 at 05:27.
Pushed by mircomir into branch 'master'.

EXIF: add support for Windows Explorer tags

M  +3    -0    README.md
M  +-    --    autotests/read/jxl/gimp_exif.jxl
M  +8    -0    autotests/read/jxl/gimp_exif.jxl.json
M  +-    --    autotests/read/jxr/metadata.jxr
M  +8    -0    autotests/read/jxr/metadata.jxr.json
M  +8    -0    autotests/write/basic/jxl.json
M  +4    -0    autotests/write/basic/jxr.json
M  +11   -0    src/imageformats/jxr.cpp
M  +93   -4    src/imageformats/microexif.cpp
M  +22   -1    src/imageformats/microexif_p.h
M  +2    -0    src/imageformats/util_p.h

https://invent.kde.org/frameworks/kimageformats/-/commit/c21b50d699bdbb43d499482dfe412f8a77697782

diff --git a/README.md b/README.md
index 6b37ef45..4110b14b 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,7 @@ About the image:
   scanned.
 - `HostComputer`: The computer and/or operating system in use at the time 
   of image creation.
+- `Keywords`: Keywords, separated by semicolons, that represent the image.
 - `Latitude`: Floating-point number indicating the latitude in degrees 
   north of the equator (e.g. 27.717).
 - `Longitude`: Floating-point number indicating the longitude in degrees 
@@ -154,6 +155,8 @@ About the image:
   without milliseconds (e.g. 2024-03-23T15:30:43). This value should be 
   updated every time the image is saved.
 - `Owner`: Name of the owner of the image.
+- `Rating`: Integer number indicating the image rating (usually between 1 
+  and 5).
 - `Software`: Name and version number of the software package(s) used to 
   create the image.
 - `Speed`: Floating-point number indicating the speed of GPS receiver 
diff --git a/autotests/read/jxl/gimp_exif.jxl b/autotests/read/jxl/gimp_exif.jxl
index 2ca16111..98edab09 100644
Binary files a/autotests/read/jxl/gimp_exif.jxl and b/autotests/read/jxl/gimp_exif.jxl differ
diff --git a/autotests/read/jxl/gimp_exif.jxl.json b/autotests/read/jxl/gimp_exif.jxl.json
index 1a963c64..58d24e46 100644
--- a/autotests/read/jxl/gimp_exif.jxl.json
+++ b/autotests/read/jxl/gimp_exif.jxl.json
@@ -12,6 +12,14 @@
                 "key" : "ModificationDate",
                 "value" : "2025-01-05T10:18:16"
             },
+            {
+                "key" : "Keywords",
+                "value" : "kde;w11"
+            },
+            {
+                "key" : "Rating",
+                "value" : "4"
+            },
             {
                 "key" : "Software" ,
                 "value" : "GIMP 3.0.0-RC2"
diff --git a/autotests/read/jxr/metadata.jxr b/autotests/read/jxr/metadata.jxr
index bce9beef..3a9992dc 100644
Binary files a/autotests/read/jxr/metadata.jxr and b/autotests/read/jxr/metadata.jxr differ
diff --git a/autotests/read/jxr/metadata.jxr.json b/autotests/read/jxr/metadata.jxr.json
index 9153d4c2..4d9f98f7 100644
--- a/autotests/read/jxr/metadata.jxr.json
+++ b/autotests/read/jxr/metadata.jxr.json
@@ -59,6 +59,14 @@
             {
                 "key" : "Model",
                 "value" : "KImageFormats"
+            },
+            {
+                "key" : "Keywords",
+                "value" : "broacast;test;kde"
+            },
+            {
+                "key" : "Rating",
+                "value" : "5"
             }
         ],
         "resolution" : {
diff --git a/autotests/write/basic/jxl.json b/autotests/write/basic/jxl.json
index f6c495d0..9e250be9 100644
--- a/autotests/write/basic/jxl.json
+++ b/autotests/write/basic/jxl.json
@@ -37,6 +37,14 @@
             "key" : "Description",
             "value" : "テレビ放送テスト映像。(TV broadcast test image.)"
         },
+        {
+            "key" : "Keywords",
+            "value" : "kde;test"
+        },
+        {
+            "key" : "Rating",
+            "value" : "5"
+        },
         {
             "key" : "Latitude",
             "value" : "44.6478"
diff --git a/autotests/write/basic/jxr.json b/autotests/write/basic/jxr.json
index 77882074..86e1cd79 100644
--- a/autotests/write/basic/jxr.json
+++ b/autotests/write/basic/jxr.json
@@ -29,6 +29,10 @@
             "key" : "Description",
             "value" : "TV broadcast test image."
         },
+        {
+            "key" : "Rating",
+            "value" : "4"
+        },
         {
             "key" : "Latitude",
             "value" : "44.6478"
diff --git a/src/imageformats/jxr.cpp b/src/imageformats/jxr.cpp
index c9473f51..331ce7c8 100644
--- a/src/imageformats/jxr.cpp
+++ b/src/imageformats/jxr.cpp
@@ -739,6 +739,13 @@ public:
         DESCRIPTIVEMETADATA meta;
         memset(&meta, 0, sizeof(meta));
 
+        auto ok = false;
+        auto rating = image.text(QStringLiteral(META_KEY_RATING)).toUInt(&ok);
+        if (ok) {
+            meta.pvarRatingStars.vt = DPKVT_UI2;
+            meta.pvarRatingStars.VT.uiVal = quint16(rating);
+        }
+
 #define META_CTEXT(name, field)                                                                                                                                \
     auto field = image.text(QStringLiteral(name)).toUtf8();                                                                                                    \
     if (!field.isEmpty()) {                                                                                                                                    \
@@ -1017,6 +1024,10 @@ private:
             return false;
         }
 
+        if (meta.pvarRatingStars.vt == DPKVT_UI2) {
+            m_txtMeta.insert(QStringLiteral(META_KEY_RATING), QStringLiteral("%1").arg(meta.pvarRatingStars.VT.uiVal));
+        }
+
 #define META_TEXT(name, field)                                                                                                                                 \
     if (meta.field.vt == DPKVT_LPSTR)                                                                                                                          \
         m_txtMeta.insert(QStringLiteral(name), QString::fromUtf8(meta.field.VT.pszVal));                                                                       \
diff --git a/src/imageformats/microexif.cpp b/src/imageformats/microexif.cpp
index 23dd3f4d..01901322 100644
--- a/src/imageformats/microexif.cpp
+++ b/src/imageformats/microexif.cpp
@@ -30,6 +30,9 @@
 #define TIFF_ARTIST 0x013B
 #define TIFF_DATETIME 0x0132
 #define TIFF_COPYRIGHT 0x8298
+#define TIFF_XPRATING 0x4746  // added by Windows Explorer Image Properties
+#define TIFF_XPTITLE 0x9C9B // added by Windows Explorer Image Properties
+#define TIFF_XPKEYWORDS 0x9C9E // added by Windows Explorer Image Properties
 
 #define TIFF_VAL_URES_NOABSOLUTE 1
 #define TIFF_VAL_URES_INCH 2
@@ -134,6 +137,9 @@ static const KnownTags staticTagTypes = {
     TagInfo(TIFF_ARTIST, ExifTagType::Utf8),
     TagInfo(TIFF_DATETIME, ExifTagType::Ascii),
     TagInfo(TIFF_COPYRIGHT, ExifTagType::Utf8),
+    TagInfo(TIFF_XPRATING, ExifTagType::Short),
+    TagInfo(TIFF_XPTITLE, ExifTagType::Byte),
+    TagInfo(TIFF_XPKEYWORDS, ExifTagType::Byte),
     TagInfo(EXIF_EXPOSURETIME, ExifTagType::Rational),
     TagInfo(EXIF_FNUMBER, ExifTagType::Rational),
     TagInfo(EXIF_EXIFIFD, ExifTagType::Long),
@@ -206,8 +212,8 @@ static const QList<std::pair<quint16, QString>> exifStrMap = {
     std::pair<quint16, QString>(EXIF_BODYSERIALNUMBER, QStringLiteral(META_KEY_SERIALNUMBER)),
     std::pair<quint16, QString>(EXIF_LENSMAKE, QStringLiteral(META_KEY_LENS_MANUFACTURER)),
     std::pair<quint16, QString>(EXIF_LENSMODEL, QStringLiteral(META_KEY_LENS_MODEL)),
-    std::pair<quint16, QString>(EXIF_LENSSERIALNUMBER, QStringLiteral(META_KEY_LENS_SERIALNUMBER)),
-    std::pair<quint16, QString>(EXIF_IMAGETITLE, QStringLiteral(META_KEY_TITLE)),
+    std::pair<quint16, QString>(EXIF_LENSSERIALNUMBER, QStringLiteral(META_KEY_LENS_SERIALNUMBER))
+    // std::pair<quint16, QString>(EXIF_IMAGETITLE, QStringLiteral(META_KEY_TITLE)) // using functions due to XP title fallback
 };
 // clang-format on
 
@@ -842,6 +848,32 @@ void MicroExif::setCopyright(const QString &s)
     setTiffString(TIFF_COPYRIGHT, s);
 }
 
+QStringList MicroExif::keywords() const
+{
+    auto val = utf16String(m_tiffTags, TIFF_XPKEYWORDS);
+    if (val.isEmpty())
+        return {};
+    return val.split(QChar(u';'), Qt::SkipEmptyParts);
+}
+
+void MicroExif::setKeywords(const QStringList &k)
+{
+    setUtf16String(m_tiffTags, TIFF_XPKEYWORDS, k.join(QChar(u';')));
+}
+
+quint16 MicroExif::rating() const
+{
+    return m_tiffTags.value(TIFF_XPRATING).toUInt();
+}
+
+void MicroExif::setRating(quint16 rating)
+{
+    if (rating == 0)
+        m_tiffTags.remove(TIFF_XPRATING);
+    else
+        m_tiffTags.insert(TIFF_XPRATING, rating);
+}
+
 QString MicroExif::make() const
 {
     return tiffString(TIFF_MAKE);
@@ -964,12 +996,18 @@ void MicroExif::setDateTimeDigitized(const QDateTime &dt)
 
 QString MicroExif::title() const
 {
-    return exifString(EXIF_IMAGETITLE);
+    auto s = exifString(EXIF_IMAGETITLE);
+    if (s.isEmpty()) { // fall back to XP title
+        s = utf16String(m_tiffTags, TIFF_XPTITLE);
+    }
+    return s;
 }
 
-void MicroExif::setImageTitle(const QString &s)
+void MicroExif::setTitle(const QString &s)
 {
     setExifString(EXIF_IMAGETITLE, s);
+    // Since there is an official title tag, I never write the non-standard Microsoft one.
+    setUtf16String(m_tiffTags, TIFF_XPTITLE, QString());
 }
 
 QUuid MicroExif::uniqueId() const
@@ -1337,6 +1375,11 @@ void MicroExif::updateImageMetadata(QImage &targetImage, bool replaceExisting) c
         if (!s.isEmpty())
             targetImage.setText(p.second, s);
     }
+    if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_TITLE)).isEmpty()) {
+        auto s = title();
+        if (!s.isEmpty())
+            targetImage.setText(QStringLiteral(META_KEY_TITLE), s);
+    }
 
     // set date and time
     if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_MODIFICATIONDATE)).isEmpty()) {
@@ -1423,6 +1466,18 @@ void MicroExif::updateImageMetadata(QImage &targetImage, bool replaceExisting) c
         if (v != WhiteBalance::NotSet)
             targetImage.setText(QStringLiteral(META_KEY_WHITEBALANCE), QStringLiteral("%1").arg(quint16(v)));
     }
+
+    // set Microsoft tags
+    if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_KEYWORDS)).isEmpty()) {
+        auto keywords = this->keywords();
+        if (!keywords.isEmpty())
+            targetImage.setText(QStringLiteral(META_KEY_KEYWORDS), keywords.join(QChar(u';')));
+    }
+    if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_RATING)).isEmpty()) {
+        auto v = rating();
+        if (v != 0)
+            targetImage.setText(QStringLiteral(META_KEY_RATING), QStringLiteral("%1").arg(quint16(v)));
+    }
 }
 
 bool MicroExif::updateImageResolution(QImage &targetImage)
@@ -1518,6 +1573,7 @@ MicroExif MicroExif::fromImage(const QImage &image)
     for (auto &&p : exifStrMap) {
         exif.setExifString(p.first, image.text(p.second));
     }
+    exif.setTitle(image.text(QStringLiteral(META_KEY_TITLE)));
 
     // TIFF Software
     if (exif.software().isEmpty()) {
@@ -1587,6 +1643,13 @@ MicroExif MicroExif::fromImage(const QImage &image)
     if (ok)
         exif.setWhiteBalance(WhiteBalance(whtb));
 
+    // Microsoft tags
+    exif.setKeywords(image.text(QStringLiteral(META_KEY_KEYWORDS)).split(QChar(u';'), Qt::SkipEmptyParts));
+
+    auto rating = image.text(QStringLiteral(META_KEY_RATING)).toUInt(&ok);
+    if (ok)
+        exif.setRating(rating);
+
     return exif;
 }
 
@@ -1676,3 +1739,29 @@ QString MicroExif::string(const Tags &tags, quint16 tagId)
 {
     return tags.value(tagId).toString();
 }
+
+void MicroExif::setUtf16String(Tags &tags, quint16 tagId, const QString &s)
+{
+    if (s.isEmpty()) {
+        tags.remove(tagId);
+        return;
+    }
+    auto s16 = s.toStdU16String();
+    QList<quint8> uba;
+    auto ba = QByteArrayView(reinterpret_cast<char*>(s16.data()), s.size() * 2);
+    for(auto&& c : ba)
+        uba.append(quint8(c));
+    uba.append(quint8('\0'));
+    uba.append(quint8('\0'));
+    tags.insert(tagId, QVariant::fromValue(uba));
+}
+
+QString MicroExif::utf16String(const Tags &tags, quint16 tagId)
+{
+    auto ba = tags.value(tagId).value<QList<quint8>>();
+    if (ba.isEmpty())
+        return {};
+    auto p16 = reinterpret_cast<char16_t*>(ba.data());
+    auto sz = std::max(ba.size() / 2 - 1, qsizetype());
+    return QString::fromUtf16(p16, sz);
+}
diff --git a/src/imageformats/microexif_p.h b/src/imageformats/microexif_p.h
index 441d7136..cc5bdd04 100644
--- a/src/imageformats/microexif_p.h
+++ b/src/imageformats/microexif_p.h
@@ -251,6 +251,24 @@ public:
     QString copyright() const;
     void setCopyright(const QString& s);
 
+    /*!
+     * \brief keywords
+     * Keywords tag used by Windows.
+     * \note Non-standard, added by Windows Explorer.
+     * \return The list of tags.
+     */
+    QStringList keywords() const;
+    void setKeywords(const QStringList& k);
+
+    /*!
+     * \brief rating
+     * Rating tag used by Windows.
+     * \note Non-standard, added by Windows Explorer.
+     * \return The rating. Usually between 0 (not set) and 5.
+     */
+    quint16 rating() const;
+    void setRating(quint16 rating);
+
     /*!
      * \brief make
      * \return The manufacturer of the recording equipment.
@@ -319,7 +337,7 @@ public:
      * \return The title of the image.
      */
     QString title() const;
-    void setImageTitle(const QString &s);
+    void setTitle(const QString &s);
 
     /*!
      * \brief uniqueId
@@ -555,6 +573,9 @@ private:
     static void setString(Tags &tags, quint16 tagId, const QString &s);
     static QString string(const Tags &tags, quint16 tagId);
 
+    static void setUtf16String(Tags &tags, quint16 tagId, const QString &s);
+    static QString utf16String(const Tags &tags, quint16 tagId);
+
 private:
     Tags m_tiffTags;
     Tags m_exifTags;
diff --git a/src/imageformats/util_p.h b/src/imageformats/util_p.h
index fda946e1..c7e79ecd 100644
--- a/src/imageformats/util_p.h
+++ b/src/imageformats/util_p.h
@@ -44,10 +44,12 @@
 #define META_KEY_DIRECTION "Direction"
 #define META_KEY_DOCUMENTNAME "DocumentName"
 #define META_KEY_HOSTCOMPUTER "HostComputer"
+#define META_KEY_KEYWORDS "Keywords"
 #define META_KEY_LATITUDE "Latitude"
 #define META_KEY_LONGITUDE "Longitude"
 #define META_KEY_MODIFICATIONDATE "ModificationDate"
 #define META_KEY_OWNER "Owner"
+#define META_KEY_RATING "Rating"
 #define META_KEY_SOFTWARE "Software"
 #define META_KEY_SPEED "Speed"
 #define META_KEY_TITLE "Title"
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.