diff --git a/.kde-ci.yml b/.kde-ci.yml index baca634..8919827 100644 --- a/.kde-ci.yml +++ b/.kde-ci.yml @@ -8,3 +8,4 @@ Options: test-before-installing: True require-passing-tests-on: [ 'Linux', 'FreeBSD', 'Windows' ] cmake-options: "-DKIMAGEFORMATS_DDS=ON -DKIMAGEFORMATS_JXR=ON" + per-test-timeout: 90 diff --git a/README.md b/README.md index 6ffb7ef..d192cbe 100644 --- a/README.md +++ b/README.md @@ -113,17 +113,89 @@ clamp). This is not a plugin issue. ### Metadata -Metadata support is implemented in all formats that support it. In particular, -in addition to the classic `"Description"`, `"Author"`, `"Copyright"`, etc... where -possible, XMP data is supported via the `"XML:com.adobe.xmp"` key. +Metadata support is available in formats that include it via +`QImage::setText()` and `QImage::text()`. To ensure consistent metadata +functionality, the following keys have been adopted. -Please note that only the most common metadata is supported. +About the image: +- `Altitude`: Floating-point number indicating the GPS altitude in meters + above sea level (e.g. 35.4). +- `Author`: Person who created the image. +- `Comment`: Additional image information in human-readable form, for + example a verbal description of the image. +- `Copyright`: Copyright notice of the person or organization that claims + the copyright to the image. +- `CreationDate`: Creation date and time in ISO 8601 format without + milliseconds (e.g. 2024-03-23T15:30:43). +- `Description`: A string that describes the subject of the image. +- `DocumentName`: The name of the document from which this image was + scanned. +- `HostComputer`: The computer and/or operating system in use at the time + of image creation. +- `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 + east of Greenwich (e.g. 85.317). +- `Owner`: Name of the owner of the image. +- `Software`: Name and version number of the software package(s) used to + create the image. +- `Title`: The title of the image. + +About the camera: +- `Manufacturer`: The manufacturer of the recording equipment. +- `Model`: The model name or model number of the recording equipment. +- `SerialNumber`: The serial number of the recording equipment. + +About the lens: +- `LensManufacturer`: The manufacturer of the interchangeable lens that was + used. +- `LensModel`: The model name or model number of the lens that was used. +- `LensSerialNumber`: The serial number of the interchangeable lens that was + used. + +Complex metadata (requires a parser): +- `XML:org.gimp.xml`: XML metadata generated by GIMP and present only in XCF + files. +- `XML:com.adobe.xmp`: [Extensible Metadata Platform (XMP)](https://developer.adobe.com/xmp/docs/) + is the metadata standard used by Adobe applications and is supported by all + common image formats. **Note that XMP metadata is read and written by + plugins as is.** Since it may contain information present in other metadata + (e.g. `Description`), it is the user's responsibility to ensure consistency + between all metadata and XMP metadata when writing an image. + +Supported metadata may vary from one plugin to another. Please note that only +the most common metadata are supported and some plugins may return keys not +listed here. + +### EXIF Metadata + +[EXIF (Exchangeable Image File Format)](https://en.wikipedia.org/wiki/Exif) +metadata is a standard for embedding information within the image file itself. + +Unlike the metadata described above, EXIF ​​metadata is used internally by some +plugins to standardize image handling. For example, the JXL plugin uses them +to **set/get the image resolution and metadata**. They are also needed to +make the image properties appear in the file details of some file managers +(e.g. Dolphin). + +When reading, EXIF meta​​data is converted into simple metadata (e.g. +`Description`) and inserted into the image if and only if it is not already +present. + +On writing, the image metadata is converted to EXIF ​​and saved appropriately. +Note that, if not present in the image to be saved, the following metadata +are created automatically: + +- `Software`: Created using `applicationName` and `applicationVersion` methods + of [`QCoreApplication`](https://doc.qt.io/qt-6/qcoreapplication.html). +- `CreationDate`: Set to current time and date. ### ICC profile support -ICC support is fully implemented in all formats that support it. When saving, -some formats convert the image using color profiles according to -specifications. In particular, HDR formats almost always convert to linear +ICC profile support is implemented in all formats that handle them using +[`QColorSpace`](https://doc.qt.io/qt-6/qcolorspace.html). When saving, some +plugins convert the image using color profiles according to format +specifications. In particular, HDR formats almost always convert to linear RGB. ### Maximum image size @@ -194,8 +266,10 @@ compiled with Qt 6.8+. **This plugin can be disabled by setting `KIMAGEFORMATS_DDS` to `OFF` in your cmake options.** -The following defines can be defined in cmake to modify the behavior of the plugin: -- `DDS_DISABLE_STRIDE_ALIGNMENT`: disable the stride aligment based on DDS pitch: it is known that some writers do not set it correctly. +The following defines can be defined in cmake to modify the behavior of the +plugin: +- `DDS_DISABLE_STRIDE_ALIGNMENT`: disable the stride aligment based on DDS + pitch: it is known that some writers do not set it correctly. ### The HEIF plugin @@ -209,9 +283,13 @@ will compile but will fail the tests. ### The EXR plugin -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. +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. ### The EPS plugin @@ -223,8 +301,10 @@ create a temporary PDF file which is then converted to EPS. Therefore, if ### The HDR plugin -The following defines can be defined in cmake to modify the behavior of the plugin: -- `HDR_HALF_QUALITY`: on read, a 16-bit float image is returned instead of a 32-bit float one. +The following defines can be defined in cmake to modify the behavior of the +plugin: +- `HDR_HALF_QUALITY`: on read, a 16-bit float image is returned instead of a + 32-bit float one. ### The JP2 plugin @@ -232,16 +312,19 @@ The following defines can be defined in cmake to modify the behavior of the plug in your cmake options.** JP2 plugin has the following limitations due to the lack of support by OpenJPEG: -- Metadata are not supported -- Image resolution is not supported +- Metadata are not supported. +- Image resolution is not supported. ### The JXL plugin **The current version of the plugin limits the image size to 256 megapixels according to feature level 5 of the JXL stream encoding.** -The following defines can be defined in cmake to modify the behavior of the plugin: -- `JXL_HDR_PRESERVATION_DISABLED`: disable floating point images (both read and write) by converting them to UINT16 images. Any HDR data is lost. Note that FP images are always disabled when compiling with libJXL less than v0.9. +The following defines can be defined in cmake to modify the behavior of the +plugin: +- `JXL_HDR_PRESERVATION_DISABLED`: disable floating point images (both read + and write) by converting them to UINT16 images. Any HDR data is lost. Note + that FP images are always disabled when compiling with libJXL less than v0.9. - `JXL_DECODE_BOXES_DISABLED`: disable reading of metadata (e.g. XMP). ### The JXR plugin @@ -249,11 +332,21 @@ The following defines can be defined in cmake to modify the behavior of the plug **This plugin is disabled by default. It can be enabled by settings `KIMAGEFORMATS_JXR` to `ON` in your cmake options.** -The following defines can be defined in cmake to modify the behavior of the plugin: -- `JXR_DENY_FLOAT_IMAGE`: disables the use of float images and consequently any HDR data will be lost. -- `JXR_DISABLE_DEPTH_CONVERSION`: remove the neeeds of additional memory by disabling the conversion between different color depths (e.g. RGBA64bpp to RGBA32bpp) at the cost of reduced compatibility. -- `JXR_DISABLE_BGRA_HACK`: Windows displays and opens JXR files correctly out of the box. Unfortunately it doesn't seem to open (P)RGBA @32bpp files as it only wants (P)BGRA32bpp files (a format not supported by Qt). Only for this format an hack is activated to guarantee total compatibility of the plugin with Windows. -- `JXR_ENABLE_ADVANCED_METADATA`: enable metadata support (e.g. XMP). Some distributions use an incomplete JXR library that does not allow reading metadata, causing compilation errors. +The following defines can be defined in cmake to modify the behavior of the +plugin: +- `JXR_DENY_FLOAT_IMAGE`: disables the use of float images and consequently + any HDR data will be lost. +- `JXR_DISABLE_DEPTH_CONVERSION`: remove the neeeds of additional memory by + disabling the conversion between different color depths (e.g. RGBA64bpp to + RGBA32bpp) at the cost of reduced compatibility. +- `JXR_DISABLE_BGRA_HACK`: Windows displays and opens JXR files correctly out + of the box. Unfortunately it doesn't seem to open (P)RGBA @32bpp files as + it only wants (P)BGRA32bpp files (a format not supported by Qt). Only for + this format an hack is activated to guarantee total compatibility of the + plugin with Windows. +- `JXR_ENABLE_ADVANCED_METADATA`: enable metadata support (e.g. XMP). Some + distributions use an incomplete JXR library that does not allow reading + metadata, causing compilation errors. ### The KRA plugin @@ -270,15 +363,19 @@ image. ### The PSD plugin PSD support has the following limitations: -- Only images saved by Photoshop using compatibility mode enabled (Photoshop default) can be decoded. +- Only images saved by Photoshop using compatibility mode enabled (Photoshop + default) can be decoded. - Multichannel images are treated as CMYK if they have 2 or more channels. - Multichannel images are treated as Grayscale if they have 1 channel. - Duotone images are treated as grayscale images. - Extra channels other than alpha are discarded. -The following defines can be defined in cmake to modify the behavior of the plugin: -- `PSD_FAST_LAB_CONVERSION`: the LAB image is converted to linear sRGB instead of sRGB which significantly increases performance. -- `PSD_NATIVE_CMYK_SUPPORT_DISABLED`: disable native support for CMYK images when compiled with Qt 6.8+ +The following defines can be defined in cmake to modify the behavior of the +plugin: +- `PSD_FAST_LAB_CONVERSION`: the LAB image is converted to linear sRGB instead + of sRGB which significantly increases performance. +- `PSD_NATIVE_CMYK_SUPPORT_DISABLED`: disable native support for CMYK images + when compiled with Qt 6.8+ ### The RAW plugin @@ -293,7 +390,9 @@ The default setting tries to balance quality and conversion speed. ### The XCF plugin XCF support has the following limitations: -- XCF format up to [version 12](https://testing.developer.gimp.org/core/standards/xcf/#version-history) (no support for GIMP 3). +- XCF format up to [version 12](https://testing.developer.gimp.org/core/standards/xcf/#version-history) + (no support for GIMP 3). - The returned image is always 8-bit. - Cannot read zlib compressed files. -- The rendered image may be slightly different (colors/transparencies) than in GIMP. +- The rendered image may be slightly different (colors/transparencies) than + in GIMP. diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index a432dfa..78772c1 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -19,9 +19,15 @@ macro(kimageformats_read_tests) endif() foreach(_testname ${KIF_RT_UNPARSED_ARGUMENTS}) + string(REGEX MATCH "-skipoptional" _is_skip_optional "${_testname}") + unset(skip_optional_arg) + if (_is_skip_optional) + set(skip_optional_arg "--skip-optional-tests") + string(REGEX REPLACE "-skipoptional$" "" _testname "${_testname}") + endif() add_test( NAME kimageformats-read-${_testname} - COMMAND readtest ${_fuzzarg} ${_testname} + COMMAND readtest ${skip_optional_arg} ${_fuzzarg} ${_testname} ) endforeach(_testname) endmacro() @@ -132,9 +138,15 @@ if (OpenJPEG_FOUND) endif() if (LibJXL_FOUND AND LibJXLThreads_FOUND) - kimageformats_read_tests( - jxl - ) + if(LibJXL_VERSION VERSION_GREATER_EQUAL "0.11.0") + kimageformats_read_tests( + jxl + ) + else() + kimageformats_read_tests( + jxl-skipoptional + ) + endif() kimageformats_write_tests( jxl-nodatacheck-lossless ) diff --git a/autotests/read/jxl/gimp_exif.jxl.json b/autotests/read/jxl/gimp_exif.jxl.json new file mode 100644 index 0000000..0b115c7 --- /dev/null +++ b/autotests/read/jxl/gimp_exif.jxl.json @@ -0,0 +1,19 @@ +[ + { + "fileName" : "gimp_exif.png", + "metadata" : [ + { + "Key" : "CreationDate", + "Value" : "2025-01-05T10:18:16" + }, + { + "Key" : "Software" , + "Value" : "GIMP 3.0.0-RC2" + } + ], + "resolution" : { + "dotsPerMeterX" : 5905, + "dotsPerMeterY" : 6692 + } + } +] diff --git a/autotests/readtest.cpp b/autotests/readtest.cpp index d829a8c..bdef725 100644 --- a/autotests/readtest.cpp +++ b/autotests/readtest.cpp @@ -198,7 +198,7 @@ int main(int argc, char **argv) QCoreApplication::removeLibraryPath(QStringLiteral(PLUGIN_DIR)); QCoreApplication::addLibraryPath(QStringLiteral(PLUGIN_DIR)); QCoreApplication::setApplicationName(QStringLiteral("readtest")); - QCoreApplication::setApplicationVersion(QStringLiteral("1.2.0")); + QCoreApplication::setApplicationVersion(QStringLiteral("1.3.0")); QCommandLineParser parser; parser.setApplicationDescription(QStringLiteral("Performs basic image conversion checking.")); @@ -208,8 +208,11 @@ int main(int argc, char **argv) QCommandLineOption fuzz(QStringList() << QStringLiteral("f") << QStringLiteral("fuzz"), QStringLiteral("Allow for some deviation in ARGB data."), QStringLiteral("max")); - parser.addOption(fuzz); + QCommandLineOption skipOptTest({QStringLiteral("skip-optional-tests")}, + QStringLiteral("Skip optional data tests (metadata, resolution, etc.).")); + parser.addOption(fuzz); + parser.addOption(skipOptTest); parser.process(app); const QStringList args = parser.positionalArguments(); @@ -314,6 +317,7 @@ int main(int argc, char **argv) continue; } + // option test OptionTest optionTest; if (!optionTest.store(&inputReader)) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": error while reading options\n"; @@ -339,6 +343,17 @@ int main(int argc, char **argv) continue; } + // metadata checks + if (!parser.isSet(skipOptTest)) { + QString optError; + if (!timg.checkOptionaInfo(inputImage, optError)) { + QTextStream(stdout) << "FAIL : " << fi.fileName() << " : " << optError << "\n"; + ++failed; + continue; + } + } + + // image compare if (expImage.width() != inputImage.width()) { QTextStream(stdout) << "FAIL : " << fi.fileName() << ": width was " << inputImage.width() << " but " << expfilename << " width was " << expImage.width() << "\n"; diff --git a/autotests/templateimage.cpp b/autotests/templateimage.cpp index f6a2479..4e44097 100644 --- a/autotests/templateimage.cpp +++ b/autotests/templateimage.cpp @@ -12,6 +12,49 @@ #include #include +static QJsonObject searchObject(const QFileInfo& file) +{ + auto fi = QFileInfo(QStringLiteral("%1.json").arg(file.filePath())); + if (!fi.exists()) { + return {}; + } + + QFile f(fi.filePath()); + if (!f.open(QFile::ReadOnly)) { + return {}; + } + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(f.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isArray()) { + return {}; + } + + auto currentQt = QVersionNumber::fromString(qVersion()); + auto arr = doc.array(); + for (auto val : arr) { + if (!val.isObject()) + continue; + auto obj = val.toObject(); + auto minQt = QVersionNumber::fromString(obj.value("minQtVersion").toString()); + auto maxQt = QVersionNumber::fromString(obj.value("maxQtVersion").toString()); + auto name = obj.value("fileName").toString(); + auto unsupportedFormat = obj.value("unsupportedFormat").toBool(); + + // filter + if (name.isEmpty() && !unsupportedFormat) + continue; + if (!minQt.isNull() && currentQt < minQt) + continue; + if (!maxQt.isNull() && currentQt > maxQt) + continue; + return obj; + } + + return {}; +} + + TemplateImage::TemplateImage(const QFileInfo &fi) : m_fi(fi) { @@ -45,6 +88,43 @@ QFileInfo TemplateImage::compareImage(TestFlags &flags, QString& comment) const return legacyImage(); } +bool TemplateImage::checkOptionaInfo(const QImage& image, QString& error) const +{ + auto obj = searchObject(m_fi); + if (obj.isEmpty()) { + return true; + } + + // Test resolution + auto res = obj.value("resolution").toObject(); + if (!res.isEmpty()) { + auto resx = res.value("dotsPerMeterX").toInt(); + auto resy = res.value("dotsPerMeterY").toInt(); + if (resx != image.dotsPerMeterX()) { + error = QStringLiteral("X resolution mismatch (current: %1, expected: %2)!").arg(image.dotsPerMeterX()).arg(resx); + return false; + } + if (resy != image.dotsPerMeterY()) { + error = QStringLiteral("Y resolution mismatch (current: %1, expected: %2)!").arg(image.dotsPerMeterY()).arg(resy); + return false; + } + } + + // Test metadata + auto meta = obj.value("metadata").toArray(); + for (auto jv : meta) { + auto obj = jv.toObject(); + auto key = obj.value("Key").toString(); + auto val = obj.value("Value").toString(); + auto cur = image.text(key); + if (cur != val) { + error = QStringLiteral("Metadata '%1' mismatch (current: '%2', expected:'%3')!").arg(key, cur, val); + return false; + } + } + + return true; +} QStringList TemplateImage::suffixes() { @@ -66,51 +146,25 @@ QFileInfo TemplateImage::legacyImage() const QFileInfo TemplateImage::jsonImage(TestFlags &flags, QString& comment) const { flags = TestFlag::None; - auto fi = QFileInfo(QStringLiteral("%1.json").arg(m_fi.filePath())); - if (!fi.exists()) { + + auto obj = searchObject(m_fi); + if (obj.isEmpty()) { return {}; } - QFile f(fi.filePath()); - if (!f.open(QFile::ReadOnly)) { + auto name = obj.value("fileName").toString(); + auto unsupportedFormat = obj.value("unsupportedFormat").toBool(); + comment = obj.value("comment").toString(); + + if(obj.value("disableAutoTransform").toBool()) { + flags |= TestFlag::DisableAutotransform; + } + + if (unsupportedFormat) { + flags |= TestFlag::SkipTest; return {}; } - QJsonParseError err; - auto doc = QJsonDocument::fromJson(f.readAll(), &err); - if (err.error != QJsonParseError::NoError || !doc.isArray()) { - return {}; - } - - auto currentQt = QVersionNumber::fromString(qVersion()); - auto arr = doc.array(); - for (auto val : arr) { - if (!val.isObject()) - continue; - auto obj = val.toObject(); - auto minQt = QVersionNumber::fromString(obj.value("minQtVersion").toString()); - auto maxQt = QVersionNumber::fromString(obj.value("maxQtVersion").toString()); - auto name = obj.value("fileName").toString(); - auto unsupportedFormat = obj.value("unsupportedFormat").toBool(); - comment = obj.value("comment").toString(); - - if(obj.value("disableAutoTransform").toBool()) - flags |= TestFlag::DisableAutotransform; - - // filter - if (name.isEmpty() && !unsupportedFormat) - continue; - if (!minQt.isNull() && currentQt < minQt) - continue; - if (!maxQt.isNull() && currentQt > maxQt) - continue; - if (unsupportedFormat) { - flags |= TestFlag::SkipTest; - break; - } - return QFileInfo(QStringLiteral("%1/%2").arg(fi.path(), name)); - } - - return {}; + return QFileInfo(QStringLiteral("%1/%2").arg(m_fi.path(), name)); } diff --git a/autotests/templateimage.h b/autotests/templateimage.h index 5368982..c613a59 100644 --- a/autotests/templateimage.h +++ b/autotests/templateimage.h @@ -8,6 +8,7 @@ #define TEMPLATEIMAGE_H #include +#include /*! * \brief The TemplateImage class @@ -60,6 +61,15 @@ public: */ QFileInfo compareImage(TestFlags &flags, QString& comment) const; + /*! + * \brief checkOptionaInfo + * Verify the optional information (resolution, metadata, etc.) of the image with that in the template if present. + * \param image The image to check optional information on. + * \param error The error message when returns false. + * \return True on success, otherwise false. + */ + bool checkOptionaInfo(const QImage& image, QString& error) const; + /*! * \brief suffixes * \return The list of suffixes considered templates. diff --git a/src/imageformats/CMakeLists.txt b/src/imageformats/CMakeLists.txt index bcc9170..d0dd999 100644 --- a/src/imageformats/CMakeLists.txt +++ b/src/imageformats/CMakeLists.txt @@ -75,7 +75,7 @@ endif() ################################## if (LibJXL_FOUND AND LibJXLThreads_FOUND) - kimageformats_add_plugin(kimg_jxl SOURCES jxl.cpp) + kimageformats_add_plugin(kimg_jxl SOURCES jxl.cpp microexif.cpp) target_link_libraries(kimg_jxl PRIVATE PkgConfig::LibJXL PkgConfig::LibJXLThreads) if(LibJXL_VERSION VERSION_GREATER_EQUAL "0.9.0") if(LibJXLCMS_FOUND) diff --git a/src/imageformats/jxl.cpp b/src/imageformats/jxl.cpp index a3f1858..ad970f1 100644 --- a/src/imageformats/jxl.cpp +++ b/src/imageformats/jxl.cpp @@ -10,6 +10,7 @@ #include #include "jxl_p.h" +#include "microexif_p.h" #include "util_p.h" #include @@ -461,6 +462,17 @@ bool QJpegXLHandler::decode_one_frame() m_current_image.setText(QStringLiteral(META_KEY_XMP_ADOBE), QString::fromUtf8(m_xmp)); } + if (!m_exif.isEmpty()) { + auto exif = MicroExif::fromByteArray(m_exif); + // set image resolution + if (exif.horizontalResolution() > 0) + m_current_image.setDotsPerMeterX(qRound(exif.horizontalResolution() / 25.4 * 1000)); + if (exif.verticalResolution() > 0) + m_current_image.setDotsPerMeterY(qRound(exif.verticalResolution() / 25.4 * 1000)); + // set image metadata + exif.toImageMetadata(m_current_image); + } + if (JxlDecoderSetImageOutBuffer(m_decoder, &m_input_pixel_format, m_current_image.bits(), m_buffer_size) != JXL_DEC_SUCCESS) { qWarning("ERROR: JxlDecoderSetImageOutBuffer failed"); m_parseState = ParseJpegXLError; @@ -639,7 +651,6 @@ bool QJpegXLHandler::write(const QImage &image) qWarning("Failed to create Jxl encoder"); return false; } - JxlEncoderUseBoxes(encoder); if (m_quality > 100) { m_quality = 100; @@ -647,8 +658,12 @@ bool QJpegXLHandler::write(const QImage &image) m_quality = 90; } + JxlEncoderUseContainer(encoder, JXL_TRUE); + JxlEncoderUseBoxes(encoder); + JxlBasicInfo output_info; JxlEncoderInitBasicInfo(&output_info); + output_info.have_container = JXL_TRUE; QByteArray iccprofile; QColorSpace tmpcs = image.colorSpace(); @@ -668,8 +683,6 @@ bool QJpegXLHandler::write(const QImage &image) || (pixel_count > FEATURE_LEVEL_5_PIXELS) || (image.width() > FEATURE_LEVEL_5_WIDTH) || (image.height() > FEATURE_LEVEL_5_HEIGHT)) { - output_info.have_container = JXL_TRUE; - JxlEncoderUseContainer(encoder, JXL_TRUE); JxlEncoderSetCodestreamLevel(encoder, 10); } // clang-format on @@ -788,6 +801,7 @@ bool QJpegXLHandler::write(const QImage &image) auto cs = image.colorSpace(); if (cs.isValid() && cs.colorModel() == QColorSpace::ColorModel::Cmyk && image.format() == QImage::Format_CMYK8888) { tmpimage = image.convertedToColorSpace(QColorSpace(QColorSpace::SRgb), tmpformat); + iccprofile.clear(); } else { tmpimage = image.convertToFormat(tmpformat); } @@ -820,6 +834,20 @@ bool QJpegXLHandler::write(const QImage &image) return false; } + auto exif_data = MicroExif::fromImage(image).toByteArray(); + if (!exif_data.isEmpty()) { + exif_data = QByteArray::fromHex("00000000") + exif_data; + const char *box_type = "Exif"; + status = JxlEncoderAddBox(encoder, box_type, reinterpret_cast(exif_data.constData()), exif_data.size(), JXL_FALSE); + if (status != JXL_ENC_SUCCESS) { + qWarning("JxlEncoderAddBox failed!"); + if (runner) { + JxlThreadParallelRunnerDestroy(runner); + } + JxlEncoderDestroy(encoder); + return false; + } + } auto xmp_data = image.text(QStringLiteral(META_KEY_XMP_ADOBE)).toUtf8(); if (!xmp_data.isEmpty()) { const char *box_type = "xml "; diff --git a/src/imageformats/microexif.cpp b/src/imageformats/microexif.cpp new file mode 100644 index 0000000..61418ae --- /dev/null +++ b/src/imageformats/microexif.cpp @@ -0,0 +1,1197 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2025 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.1-or-later +*/ + +#include "microexif_p.h" +#include "util_p.h" + +#include +#include +#include +#include +#include + +// TIFF 6 specs +#define TIFF_IMAGEWIDTH 0x100 +#define TIFF_IMAGEHEIGHT 0x101 +#define TIFF_BITSPERSAMPLE 0x102 +#define TIFF_IMAGEDESCRIPTION 0x10E +#define TIFF_MAKE 0x10F +#define TIFF_MODEL 0x110 +#define TIFF_ORIENT 0x0112 +#define TIFF_XRES 0x011A +#define TIFF_YRES 0x011B +#define TIFF_URES 0x0128 +#define TIFF_SOFTWARE 0x0131 +#define TIFF_ARTIST 0x013B +#define TIFF_DATETIME 0x0132 +#define TIFF_COPYRIGHT 0x8298 + +#define TIFF_VAL_URES_NOABSOLUTE 1 +#define TIFF_VAL_URES_INCH 2 +#define TIFF_VAL_URES_CENTIMETER 3 + +// EXIF 3 specs +#define EXIF_EXIFIFD 0x8769 +#define EXIF_GPSIFD 0x8825 +#define EXIF_OFFSETTIME 0x9010 +#define EXIF_COLORSPACE 0xA001 +#define EXIF_PIXELXDIM 0xA002 +#define EXIF_PIXELYDIM 0xA003 +#define EXIF_IMAGEUNIQUEID 0xA420 +#define EXIF_BODYSERIALNUMBER 0xA431 +#define EXIF_LENSMAKE 0xA433 +#define EXIF_LENSMODEL 0xA434 +#define EXIF_LENSSERIALNUMBER 0xA435 +#define EXIF_IMAGETITLE 0xA436 +#define EXIF_EXIFVERSION 0x9000 + +#define EXIF_VAL_COLORSPACE_SRGB 1 +#define EXIF_VAL_COLORSPACE_UNCAL 0xFFFF + +#define GPS_GPSVERSION 0 +#define GPS_LATITUDEREF 1 +#define GPS_LATITUDE 2 +#define GPS_LONGITUDEREF 3 +#define GPS_LONGITUDE 4 +#define GPS_ALTITUDEREF 5 +#define GPS_ALTITUDE 6 + +#define EXIF_TAG_VALUE(n, byteSize) (((n) << 6) | ((byteSize) & 0x3F)) +#define EXIF_TAG_SIZEOF(dataType) (quint16(dataType) & 0x3F) +#define EXIF_TAG_DATATYPE(dataType) (quint16(dataType) >> 6) + +enum class ExifTagType : quint16 { + // Base data types + Byte = EXIF_TAG_VALUE(1, 1), + Ascii = EXIF_TAG_VALUE(2, 1), + Short = EXIF_TAG_VALUE(3, 2), + Long = EXIF_TAG_VALUE(4, 4), + Rational = EXIF_TAG_VALUE(5, 8), + + // Extended data types + SByte = EXIF_TAG_VALUE(6, 1), + Undefined = EXIF_TAG_VALUE(7, 1), + SShort = EXIF_TAG_VALUE(8, 2), + SLong = EXIF_TAG_VALUE(9, 4), + SRational = EXIF_TAG_VALUE(10, 8), + + Float = EXIF_TAG_VALUE(11, 4), // not used in EXIF specs + Double = EXIF_TAG_VALUE(12, 8), // not used in EXIF specs + Ifd = EXIF_TAG_VALUE(13, 4), // not used in EXIF specs + + // BigTiff data types (EXIF specs are 32-bits only) + Long8 = EXIF_TAG_VALUE(16, 8), // not used in EXIF specs + SLong8 = EXIF_TAG_VALUE(17, 8), // not used in EXIF specs + Ifd8 = EXIF_TAG_VALUE(18, 8), // not used in EXIF specs + + // Exif 3.0 only + Utf8 = EXIF_TAG_VALUE(129, 1) +}; + +using TagPos = QHash; +using KnownTags = QHash; +using TagInfo = std::pair; + +/*! + * \brief staticTagTypes + * The supported tags. + * \note EXIF tags are an extension of TIFF tags, so I'm writing them all together. + */ +// clang-format off +static const KnownTags staticTagTypes = { + TagInfo(TIFF_IMAGEWIDTH, ExifTagType::Long), + TagInfo(TIFF_IMAGEHEIGHT, ExifTagType::Long), + TagInfo(TIFF_BITSPERSAMPLE, ExifTagType::Short), + TagInfo(TIFF_IMAGEDESCRIPTION, ExifTagType::Ascii), + TagInfo(TIFF_MAKE, ExifTagType::Ascii), + TagInfo(TIFF_MODEL, ExifTagType::Ascii), + TagInfo(TIFF_ORIENT, ExifTagType::Short), + TagInfo(TIFF_XRES, ExifTagType::Rational), + TagInfo(TIFF_YRES, ExifTagType::Rational), + TagInfo(TIFF_URES, ExifTagType::Short), + TagInfo(TIFF_SOFTWARE, ExifTagType::Ascii), + TagInfo(TIFF_ARTIST, ExifTagType::Ascii), + TagInfo(TIFF_DATETIME, ExifTagType::Ascii), + TagInfo(TIFF_COPYRIGHT, ExifTagType::Ascii), + TagInfo(EXIF_EXIFIFD, ExifTagType::Long), + TagInfo(EXIF_GPSIFD, ExifTagType::Long), + TagInfo(EXIF_OFFSETTIME, ExifTagType::Ascii), + TagInfo(EXIF_COLORSPACE, ExifTagType::Short), + TagInfo(EXIF_PIXELXDIM, ExifTagType::Long), + TagInfo(EXIF_PIXELYDIM, ExifTagType::Long), + TagInfo(EXIF_IMAGEUNIQUEID, ExifTagType::Ascii), + TagInfo(EXIF_BODYSERIALNUMBER, ExifTagType::Ascii), + TagInfo(EXIF_LENSMAKE, ExifTagType::Ascii), + TagInfo(EXIF_LENSMODEL, ExifTagType::Ascii), + TagInfo(EXIF_LENSSERIALNUMBER, ExifTagType::Ascii), + TagInfo(EXIF_IMAGETITLE, ExifTagType::Ascii), + TagInfo(EXIF_EXIFVERSION, ExifTagType::Undefined) +}; +// clang-format on + +/*! + * \brief staticGpsTagTypes + */ +// clang-format off +static const KnownTags staticGpsTagTypes = { + TagInfo(GPS_GPSVERSION, ExifTagType::Byte), + TagInfo(GPS_LATITUDEREF, ExifTagType::Ascii), + TagInfo(GPS_LATITUDE, ExifTagType::Rational), + TagInfo(GPS_LONGITUDEREF, ExifTagType::Ascii), + TagInfo(GPS_LONGITUDE, ExifTagType::Rational), + TagInfo(GPS_ALTITUDEREF, ExifTagType::Byte), + TagInfo(GPS_ALTITUDE, ExifTagType::Rational) +}; +// clang-format on + +/*! + * \brief tiffStrMap + * TIFF string <-> metadata + */ +// clang-format off +static const QList> tiffStrMap = { + std::pair(TIFF_IMAGEDESCRIPTION, QStringLiteral(META_KEY_DESCRIPTION)), + std::pair(TIFF_ARTIST, QStringLiteral(META_KEY_AUTHOR)), + std::pair(TIFF_SOFTWARE, QStringLiteral(META_KEY_SOFTWARE)), + std::pair(TIFF_COPYRIGHT, QStringLiteral(META_KEY_COPYRIGHT)), + std::pair(TIFF_MAKE, QStringLiteral(META_KEY_MANUFACTURER)), + std::pair(TIFF_MODEL, QStringLiteral(META_KEY_MODEL)) +}; +// clang-format on + +/*! + * \brief exifStrMap + * EXIF string <-> metadata + */ +// clang-format off +static const QList> 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)), +}; +// clang-format on + +/*! + * \brief timeOffset + * \param offset The EXIF string of the offset from UTC. + * \return The offset in minutes. + */ +static qint16 timeOffset(const QString& offset) +{ + if (offset.size() != 6 || offset.at(3) != u':') + return 0; + auto ok = false; + auto hh = offset.left(3).toInt(&ok); + if (!ok) + return 0; + auto mm = offset.mid(4, 2).toInt(&ok) * (hh < 0 ? -1 : 1); + if (!ok) + return 0; + return qint16(hh * 60 + mm); +} + +/*! + * \brief timeOffset + * \param offset Offset from UTC in minutes. + * \return The EXIF string of the offset. + */ +static QString timeOffset(qint16 offset) +{ + auto absOff = quint16(std::abs(offset)); + return QStringLiteral("%1%2:%3") + .arg(offset < 0 ? QStringLiteral("-") : QStringLiteral("+")) + .arg(absOff / 60, 2, 10, QChar(u'0')) + .arg(absOff % 60, 2, 10, QChar(u'0')); +} + + +/*! + * \brief checkHeader + * \param ds The data stream + * \return True if header is a valid EXIF, otherwise false. + */ +static bool checkHeader(QDataStream &ds) +{ + quint16 order; + ds >> order; + if (order == 0x4949) { + ds.setByteOrder(QDataStream::LittleEndian); + } else if (order == 0x4d4d) { + ds.setByteOrder(QDataStream::BigEndian); + } else { + return false; + } + + quint16 version; + ds >> version; + if (version != 0x2A) + return false; + + quint32 offset; + ds >> offset; + offset -= 8; + if (ds.skipRawData(offset) != offset) + return false; + + return ds.status() == QDataStream::Ok; +} + +/*! + * \brief updatePos + * Write the current stram position in \a pos position as uint32. + * \return True on success, otherwise false; + */ +static bool updatePos(QDataStream &ds, quint32 pos) +{ + auto dev = ds.device(); + if (pos != 0) { + auto p = dev->pos(); + if (!dev->seek(pos)) + return false; + ds << quint32(p); + if (!dev->seek(p)) + return false; + } + return ds.status() == QDataStream::Ok; +} + +static qint32 countBytes(const ExifTagType &dataType, const QVariant &value) +{ + auto count = 1; + if (dataType == ExifTagType::Ascii) { + count = value.toString().toLatin1().size() + 1; // ASCIIZ + } else if (dataType == ExifTagType::Utf8) { + count = value.toString().toUtf8().size() + 1; // ASCIIZ + } else if (dataType == ExifTagType::Undefined) { + count = value.toByteArray().size(); + } else if (dataType == ExifTagType::Byte) { + count = value.value>().size(); + } else if (dataType == ExifTagType::Short) { + count = value.value>().size(); + } else if (dataType == ExifTagType::Long || dataType == ExifTagType::Ifd) { + count = value.value>().size(); + } else if (dataType == ExifTagType::SByte) { + count = value.value>().size(); + } else if (dataType == ExifTagType::SShort) { + count = value.value>().size(); + } else if (dataType == ExifTagType::SLong) { + count = value.value>().size(); + } else if (dataType == ExifTagType::Rational || dataType == ExifTagType::SRational || dataType == ExifTagType::Double) { + count = value.value>().size(); + } else if (dataType == ExifTagType::Float) { + count = value.value>().size(); + } + return std::max(1, count); +} + +template +static void writeList(QDataStream &ds, const QVariant &value) +{ + auto l = value.value>(); + if (l.isEmpty()) + l.append(value.toInt()); + for (;l.size() < qsizetype(4 / sizeof(T));) + l.append(T()); + for (auto &&v : l) + ds << v; +} + +inline qint32 rationalPrecision(double v) +{ + v = qAbs(v); + return 8 - qBound(0, v < 1 ? 8 : int(std::log10(v)), 8); +} + +template +static void writeRationalList(QDataStream &ds, const QVariant &value) +{ + auto l = value.value>(); + if (l.isEmpty()) + l.append(value.toDouble()); + for (auto &&v : l) { + auto den = std::pow(10, rationalPrecision(v)); + ds << T(qRound(v * den)); + ds << T(den); + } +} + +static void writeByteArray(QDataStream &ds, const QByteArray &ba) +{ + for (auto &&v : ba) + ds << v; + for (auto n = ba.size(); n < 4; ++n) + ds << char(); +} + +static void writeData(QDataStream &ds, const QVariant &value, const ExifTagType& dataType) +{ + if (dataType == ExifTagType::Ascii) { + writeByteArray(ds, value.toString().toLatin1().append(char())); + } else if (dataType == ExifTagType::Utf8) { + writeByteArray(ds, value.toString().toUtf8().append(char())); + } else if (dataType == ExifTagType::Undefined) { + writeByteArray(ds, value.toByteArray()); + } else if (dataType == ExifTagType::Byte) { + writeList(ds, value); + } else if (dataType == ExifTagType::SByte) { + writeList(ds, value); + } else if (dataType == ExifTagType::Short) { + writeList(ds, value); + } else if (dataType == ExifTagType::SShort) { + writeList(ds, value); + } else if (dataType == ExifTagType::Long || dataType == ExifTagType::Ifd) { + writeList(ds, value); + } else if (dataType == ExifTagType::SLong) { + writeList(ds, value); + } else if (dataType == ExifTagType::Rational) { + writeRationalList(ds, value); + } else if (dataType == ExifTagType::SRational) { + writeRationalList(ds, value); + } +} + +/*! + * \brief writeIfd + * \param ds The stream. + * \param tags The list of tags to write. + * \param pos The position of the TAG value to update with this IFD position. + * \param knownTags List of known and supported tags. + * \return True on success, otherwise false. + */ +static bool writeIfd(QDataStream &ds, const MicroExif::Tags &tags, TagPos &positions, quint32 pos = 0, const KnownTags &knownTags = staticTagTypes) +{ + if (tags.isEmpty()) + return true; + if (!updatePos(ds, pos)) + return false; + + auto keys = tags.keys(); + auto entries = quint16(keys.size()); + ds << entries; + for (auto &&key : keys) { + if (!knownTags.contains(key)) { + continue; + } + auto value = tags.value(key); + auto dataType = knownTags.value(key); + auto count = countBytes(dataType, value); + + ds << quint16(key); + ds << quint16(EXIF_TAG_DATATYPE(dataType)); + ds << quint32(count); + positions.insert(key, quint32(ds.device()->pos())); + auto valueSize = count * EXIF_TAG_SIZEOF(dataType); + if (valueSize > 4) { + ds << quint32(); + } else { + writeData(ds, value, dataType); + } + } + // no more IFDs + ds << quint32(); + + // write data larger than 4 bytes + for (auto &&key : keys) { + if (!knownTags.contains(key)) { + continue; + } + + auto value = tags.value(key); + auto dataType = knownTags.value(key); + auto count = countBytes(dataType, value); + auto valueSize = count * EXIF_TAG_SIZEOF(dataType); + if (valueSize <= 4) + continue; + if (!updatePos(ds, positions.value(key))) + return false; + writeData(ds, value, dataType); + } + + return ds.status() == QDataStream::Ok; +} + +template +static QList readList(QDataStream &ds, quint32 count) +{ + QList l; + T c; + for (quint32 i = 0; i < count; ++i) { + ds >> c; + l.append(c); + } + for (auto n = count; n < quint32(4 / sizeof(T)); ++n) { + ds >> c; + } + return l; +} + +template +static QList readRationalList(QDataStream &ds, quint32 count) +{ + QList l; + for (quint32 i = 0; i < count; ++i) { + T num; + ds >> num; + T den; + ds >> den; + l.append(den == 0 ? 0 : double(num) / double(den)); + } + return l; +} + +static QByteArray readBytes(QDataStream &ds, quint32 count, bool asciiz) +{ + QByteArray l; + if (count == 0) { + return l; + } + char c; + for (quint32 i = 0; i < count; ++i) { + ds >> c; + l.append(c); + } + if (asciiz && l.at(l.size() - 1) == 0) { + l.removeLast(); + } + for (auto n = count; n < 4; ++n) { + ds >> c; + } + return l; +} + +/*! + * \brief readIfd + * \param ds The stream. + * \param tags Where to sotro the read tags. + * \param pos The position of the IFD. + * \param knownTags List of known and supported tags. + * \param nextIfd The position of next IFD (0 if none). + * \return True on succes, otherwise false. + */ +static bool readIfd(QDataStream &ds, MicroExif::Tags &tags, quint32 pos = 0, const KnownTags &knownTags = staticTagTypes, quint32 *nextIfd = nullptr) +{ + auto localNextIfd = quint32(); + if (nextIfd == nullptr) + nextIfd = &localNextIfd; + *nextIfd = 0; + + auto device = ds.device(); + if (pos && !device->seek(pos)) + return false; + + quint16 entries; + ds >> entries; + if (ds.status() != QDataStream::Ok) + return false; + + for (quint16 i = 0; i < entries; ++i) { + quint16 tagId; + ds >> tagId; + quint16 dataType; + ds >> dataType; + quint32 count; + ds >> count; + if (ds.status() != QDataStream::Ok) + return false; + + // search for supported values only + if (!knownTags.contains(tagId)) { + quint32 value; + ds >> value; + continue; + } + + // read TAG data + auto toRead = qint64(count) * EXIF_TAG_SIZEOF(knownTags.value(tagId)); + if (toRead > qint64(device->size())) + return false; + + auto curPos = qint64(); + if (toRead > 4) { + quint32 value; + ds >> value; + curPos = device->pos(); + if (!device->seek(value)) + return false; + } + + if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Ascii) || dataType == EXIF_TAG_DATATYPE(ExifTagType::Utf8)) { + auto l = readBytes(ds, count, true); + if (!l.isEmpty()) + tags.insert(tagId, dataType == EXIF_TAG_DATATYPE(ExifTagType::Utf8) ? QString::fromUtf8(l) : QString::fromLatin1(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Undefined)) { + auto l = readBytes(ds, count, false); + if (!l.isEmpty()) + tags.insert(tagId, l); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Byte)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::SByte)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Short)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::SShort)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Long) || dataType == EXIF_TAG_DATATYPE(ExifTagType::Ifd)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::SLong)) { + auto l = readList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::Rational)) { + auto l = readRationalList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } else if (dataType == EXIF_TAG_DATATYPE(ExifTagType::SRational)) { + auto l = readRationalList(ds, count); + tags.insert(tagId, l.size() == 1 ? QVariant(l.first()) : QVariant::fromValue(l)); + } + + if (curPos > 0 && !device->seek(curPos)) + return false; + } + ds >> *nextIfd; + + return true; +} + +MicroExif::MicroExif() +{ + +} + +void MicroExif::clear() +{ + m_tiffTags.clear(); + m_exifTags.clear(); + m_gpsTags.clear(); +} + +bool MicroExif::isEmpty() const +{ + return m_tiffTags.isEmpty() && m_exifTags.isEmpty() && m_gpsTags.isEmpty(); +} + +double MicroExif::horizontalResolution() const +{ + auto u = m_tiffTags.value(TIFF_URES).toUInt(); + auto v = m_tiffTags.value(TIFF_XRES).toDouble(); + if (u == TIFF_VAL_URES_CENTIMETER) + return v * 2.54; + return v; +} + +void MicroExif::setHorizontalResolution(double hres) +{ + auto u = m_tiffTags.value(TIFF_URES).toUInt(); + if (u == TIFF_VAL_URES_CENTIMETER) { + hres /= 2.54; + } else if (u < TIFF_VAL_URES_INCH) { + m_tiffTags.insert(TIFF_URES, TIFF_VAL_URES_INCH); + } + m_tiffTags.insert(TIFF_XRES, hres); +} + +double MicroExif::verticalResolution() const +{ + auto u = m_tiffTags.value(TIFF_URES).toUInt(); + auto v = m_tiffTags.value(TIFF_YRES).toDouble(); + if (u == TIFF_VAL_URES_CENTIMETER) + return v * 2.54; + return v; +} + +void MicroExif::setVerticalResolution(double vres) +{ + auto u = m_tiffTags.value(TIFF_URES).toUInt(); + if (u == TIFF_VAL_URES_CENTIMETER) { + vres /= 2.54; + } else if (u < TIFF_VAL_URES_INCH) { + m_tiffTags.insert(TIFF_URES, TIFF_VAL_URES_INCH); + } + m_tiffTags.insert(TIFF_YRES, vres); +} + +QColorSpace MicroExif::colosSpace() const +{ + if (m_exifTags.value(EXIF_COLORSPACE).toUInt() == EXIF_VAL_COLORSPACE_SRGB) + return QColorSpace(QColorSpace::SRgb); + return QColorSpace(); +} + +void MicroExif::setColorSpace(const QColorSpace &cs) +{ + auto srgb = cs.transferFunction() == QColorSpace::TransferFunction::SRgb && cs.primaries() == QColorSpace::Primaries::SRgb; + m_exifTags.insert(EXIF_COLORSPACE, srgb ? EXIF_VAL_COLORSPACE_SRGB : EXIF_VAL_COLORSPACE_UNCAL); +} + +void MicroExif::setColorSpace(const QColorSpace::NamedColorSpace &csName) +{ + auto srgb = csName == QColorSpace::SRgb; + m_exifTags.insert(EXIF_COLORSPACE, srgb ? EXIF_VAL_COLORSPACE_SRGB : EXIF_VAL_COLORSPACE_UNCAL); +} + +qint32 MicroExif::width() const +{ + return m_tiffTags.value(TIFF_IMAGEWIDTH).toUInt(); +} + +void MicroExif::setWidth(qint32 w) +{ + m_tiffTags.insert(TIFF_IMAGEWIDTH, w); + m_exifTags.insert(EXIF_PIXELXDIM, w); +} + +qint32 MicroExif::height() const +{ + return m_tiffTags.value(TIFF_IMAGEHEIGHT).toUInt(); +} + +void MicroExif::setHeight(qint32 h) +{ + m_tiffTags.insert(TIFF_IMAGEHEIGHT, h); + m_exifTags.insert(EXIF_PIXELYDIM, h); +} + +quint16 MicroExif::orientation() const +{ + return m_tiffTags.value(TIFF_ORIENT).toUInt(); +} + +void MicroExif::setOrientation(quint16 orient) +{ + if (orient < 1 || orient > 8) + m_tiffTags.remove(TIFF_ORIENT); + else + m_tiffTags.insert(TIFF_ORIENT, orient); +} + +QImageIOHandler::Transformation MicroExif::transformation() const +{ + switch (orientation()) { + case 1: + return QImageIOHandler::TransformationNone; + case 2: + return QImageIOHandler::TransformationMirror; + case 3: + return QImageIOHandler::TransformationRotate180; + case 4: + return QImageIOHandler::TransformationFlip; + case 5: + return QImageIOHandler::TransformationFlipAndRotate90; + case 6: + return QImageIOHandler::TransformationRotate90; + case 7: + return QImageIOHandler::TransformationMirrorAndRotate90; + case 8: + return QImageIOHandler::TransformationRotate270; + default: + break; + }; + return QImageIOHandler::TransformationNone; +} + +void MicroExif::setTransformation(const QImageIOHandler::Transformation &t) +{ + switch (t) { + case QImageIOHandler::TransformationNone: + setOrientation(1); + break; + case QImageIOHandler::TransformationMirror: + setOrientation(2); + break; + case QImageIOHandler::TransformationRotate180: + setOrientation(3); + break; + case QImageIOHandler::TransformationFlip: + setOrientation(4); + break; + case QImageIOHandler::TransformationFlipAndRotate90: + setOrientation(5); + break; + case QImageIOHandler::TransformationRotate90: + setOrientation(6); + break; + case QImageIOHandler::TransformationMirrorAndRotate90: + setOrientation(7); + break; + case QImageIOHandler::TransformationRotate270: + setOrientation(8); + break; + default: + break; + } + setOrientation(0); // no orientation set +} + +QString MicroExif::software() const +{ + return tiffString(TIFF_SOFTWARE); +} + +void MicroExif::setSoftware(const QString &s) +{ + setTiffString(TIFF_SOFTWARE, s); +} + +QString MicroExif::description() const +{ + return tiffString(TIFF_IMAGEDESCRIPTION); +} + +void MicroExif::setDescription(const QString &s) +{ + setTiffString(TIFF_IMAGEDESCRIPTION, s); +} + +QString MicroExif::artist() const +{ + return tiffString(TIFF_ARTIST); +} + +void MicroExif::setArtist(const QString &s) +{ + setTiffString(TIFF_ARTIST, s); +} + +QString MicroExif::copyright() const +{ + return tiffString(TIFF_COPYRIGHT); +} + +void MicroExif::setCopyright(const QString &s) +{ + setTiffString(TIFF_COPYRIGHT, s); +} + +QString MicroExif::make() const +{ + return tiffString(TIFF_MAKE); +} + +void MicroExif::setMake(const QString &s) +{ + setTiffString(TIFF_MAKE, s); +} + +QString MicroExif::model() const +{ + return tiffString(TIFF_MODEL); +} + +void MicroExif::setModel(const QString &s) +{ + setTiffString(TIFF_MODEL, s); +} + +QString MicroExif::serialNumber() const +{ + return tiffString(EXIF_BODYSERIALNUMBER); +} + +void MicroExif::setSerialNumber(const QString &s) +{ + setTiffString(EXIF_BODYSERIALNUMBER, s); +} + +QString MicroExif::lensMake() const +{ + return tiffString(EXIF_LENSMAKE); +} + +void MicroExif::setLensMake(const QString &s) +{ + setTiffString(EXIF_LENSMAKE, s); +} + +QString MicroExif::lensModel() const +{ + return tiffString(EXIF_LENSMODEL); +} + +void MicroExif::setLensModel(const QString &s) +{ + setTiffString(EXIF_LENSMODEL, s); +} + +QString MicroExif::lensSerialNumber() const +{ + return tiffString(EXIF_LENSSERIALNUMBER); +} + +void MicroExif::setLensSerialNumber(const QString &s) +{ + setTiffString(EXIF_LENSSERIALNUMBER, s); +} + +QDateTime MicroExif::dateTime() const +{ + auto dt = QDateTime::fromString(tiffString(TIFF_DATETIME), QStringLiteral("yyyy:MM:dd HH:mm:ss")); + auto ofTag = exifString(EXIF_OFFSETTIME); + if (dt.isValid() && !ofTag.isEmpty()) + dt.setTimeZone(QTimeZone::fromSecondsAheadOfUtc(timeOffset(ofTag) * 60)); + return(dt); +} + +void MicroExif::setDateTime(const QDateTime &dt) +{ + if (!dt.isValid()) { + m_tiffTags.remove(TIFF_DATETIME); + m_exifTags.remove(EXIF_OFFSETTIME); + return; + } + setTiffString(TIFF_DATETIME, dt.toString(QStringLiteral("yyyy:MM:dd HH:mm:ss"))); + setExifString(EXIF_OFFSETTIME, timeOffset(dt.offsetFromUtc() / 60)); +} + +QString MicroExif::title() const +{ + return exifString(EXIF_IMAGETITLE); +} + +void MicroExif::setImageTitle(const QString &s) +{ + setExifString(EXIF_IMAGETITLE, s); +} + +QUuid MicroExif::uniqueId() const +{ + auto s = exifString(EXIF_IMAGEUNIQUEID); + if (s.length() == 32) { + auto tmp = QStringLiteral("%1-%2-%3-%4-%5").arg(s.left(8), s.mid(8, 4), s.mid(12, 4), s.mid(16, 4), s.mid(20)); + return QUuid(tmp); + } + return {}; +} + +void MicroExif::setUniqueId(const QUuid &uuid) +{ + if (uuid.isNull()) + setExifString(EXIF_IMAGEUNIQUEID, QString()); + else + setExifString(EXIF_IMAGEUNIQUEID, uuid.toString(QUuid::WithoutBraces).replace(QStringLiteral("-"), QString())); +} + +double MicroExif::latitude() const +{ + auto ref = gpsString(GPS_LATITUDEREF).toUpper(); + if (ref != QStringLiteral("N") && ref != QStringLiteral("S")) + return qQNaN(); + auto lat = m_gpsTags.value(GPS_LATITUDE).value>(); + if (lat.size() != 3) + return qQNaN(); + auto degree = lat.at(0) + lat.at(1) / 60 + lat.at(2) / 3600; + if (degree < -90.0 || degree > 90.0) + return qQNaN(); + return ref == QStringLiteral("N") ? degree : -degree; +} + +void MicroExif::setLatitude(double degree) +{ + if (degree < -90.0 || degree > 90.0) + return; // invalid latitude + auto adeg = qAbs(degree); + auto min = (adeg - int(adeg)) * 60; + auto sec = (min - int(min)) * 60; + m_gpsTags.insert(GPS_LATITUDEREF, degree < 0 ? QStringLiteral("S") : QStringLiteral("N")); + m_gpsTags.insert(GPS_LATITUDE, QVariant::fromValue(QList() << int(adeg) << int(min) << sec)); +} + +double MicroExif::longitude() const +{ + auto ref = gpsString(GPS_LONGITUDEREF).toUpper(); + if (ref != QStringLiteral("E") && ref != QStringLiteral("W")) + return qQNaN(); + auto lon = m_gpsTags.value(GPS_LONGITUDE).value>(); + if (lon.size() != 3) + return qQNaN(); + auto degree = lon.at(0) + lon.at(1) / 60 + lon.at(2) / 3600; + if (degree < -180.0 || degree > 180.0) + return qQNaN(); + return ref == QStringLiteral("E") ? degree : -degree; +} + +void MicroExif::setLongitude(double degree) +{ + if (degree < -180.0 || degree > 180.0) + return; // invalid longitude + auto adeg = qAbs(degree); + auto min = (adeg - int(adeg)) * 60; + auto sec = (min - int(min)) * 60; + m_gpsTags.insert(GPS_LONGITUDEREF, degree < 0 ? QStringLiteral("W") : QStringLiteral("E")); + m_gpsTags.insert(GPS_LONGITUDE, QVariant::fromValue(QList() << int(adeg) << int(min) << sec)); +} + +double MicroExif::altitude() const +{ + auto ref = m_gpsTags.value(GPS_ALTITUDEREF); + if (ref.isNull()) + return qQNaN(); + auto alt = m_gpsTags.value(GPS_ALTITUDE).toDouble(); + return (ref.toInt() == 0 || ref.toInt() == 2) ? alt : -alt; +} + +void MicroExif::setAltitude(double meters) +{ + m_gpsTags.insert(GPS_ALTITUDEREF, quint8(meters < 0 ? 1 : 0)); + m_gpsTags.insert(GPS_ALTITUDE, meters); +} + +QByteArray MicroExif::toByteArray(const QDataStream::ByteOrder &byteOrder) const +{ + QByteArray ba; + { + QBuffer buf(&ba); + if (!write(&buf, byteOrder)) + return {}; + } + return ba; +} + +bool MicroExif::write(QIODevice *device, const QDataStream::ByteOrder &byteOrder) const +{ + if (device == nullptr || device->isSequential() || isEmpty()) + return false; + if (device->open(QBuffer::WriteOnly)) { + QDataStream ds(device); + ds.setByteOrder(byteOrder); + if (!writeHeader(ds)) + return false; + if (!writeIfds(ds)) + return false; + } + device->close(); + return true; +} + +void MicroExif::toImageMetadata(QImage &targetImage, bool replaceExisting) const +{ + // set TIFF strings + for (auto &&p : tiffStrMap) { + if (!replaceExisting && !targetImage.text(p.second).isEmpty()) + continue; + auto s = tiffString(p.first); + if (!s.isEmpty()) + targetImage.setText(p.second, s); + } + + // set EXIF strings + for (auto &&p : exifStrMap) { + if (!replaceExisting && !targetImage.text(p.second).isEmpty()) + continue; + auto s = exifString(p.first); + if (!s.isEmpty()) + targetImage.setText(p.second, s); + } + + // set date and time + if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_CREATIONDATE)).isEmpty()) { + auto dt = dateTime(); + if (dt.isValid()) + targetImage.setText(QStringLiteral(META_KEY_CREATIONDATE), dt.toString(Qt::ISODate)); + } + + // set GPS info + if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_ALTITUDE)).isEmpty()) { + auto v = altitude(); + if (!qIsNaN(v)) + targetImage.setText(QStringLiteral(META_KEY_ALTITUDE), QStringLiteral("%1").arg(v, 0, 'g', 9)); + } + if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_LATITUDE)).isEmpty()) { + auto v = latitude(); + if (!qIsNaN(v)) + targetImage.setText(QStringLiteral(META_KEY_LATITUDE), QStringLiteral("%1").arg(v, 0, 'g', 9)); + } + if (replaceExisting || targetImage.text(QStringLiteral(META_KEY_LONGITUDE)).isEmpty()) { + auto v = longitude(); + if (!qIsNaN(v)) + targetImage.setText(QStringLiteral(META_KEY_LONGITUDE), QStringLiteral("%1").arg(v, 0, 'g', 9)); + } +} + +MicroExif MicroExif::fromByteArray(const QByteArray &ba) +{ + QBuffer buf; + buf.setData(ba); + return fromDevice(&buf); +} + +MicroExif MicroExif::fromDevice(QIODevice *device) +{ + if (device == nullptr || device->isSequential()) + return {}; + if (!device->open(QBuffer::ReadOnly)) + return {}; + + QDataStream ds(device); + if (!checkHeader(ds)) + return {}; + + MicroExif exif; + + // read TIFF ifd + if (!readIfd(ds, exif.m_tiffTags)) + return {}; + + // read EXIF ifd + if (auto pos = exif.m_tiffTags.value(EXIF_EXIFIFD).toUInt()) { + if (!readIfd(ds, exif.m_exifTags, pos)) + return {}; + } + + // read GPS ifd + if (auto pos = exif.m_tiffTags.value(EXIF_GPSIFD).toUInt()) { + if (!readIfd(ds, exif.m_gpsTags, pos, staticGpsTagTypes)) + return {}; + } + + return exif; +} + +MicroExif MicroExif::fromImage(const QImage &image) +{ + MicroExif exif; + if (image.isNull()) + return exif; + + // Image properties + exif.setWidth(image.width()); + exif.setHeight(image.height()); + exif.setHorizontalResolution(image.dotsPerMeterX() * 25.4 / 1000); + exif.setVerticalResolution(image.dotsPerMeterY() * 25.4 / 1000); + exif.setColorSpace(image.colorSpace()); + + // TIFF strings + for (auto &&p : tiffStrMap) { + exif.setTiffString(p.first, image.text(p.second)); + } + + // EXIF strings + for (auto &&p : exifStrMap) { + exif.setExifString(p.first, image.text(p.second)); + } + + // TIFF Software + if (exif.software().isEmpty()) { + auto sw = QCoreApplication::applicationName(); + auto ver = sw = QCoreApplication::applicationVersion(); + if (!sw.isEmpty() && !ver.isEmpty()) + sw.append(QStringLiteral(" %1").arg(ver)); + exif.setSoftware(sw.trimmed()); + } + + // TIFF Creation date and time + auto dt = QDateTime::fromString(image.text(QStringLiteral(META_KEY_CREATIONDATE)), Qt::ISODate); + if (!dt.isValid()) + dt = QDateTime::currentDateTime(); + exif.setDateTime(dt); + + // GPS Info + auto ok = false; + auto alt = image.text(QStringLiteral(META_KEY_ALTITUDE)).toDouble(&ok); + if (ok) + exif.setAltitude(alt); + auto lat = image.text(QStringLiteral(META_KEY_LATITUDE)).toDouble(&ok); + if (ok) + exif.setLatitude(lat); + auto lon = image.text(QStringLiteral(META_KEY_LONGITUDE)).toDouble(&ok); + if (ok) + exif.setLongitude(lon); + + return exif; +} + +void MicroExif::setTiffString(quint16 tagId, const QString &s) +{ + MicroExif::setString(m_tiffTags, tagId, s); +} + +QString MicroExif::tiffString(quint16 tagId) const +{ + return MicroExif::string(m_tiffTags, tagId); +} + +void MicroExif::setExifString(quint16 tagId, const QString &s) +{ + MicroExif::setString(m_exifTags, tagId, s); +} + +QString MicroExif::exifString(quint16 tagId) const +{ + return MicroExif::string(m_exifTags, tagId); +} + +void MicroExif::setGpsString(quint16 tagId, const QString &s) +{ + MicroExif::setString(m_gpsTags, tagId, s); +} + +QString MicroExif::gpsString(quint16 tagId) const +{ + return MicroExif::string(m_gpsTags, tagId); +} + +bool MicroExif::writeHeader(QDataStream &ds) const +{ + if (ds.byteOrder() == QDataStream::LittleEndian) + ds << quint16(0x4949); // II + else + ds << quint16(0x4d4d); // MM + ds << quint16(0x002a); // Tiff V6 + ds << quint32(8); // IFD offset + return ds.status() == QDataStream::Ok; +} + +bool MicroExif::writeIfds(QDataStream &ds) const +{ + auto tiffTags = m_tiffTags; + auto exifTags = m_exifTags; + auto gpsTags = m_gpsTags; + updateTags(tiffTags, exifTags, gpsTags); + + TagPos positions; + if (!writeIfd(ds, tiffTags, positions)) + return false; + if (!writeIfd(ds, exifTags, positions, positions.value(EXIF_EXIFIFD))) + return false; + if (!writeIfd(ds, gpsTags, positions, positions.value(EXIF_GPSIFD), staticGpsTagTypes)) + return false; + return true; +} + +void MicroExif::updateTags(Tags &tiffTags, Tags &exifTags, Tags &gpsTags) const +{ + if (exifTags.isEmpty()) { + tiffTags.remove(EXIF_EXIFIFD); + } else { + tiffTags.insert(EXIF_EXIFIFD, quint32()); + exifTags.insert(EXIF_EXIFVERSION, QByteArray("0300")); + } + if (gpsTags.isEmpty()) { + tiffTags.remove(EXIF_GPSIFD); + } else { + tiffTags.insert(EXIF_GPSIFD, quint32()); + gpsTags.insert(GPS_GPSVERSION, QByteArray("2400")); + } +} + +void MicroExif::setString(Tags &tags, quint16 tagId, const QString &s) +{ + if (s.isEmpty()) + tags.remove(tagId); + else + tags.insert(tagId, s); +} + +QString MicroExif::string(const Tags &tags, quint16 tagId) +{ + return tags.value(tagId).toString(); +} diff --git a/src/imageformats/microexif_p.h b/src/imageformats/microexif_p.h new file mode 100644 index 0000000..724a0ac --- /dev/null +++ b/src/imageformats/microexif_p.h @@ -0,0 +1,307 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2025 Mirco Miranda + + SPDX-License-Identifier: LGPL-2.1-or-later +*/ + +#ifndef MICROEXIF_P_H +#define MICROEXIF_P_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if Q_BYTE_ORDER == Q_LITTLE_ENDIAN +#define EXIF_DEFAULT_BYTEORDER QDataStream::LittleEndian +#else +#define EXIF_DEFAULT_BYTEORDER QDataStream::BigEndian +#endif + +/*! + * \brief The MicroExif class + * Class to extract / write minimal EXIF data (e.g. resolution, rotation, + * some strings). + * + * This class is a partial (or rather minimal) implementation and is only used + * to avoid including external libraries when only a few tags are needed. + * + * It reads/writes the main IFD only. + */ +class MicroExif +{ +public: + using Tags = QMap; + + /*! + * \brief MicroExif + * Constructs an empty class. + * \sa isEmpty + */ + MicroExif(); + + MicroExif(const MicroExif &other) = default; + MicroExif &operator=(const MicroExif &other) = default; + + /*! + * \brief clear + * Removes all items. + */ + void clear(); + + /*! + * \brief isEmpty + * \return True if it contains no items, otherwise false. + */ + bool isEmpty() const; + + /*! + * \brief horizontalResolution + * \return The horizontal resolution in DPI. + */ + double horizontalResolution() const; + void setHorizontalResolution(double hres); + + /*! + * \brief verticalResolution + * \return The vertical resolution in DPI. + */ + double verticalResolution() const; + void setVerticalResolution(double vres); + + /*! + * \brief colosSpace + * \return sRGB color space or an invalid one. + */ + QColorSpace colosSpace() const; + void setColorSpace(const QColorSpace& cs); + void setColorSpace(const QColorSpace::NamedColorSpace& csName); + + /*! + * \brief width + * \return The image width. + */ + qint32 width() const; + void setWidth(qint32 w); + + /*! + * \brief height + * \return The image height. + */ + qint32 height() const; + void setHeight(qint32 h); + + /*! + * \brief orientation + * The orientation of the image with respect to the rows and columns. + * + * Valid orientation values: + * - 1 = The 0th row is at the visual top of the image, and the 0th column is the visual left-hand side. + * - 2 = The 0th row is at the visual top of the image, and the 0th column is the visual right-hand side. + * - 3 = The 0th row is at the visual bottom of the image, and the 0th column is the visual right-hand side. + * - 4 = The 0th row is at the visual bottom of the image, and the 0th column is the visual left-hand side. + * - 5 = The 0th row is the visual left-hand side of the image, and the 0th column is the visual top. + * - 6 = The 0th row is the visual right-hand side of the image, and the 0th column is the visual top. + * - 7 = The 0th row is the visual right-hand side of the image, and the 0th column is the visual bottom. + * - 8 = The 0th row is the visual left-hand side of the image, and the 0th column is the visual bottom. + * \return The orientation value or 0 if none. + * \sa transformation + */ + quint16 orientation() const; + void setOrientation(quint16 orient); + + /*! + * \brief transformation + * \return The orientation converted in the equvalent Qt transformation. + * \sa orientation + */ + QImageIOHandler::Transformation transformation() const; + void setTransformation(const QImageIOHandler::Transformation& t); + + /*! + * \brief software + * \return Name and version number of the software package(s) used to create the image. + */ + QString software() const; + void setSoftware(const QString& s); + + /*! + * \brief description + * \return A string that describes the subject of the image. + */ + QString description() const; + void setDescription(const QString& s); + + /*! + * \brief artist + * \return Person who created the image. + */ + QString artist() const; + void setArtist(const QString& s); + + /*! + * \brief copyright + * \return Copyright notice of the person or organization that claims the copyright to the image. + */ + QString copyright() const; + void setCopyright(const QString& s); + + /*! + * \brief make + * \return The manufacturer of the recording equipment. + */ + QString make() const; + void setMake(const QString& s); + + /*! + * \brief model + * \return The model name or model number of the equipment. + */ + QString model() const; + void setModel(const QString& s); + + /*! + * \brief serialNumber + * \return The serial number of the recording equipment. + */ + QString serialNumber() const; + void setSerialNumber(const QString &s); + + /*! + * \brief lensMake + * \return The manufacturer of the interchangeable lens that was used. + */ + QString lensMake() const; + void setLensMake(const QString &s); + + /*! + * \brief lensModel + * \return The model name or model number of the lens that was used. + */ + QString lensModel() const; + void setLensModel(const QString &s); + + /*! + * \brief lensSerialNumber + * \return The serial number of the interchangeable lens that was used. + */ + QString lensSerialNumber() const; + void setLensSerialNumber(const QString &s); + + /*! + * \brief dateTime + * \return Creation date and time. + */ + QDateTime dateTime() const; + void setDateTime(const QDateTime& dt); + + /*! + * \brief title + * \return The title of the image. + */ + QString title() const; + void setImageTitle(const QString &s); + + /*! + * \brief uniqueId + * \return An identifier assigned uniquely to each image or null one if none. + */ + QUuid uniqueId() const; + void setUniqueId(const QUuid &uuid); + + /*! + * \brief latitude + * \return Floating-point number indicating the latitude in degrees north of the equator (e.g. 27.717) or NaN if not set. + */ + double latitude() const; + void setLatitude(double degree); + + /*! + * \brief longitude + * \return Floating-point number indicating the longitude in degrees east of Greenwich (e.g. 85.317) or NaN if not set. + */ + double longitude() const; + void setLongitude(double degree); + + /*! + * \brief altitude + * \return Floating-point number indicating the GPS altitude in meters above sea level or ellipsoidal surface (e.g. 35.4) or NaN if not set. + * \note It makes no distinction between an 'ellipsoidal surface' and 'sea level'. + */ + double altitude() const; + void setAltitude(double meters); + + /*! + * \brief toByteArray + * \param byteOrder Sets the serialization byte order for EXIF data. + * \return A byte array containing the serialized data. + */ + QByteArray toByteArray(const QDataStream::ByteOrder &byteOrder = EXIF_DEFAULT_BYTEORDER) const; + + /*! + * \brief write + * Serialize the class on a device. + * \param device A random access device. + * \param byteOrder Sets the serialization byte order for EXIF data. + * \return True on success, otherwise false. + */ + bool write(QIODevice *device, const QDataStream::ByteOrder &byteOrder = EXIF_DEFAULT_BYTEORDER) const; + + /*! + * \brief toImageMetadata + * Helper to set metadata in an image. + * \param targetImage The image to set metadata on. + * \param replaceExisting Replaces any existing metadata. + */ + void toImageMetadata(QImage& targetImage, bool replaceExisting = false) const; + + /*! + * \brief fromByteArray + * Creates the class from RAW EXIF data. + * \return The created class (empty on error). + * \sa isEmpty + */ + static MicroExif fromByteArray(const QByteArray &ba); + + /*! + * \brief fromDevice + * Creates the class from a device. + * \param device A random access device. + * \return The created class (empty on error). + * \sa isEmpty + */ + static MicroExif fromDevice(QIODevice *device); + + /*! + * \brief fromImage + * Creates the class and fill it with image info (e.g. resolution). + */ + static MicroExif fromImage(const QImage &image); + +private: + void setTiffString(quint16 tagId, const QString &s); + QString tiffString(quint16 tagId) const; + void setExifString(quint16 tagId, const QString& s); + QString exifString(quint16 tagId) const; + void setGpsString(quint16 tagId, const QString& s); + QString gpsString(quint16 tagId) const; + bool writeHeader(QDataStream &ds) const; + bool writeIfds(QDataStream &ds) const; + void updateTags(Tags &tiffTags, Tags &exifTags, Tags &gpsTags) const; + + static void setString(Tags &tags, quint16 tagId, const QString &s); + static QString string(const Tags &tags, quint16 tagId); + +private: + Tags m_tiffTags; + Tags m_exifTags; + Tags m_gpsTags; +}; + +#endif // MICROEXIF_P_H