From 5e5072d590f02b8a8d8b7d9719a459679897568d Mon Sep 17 00:00:00 2001 From: Mirco Miranda Date: Thu, 10 Sep 2026 11:29:32 +0200 Subject: [PATCH] EXR: store of "adobe_rsrc" attribute with XMP/EXIF/Resolution data --- README.md | 9 +- src/imageformats/exr.cpp | 165 ++++++++++++++++++++++++++------- src/imageformats/exr_p.h | 6 ++ src/imageformats/microexif.cpp | 6 +- src/imageformats/photoshop.cpp | 125 +++++++++++++++++++++++++ src/imageformats/photoshop_p.h | 65 ++++++++++++- 6 files changed, 336 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 99f9b9d..571e7ad 100644 --- a/README.md +++ b/README.md @@ -384,9 +384,12 @@ The following defines can be defined in cmake to modify the behavior of the plugin: - `EXR_CONVERT_TO_SRGB`: the linear data is converted to sRGB on read to accommodate programs that do not support color profiles. -- `EXR_DISABLE_XMP_ATTRIBUTE`: disables the stores XMP values in a non-standard - attribute named "xmp". Note that Gimp reads the "xmp" attribute and Darktable - writes it as well. +- `EXR_DISABLE_XMP_ATTRIBUTE`: disables the storage of XMP values in a + non-standard attribute named "xmp". Note that Gimp reads the "xmp" attribute + and Darktable writes it as well. +- `EXR_DISABLE_ADOBE_ATTRIBUTE`: disables the storage of non-standard + attribute "adobe_rsrc". Note that Photoshop uses "adobe_rsrc" attribute to + read and write XMP/EXIF/IPTC data. The plugin can set the following additional metadata: - `EXRLayerName`: A string containing the name of the EXR layer used to decode diff --git a/src/imageformats/exr.cpp b/src/imageformats/exr.cpp index 9dccede..9336b5f 100644 --- a/src/imageformats/exr.cpp +++ b/src/imageformats/exr.cpp @@ -21,8 +21,8 @@ */ //#define EXR_CONVERT_TO_SRGB // default: commented -> you should define it in your cmake file -/* *** EXR_STORE_XMP_ATTRIBUTE *** - * If defined, disables the stores XMP values in a non-standard attribute named "xmp". +/* *** EXR_DISABLE_XMP_ATTRIBUTE *** + * If defined, disables the storage of XMP values ​​in the non-standard attribute named "xmp" * The QImage metadata used is "XML:com.adobe.xmp". * NOTE: The use of non-standard attributes is possible but discouraged by the specification. However, * metadata is essential for good image management and programs like darktable also set this @@ -30,6 +30,15 @@ */ //#define EXR_DISABLE_XMP_ATTRIBUTE // default: commented -> you should define it in your cmake file +/* *** EXR_DISABLE_ADOBE_ATTRIBUTE *** + * If defined, disables the storage of non-standard attribute "adobe_rsrc". + * NOTE: The use of non-standard attributes is possible but discouraged by the specification. However, + * metadata is essential for good image management and programs like Photoshop also set this + * attribute. Photoshop uses "adobe_rsrc" attribute to read/write XMP/EXIF/IPTC data. + */ +//#define EXR_DISABLE_ADOBE_ATTRIBUTE // default: commented -> you should define it in your cmake file + + /* *** EXR_MAX_IMAGE_WIDTH and EXR_MAX_IMAGE_HEIGHT *** * The maximum size in pixel allowed by the plugin. */ @@ -92,6 +101,12 @@ Q_LOGGING_CATEGORY(LOG_EXRPLUGIN, "kf.imageformats.plugins.exr", QtDebugMsg) Q_LOGGING_CATEGORY(LOG_EXRPLUGIN, "kf.imageformats.plugins.exr", QtWarningMsg) #endif +#ifndef EXR_DISABLE_ADOBE_ATTRIBUTE +#if QT_VERSION_CHECK(OPENEXR_VERSION_MAJOR, OPENEXR_VERSION_MINOR, OPENEXR_VERSION_PATCH) < QT_VERSION_CHECK(3, 3, 0) +#define EXR_DISABLE_ADOBE_ATTRIBUTE +#endif +#endif + class K_IStream : public Imf::IStream { public: @@ -213,6 +228,7 @@ EXRHandler::EXRHandler() , m_imageCount(0) , m_startPos(-1) , m_subType(EXR_SUBFORMAT_RGB) + , m_transformation(QImageIOHandler::TransformationNone) { // Set the number of threads to use (0 is allowed) Imf::setGlobalThreadCount(QThread::idealThreadCount() / 2); @@ -292,6 +308,27 @@ static void printAttributes(const Imf::Header &h) } #endif +static PSDImageResourceSection readPSDImageResourceSection(const Imf::Header &header) +{ + if (auto adobe = header.findTypedAttribute("adobe_rsrc")) { + auto &&data = adobe->data(); + auto ba = QByteArray(data, data.size()); + ba.prepend((data.size()) & 0xFF); + ba.prepend((data.size() >> 8) & 0xFF); + ba.prepend((data.size() >> 16) & 0xFF); + ba.prepend((data.size() >> 24) & 0xFF); + QDataStream ds(ba); + ds.setByteOrder(QDataStream::BigEndian); + auto ok = false; + auto irs = readImageResourceSection(ds, &ok); + if (ok) { + return irs; + } + } + return{}; +} + + /*! * \brief readMetadata * Reads EXR attributes from the \a header and set its as metadata in the \a image. @@ -389,31 +426,17 @@ static void readMetadata(const Imf::Header &header, QImage &image) } // Photoshop image resource section - if (auto adobe = header.findTypedAttribute("adobe_rsrc")) { - auto &&data = adobe->data(); - auto ba = QByteArray(data, data.size()); - ba.prepend((data.size()) & 0xFF); - ba.prepend((data.size() >> 8) & 0xFF); - ba.prepend((data.size() >> 16) & 0xFF); - ba.prepend((data.size() >> 24) & 0xFF); - QDataStream ds(ba); - ds.setByteOrder(QDataStream::BigEndian); - auto ok = false; - auto irs = readImageResourceSection(ds, &ok); - if (ok) { - if (irs.contains(IRI_EXIFDATA1)) { - auto exif = MicroExif::fromByteArray(irs.value(IRI_EXIFDATA1).data); - exif.updateImageMetadata(image); - exif.updateImageResolution(image); - } - - if (irs.contains(IRI_XMPMETADATA)) { - auto irb = irs.value(IRI_XMPMETADATA); - auto xmp = QString::fromUtf8(irb.data); - if (!xmp.isEmpty()) - image.setText(QStringLiteral(META_KEY_XMP_ADOBE), xmp); - } - } + auto irs = readPSDImageResourceSection(header); + if (irs.contains(IRI_EXIFDATA1)) { + auto exif = MicroExif::fromByteArray(irs.value(IRI_EXIFDATA1).data); + exif.updateImageMetadata(image); + exif.updateImageResolution(image); + } + if (irs.contains(IRI_XMPMETADATA)) { + auto irb = irs.value(IRI_XMPMETADATA); + auto xmp = QString::fromUtf8(irb.data); + if (!xmp.isEmpty()) + image.setText(QStringLiteral(META_KEY_XMP_ADOBE), xmp); } } @@ -580,8 +603,11 @@ bool makePreview(const QImage &image, Imf::Array2D &pixels) * \brief setMetadata * Reades the metadata from \a image and set its as attributes in the \a header. */ -static void setMetadata(const QImage &image, Imf::Header &header) +static void setMetadata(const QImage &image, Imf::Header &header, const QImageIOHandler::Transformation &transformation) { + PSDImageResourceSection irs; + QString xmpData; + auto dateTime = QDateTime::currentDateTime(); for (auto &&key : image.textKeys()) { auto text = image.text(key); @@ -612,11 +638,9 @@ static void setMetadata(const QImage &image, Imf::Header &header) } } -#ifndef EXR_DISABLE_XMP_ATTRIBUTE // warning: Non-standard attribute! if (!key.compare(QStringLiteral(META_KEY_XMP_ADOBE), Qt::CaseInsensitive)) { - header.insert("xmp", Imf::StringAttribute(text.toStdString())); + xmpData = text; } -#endif if (!key.compare(QStringLiteral(META_KEY_MANUFACTURER), Qt::CaseInsensitive)) { header.insert("cameraMake", Imf::StringAttribute(text.toStdString())); @@ -676,6 +700,41 @@ static void setMetadata(const QImage &image, Imf::Header &header) header.insert("pixelAspectRatio", Imf::FloatAttribute(float(image.dotsPerMeterY()) / float(image.dotsPerMeterX()))); } +#ifndef EXR_DISABLE_XMP_ATTRIBUTE + if (!xmpData.isEmpty()) { + header.insert("xmp", Imf::StringAttribute(xmpData.toStdString())); + } +#endif + +#ifndef EXR_DISABLE_ADOBE_ATTRIBUTE + auto resInfo = PSDResolutionInfoBlock::fromImage(image); + if (resInfo.isValid()) { + PSDImageResourceBlock irb; + irb.data = resInfo.toByteArray(); + if (!irb.data.isEmpty()) + irs.insert(IRI_RESOLUTIONINFO, irb); + } + auto exif = MicroExif::fromImage(image); + if (!exif.isEmpty()) { + exif.setTransformation(transformation); + PSDImageResourceBlock irb; + irb.data = exif.toByteArray(QDataStream::BigEndian); + irs.insert(IRI_EXIFDATA1, irb); + } + if (!xmpData.isEmpty()) { + PSDImageResourceBlock irb; + irb.data = xmpData.toUtf8(); + irs.insert(IRI_XMPMETADATA, irb); + } + if (!irs.isEmpty()) { + bool ok = false; + auto ba = irs.toByteArray(&ok); + if (ok) { + header.insert("adobe_rsrc", Imf::OpaqueAttribute("adobe_rsrc", ba.size(), ba.data())); + } + } +#endif + // set default chroma (default constructor ITU-R BT.709-3 -> sRGB) // The image is converted to Linear sRGB so, the chroma is the default EXR value. // If a file doesn’t have a chromaticities attribute, display software should assume that the @@ -718,7 +777,7 @@ bool EXRHandler::write(const QImage &image) } // set metadata (EXR attributes) - setMetadata(image, header); + setMetadata(image, header, m_transformation); // write the EXR K_OStream ostr(device()); @@ -791,6 +850,15 @@ void EXRHandler::setOption(ImageOption option, const QVariant &value) m_subType = EXR_SUBFORMAT_RGB; } } +#ifndef EXR_DISABLE_ADOBE_ATTRIBUTE + if (option == QImageIOHandler::ImageTransformation) { + auto ok = false; + auto t = value.toInt(&ok); + if (ok) { + m_transformation = QImageIOHandler::Transformation(t); + } + } +#endif } bool EXRHandler::supportsOption(ImageOption option) const @@ -815,6 +883,11 @@ bool EXRHandler::supportsOption(ImageOption option) const if (option == QImageIOHandler::SupportedSubTypes) { return true; } +#ifndef EXR_DISABLE_ADOBE_ATTRIBUTE + if (option == QImageIOHandler::ImageTransformation) { + return true; + } +#endif return false; } @@ -870,13 +943,37 @@ QVariant EXRHandler::option(ImageOption option) const } if (option == QImageIOHandler::SupportedSubTypes) { - return QVariant::fromValue(QList() << EXR_SUBFORMAT_RGB << EXR_SUBFORMAT_YC); + v = QVariant::fromValue(QList() << EXR_SUBFORMAT_RGB << EXR_SUBFORMAT_YC); } if (option == QImageIOHandler::SubType) { - return QVariant::fromValue(m_subType); + v = QVariant::fromValue(m_subType); } +#ifndef EXR_DISABLE_ADOBE_ATTRIBUTE + if (option == QImageIOHandler::ImageTransformation) { + if (auto d = device()) { + // transactions works on both random and sequential devices + d->startTransaction(); + if (m_startPos > -1) { + d->seek(m_startPos); + } + try { + K_IStream istr(d); + Imf::RgbaInputFile file(istr); + auto irs = readPSDImageResourceSection(file.header()); + if (irs.contains(IRI_EXIFDATA1)) { + auto exif = MicroExif::fromByteArray(irs.value(IRI_EXIFDATA1).data); + v = int(exif.transformation()); + } + } catch (const std::exception &) { + // broken file or unsupported version + } + d->rollbackTransaction(); + } + } +#endif + return v; } diff --git a/src/imageformats/exr_p.h b/src/imageformats/exr_p.h index 20e8dd6..7c45d15 100644 --- a/src/imageformats/exr_p.h +++ b/src/imageformats/exr_p.h @@ -126,6 +126,12 @@ private: * - YC: converts RGB data into luminance (Y) and 2x2 subsampled chroma (RY, BY), cutting raw color data by ~50% for significantly smaller file sizes. */ QByteArray m_subType; + + /*! + * \brief m_transformation + * The Qt image transformation. + */ + QImageIOHandler::Transformation m_transformation; }; class EXRPlugin : public QImageIOPlugin diff --git a/src/imageformats/microexif.cpp b/src/imageformats/microexif.cpp index 0190132..6be98dd 100644 --- a/src/imageformats/microexif.cpp +++ b/src/imageformats/microexif.cpp @@ -84,6 +84,8 @@ #define EXIF_TAG_SIZEOF(dataType) (quint16(dataType) & 0x3F) #define EXIF_TAG_DATATYPE(dataType) (quint16(dataType) >> 6) +#define GPS_GPSVERSION_VALUE QList{0x02, 0x04, 0x00, 0x00} + enum class ExifTagType : quint16 { // Base data types Byte = EXIF_TAG_VALUE(1, 1), @@ -1325,7 +1327,7 @@ QByteArray MicroExif::gpsIfdByteArray(const QDataStream::ByteOrder &byteOrder, c QDataStream ds(&ba, QIODevice::WriteOnly); ds.setByteOrder(byteOrder); auto gpsTags = m_gpsTags; - gpsTags.insert(GPS_GPSVERSION, QByteArray("2400")); + gpsTags.insert(GPS_GPSVERSION, QVariant::fromValue(GPS_GPSVERSION_VALUE)); TagPos positions; if (!writeIfd(ds, version, gpsTags, positions, 0, staticGpsTagTypes)) return {}; @@ -1723,7 +1725,7 @@ void MicroExif::updateTags(Tags &tiffTags, Tags &exifTags, Tags &gpsTags, const tiffTags.remove(EXIF_GPSIFD); } else { tiffTags.insert(EXIF_GPSIFD, quint32()); - gpsTags.insert(GPS_GPSVERSION, QByteArray("2400")); + gpsTags.insert(GPS_GPSVERSION, QVariant::fromValue(GPS_GPSVERSION_VALUE)); } } diff --git a/src/imageformats/photoshop.cpp b/src/imageformats/photoshop.cpp index 4e8103a..e33f077 100644 --- a/src/imageformats/photoshop.cpp +++ b/src/imageformats/photoshop.cpp @@ -44,6 +44,23 @@ QString readPascalString(QDataStream &s, qint32 alignBytes, qint32 *size) return str; } +bool writePascalString(const QString &str, QDataStream &s, qint32 alignBytes) +{ + auto data = str.toLatin1(); + if(data.size() > 250) { + data = data.left(250); + } + auto sz = data.size(); + s << quint8(sz); + if (sz && s.writeRawData(data.data(), sz) != sz) { + return false; + } + for(alignBytes = std::max(1, alignBytes), sz += 1; sz % alignBytes; ++sz) { + s << char(); + } + return s.status() == QDataStream::Ok; +} + PSDImageResourceSection readImageResourceSection(QDataStream &s, bool *ok) { PSDImageResourceSection irs; @@ -138,3 +155,111 @@ PSDImageResourceSection readImageResourceSection(QDataStream &s, bool *ok) return irs; } + +bool writeImageResourceSection(const PSDImageResourceSection &irs, QDataStream &s) +{ + bool ok = false; + auto ba = irs.toByteArray(&ok); + if (!ok) { + return false; + } + s << quint32(ba.size()); + if (s.writeRawData(ba.data(), ba.size()) != ba.size()) { + return false; + } + return (s.status() == QDataStream::Ok); +} + + +QByteArray PSDImageResourceSection::toByteArray(bool *ok) const +{ + QByteArray ba; + + bool tmp = true; + if (ok == nullptr) + ok = &tmp; + *ok = true; + + if (!isEmpty()) { + QDataStream s(&ba, QDataStream::WriteOnly); + s.setByteOrder(QDataStream::BigEndian); + + auto ids = keys(); + for(auto &&id : ids) { + auto irb = value(id); + if (irb.data.isEmpty()) { + continue; + } + + // signature + s << quint32(S_8BIM); + + // resource id + s << quint16(id); + + // resource name (2 bytes aligned) + if (!writePascalString(irb.name, s, 2)) { + *ok = false; + return{}; + } + + // data (2 bytes aligned) + auto sz = irb.data.size(); + s << quint32(sz); + if (s.writeRawData(irb.data.data(), sz) != sz) { + *ok = false; + return{}; + } + if (sz % 2) { + s << char(); + } + + if (s.status() != QDataStream::Ok) { + *ok = false; + return{}; + } + } + } + + Q_ASSERT(ba.size() % 2 == 0); + return ba; +} + +PSDResolutionInfoBlock::PSDResolutionInfoBlock(qint32 ppmX, qint32 ppmY) + : m_ppmX(ppmX) + , m_ppmY(ppmY) +{ + +} + +bool PSDResolutionInfoBlock::isValid() const +{ + return m_ppmX > 0 && m_ppmY > 0; +} + +PSDResolutionInfoBlock PSDResolutionInfoBlock::fromImage(const QImage &image) +{ + return PSDResolutionInfoBlock(image.dotsPerMeterX(), image.dotsPerMeterY()); +} + +QByteArray PSDResolutionInfoBlock::toByteArray() const +{ + QByteArray ba; + QDataStream ds(&ba, QIODevice::WriteOnly); + ds.setByteOrder(QDataStream::BigEndian); + + auto hres = qRoundOrZero(dppm2dpi(m_ppmX) * 65536); + ds << hres; + ds << quint16(1); // dpi + ds << quint16(2); // cm (display) + auto vres = qRoundOrZero(dppm2dpi(m_ppmY) * 65536); + ds << vres; + ds << quint16(1); + ds << quint16(2); + + if (hres == 0 || vres == 0) { + return{}; + } + + return ba; +} diff --git a/src/imageformats/photoshop_p.h b/src/imageformats/photoshop_p.h index 6db37eb..642f4e6 100644 --- a/src/imageformats/photoshop_p.h +++ b/src/imageformats/photoshop_p.h @@ -9,6 +9,7 @@ #include #include +#include #include enum Signature : quint32 { @@ -55,7 +56,50 @@ struct PSDImageResourceBlock { QByteArray data; }; -using PSDImageResourceSection = QHash; +class PSDImageResourceSection : public QHash +{ +public: + PSDImageResourceSection() : QHash() {} + PSDImageResourceSection(const PSDImageResourceSection& other) = default; + PSDImageResourceSection& operator =(const PSDImageResourceSection& other) = default; + + /*! + * \brief toByteArray + * \param ok Pointer to the operation result variable. + * \return The binary IRB to be written into the PSD file. + */ + QByteArray toByteArray(bool *ok = nullptr) const; +}; + +class PSDResolutionInfoBlock +{ +public: + PSDResolutionInfoBlock(qint32 ppmX, qint32 ppmY); + PSDResolutionInfoBlock(const PSDResolutionInfoBlock& other) = default; + PSDResolutionInfoBlock& operator =(const PSDResolutionInfoBlock& other) = default; + + /*! + * \brief isValid + * \return true if both m_ppmX and m_ppmY are grater than 0. Otherwise false. + */ + bool isValid() const; + + /*! + * \brief fromImage + * Initialize the class using the image resolution. + */ + static PSDResolutionInfoBlock fromImage(const QImage& image); + + /*! + * \brief toByteArray + * \return The binary data ready for the IMage Resource Section. + */ + QByteArray toByteArray() const; + +private: + qint32 m_ppmX; + qint32 m_ppmY; +}; /*! @@ -68,6 +112,16 @@ using PSDImageResourceSection = QHash; */ QString readPascalString(QDataStream &s, qint32 alignBytes = 1, qint32 *size = nullptr); +/*! + * \brief writePascalString + * Writes the Pascal string as defined in the PSD specification. + * \param str The string to be written. + * \param s The stream. + * \param alignBytes Alignment of the string. + * \return True on success, otherwise false. + */ +bool writePascalString(const QString& str, QDataStream &s, qint32 alignBytes = 1); + /*! * \brief readImageResourceSection * Reads the image resource section. @@ -77,4 +131,13 @@ QString readPascalString(QDataStream &s, qint32 alignBytes = 1, qint32 *size = n */ PSDImageResourceSection readImageResourceSection(QDataStream &s, bool *ok = nullptr); +/*! + * \brief writeImageResourceSection + * Writes the image resource section. + * \param irs The image resource section raw data. + * \param s The stream. + * \return True on success, otherwise false. + */ +bool writeImageResourceSection(const PSDImageResourceSection& irs, QDataStream &s); + #endif // PHOTOSHOP_P_H