diff --git a/CMakeLists.txt b/CMakeLists.txt index ba8f1c8..c77dbd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,8 +7,21 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) find_package(Qt6 REQUIRED COMPONENTS Widgets LinguistTools) -find_package(PkgConfig REQUIRED) -pkg_check_modules(EXIV2 REQUIRED exiv2) +include(FetchContent) +set(EXIV2_BUILD_SAMPLES OFF CACHE INTERNAL "") +set(EXIV2_BUILD_EXIV2_COMMAND OFF CACHE INTERNAL "") +set(EXIV2_BUILD_UNIT_TESTS OFF CACHE INTERNAL "") +set(EXIV2_BUILD_DOC OFF CACHE INTERNAL "") +set(EXIV2_ENABLE_INIH OFF CACHE INTERNAL "") +FetchContent_Declare( + exiv2 + GIT_REPOSITORY https://github.com/Exiv2/exiv2.git + GIT_TAG v0.28.8 +) +FetchContent_MakeAvailable(exiv2) + +# Exiv2 0.28.8 generates this header in the build root but doesn't add it to include paths +target_include_directories(exiv2lib INTERFACE $) qt_add_executable(SagoImageBrowser src/main.cpp @@ -38,12 +51,11 @@ qt_add_executable(SagoImageBrowser set_source_files_properties(extra/icons/sago_image_browser.svg PROPERTIES QT_RESOURCE_ALIAS sago_image_browser.svg) qt_add_resources(SagoImageBrowser "icons" PREFIX "/icons" FILES extra/icons/sago_image_browser.svg) -target_include_directories(SagoImageBrowser PRIVATE ${EXIV2_INCLUDE_DIRS}) -target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets ${EXIV2_LIBRARIES}) +target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets exiv2lib) add_executable(test_exifreader test/test_exifreader.cpp src/exifreader.cpp) -target_include_directories(test_exifreader PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ${EXIV2_INCLUDE_DIRS}) -target_link_libraries(test_exifreader PRIVATE Qt6::Widgets ${EXIV2_LIBRARIES}) +target_include_directories(test_exifreader PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(test_exifreader PRIVATE Qt6::Widgets exiv2lib) qt_add_translations(SagoImageBrowser TS_FILES translations/da.ts diff --git a/extra/docker/Dockerfile b/extra/docker/Dockerfile index d3579f3..9c24b31 100644 --- a/extra/docker/Dockerfile +++ b/extra/docker/Dockerfile @@ -7,6 +7,8 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apt-get update && apt-get install --no-install-recommends -y \ build-essential \ cmake \ + git \ + ca-certificates \ pkg-config \ qt6-base-dev \ qt6-tools-dev \ @@ -14,7 +16,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ libqt6core6 \ libqt6gui6 \ libqt6widgets6 \ - libexiv2-dev \ + zlib1g-dev \ + libexpat1-dev \ + libbrotli-dev \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /staging/sago_image_browser diff --git a/src/exifreader.cpp b/src/exifreader.cpp index 0ee3e61..48ab6d6 100644 --- a/src/exifreader.cpp +++ b/src/exifreader.cpp @@ -34,6 +34,39 @@ static void addField(QList> &result, const char *label, result.append({QString::fromLatin1(label), value}); } +// 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() @@ -172,3 +205,27 @@ bool ExifReader::saveCaption(const QByteArray &path, 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; + } +} diff --git a/src/exifreader.h b/src/exifreader.h index c6b1d29..bdd6632 100644 --- a/src/exifreader.h +++ b/src/exifreader.h @@ -34,4 +34,9 @@ ExifData read(const QByteArray &path); bool saveCaption(const QByteArray &path, const QString &caption, const ExifData &oldData); + +// Rotate the image 90 degrees by updating the Exif.Image.Orientation tag only +// (lossless — pixel data is left untouched). clockwise=true rotates right. +// Returns true on success. +bool rotate(const QByteArray &path, bool clockwise); } // namespace ExifReader diff --git a/src/imagemodel.cpp b/src/imagemodel.cpp index 340c6f6..e15e0f5 100644 --- a/src/imagemodel.cpp +++ b/src/imagemodel.cpp @@ -226,6 +226,23 @@ void ImageModel::requestThumbnails(int firstRow, int lastRow) queueRow(row); } +void ImageModel::refreshThumbnail(const QModelIndex &index) +{ + if (!index.isValid()) + return; + + const int row = index.row(); + if (row < 0 || row >= m_items.size() || m_items[row].isFolder) + return; + + // The on-disk thumbnail cache is keyed by source mtime, so it invalidates + // itself once the file is rewritten. Drop the in-memory copy and re-decode. + m_items[row].thumbnail = m_placeholder; + m_items[row].loaded = false; + emit dataChanged(index, index, {Qt::DecorationRole}); + queueRow(row); +} + void ImageModel::queueRow(int row) { if (m_pendingRows.contains(row)) diff --git a/src/imagemodel.h b/src/imagemodel.h index e64708d..35b200c 100644 --- a/src/imagemodel.h +++ b/src/imagemodel.h @@ -31,6 +31,8 @@ public: QByteArray currentDirectory() const; void requestThumbnails(int firstRow, int lastRow); + // Discard the cached thumbnail for a row and re-decode it from disk. + void refreshThumbnail(const QModelIndex &index); void setCacheMaxSize(int n); void setThumbnailSize(ThumbnailCache::Size size); diff --git a/src/imageviewwidget.cpp b/src/imageviewwidget.cpp index 837873f..ed2cd7c 100644 --- a/src/imageviewwidget.cpp +++ b/src/imageviewwidget.cpp @@ -171,6 +171,11 @@ void ImageViewWidget::clearCache() m_neighborPaths.clear(); } +void ImageViewWidget::invalidateCache(const QByteArray &path) +{ + m_cache.remove(path); +} + void ImageViewWidget::setBackgroundColor(const QString &color) { m_backgroundColor = color; diff --git a/src/imageviewwidget.h b/src/imageviewwidget.h index be1091a..78f13a1 100644 --- a/src/imageviewwidget.h +++ b/src/imageviewwidget.h @@ -45,6 +45,8 @@ public: void prefetchImage(const QByteArray &path); void setNeighborPaths(const QList &paths); void clearCache(); + // Drop any cached pixmap for path so it is re-decoded from disk next time. + void invalidateCache(const QByteArray &path); void setExifData(const ExifData &data); void toggleExifOverlay(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 092e327..bfd1d7e 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -640,6 +640,9 @@ void MainWindow::showImageContextMenu(const QPoint &pos) QAction *copyFilenameAction = menu.addAction(tr("Copy filename")); QAction *copyFullPathAction = menu.addAction(tr("Copy full path")); menu.addSeparator(); + QAction *rotateLeftAction = menu.addAction(tr("Rotate Left")); + QAction *rotateRightAction = menu.addAction(tr("Rotate Right")); + menu.addSeparator(); QAction *editDescriptionAction = menu.addAction(tr("Edit description")); QAction *moveToTrashAction = menu.addAction(tr("Move to trash")); moveToTrashAction->setShortcut( @@ -650,6 +653,10 @@ void MainWindow::showImageContextMenu(const QPoint &pos) QGuiApplication::clipboard()->setText(filenameFromPath(path)); } else if (chosen == copyFullPathAction) { QGuiApplication::clipboard()->setText(displayPath(path)); + } else if (chosen == rotateLeftAction) { + rotateImage(index, false); + } else if (chosen == rotateRightAction) { + rotateImage(index, true); } else if (chosen == editDescriptionAction) { onEditCaption(); } else if (chosen == moveToTrashAction) { @@ -657,6 +664,38 @@ void MainWindow::showImageContextMenu(const QPoint &pos) } } +void MainWindow::rotateImage(const QModelIndex &index, bool clockwise) +{ + if (!index.isValid() || m_imageModel->isFolder(index)) + return; + + const QByteArray path = m_imageModel->filePath(index); + + if (::access(path.constData(), W_OK) != 0) { + QMessageBox::warning(this, tr("Cannot Rotate Image"), + tr("The file is write-protected and cannot be rotated.\n\n%1") + .arg(displayPath(path))); + return; + } + + if (!ExifReader::rotate(path, clockwise)) { + QMessageBox::warning(this, tr("Cannot Rotate Image"), + tr("The image orientation could not be changed. The format may not " + "support EXIF orientation.\n\n%1") + .arg(displayPath(path))); + return; + } + + m_imageModel->refreshThumbnail(index); + showPreview(index); + + if (m_imageView->currentPath() == path) { + m_imageView->invalidateCache(path); + m_imageView->setImage(path); + m_imageView->setExifData(ExifReader::read(path)); + } +} + void MainWindow::moveImageToTrash(const QByteArray &path) { if (path.isEmpty()) diff --git a/src/mainwindow.h b/src/mainwindow.h index 26a6c57..e4dde9e 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -50,6 +50,7 @@ private: void showImageContextMenu(const QPoint &pos); void moveImageToTrash(const QByteArray &path); void moveCurrentImageToTrash(); + void rotateImage(const QModelIndex &index, bool clockwise); void onPathEntered(); void updatePathField(const QByteArray &path); QList computeNeighborPaths(const QModelIndex &index) const;