[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"))) {