From c21b50d699bdbb43d499482dfe412f8a77697782 Mon Sep 17 00:00:00 2001 From: Mirco Miranda Date: Fri, 10 Jul 2026 15:35:38 +0200 Subject: [PATCH] EXIF: add support for Windows Explorer tags --- README.md | 3 + autotests/read/jxl/gimp_exif.jxl | Bin 14497 -> 11426 bytes autotests/read/jxl/gimp_exif.jxl.json | 8 +++ autotests/read/jxr/metadata.jxr | Bin 92860 -> 87207 bytes autotests/read/jxr/metadata.jxr.json | 8 +++ autotests/write/basic/jxl.json | 8 +++ autotests/write/basic/jxr.json | 4 ++ src/imageformats/jxr.cpp | 11 +++ src/imageformats/microexif.cpp | 97 ++++++++++++++++++++++++-- src/imageformats/microexif_p.h | 23 +++++- src/imageformats/util_p.h | 2 + 11 files changed, 159 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6b37ef4..4110b14 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 2ca161118a43d8e1dd04b348529eb54d788d0936..98edab09141d38b22366d8ffe28d869cbc85aefe 100644 GIT binary patch delta 541 zcmV+&0^mAZSBCTkNqD8}Oq6NN+~ zBt`%_IE91YiHClJtSAmB@D%m%3(Gced%K|xT|`ZsGl<<4Q7X~IK$L$Pn20*xn1mm! zaV(Le(2HMGQ5eD>uC7H5w+#1aeuX~#3%9xGAEkk%iT-kZ`}lt{x3`ma()QbVMU|?9 z)09us!CX$K`S>&+`q1$1%%>~^0004TVsdX{cx`MT8zut)t}dl6d$ z7sMMvmQq^L>G1cjbsaSX`w>AAGcv!W&(1G^UqQ4xU6W}|EjrQ{)K2L}hB>Y(c~ z$3U)kg`iuxsQW>X%OQzlK&V%)gxy=MVk7|#rXJ!{_Nae>vZf)GDH@uLhJo{`w$KC3 z>xoq8D_B-5o+%{{0et{=1Dc{i&#}-a*Sb^F$27DCJcl5QofMe`ed!lAn#Qo&!09v_ zjfMq6Y5;7bj7m}FXp36ON8xJ(@QsNRpeZW!9Bbl^+|pXyu-&HQ_<8r3 f<$LIIv?Ud2GE}K?EU4+u=`{C+&kD51o3ZNv6lV_Q delta 3636 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 exifStrMap = { std::pair(EXIF_BODYSERIALNUMBER, QStringLiteral(META_KEY_SERIALNUMBER)), std::pair(EXIF_LENSMAKE, QStringLiteral(META_KEY_LENS_MANUFACTURER)), std::pair(EXIF_LENSMODEL, QStringLiteral(META_KEY_LENS_MODEL)), - std::pair(EXIF_LENSSERIALNUMBER, QStringLiteral(META_KEY_LENS_SERIALNUMBER)), - std::pair(EXIF_IMAGETITLE, QStringLiteral(META_KEY_TITLE)), + std::pair(EXIF_LENSSERIALNUMBER, QStringLiteral(META_KEY_LENS_SERIALNUMBER)) + // std::pair(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 uba; + auto ba = QByteArrayView(reinterpret_cast(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>(); + if (ba.isEmpty()) + return {}; + auto p16 = reinterpret_cast(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 441d713..cc5bdd0 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 fda946e..c7e79ec 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"