commit 48534775
Add option for to rotate image and pin Exiv2 to 0.28.8 as the library is not API stable between versions
Changed files
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 $<BUILD_INTERFACE:${CMAKE_BINARY_DIR}>)
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<QPair<QString, QString>> &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<Exiv2::Image> 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<int>(it->toInt64());
+
+ const int updated = clockwise ? orientationRotatedClockwise(current)
+ : orientationRotatedCounterClockwise(current);
+ exif["Exif.Image.Orientation"] = static_cast<uint16_t>(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<QByteArray> &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<QByteArray> computeNeighborPaths(const QModelIndex &index) const;