diff --git a/src/exifreader.cpp b/src/exifreader.cpp index 06db53d..0a9ddb7 100644 --- a/src/exifreader.cpp +++ b/src/exifreader.cpp @@ -1,10 +1,14 @@ #include "exifreader.h" -#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) { @@ -53,17 +57,17 @@ QList> ExifData::toList() const return result; } -ExifData ExifReader::read(const QString &path) +ExifData ExifReader::read(const QByteArray &path) { ExifData data; - QFileInfo fi(path); - if (!fi.exists() || fi.isDir()) + struct ::stat st{}; + if (::stat(path.constData(), &st) != 0 || !S_ISREG(st.st_mode)) return data; // File size { - qint64 size = fi.size(); + qint64 size = static_cast(st.st_size); if (size < 1024) data.fileSize = QString::number(size) + QLatin1String(" B"); else if (size < 1024 * 1024) @@ -74,17 +78,26 @@ ExifData ExifReader::read(const QString &path) // Image dimensions via Qt (reads only the header, no full decode needed) { - QImageReader reader(path); - reader.setDecideFormatFromContent(true); - QSize sz = reader.size(); - if (sz.isValid()) - data.dimensions = QString::fromLatin1("%1 * %2") - .arg(sz.width()).arg(sz.height()); + 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()); + std::unique_ptr image = + Exiv2::ImageFactory::open(path.toStdString()); image->readMetadata(); const Exiv2::ExifData &exif = image->exifData(); diff --git a/src/exifreader.h b/src/exifreader.h index 7b354c4..6d2957a 100644 --- a/src/exifreader.h +++ b/src/exifreader.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -21,5 +22,5 @@ struct ExifData { }; namespace ExifReader { -ExifData read(const QString &path); +ExifData read(const QByteArray &path); } // namespace ExifReader diff --git a/src/imagemodel.cpp b/src/imagemodel.cpp index 6fb9368..93cc8d4 100644 --- a/src/imagemodel.cpp +++ b/src/imagemodel.cpp @@ -1,11 +1,14 @@ #include "imagemodel.h" #include "thumbnailworker.h" -#include +#include #include -#include #include +#include +#include +#include + ImageModel::ImageModel(QObject *parent) : QAbstractListModel(parent) { @@ -69,6 +72,11 @@ void ImageModel::setThumbnailSize(ThumbnailCache::Size size) void ImageModel::setDirectory(const QString &path) { + setDirectory(QFile::encodeName(path)); +} + +void ImageModel::setDirectory(const QByteArray &path) +{ m_cancelFlag = true; m_threadPool.waitForDone(); m_cancelFlag = false; @@ -94,49 +102,101 @@ void ImageModel::setDirectory(const QString &path) m_pendingRows.clear(); m_currentDir = path; - QDir dir(path); + // Build set of supported image extensions (lower-case, without dot) + QSet imageExts; + for (const QByteArray &fmt : QImageReader::supportedImageFormats()) + imageExts.insert(fmt.toLower()); + + // Use std::filesystem::directory_iterator so that directory entries with + // non-UTF-8 filenames (e.g. Latin-1 encoded names) are not silently + // skipped or mangled as they would be with QDir::entryInfoList(). + namespace fs = std::filesystem; + + std::vector dirs; + std::vector imageFiles; + + try { + fs::path dirPath(path.toStdString()); + + // Add ".." parent entry directly with the literal display name ".." + // (mirrors QDir::NoDot behaviour) + fs::path parentPath = dirPath.parent_path(); + if (!parentPath.empty() && parentPath != dirPath) { + QPixmap folderPixmap = m_folderIcon.pixmap(128, 128); + m_items.append({QByteArray::fromStdString(parentPath.native()), + QStringLiteral(".."), folderPixmap, true, true}); + } - // Add subdirectories first - dir.setFilter(QDir::AllDirs | QDir::NoDot); - dir.setSorting(QDir::Name); - const auto dirs = dir.entryInfoList(); - QPixmap folderPixmap = m_folderIcon.pixmap(128, 128); - for (const QFileInfo &d : dirs) - { - QString name = d.fileName(); // preserves ".." for the parent entry - m_items.append({d.absoluteFilePath(), name, folderPixmap, true, true}); + for (const fs::directory_entry &entry : + fs::directory_iterator(dirPath, + fs::directory_options::skip_permission_denied)) + { + std::error_code ec; + const std::string fname = entry.path().filename().native(); + if (fname.empty() || fname[0] == '.') + continue; // skip hidden entries + + if (entry.is_directory(ec)) { + dirs.push_back(entry.path().native()); + } else if (entry.is_regular_file(ec)) { + // Filter by image extension + const std::string &nativeExt = entry.path().extension().native(); + if (nativeExt.empty()) + continue; + // extension() includes the dot; strip it and lower-case + QByteArray ext = QByteArray(nativeExt.c_str() + 1, + static_cast(nativeExt.size()) - 1).toLower(); + if (imageExts.contains(ext)) + imageFiles.push_back(entry.path().native()); + } + } + } catch (const fs::filesystem_error &) { + // Directory unreadable — show empty listing } - // Then add image files - QStringList filters; + // Sort subdirectories by filename + std::sort(dirs.begin(), dirs.end(), + [](const auto &a, const auto &b) { + return fs::path(a).filename() < fs::path(b).filename(); + }); - const auto formats = QImageReader::supportedImageFormats(); - for (const QByteArray &fmt : formats) - filters << "*." + fmt; + std::sort(imageFiles.begin(), imageFiles.end(), + [](const auto &a, const auto &b) { + return fs::path(a).filename() < fs::path(b).filename(); + }); - dir.setNameFilters(filters); - dir.setFilter(QDir::Files); - dir.setSorting(QDir::Name); - - const auto files = dir.entryInfoList(); + QPixmap folderPixmap = m_folderIcon.pixmap(128, 128); + for (const std::string &nativePath : dirs) + { + QByteArray entryPath = QByteArray::fromStdString(nativePath); + std::string fname = fs::path(nativePath).filename().native(); + QString displayName = QString::fromLocal8Bit(fname.c_str(), + static_cast(fname.size())); + m_items.append({entryPath, displayName, folderPixmap, true, true}); + } // Restore cached thumbnails for this folder if available const ThumbnailMap *cached = m_folderThumbnailCache.contains(path) ? &m_folderThumbnailCache[path] : nullptr; - for (const QFileInfo &file : files) + for (const std::string &nativePath : imageFiles) { + QByteArray entryPath = QByteArray::fromStdString(nativePath); + std::string fname = fs::path(nativePath).filename().native(); + QString displayName = QString::fromLocal8Bit(fname.c_str(), + static_cast(fname.size())); + QPixmap thumb = m_placeholder; bool loaded = false; if (cached) { - auto it = cached->find(file.absoluteFilePath()); + auto it = cached->find(entryPath); if (it != cached->end()) { thumb = it.value(); loaded = true; } } - m_items.append({file.absoluteFilePath(), file.fileName(), thumb, loaded, false}); + m_items.append({entryPath, displayName, thumb, loaded, false}); } endResetModel(); @@ -187,7 +247,7 @@ void ImageModel::queueRow(int row) m_threadPool.start(worker); } -QString ImageModel::filePath(const QModelIndex &index) const +QByteArray ImageModel::filePath(const QModelIndex &index) const { if (!index.isValid()) return {}; @@ -202,7 +262,7 @@ bool ImageModel::isFolder(const QModelIndex &index) const return m_items[index.row()].isFolder; } -QString ImageModel::currentDirectory() const +QByteArray ImageModel::currentDirectory() const { return m_currentDir; } diff --git a/src/imagemodel.h b/src/imagemodel.h index 515de46..e64708d 100644 --- a/src/imagemodel.h +++ b/src/imagemodel.h @@ -3,6 +3,7 @@ #include "thumbnailcache.h" #include +#include #include #include #include @@ -22,10 +23,12 @@ public: explicit ImageModel(QObject *parent = nullptr); ~ImageModel(); - void setDirectory(const QString &path); - QString filePath(const QModelIndex &index) const; + // path is the raw native OS byte string (handles non-UTF-8 filenames) + void setDirectory(const QByteArray &path); + void setDirectory(const QString &path); // convenience: converts via QFile::encodeName + QByteArray filePath(const QModelIndex &index) const; bool isFolder(const QModelIndex &index) const; - QString currentDirectory() const; + QByteArray currentDirectory() const; void requestThumbnails(int firstRow, int lastRow); void setCacheMaxSize(int n); @@ -39,7 +42,7 @@ public: private: struct Item { - QString path; + QByteArray path; // raw native OS bytes — safe for non-UTF-8 filenames QString displayName; QPixmap thumbnail; bool loaded = false; @@ -48,7 +51,7 @@ private: void queueRow(int row); - using ThumbnailMap = QHash; + using ThumbnailMap = QHash; QVector m_items; QThreadPool m_threadPool; @@ -57,11 +60,11 @@ private: QSet m_pendingRows; std::atomic_bool m_cancelFlag{false}; QIcon m_folderIcon; - QString m_currentDir; + QByteArray m_currentDir; // Folder thumbnail cache (last N visited directories) - QHash m_folderThumbnailCache; - QList m_cacheOrder; + QHash m_folderThumbnailCache; + QList m_cacheOrder; int m_cacheMaxSize = 4; ThumbnailCache::Size m_thumbnailSize = ThumbnailCache::Size::Normal; diff --git a/src/imageviewwidget.cpp b/src/imageviewwidget.cpp index b94233d..44802b2 100644 --- a/src/imageviewwidget.cpp +++ b/src/imageviewwidget.cpp @@ -3,21 +3,38 @@ #include #include #include +#include #include #include #include #include +#include +#include + // --- ImageLoadWorker --- -ImageLoadWorker::ImageLoadWorker(const QString &path, QObject *parent) +ImageLoadWorker::ImageLoadWorker(const QByteArray &path, QObject *parent) : QThread(parent), m_path(path) { } void ImageLoadWorker::run() { - QImageReader reader(m_path); + int fd = ::open(m_path.constData(), O_RDONLY | O_CLOEXEC); + if (fd < 0) { + emit imageLoaded(m_path, QPixmap{}); + return; + } + + QFile file; + if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); + emit imageLoaded(m_path, QPixmap{}); + return; + } + + QImageReader reader(&file); reader.setAutoTransform(true); reader.setDecideFormatFromContent(true); reader.setAllocationLimit(0); @@ -44,9 +61,19 @@ ImageViewWidget::ImageViewWidget(QWidget *parent) setBackgroundColor("black"); } -QPixmap ImageViewWidget::loadImageFromDisk(const QString &path) +QPixmap ImageViewWidget::loadImageFromDisk(const QByteArray &path) { - QImageReader reader(path); + int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC); + if (fd < 0) + return {}; + + QFile file; + if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); + return {}; + } + + QImageReader reader(&file); reader.setAutoTransform(true); reader.setDecideFormatFromContent(true); reader.setAllocationLimit(0); @@ -59,10 +86,10 @@ QPixmap ImageViewWidget::loadImageFromDisk(const QString &path) image = image.convertToFormat(QImage::Format_ARGB32); return QPixmap::fromImage(image); } - return QPixmap(); + return {}; } -void ImageViewWidget::setImage(const QString &path) +void ImageViewWidget::setImage(const QByteArray &path) { m_currentPath = path; m_exifData = ExifData{}; // clear stale EXIF until new data is set @@ -83,7 +110,7 @@ void ImageViewWidget::setImage(const QString &path) // Trigger prefetch of neighbors if they have been set if (!m_neighborPaths.isEmpty()) { // Evict entries that are no longer in the neighbor window - QSet keep(m_neighborPaths.begin(), m_neighborPaths.end()); + QSet keep(m_neighborPaths.begin(), m_neighborPaths.end()); keep.insert(m_currentPath); for (auto it = m_cache.begin(); it != m_cache.end(); ) { if (!keep.contains(it.key())) @@ -93,13 +120,13 @@ void ImageViewWidget::setImage(const QString &path) } // Prefetch neighbors that are not yet cached - for (const QString &neighbor : m_neighborPaths) { + for (const QByteArray &neighbor : m_neighborPaths) { prefetchImage(neighbor); } } } -void ImageViewWidget::prefetchImage(const QString &path) +void ImageViewWidget::prefetchImage(const QByteArray &path) { if (m_cache.contains(path) || m_pendingLoads.contains(path)) return; @@ -111,19 +138,19 @@ void ImageViewWidget::prefetchImage(const QString &path) worker->start(); } -void ImageViewWidget::onImageLoaded(const QString &path, const QPixmap &pixmap) +void ImageViewWidget::onImageLoaded(const QByteArray &path, const QPixmap &pixmap) { m_pendingLoads.remove(path); // Only cache if path is still relevant (current or a neighbor) - QSet keep(m_neighborPaths.begin(), m_neighborPaths.end()); + QSet keep(m_neighborPaths.begin(), m_neighborPaths.end()); keep.insert(m_currentPath); if (keep.contains(path)) { m_cache[path] = pixmap; } } -void ImageViewWidget::setNeighborPaths(const QStringList &paths) +void ImageViewWidget::setNeighborPaths(const QList &paths) { m_neighborPaths = paths; } diff --git a/src/imageviewwidget.h b/src/imageviewwidget.h index 4d87b1b..3895eca 100644 --- a/src/imageviewwidget.h +++ b/src/imageviewwidget.h @@ -3,10 +3,10 @@ #include #include #include -#include -#include +#include +#include #include -#include +#include #include #include "exifreader.h" @@ -14,12 +14,12 @@ class ImageLoadWorker : public QThread { Q_OBJECT public: - explicit ImageLoadWorker(const QString &path, QObject *parent = nullptr); + explicit ImageLoadWorker(const QByteArray &path, QObject *parent = nullptr); void run() override; signals: - void imageLoaded(const QString &path, const QPixmap &pixmap); + void imageLoaded(const QByteArray &path, const QPixmap &pixmap); private: - QString m_path; + QByteArray m_path; }; class ImageViewWidget : public QWidget @@ -35,15 +35,15 @@ public: explicit ImageViewWidget(QWidget *parent = nullptr); - void setImage(const QString &path); + void setImage(const QByteArray &path); void setBackgroundColor(const QString &color); void zoomIn(); void zoomOut(); void zoomOriginal(); void zoomFitToScreen(); - void prefetchImage(const QString &path); - void setNeighborPaths(const QStringList &paths); + void prefetchImage(const QByteArray &path); + void setNeighborPaths(const QList &paths); void clearCache(); void setExifData(const ExifData &data); @@ -64,19 +64,19 @@ protected: private: void clampOffset(int imgW, int imgH); - QPixmap loadImageFromDisk(const QString &path); - void onImageLoaded(const QString &path, const QPixmap &pixmap); + QPixmap loadImageFromDisk(const QByteArray &path); + void onImageLoaded(const QByteArray &path, const QPixmap &pixmap); QPixmap m_pixmap; ZoomMode m_zoomMode = FitToScreen; double m_zoomFactor = 1.0; QString m_backgroundColor = "black"; - QString m_currentPath; + QByteArray m_currentPath; // Image cache: path -> pixmap - QMap m_cache; - QStringList m_neighborPaths; // ordered list of paths around current image - QSet m_pendingLoads; // paths currently being loaded in background + QMap m_cache; + QList m_neighborPaths; // ordered list of paths around current image + QSet m_pendingLoads; // paths currently being loaded in background // Panning state QPoint m_offset; // current pan offset (pixels) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 12ff796..110812f 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -5,6 +5,7 @@ #include "thumbnailcache.h" #include "exifreader.h" +#include #include #include #include @@ -29,6 +30,10 @@ #include #include +#include +#include +#include + namespace { ThumbnailCache::Size thumbnailSizeFromString(const QString &s) @@ -160,9 +165,17 @@ void MainWindow::setupMenuBar() void MainWindow::navigateToFolder(const QString &path) { + navigateToFolder(QFile::encodeName(path)); +} + +void MainWindow::navigateToFolder(const QByteArray &path) +{ m_previousFolderPath = m_imageModel->currentDirectory(); m_imageModel->setDirectory(path); - QModelIndex dirIdx = m_dirModel->index(path); + // The tree view uses QFileSystemModel which works with QString; convert for it. + // If the path contains non-UTF-8 bytes the index lookup may fail — that is + // acceptable (the tree view simply won't highlight the node). + QModelIndex dirIdx = m_dirModel->index(QFile::decodeName(path)); if (dirIdx.isValid()) m_treeView->setCurrentIndex(dirIdx); QTimer::singleShot(0, this, [this]() { @@ -178,19 +191,19 @@ void MainWindow::openPath(const QString &path) return; if (info.isDir()) { - navigateToFolder(info.absoluteFilePath()); + navigateToFolder(QFile::encodeName(info.absoluteFilePath())); } else if (info.isFile()) { - navigateToFolder(info.absolutePath()); + navigateToFolder(QFile::encodeName(info.absolutePath())); // ImageModel::setDirectory is synchronous, so items are ready now. // Find the file in the model and open it. + QByteArray target = QFile::encodeName(info.absoluteFilePath()); for (int i = 0; i < m_imageModel->rowCount(); ++i) { QModelIndex idx = m_imageModel->index(i); - if (m_imageModel->filePath(idx) == info.absoluteFilePath()) { + if (m_imageModel->filePath(idx) == target) { m_listView->setCurrentIndex(idx); m_listView->scrollTo(idx); showPreview(idx); - // ImageModel only lists image files and folders; non-folder = image. if (!m_imageModel->isFolder(idx)) enterSingleImageMode(idx); break; @@ -208,7 +221,7 @@ void MainWindow::selectPreviousFolderIfExists() for (int i = 0; i < m_imageModel->rowCount(); ++i) { QModelIndex idx = m_imageModel->index(i); if (m_imageModel->isFolder(idx)) { - QString folderPath = m_imageModel->filePath(idx); + QByteArray folderPath = m_imageModel->filePath(idx); if (folderPath == m_previousFolderPath) { m_listView->setCurrentIndex(idx); m_listView->scrollTo(idx); @@ -246,7 +259,7 @@ void MainWindow::setupConnections() connect(m_listView, &QListView::doubleClicked, this, [this](const QModelIndex &index) { if (m_imageModel->isFolder(index)) { - QString path = m_imageModel->filePath(index); + QByteArray path = m_imageModel->filePath(index); navigateToFolder(path); } else { enterSingleImageMode(index); @@ -321,31 +334,36 @@ void MainWindow::showPreview(const QModelIndex &index) if (!index.isValid()) return; - QString path = m_imageModel->filePath(index); - - QImageReader reader(path); - reader.setAutoTransform(true); - // Scale during decoding for the preview to avoid the 128 MB allocation limit - QSize fullSize = reader.size(); - if (fullSize.isValid()) { - QSize target = fullSize.scaled(m_previewLabel->size(), Qt::KeepAspectRatio); - reader.setScaledSize(target); + QByteArray path = m_imageModel->filePath(index); + + 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.setAutoTransform(true); + QSize fullSize = reader.size(); + if (fullSize.isValid()) { + QSize target = fullSize.scaled(m_previewLabel->size(), Qt::KeepAspectRatio); + reader.setScaledSize(target); + } + QImage image = reader.read(); + m_previewLabel->setPixmap( + QPixmap::fromImage(image).scaled( + m_previewLabel->size(), + Qt::KeepAspectRatio, + Qt::SmoothTransformation)); + } } - QImage image = reader.read(); - - m_previewLabel->setPixmap( - QPixmap::fromImage(image).scaled( - m_previewLabel->size(), - Qt::KeepAspectRatio, - Qt::SmoothTransformation)); updateExifInfo(path); } -void MainWindow::updateExifInfo(const QString &path) +void MainWindow::updateExifInfo(const QByteArray &path) { - QFileInfo fi(path); - if (fi.isDir()) { + // Check if this is a folder by attempting stat + struct ::stat st{}; + if (::stat(path.constData(), &st) != 0 || S_ISDIR(st.st_mode)) { m_exifTable->setRowCount(0); return; } @@ -378,7 +396,7 @@ void MainWindow::enterSingleImageMode(const QModelIndex &index) } m_imageView->setBackgroundColor(m_backgroundColorPreference); - QString path = m_imageModel->filePath(index); + QByteArray path = m_imageModel->filePath(index); m_imageView->setNeighborPaths(computeNeighborPaths(index)); m_imageView->setImage(path); m_imageView->setExifData(ExifReader::read(path)); @@ -412,9 +430,9 @@ void MainWindow::leaveSingleImageMode() m_listView->setFocus(); } -QStringList MainWindow::computeNeighborPaths(const QModelIndex &index) const +QList MainWindow::computeNeighborPaths(const QModelIndex &index) const { - QStringList paths; + QList paths; int row = index.row(); // Collect up to CacheKeepBehind non-folder images before current @@ -475,7 +493,7 @@ void MainWindow::keyPressEvent(QKeyEvent *event) if (current.isValid()) { if (m_imageModel->isFolder(current)) { - QString path = m_imageModel->filePath(current); + QByteArray path = m_imageModel->filePath(current); navigateToFolder(path); } else { enterSingleImageMode(current); diff --git a/src/mainwindow.h b/src/mainwindow.h index fef271a..444b448 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1,5 +1,6 @@ #pragma once +#include #include class QTreeView; @@ -31,15 +32,16 @@ private: void savePreferences(); void onPreferencesTriggered(); void resetLayoutToDefault(); - void navigateToFolder(const QString &path); + void navigateToFolder(const QByteArray &path); + void navigateToFolder(const QString &path); // convenience: converts via QFile::encodeName void selectPreviousFolderIfExists(); void setFullScreenMode(bool fullScreen); void loadVisibleThumbnails(); void showPreview(const QModelIndex &index); - void updateExifInfo(const QString &path); + void updateExifInfo(const QByteArray &path); void enterSingleImageMode(const QModelIndex &index); void leaveSingleImageMode(); - QStringList computeNeighborPaths(const QModelIndex &index) const; + QList computeNeighborPaths(const QModelIndex &index) const; void keyPressEvent(QKeyEvent *event) override; bool eventFilter(QObject *obj, QEvent *event) override; @@ -61,7 +63,7 @@ private: ImageViewWidget *m_imageView; bool m_wasMaximized = false; bool m_imageViewWasFullScreen = false; - QString m_previousFolderPath; + QByteArray m_previousFolderPath; QMenuBar *m_menuBar = nullptr; QDockWidget *m_rootDock = nullptr; QDockWidget *m_folderDock = nullptr; diff --git a/src/thumbnailcache.cpp b/src/thumbnailcache.cpp index fd25ee2..8f6ad00 100644 --- a/src/thumbnailcache.cpp +++ b/src/thumbnailcache.cpp @@ -3,12 +3,12 @@ #include #include #include -#include -#include #include #include #include +#include + namespace { constexpr const char *kSoftware = "SagoImageBrowser"; @@ -38,43 +38,41 @@ QString ThumbnailCache::subdirFor(Size size) return cacheRoot() + "/" + name; } -QString ThumbnailCache::canonicalUri(const QString &absPath) +QByteArray ThumbnailCache::canonicalUri(const QByteArray &absPath) { - // Per the spec, use the absolute file URI (percent-encoded). - return QUrl::fromLocalFile(absPath).toString(QUrl::FullyEncoded); + // Build a correct file:// URI by percent-encoding the raw byte path. + // Forward slashes are kept unencoded; all other bytes (including non-UTF-8 + // ones such as Latin-1 \xF8) are percent-encoded. + return "file://" + QUrl::toPercentEncoding(absPath, "/"); } -QString ThumbnailCache::hashedName(const QString &canonicalUri) +QString ThumbnailCache::hashedName(const QByteArray &canonicalUri) { - QByteArray hash = QCryptographicHash::hash(canonicalUri.toUtf8(), - QCryptographicHash::Md5) - .toHex(); + QByteArray hash = + QCryptographicHash::hash(canonicalUri, QCryptographicHash::Md5).toHex(); return QString::fromLatin1(hash) + ".png"; } -bool ThumbnailCache::isInsideCache(const QString &absPath) +bool ThumbnailCache::isInsideCache(const QByteArray &absPath) { - QString abs = QFileInfo(absPath).absoluteFilePath(); - QString root = QFileInfo(cacheRoot()).absoluteFilePath(); - if (root.isEmpty()) - return false; - return abs.startsWith(root + "/") || abs == root; + QByteArray root = cacheRoot().toUtf8(); // cache root is always valid UTF-8 + return absPath.startsWith(root + '/') || absPath == root; } -QImage ThumbnailCache::load(const QString &sourcePath, Size size) +QImage ThumbnailCache::load(const QByteArray &sourcePath, Size size) { - QFileInfo info(sourcePath); - if (!info.exists() || !info.isFile()) + struct ::stat st{}; + if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode)) return {}; - const QString uri = canonicalUri(info.absoluteFilePath()); + const QByteArray uri = canonicalUri(sourcePath); const QString cachePath = subdirFor(size) + "/" + hashedName(uri); QImage img; if (!img.load(cachePath, "PNG")) return {}; - const qint64 mtime = info.lastModified().toSecsSinceEpoch(); + const qint64 mtime = static_cast(st.st_mtime); bool ok = false; const qint64 cachedMTime = img.text("Thumb::MTime").toLongLong(&ok); if (!ok || cachedMTime != mtime) @@ -83,16 +81,16 @@ QImage ThumbnailCache::load(const QString &sourcePath, Size size) return img; } -void ThumbnailCache::save(const QString &sourcePath, Size size, const QImage &thumbImage) +void ThumbnailCache::save(const QByteArray &sourcePath, Size size, const QImage &thumbImage) { if (thumbImage.isNull()) return; - QFileInfo info(sourcePath); - if (!info.exists() || !info.isFile()) + struct ::stat st{}; + if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode)) return; - const QString uri = canonicalUri(info.absoluteFilePath()); + const QByteArray uri = canonicalUri(sourcePath); const QString subdir = subdirFor(size); const QString finalPath = subdir + "/" + hashedName(uri); @@ -103,16 +101,10 @@ void ThumbnailCache::save(const QString &sourcePath, Size size, const QImage &th QFile::setPermissions(cacheRoot(), dirPerms()); QImage tagged = thumbImage; - tagged.setText("Thumb::URI", uri); + tagged.setText("Thumb::URI", QString::fromLatin1(uri)); tagged.setText("Thumb::MTime", - QString::number(info.lastModified().toSecsSinceEpoch())); - tagged.setText("Thumb::Size", QString::number(info.size())); - QMimeDatabase mimeDb; - QString mime = mimeDb.mimeTypeForFile(info).name(); - if (!mime.isEmpty()) - tagged.setText("Thumb::Mimetype", mime); - tagged.setText("Thumb::Image::Width", QString::number(thumbImage.width())); - tagged.setText("Thumb::Image::Height", QString::number(thumbImage.height())); + QString::number(static_cast(st.st_mtime))); + tagged.setText("Thumb::Size", QString::number(static_cast(st.st_size))); tagged.setText("Software", QString::fromLatin1(kSoftware)); QTemporaryFile tmp(subdir + "/tmp_XXXXXX.png"); diff --git a/src/thumbnailcache.h b/src/thumbnailcache.h index 67b9155..d9c0f66 100644 --- a/src/thumbnailcache.h +++ b/src/thumbnailcache.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -13,17 +14,17 @@ public: }; // Returns a non-null QImage if a valid cached thumbnail exists for sourcePath - // at the requested size. Validity is determined by comparing the original - // file's mtime to the Thumb::MTime tEXt chunk in the cached PNG. - static QImage load(const QString &sourcePath, Size size); + // at the requested size. sourcePath must be the raw native OS byte string. + static QImage load(const QByteArray &sourcePath, Size size); // Atomically writes thumbImage to the cache with the required Freedesktop - // tEXt chunks. No-op if sourcePath cannot be stat'ed. - static void save(const QString &sourcePath, Size size, const QImage &thumbImage); + // tEXt chunks. sourcePath must be the raw native OS byte string. + static void save(const QByteArray &sourcePath, Size size, const QImage &thumbImage); static QString cacheRoot(); static QString subdirFor(Size size); - static QString canonicalUri(const QString &absPath); - static QString hashedName(const QString &canonicalUri); - static bool isInsideCache(const QString &absPath); + // Canonical file:// URI built from raw byte path (correct percent-encoding) + static QByteArray canonicalUri(const QByteArray &absPath); + static QString hashedName(const QByteArray &canonicalUri); + static bool isInsideCache(const QByteArray &absPath); }; diff --git a/src/thumbnailworker.cpp b/src/thumbnailworker.cpp index 04868a5..3e1024f 100644 --- a/src/thumbnailworker.cpp +++ b/src/thumbnailworker.cpp @@ -2,8 +2,12 @@ #include "thumbnailcache.h" +#include #include +#include +#include + namespace { constexpr int kDisplaySize = 128; @@ -20,7 +24,7 @@ QImage downscaleForDisplay(const QImage &src) } } // namespace -ThumbnailWorker::ThumbnailWorker(const QString &path, +ThumbnailWorker::ThumbnailWorker(const QByteArray &path, int row, ThumbnailCache::Size size, std::atomic_bool *cancelFlag) @@ -53,7 +57,20 @@ void ThumbnailWorker::run() return; } - QImageReader reader(m_path); + // Open the file via POSIX open() so that paths with non-UTF-8 bytes + // (e.g. Latin-1 encoded filenames) are handled correctly. + int fd = ::open(m_path.constData(), O_RDONLY | O_CLOEXEC); + if (fd < 0) + return; + + QFile file; + if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) + { + ::close(fd); + return; + } + + QImageReader reader(&file); reader.setAutoTransform(true); // Scale during decoding to avoid hitting the allocation limit diff --git a/src/thumbnailworker.h b/src/thumbnailworker.h index c481763..65d6485 100644 --- a/src/thumbnailworker.h +++ b/src/thumbnailworker.h @@ -2,6 +2,7 @@ #include "thumbnailcache.h" +#include #include #include #include @@ -12,7 +13,7 @@ class ThumbnailWorker : public QObject, public QRunnable Q_OBJECT public: - ThumbnailWorker(const QString &path, + ThumbnailWorker(const QByteArray &path, int row, ThumbnailCache::Size size, std::atomic_bool *cancelFlag); @@ -23,7 +24,7 @@ signals: void finished(int row, const QImage &image); private: - QString m_path; + QByteArray m_path; int m_row; ThumbnailCache::Size m_size; std::atomic_bool *m_cancelFlag;