diff --git a/CMakeLists.txt b/CMakeLists.txt index 12a49be..b3f5b13 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,9 @@ include(GNUInstallDirs) find_package(Qt6 REQUIRED COMPONENTS Widgets LinguistTools) +# exiv2's exported target lists Iconv::Iconv in its link interface (notably on +# the MXE/MinGW Windows build), so the imported target must exist before the +# exiv2 config below is consumed — even though we never call iconv directly. find_package(Iconv REQUIRED) # Use the system-provided exiv2 (both 0.27 and 0.28 are supported) @@ -40,6 +43,8 @@ qt_add_executable(SagoImageBrowser src/shortcutmanager.h src/exifreader.cpp src/exifreader.h + src/nativepath.cpp + src/nativepath.h src/pathcompletermodel.cpp src/pathcompletermodel.h src/thumbnaildelegate.cpp @@ -50,7 +55,7 @@ qt_add_resources(SagoImageBrowser "icons" PREFIX "/icons" FILES extra/icons/sago target_link_libraries(SagoImageBrowser PRIVATE Qt6::Widgets ${EXIV2_TARGET}) -add_executable(test_exifreader test/test_exifreader.cpp src/exifreader.cpp) +add_executable(test_exifreader test/test_exifreader.cpp src/exifreader.cpp src/nativepath.cpp) target_include_directories(test_exifreader PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_exifreader PRIVATE Qt6::Widgets ${EXIV2_TARGET}) diff --git a/src/exifreader.cpp b/src/exifreader.cpp index 085f678..0fa0024 100644 --- a/src/exifreader.cpp +++ b/src/exifreader.cpp @@ -25,6 +25,7 @@ SOFTWARE. #include "exifreader.h" +#include "nativepath.h" #include #include @@ -39,10 +40,16 @@ using Exiv2ImagePtr = Exiv2::Image::UniquePtr; using Exiv2ImagePtr = Exiv2::Image::AutoPtr; #endif -#include -#include -#include -#include +// exiv2 cannot represent an arbitrary Windows name through a narrow std::string; +// on Windows use its wide-path overload fed from our lossless WTF-8 handle. +static Exiv2ImagePtr openExiv2(const QByteArray &path) +{ +#ifdef _WIN32 + return Exiv2::ImageFactory::open(nativepath::wideFromNative(path)); +#else + return Exiv2::ImageFactory::open(path.toStdString()); +#endif +} // Raw EXIF date format is "YYYY:MM:DD HH:MM:SS"; reformat date separators to dashes static QString formatDate(const QString &raw) @@ -153,21 +160,20 @@ ExifData ExifReader::read(const QByteArray &path) { ExifData data; - struct ::stat st{}; - if (::stat(path.constData(), &st) != 0 || !S_ISREG(st.st_mode)) + const nativepath::NativeStat st = nativepath::nativeStat(path); + if (!st.isRegular) 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())); + const QByteArray nativeName = + nativepath::nativeFromPath(nativepath::pathFromNative(path).filename()); + data.filename = nativepath::displayFromNative(nativeName); } // File size { - qint64 size = static_cast(st.st_size); + qint64 size = st.size; if (size < 1024) data.fileSize = QString::number(size) + QLatin1String(" B"); else if (size < 1024 * 1024) @@ -178,25 +184,20 @@ ExifData ExifReader::read(const QByteArray &path) // 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); - } + QFile file; + if (nativepath::openNativeRead(path, file)) { + 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()); } } // EXIF tags via exiv2 try { - Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString()); + Exiv2ImagePtr image = openExiv2(path); image->readMetadata(); const Exiv2::ExifData &exif = image->exifData(); @@ -252,7 +253,7 @@ bool ExifReader::saveCaption(const QByteArray &path, const ExifData &oldData) { try { - Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString()); + Exiv2ImagePtr image = openExiv2(path); image->readMetadata(); // Erase all existing Caption-Abstract entries then add the new one @@ -284,7 +285,7 @@ bool ExifReader::saveCaption(const QByteArray &path, bool ExifReader::rotate(const QByteArray &path, bool clockwise) { try { - Exiv2ImagePtr image = Exiv2::ImageFactory::open(path.toStdString()); + Exiv2ImagePtr image = openExiv2(path); image->readMetadata(); Exiv2::ExifData &exif = image->exifData(); diff --git a/src/fsdirmodel.cpp b/src/fsdirmodel.cpp index 7752414..685b6b4 100644 --- a/src/fsdirmodel.cpp +++ b/src/fsdirmodel.cpp @@ -24,6 +24,7 @@ SOFTWARE. */ #include "fsdirmodel.h" +#include "nativepath.h" #include @@ -41,19 +42,19 @@ QList FsDirModel::listSubdirs(Node *parentNode) { QList result; try { - std::vector> entries; // {nativePath, filename} + std::vector> entries; // {nativePath, filename} for (const fs::directory_entry &entry : - fs::directory_iterator(parentNode->nativePath.toStdString(), + fs::directory_iterator(nativepath::pathFromNative(parentNode->nativePath), fs::directory_options::skip_permission_denied)) { std::error_code ec; - const std::string fname = entry.path().filename().native(); - if (fname.empty() || fname[0] == '.') + const QByteArray fname = nativepath::nativeFromPath(entry.path().filename()); + if (fname.isEmpty() || fname.at(0) == '.') continue; if (!entry.is_directory(ec)) continue; - entries.push_back({entry.path().native(), fname}); + entries.push_back({nativepath::nativeFromPath(entry.path()), fname}); } std::sort(entries.begin(), entries.end(), @@ -62,9 +63,8 @@ QList FsDirModel::listSubdirs(Node *parentNode) result.reserve(static_cast(entries.size())); for (const auto &[npath, fname] : entries) { auto *child = new Node; - child->nativePath = QByteArray::fromStdString(npath); - child->displayName = QString::fromLocal8Bit(fname.c_str(), - static_cast(fname.size())); + child->nativePath = npath; + child->displayName = nativepath::displayFromNative(fname); child->parent = parentNode; result.append(child); } @@ -107,7 +107,7 @@ FsDirModel::~FsDirModel() void FsDirModel::setRootPath(const QString &path) { - setRootPath(QFile::encodeName(path)); + setRootPath(nativepath::nativeFromDisplay(path)); } void FsDirModel::setRootPath(const QByteArray &nativePath) @@ -119,10 +119,11 @@ void FsDirModel::setRootPath(const QByteArray &nativePath) auto *node = new Node; node->nativePath = nativePath; // Display name = last path component (empty for "/", use "/" in that case) - std::string fname = fs::path(nativePath.toStdString()).filename().native(); - node->displayName = fname.empty() - ? QFile::decodeName(nativePath) - : QString::fromLocal8Bit(fname.c_str(), static_cast(fname.size())); + const QByteArray fname = + nativepath::nativeFromPath(nativepath::pathFromNative(nativePath).filename()); + node->displayName = fname.isEmpty() + ? nativepath::displayFromNative(nativePath) + : nativepath::displayFromNative(fname); node->parent = &m_virtualRoot; m_virtualRoot.children.append(node); } @@ -149,8 +150,9 @@ QModelIndex FsDirModel::indexForPath(const QByteArray &nativePath) if (nativePath.isEmpty() || m_virtualRoot.children.isEmpty()) return {}; - const fs::path target(nativePath.toStdString()); - const fs::path root(m_virtualRoot.children.first()->nativePath.toStdString()); + const fs::path target = nativepath::pathFromNative(nativePath); + const fs::path root = + nativepath::pathFromNative(m_virtualRoot.children.first()->nativePath); // Collect the ancestry chain from root down to target std::vector chain; @@ -184,10 +186,10 @@ QModelIndex FsDirModel::indexForPath(const QByteArray &nativePath) } // Find the child whose nativePath matches step - const std::string stepStr = step.native(); + const QByteArray stepBytes = nativepath::nativeFromPath(step); bool found = false; for (int i = 0; i < node->children.size(); ++i) { - if (node->children[i]->nativePath.toStdString() == stepStr) { + if (node->children[i]->nativePath == stepBytes) { node = node->children[i]; idx = createIndex(i, 0, node); found = true; diff --git a/src/fsdirmodel.h b/src/fsdirmodel.h index 361c4e7..c9c039e 100644 --- a/src/fsdirmodel.h +++ b/src/fsdirmodel.h @@ -49,7 +49,7 @@ public: // and pass the result to QTreeView::setRootIndex() so the root node itself // is hidden and only its children are displayed as top-level items. void setRootPath(const QByteArray &nativePath); - void setRootPath(const QString &path); // convenience: converts via QFile::encodeName + void setRootPath(const QString &path); // convenience: converts via nativepath::nativeFromDisplay // Model index of the root-path node (the one whose *children* are shown // as top-level items when passed to QTreeView::setRootIndex). diff --git a/src/imagemodel.cpp b/src/imagemodel.cpp index 41ea26a..e238ffc 100644 --- a/src/imagemodel.cpp +++ b/src/imagemodel.cpp @@ -25,6 +25,7 @@ SOFTWARE. #include "imagemodel.h" #include "thumbnailworker.h" +#include "nativepath.h" #include #include @@ -96,7 +97,7 @@ void ImageModel::setThumbnailSize(ThumbnailCache::Size size) void ImageModel::setDirectory(const QString &path) { - setDirectory(QFile::encodeName(path)); + setDirectory(nativepath::nativeFromDisplay(path)); } void ImageModel::setDirectory(const QByteArray &path) @@ -136,18 +137,18 @@ void ImageModel::setDirectory(const QByteArray &path) // skipped or mangled as they would be with QDir::entryInfoList(). namespace fs = std::filesystem; - std::vector dirs; - std::vector imageFiles; + std::vector dirs; + std::vector imageFiles; try { - fs::path dirPath(path.toStdString()); + fs::path dirPath = nativepath::pathFromNative(path); // 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()), + m_items.append({nativepath::nativeFromPath(parentPath), QStringLiteral(".."), folderPixmap, true, true}); } @@ -156,46 +157,45 @@ void ImageModel::setDirectory(const QByteArray &path) fs::directory_options::skip_permission_denied)) { std::error_code ec; - const std::string fname = entry.path().filename().native(); - if (fname.empty() || fname[0] == '.') + const QByteArray fname = nativepath::nativeFromPath(entry.path().filename()); + if (fname.isEmpty() || fname.at(0) == '.') continue; // skip hidden entries if (entry.is_directory(ec)) { - dirs.push_back(entry.path().native()); + dirs.push_back(nativepath::nativeFromPath(entry.path())); } else if (entry.is_regular_file(ec)) { // Filter by image extension - const std::string &nativeExt = entry.path().extension().native(); - if (nativeExt.empty()) + const QByteArray nativeExt = nativepath::nativeFromPath(entry.path().extension()); + if (nativeExt.isEmpty()) continue; // extension() includes the dot; strip it and lower-case - QByteArray ext = QByteArray(nativeExt.c_str() + 1, - static_cast(nativeExt.size()) - 1).toLower(); + QByteArray ext = nativeExt.mid(1).toLower(); if (imageExts.contains(ext)) - imageFiles.push_back(entry.path().native()); + imageFiles.push_back(nativepath::nativeFromPath(entry.path())); } } } catch (const fs::filesystem_error &) { // Directory unreadable — show empty listing } - // Sort subdirectories by filename + // Sort subdirectories and image files by filename std::sort(dirs.begin(), dirs.end(), - [](const auto &a, const auto &b) { - return fs::path(a).filename() < fs::path(b).filename(); + [](const QByteArray &a, const QByteArray &b) { + return nativepath::pathFromNative(a).filename() + < nativepath::pathFromNative(b).filename(); }); - std::sort(imageFiles.begin(), imageFiles.end(), - [](const auto &a, const auto &b) { - return fs::path(a).filename() < fs::path(b).filename(); + [](const QByteArray &a, const QByteArray &b) { + return nativepath::pathFromNative(a).filename() + < nativepath::pathFromNative(b).filename(); }); QPixmap folderPixmap = m_folderIcon.pixmap(128, 128); - for (const std::string &nativePath : dirs) + for (const QByteArray &entryPath : 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())); + const QByteArray fname = + nativepath::nativeFromPath(nativepath::pathFromNative(entryPath).filename()); + QString displayName = nativepath::displayFromNative(fname); m_items.append({entryPath, displayName, folderPixmap, true, true}); } @@ -204,12 +204,11 @@ void ImageModel::setDirectory(const QByteArray &path) ? &m_folderThumbnailCache[path] : nullptr; - for (const std::string &nativePath : imageFiles) + for (const QByteArray &entryPath : 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())); + const QByteArray fname = + nativepath::nativeFromPath(nativepath::pathFromNative(entryPath).filename()); + QString displayName = nativepath::displayFromNative(fname); QPixmap thumb = m_placeholder; bool loaded = false; diff --git a/src/imagemodel.h b/src/imagemodel.h index 60bd51c..6534de2 100644 --- a/src/imagemodel.h +++ b/src/imagemodel.h @@ -50,7 +50,7 @@ public: // 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 + void setDirectory(const QString &path); // convenience: converts via nativepath::nativeFromDisplay QByteArray filePath(const QModelIndex &index) const; bool isFolder(const QModelIndex &index) const; QByteArray currentDirectory() const; diff --git a/src/imageviewwidget.cpp b/src/imageviewwidget.cpp index 59686d2..2bd7bdc 100644 --- a/src/imageviewwidget.cpp +++ b/src/imageviewwidget.cpp @@ -25,6 +25,7 @@ SOFTWARE. #include "imageviewwidget.h" #include "shortcutmanager.h" +#include "nativepath.h" #include #include @@ -36,9 +37,6 @@ SOFTWARE. #include #include -#include -#include - // --- ImageLoadWorker --- ImageLoadWorker::ImageLoadWorker(const QByteArray &path, QObject *parent) @@ -54,15 +52,8 @@ ImageLoadWorker::ImageLoadWorker(const QByteArray &path, QObject *parent) // affinity), so onImageLoaded() always executes on the main thread. void ImageLoadWorker::run() { - 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); + if (!nativepath::openNativeRead(m_path, file)) { emit imageLoaded(m_path, QPixmap{}); return; } @@ -97,15 +88,9 @@ ImageViewWidget::ImageViewWidget(QWidget *parent) QPixmap ImageViewWidget::loadImageFromDisk(const QByteArray &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); + if (!nativepath::openNativeRead(path, file)) return {}; - } QImageReader reader(&file); reader.setAutoTransform(true); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1dc4c1f..c1e7f7a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -69,10 +69,9 @@ SOFTWARE. #include #include -#include #include -#include -#include + +#include "nativepath.h" namespace { @@ -84,15 +83,14 @@ ThumbnailCache::Size thumbnailSizeFromString(const QString &s) QString displayPath(const QByteArray &path) { - return QString::fromLocal8Bit(path.constData(), path.size()); + return nativepath::displayFromNative(path); } QString filenameFromPath(const QByteArray &path) { - const std::filesystem::path fsPath(path.toStdString()); - const std::string nativeName = fsPath.filename().native(); - return QString::fromLocal8Bit(nativeName.data(), - static_cast(nativeName.size())); + const QByteArray nativeName = + nativepath::nativeFromPath(nativepath::pathFromNative(path).filename()); + return nativepath::displayFromNative(nativeName); } } // namespace @@ -183,7 +181,7 @@ void MainWindow::setupUi() setCentralWidget(m_stack); - updatePathField(QFile::encodeName(QDir::homePath())); + updatePathField(nativepath::nativeFromDisplay(QDir::homePath())); // Dock widgets — can be dragged, floated and stacked by the user m_rootDock = new QDockWidget(tr("Roots"), this); @@ -240,7 +238,7 @@ void MainWindow::setupMenuBar() void MainWindow::navigateToFolder(const QString &path) { - navigateToFolder(QFile::encodeName(path)); + navigateToFolder(nativepath::nativeFromDisplay(path)); } QByteArray MainWindow::siblingFolder(int delta) const @@ -250,12 +248,12 @@ QByteArray MainWindow::siblingFolder(int delta) const return {}; namespace fs = std::filesystem; - fs::path current(currentDir.toStdString()); + fs::path current = nativepath::pathFromNative(currentDir); fs::path parent = current.parent_path(); if (parent.empty() || parent == current) return {}; - QByteArray parentPath = QByteArray::fromStdString(parent.native()); + QByteArray parentPath = nativepath::nativeFromPath(parent); QModelIndex parentIdx = m_dirModel->indexForPath(parentPath); if (!parentIdx.isValid()) return {}; @@ -313,7 +311,7 @@ void MainWindow::updatePathField(const QByteArray &path) { if (!m_pathEdit) return; - const QString text = QFile::decodeName(path); + const QString text = nativepath::displayFromNative(path); if (m_pathEdit->text() != text) m_pathEdit->setText(text); } @@ -333,13 +331,13 @@ void MainWindow::openPath(const QString &path) return; if (info.isDir()) { - navigateToFolder(QFile::encodeName(info.absoluteFilePath())); + navigateToFolder(nativepath::nativeFromDisplay(info.absoluteFilePath())); } else if (info.isFile()) { - navigateToFolder(QFile::encodeName(info.absolutePath())); + navigateToFolder(nativepath::nativeFromDisplay(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()); + QByteArray target = nativepath::nativeFromDisplay(info.absoluteFilePath()); for (int i = 0; i < m_imageModel->rowCount(); ++i) { QModelIndex idx = m_imageModel->index(i); if (m_imageModel->filePath(idx) == target) { @@ -394,7 +392,7 @@ void MainWindow::setupConnections() QString rootPath = current->data(Qt::UserRole).toString(); m_dirModel->setRootPath(rootPath); m_treeView->setRootIndex(m_dirModel->rootIndex()); - navigateToFolder(QFile::encodeName(rootPath)); + navigateToFolder(nativepath::nativeFromDisplay(rootPath)); }); connect(m_treeView, &QTreeView::clicked, this, @@ -537,24 +535,21 @@ void MainWindow::showPreview(const QModelIndex &index) 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)); + QFile file; + if (nativepath::openNativeRead(path, file)) { + 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)); } updateExifInfo(path); @@ -563,8 +558,8 @@ void MainWindow::showPreview(const QModelIndex &index) void MainWindow::updateExifInfo(const QByteArray &path) { // Check if this is a folder by attempting stat - struct ::stat st{}; - if (::stat(path.constData(), &st) != 0 || S_ISDIR(st.st_mode)) { + const nativepath::NativeStat st = nativepath::nativeStat(path); + if (!st.exists || st.isDir) { m_exifTable->setRowCount(0); return; } @@ -633,7 +628,7 @@ void MainWindow::onEditCaption() if (path.isEmpty()) return; - if (::access(path.constData(), W_OK) != 0) { + if (!nativepath::isWritable(path)) { QMessageBox::warning(this, tr("Cannot Edit Caption"), tr("The file is write-protected and cannot be edited.\n\n%1") .arg(displayPath(path))); @@ -698,7 +693,7 @@ void MainWindow::executeContextMenu(const QPoint &globalPos, const QByteArray &p QAction *copyFullPathAction = menu.addAction(tr("Copy full path")); menu.addSeparator(); - QFileInfo info(QFile::decodeName(path)); + QFileInfo info(nativepath::displayFromNative(path)); QString suffix = info.suffix().toLower(); bool isJpg = (suffix == "jpg" || suffix == "jpeg"); @@ -738,7 +733,7 @@ void MainWindow::rotateImage(const QModelIndex &index, bool clockwise) const QByteArray path = m_imageModel->filePath(index); - if (::access(path.constData(), W_OK) != 0) { + if (!nativepath::isWritable(path)) { QMessageBox::warning(this, tr("Cannot Rotate Image"), tr("The file is write-protected and cannot be rotated.\n\n%1") .arg(displayPath(path))); diff --git a/src/mainwindow.h b/src/mainwindow.h index 6b784f6..5e92e39 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -63,7 +63,7 @@ private: void onPreferencesTriggered(); void resetLayoutToDefault(); void navigateToFolder(const QByteArray &path); - void navigateToFolder(const QString &path); // convenience: converts via QFile::encodeName + void navigateToFolder(const QString &path); // convenience: converts via nativepath::nativeFromDisplay void selectPreviousFolderIfExists(); void setFullScreenMode(bool fullScreen); void loadVisibleThumbnails(); diff --git a/src/nativepath.cpp b/src/nativepath.cpp new file mode 100644 index 0000000..783815e --- /dev/null +++ b/src/nativepath.cpp @@ -0,0 +1,256 @@ +/* +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 "nativepath.h" + +#include + +#ifdef _WIN32 +#include +#include +#include +#else +#include +#include +#include +#endif + +namespace nativepath { + +#ifdef _WIN32 + +namespace { + +constexpr char32_t kReplacement = 0xFFFD; +constexpr char32_t kHighSurrogateLo = 0xD800; +constexpr char32_t kHighSurrogateHi = 0xDBFF; +constexpr char32_t kLowSurrogateLo = 0xDC00; +constexpr char32_t kLowSurrogateHi = 0xDFFF; +constexpr char32_t kSupplementary = 0x10000; + +// Append a code point as (W)TF-8. Surrogate code points (0xD800-0xDFFF) are +// encoded as 3 bytes rather than rejected — this is what makes it WTF-8. +void appendUtf8(QByteArray &out, char32_t cp) +{ + if (cp < 0x80) { + out.append(static_cast(cp)); + } else if (cp < 0x800) { + out.append(static_cast(0xC0 | (cp >> 6))); + out.append(static_cast(0x80 | (cp & 0x3F))); + } else if (cp < kSupplementary) { + out.append(static_cast(0xE0 | (cp >> 12))); + out.append(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.append(static_cast(0x80 | (cp & 0x3F))); + } else { + out.append(static_cast(0xF0 | (cp >> 18))); + out.append(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.append(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.append(static_cast(0x80 | (cp & 0x3F))); + } +} + +} // namespace + +std::wstring wideFromNative(const QByteArray &nativePath) +{ + std::wstring out; + out.reserve(static_cast(nativePath.size())); + + const unsigned char *p = + reinterpret_cast(nativePath.constData()); + const qsizetype n = nativePath.size(); + + qsizetype i = 0; + while (i < n) { + const unsigned char b0 = p[i]; + char32_t cp; + qsizetype len; + if (b0 < 0x80) { cp = b0; len = 1; } + else if ((b0 & 0xE0) == 0xC0) { cp = b0 & 0x1F; len = 2; } + else if ((b0 & 0xF0) == 0xE0) { cp = b0 & 0x0F; len = 3; } + else if ((b0 & 0xF8) == 0xF0) { cp = b0 & 0x07; len = 4; } + else { + out.push_back(static_cast(kReplacement)); + ++i; + continue; + } + + if (i + len > n) { + out.push_back(static_cast(kReplacement)); + ++i; + continue; + } + + bool ok = true; + for (qsizetype k = 1; k < len; ++k) { + const unsigned char bk = p[i + k]; + if ((bk & 0xC0) != 0x80) { ok = false; break; } + cp = (cp << 6) | (bk & 0x3F); + } + if (!ok) { + out.push_back(static_cast(kReplacement)); + ++i; + continue; + } + + i += len; + if (cp < kSupplementary) { + out.push_back(static_cast(cp)); // BMP incl. lone surrogates + } else { + cp -= kSupplementary; + out.push_back(static_cast(kHighSurrogateLo + (cp >> 10))); + out.push_back(static_cast(kLowSurrogateLo + (cp & 0x3FF))); + } + } + return out; +} + +QByteArray nativeFromWide(const std::wstring &wide) +{ + QByteArray out; + out.reserve(static_cast(wide.size()) * 3); + + const size_t n = wide.size(); + for (size_t i = 0; i < n; ++i) { + char32_t cp = static_cast(wide[i]); + if (cp >= kHighSurrogateLo && cp <= kHighSurrogateHi && i + 1 < n) { + const char16_t next = static_cast(wide[i + 1]); + if (next >= kLowSurrogateLo && next <= kLowSurrogateHi) { + cp = kSupplementary + + ((cp - kHighSurrogateLo) << 10) + + (next - kLowSurrogateLo); + ++i; + } + } + appendUtf8(out, cp); + } + return out; +} + +std::filesystem::path pathFromNative(const QByteArray &nativePath) +{ + return std::filesystem::path(wideFromNative(nativePath)); +} + +QByteArray nativeFromPath(const std::filesystem::path &path) +{ + return nativeFromWide(path.native()); +} + +QString displayFromNative(const QByteArray &nativePath) +{ + const std::wstring w = wideFromNative(nativePath); + return QString::fromWCharArray(w.data(), static_cast(w.size())); +} + +QByteArray nativeFromDisplay(const QString &text) +{ + return nativeFromWide(text.toStdWString()); +} + +bool openNativeRead(const QByteArray &nativePath, QFile &outFile) +{ + const std::wstring w = wideFromNative(nativePath); + outFile.setFileName( + QString::fromWCharArray(w.data(), static_cast(w.size()))); + return outFile.open(QIODevice::ReadOnly); +} + +NativeStat nativeStat(const QByteArray &nativePath) +{ + NativeStat info; + struct _stat64 st{}; + if (_wstat64(wideFromNative(nativePath).c_str(), &st) != 0) + return info; + info.exists = true; + info.isRegular = (st.st_mode & _S_IFREG) != 0; + info.isDir = (st.st_mode & _S_IFDIR) != 0; + info.size = static_cast(st.st_size); + info.mtime = static_cast(st.st_mtime); + return info; +} + +bool isWritable(const QByteArray &nativePath) +{ + constexpr int kWriteMode = 2; // _waccess: 02 == write permission + return _waccess(wideFromNative(nativePath).c_str(), kWriteMode) == 0; +} + +#else // POSIX + +std::filesystem::path pathFromNative(const QByteArray &nativePath) +{ + return std::filesystem::path(nativePath.toStdString()); +} + +QByteArray nativeFromPath(const std::filesystem::path &path) +{ + return QByteArray::fromStdString(path.native()); +} + +QString displayFromNative(const QByteArray &nativePath) +{ + return QString::fromLocal8Bit(nativePath.constData(), nativePath.size()); +} + +QByteArray nativeFromDisplay(const QString &text) +{ + return QFile::encodeName(text); +} + +bool openNativeRead(const QByteArray &nativePath, QFile &outFile) +{ + const int fd = ::open(nativePath.constData(), O_RDONLY | O_CLOEXEC); + if (fd < 0) + return false; + if (!outFile.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); + return false; + } + return true; +} + +NativeStat nativeStat(const QByteArray &nativePath) +{ + NativeStat info; + struct ::stat st{}; + if (::stat(nativePath.constData(), &st) != 0) + return info; + info.exists = true; + info.isRegular = S_ISREG(st.st_mode); + info.isDir = S_ISDIR(st.st_mode); + info.size = static_cast(st.st_size); + info.mtime = static_cast(st.st_mtime); + return info; +} + +bool isWritable(const QByteArray &nativePath) +{ + return ::access(nativePath.constData(), W_OK) == 0; +} + +#endif + +} // namespace nativepath diff --git a/src/nativepath.h b/src/nativepath.h new file mode 100644 index 0000000..07d4ead --- /dev/null +++ b/src/nativepath.h @@ -0,0 +1,85 @@ +/* +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. + +*/ + +#pragma once + +#include +#include + +#include +#include + +class QFile; + +// Central conversion layer between the application's canonical path handle +// (a QByteArray of "native OS bytes") and the platform's filesystem APIs. +// +// The rest of the code stores every path as a QByteArray and never inspects +// its bytes. What those bytes *mean* is platform-defined and lives only here: +// * POSIX : the raw bytes returned by the OS (may be invalid UTF-8, e.g. +// Latin-1 names) — passed through unchanged. +// * Windows: WTF-8 — the UTF-8 generalisation that also encodes unpaired +// UTF-16 surrogates, so ill-formed-but-valid Windows filenames +// round-trip losslessly, matching the POSIX "never mangle a name" +// guarantee. +// +// This is the only translation unit that contains platform #ifdefs. +namespace nativepath { + +// QByteArray native path <-> std::filesystem::path (used for enumeration and +// path decomposition such as filename()/parent_path()/extension()). +std::filesystem::path pathFromNative(const QByteArray &nativePath); +QByteArray nativeFromPath(const std::filesystem::path &path); + +// Native byte path -> lossy display string for the UI. +QString displayFromNative(const QByteArray &nativePath); + +// Well-formed display/UI string (dialogs, typed input) -> native byte path. +QByteArray nativeFromDisplay(const QString &text); + +// Open a file read-only and adopt it into outFile. Returns true on success. +// Bypasses Qt's name-based path handling on POSIX (so non-UTF-8 names work); +// on Windows Qt already opens via the wide API, which is lossless. +bool openNativeRead(const QByteArray &nativePath, QFile &outFile); + +struct NativeStat { + bool exists = false; + bool isRegular = false; + bool isDir = false; + qint64 size = 0; + qint64 mtime = 0; +}; +NativeStat nativeStat(const QByteArray &nativePath); + +// True if the file exists and is writable by the current process. +bool isWritable(const QByteArray &nativePath); + +#ifdef _WIN32 +// WTF-8 codec. Exposed for the exiv2 wide-path overload and for unit tests. +std::wstring wideFromNative(const QByteArray &nativePath); +QByteArray nativeFromWide(const std::wstring &wide); +#endif + +} // namespace nativepath diff --git a/src/pathcompletermodel.cpp b/src/pathcompletermodel.cpp index 931888d..7a024a9 100644 --- a/src/pathcompletermodel.cpp +++ b/src/pathcompletermodel.cpp @@ -24,6 +24,7 @@ SOFTWARE. */ #include "pathcompletermodel.h" +#include "nativepath.h" #include @@ -75,27 +76,26 @@ void PathCompleterModel::setPrefix(const QString &text) QStringList newEntries; try { - const QByteArray parentNative = QFile::encodeName(parentDir); - std::vector> entries; // {nativePath, filename} + const QByteArray parentNative = nativepath::nativeFromDisplay(parentDir); + std::vector> entries; // {nativePath, filename} for (const fs::directory_entry &entry : - fs::directory_iterator(parentNative.toStdString(), + fs::directory_iterator(nativepath::pathFromNative(parentNative), fs::directory_options::skip_permission_denied)) { std::error_code ec; - const std::string fname = entry.path().filename().native(); - if (fname.empty() || fname[0] == '.') + const QByteArray fname = nativepath::nativeFromPath(entry.path().filename()); + if (fname.isEmpty() || fname.at(0) == '.') continue; if (!entry.is_directory(ec)) continue; - entries.push_back({entry.path().native(), fname}); + entries.push_back({nativepath::nativeFromPath(entry.path()), fname}); } std::sort(entries.begin(), entries.end(), [](const auto &a, const auto &b) { return a.second < b.second; }); newEntries.reserve(static_cast(entries.size())); for (const auto &[npath, fname] : entries) { - newEntries.append(QFile::decodeName( - QByteArray::fromStdString(npath))); + newEntries.append(nativepath::displayFromNative(npath)); } } catch (const fs::filesystem_error &) { // Inaccessible parent — fall through with an empty list. diff --git a/src/pathcompletermodel.h b/src/pathcompletermodel.h index 49ec973..71639bb 100644 --- a/src/pathcompletermodel.h +++ b/src/pathcompletermodel.h @@ -35,8 +35,8 @@ SOFTWARE. // when the parent directory actually changes, so per-keystroke cost is just // the QCompleter filter pass over an already-loaded list. // -// Directory enumeration mirrors FsDirModel::listSubdirs (std::filesystem with -// QFile::decodeName), so non-ASCII names that round-trip through the locale +// Directory enumeration mirrors FsDirModel::listSubdirs (std::filesystem via +// the nativepath helpers), so names the OS's Unicode encoding cannot represent // are listed correctly. class PathCompleterModel : public QAbstractListModel { diff --git a/src/thumbnailcache.cpp b/src/thumbnailcache.cpp index 1832f06..3f81b64 100644 --- a/src/thumbnailcache.cpp +++ b/src/thumbnailcache.cpp @@ -24,6 +24,7 @@ SOFTWARE. */ #include "thumbnailcache.h" +#include "nativepath.h" #include #include @@ -32,8 +33,6 @@ SOFTWARE. #include #include -#include - namespace { constexpr const char *kSoftware = "SagoImageBrowser"; @@ -89,8 +88,8 @@ bool ThumbnailCache::isInsideCache(const QByteArray &absPath) // using its arguments. Safe to call concurrently from multiple threads. QImage ThumbnailCache::load(const QByteArray &sourcePath, Size size) { - struct ::stat st{}; - if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode)) + const nativepath::NativeStat st = nativepath::nativeStat(sourcePath); + if (!st.isRegular) return {}; const QByteArray uri = canonicalUri(sourcePath); @@ -100,7 +99,7 @@ QImage ThumbnailCache::load(const QByteArray &sourcePath, Size size) if (!img.load(cachePath, "PNG")) return {}; - const qint64 mtime = static_cast(st.st_mtime); + const qint64 mtime = st.mtime; bool ok = false; const qint64 cachedMTime = img.text("Thumb::MTime").toLongLong(&ok); if (!ok || cachedMTime != mtime) @@ -118,8 +117,8 @@ void ThumbnailCache::save(const QByteArray &sourcePath, Size size, const QImage if (thumbImage.isNull()) return; - struct ::stat st{}; - if (::stat(sourcePath.constData(), &st) != 0 || !S_ISREG(st.st_mode)) + const nativepath::NativeStat st = nativepath::nativeStat(sourcePath); + if (!st.isRegular) return; const QByteArray uri = canonicalUri(sourcePath); @@ -134,9 +133,8 @@ void ThumbnailCache::save(const QByteArray &sourcePath, Size size, const QImage QImage tagged = thumbImage; tagged.setText("Thumb::URI", QString::fromLatin1(uri)); - tagged.setText("Thumb::MTime", - QString::number(static_cast(st.st_mtime))); - tagged.setText("Thumb::Size", QString::number(static_cast(st.st_size))); + tagged.setText("Thumb::MTime", QString::number(st.mtime)); + tagged.setText("Thumb::Size", QString::number(st.size)); tagged.setText("Software", QString::fromLatin1(kSoftware)); QTemporaryFile tmp(subdir + "/tmp_XXXXXX.png"); diff --git a/src/thumbnailworker.cpp b/src/thumbnailworker.cpp index e4863f1..2116ed0 100644 --- a/src/thumbnailworker.cpp +++ b/src/thumbnailworker.cpp @@ -26,13 +26,11 @@ SOFTWARE. #include "thumbnailworker.h" #include "thumbnailcache.h" +#include "nativepath.h" #include #include -#include -#include - namespace { constexpr int kDisplaySize = 128; @@ -89,18 +87,12 @@ void ThumbnailWorker::run() return; } - // 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; - + // Open via the native-path helper so that names the OS's Unicode encoding + // cannot represent (non-UTF-8 bytes on POSIX, unpaired surrogates on + // Windows) are handled correctly. QFile file; - if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) - { - ::close(fd); + if (!nativepath::openNativeRead(m_path, file)) return; - } QImageReader reader(&file); reader.setAutoTransform(true);