/* MIT License Copyright (c) 2026 Poul Sander Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "exifreader.h" #include #include #include #include #include #include #include #include // Raw EXIF date format is "YYYY:MM:DD HH:MM:SS"; reformat date separators to dashes static QString formatDate(const QString &raw) { if (raw.size() >= 10 && raw[4] == u':' && raw[7] == u':') return raw.left(4) + u'-' + raw.mid(5, 2) + u'-' + raw.mid(8, 2) + raw.mid(10); return raw; } static QString findTag(const Exiv2::ExifData &exif, const std::string &key) { try { Exiv2::ExifData::const_iterator it = exif.findKey(Exiv2::ExifKey(key)); if (it != exif.end()) return QString::fromStdString(it->print(&exif)).trimmed(); } catch (...) {} return {}; } static void addField(QList> &result, const char *label, const QString &value) { if (!value.isEmpty()) result.append({QCoreApplication::translate("ExifReader", label), value}); } static double parseGpsCoordinate(const Exiv2::ExifData &exif, const std::string &key) { try { Exiv2::ExifData::const_iterator it = exif.findKey(Exiv2::ExifKey(key)); if (it != exif.end() && it->count() >= 3) { double d = it->toRational(0).first / static_cast(it->toRational(0).second); double m = it->toRational(1).first / static_cast(it->toRational(1).second); double s = it->toRational(2).first / static_cast(it->toRational(2).second); return d + m / 60.0 + s / 3600.0; } } catch (...) {} return 0.0; } // EXIF orientation values run 1-8 and may encode mirroring. A 90-degree // rotation maps each value to another; the tables below cover all eight so // mirrored images stay correct. Unknown values are treated as 1 (normal). static int orientationRotatedClockwise(int orientation) { switch (orientation) { case 1: return 6; case 2: return 7; case 3: return 8; case 4: return 5; case 5: return 2; case 6: return 3; case 7: return 4; case 8: return 1; default: return 6; } } static int orientationRotatedCounterClockwise(int orientation) { switch (orientation) { case 1: return 8; case 2: return 5; case 3: return 6; case 4: return 7; case 5: return 4; case 6: return 1; case 7: return 2; case 8: return 3; default: return 8; } } bool ExifData::isEmpty() const { return captionAbstract.isEmpty() && description.isEmpty() && dateTime.isEmpty() && make.isEmpty() && model.isEmpty() && exposureTime.isEmpty() && fNumber.isEmpty() && iso.isEmpty() && focalLength.isEmpty() && flash.isEmpty() && filename.isEmpty() && dimensions.isEmpty() && fileSize.isEmpty() && orientation.isEmpty() && latitude.isEmpty() && longitude.isEmpty() && osmLink.isEmpty(); } QList> ExifData::toList() const { QList> result; addField(result, QT_TRANSLATE_NOOP("ExifReader", "Caption"), captionAbstract); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Description"), description); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Date/Time"), dateTime); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Camera Make"), make); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Camera Model"), model); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Exposure"), exposureTime); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Aperture"), fNumber); addField(result, QT_TRANSLATE_NOOP("ExifReader", "ISO"), iso); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Focal Length"), focalLength); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Flash"), flash); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Filename"), filename); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Dimensions"), dimensions); addField(result, QT_TRANSLATE_NOOP("ExifReader", "File Size"), fileSize); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Orientation"), orientation); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Latitude"), latitude); addField(result, QT_TRANSLATE_NOOP("ExifReader", "Longitude"), longitude); addField(result, QT_TRANSLATE_NOOP("ExifReader", "OpenStreetMap"), osmLink); return result; } ExifData ExifReader::read(const QByteArray &path) { ExifData data; struct ::stat st{}; if (::stat(path.constData(), &st) != 0 || !S_ISREG(st.st_mode)) return data; // Filename { const std::filesystem::path fsPath(path.toStdString()); const std::string nativeName = fsPath.filename().native(); data.filename = QString::fromLocal8Bit( nativeName.data(), static_cast(nativeName.size())); } // File size { qint64 size = static_cast(st.st_size); if (size < 1024) data.fileSize = QString::number(size) + QLatin1String(" B"); else if (size < 1024 * 1024) data.fileSize = QString::number(size / 1024.0, 'f', 1) + QLatin1String(" KiB"); else data.fileSize = QString::number(size / (1024.0 * 1024.0), 'f', 1) + QLatin1String(" MiB"); } // Image dimensions via Qt (reads only the header, no full decode needed) { int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC); if (fd >= 0) { QFile file; if (file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { QImageReader reader(&file); reader.setDecideFormatFromContent(true); QSize sz = reader.size(); if (sz.isValid()) data.dimensions = QString::fromLatin1("%1 * %2") .arg(sz.width()).arg(sz.height()); } else { ::close(fd); } } } // EXIF tags via exiv2 try { std::unique_ptr image = Exiv2::ImageFactory::open(path.toStdString()); image->readMetadata(); const Exiv2::ExifData &exif = image->exifData(); data.dateTime = formatDate(findTag(exif, "Exif.Photo.DateTimeOriginal")); if (data.dateTime.isEmpty()) data.dateTime = formatDate(findTag(exif, "Exif.Image.DateTime")); data.make = findTag(exif, "Exif.Image.Make"); data.model = findTag(exif, "Exif.Image.Model"); data.exposureTime = findTag(exif, "Exif.Photo.ExposureTime"); data.fNumber = findTag(exif, "Exif.Photo.FNumber"); data.iso = findTag(exif, "Exif.Photo.ISOSpeedRatings"); data.focalLength = findTag(exif, "Exif.Photo.FocalLength"); data.flash = findTag(exif, "Exif.Photo.Flash"); data.description = findTag(exif, "Exif.Image.ImageDescription"); data.orientation = findTag(exif, "Exif.Image.Orientation"); // GPS QString latStr = findTag(exif, "Exif.GPSInfo.GPSLatitude"); QString latRef = findTag(exif, "Exif.GPSInfo.GPSLatitudeRef"); if (!latStr.isEmpty() && !latRef.isEmpty()) { data.latitude = latStr + " " + latRef; } QString lonStr = findTag(exif, "Exif.GPSInfo.GPSLongitude"); QString lonRef = findTag(exif, "Exif.GPSInfo.GPSLongitudeRef"); if (!lonStr.isEmpty() && !lonRef.isEmpty()) { data.longitude = lonStr + " " + lonRef; } if (!data.latitude.isEmpty() && !data.longitude.isEmpty()) { double lat = parseGpsCoordinate(exif, "Exif.GPSInfo.GPSLatitude"); if (latRef == "S") lat = -lat; double lon = parseGpsCoordinate(exif, "Exif.GPSInfo.GPSLongitude"); if (lonRef == "W") lon = -lon; data.osmLink = QString::fromLatin1("https://www.openstreetmap.org/?mlat=%1&mlon=%2#map=16/%1/%2") .arg(lat, 0, 'f', 6).arg(lon, 0, 'f', 6); } // IPTC Caption-Abstract const Exiv2::IptcData &iptc = image->iptcData(); auto iptcIt = iptc.findKey(Exiv2::IptcKey("Iptc.Application2.Caption")); if (iptcIt != iptc.end()) data.captionAbstract = QString::fromStdString(iptcIt->toString()).trimmed(); } catch (const Exiv2::Error &) { // No EXIF or unsupported format — leave tags empty } return data; } bool ExifReader::saveCaption(const QByteArray &path, const QString &caption, const ExifData &oldData) { try { std::unique_ptr image = Exiv2::ImageFactory::open(path.toStdString()); image->readMetadata(); // Erase all existing Caption-Abstract entries then add the new one Exiv2::IptcData &iptc = image->iptcData(); { Exiv2::IptcKey key("Iptc.Application2.Caption"); auto it = iptc.findKey(key); while (it != iptc.end()) { it = iptc.erase(it); it = iptc.findKey(key); } } Exiv2::Iptcdatum datum(Exiv2::IptcKey("Iptc.Application2.Caption")); datum.setValue(caption.toStdString()); iptc.add(datum); // If Caption-Abstract and Description were previously in sync, keep them in sync if (oldData.captionAbstract == oldData.description) { image->exifData()["Exif.Image.ImageDescription"] = caption.toStdString(); } image->writeMetadata(); return true; } catch (const Exiv2::Error &) { return false; } } bool ExifReader::rotate(const QByteArray &path, bool clockwise) { try { std::unique_ptr image = Exiv2::ImageFactory::open(path.toStdString()); image->readMetadata(); Exiv2::ExifData &exif = image->exifData(); int current = 1; // EXIF default when the tag is absent auto it = exif.findKey(Exiv2::ExifKey("Exif.Image.Orientation")); if (it != exif.end()) current = static_cast(it->toInt64()); const int updated = clockwise ? orientationRotatedClockwise(current) : orientationRotatedCounterClockwise(current); exif["Exif.Image.Orientation"] = static_cast(updated); image->writeMetadata(); return true; } catch (const Exiv2::Error &) { return false; } }