From 24b55d8d878b7b314710107c64456ed315d230c7 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Sat, 21 Feb 2026 17:12:06 -0700 Subject: [PATCH 01/25] doc: Add link to the latest GitHub Release Point users to pre-built .deb packages available from the GitHub Releases page, as an alternative to the PPA and building from source. Co-developed-by: Claude Opus 4.6 --- doc/install.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/install.rst b/doc/install.rst index cd36567..eabee46 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -20,6 +20,13 @@ This provides pre-built packages for the following Ubuntu releases: - Noble (24.04 LTS) - Questing (25.10) +Pre-built .deb Packages +----------------------- + +Pre-built ``.deb`` packages for a range of Debian and Ubuntu releases are +available from the `latest GitHub Release +`_. + Building from Source -------------------- From e56c85c80a14c07d11bde81c28715117d78cc570 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Sun, 22 Feb 2026 07:22:49 -0700 Subject: [PATCH 02/25] app: Set a connection timeout for the local URL probe The local URL probe relies on Future.timeout() to give up after a few seconds, but the underlying io.HttpClient has no connectionTimeout set. On some platforms the OS-level TCP connect retries SYN packets for 30+ seconds to an unreachable local IP, making the app appear to hang. Set connectionTimeout on the HttpClient so the socket layer itself gives up quickly. Also reduce the default timeout from 2 seconds to 500 ms, since anything reachable on the local network responds well within that. Co-developed-by: Claude Opus 4.6 --- app/lib/services/api_service.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/lib/services/api_service.dart b/app/lib/services/api_service.dart index dc729f0..3f2c04b 100644 --- a/app/lib/services/api_service.dart +++ b/app/lib/services/api_service.dart @@ -51,21 +51,27 @@ class ApiService { /// When connecting to a local IP the server's certificate is issued /// for the domain name, not the IP address. This client accepts /// that mismatch for the specific local host only. - http.Client _createTrustingClient(String host) { + http.Client _createTrustingClient( + String host, { + Duration? connectionTimeout, + }) { final ioClient = io.HttpClient() ..badCertificateCallback = (io.X509Certificate cert, String h, int port) => h == host; + if (connectionTimeout != null) { + ioClient.connectionTimeout = connectionTimeout; + } return IOClient(ioClient); } /// Try the local URL first; if it responds within [timeout], use it /// as the active base URL. Otherwise fall back to the main URL. Future tryLocalUrl({ - Duration timeout = const Duration(seconds: 2), + Duration timeout = const Duration(milliseconds: 500), }) async { if (_localBaseUrl == null || _localBaseUrl!.isEmpty) return; final uri = Uri.parse('$_localBaseUrl/status'); - final client = _createTrustingClient(uri.host); + final client = _createTrustingClient(uri.host, connectionTimeout: timeout); try { final response = await client.get(uri, headers: _headers) .timeout(timeout); From b9b735c1efba2747630431b7fbbacff202513017 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 03:21:37 -0700 Subject: [PATCH 03/25] dirmodel: Fix crash creating a dir not in the cache When a directory is created on the filesystem outside of paperman (or since the cache was last built), refreshCache() calls findItemW() which returns nullptr for the unknown path. The subsequent freeChildren() call on the null pointer causes a segfault. Handle this by dropping the stale cache and rebuilding it from scratch when the directory is not found, matching the existing behaviour for when no cache exists at all. Add a test that reproduces the crash by creating a directory on the filesystem bypassing paperman, then attempting to create a subdirectory inside it. Co-developed-by: Claude Opus 4.6 --- dirmodel.cpp | 9 +++++++++ test/test_ops.cpp | 36 ++++++++++++++++++++++++++++++++++++ test/test_ops.h | 3 +++ 3 files changed, 48 insertions(+) diff --git a/dirmodel.cpp b/dirmodel.cpp index acb21a4..65e1cb4 100644 --- a/dirmodel.cpp +++ b/dirmodel.cpp @@ -159,6 +159,15 @@ bool Diritem::refreshCache(const QString dirPath, Operation *op) Q_ASSERT(_dir_cache); top = _dir_cache->findItemW(rel); + if (!top) { + // Directory is not in the cache, e.g. it was created outside the + // app since the cache was last built. Rebuild from scratch. + dropCache(); + if (!buildCache(op)) + return false; + return true; + } + TreeItem *updated = utilScanDir(dirPath, op); top->freeChildren(); diff --git a/test/test_ops.cpp b/test/test_ops.cpp index 2b5e8e1..7c49e41 100644 --- a/test/test_ops.cpp +++ b/test/test_ops.cpp @@ -553,6 +553,42 @@ void TestOps::testCreateDir() QCOMPARE(foundIndex.isValid(), true); } +void TestOps::testCreateDirInNewParent() +{ + Mainwindow me; + + // Add our test repo + auto path = setupRepo(); + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + // Create a top-level subdirectory to trigger initial cache building + QString topDir = path + "/subdir"; + QModelIndex dirIndex; + bool ok = desktop->newDir(topDir, dirIndex); + QCOMPARE(ok, true); + + // Create a new directory on the filesystem, bypassing paperman. This + // simulates a directory that was created outside the app, or one that + // appeared since the cache was last built (e.g. a new year directory) + QDir(path + "/main/one").mkdir("c"); + QDir dir(path + "/main/one/c"); + QCOMPARE(dir.exists(), true); + + // Now try to create a subdirectory inside the new directory. The cache + // does not know about "main/one/c", so refreshCache() must handle a + // missing parent gracefully rather than crashing. + QString nestedDir = path + "/main/one/c/leaf"; + QModelIndex nestedIndex; + ok = desktop->newDir(nestedDir, nestedIndex); + QCOMPARE(ok, true); + + // Check that the directory was created on disk + QDir leafDir(nestedDir); + QCOMPARE(leafDir.exists(), true); +} + void TestOps::testMoveToDir() { Mainwindow me; diff --git a/test/test_ops.h b/test/test_ops.h index 78c1375..d5abd44 100644 --- a/test/test_ops.h +++ b/test/test_ops.h @@ -52,6 +52,9 @@ private slots: //! Test creating a dir void testCreateDir(); + //! Test creating a dir inside a directory not in the cache + void testCreateDirInNewParent(); + //! Test moving a file to a different directory void testMoveToDir(); From d96b10ab0981478332b3d6e057da5af301dbe7eb Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 04:08:55 -0700 Subject: [PATCH 04/25] test: Add test for folder month suggestion after newDir Add testFindFoldersSuggestsMonth() which verifies that findFolders() correctly suggests the next month directory after creating directories through the app within the same session. The test builds the cache, creates 01jan and 02feb via newDir(), then checks that findFolders() suggests 03mar. This exercises the cache update path to ensure the in-memory cache reflects newly created directories. Co-developed-by: Claude Opus 4.6 --- test/test_ops.cpp | 47 +++++++++++++++++++++++++++++++++++++++++++++++ test/test_ops.h | 3 +++ 2 files changed, 50 insertions(+) diff --git a/test/test_ops.cpp b/test/test_ops.cpp index 7c49e41..09403f3 100644 --- a/test/test_ops.cpp +++ b/test/test_ops.cpp @@ -905,3 +905,50 @@ void TestOps::testRenameDir() QModelIndex oldDirIndex = dirmodel->index(oldPath); QCOMPARE(oldDirIndex.isValid(), false); } + +void TestOps::testFindFoldersSuggestsMonth() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + QModelIndex root = dirmodel->index(path); + QCOMPARE(root.isValid(), true); + + // Trigger initial cache building by calling findFolders + QStringList missing; + QStringList folders = dirmodel->findFolders("bills", path, root, missing, + nullptr); + QCOMPARE(missing.size(), 0); + + // Now create month directories through the app, as the user did + QString jan = path + "/bills/2026/01jan"; + QString feb = path + "/bills/2026/02feb"; + QModelIndex janIndex, febIndex; + bool ok = desktop->newDir(jan, janIndex); + QCOMPARE(ok, true); + ok = desktop->newDir(feb, febIndex); + QCOMPARE(ok, true); + + // Re-obtain the root index since newDir modifies the model + root = dirmodel->index(path); + QCOMPARE(root.isValid(), true); + + // Search again - the in-memory cache should now include the new + // directories and suggest creating the current month + folders = dirmodel->findFolders("bills", path, root, missing, nullptr); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list, " + "got: [%1]").arg(missing.join(", ")))); +} diff --git a/test/test_ops.h b/test/test_ops.h index d5abd44..e6b1df6 100644 --- a/test/test_ops.h +++ b/test/test_ops.h @@ -67,6 +67,9 @@ private slots: //! Test renaming a directory void testRenameDir(); + //! Test that findFolders suggests the next month directory + void testFindFoldersSuggestsMonth(); + private: //! Setup the test repo and return its model and root index void getTestRepo(Mainwindow *me, Desktopmodel*& model, From 4cf65294f66cf7c2dd4ba7f9445c038c21663001 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 04:09:18 -0700 Subject: [PATCH 05/25] mainwindow: Null _label after deletion in updateProgress() The uninit handler deletes _label but does not null the pointer, unlike _progress which is correctly set to zero on the next line. If two Operation objects are created and destroyed in sequence, the second uninit attempts to delete the dangling _label pointer, causing a crash. Add the missing null assignment. Co-developed-by: Claude Opus 4.6 --- mainwindow.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/mainwindow.cpp b/mainwindow.cpp index c5a07f9..d5b8e90 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -330,6 +330,7 @@ void Mainwindow::updateProgress(enum Operation::state_t state, int percent, delete _progress; delete _label; _progress = 0; + _label = 0; break; default: _progress->setValue (percent); From d34872305bc08e47c58aa9ddc93becbb4420ceb3 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 07:27:38 -0700 Subject: [PATCH 06/25] desktopwidget: Fix stale Dirview context after doNewDir() doNewDir() calls _model->mkdir() which internally calls QDirModel::refresh(). This refresh invalidates the QPersistentModelIndex _context in Dirview. Any subsequent operation that depends on _context, such as getRootIndex() used by findFolders() in the pscan dialog, silently returns an invalid index and produces empty results. Save the parent directory path before mkdir and re-establish _context afterwards via selectContextItem(). This matches the approach already used in the test-friendly newDir(path, index) variant, which calls selectDir() after mkdir. Co-developed-by: Claude Opus 4.6 --- desktopwidget.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/desktopwidget.cpp b/desktopwidget.cpp index c8597cb..b19f9cf 100644 --- a/desktopwidget.cpp +++ b/desktopwidget.cpp @@ -781,12 +781,23 @@ QModelIndex Desktopwidget::doNewDir(const QString& name, QString& path) { QModelIndex index = _dir->menuGetModelIndex (); QModelIndex src_ind = _dir_proxy->mapToSource(index); - path = _model->data(src_ind, QDirModel::FilePathRole).toString() + - QDir::separator() + name; + QString parentPath = _model->data(src_ind, + QDirModel::FilePathRole).toString(); + path = parentPath + QDir::separator() + name; Operation op("Creating directory", 0, this); QModelIndex new_ind = _model->mkdir(src_ind, name, &op); + // mkdir() refreshes the QDirModel, which can invalidate the + // proxy model's persistent indices, including _context in Dirview. + // Re-select the parent so that getRootIndex() and other operations + // that depend on _context still work. + src_ind = _model->index(parentPath); + if (src_ind.isValid()) { + QModelIndex proxy_ind = _dir_proxy->mapFromSource(src_ind); + _dir->selectContextItem(proxy_ind); + } + return new_ind; } From 181f3b1be2c5f39a79eef6ca547eee0b0a81768b Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 07:27:51 -0700 Subject: [PATCH 07/25] test: Add tests for folder suggestions via the desktop path Add two tests that exercise the Desktopwidget::findFolders() code path, which is used by the pscan dialog's F4 folder-search feature. testFindFoldersSuggestsMonthViaDesktop simulates the real UI workflow: set _context to bills/2026 as if the user right-clicked, then create directories via doNewDir(), and verify that findFolders() still suggests the next month. Before the previous fix, this test fails because doNewDir() invalidates _context and getRootIndex() returns an invalid index. testFindFoldersSuggestsMonthAfterRefresh does the same but also calls refreshDirmodelCache() between creating directories and searching, to cover the case where the user manually refreshes the cache. Co-developed-by: Claude Opus 4.6 --- test/test_ops.cpp | 110 ++++++++++++++++++++++++++++++++++++++++++++++ test/test_ops.h | 6 +++ 2 files changed, 116 insertions(+) diff --git a/test/test_ops.cpp b/test/test_ops.cpp index 09403f3..9e4b0ab 100644 --- a/test/test_ops.cpp +++ b/test/test_ops.cpp @@ -9,6 +9,7 @@ #include "desktopview.h" #include "desktopwidget.h" #include "dirmodel.h" +#include "dirview.h" #include "mainwindow.h" #include "test_ops.h" @@ -952,3 +953,112 @@ void TestOps::testFindFoldersSuggestsMonth() qPrintable(QString("Expected 'bills/2026/03mar' in missing list, " "got: [%1]").arg(missing.join(", ")))); } + +void TestOps::testFindFoldersSuggestsMonthViaDesktop() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + // Trigger initial cache building through the desktop path, which + // uses getRootDirectory() / getRootIndex() internally + QStringList missing; + QString dirPath; + QStringList folders = desktop->findFolders("bills", dirPath, missing); + QCOMPARE(dirPath, path); + QCOMPARE(missing.size(), 0); + + // Simulate the UI workflow: user navigates to bills/2026 in the + // Dirview tree, then right-clicks to create directories. The UI + // doNewDir() creates via _model->mkdir() then re-selects the parent. + // First, set the Dirview context to bills/2026, as if the user + // right-clicked on it. + QModelIndex bills2026_src = dirmodel->index(path + "/bills/2026"); + QVERIFY2(bills2026_src.isValid(), + "bills/2026 should exist in the model"); + QModelIndex bills2026_proxy = desktop->_dir_proxy->mapFromSource( + bills2026_src); + desktop->_dir->selectContextItem(bills2026_proxy); + + // Create 01jan via doNewDir(), as the UI does from the right-click menu + QString newPath; + QModelIndex jan_ind = desktop->doNewDir("01jan", newPath); + QVERIFY2(jan_ind.isValid(), "doNewDir 01jan should succeed"); + + // Create 02feb the same way - doNewDir uses _context as the parent + QModelIndex feb_ind = desktop->doNewDir("02feb", newPath); + QVERIFY2(feb_ind.isValid(), "doNewDir 02feb should succeed"); + + // Check that getRootIndex() still works after doNewDir + QModelIndex root = desktop->getRootIndex(); + QVERIFY2(root.isValid(), + "getRootIndex() should still be valid after doNewDir"); + QVERIFY2(dirmodel->isRoot(root), + "getRootIndex() should return a root index"); + + // Search through the desktop path - this is how the pscan dialog + // finds folders. + folders = desktop->findFolders("bills", dirPath, missing); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list, " + "got: [%1]").arg(missing.join(", ")))); +} + +void TestOps::testFindFoldersSuggestsMonthAfterRefresh() +{ + Mainwindow me; + + // Set up a repo with bills/2026 but no month directories yet + auto path = setupRepo(); + QDir dir(path); + Q_ASSERT(dir.mkpath("bills/2026")); + + Desktopwidget *desktop = me.getDesktop(); + err_info *err = desktop->addDir(path); + Q_ASSERT(!err); + + Dirmodel *dirmodel = desktop->getDirmodel(); + Q_ASSERT(dirmodel); + + // Trigger initial cache building + QStringList missing; + QString dirPath; + QStringList folders = desktop->findFolders("bills", dirPath, missing); + QCOMPARE(dirPath, path); + QCOMPARE(missing.size(), 0); + + // Set the Dirview context to bills/2026, as if the user right-clicked + QModelIndex bills2026_src = dirmodel->index(path + "/bills/2026"); + QVERIFY(bills2026_src.isValid()); + QModelIndex bills2026_proxy = desktop->_dir_proxy->mapFromSource( + bills2026_src); + desktop->_dir->selectContextItem(bills2026_proxy); + + // Create directories via doNewDir, as the UI does + QString newPath; + desktop->doNewDir("01jan", newPath); + desktop->doNewDir("02feb", newPath); + + // User presses "Refresh cache" from the context menu, which also + // uses _context to determine the refresh point + desktop->refreshDirmodelCache(path); + + // Now search via desktop path + folders = desktop->findFolders("bills", dirPath, missing); + + QVERIFY2(missing.contains("bills/2026/03mar"), + qPrintable(QString("Expected 'bills/2026/03mar' in missing list " + "after cache refresh, got: [%1]") + .arg(missing.join(", ")))); +} diff --git a/test/test_ops.h b/test/test_ops.h index e6b1df6..70b2db2 100644 --- a/test/test_ops.h +++ b/test/test_ops.h @@ -70,6 +70,12 @@ private slots: //! Test that findFolders suggests the next month directory void testFindFoldersSuggestsMonth(); + //! Test that findFolders via the desktop path suggests the next month + void testFindFoldersSuggestsMonthViaDesktop(); + + //! Test that findFolders works after a cache refresh + void testFindFoldersSuggestsMonthAfterRefresh(); + private: //! Setup the test repo and return its model and root index void getTestRepo(Mainwindow *me, Desktopmodel*& model, From 0b43d0902155441e62fe1727aeefa32c18753326 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 07:45:11 -0700 Subject: [PATCH 08/25] filemax: Fix use-after-free in Filemax::create() The code stores a pointer from toLatin1().constData() in a local variable, but the temporary QByteArray is destroyed at the end of the statement, leaving a dangling pointer. The subsequent fopen() and utilSetGroup() calls read freed memory. Keep the QByteArray alive in a named variable and use constData() from it instead. Co-developed-by: Claude Opus 4.6 --- filemax.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/filemax.cpp b/filemax.cpp index 4b9ca13..3c72703 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -5741,13 +5741,13 @@ err_info *Filemax::create (void) _signature = 0x46476956; _valid = true; - const char *fname = _pathname.toLatin1().constData(); - _fin = fopen (fname, "w+b"); + QByteArray fname = _pathname.toLatin1(); + _fin = fopen (fname.constData(), "w+b"); if (!_fin) return err_make (ERRFN, ERR_cannot_open_file1, - _pathname.toLatin1 ().constData()); + fname.constData()); - utilSetGroup(fname); + utilSetGroup(fname.constData()); debug2 (("page count %d, chunk count %d\n", _pages.size (), _chunks.size ())); return NULL; From 3ff01f677b7fa3565204872dc0b3536f7dc6cfb4 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 09:36:56 -0700 Subject: [PATCH 09/25] filemax: Add padding in max_cache_data() for 4-byte lookahead The compressed tile decoder in decode_compressed_tile() reads a 4-byte word from the input buffer at each step. When the input pointer is near the end of the buffer, this read overflows the allocation. Add 4 bytes of zero padding when allocating the cache buffer so that the 4-byte lookahead near the end of compressed tile data does not cause a heap-buffer-overflow. The extra bytes are zero-filled by Qt's resize() and only read data comes from fread() with the original size. Found by AddressSanitizer. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/filemax.cpp b/filemax.cpp index 3c72703..9321349 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -734,7 +734,10 @@ err_info *Filemax::max_cache_data (cache_info &cache, int pos, { int n; - cache.buff.resize (size); + // Allocate extra padding so that callers which read a 4-byte word + // near the end of the buffer (e.g. decode_compressed_tile) do not + // overflow. Qt zero-fills the extra bytes. + cache.buff.resize (size + 4); // read the data fseek (_fin, pos, SEEK_SET); From 50bc542495074b8993093c5f70c5a65e9bd8eeca Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 09:37:08 -0700 Subject: [PATCH 10/25] filemax: Fix heap overflow in insert_chunk() when reusing larger slot When insert_chunk() reuses an existing chunk slot that is larger than the new chunk data, it expands new_chunk.size to match the old slot size. However, it does not reallocate new_chunk.buf, leaving the buffer at its original (smaller) size. When flush_chunks() later writes chunk.size bytes from chunk.buf, it reads past the end of the buffer. Reallocate the buffer to match the expanded size and zero-fill the new portion. Found by AddressSanitizer. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/filemax.cpp b/filemax.cpp index 9321349..9f1c6ef 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -4911,9 +4911,16 @@ err_info *Filemax::insert_chunk (chunk_info &new_chunk, int *posp) // we can't cope with any gaps! else { - unsigned short *ptr = (unsigned short *)new_chunk.buf; + int old_size = new_chunk.size; new_chunk.size = chunk->size; + new_chunk.buf = (byte *)realloc(new_chunk.buf, new_chunk.size); + if (new_chunk.size > old_size) + memset(new_chunk.buf + old_size, 0, + new_chunk.size - old_size); + + unsigned short *ptr = (unsigned short *)new_chunk.buf; + ptr [1] = chunk->size; ptr [2] = chunk->size >> 16; } From ea40427af225bd868b6086d2fa93569b36856549 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 09:53:00 -0700 Subject: [PATCH 11/25] filemax: Fix memory leaks in build_chunk() When build_chunk() is called with force=true and chunk.buf already exists, it recalculates chunk.size and allocates a new buffer via alloc_chunk_buf() without freeing the old one. This leaks the previous buffer each time a chunk is force-rebuilt. Free the old buffer before allocating the new one for all three chunk types (bermuda, annot, env). The existing TODO comments indicated the developer was aware of this issue. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filemax.cpp b/filemax.cpp index 9f1c6ef..f6388e8 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -4696,7 +4696,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) case CT_bermuda : chunk.size = ALIGN_CHUNK (POS_chunk_header_size + 0x16 + 0xc * _pages.size ()); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_bermuda (buf); @@ -4711,7 +4711,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) count += 1 + strlen (_annot_data [type].toLatin1()); chunk.size = ALIGN_CHUNK (POS_chunk_header_size + 0x45 + count); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_annot (buf, chunk.size, _annot_data); @@ -4727,7 +4727,7 @@ err_info *Filemax::build_chunk (chunk_info &chunk, bool force) count += 1 + strlen (_env_data [type].toLatin1()); chunk.size = ALIGN_CHUNK (POS_chunk_header_size + POS_env_string0 + count); - //TODO: need to free old buf here + mem_free (CV &chunk.buf); CALL (alloc_chunk_buf (chunk, &buf)); add_generic_chunk_header (chunk, buf); write_env (buf, chunk.size, _env_data); From e447253596e7019e07c577fc9561ac27162f15cd Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 10:07:55 -0700 Subject: [PATCH 12/25] app: Provide an icon ready for release Add an icon and prepare for a release on the Play store. Signed-off-by: Simon Glass --- app/android/app/build.gradle.kts | 1 + app/assets/icon.png | Bin 0 -> 14046 bytes app/pubspec.yaml | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 app/assets/icon.png diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index a10d035..1425555 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -59,6 +59,7 @@ android { play { serviceAccountCredentials.set(file("play-account.json")) track.set("internal") + releaseStatus.set(com.github.triplet.gradle.androidpublisher.ReleaseStatus.DRAFT) defaultToAppBundles.set(true) } diff --git a/app/assets/icon.png b/app/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ef787c275e549ea21df4bdbddad33ba40fd4f828 GIT binary patch literal 14046 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 Qy8=k_j;?CSt@|(jFB`#KBLDyZ literal 0 HcmV?d00001 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 21fe82b..7154da6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.0+1 +version: 1.3.0+2 environment: sdk: ^3.11.0 From e50b8375ff4a5fd08d3f5a96c21994075cdfae72 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 10:43:36 -0700 Subject: [PATCH 13/25] file: Fix blank colour previews in colour_image_for_blank() The colour_image_for_blank() function, which tints colour previews to indicate blank pages, has two bugs that cause colour document previews to display as blank white rectangles: The rgb pointer is never incremented in the inner loop, so only the first pixel of each row is ever processed. Also, the colour calculation adds the component values together as a raw integer instead of constructing a proper QRgb value with qRgb(). For a white pixel this produces an integer ~701 which, interpreted as QRgb, has alpha=0 making the pixel fully transparent. Add the missing rgb++ increment and wrap the calculation in qRgb() to match the pattern used by the greyscale palette entries elsewhere. Co-developed-by: Claude Opus 4.5 --- file.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/file.cpp b/file.cpp index b766b98..36f5cbd 100644 --- a/file.cpp +++ b/file.cpp @@ -526,10 +526,12 @@ void File::colour_image_for_blank (QImage &image) for (i = 0; i < image.height (); i++) { rgb = (QRgb *)image.scanLine (i); - for (int x = 0; x < image.width (); x++) + for (int x = 0; x < image.width (); x++, rgb++) { col = *rgb; - *rgb = qRed (col) * CONFIG_preview_col_mult + qGreen (col) * CONFIG_preview_col_mult + qBlue (col); + *rgb = qRgb (qRed (col) * CONFIG_preview_col_mult, + qGreen (col) * CONFIG_preview_col_mult, + qBlue (col)); } } } From 501810d4bfe72882e0a1def01ed192c610d29797 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 10:50:26 -0700 Subject: [PATCH 14/25] filemax: Fix transparent colour previews in rle_decode() Colour (24bpp) preview thumbnails appear blank in the main view because the alpha byte is written as 0 (fully transparent) instead of 0xff (fully opaque). The rle_decode() function expands 3-byte RGB preview pixels to 4-byte RGBX, but sets the fourth byte to zero. When this data is wrapped in a QImage with Format_RGB32 and converted to QPixmap, the zero alpha causes every pixel to be transparent, producing blank white thumbnails. Monochrome previews are unaffected because they use Format_Indexed8 with palette entries from qRgb() which includes alpha=0xff. Change the alpha byte from 0 to 0xff so colour previews render as opaque. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filemax.cpp b/filemax.cpp index f6388e8..b839e75 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -1739,7 +1739,7 @@ static err_info *rle_decode (QString &fname, byte *buf, int size, *out++ = *ptr++; *out++ = *ptr++; *out++ = *ptr++; - *out++ = 0; + *out++ = 0xff; } while ((ptr - buf) & 3) ptr++; // word align at the end of each line From 70328a78ce5ca931f2a8100b3c825e1f5c4977af Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 12:24:39 -0700 Subject: [PATCH 15/25] filemax: Add encode_8bpp_preview() for greyscale preview encoding The add_preview() function stores raw 8bpp preview bytes with a plain memcpy(), but decode_8bpp_preview() expects a nibble-based run-length format. This mismatch causes rebuilt greyscale previews to appear completely black. Add encode_8bpp_preview() which quantises each pixel to a 4-bit nibble and emits runs in the format that the decoder expects. The decoder inverts via "255 - value" to bridge the polarity difference between image convention (0=black) and preview colour table (0=white), so the encoder does not invert. Use it in add_preview() for the 8bpp case. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++--- filemax.h | 5 +++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/filemax.cpp b/filemax.cpp index b839e75..d7087dc 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -1651,7 +1651,7 @@ err_info *Filemax::decode_tiledata (chunk_info &chunk, #define DEBUG_MAX_COUNT 0 -static int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) +int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) { byte *out_start = out; int count, value; @@ -1677,6 +1677,64 @@ static int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end) } +/** + * Encode 8bpp greyscale preview data in the nibble format expected by + * decode_8bpp_preview(). + * + * Each output byte encodes a run of pixels: + * upper nibble = count/2 (0 means count 1) + * lower nibble = quantised grey value (inverted) + * + * The encoding is lossy: 256 grey levels are reduced to 16. + * + * @param raw Raw 8bpp preview data + * @param size Number of raw bytes + * @param out Output buffer (must be at least @a size bytes) + * @return Number of encoded bytes written + */ +int encode_8bpp_preview (byte *raw, int size, byte *out) + { + byte *raw_end = raw + size; + byte *out_start = out; + + while (raw < raw_end) + { + // Quantise pixel to nibble; the decoder inverts via 255 - value, + // bridging the polarity difference between image (0=black) and + // preview colour table (0=white), so we do not invert here. + byte nibble = *raw >> 4; + + // Count run of pixels that quantise to the same nibble + byte *run_start = raw; + + while (raw < raw_end && (*raw >> 4) == nibble) + raw++; + int count = raw - run_start; + + // Emit encoded bytes: max 30 pixels per byte (upper nibble 15) + while (count > 0) + { + if (count == 1) + { + *out++ = nibble; // upper nibble 0 means count 1 + count = 0; + } + else + { + int run = count > 30 ? 30 : count; + + // upper nibble encodes count/2, so make even + run &= ~1; + *out++ = ((run >> 1) << 4) | nibble; + count -= run; + } + } + } + + return out - out_start; + } + + /* decode a buffer of run-length-encoded data \param buf buffer holding rle data @@ -4297,8 +4355,7 @@ static int add_preview (chunk_info &chunk, part_info &part) case 8 : dest = (byte *)malloc (chunk.preview_bytes); - memcpy (dest, chunk.preview, chunk.preview_bytes); - len = chunk.preview_bytes; + len = encode_8bpp_preview (chunk.preview, chunk.preview_bytes, dest); break; case 24 : diff --git a/filemax.h b/filemax.h index 89cc994..11d7659 100644 --- a/filemax.h +++ b/filemax.h @@ -634,6 +634,11 @@ class Filemax : public File }; +// 8bpp preview encode/decode (nibble-based format) +int decode_8bpp_preview (byte *ptr, byte *end, byte *out, byte *out_end); +int encode_8bpp_preview (byte *raw, int size, byte *out); + + class Filemaxpage : public Filepage { public: From f44cad66fcf8b17d1723ed7991f6b8d00ac137e4 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 12:26:09 -0700 Subject: [PATCH 16/25] filemax: Extract rebuildPagePreview() from rebuildPreviews() The rebuildPreviews() function processes all pages in a batch, which is not suitable for on-the-fly rebuilding of individual pages. Extract the per-page logic into a new rebuildPagePreview() method that handles a single page: it opens the file, loads chunks, decodes the full image, builds a preview, and flushes. It returns early if the page is not 8bpp greyscale or already has a real preview. Simplify rebuildPreviews() to call it in a loop. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 180 +++++++++++++++++++++++++++------------------------- filemax.h | 3 + 2 files changed, 96 insertions(+), 87 deletions(-) diff --git a/filemax.cpp b/filemax.cpp index d7087dc..9d13892 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -5837,131 +5837,137 @@ Filemaxpage::~Filemaxpage (void) } -err_info *Filemax::rebuildPreviews () +err_info *Filemax::rebuildPagePreview (int pagenum) { CALL (ensure_open ()); CALL (ensure_all_chunks ()); - int pc = pagecount (); + chunk_info *chunk; + page_info *page; - for (int pagenum = 0; pagenum < pc; pagenum++) - { - chunk_info *chunk; - page_info *page; + CALL (find_page_chunk (pagenum, chunk, NULL, &page)); - CALL (find_page_chunk (pagenum, chunk, NULL, &page)); + // only fix 8bpp greyscale pages + if (chunk->bits != 8) + return NULL; - // only fix 8bpp greyscale pages - if (chunk->bits != 8) - continue; + // skip pages that already have a real preview (> 4 bytes) + if (chunk->parts.size () <= PT_preview) + return NULL; + if (chunk->parts [PT_preview].size > 4) + return NULL; - // skip pages that already have a real preview (> 4 bytes) - if (chunk->parts.size () <= PT_preview) - continue; - if (chunk->parts [PT_preview].size > 4) - continue; + // load the chunk buffer from disk so we can extract part data + CALL (read_chunk_buf (*chunk)); - // load the chunk buffer from disk so we can extract part data - CALL (read_chunk_buf (*chunk)); + // copy each part's data from chunk.buf into individual part.buf + for (int i = 0; i < chunk->parts.size (); i++) + { + part_info &part = chunk->parts [i]; - // copy each part's data from chunk.buf into individual part.buf - for (int i = 0; i < chunk->parts.size (); i++) + if (!part.buf && part.size > 0) { - part_info &part = chunk->parts [i]; - - if (!part.buf && part.size > 0) - { - part.buf = (byte *)malloc (part.size); - memcpy (part.buf, chunk->buf + 0x20 + part.start, part.size); - } + part.buf = (byte *)malloc (part.size); + memcpy (part.buf, chunk->buf + 0x20 + part.start, part.size); } + } - // work on a local copy so insert_chunk can safely reallocate _chunks - chunk_info local = *chunk; + // work on a local copy so insert_chunk can safely reallocate _chunks + chunk_info local = *chunk; - local.buf = NULL; // don't share the original buffer pointer + local.buf = NULL; // don't share the original buffer pointer - // transfer tile ownership to local so chunk_free won't double-free - chunk->tile = NULL; - chunk->tile_count = 0; + // transfer tile ownership to local so chunk_free won't double-free + chunk->tile = NULL; + chunk->tile_count = 0; - // decode the full image from tile data - byte *imagep = NULL; + // decode the full image from tile data + byte *imagep = NULL; - CALL (decode_image (local, imagep, NULL)); + CALL (decode_image (local, imagep, NULL)); - // set up the preview size and build the preview - local.preview_size.x = local.image_size.x / PREVIEW_SCALE; - local.preview_size.y = local.image_size.y / PREVIEW_SCALE; - build_preview (local, local.line_bytes, local.bits); + // set up the preview size and build the preview + local.preview_size.x = local.image_size.x / PREVIEW_SCALE; + local.preview_size.y = local.image_size.y / PREVIEW_SCALE; + build_preview (local, local.line_bytes, local.bits); - // free the old preview part buffer and create the new one - part_info &pv = local.parts [PT_preview]; + // free the old preview part buffer and create the new one + part_info &pv = local.parts [PT_preview]; - if (pv.buf) - free (pv.buf); - pv.buf = NULL; - pv.size = 0; - add_preview (local, pv); + if (pv.buf) + free (pv.buf); + pv.buf = NULL; + pv.size = 0; + add_preview (local, pv); - // recalculate part offsets - int total = POS_part0 + 8 * local.parts.size (); + // recalculate part offsets + int total = POS_part0 + 8 * local.parts.size (); - for (int i = 0; i < local.parts.size (); i++) - { - part_info &part = local.parts [i]; + for (int i = 0; i < local.parts.size (); i++) + { + part_info &part = local.parts [i]; - part.start = total - 0x20; - total += part.size; - } - local.size = ALIGN_CHUNK (total); + part.start = total - 0x20; + total += part.size; + } + local.size = ALIGN_CHUNK (total); - // reassemble the chunk buffer - byte *buf; + // reassemble the chunk buffer + byte *buf; - // assign a new chunkid so find_page_chunk won't match the old - // unused chunk on the next load - local.chunkid = _chunkid_next++; - page->chunkid = local.chunkid; + // assign a new chunkid so find_page_chunk won't match the old + // unused chunk on the next load + local.chunkid = _chunkid_next++; + page->chunkid = local.chunkid; - CALL (alloc_chunk_buf (local, &buf)); - add_image_header (local, buf); - for (int i = 0; i < local.parts.size (); i++) - { - part_info &part = local.parts [i]; + CALL (alloc_chunk_buf (local, &buf)); + add_image_header (local, buf); + for (int i = 0; i < local.parts.size (); i++) + { + part_info &part = local.parts [i]; - memcpy (buf + 0x20 + part.start, part.buf, part.size); - } - CALL (insert_chunk (local, &page->image)); + memcpy (buf + 0x20 + part.start, part.buf, part.size); + } + CALL (insert_chunk (local, &page->image)); - // clear local ownership: insert_chunk copied everything into _chunks - local.image = NULL; - local.preview = NULL; - local.tile = NULL; - local.tile_count = 0; - local.buf = NULL; - local.parts.clear (); + // clear local ownership: insert_chunk copied everything into _chunks + local.image = NULL; + local.preview = NULL; + local.tile = NULL; + local.tile_count = 0; + local.buf = NULL; + local.parts.clear (); - // update the roswell chunk to point to the new image position - chunk_info *ros_chunk; + // update the roswell chunk to point to the new image position + chunk_info *ros_chunk; - CALL (chunk_find (page->roswell, ros_chunk, NULL)); - CALL (read_chunk_buf (*ros_chunk)); - unsigned short *rdata = (unsigned short *)(ros_chunk->buf + 0x42); + CALL (chunk_find (page->roswell, ros_chunk, NULL)); + CALL (read_chunk_buf (*ros_chunk)); + unsigned short *rdata = (unsigned short *)(ros_chunk->buf + 0x42); - rdata [0] = page->image & 0xffff; - rdata [1] = page->image >> 16; - ros_chunk->saved = false; + rdata [0] = page->image & 0xffff; + rdata [1] = page->image >> 16; + ros_chunk->saved = false; - printf ("Page %d: rebuilt preview (%dx%d)\n", pagenum + 1, - local.preview_size.x, local.preview_size.y); - } + printf ("Page %d: rebuilt preview (%dx%d)\n", pagenum + 1, + local.preview_size.x, local.preview_size.y); CALL (flush ()); return NULL; } +err_info *Filemax::rebuildPreviews () + { + int pc = pagecount (); + + for (int pagenum = 0; pagenum < pc; pagenum++) + CALL (rebuildPagePreview (pagenum)); + + return NULL; + } + + err_info *Filemax::getImage (int pagenum, bool, QImage &image, QSize &Size, QSize &trueSize, int &bpp, bool blank) { diff --git a/filemax.h b/filemax.h index 11d7659..4c1d68b 100644 --- a/filemax.h +++ b/filemax.h @@ -220,6 +220,9 @@ class Filemax : public File /** rebuild missing greyscale previews in the file */ err_info *rebuildPreviews (); + /** rebuild a stub greyscale preview for a single page, if needed */ + err_info *rebuildPagePreview (int pagenum); + /*********** end of functions which the base class should implement ******/ public: From f0338e47cc227e489ef8cbfab151388e485224e3 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 12:27:32 -0700 Subject: [PATCH 17/25] filemax: Rebuild stub previews on the fly in getPreviewPixmap() Some old PaperPort .max files have stub previews (4 bytes or fewer) for 8bpp greyscale pages. Rather than requiring a batch rebuild of the whole database, detect these stubs at display time and rebuild automatically. When getPreviewPixmap() encounters a stub preview on an 8bpp page, it frees any temporary chunk, calls rebuildPagePreview() to decode the full image and generate a proper preview, then re-fetches the chunk and continues with normal preview decoding. The rebuilt preview is flushed to disk so this only happens once per page. Co-developed-by: Claude Opus 4.5 --- filemax.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/filemax.cpp b/filemax.cpp index 9d13892..5bc7310 100644 --- a/filemax.cpp +++ b/filemax.cpp @@ -6039,6 +6039,23 @@ err_info *Filemax::getPreviewPixmap (int pagenum, QPixmap &pixmap, bool blank) if (debug_level >= 3) show_file (stderr); CALL (find_page_chunk (pagenum, chunk, &temp, NULL)); + + // Rebuild stub greyscale previews on the fly + if (chunk->bits == 8 + && chunk->parts.size () > PT_preview + && chunk->parts [PT_preview].size <= 4) + { + if (temp) + { + chunk_free (*chunk); + delete chunk; + } + ensure_closed (); + CALL (rebuildPagePreview (pagenum)); + CALL (ensure_open ()); + CALL (find_page_chunk (pagenum, chunk, &temp, NULL)); + } + QSize Size = QSize (chunk->preview_size.x, chunk->preview_size.y); int bpp = chunk->bits == 24 ? 24 : 8; CALL(ensure_open()); From 1609cdc071030ec1586bbaebe69b59f261434d89 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 12:27:56 -0700 Subject: [PATCH 18/25] test: Add 8bpp preview encode/decode tests Add two tests for the greyscale preview encoding: testPreview8bppRoundtrip() creates a synthetic gradient, encodes it, decodes it, and verifies each pixel is within the nibble quantisation tolerance. It also checks that the decoder correctly inverts the polarity (image 0=black becomes preview 255=black). testPreviewFromJpeg() loads greyscale_gradient.jpg, scales it to preview size, runs the encode/decode roundtrip, and checks the qCompress'd result against a known size, catching any regression in the encoding logic. Co-developed-by: Claude Opus 4.5 --- test/test_utils.cpp | 114 ++++++++++++++++++++++++++++++++++++++++++++ test/test_utils.h | 6 +++ 2 files changed, 120 insertions(+) diff --git a/test/test_utils.cpp b/test/test_utils.cpp index a37fd37..1749783 100644 --- a/test/test_utils.cpp +++ b/test/test_utils.cpp @@ -1,5 +1,6 @@ #include +#include "../filemax.h" #include "../utils.h" #include "test.h" @@ -328,3 +329,116 @@ void TestUtils::testFindItem() chkw = root->findItemW("somedir/more-subdir/another-file"); QCOMPARE(chk, chkw); } + +void TestUtils::testPreview8bppRoundtrip() +{ + // Create a test pattern with varying grey levels (image convention: + // 0=black, 255=white) + const int width = 100; + const int height = 50; + const int size = width * height; + byte raw[size]; + + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + raw[y * width + x] = (x * 255) / (width - 1); + + // Encode + byte encoded[size]; + int enc_len = encode_8bpp_preview(raw, size, encoded); + + QVERIFY(enc_len > 0); + QVERIFY(enc_len <= size); + + // Decode: the decoder inverts the polarity (image 0=black becomes + // preview 255=black) so the expected value is 255 - raw + byte decoded[size]; + + memset(decoded, 0, size); + int dec_len = decode_8bpp_preview(encoded, encoded + enc_len, + decoded, decoded + size); + QCOMPARE(dec_len, size); + + // Check each pixel against the expected inverted value, within + // quantisation tolerance (nibble reduces 256 levels to 16) + int max_err = 0; + + for (int i = 0; i < size; i++) { + int expected = 255 - raw[i]; + int diff = abs(expected - (int)decoded[i]); + + if (diff > max_err) + max_err = diff; + } + + // Nibble quantisation can produce up to ~17 error per pixel + QVERIFY2(max_err <= 17, + qPrintable(QString("max pixel error %1 exceeds tolerance 17") + .arg(max_err))); + + // Verify the decoded data is not all one value + int sum = 0; + + for (int i = 0; i < size; i++) + sum += decoded[i]; + QVERIFY2(sum > 0, "decoded preview is all-zero"); + QVERIFY2(sum < size * 255, "decoded preview is all-255"); +} + +void TestUtils::testPreviewFromJpeg() +{ + // Load the deterministic greyscale test image + QImage img(testSrc + "/greyscale_gradient.jpg"); + + QVERIFY2(!img.isNull(), "failed to load greyscale_gradient.jpg"); + + // Convert to 8bpp greyscale if needed + if (img.format() != QImage::Format_Indexed8) + img = img.convertToFormat(QImage::Format_Indexed8); + + int pw = img.width() / 24; + int ph = img.height() / 24; + int pwidth = (pw + 3) & ~3; // word-aligned + int psize = pwidth * ph; + + // Scale down to preview size (matching scale_8bpp logic) + QByteArray preview(psize, 0); + + for (int y = 0; y < ph; y++) { + for (int x = 0; x < pw; x++) { + int sum = 0; + int count = 0; + + for (int sy = 0; sy < 24 && y * 24 + sy < img.height(); sy++) { + const uchar *line = img.scanLine(y * 24 + sy); + + for (int sx = 0; sx < 24; sx++) + sum += line[x * 24 + sx]; + count += 24; + } + preview[y * pwidth + x] = sum / count; + } + } + + // Encode + QByteArray encoded(psize, 0); + int enc_len = encode_8bpp_preview((byte *)preview.data(), psize, + (byte *)encoded.data()); + QVERIFY(enc_len > 0); + + // Decode + byte *enc = (byte *)encoded.data(); + QByteArray decoded(psize, 0); + + decode_8bpp_preview(enc, enc + enc_len, + (byte *)decoded.data(), + (byte *)decoded.data() + psize); + + // Compress and check deterministic size + QByteArray compressed = qCompress(decoded); + int csize = compressed.size(); + + QVERIFY2(csize > 50, qPrintable( + QString("compressed preview too small: %1").arg(csize))); + QCOMPARE(csize, 1412); +} diff --git a/test/test_utils.h b/test/test_utils.h index 05e787b..bc2395a 100644 --- a/test/test_utils.h +++ b/test/test_utils.h @@ -23,6 +23,12 @@ private slots: void testAdopt(); void testFindItem(); void testImageDepth(); + + //! Test 8bpp preview encode/decode roundtrip + void testPreview8bppRoundtrip(); + + //! Test preview encode/decode against the greyscale test image + void testPreviewFromJpeg(); private: // Create files in a temporary directory structure used for testing void createDirStructure(QTemporaryDir& tmp); From 2b3c352c79541d999e624c7a40c5eb61921d7c23 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 14:32:59 -0700 Subject: [PATCH 19/25] filejpeg: Free page objects in Filejpeg destructor The Filejpeg destructor is empty, so the Filejpegpage objects in the _pages list are never freed. The remove() method does clean them up, but that also deletes the files from disk and is not called on normal destruction. Add qDeleteAll() to the destructor to free the page objects. Co-developed-by: Claude Opus 4.5 --- filejpeg.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/filejpeg.cpp b/filejpeg.cpp index 76e4416..a9d1862 100644 --- a/filejpeg.cpp +++ b/filejpeg.cpp @@ -46,6 +46,7 @@ Filejpeg::Filejpeg (const QString &dir, const QString &filename, Desk *desk) Filejpeg::~Filejpeg () { + qDeleteAll (_pages); } err_info *Filejpeg::load (void) From 1e4162bd07083970771d971b98ac0ea29bc83c0e Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 14:33:09 -0700 Subject: [PATCH 20/25] desktopview: Free Measure object in destructor The Desktopview constructor allocates a Measure object but the destructor is empty, so it is leaked. Add delete to the destructor to free it. Co-developed-by: Claude Opus 4.5 --- desktopview.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/desktopview.cpp b/desktopview.cpp index 6a4086a..d50ac74 100644 --- a/desktopview.cpp +++ b/desktopview.cpp @@ -75,6 +75,7 @@ Desktopview::Desktopview (QWidget *parent) Desktopview::~Desktopview () { + delete _measure; } Measure *Desktopview::getMeasure() From 2d22a2f7c86b2c1791e240e8b22fd59d0c2b221b Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 14:33:22 -0700 Subject: [PATCH 21/25] dirmodel: Free map in Dirmodel destructor The constructor allocates _map with new but the destructor does not free it, causing a memory leak. Add delete to the destructor. Co-developed-by: Claude Opus 4.5 --- dirmodel.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/dirmodel.cpp b/dirmodel.cpp index 65e1cb4..627e4dd 100644 --- a/dirmodel.cpp +++ b/dirmodel.cpp @@ -205,6 +205,7 @@ Dirmodel::~Dirmodel () { while (!_item.empty ()) delete _item.takeFirst (); + delete _map; } // inline bool indexValid(const QModelIndex &index) const { From 80620fe9bbfaba7499c47f25e8400dbb8ed0c26a Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 14:33:34 -0700 Subject: [PATCH 22/25] desktopwidget: Free model and proxy in destructor The constructor creates _model (Dirmodel) and _dir_proxy (Dirproxy) without a parent, so they are not auto-freed by Qt's object hierarchy. The destructor does not free them either, causing a large leak of the directory model and all its internal state. Add delete calls for both, after the view is freed. Co-developed-by: Claude Opus 4.5 --- desktopwidget.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktopwidget.cpp b/desktopwidget.cpp index b19f9cf..260fa83 100644 --- a/desktopwidget.cpp +++ b/desktopwidget.cpp @@ -290,6 +290,8 @@ Desktopwidget::~Desktopwidget () delete _modelconv; delete _modelconv_assert; delete _dir; + delete _dir_proxy; + delete _model; } From 0b76e374b0063edf6049b38756c71fe43e121c4c Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 2 Mar 2026 20:20:02 -0700 Subject: [PATCH 23/25] paperstack: Fix array delete mismatch in finishJpeg() setupJpeg() allocates _buffer with new[] but finishJpeg() frees it with delete instead of delete[]. This is undefined behaviour and is caught by AddressSanitizer. Use delete[] to match the new[] allocation. Co-developed-by: Claude Opus 4.5 --- paperstack.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paperstack.cpp b/paperstack.cpp index f24c4c5..4190bb4 100644 --- a/paperstack.cpp +++ b/paperstack.cpp @@ -472,7 +472,7 @@ void PPage::finishJpeg (void) jpeg_destroy_decompress (&_cinfo); if (_jerr.err) qDebug () << "JPEG error" << _jerr.err; - delete _buffer; + delete[] _buffer; _jpeg_created = false; } } From b685275e6214c4a156fea16b329b6bcd1b03e7fa Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Thu, 5 Mar 2026 09:04:42 -0700 Subject: [PATCH 24/25] dirmodel: Add a single file to the cache after scan After each scan completes, slotStackConfirm() calls refreshDirmodelCache() which rescans the entire directory tree under the scan path with utilScanDir(). This is visible as a progress bar sweeping across the bottom of the window and causes unnecessary delay, particularly for large directories. Add a targeted addFileToCache() method that inserts just the new filename into the existing in-memory cache tree and writes it out, avoiding the full directory rescan. The full rescan remains available via the "Refresh cache" context-menu action. Also add an optional fname parameter to confirmScan() so the caller can capture the scanned filename before it is cleared. Co-developed-by: Claude Opus 4.5 --- desktopmodel.cpp | 4 +++- desktopmodel.h | 6 ++++-- desktopwidget.cpp | 7 +++++++ desktopwidget.h | 8 ++++++++ dirmodel.cpp | 36 ++++++++++++++++++++++++++++++++++++ dirmodel.h | 15 +++++++++++++++ mainwidget.cpp | 5 +++-- 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/desktopmodel.cpp b/desktopmodel.cpp index d65a015..2d86d47 100644 --- a/desktopmodel.cpp +++ b/desktopmodel.cpp @@ -836,11 +836,13 @@ err_info *Desktopmodel::cancelScan (void) } -err_info *Desktopmodel::confirmScan (void) +err_info *Desktopmodel::confirmScan (QString *fname) { // qDebug () << "Desktopmodel::confirmScan"; Q_ASSERT (_scan_desk && _scan_file); + if (fname) + *fname = _scan_file->filename (); QModelIndex ind = index (_scan_file->filename (), _scan_parent); // flush the item diff --git a/desktopmodel.h b/desktopmodel.h index e58c7ea..bbc7fd1 100644 --- a/desktopmodel.h +++ b/desktopmodel.h @@ -853,8 +853,10 @@ class Desktopmodel : public QAbstractItemModel \returns error, or NULL if none */ err_info *beginScan (QModelIndex parent, const QString &stack_name); - /** confirm and save the pending scan */ - err_info *confirmScan (void); + /** confirm and save the pending scan + * + * \param fname if non-null, returns the filename of the scanned stack */ + err_info *confirmScan (QString *fname = nullptr); /** cancel and remove the pending scan */ err_info *cancelScan (void); diff --git a/desktopwidget.cpp b/desktopwidget.cpp index 260fa83..7fad64d 100644 --- a/desktopwidget.cpp +++ b/desktopwidget.cpp @@ -898,7 +898,14 @@ void Desktopwidget::refreshDirmodelCache(const QString& dirPath) Operation op("Updating cache", 0, this); _model->refreshCacheFrom(src_ind, &op); +} + +void Desktopwidget::addFileToDirmodelCache(const QString &dirPath, + const QString &filename) +{ + QModelIndex src_ind = _model->index(dirPath); + _model->addFileToCache(src_ind, filename); } void Desktopwidget::slotDirChanged (QString &dirPath, QModelIndex &deskind) diff --git a/desktopwidget.h b/desktopwidget.h index 94237b7..829c05c 100644 --- a/desktopwidget.h +++ b/desktopwidget.h @@ -203,6 +203,14 @@ class Desktopwidget : public QSplitter //QWidget * @param dirPath Full path to the directory to update */ void refreshDirmodelCache(const QString& dirPath); + + /** + * @brief Add a single file to the dirmodel cache + * @param dirPath Full path to the directory containing the file + * @param filename Leaf filename to add + */ + void addFileToDirmodelCache(const QString &dirPath, + const QString &filename); protected: //bool eventFilter (QObject *watched_object, QEvent *e); diff --git a/dirmodel.cpp b/dirmodel.cpp index 627e4dd..a33806a 100644 --- a/dirmodel.cpp +++ b/dirmodel.cpp @@ -181,6 +181,31 @@ bool Diritem::refreshCache(const QString dirPath, Operation *op) return true; } +bool Diritem::addFileToCache(const QString &dirPath, + const QString &filename) +{ + if (!_dir_cache && !readCache()) + return false; + + QString rel = dirPath.mid(_dir.size() + 1); + + Q_ASSERT(_dir_cache); + TreeItem *top = _dir_cache->findItemW(rel); + if (!top) + return false; + + QVector columnData = {filename}; + TreeItem *child = new TreeItem(columnData, top); + + top->appendChild(child); + if (!utilWriteTree(dirCacheFilename(), _dir_cache)) { + qInfo() << "Failed to write cache"; + return false; + } + + return true; +} + QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED Dirmodel::Dirmodel (QObject * parent) @@ -954,3 +979,14 @@ void Dirmodel::refreshCacheFrom(const QModelIndex& parent, Operation *op) Q_ASSERT(item); item->refreshCache(path, op); } + +void Dirmodel::addFileToCache(const QModelIndex &parent, + const QString &filename) +{ + QDir dir; + QString path = dir.absoluteFilePath(filePath(parent)); + + Diritem *item = findItem(parent); + if (item) + item->addFileToCache(path, filename); +} diff --git a/dirmodel.h b/dirmodel.h index d9c2fae..e269b5f 100644 --- a/dirmodel.h +++ b/dirmodel.h @@ -77,6 +77,14 @@ class Diritem */ bool refreshCache(const QString path, Operation *op); + /** + * @brief Add a single file to the cache + * @param dirPath Full path to the directory containing the file + * @param filename Leaf filename to add + * @return true if OK, false on failure + */ + bool addFileToCache(const QString &dirPath, const QString &filename); + private: // Get the filename for the dir cache QString dirCacheFilename() const; @@ -292,6 +300,13 @@ class Dirmodel : public QDirModel */ void refreshCacheFrom(const QModelIndex& parent, Operation *op); + /** + * @brief Add a single file to the cache + * @param parent Parent index of the directory containing the file + * @param filename Leaf filename to add + */ + void addFileToCache(const QModelIndex &parent, const QString &filename); + private: /** counts the number of files in 'path', adds it to count and returns it. Stops if count > max diff --git a/mainwidget.cpp b/mainwidget.cpp index 6d4b673..29f66da 100644 --- a/mainwidget.cpp +++ b/mainwidget.cpp @@ -501,13 +501,14 @@ void Mainwidget::slotStackConfirm (void) // qDebug () << "slotStackConfirm"; if (!_scan_cancelling) { - err_info *err = _contents->confirmScan (); + QString fname; + err_info *err = _contents->confirmScan (&fname); //FIXME: should we cancel in this case? if (err) _scan->cancelScan (err); - _desktop->refreshDirmodelCache(_scan_path); + _desktop->addFileToDirmodelCache(_scan_path, fname); } } From 746c83c345f50065c9c6a64552e1f66f142947f5 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Thu, 5 Mar 2026 10:00:07 -0700 Subject: [PATCH 25/25] site: Add app download links for Android and iPhone The Android app is not on the Play Store, so change the link to download the APK directly from the GitHub release. Also add a link to the iPhone app on the App Store. Co-developed-by: Claude Opus 4.6 --- site/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/index.html b/site/index.html index 7de57ed..43e2073 100644 --- a/site/index.html +++ b/site/index.html @@ -140,7 +140,8 @@

Paperman