[frameworks/kio] /: FilePreviewJob: regenerate a cached thumbnail that is too small

Méven Car <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 96f645896369a290d70fae42d60994ff9036a920 by Méven Car.
Committed on 30/07/2026 at 17:17.
Pushed by meven into branch 'master'.

FilePreviewJob: regenerate a cached thumbnail that is too small

isCacheValid() validated the identity of a cached thumbnail (URI, mtime,
file size, thumbnailer version) but never its dimensions. A thumbnail
cached for a smaller request, at a lower icon size or a lower device
pixel ratio, can land in the same cache tier as a larger request and be
reused unchanged. emitPreview() only ever downscales, so the undersized
thumbnail was passed through and scaled up by the caller, looking blurry.

Reject a cached thumbnail whose longer edge is smaller than the size
needed now, but only when the original image is large enough to yield a
bigger one, read from the freedesktop Thumb::Image::Width and
Thumb::Image::Height metadata. Without that guard a small original, whose
thumbnail can never be larger, would be regenerated on every request.

Add a data-driven test covering the too-small large-original case that
must regenerate and the guard cases that must keep the cache.

M  +85   -0    autotests/filepreviewjobtest.cpp
M  +2    -0    autotests/filepreviewjobtest.h
M  +12   -0    src/gui/filepreviewjob.cpp

https://invent.kde.org/frameworks/kio/-/commit/96f645896369a290d70fae42d60994ff9036a920

diff --git a/autotests/filepreviewjobtest.cpp b/autotests/filepreviewjobtest.cpp
index b369895ad6..adb34b9e9b 100644
--- a/autotests/filepreviewjobtest.cpp
+++ b/autotests/filepreviewjobtest.cpp
@@ -11,6 +11,8 @@
 
 #include <KFileItem>
 
+#include <QDateTime>
+#include <QImage>
 #include <QSignalSpy>
 #include <QStandardPaths>
 #include <QTemporaryDir>
@@ -22,6 +24,23 @@ QTEST_GUILESS_MAIN(FilePreviewJobTest)
 
 using namespace KIO;
 
+// A cached-thumbnail stand-in: cachedW x cachedH px with the metadata isCacheValid()
+// reads. origW/origH <= 0 omit the original-size tags.
+static QImage makeCachedThumb(int cachedW, int cachedH, const QByteArray &origName, qint64 mtime, int origW, int origH)
+{
+    QImage thumb(cachedW, cachedH, QImage::Format_ARGB32);
+    thumb.fill(Qt::black);
+    thumb.setText(QStringLiteral("Thumb::URI"), QString::fromUtf8(origName));
+    thumb.setText(QStringLiteral("Thumb::MTime"), QString::number(mtime));
+    if (origW > 0) {
+        thumb.setText(QStringLiteral("Thumb::Image::Width"), QString::number(origW));
+    }
+    if (origH > 0) {
+        thumb.setText(QStringLiteral("Thumb::Image::Height"), QString::number(origH));
+    }
+    return thumb;
+}
+
 void FilePreviewJobTest::initTestCase()
 {
     QStandardPaths::setTestModeEnabled(true);
@@ -74,3 +93,69 @@ void FilePreviewJobTest::testTimeoutTimerStoppedOnFinish()
 
     delete job;
 }
+
+void FilePreviewJobTest::testCacheSizeValidation_data()
+{
+    QTest::addColumn<int>("cachedW");
+    QTest::addColumn<int>("cachedH");
+    QTest::addColumn<int>("origW");
+    QTest::addColumn<int>("origH");
+    QTest::addColumn<bool>("expectedValid");
+    QTest::addColumn<QSize>("expectedOutputSize"); // emitted preview when the cache is accepted
+
+    // The request below needs 256 * 1.75 == 448 px (longer edge).
+    QTest::newRow("exact size") << 448 << 448 << 4000 << 3000 << true << QSize(448, 448);
+    QTest::newRow("larger than needed") << 512 << 512 << 4000 << 3000 << true << QSize(448, 448); // downscaled
+    // Too small for the current request, but the original is large enough to yield a
+    // bigger thumbnail: reject so it is regenerated at full resolution.
+    QTest::newRow("too small, large original") << 256 << 256 << 4000 << 3000 << false << QSize();
+    // Too small, but the original is itself that small: keep it, regenerating would
+    // not produce anything bigger and would just repeat on every request.
+    QTest::newRow("too small, small original") << 256 << 200 << 256 << 200 << true << QSize(256, 200);
+    // Too small, but no original-size metadata to prove a bigger one exists: keep it.
+    QTest::newRow("too small, no metadata") << 256 << 256 << 0 << 0 << true << QSize(256, 256);
+}
+
+void FilePreviewJobTest::testCacheSizeValidation()
+{
+    QFETCH(int, cachedW);
+    QFETCH(int, cachedH);
+    QFETCH(int, origW);
+    QFETCH(int, origH);
+    QFETCH(bool, expectedValid);
+    QFETCH(QSize, expectedOutputSize);
+
+    QTemporaryDir thumbRoot;
+    QVERIFY(thumbRoot.isValid());
+
+    const KFileItem item(QUrl::fromLocalFile(QStringLiteral("/tmp/does-not-matter.png")));
+
+    PreviewOptions options;
+    options.size = QSize(256, 256);
+    options.devicePixelRatio = 1.75; // needed longer edge = 256 * 1.75 = 448 px
+
+    PreviewSetupData setupData;
+    setupData.thumbRoot = thumbRoot.path();
+
+    auto *job = new FilePreviewJob(item, FilePreviewJob::UnknownDeviceId, options, setupData);
+    job->setAutoDelete(false);
+
+    // Make isCacheValid()'s identity checks pass (matching URI and mtime, no
+    // Thumb::Size and no thumbnailer plugin), so only the size logic is exercised.
+    const QByteArray origName = "file:///tmp/does-not-matter.png";
+    const qint64 mtime = 1000000;
+    job->m_origName = origName;
+    job->m_tOrig = QDateTime::fromSecsSinceEpoch(mtime);
+
+    QImage thumb = makeCachedThumb(cachedW, cachedH, origName, mtime, origW, origH);
+    thumb.setDevicePixelRatio(options.devicePixelRatio); // as loadThumbnailFromCache() sets it
+    QCOMPARE(job->isCacheValid(thumb), expectedValid);
+
+    if (expectedValid) {
+        // An accepted cache is emitted as-is (only downscaled when larger than needed).
+        job->emitPreview(thumb);
+        QCOMPARE(job->previewImage().size(), expectedOutputSize);
+    }
+
+    delete job;
+}
diff --git a/autotests/filepreviewjobtest.h b/autotests/filepreviewjobtest.h
index c77a331697..a9bc40440d 100644
--- a/autotests/filepreviewjobtest.h
+++ b/autotests/filepreviewjobtest.h
@@ -17,6 +17,8 @@ class FilePreviewJobTest : public QObject
 private Q_SLOTS:
     void initTestCase();
     void testTimeoutTimerStoppedOnFinish();
+    void testCacheSizeValidation_data();
+    void testCacheSizeValidation();
 };
 
 #endif
diff --git a/src/gui/filepreviewjob.cpp b/src/gui/filepreviewjob.cpp
index 4bf52613c2..4c3ea3cbb8 100644
--- a/src/gui/filepreviewjob.cpp
+++ b/src/gui/filepreviewjob.cpp
@@ -368,6 +368,18 @@ bool FilePreviewJob::isCacheValid(const QImage &thumb)
         return false;
     }
 
+    // Reject a cached thumbnail smaller than needed now (blurry if scaled up), but
+    // only when the original is large enough to yield a bigger one; else keep it.
+    const int neededPixels = qMax(m_options.size.width(), m_options.size.height()) * m_options.devicePixelRatio;
+    const int cachedPixels = qMax(thumb.width(), thumb.height());
+    if (cachedPixels < neededPixels) {
+        const int origWidth = thumb.text(QStringLiteral("Thumb::Image::Width")).toInt();
+        const int origHeight = thumb.text(QStringLiteral("Thumb::Image::Height")).toInt();
+        if (qMax(origWidth, origHeight) > cachedPixels) {
+            return false;
+        }
+    }
+
     QString thumbnailerVersion = m_plugin.value(QStringLiteral("ThumbnailerVersion"));
 
     if (!thumbnailerVersion.isEmpty() && thumb.text(QStringLiteral("Software")).startsWith(QLatin1String("KDE Thumbnail Generator"))) {
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.