From 1e39183e702adc079fb00f3664030264b3492d65 Mon Sep 17 00:00:00 2001 From: zhanghongyuan Date: Tue, 15 Sep 2026 14:51:37 +0800 Subject: [PATCH 1/3] fix(reader): pin render task lifetime to renderer ref and uuid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render task structs now carry a renderer shared reference and a uuid snapshot; workers dereference only those and skip tasks whose sheet is gone (existSheetByUuid). DocSheet owns SheetRenderer via QSharedPointer and purges queued tasks (clearAllTasksForSheet/Page) before destruction. BrowserPage validates page aliveness before writing render results back, and the pinch-zoom timer callback is bound to its receiver to avoid UAF. 渲染任务结构体改为携带渲染器共享引用与入队时快照的 uuid:worker 线 程仅解引用这两者,并经 existSheetByUuid 跳过已销毁文档的任务。 DocSheet 改用 QSharedPointer 持有 SheetRenderer,析构前排空排队任务 (clearAllTasksForSheet/Page);BrowserPage 回写渲染结果前校验页面 存活;捏合缩放定时器回调绑定 receiver,消除悬空指针。 Log: 渲染任务生命周期加固,消除文档销毁后 worker/回包路径悬空指针 PMS: BUG-377151 Influence: 文档关闭/缩放过程中渲染回调不再有悬空指针风险;正常渲染行为不变。 --- reader/browser/BrowserPage.cpp | 35 ++++++ reader/browser/BrowserPage.h | 9 ++ reader/browser/PageRenderThread.cpp | 154 ++++++++++++++++++----- reader/browser/PageRenderThread.h | 48 ++++++- reader/browser/SheetBrowser.cpp | 3 +- reader/sidebar/SideBarImageViewModel.cpp | 4 + reader/uiframe/DocSheet.cpp | 29 +++-- reader/uiframe/DocSheet.h | 17 ++- reader/uiframe/SheetRenderer.cpp | 27 ++-- reader/uiframe/SheetRenderer.h | 21 +++- tests/browser/ut_browserpage.cpp | 1 + tests/browser/ut_pagerenderthread.cpp | 12 +- tests/browser/ut_sheetbrowser.cpp | 6 +- tests/uiframe/ut_docsheet.cpp | 6 +- tests/uiframe/ut_sheetrenderer.cpp | 6 +- tests/ut_mainwindow.cpp | 2 +- 16 files changed, 308 insertions(+), 72 deletions(-) diff --git a/reader/browser/BrowserPage.cpp b/reader/browser/BrowserPage.cpp index a72defc9e..8580eb6a0 100644 --- a/reader/browser/BrowserPage.cpp +++ b/reader/browser/BrowserPage.cpp @@ -33,10 +33,15 @@ const int ICON_SIZE = 23; +// 主线程专用的存活页注册表:入队时的 page 裸指针仅在回包时由主线程 handler 解引用, +// handler 必须先经 existPage 校验,避免向已析构页面回包(UAF) +static QSet g_alivePages; + BrowserPage::BrowserPage(SheetBrowser *parent, int index, DocSheet *sheet) : QGraphicsItem(), m_sheet(sheet), m_parent(parent), m_index(index) { qCDebug(appLog) << "BrowserPage created, index:" << index; + g_alivePages.insert(this); setAcceptHoverEvents(true); setFlag(QGraphicsItem::ItemIsPanel); @@ -48,7 +53,9 @@ BrowserPage::BrowserPage(SheetBrowser *parent, int index, DocSheet *sheet) : BrowserPage::~BrowserPage() { // qCDebug(appLog) << "BrowserPage destroyed, index:" << m_index; + g_alivePages.remove(this); PageRenderThread::clearImageTasks(m_sheet, this); + PageRenderThread::clearAllTasksForPage(this); // 断开并销毁夜间异步任务 watcher:后台滤镜任务持有的都是副本,安全丢弃 delete m_nightWatcher; @@ -62,6 +69,12 @@ BrowserPage::~BrowserPage() // qCDebug(appLog) << "BrowserPage::~BrowserPage() - Destructor completed"; } +bool BrowserPage::existPage(const BrowserPage *page) +{ + // 仅主线程调用:注册表只在主线程增删,无需加锁 + return g_alivePages.contains(page); +} + QRectF BrowserPage::boundingRect() const { // qCDebug(appLog) << "BrowserPage::boundingRect() - Calculating bounding rectangle"; @@ -329,6 +342,10 @@ void BrowserPage::render(const double &scaleFactor, const Dr::Rotation &rotation task.page = this; + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); + task.pageIndex = itemIndex(); + task.pixmapId = m_pixmapId; const qreal deviceRatio = dApp ? dApp->devicePixelRatio() : 1.0; @@ -345,6 +362,12 @@ void BrowserPage::render(const double &scaleFactor, const Dr::Rotation &rotation task.page = this; + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); + task.pageIndex = itemIndex(); + task.scaleFactor = m_scaleFactor; + task.originSize = m_originSizeF; + task.pixmapId = m_pixmapId; const qreal deviceRatio = dApp ? dApp->devicePixelRatio() : 1.0; @@ -365,6 +388,10 @@ void BrowserPage::render(const double &scaleFactor, const Dr::Rotation &rotation task.page = this; + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); + task.pageIndex = itemIndex(); + PageRenderThread::appendTask(task); } } @@ -394,6 +421,10 @@ void BrowserPage::renderRect(const QRectF &rect) task.page = this; + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); + task.pageIndex = itemIndex(); + task.pixmapId = m_pixmapId; task.whole = QRect(0, 0, @@ -651,6 +682,10 @@ void BrowserPage::loadWords() task.page = this; + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); + task.pageIndex = itemIndex(); + PageRenderThread::appendTask(task); m_wordHasRendered = false; diff --git a/reader/browser/BrowserPage.h b/reader/browser/BrowserPage.h index 9e2198db8..b2a9d5986 100644 --- a/reader/browser/BrowserPage.h +++ b/reader/browser/BrowserPage.h @@ -41,6 +41,15 @@ class BrowserPage : public QGraphicsItem ~BrowserPage() override; + /** + * @brief existPage + * 判断页面是否存活(仅主线程调用)。渲染回包 handler 解引用 task.page + * 之前必须校验,避免向已析构页面回包 + * @param page 待校验页面 + * @return 存活返回 true + */ + static bool existPage(const BrowserPage *page); + /** * @brief 文档页缩放后的原区域 不受旋转影响 * @return diff --git a/reader/browser/PageRenderThread.cpp b/reader/browser/PageRenderThread.cpp index 3bcb3c7bb..8fd96bb86 100644 --- a/reader/browser/PageRenderThread.cpp +++ b/reader/browser/PageRenderThread.cpp @@ -133,6 +133,79 @@ bool PageRenderThread::clearImageTasks(DocSheet *sheet, BrowserPage *page, int p // qCDebug(appLog) << "PageRenderThread::clearImageTasks() - Clear image tasks completed"; return true; } +void PageRenderThread::clearAllTasksForSheet(DocSheet *sheet) +{ + PageRenderThread *inst = instance(); + if (nullptr == inst) + return; + + // 排空所有引用该 sheet 的待处理任务,避免析构后后台线程悬空访问 + inst->m_pageNormalImageMutex.lock(); + for (int i = inst->m_pageNormalImageTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageNormalImageTasks[i].sheet == sheet) + inst->m_pageNormalImageTasks.removeAt(i); + } + inst->m_pageNormalImageMutex.unlock(); + + inst->m_pageSliceImageMutex.lock(); + for (int i = inst->m_pageSliceImageTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageSliceImageTasks[i].sheet == sheet) + inst->m_pageSliceImageTasks.removeAt(i); + } + inst->m_pageSliceImageMutex.unlock(); + + inst->m_pageBigImageMutex.lock(); + for (int i = inst->m_pageBigImageTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageBigImageTasks[i].sheet == sheet) + inst->m_pageBigImageTasks.removeAt(i); + } + inst->m_pageBigImageMutex.unlock(); + + inst->m_pageWordMutex.lock(); + for (int i = inst->m_pageWordTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageWordTasks[i].sheet == sheet) + inst->m_pageWordTasks.removeAt(i); + } + inst->m_pageWordMutex.unlock(); + + inst->m_pageAnnotationMutex.lock(); + for (int i = inst->m_pageAnnotationTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageAnnotationTasks[i].sheet == sheet) + inst->m_pageAnnotationTasks.removeAt(i); + } + inst->m_pageAnnotationMutex.unlock(); + + inst->m_pageThumbnailMutex.lock(); + for (int i = inst->m_pageThumbnailTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageThumbnailTasks[i].sheet == sheet) + inst->m_pageThumbnailTasks.removeAt(i); + } + inst->m_pageThumbnailMutex.unlock(); +} + +void PageRenderThread::clearAllTasksForPage(const BrowserPage *page) +{ + PageRenderThread *inst = instance(); + if (nullptr == inst || nullptr == page) + return; + + // BrowserPage 析构时调用:文字/注释任务不在 clearImageTasks 覆盖范围内, + // 若不清除,worker 完成后回包将解引用已析构的 page(主线程 handler 崩溃) + inst->m_pageWordMutex.lock(); + for (int i = inst->m_pageWordTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageWordTasks[i].page == page) + inst->m_pageWordTasks.removeAt(i); + } + inst->m_pageWordMutex.unlock(); + + inst->m_pageAnnotationMutex.lock(); + for (int i = inst->m_pageAnnotationTasks.count() - 1; i >= 0; --i) { + if (inst->m_pageAnnotationTasks[i].page == page) + inst->m_pageAnnotationTasks.removeAt(i); + } + inst->m_pageAnnotationMutex.unlock(); +} + void PageRenderThread::appendTask(DocPageNormalImageTask task) { @@ -356,9 +429,13 @@ void PageRenderThread::run() continue; } + if (task.renderer.isNull()) { + continue; + } + QList renderRects; - if (task.sheet->renderer()->hasWidgetAnnots(task.page->itemIndex())) { + if (task.renderer->hasWidgetAnnots(task.pageIndex)) { //if has signature,render whole rect renderRects.append(task.rect); } else { @@ -376,12 +453,12 @@ void PageRenderThread::run() if (m_quit) break; - //外部删除了此处不判断会导致崩溃 - if (!DocSheet::existSheet(task.sheet)) + //uuid失效说明sheet已销毁(或新文档已接管),仅用于避免为将死文档做无用渲染; + //正确性由任务中的renderer共享引用保证,此处解引用不再有UAF风险 + if (!DocSheet::existSheetByUuid(task.uuid)) break; - //判断page存在之后 使用page之前,也就是此处,如果主线程先一步进入page被删流程,【理论上会导致崩溃】,目前概率非常低,未发现 - QImage image = task.sheet->getImage(task.page->itemIndex(), task.rect.width(), task.rect.height(), + QImage image = task.renderer->getImage(task.pageIndex, task.rect.width(), task.rect.height(), QRect(static_cast(rect.x()), static_cast(rect.y()), static_cast(rect.width()), @@ -420,9 +497,9 @@ void PageRenderThread::run() break; // 预取图片对象 bbox(夜间蒙版用):在工作线程取,避免 UI 线程与渲染争文档锁 - if (DocSheet::existSheet(task.sheet) && task.sheet->renderer()->opened()) { - task.imageRects = task.sheet->renderer()->getImageObjectRects( - task.page->itemIndex(), task.rect.width(), task.rect.height()); + if (DocSheet::existSheetByUuid(task.uuid) && task.renderer->opened()) { + task.imageRects = task.renderer->getImageObjectRects( + task.pageIndex, task.rect.width(), task.rect.height()); } emit sigDocPageBigImageTaskFinished(task, pixmap); @@ -598,7 +675,7 @@ bool PageRenderThread::execNextDocPageNormalImageTask() return false; } - if (!DocSheet::existSheet(task.sheet)) { + if (task.renderer.isNull() || !DocSheet::existSheetByUuid(task.uuid)) { qCWarning(appLog) << "Sheet no longer exists, skip task"; return true; } @@ -607,24 +684,22 @@ bool PageRenderThread::execNextDocPageNormalImageTask() int targetHeight = task.rect.height(); if (targetWidth <= 0 || targetHeight <= 0) { const qreal deviceRatio = dApp ? dApp->devicePixelRatio() : 1.0; - const double pageScale = (task.page && task.page->m_scaleFactor > 0.0) - ? task.page->m_scaleFactor - : (task.sheet ? task.sheet->operation().scaleFactor : 1.0); - const QSizeF pageSize = task.page ? task.page->m_originSizeF : QSizeF(); + const double pageScale = task.scaleFactor > 0.0 ? task.scaleFactor : 1.0; + const QSizeF pageSize = task.originSize; targetWidth = qMax(1, qRound(pageSize.width() * pageScale * deviceRatio)); targetHeight = qMax(1, qRound(pageSize.height() * pageScale * deviceRatio)); task.rect = QRect(0, 0, targetWidth, targetHeight); } - QImage image = task.sheet->getImage(task.page->itemIndex(), targetWidth, targetHeight); + QImage image = task.renderer->getImage(task.pageIndex, targetWidth, targetHeight); if (image.isNull()) { - qCWarning(appLog) << "Failed to get image for page:" << task.page->itemIndex(); + qCWarning(appLog) << "Failed to get image for page:" << task.pageIndex; } else { - qCDebug(appLog) << "Image rendered successfully for page:" << task.page->itemIndex(); + qCDebug(appLog) << "Image rendered successfully for page:" << task.pageIndex; // 预取图片对象 bbox(夜间蒙版用):在工作线程取,避免 UI 线程与渲染争文档锁 - task.imageRects = task.sheet->renderer()->getImageObjectRects( - task.page->itemIndex(), targetWidth, targetHeight); + task.imageRects = task.renderer->getImageObjectRects( + task.pageIndex, targetWidth, targetHeight); emit sigDocPageNormalImageTaskFinished(task, QPixmap::fromImage(image)); } @@ -647,12 +722,12 @@ bool PageRenderThread::execNextDocPageSliceImageTask() } - if (!DocSheet::existSheet(task.sheet)) { + if (task.renderer.isNull() || !DocSheet::existSheetByUuid(task.uuid)) { qCDebug(appLog) << "文档不存在,取切片任务已结束"; return true; } - QImage image = task.sheet->getImage(task.page->itemIndex(), task.whole.width(), task.whole.height(), task.slice); + QImage image = task.renderer->getImage(task.pageIndex, task.whole.width(), task.whole.height(), task.slice); if (!image.isNull()) emit sigDocPageSliceImageTaskFinished(task, QPixmap::fromImage(image)); @@ -676,13 +751,13 @@ bool PageRenderThread::execNextDocPageWordTask() return false; } - if (!DocSheet::existSheet(task.sheet)) { + if (task.renderer.isNull() || !DocSheet::existSheetByUuid(task.uuid)) { qCDebug(appLog) << "文档不存在,取页码文字任务已结束"; return true; } - const QList &words = task.sheet->renderer()->getWords(task.page->itemIndex()); + const QList &words = task.renderer->getWords(task.pageIndex); emit sigDocPageWordTaskFinished(task, words); @@ -705,12 +780,12 @@ bool PageRenderThread::execNextDocPageAnnotationTask() return false; } - if (!DocSheet::existSheet(task.sheet)) { + if (task.renderer.isNull() || !DocSheet::existSheetByUuid(task.uuid)) { qCDebug(appLog) << "文档不存在,取页码注释任务已结束"; return true; } - const QList annots = task.sheet->renderer()->getAnnotations(task.page->itemIndex()); + const QList annots = task.renderer->getAnnotations(task.pageIndex); emit sigDocPageAnnotationTaskFinished(task, annots); @@ -733,15 +808,16 @@ bool PageRenderThread::execNextDocPageThumbnailTask() return false; } - if (!DocSheet::existSheet(task.sheet)) { + if (task.renderer.isNull() || !DocSheet::existSheetByUuid(task.uuid)) { qCDebug(appLog) << "文档不存在,缩略图任务已结束"; return true; } - QImage image = task.sheet->getImage(task.index, 174, 174); + QImage image = task.renderer->getImage(task.index, 174, 174); - if (!image.isNull()) + if (!image.isNull()) { emit sigDocPageThumbnailTaskFinished(task, QPixmap::fromImage(image)); + } qCDebug(appLog) << "执行缩略图任务已完成"; return true; } @@ -761,19 +837,22 @@ bool PageRenderThread::execNextDocOpenTask() return false;//false 为不用再继续循环调用 } - if (!DocSheet::existSheet(task.sheet)) { + if (task.uuid.isEmpty() || !DocSheet::existSheetByUuid(task.uuid)) { qCDebug(appLog) << "文档不存在,文档打开任务已结束"; return true; } - QString filePath = task.sheet->filePath(); + QString filePath = task.filePath; PERF_PRINT_BEGIN("POINT-03", QString("filename=%1,filesize=%2").arg(QFileInfo(filePath).fileName()).arg(QFileInfo(filePath).size())); deepin_reader::Document::Error error = deepin_reader::Document::NoError; - qCDebug(appLog) << "PageRenderThread::execNextDocOpenTask" << task.sheet->convertedFileDir(); - deepin_reader::Document *document = deepin_reader::DocumentFactory::getDocument(task.sheet->fileType(), filePath, task.sheet->convertedFileDir(), task.password, &(task.sheet->m_process), error); + qCDebug(appLog) << "PageRenderThread::execNextDocOpenTask" << task.convertedFileDir; + //getDocument出参改为局部变量,由worker带回、主线程回调写回sheet,避免跨线程写sheet成员 + QProcess *process = nullptr; + deepin_reader::Document *document = deepin_reader::DocumentFactory::getDocument(task.fileType, filePath, task.convertedFileDir, task.password, &process, error); + task.process = process; if (nullptr == document) { emit sigDocOpenTask(task, error, nullptr, QList()); @@ -831,6 +910,8 @@ void PageRenderThread::onDocPageNormalImageTaskFinished(DocPageNormalImageTask t { // qCDebug(appLog) << "PageRenderThread::onDocPageNormalImageTaskFinished() - Starting on doc page normal image task finished"; if (DocSheet::existSheet(task.sheet)) { + if (nullptr != task.page && !BrowserPage::existPage(task.page)) + return; // 页面已析构,丢弃残留回包(task.page 非空时必须存活才可解引用) task.page->setImageObjectRects(task.imageRects, task.rect.width(), task.rect.height()); task.page->handleRenderFinished(task.pixmapId, pixmap); } @@ -841,6 +922,8 @@ void PageRenderThread::onDocPageSliceImageTaskFinished(DocPageSliceImageTask tas { // qCDebug(appLog) << "PageRenderThread::onDocPageSliceImageTaskFinished() - Starting on doc page slice image task finished"; if (DocSheet::existSheet(task.sheet)) { + if (nullptr != task.page && !BrowserPage::existPage(task.page)) + return; // 页面已析构,丢弃残留回包 task.page->handleRenderFinished(task.pixmapId, pixmap, task.slice); } // qCDebug(appLog) << "PageRenderThread::onDocPageSliceImageTaskFinished() - On doc page slice image task finished completed"; @@ -850,6 +933,8 @@ void PageRenderThread::onDocPageBigImageTaskFinished(DocPageBigImageTask task, Q { // qCDebug(appLog) << "PageRenderThread::onDocPageBigImageTaskFinished() - Starting on doc page big image task finished"; if (DocSheet::existSheet(task.sheet)) { + if (nullptr != task.page && !BrowserPage::existPage(task.page)) + return; // 页面已析构,丢弃残留回包 task.page->setImageObjectRects(task.imageRects, task.rect.width(), task.rect.height()); task.page->handleRenderFinished(task.pixmapId, pixmap); } @@ -860,6 +945,8 @@ void PageRenderThread::onDocPageWordTaskFinished(DocPageWordTask task, QListhandleWordLoaded(words); } // qCDebug(appLog) << "PageRenderThread::onDocPageWordTaskFinished() - On doc page word task finished completed"; @@ -869,6 +956,8 @@ void PageRenderThread::onDocPageAnnotationTaskFinished(DocPageAnnotationTask tas { // qCDebug(appLog) << "PageRenderThread::onDocPageAnnotationTaskFinished() - Starting on doc page annotation task finished"; if (DocSheet::existSheet(task.sheet)) { + if (nullptr != task.page && !BrowserPage::existPage(task.page)) + return; // 页面已析构,丢弃残留回包 task.page->handleAnnotationLoaded(annots); } // qCDebug(appLog) << "PageRenderThread::onDocPageAnnotationTaskFinished() - On doc page annotation task finished completed"; @@ -894,6 +983,9 @@ void PageRenderThread::onDocOpenTask(DocOpenTask task, deepin_reader::Document:: return; } + //getDocument在worker线程创建的QProcess,此处(主线程)写回sheet,消除worker对sheet成员的跨线程写 + sheet->m_process = task.process; + sheet->renderer()->handleOpened(error, document, pages); // qCDebug(appLog) << "PageRenderThread::onDocOpenTask() - On doc open task completed"; } diff --git a/reader/browser/PageRenderThread.h b/reader/browser/PageRenderThread.h index 13a6945a7..12b4a6df5 100644 --- a/reader/browser/PageRenderThread.h +++ b/reader/browser/PageRenderThread.h @@ -14,13 +14,24 @@ #include #include #include +#include class DocSheet; class BrowserPage; class SheetRenderer; class SideBarImageViewModel; +class QProcess; +// worker 线程约定:任务结构体中仅允许解引用 renderer(共享引用,生命周期安全)与 +// 入队时快照的值类型数据;sheet/page 等裸指针仅供主线程回调使用,worker 禁止访问。 struct DocPageNormalImageTask {//正常取图 + // ---- worker 线程只读数据 ---- + QSharedPointer renderer; //渲染器共享引用 + QString uuid; //入队时sheet唯一标识,worker据此跳过已销毁文档的任务(仅优化) + int pageIndex = -1; //入队时快照,worker不再解引用BrowserPage + qreal scaleFactor = 0.0; //入队时快照,兜底尺寸计算用 + QSizeF originSize; //入队时快照,兜底尺寸计算用 + // ---- 仅供主线程回调使用 ---- DocSheet *sheet = nullptr; BrowserPage *page = nullptr; int pixmapId = 0; //任务艾迪 @@ -29,6 +40,9 @@ struct DocPageNormalImageTask {//正常取图 }; struct DocPageSliceImageTask {//取切片 + QSharedPointer renderer; + QString uuid; + int pageIndex = -1; DocSheet *sheet = nullptr; BrowserPage *page = nullptr; int pixmapId = 0; //任务艾迪 @@ -37,6 +51,9 @@ struct DocPageSliceImageTask {//取切片 }; struct DocPageBigImageTask {//取大图 + QSharedPointer renderer; + QString uuid; + int pageIndex = -1; DocSheet *sheet = nullptr; BrowserPage *page = nullptr; int pixmapId = 0; //任务艾迪 @@ -45,26 +62,37 @@ struct DocPageBigImageTask {//取大图 }; struct DocPageWordTask {//取页码文字 + QSharedPointer renderer; + QString uuid; + int pageIndex = -1; DocSheet *sheet = nullptr; BrowserPage *page = nullptr; }; struct DocPageAnnotationTask {//取页码注释 + QSharedPointer renderer; + QString uuid; + int pageIndex = -1; DocSheet *sheet = nullptr; BrowserPage *page = nullptr; }; struct DocPageThumbnailTask {//缩略图 + QSharedPointer renderer; + QString uuid; DocSheet *sheet = nullptr; SideBarImageViewModel *model = nullptr; int index = -1; }; struct DocOpenTask {//打开文档 - DocSheet *sheet = nullptr; + DocSheet *sheet = nullptr; //仅供主线程回调使用 QString password; - SheetRenderer *renderer = nullptr; QString uuid; //排队时的sheet唯一标识,防止地址复用误判存活 + QString filePath; //入队时快照,worker不再访问sheet + QString convertedFileDir; + int fileType = 0; //Dr::FileType + QProcess *process = nullptr; //getDocument出参,由主线程回调写回sheet->m_process }; struct DocCloseTask {//关闭文档 @@ -89,6 +117,22 @@ class PageRenderThread : public QThread * @return 是否成功 */ static bool clearImageTasks(DocSheet *sheet, BrowserPage *page, int pixmapId = -1); + /** + * @brief clearAllTasksForSheet + * 清除指定 sheet 的所有待处理任务(图片/文字/注释/缩略图)。 + * 用于 DocSheet 析构前排空引用该 sheet 的后台任务,避免悬空访问。 + * @param sheet 目标 sheet 指针 + */ + static void clearAllTasksForSheet(DocSheet *sheet); + + /** + * @brief clearAllTasksForPage + * 排空队列中所有引用指定 page 的文字/注释任务(图像任务由 clearImageTasks 负责); + * 仅主线程调用(BrowserPage 析构时),与 worker 通过各队列互斥锁互斥 + * @param page 页面对象 + */ + static void clearAllTasksForPage(const BrowserPage *page); + /** * @brief appendTask diff --git a/reader/browser/SheetBrowser.cpp b/reader/browser/SheetBrowser.cpp index d61023445..8a7703425 100644 --- a/reader/browser/SheetBrowser.cpp +++ b/reader/browser/SheetBrowser.cpp @@ -925,7 +925,8 @@ void SheetBrowser::pinchTriggered(QPinchGesture *gesture) if (gesture->state() == Qt::GestureFinished) { // qCDebug(appLog) << "SheetBrowser::pinchTriggered() - Gesture finished"; this->setProperty("pinchgetsturing", false); - QTimer::singleShot(10, [this]() { + // 必须传 receiver=this:否则 browser 在定时器触发前被销毁时,lambda 仍会执行并写已死对象(UAF) + QTimer::singleShot(10, this, [this]() { //稍微延迟下,不然还是会引起mouse事件触发 m_startPinch = false; }); diff --git a/reader/sidebar/SideBarImageViewModel.cpp b/reader/sidebar/SideBarImageViewModel.cpp index b493450fa..f8ab61d49 100644 --- a/reader/sidebar/SideBarImageViewModel.cpp +++ b/reader/sidebar/SideBarImageViewModel.cpp @@ -129,6 +129,8 @@ QVariant SideBarImageViewModel::data(const QModelIndex &index, int role) const task.sheet = m_sheet; task.index = nRow; task.model = const_cast(this); + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); PageRenderThread::appendTask(task); } @@ -198,6 +200,8 @@ void SideBarImageViewModel::onUpdateImage(int index) task.sheet = m_sheet; task.index = index; task.model = const_cast(this); + task.renderer = m_sheet ? m_sheet->rendererPtr() : nullptr; + task.uuid = m_sheet ? m_sheet->uuid() : QString(); PageRenderThread::appendTask(task); qCDebug(appLog) << "Updating image for index:" << index << "end"; } diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index 6a4cdf584..e9d13210e 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -72,8 +72,8 @@ DocSheet::DocSheet(const Dr::FileType &fileType, const QString &filePath, QWidg connect(m_searchTask, &PageSearchThread::finished, this, &DocSheet::onSearchFinished, Qt::QueuedConnection); connect(m_searchTask, &PageSearchThread::sigSearchResultNotEmpty, this, &DocSheet::onSearchResultNotEmpty, Qt::QueuedConnection); - m_renderer = new SheetRenderer(this); - connect(m_renderer, &SheetRenderer::sigOpened, this, &DocSheet::onOpened); + m_renderer = QSharedPointer::create(); + connect(m_renderer.data(), &SheetRenderer::sigOpened, this, &DocSheet::onOpened); m_browser = new SheetBrowser(this); m_browser->setMinimumWidth(481); @@ -153,12 +153,14 @@ DocSheet::~DocSheet() setAlive(false); + // 排空引用本 sheet 的所有待处理渲染任务(仅优化,避免为将死文档做无用渲染); + // 正确性由任务中捕获的renderer共享引用与uuid校验保证,不再依赖析构与线程间的时序 + PageRenderThread::clearAllTasksForSheet(this); + delete m_browser; delete m_sidebar; - delete m_renderer; - delete m_searchTask; delete m_encryPage; @@ -231,6 +233,17 @@ bool DocSheet::existSheet(DocSheet *sheet) return result; } +bool DocSheet::existSheetByUuid(const QString &uuid) +{ + g_lock.lockForRead(); + + bool result = !uuid.isEmpty() && g_uuidList.contains(uuid); + + g_lock.unlock(); + + return result; +} + DocSheet *DocSheet::getSheet(QString uuid) { qCDebug(appLog) << "getSheet"; @@ -275,7 +288,7 @@ bool DocSheet::openFileExec(const QString &password) qCDebug(appLog) << "Executing file open synchronously"; m_password = password; - bool result = m_renderer->openFileExec(password); + bool result = m_renderer->openFileExec(password, m_filePath, convertedFileDir(), m_uuid, static_cast(m_fileType), this); if (!result) { qCWarning(appLog) << "Failed to open file synchronously"; } @@ -289,7 +302,7 @@ void DocSheet::openFileAsync(const QString &password) m_password = password; qCInfo(appLog) << "添加异步打开任务..."; - m_renderer->openFileAsync(m_password); + m_renderer->openFileAsync(m_password, m_filePath, convertedFileDir(), m_uuid, static_cast(m_fileType), this); } void DocSheet::jumpToPage(int page) @@ -1852,7 +1865,7 @@ void DocSheet::onExtractPassword(const QString &password) qCDebug(appLog) << "Extracted password, attempting to open file"; m_password = password; - m_renderer->openFileAsync(m_password); + m_renderer->openFileAsync(m_password, m_filePath, convertedFileDir(), m_uuid, static_cast(m_fileType), this); } void DocSheet::saveCurrentViewState() @@ -1964,7 +1977,7 @@ float DocSheet::currentScrollPosition() const SheetRenderer *DocSheet::renderer() { // qCDebug(appLog) << "renderer"; - return m_renderer; + return m_renderer.data(); } void DocSheet::onPopPrintDialog() diff --git a/reader/uiframe/DocSheet.h b/reader/uiframe/DocSheet.h index 63a0c97a5..122658a4d 100644 --- a/reader/uiframe/DocSheet.h +++ b/reader/uiframe/DocSheet.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -109,6 +110,14 @@ class DocSheet : public Dtk::Widget::DSplitter */ static bool existSheet(DocSheet *sheet); + /** + * @brief existSheetByUuid + * 进程是否存在该uuid对应的文档(worker线程安全,仅读锁+串查找) + * @param uuid + * @return + */ + static bool existSheetByUuid(const QString &uuid); + /** * @brief getSheet * 根据uuid返回sheet @@ -704,6 +713,12 @@ class DocSheet : public Dtk::Widget::DSplitter */ SheetRenderer *renderer(); + /** + * @brief rendererPtr + * 获取渲染器共享引用(入队时捕获,任务在worker线程使用期间生命周期安全) + */ + QSharedPointer rendererPtr() const { return m_renderer; } + QString uuid() const { return m_uuid; } public slots: @@ -955,7 +970,7 @@ private slots: SheetSidebar *m_sidebar = nullptr; //操作左侧ui SheetBrowser *m_browser = nullptr; //操作右侧ui - SheetRenderer *m_renderer = nullptr; //数据渲染器 + QSharedPointer m_renderer; //数据渲染器(共享所有权:worker持有引用期间不因析构悬空) QString m_filePath; QString m_password; diff --git a/reader/uiframe/SheetRenderer.cpp b/reader/uiframe/SheetRenderer.cpp index 08a5f891a..e285e838c 100644 --- a/reader/uiframe/SheetRenderer.cpp +++ b/reader/uiframe/SheetRenderer.cpp @@ -9,9 +9,11 @@ #include -SheetRenderer::SheetRenderer(DocSheet *parent) : QObject(parent), m_sheet(parent) +// 无父对象:由DocSheet以QSharedPointer持有,去除了对DocSheet的反向依赖, +// 使本对象可被worker线程通过共享引用安全使用 +SheetRenderer::SheetRenderer() : QObject(nullptr) { - qCDebug(appLog) << "Creating SheetRenderer for sheet:" << (parent ? parent->filePath() : "null"); + qCDebug(appLog) << "Creating SheetRenderer (parentless, shared ownership)"; } SheetRenderer::~SheetRenderer() @@ -27,14 +29,15 @@ SheetRenderer::~SheetRenderer() qCDebug(appLog) << "关闭文档任务已添加"; } -bool SheetRenderer::openFileExec(const QString &password) +bool SheetRenderer::openFileExec(const QString &password, const QString &filePath, + const QString &convertedFileDir, const QString &uuid, int fileType, DocSheet *sheet) { qCDebug(appLog) << "Executing synchronous file open"; QEventLoop loop; connect(this, &SheetRenderer::sigOpened, &loop, &QEventLoop::quit); - openFileAsync(password); + openFileAsync(password, filePath, convertedFileDir, uuid, fileType, sheet); loop.exec(); @@ -45,19 +48,23 @@ bool SheetRenderer::openFileExec(const QString &password) return success; } -void SheetRenderer::openFileAsync(const QString &password) +void SheetRenderer::openFileAsync(const QString &password, const QString &filePath, + const QString &convertedFileDir, const QString &uuid, int fileType, DocSheet *sheet) { qCDebug(appLog) << "Starting asynchronous file open"; DocOpenTask task; - task.sheet = m_sheet; - task.password = password; - task.renderer = this; + task.uuid = uuid; + + task.filePath = filePath; + + task.convertedFileDir = convertedFileDir; + + task.fileType = fileType; - if (nullptr != m_sheet) - task.uuid = m_sheet->uuid(); + task.sheet = sheet; //仅供主线程回调路由,worker线程不访问 PageRenderThread::appendTask(task); qCDebug(appLog) << "SheetRenderer::openFileAsync end"; diff --git a/reader/uiframe/SheetRenderer.h b/reader/uiframe/SheetRenderer.h index d920b8963..75ceaf85c 100644 --- a/reader/uiframe/SheetRenderer.h +++ b/reader/uiframe/SheetRenderer.h @@ -18,7 +18,7 @@ class SheetRenderer : public QObject { Q_OBJECT public: - explicit SheetRenderer(DocSheet *parent); + explicit SheetRenderer(); ~SheetRenderer(); @@ -26,16 +26,28 @@ class SheetRenderer : public QObject * @brief openFileExec * 阻塞式打开文档 * @param password + * @param filePath 文档路径(入队时快照,worker不再访问DocSheet) + * @param convertedFileDir 转换目录(入队时快照) + * @param uuid 所属DocSheet唯一标识 + * @param fileType 文档类型(入队时快照) + * @param sheet 所属DocSheet,仅供主线程回调路由使用 */ - bool openFileExec(const QString &password); + bool openFileExec(const QString &password, const QString &filePath, + const QString &convertedFileDir, const QString &uuid, int fileType, DocSheet *sheet); /** * @brief openFileAsync * 异步式打开文档,完成后会发出sigFileOpened * @param password 文档密码 + * @param filePath 文档路径(入队时快照,worker不再访问DocSheet) + * @param convertedFileDir 转换目录(入队时快照) + * @param uuid 所属DocSheet唯一标识(主线程回调路由用) + * @param fileType 文档类型(入队时快照) + * @param sheet 所属DocSheet,仅供主线程回调路由使用(worker禁止解引用) * @return */ - void openFileAsync(const QString &password); + void openFileAsync(const QString &password, const QString &filePath, + const QString &convertedFileDir, const QString &uuid, int fileType, DocSheet *sheet); /** * @brief opened @@ -222,7 +234,8 @@ class SheetRenderer : public QObject void sigOpened(deepin_reader::Document::Error error); private: - DocSheet *m_sheet = nullptr; + // 注意:无m_sheet反向指针、无QObject父对象;由DocSheet以QSharedPointer持有, + // worker线程持有共享引用期间生命周期安全,可跨线程安全使用 deepin_reader::Document::Error m_error = deepin_reader::Document::NoError; bool m_pageLabelLoaded = false; //是否已经加载page label QMap m_lable2Page; // 文档下标页码 diff --git a/tests/browser/ut_browserpage.cpp b/tests/browser/ut_browserpage.cpp index f098ff600..f5171d532 100644 --- a/tests/browser/ut_browserpage.cpp +++ b/tests/browser/ut_browserpage.cpp @@ -538,6 +538,7 @@ TEST_F(TestBrowserPage, UT_BrowserPage_addHighlightAnnotation_001) s.set(ADDR(SheetRenderer, getWords), getWords_stub); s.set(ADDR(QGraphicsItem, isSelected), isSelected_stub); s.set(ADDR(SheetRenderer, addHighlightAnnotation), addHighlightAnnotation_stub); + s.set(ADDR(BrowserPage, renderRect), renderRect_stub); BrowserWord *w1 = new BrowserWord(nullptr, Word("first", QRectF(0, 0, 20, 10))); BrowserWord *w2 = new BrowserWord(nullptr, Word("second", QRectF(20, 0, 40, 10))); m_tester->m_words.append(w1); diff --git a/tests/browser/ut_pagerenderthread.cpp b/tests/browser/ut_pagerenderthread.cpp index ee5f853f6..8f0294f75 100644 --- a/tests/browser/ut_pagerenderthread.cpp +++ b/tests/browser/ut_pagerenderthread.cpp @@ -101,7 +101,7 @@ static QString uuid_stub() // DocSheet::renderer 档:占位渲染器(handleOpened已stub)。堆分配不释放,避免静态对象在 main 返回后析构 static SheetRenderer *renderer_stub() { - static SheetRenderer *dummy = new SheetRenderer(nullptr); + static SheetRenderer *dummy = new SheetRenderer(); return dummy; } @@ -216,7 +216,6 @@ TEST_F(TestPageRenderThread, UT_PageRenderThread_onDocOpenTask_001) { DocOpenTask task; task.sheet = nullptr; - task.renderer = nullptr; QList pages; m_tester->onDocOpenTask(task, deepin_reader::Document::NoError, nullptr, pages); SUCCEED(); @@ -340,9 +339,12 @@ TEST_F(TestPageRenderThread, UT_PageRenderThread_onDocOpenTask_002) s.set(ADDR(DocSheet, renderer), renderer_stub); s.set(ADDR(SheetRenderer, handleOpened), handleOpened_stub); + // onDocOpenTask会直接写 sheet->m_process(非函数调用,stub拦截不到), + // 因此用足够大的可写静态存储充当假sheet,而非非法地址 + static unsigned char fake_sheet_storage[8192] = {}; + DocOpenTask task; - task.sheet = reinterpret_cast(0x1); //成员调用均已被stub - task.renderer = nullptr; + task.sheet = reinterpret_cast(fake_sheet_storage); //成员调用均已被stub task.uuid = "ut-sheet-uuid"; //与uuid_stub一致,校验通过 QList pages; m_tester->onDocOpenTask(task, deepin_reader::Document::NoError, nullptr, pages); @@ -361,7 +363,6 @@ TEST_F(TestPageRenderThread, UT_PageRenderThread_onDocOpenTask_003) DocOpenTask task; task.sheet = reinterpret_cast(0x1); - task.renderer = reinterpret_cast(0x1); //悬空,不应被解引用 task.uuid = "stale-uuid"; //与uuid_stub不一致 QList pages; m_tester->onDocOpenTask(task, deepin_reader::Document::NoError, nullptr, pages); @@ -466,7 +467,6 @@ TEST_F(TestPageRenderThread, UT_PageRenderThread_appendTask_Open) DocOpenTask task; task.sheet = nullptr; - task.renderer = nullptr; PageRenderThread::appendTask(task); EXPECT_FALSE(m_tester->m_openTasks.isEmpty()); m_tester->m_openTasks.clear(); diff --git a/tests/browser/ut_sheetbrowser.cpp b/tests/browser/ut_sheetbrowser.cpp index 7ca19eb62..f6a957fa4 100644 --- a/tests/browser/ut_sheetbrowser.cpp +++ b/tests/browser/ut_sheetbrowser.cpp @@ -208,8 +208,10 @@ Qt::MouseEventSource source_stub2() static BrowserPage *g_pBrowserPage2 = nullptr; BrowserPage *getBrowserPageForPoint_stub(QPointF &) { - DocSheet sheet(Dr::FileType::PDF, "1.pdf", nullptr); - g_pBrowserPage2 = new BrowserPage(nullptr, 0, &sheet); + // 用堆上静态sheet而非栈对象:返回的BrowserPage持有其指针, + // 若绑定栈对象会在函数返回后悬空(loadWords等路径解引用即崩) + static DocSheet *s_stubSheet = new DocSheet(Dr::FileType::PDF, "1.pdf", nullptr); + g_pBrowserPage2 = new BrowserPage(nullptr, 0, s_stubSheet); g_pBrowserPage2->m_index = 3; return g_pBrowserPage2; } diff --git a/tests/uiframe/ut_docsheet.cpp b/tests/uiframe/ut_docsheet.cpp index e586cf728..9049bd468 100644 --- a/tests/uiframe/ut_docsheet.cpp +++ b/tests/uiframe/ut_docsheet.cpp @@ -73,13 +73,13 @@ QImage firstThumbnail_stub(const QString &) return QImage(100, 100, QImage::Format_ARGB32); } -bool openFileExec_stub(const QString &) +bool openFileExec_stub(const QString &, const QString &, const QString &, const QString &, int, DocSheet *) { g_funcName = __FUNCTION__; return true; } -void openFileAsync_stub(const QString &) +void openFileAsync_stub(const QString &, const QString &, const QString &, const QString &, int, DocSheet *) { g_funcName = __FUNCTION__; } @@ -1493,7 +1493,7 @@ QSizeF getPageSize_stub2(int) return QSizeF(100.0, 200.0); } -void openFileAsync_stub2(const QString &) +void openFileAsync_stub2(const QString &, const QString &, const QString &, const QString &, int, DocSheet *) { g_funcName = __FUNCTION__; } diff --git a/tests/uiframe/ut_sheetrenderer.cpp b/tests/uiframe/ut_sheetrenderer.cpp index 8ab8f178e..a3fb987dd 100644 --- a/tests/uiframe/ut_sheetrenderer.cpp +++ b/tests/uiframe/ut_sheetrenderer.cpp @@ -56,7 +56,7 @@ void TestSheetRenderer::SetUp() QString strPath = UTSOURCEDIR; strPath += "/files/1.pdf"; m_sheet = new DocSheet(Dr::FileType::PDF, strPath, m_parent); - m_tester = m_sheet->m_renderer; + m_tester = m_sheet->m_renderer.data(); ASSERT_NE(m_tester, nullptr); } @@ -275,7 +275,7 @@ TEST_F(TestSheetRenderer, testOpenFileAsync) // otherwise a queued sigDocOpenTask referencing this renderer outlives the test // and gets delivered to freed memory later. QSignalSpy spy(m_tester, &SheetRenderer::sigOpened); - m_tester->openFileAsync("test"); + m_tester->openFileAsync("test", UTSOURCEDIR "/files/normal.pdf", QString(), m_sheet->uuid(), static_cast(Dr::PDF), m_sheet); QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 1, 30000); SUCCEED(); } @@ -283,6 +283,6 @@ TEST_F(TestSheetRenderer, testOpenFileAsync) TEST_F(TestSheetRenderer, testOpenFileExec) { // Let openFileExec wait for the REAL sigOpened of the actual open task. - bool result = m_tester->openFileExec("test"); + bool result = m_tester->openFileExec("test", UTSOURCEDIR "/files/normal.pdf", QString(), m_sheet->uuid(), static_cast(Dr::PDF), m_sheet); EXPECT_TRUE(result); } diff --git a/tests/ut_mainwindow.cpp b/tests/ut_mainwindow.cpp index 456473ea9..7beade3d4 100644 --- a/tests/ut_mainwindow.cpp +++ b/tests/ut_mainwindow.cpp @@ -21,7 +21,7 @@ #include "ut_compat.h" #include -static void openFileAsync_stub(const QString &) +static void openFileAsync_stub(const QString &, const QString &, const QString &, const QString &, int, DocSheet *) { return; } From 3fd6203c50c65cb2ce5051cdd758af910b1bcabf Mon Sep 17 00:00:00 2001 From: zhanghongyuan Date: Tue, 15 Sep 2026 17:07:57 +0800 Subject: [PATCH 2/3] fix(database): synchronize cleanup of bookmarks and tab groups in orphan states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup could leave dangling references because bookmarks and tab groups were removed independently while ownership records were still being reconciled; the residue was re-persisted by later sync runs. 孤立状态下的书签与标签组独立清理时归属记录仍在协调,残留项会被 后续同步再次落盘。 Log: 修复孤立状态下书签与标签组清理不同步导致残留引用的问题 Influence: 数据库书签与标签组清理逻辑,孤立状态下不再残留悬挂引用。 --- reader/app/Database.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/reader/app/Database.cpp b/reader/app/Database.cpp index b37190027..53823fb2e 100644 --- a/reader/app/Database.cpp +++ b/reader/app/Database.cpp @@ -794,10 +794,13 @@ int Database::cleanupOrphanStates() cleanedCount++; qCDebug(appLog) << "Cleaned orphan state:" << path; } - // 同步清理书签(在同一事务中保证数据一致性) + // 同步清理书签与标签页组(在同一事务中保证数据一致性) deleteQuery.prepare("DELETE FROM bookmark WHERE filePath = :filePath"); deleteQuery.bindValue(":filePath", path); deleteQuery.exec(); + deleteQuery.prepare("DELETE FROM tabgroup WHERE filePath = :filePath"); + deleteQuery.bindValue(":filePath", path); + deleteQuery.exec(); } transaction.commit(); From 7f6aeae0427ff5e15b45ca431302b8d7fbb8e276 Mon Sep 17 00:00:00 2001 From: zhanghongyuan Date: Tue, 15 Sep 2026 14:52:25 +0800 Subject: [PATCH 3/3] fix(reader): invert sidebar thumbnails only under dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar thumbnails followed eye-protection mode and stayed white in dark theme. Now they follow the system theme only: white pages are inverted via the shared NightFilter (CIELAB L*), image objects keep original colors through a mask prefetched by the worker and cached with the thumbnail, and scanned pages (>70% coverage) are fully inverted. Bookmark/notes lists and the search-result page thumbnails follow the same rules, and mask rects are scaled to the scaled pixmap before filtering. 侧边栏缩略图原先跟随护眼模式,深色主题下仍是白底。现改为只跟随系统 深浅主题:白底经主干夜间滤镜(CIELAB L*)反转为黑底白字,照片区域按 worker 预取、随缩略图缓存的图片对象蒙版保持原色,扫描页(覆盖率超 70%)整页反色;书签/注释列表与搜索结果页的页面小图同规则,蒙版坐标 先映射到缩放后的像素图再进滤镜。 Log: 侧边栏缩略图/书签/注释/搜索结果深色主题反色并支持图片对象蒙版 PMS: BUG-377151 Influence: 深色主题下侧边栏(含触发搜索后的结果页)观感与主视图一致,浅色主题显示不变。 --- reader/browser/PageRenderThread.cpp | 7 +- reader/browser/PageRenderThread.h | 1 + reader/sidebar/BookMarkDelegate.cpp | 52 +++--- reader/sidebar/NotesDelegate.cpp | 47 ++++- reader/sidebar/NotesDelegate.h | 8 + reader/sidebar/SearchResDelegate.cpp | 25 ++- reader/sidebar/SearchResDelegate.h | 18 ++ reader/sidebar/SideBarImageViewModel.cpp | 9 +- reader/sidebar/SideBarImageViewModel.h | 8 +- reader/sidebar/ThumbnailDelegate.cpp | 77 ++++---- reader/sidebar/ThumbnailDelegate.h | 15 ++ reader/uiframe/DocSheet.cpp | 9 +- reader/uiframe/DocSheet.h | 12 +- tests/browser/ut_pagerenderthread.cpp | 130 +++++++++++++- tests/sidebar/ut_bookmarkdelegate.cpp | 139 +++++++++++++++ tests/sidebar/ut_notesdelegate.cpp | 139 +++++++++++++++ tests/sidebar/ut_searchresdelegate.cpp | 147 +++++++++++++++- tests/sidebar/ut_sidebarimageviewmodel.cpp | 32 ++++ tests/sidebar/ut_thumbnaildelegate.cpp | 193 +++++++++++++++++++++ tests/uiframe/ut_docsheet.cpp | 21 +++ 20 files changed, 1002 insertions(+), 87 deletions(-) diff --git a/reader/browser/PageRenderThread.cpp b/reader/browser/PageRenderThread.cpp index 8fd96bb86..94092086c 100644 --- a/reader/browser/PageRenderThread.cpp +++ b/reader/browser/PageRenderThread.cpp @@ -816,6 +816,11 @@ bool PageRenderThread::execNextDocPageThumbnailTask() QImage image = task.renderer->getImage(task.index, 174, 174); if (!image.isNull()) { + // 预取图片对象 bbox(夜间/深色蒙版用):在工作线程取,避免 UI 线程与渲染争文档锁; + // 与 getImage 同尺寸(174,174)请求,蒙版与缩略图输出像素一一对齐 + if (DocSheet::existSheetByUuid(task.uuid) && task.renderer->opened()) { + task.imageRects = task.renderer->getImageObjectRects(task.index, 174, 174); + } emit sigDocPageThumbnailTaskFinished(task, QPixmap::fromImage(image)); } qCDebug(appLog) << "执行缩略图任务已完成"; @@ -967,7 +972,7 @@ void PageRenderThread::onDocPageThumbnailTask(DocPageThumbnailTask task, QPixmap { // qCDebug(appLog) << "PageRenderThread::onDocPageThumbnailTask() - Starting on doc page thumbnail task"; if (DocSheet::existSheet(task.sheet)) { - task.model->handleRenderThumbnail(task.index, pixmap); + task.model->handleRenderThumbnail(task.index, pixmap, task.imageRects); } // qCDebug(appLog) << "PageRenderThread::onDocPageThumbnailTask() - On doc page thumbnail task completed"; } diff --git a/reader/browser/PageRenderThread.h b/reader/browser/PageRenderThread.h index 12b4a6df5..0b8ff46bb 100644 --- a/reader/browser/PageRenderThread.h +++ b/reader/browser/PageRenderThread.h @@ -83,6 +83,7 @@ struct DocPageThumbnailTask {//缩略图 DocSheet *sheet = nullptr; SideBarImageViewModel *model = nullptr; int index = -1; + QVector imageRects; //图片对象 bbox(夜间/深色蒙版用,与缩略图像素对齐,worker线程预取) }; struct DocOpenTask {//打开文档 diff --git a/reader/sidebar/BookMarkDelegate.cpp b/reader/sidebar/BookMarkDelegate.cpp index de692eed4..7bea597e9 100644 --- a/reader/sidebar/BookMarkDelegate.cpp +++ b/reader/sidebar/BookMarkDelegate.cpp @@ -5,6 +5,7 @@ #include "BookMarkDelegate.h" #include "SideBarImageViewModel.h" +#include "NightFilter.h" #include "Application.h" #include "ddlog.h" @@ -48,42 +49,33 @@ void BookMarkDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti clipPath.addRoundedRect(rect, borderRadius, borderRadius); painter->setClipPath(clipPath); // 深色主题下将白底缩略图反色为黑底白字(仅绘制时反色,不改缓存原图): - // HSL 亮度反转,下限钳制 37(#252525),反转后 ≥192 提亮纯白。 + // 统一走主干夜间滤镜 NightFilter(CIELAB L* 反转),图片对象区域不反色 + // (照片零负片,与主视图蒙版行为一致),含扫描页整页反色特判。 if (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType) { - // 按源图 cacheKey() 缓存反色结果,避免每次重绘重复逐像素计算 + // 图片对象 bbox(与存储缩略图像素对齐,渲染线程预取) + const QVector imageRects = index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + // 按源缩略图 cacheKey() 缓存反色结果,避免每次重绘重复逐像素计算 QPixmap invertedPixmap; if (QPixmap *cached = m_darkPixmapCache.object(pixmap.cacheKey())) { invertedPixmap = *cached; } else { - QImage img = scalePix.toImage(); - if (!img.isNull()) { - if (img.format() != QImage::Format_ARGB32) - img = img.convertToFormat(QImage::Format_ARGB32); - const int w = img.width(); - const int h = img.height(); - const int kMinLightAfterInvert = 37; // #252525 - const int kMaxLightBoostThreshold = 192; // 0xC0,提亮阈值 - for (int y = 0; y < h; ++y) { - QRgb *line = reinterpret_cast(img.scanLine(y)); - for (int x = 0; x < w; ++x) { - const QRgb px = line[x]; - const int alpha = qAlpha(px); - QColor c = QColor::fromRgb(qRed(px), qGreen(px), qBlue(px)); - int hue, sat, light, dummy; - c.getHsl(&hue, &sat, &light, &dummy); - light = 255 - light; - if (light >= kMaxLightBoostThreshold) - light = 255; - light = qMax(light, kMinLightAfterInvert); - c.setHsl(hue, sat, light); - line[x] = qRgba(c.red(), c.green(), c.blue(), alpha); - } - } - invertedPixmap = QPixmap::fromImage(img); - invertedPixmap.setDevicePixelRatio(scalePix.devicePixelRatio()); + // bbox 是存储缩略图(174px)坐标,而 scalePix 是等比缩小后的图, + // 须先映射到 scalePix 坐标,否则覆盖率特判/蒙版区域全错 + QVector scaledRects; + scaledRects.reserve(imageRects.size()); + const qreal sx = pixmap.isNull() || pixmap.width() == 0 + ? 0.0 : qreal(scalePix.width()) / pixmap.width(); + const qreal sy = pixmap.isNull() || pixmap.height() == 0 + ? 0.0 : qreal(scalePix.height()) / pixmap.height(); + for (const QRectF &r : imageRects) + scaledRects.append(QRectF(r.x() * sx, r.y() * sy, + r.width() * sx, r.height() * sy)); + + invertedPixmap = QPixmap::fromImage(NightFilter::applyPage(scalePix.toImage(), scaledRects)); + invertedPixmap.setDevicePixelRatio(scalePix.devicePixelRatio()); + if (!invertedPixmap.isNull()) m_darkPixmapCache.insert(pixmap.cacheKey(), new QPixmap(invertedPixmap), - img.width() * img.height() * 4); // ARGB32 每像素固定 4 字节 - } + invertedPixmap.width() * invertedPixmap.height() * 4); // ARGB32 每像素固定 4 字节 } if (!invertedPixmap.isNull()) painter->drawPixmap(rect.x(), rect.y(), invertedPixmap); diff --git a/reader/sidebar/NotesDelegate.cpp b/reader/sidebar/NotesDelegate.cpp index c4a1fcdea..d4f0f99af 100644 --- a/reader/sidebar/NotesDelegate.cpp +++ b/reader/sidebar/NotesDelegate.cpp @@ -5,6 +5,7 @@ #include "NotesDelegate.h" #include "SideBarImageViewModel.h" +#include "NightFilter.h" #include "Utils.h" #include "Application.h" #include "ddlog.h" @@ -15,12 +16,14 @@ #include #include #include +#include NotesDelegate::NotesDelegate(QAbstractItemView *parent) : DStyledItemDelegate(parent) { // qCDebug(appLog) << "NotesDelegate::NotesDelegate() - Starting constructor"; m_parent = parent; + m_darkPixmapCache.setMaxCost(8 * 1024 * 1024); // 反色缓存预算 8MB,按像素字节数计费 // qCDebug(appLog) << "NotesDelegate::NotesDelegate() - Constructor completed"; } @@ -45,7 +48,42 @@ void NotesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, QPainterPath clipPath; clipPath.addRoundedRect(rect, borderRadius, borderRadius); painter->setClipPath(clipPath); - painter->drawPixmap(rect.x(), rect.y(), scalePix); + // 深色主题下将白底缩略图反色为黑底白字(仅绘制时反色,不改缓存原图): + // 统一走主干夜间滤镜 NightFilter(CIELAB L* 反转),图片对象区域不反色 + // (照片零负片,与主视图蒙版行为一致),含扫描页整页反色特判。 + if (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType) { + // 图片对象 bbox(与存储缩略图像素对齐,渲染线程预取) + const QVector imageRects = index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + // 按源缩略图 cacheKey() 缓存反色结果,避免每次重绘重复逐像素计算 + QPixmap invertedPixmap; + if (QPixmap *cached = m_darkPixmapCache.object(pixmap.cacheKey())) { + invertedPixmap = *cached; + } else { + // bbox 是存储缩略图(174px)坐标,而 scalePix 是等比缩小后的图, + // 须先映射到 scalePix 坐标,否则覆盖率特判/蒙版区域全错 + QVector scaledRects; + scaledRects.reserve(imageRects.size()); + const qreal sx = pixmap.isNull() || pixmap.width() == 0 + ? 0.0 : qreal(scalePix.width()) / pixmap.width(); + const qreal sy = pixmap.isNull() || pixmap.height() == 0 + ? 0.0 : qreal(scalePix.height()) / pixmap.height(); + for (const QRectF &r : imageRects) + scaledRects.append(QRectF(r.x() * sx, r.y() * sy, + r.width() * sx, r.height() * sy)); + + invertedPixmap = QPixmap::fromImage(NightFilter::applyPage(scalePix.toImage(), scaledRects)); + invertedPixmap.setDevicePixelRatio(scalePix.devicePixelRatio()); + if (!invertedPixmap.isNull()) + m_darkPixmapCache.insert(pixmap.cacheKey(), new QPixmap(invertedPixmap), + invertedPixmap.width() * invertedPixmap.height() * 4); // ARGB32 每像素固定 4 字节 + } + if (!invertedPixmap.isNull()) + painter->drawPixmap(rect.x(), rect.y(), invertedPixmap); + else + painter->drawPixmap(rect.x(), rect.y(), scalePix); + } else { + painter->drawPixmap(rect.x(), rect.y(), scalePix); + } painter->restore(); } @@ -57,7 +95,12 @@ void NotesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(QPen(DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->applicationPalette().highlight().color(), 2)); painter->drawRoundedRect(rect, borderRadius, borderRadius); } else { - painter->setPen(QPen(DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->applicationPalette().frameShadowBorder().color(), 1)); + QColor frameColor = DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->applicationPalette().frameShadowBorder().color(); + if (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType) { + frameColor = DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->applicationPalette().windowText().color(); + frameColor.setAlphaF(0.2); + } + painter->setPen(QPen(frameColor, 1)); painter->drawRoundedRect(rect, borderRadius, borderRadius); } painter->restore(); diff --git a/reader/sidebar/NotesDelegate.h b/reader/sidebar/NotesDelegate.h index 95bd6e63d..1ce70812a 100644 --- a/reader/sidebar/NotesDelegate.h +++ b/reader/sidebar/NotesDelegate.h @@ -8,6 +8,9 @@ #include +#include +#include + DWIDGET_USE_NAMESPACE /** * @brief The NotesDelegate class @@ -39,6 +42,11 @@ class NotesDelegate : public DStyledItemDelegate private: QAbstractItemView *m_parent = nullptr; + + /** + * @brief 深色主题反色结果缓存(键:源图 cacheKey();paint() 为 const 故 mutable) + */ + mutable QCache m_darkPixmapCache; }; #endif // NOTESDELEGATE_H diff --git a/reader/sidebar/SearchResDelegate.cpp b/reader/sidebar/SearchResDelegate.cpp index 6301a7745..c5171bcef 100644 --- a/reader/sidebar/SearchResDelegate.cpp +++ b/reader/sidebar/SearchResDelegate.cpp @@ -5,6 +5,7 @@ #include "SearchResDelegate.h" #include "SideBarImageViewModel.h" +#include "NightFilter.h" #include "Utils.h" #include "Application.h" #include "ddlog.h" @@ -39,7 +40,13 @@ void SearchResDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt if (!pixmap.isNull()) { - const QPixmap &scalePix = pixmap.scaled(pageSize); + // 深色主题下搜索结果页的页面小图与缩略图侧栏一致走主干夜间滤镜 + // (CIELAB L* 反转,图片对象区域保持原色,扫描页整页反色特判), + // 浅色主题照常绘制原图;先按原始尺寸反色再缩放,蒙版坐标无需换算 + const bool darkTheme = (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType); + const QVector imageRects = index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + const QPixmap &displayPixmap = darkTheme ? nightPixmap(pixmap, imageRects) : pixmap; + const QPixmap &scalePix = displayPixmap.scaled(pageSize); //clipPath pixmap painter->save(); QPainterPath clipPath; @@ -124,3 +131,19 @@ QSize SearchResDelegate::sizeHint(const QStyleOptionViewItem &option, const QMod return size; } +QPixmap SearchResDelegate::nightPixmap(const QPixmap &src, const QVector &imageRects) const +{ + if (src.isNull()) + return src; + + // 搜索结果列表滚动时同一张页面小图会被反复重绘,缓存反色结果避免逐像素重复计算; + // 页面重渲染时 cacheKey 必然变化,无需将蒙版纳入缓存键 + if (m_nightSourceCache.cacheKey() == src.cacheKey() && !m_nightPixmapCache.isNull()) + return m_nightPixmapCache; + + m_nightSourceCache = src; + m_nightPixmapCache = QPixmap::fromImage(NightFilter::applyPage(src.toImage(), imageRects)); + m_nightPixmapCache.setDevicePixelRatio(src.devicePixelRatio()); + return m_nightPixmapCache; +} + diff --git a/reader/sidebar/SearchResDelegate.h b/reader/sidebar/SearchResDelegate.h index 4b53b6890..e3503c439 100644 --- a/reader/sidebar/SearchResDelegate.h +++ b/reader/sidebar/SearchResDelegate.h @@ -7,6 +7,9 @@ #define SEARCHRESDELEGATE_H #include +#include +#include +#include DWIDGET_USE_NAMESPACE /** @@ -37,8 +40,23 @@ class SearchResDelegate : public DStyledItemDelegate */ QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; +private: + /** + * @brief nightPixmap + * 夜间智能滤镜/深色主题下页面小图的反色结果(带缓存): + * 走主干 NightFilter 管线(CIELAB L* 反转 + 图片对象区域跳过 + 扫描页整页反色特判), + * 与缩略图侧栏/主视图观感一致 + * @param src 原始缩略图 + * @param imageRects 图片对象 bbox(与 src 像素对齐,这些区域不反色避免照片负片) + * @return 反色后的缩略图 + */ + QPixmap nightPixmap(const QPixmap &src, const QVector &imageRects = QVector()) const; + private: QAbstractItemView *m_parent = nullptr; + + mutable QPixmap m_nightSourceCache; // 反色缓存的源缩略图 + mutable QPixmap m_nightPixmapCache; // 反色后的缩略图 }; #endif // SEARCHRESDELEGATE_H diff --git a/reader/sidebar/SideBarImageViewModel.cpp b/reader/sidebar/SideBarImageViewModel.cpp index f8ab61d49..3d64d743b 100644 --- a/reader/sidebar/SideBarImageViewModel.cpp +++ b/reader/sidebar/SideBarImageViewModel.cpp @@ -159,6 +159,9 @@ QVariant SideBarImageViewModel::data(const QModelIndex &index, int role) const } else if (role == ImageinfoType_e::IMAGE_PAGE_SIZE) { // qCDebug(appLog) << "Getting page size for index:" << nRow; return QVariant::fromValue(m_sheet->pageSizeByIndex(nRow)); + } else if (role == ImageinfoType_e::IMAGE_NIGHT_MASK) { + // 图片对象 bbox:夜间/深色反色时跳过这些区域,与主视图(BrowserPage)蒙版行为一致 + return QVariant::fromValue(m_sheet->thumbnailImageRects(nRow)); } return QVariant(); } @@ -302,11 +305,11 @@ int SideBarImageViewModel::findItemForAnno(deepin_reader::Annotation *annotation return -1; } -void SideBarImageViewModel::handleRenderThumbnail(int index, QPixmap pixmap) +void SideBarImageViewModel::handleRenderThumbnail(int index, QPixmap pixmap, const QVector &imageRects) { - qCDebug(appLog) << "Handling thumbnail render for page:" << index << "size:" << pixmap.size(); + qCDebug(appLog) << "Handling thumbnail render for page:" << index << "size:" << pixmap.size() << "imageRects:" << imageRects.size(); pixmap.setDevicePixelRatio(dApp->devicePixelRatio()); - m_sheet->setThumbnail(index, pixmap); + m_sheet->setThumbnail(index, pixmap, imageRects); m_pendingUpdatePages.insert(index); if (!m_batchUpdateTimer->isActive()) { diff --git a/reader/sidebar/SideBarImageViewModel.h b/reader/sidebar/SideBarImageViewModel.h index 8e8b877e8..f89011a09 100644 --- a/reader/sidebar/SideBarImageViewModel.h +++ b/reader/sidebar/SideBarImageViewModel.h @@ -10,11 +10,15 @@ #include #include #include +#include +#include namespace deepin_reader { class Annotation; } +Q_DECLARE_METATYPE(QVector) + typedef enum E_SideBar { SIDE_THUMBNIL = 0, SIDE_BOOKMARK, @@ -31,6 +35,7 @@ typedef enum ImageinfoType_e { IMAGE_CONTENT_TEXT = Qt::UserRole + 4, IMAGE_SEARCH_COUNT = Qt::UserRole + 5, IMAGE_PAGE_SIZE = Qt::UserRole + 6, + IMAGE_NIGHT_MASK = Qt::UserRole + 7, //图片对象 bbox 列表(夜间/深色反色时跳过图片区域,与 IMAGE_PIXMAP 像素对齐) } ImageinfoType_e; typedef struct ImagePageInfo_t { @@ -157,8 +162,9 @@ class SideBarImageViewModel : public QAbstractListModel * 处理缩略图 * @param index * @param pixmap + * @param imageRects 图片对象 bbox(与 pixmap 像素对齐,夜间/深色反色跳过图片区域用) */ - void handleRenderThumbnail(int index, QPixmap pixmap); + void handleRenderThumbnail(int index, QPixmap pixmap, const QVector &imageRects = QVector()); public slots: /** diff --git a/reader/sidebar/ThumbnailDelegate.cpp b/reader/sidebar/ThumbnailDelegate.cpp index b4fccc9f7..09d5b0258 100644 --- a/reader/sidebar/ThumbnailDelegate.cpp +++ b/reader/sidebar/ThumbnailDelegate.cpp @@ -5,6 +5,7 @@ #include "ThumbnailDelegate.h" #include "SideBarImageViewModel.h" +#include "NightFilter.h" #include "Utils.h" #include "Application.h" #include "ddlog.h" @@ -41,7 +42,9 @@ void ThumbnailDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt transform.rotate(rotate); - const QPixmap &pixmap = index.data(ImageinfoType_e::IMAGE_PIXMAP).value().transformed(transform); + const QPixmap &rawPixmap = index.data(ImageinfoType_e::IMAGE_PIXMAP).value(); + + const QPixmap &pixmap = rawPixmap.transformed(transform); const int borderRadius = 6; @@ -63,48 +66,20 @@ void ThumbnailDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QPainterPath clipPath; clipPath.addRoundedRect(rect, borderRadius, borderRadius); painter->setClipPath(clipPath); - // 深色系统主题下,缩略图卡片需与侧边栏深色背景协调:将文档原始白底黑字 - // 的缩略图反色为黑底白字;浅色主题保持原样。反色仅在绘制时进行, - // 不修改 DocSheet 中缓存的真实缩略图(始终为白底),避免主题切换时双重反色。 - // 采用与 BrowserPage::applyNightMode 相同的 HSL 亮度反转算法: - // 仅反转 Lightness 通道,保留 Hue/Saturation,避免图片色相偏移 180°。 - // 两端收敛:下限钳制 37(#252525),反转后 ≥192 提亮纯白 - // 白底黑字 → 黑底白字(文字/背景正确反色) - // 彩色图片/链接 → 仅变暗,色相保持 - if (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType) { - QImage img = pixmap.toImage(); - if (!img.isNull()) { - if (img.format() != QImage::Format_ARGB32) - img = img.convertToFormat(QImage::Format_ARGB32); - const int w = img.width(); - const int h = img.height(); - const int kMinLightAfterInvert = 37; // #252525 - const int kMaxLightBoostThreshold = 192; // 0xC0,提亮阈值 - for (int y = 0; y < h; ++y) { - QRgb *line = reinterpret_cast(img.scanLine(y)); - for (int x = 0; x < w; ++x) { - const QRgb px = line[x]; - const int alpha = qAlpha(px); - QColor c = QColor::fromRgb(qRed(px), qGreen(px), qBlue(px)); - int hue, sat, light, dummy; - c.getHsl(&hue, &sat, &light, &dummy); - light = 255 - light; - if (light >= kMaxLightBoostThreshold) - light = 255; - light = qMax(light, kMinLightAfterInvert); - c.setHsl(hue, sat, light); - line[x] = qRgba(c.red(), c.green(), c.blue(), alpha); - } - } - QPixmap invertedPixmap = QPixmap::fromImage(img); - invertedPixmap.setDevicePixelRatio(pixmap.devicePixelRatio()); - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), invertedPixmap); - } else { - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), pixmap); - } - } else { - painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), pixmap); - } + // 缩略图反色只跟随系统深色主题:深色主题下白底文档反转为黑底白字 + // (与书签/注释列表观感一致),浅色主题照常绘制原图。 + // 反色统一走主干夜间滤镜 NightFilter(CIELAB L* 反转,不再用旧 HSL 方案), + // 图片对象区域蒙版随缩略图由渲染线程预取(与主视图同源),照片区域不反色, + // 并含扫描页覆盖率特判(超过阈值整页反色,避免回贴/蒙版异常) + const bool darkTheme = (DTK_NAMESPACE::Gui::DGuiApplicationHelper::instance()->themeType() == DTK_NAMESPACE::Gui::DGuiApplicationHelper::DarkType); + const QVector imageRects = index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + + // 反色结果按未旋转的原始缩略图缓存,再叠加旋转,避免每次重绘都逐像素反色 + const QPixmap displayPixmap = darkTheme + ? nightPixmap(rawPixmap, imageRects).transformed(transform) + : pixmap; + + painter->drawPixmap(rect.x(), rect.y(), rect.width(), rect.height(), displayPixmap); painter->restore(); } @@ -138,6 +113,22 @@ QSize ThumbnailDelegate::sizeHint(const QStyleOptionViewItem &option, const QMod return DStyledItemDelegate::sizeHint(option, index); } +QPixmap ThumbnailDelegate::nightPixmap(const QPixmap &src, const QVector &imageRects) const +{ + if (src.isNull()) + return src; + + // 滚动/选中时同一张缩略图会被反复重绘,缓存反色结果避免逐像素重复计算; + // 缩略图重渲染时 cacheKey 必然变化,无需将蒙版纳入缓存键 + if (m_nightSourceCache.cacheKey() == src.cacheKey() && !m_nightPixmapCache.isNull()) + return m_nightPixmapCache; + + m_nightSourceCache = src; + m_nightPixmapCache = QPixmap::fromImage(NightFilter::applyPage(src.toImage(), imageRects)); + m_nightPixmapCache.setDevicePixelRatio(src.devicePixelRatio()); + return m_nightPixmapCache; +} + void ThumbnailDelegate::drawBookMark(QPainter *painter, const QRect &rect, bool visible) const { // qCDebug(appLog) << "Drawing bookmark at:" << rect; diff --git a/reader/sidebar/ThumbnailDelegate.h b/reader/sidebar/ThumbnailDelegate.h index 258f79018..573611429 100644 --- a/reader/sidebar/ThumbnailDelegate.h +++ b/reader/sidebar/ThumbnailDelegate.h @@ -7,6 +7,7 @@ #define IMAGEVIEWDELEGATE_H #include +#include DWIDGET_USE_NAMESPACE /** @@ -47,8 +48,22 @@ class ThumbnailDelegate : public DStyledItemDelegate */ void drawBookMark(QPainter *painter, const QRect &rect, bool visible) const; + /** + * @brief nightPixmap + * 夜间智能滤镜/深色主题下缩略图的反色结果(带缓存): + * 走主干 NightFilter 管线(CIELAB L* 反转 + 图片对象区域跳过 + 扫描页整页反色特判), + * 与主视图观感一致 + * @param src 原始缩略图 + * @param imageRects 图片对象 bbox(与 src 像素对齐,这些区域不反色避免照片负片) + * @return 反色后的缩略图 + */ + QPixmap nightPixmap(const QPixmap &src, const QVector &imageRects = QVector()) const; + private: QAbstractItemView *m_parent = nullptr; + + mutable QPixmap m_nightSourceCache; // 反色缓存的源缩略图 + mutable QPixmap m_nightPixmapCache; // 反色后的缩略图 }; #endif // IMAGEVIEWDELEGATE_H diff --git a/reader/uiframe/DocSheet.cpp b/reader/uiframe/DocSheet.cpp index e9d13210e..31ef6c014 100644 --- a/reader/uiframe/DocSheet.cpp +++ b/reader/uiframe/DocSheet.cpp @@ -531,10 +531,17 @@ QPixmap DocSheet::thumbnail(int index) return m_thumbnailMap.value(index); } -void DocSheet::setThumbnail(int index, QPixmap pixmap) +QVector DocSheet::thumbnailImageRects(int index) +{ + // qCDebug(appLog) << "thumbnailImageRects"; + return m_thumbnailImageRects.value(index); +} + +void DocSheet::setThumbnail(int index, QPixmap pixmap, const QVector &imageRects) { // qCDebug(appLog) << "setThumbnail"; m_thumbnailMap[index] = pixmap; + m_thumbnailImageRects[index] = imageRects; } void DocSheet::setScaleMode(Dr::ScaleMode mode) diff --git a/reader/uiframe/DocSheet.h b/reader/uiframe/DocSheet.h index 122658a4d..f239607d5 100644 --- a/reader/uiframe/DocSheet.h +++ b/reader/uiframe/DocSheet.h @@ -319,12 +319,21 @@ class DocSheet : public Dtk::Widget::DSplitter */ QPixmap thumbnail(int index); + /** + * @brief thumbnailImageRects + * 获取缩略图对应的图片对象 bbox(夜间/深色蒙版用,与缩略图像素对齐;需要先设置) + * @param index + * @return 该页无图片对象时为空 + */ + QVector thumbnailImageRects(int index); + /** * @brief setThumbnail * @param index * @param pixmap + * @param imageRects 图片对象 bbox(缩略图渲染坐标系,与 pixmap 对齐),随缩略图一同缓存 */ - void setThumbnail(int index, QPixmap pixmap); + void setThumbnail(int index, QPixmap pixmap, const QVector &imageRects = QVector()); /** * @brief openMagnifier @@ -978,6 +987,7 @@ private slots: QString m_uuid; QTemporaryDir *m_tempDir = nullptr; //存放临时数据 QMap m_thumbnailMap; + QMap> m_thumbnailImageRects; //缩略图图片对象 bbox(与 m_thumbnailMap 同生命周期) bool m_documentChanged = false; bool m_bookmarkChanged = false; diff --git a/tests/browser/ut_pagerenderthread.cpp b/tests/browser/ut_pagerenderthread.cpp index 8f0294f75..c7904c299 100644 --- a/tests/browser/ut_pagerenderthread.cpp +++ b/tests/browser/ut_pagerenderthread.cpp @@ -11,6 +11,7 @@ #include "stub.h" #include +#include #include #include #include @@ -82,7 +83,7 @@ static void handleAnnotationLoaded_stub(const QList g_funcName = __FUNCTION__; } -static void handleRenderThumbnail_stub(int, QPixmap) +static void handleRenderThumbnail_stub(int, QPixmap, const QVector &) { g_funcName = __FUNCTION__; } @@ -485,5 +486,132 @@ TEST_F(TestPageRenderThread, UT_PageRenderThread_appendTask_Close) m_tester->m_closeTasks.clear(); } +/**********execNextDocPageThumbnailTask*************/ + +// DocSheet::existSheetByUuid 档:任务校验通过 +static bool existSheetByUuid_true_stub(const QString &) +{ + return true; +} + +// execNextDocPageThumbnailTask: 预取图片对象 bbox(夜间/深色蒙版用)并随任务转发给模型 +static QVector g_imageRectsResult; // getImageObjectRects 返回值(输入) +static QVector g_forwardedRects; // handleRenderThumbnail 收到的 bbox(输出) +static QVector getImageObjectRects_stub(int, int, int) +{ + return g_imageRectsResult; +} + +static bool opened_true_stub() +{ + return true; +} + +static bool opened_false_stub() +{ + return false; +} + +// 足够大的可写静态存储充当假 sheet(同 onDocOpenTask_002 做法) +static unsigned char fake_sheet_storage[8192] = {}; +// 假 model:任务经信号转发到 onDocPageThumbnailTask 时以 existSheet+model 调 handleRenderThumbnail +static unsigned char fake_model_storage[8192] = {}; + +// 空删除器:测试结束不删除 renderer,避免 ~SheetRenderer 往任务池塞关闭任务 +// (会 start 真实工作线程,与测试拆解竞态导致堆损坏) +static void thumbnailRendererNoopDeleter(SheetRenderer *) {} + +static QImage getImage_stub(int, int, int, const QRect &) +{ + QImage img(174, 174, QImage::Format_ARGB32_Premultiplied); + img.fill(Qt::white); + return img; +} + +// 成员函数桩:首参占位 this(Itanium ABI 下成员函数≈隐式 this 的自由函数), +// 否则实参寄存器整体错位,解引用 rects 会读到垃圾内存 +static void handleRenderThumbnail_rects_stub(SideBarImageViewModel *, int, QPixmap, const QVector &rects) +{ + g_funcName = "handleRenderThumbnail_rects_stub"; + g_forwardedRects = rects; +} + +TEST_F(TestPageRenderThread, UT_PageRenderThread_execNextDocPageThumbnailTask_prefetchesImageRects) +{ + // renderStub 由 RAII 管理恢复,避免影响后续用例 + Stub s; + const QVector rects { QRectF(1, 2, 3, 4) }; + g_imageRectsResult = rects; + g_funcName.clear(); + + s.set(ADDR(DocSheet, existSheetByUuid), existSheetByUuid_true_stub); + // 任务经信号转发到 onDocPageThumbnailTask,其中以 existSheet 校验后调 handleRenderThumbnail + s.set(ADDR(DocSheet, existSheet), existSheet_true_stub); + s.set(ADDR(SheetRenderer, getImage), getImage_stub); + s.set(ADDR(SheetRenderer, opened), opened_true_stub); + s.set(ADDR(SheetRenderer, getImageObjectRects), getImageObjectRects_stub); + s.set(ADDR(SideBarImageViewModel, handleRenderThumbnail), handleRenderThumbnail_rects_stub); + + DocPageThumbnailTask task; + task.sheet = reinterpret_cast(fake_sheet_storage); // 成员调用均已被桩 + task.uuid = "ut-sheet-uuid"; // 与 uuid_stub 一致,校验通过 + // renderer 为空会提前结束任务;空删除器避免析构竞态(见 thumbnailRendererNoopDeleter) + task.renderer = QSharedPointer(renderer_stub(), &thumbnailRendererNoopDeleter); + task.model = reinterpret_cast(fake_model_storage); // 槽内 model->handleRenderThumbnail 已被桩 + task.index = 0; + m_tester->m_pageThumbnailTasks.append(task); + + EXPECT_TRUE(m_tester->execNextDocPageThumbnailTask()); + EXPECT_TRUE(m_tester->m_pageThumbnailTasks.isEmpty()); + // sigDocPageThumbnailTaskFinished 为 QueuedConnection,手动派发队列中的槽调用 + QEventLoop loop; + QMetaObject::invokeMethod(&loop, "quit", Qt::QueuedConnection); + loop.exec(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + // 任务转发到 handleRenderThumbnail,且预取的 bbox 原样传给模型 + EXPECT_TRUE(g_funcName == "handleRenderThumbnail_rects_stub"); + EXPECT_TRUE(g_forwardedRects == rects); + g_imageRectsResult.clear(); + g_forwardedRects.clear(); +} + +// 渲染器未打开时不预取 bbox,任务照常转发(imageRects 为空) +TEST_F(TestPageRenderThread, UT_PageRenderThread_execNextDocPageThumbnailTask_skipRectsWhenNotOpened) +{ + Stub s; + static const QVector kEmpty; + g_imageRectsResult = QVector() << QRectF(9, 9, 9, 9); // 若被误取会带入任务 + g_funcName.clear(); + + s.set(ADDR(DocSheet, existSheetByUuid), existSheetByUuid_true_stub); + // 任务经信号转发到 onDocPageThumbnailTask,其中以 existSheet 校验后调 handleRenderThumbnail + s.set(ADDR(DocSheet, existSheet), existSheet_true_stub); + s.set(ADDR(SheetRenderer, getImage), getImage_stub); + s.set(ADDR(SheetRenderer, opened), opened_false_stub); + s.set(ADDR(SheetRenderer, getImageObjectRects), getImageObjectRects_stub); + s.set(ADDR(SideBarImageViewModel, handleRenderThumbnail), handleRenderThumbnail_rects_stub); + + DocPageThumbnailTask task; + task.sheet = reinterpret_cast(fake_sheet_storage); + task.uuid = "ut-sheet-uuid"; + // renderer 为空会提前结束任务;空删除器避免析构竞态(见 thumbnailRendererNoopDeleter) + task.renderer = QSharedPointer(renderer_stub(), &thumbnailRendererNoopDeleter); + task.model = reinterpret_cast(fake_model_storage); // 槽内 model->handleRenderThumbnail 已被桩 + task.index = 0; + m_tester->m_pageThumbnailTasks.append(task); + + EXPECT_TRUE(m_tester->execNextDocPageThumbnailTask()); + // 同上:派发队列中的槽调用后再校验 + QEventLoop loop2; + QMetaObject::invokeMethod(&loop2, "quit", Qt::QueuedConnection); + loop2.exec(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + EXPECT_TRUE(g_funcName == "handleRenderThumbnail_rects_stub"); + // 未预取:转发给模型的 bbox 应为空 + EXPECT_TRUE(g_forwardedRects.isEmpty()); + g_imageRectsResult.clear(); + g_forwardedRects.clear(); +} + // (NullInstance test removed: modifying s_quitForever corrupts global state // and causes segfaults in subsequent DocSheet destructor tests.) diff --git a/tests/sidebar/ut_bookmarkdelegate.cpp b/tests/sidebar/ut_bookmarkdelegate.cpp index b87442b33..debdc0aba 100644 --- a/tests/sidebar/ut_bookmarkdelegate.cpp +++ b/tests/sidebar/ut_bookmarkdelegate.cpp @@ -14,6 +14,45 @@ #include #include #include +#include + +#include +#include + +DGUI_USE_NAMESPACE + +namespace { + +// 未打开文档时渲染器没有页面尺寸,桩掉以得到稳定的缩略图卡片区域 +QSizeF pageSizeByIndex_stub(DocSheet *, int) +{ + return QSizeF(210, 297); +} + +// 测试期间屏蔽 dtk 主题持久化,避免 setPaletteType 污染用户配置 +class ThemeGuard +{ +public: + explicit ThemeGuard(DGuiApplicationHelper::ColorType type) + { + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, true); + m_previous = DGuiApplicationHelper::instance()->themeType(); + DGuiApplicationHelper::instance()->setPaletteType(type); + } + ~ThemeGuard() + { + if (m_previous != DGuiApplicationHelper::UnknownType) + DGuiApplicationHelper::instance()->setPaletteType(m_previous); + else + DGuiApplicationHelper::instance()->setPaletteType(DGuiApplicationHelper::LightType); + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, false); + } + +private: + DGuiApplicationHelper::ColorType m_previous = DGuiApplicationHelper::UnknownType; +}; + +} // namespace class UT_BookMarkDelegate : public ::testing::Test { @@ -59,3 +98,103 @@ TEST_F(UT_BookMarkDelegate, UT_BookMarkDelegate_paint) EXPECT_TRUE(m_tester->m_parent == m_pView); delete painter; } + +namespace { + +// 绘制到离屏画布:页面 (210,297) 按 62×62 等比缩放后绘制在 +// (option.rect.x()+10, 垂直居中) 处,返回缩略图卡片内相对坐标 (rx, ry) 处像素 +QColor paintPixelAt(UT_BookMarkDelegate *fixture, const QPixmap &thumb, int rx, int ry) +{ + fixture->m_pView->getImageModel()->insertPageIndex(0); + fixture->m_sheet->setThumbnail(0, thumb); + + const QModelIndex index = fixture->m_pView->getImageModel()->index(0, 0); + if (!index.isValid()) + return QColor(); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); // 红底便于观察是否被绘制覆盖 + QPainter painter(&canvas); + fixture->m_tester->paint(&painter, option, index); + painter.end(); + + // 缩略图卡片:宽 44(=62*210/297)、高 62,起点 (10, 150-31) + return canvas.pixelColor(10 + rx, 150 - 31 + ry); +} + +} // namespace + +// 浅色主题:书签列表保持文档原始白底 +TEST_F(UT_BookMarkDelegate, UT_BookMarkDelegate_paintLightThemeKeepsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard light(DGuiApplicationHelper::LightType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 22, 31); + + EXPECT_GT(center.lightness(), 239); +} + +// 深色主题:白底反转为深色(走 NightFilter 主干滤镜) +TEST_F(UT_BookMarkDelegate, UT_BookMarkDelegate_paintDarkThemeInvertsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 22, 31); + + EXPECT_LT(center.lightness(), 32); +} + +// 深色主题 + 图片对象蒙版:照片区域保持原始像素,白底反转为深色 +TEST_F(UT_BookMarkDelegate, UT_BookMarkDelegate_paintDarkThemeWithNightMaskKeepsPhotoPixels) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + // 左半为纯色照片块(70,70,70),右半白底;蒙版罩住照片(源缩略图 174 像素坐标) + QPixmap mixed(174, 246); + mixed.fill(Qt::white); + QPainter p(&mixed); + p.fillRect(0, 0, 87, 246, QColor(70, 70, 70)); + p.end(); + + m_pView->getImageModel()->insertPageIndex(0); + m_sheet->setThumbnail(0, mixed, QVector() << QRectF(0, 0, 87, 246)); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + QPainter painter(&canvas); + m_tester->paint(&painter, option, index); + painter.end(); + + // 蒙版区域(照片中心,避开缩放采样边界)像素保持不变 + EXPECT_EQ(canvas.pixelColor(10 + 15, 150), QColor(70, 70, 70)); + // 蒙版外白底已反转为深色 + EXPECT_LT(canvas.pixelColor(10 + 40, 150).lightness(), 32); +} diff --git a/tests/sidebar/ut_notesdelegate.cpp b/tests/sidebar/ut_notesdelegate.cpp index 329efeadb..a73c402bb 100644 --- a/tests/sidebar/ut_notesdelegate.cpp +++ b/tests/sidebar/ut_notesdelegate.cpp @@ -14,6 +14,45 @@ #include #include #include +#include + +#include +#include + +DGUI_USE_NAMESPACE + +namespace { + +// 未打开文档时渲染器没有页面尺寸,桩掉以得到稳定的缩略图卡片区域 +QSizeF pageSizeByIndex_stub(DocSheet *, int) +{ + return QSizeF(210, 297); +} + +// 测试期间屏蔽 dtk 主题持久化,避免 setPaletteType 污染用户配置 +class ThemeGuard +{ +public: + explicit ThemeGuard(DGuiApplicationHelper::ColorType type) + { + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, true); + m_previous = DGuiApplicationHelper::instance()->themeType(); + DGuiApplicationHelper::instance()->setPaletteType(type); + } + ~ThemeGuard() + { + if (m_previous != DGuiApplicationHelper::UnknownType) + DGuiApplicationHelper::instance()->setPaletteType(m_previous); + else + DGuiApplicationHelper::instance()->setPaletteType(DGuiApplicationHelper::LightType); + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, false); + } + +private: + DGuiApplicationHelper::ColorType m_previous = DGuiApplicationHelper::UnknownType; +}; + +} // namespace class UT_NotesDelegate : public ::testing::Test { @@ -60,6 +99,106 @@ TEST_F(UT_NotesDelegate, UT_NotesDelegate_paint) delete painter; } +namespace { + +// 绘制到离屏画布:页面 (210,297) 按 62×62 等比缩放后绘制在 +// (option.rect.x()+10, 垂直居中) 处,返回缩略图卡片内相对坐标 (rx, ry) 处像素 +QColor paintPixelAt(UT_NotesDelegate *fixture, const QPixmap &thumb, int rx, int ry) +{ + fixture->m_pView->getImageModel()->insertPageIndex(0); + fixture->m_sheet->setThumbnail(0, thumb); + + const QModelIndex index = fixture->m_pView->getImageModel()->index(0, 0); + if (!index.isValid()) + return QColor(); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); // 红底便于观察是否被绘制覆盖 + QPainter painter(&canvas); + fixture->m_tester->paint(&painter, option, index); + painter.end(); + + // 缩略图卡片:宽 44(=62*210/297)、高 62,起点 (10, 150-31) + return canvas.pixelColor(10 + rx, 150 - 31 + ry); +} + +} // namespace + +// 浅色主题:注释列表保持文档原始白底 +TEST_F(UT_NotesDelegate, UT_NotesDelegate_paintLightThemeKeepsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard light(DGuiApplicationHelper::LightType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 22, 31); + + EXPECT_GT(center.lightness(), 239); +} + +// 深色主题:白底反转为深色(走 NightFilter 主干滤镜) +TEST_F(UT_NotesDelegate, UT_NotesDelegate_paintDarkThemeInvertsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 22, 31); + + EXPECT_LT(center.lightness(), 32); +} + +// 深色主题 + 图片对象蒙版:照片区域保持原始像素,白底反转为深色 +TEST_F(UT_NotesDelegate, UT_NotesDelegate_paintDarkThemeWithNightMaskKeepsPhotoPixels) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + // 左半为纯色照片块(70,70,70),右半白底;蒙版罩住照片(源缩略图 174 像素坐标) + QPixmap mixed(174, 246); + mixed.fill(Qt::white); + QPainter p(&mixed); + p.fillRect(0, 0, 87, 246, QColor(70, 70, 70)); + p.end(); + + m_pView->getImageModel()->insertPageIndex(0); + m_sheet->setThumbnail(0, mixed, QVector() << QRectF(0, 0, 87, 246)); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + QPainter painter(&canvas); + m_tester->paint(&painter, option, index); + painter.end(); + + // 蒙版区域(照片中心,避开缩放采样边界)像素保持不变 + EXPECT_EQ(canvas.pixelColor(10 + 15, 150), QColor(70, 70, 70)); + // 蒙版外白底已反转为深色 + EXPECT_LT(canvas.pixelColor(10 + 40, 150).lightness(), 32); +} + TEST_F(UT_NotesDelegate, UT_NotesDelegate_sizeHint) { m_pView->getImageModel()->insertPageIndex(1); diff --git a/tests/sidebar/ut_searchresdelegate.cpp b/tests/sidebar/ut_searchresdelegate.cpp index 45b5142c5..0b1e710b3 100644 --- a/tests/sidebar/ut_searchresdelegate.cpp +++ b/tests/sidebar/ut_searchresdelegate.cpp @@ -1,12 +1,12 @@ -// Copyright (C) 2019-2026 ~ 2020 UnionTech Software Technology Co.,Ltd. -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// Copyright (C) 2019 ~ 2026 Uniontech Software Technology Co.,Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later #include "SearchResDelegate.h" -#include "DocSheet.h" #include "SideBarImageListview.h" #include "SideBarImageViewModel.h" +#include "DocSheet.h" #include "stub.h" @@ -14,6 +14,45 @@ #include #include #include +#include + +#include +#include + +DGUI_USE_NAMESPACE + +namespace { + +// 未打开文档时渲染器没有页面尺寸,桩掉以得到稳定的搜索结果卡片区域 +QSizeF pageSizeByIndex_stub(DocSheet *, int) +{ + return QSizeF(210, 297); +} + +// 测试期间屏蔽 dtk 主题持久化,避免 setPaletteType 污染用户配置 +class ThemeGuard +{ +public: + explicit ThemeGuard(DGuiApplicationHelper::ColorType type) + { + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, true); + m_previous = DGuiApplicationHelper::instance()->themeType(); + DGuiApplicationHelper::instance()->setPaletteType(type); + } + ~ThemeGuard() + { + if (m_previous != DGuiApplicationHelper::UnknownType) + DGuiApplicationHelper::instance()->setPaletteType(m_previous); + else + DGuiApplicationHelper::instance()->setPaletteType(DGuiApplicationHelper::LightType); + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, false); + } + +private: + DGuiApplicationHelper::ColorType m_previous = DGuiApplicationHelper::UnknownType; +}; + +} // namespace class UT_SearchResDelegate : public ::testing::Test { @@ -27,6 +66,7 @@ class UT_SearchResDelegate : public ::testing::Test strPath += "/files/1.pdf"; m_sheet = new DocSheet(Dr::PDF, strPath, nullptr); m_pView = new SideBarImageListView(m_sheet); + m_pView->setListType(E_SideBar::SIDE_SEARCH); m_tester = new SearchResDelegate(m_pView); m_pView->setItemDelegate(m_tester); m_tester->disconnect(); @@ -60,6 +100,107 @@ TEST_F(UT_SearchResDelegate, UT_SearchResDelegate_paint) delete painter; } +namespace { + +// 绘制到离屏画布:页面 (210,297) 按 62×62 等比缩放后绘制在 +// (option.rect.x()+10, 垂直居中) 处,返回卡片内相对坐标 (rx, ry) 处像素 +QColor paintPixelAt(UT_SearchResDelegate *fixture, const QPixmap &thumb, int rx, int ry) +{ + fixture->m_pView->getImageModel()->insertPageIndex(0); + fixture->m_sheet->setThumbnail(0, thumb); + + const QModelIndex index = fixture->m_pView->getImageModel()->index(0, 0); + if (!index.isValid()) + return QColor(); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); // 红底便于观察是否被绘制覆盖 + QPainter painter(&canvas); + fixture->m_tester->paint(&painter, option, index); + painter.end(); + + // 搜索结果卡片:宽 43(=62*210/297)、高 62,起点 (10, 150-31) + return canvas.pixelColor(10 + rx, 150 - 31 + ry); +} + +} // namespace + +// 浅色主题:搜索结果页小图保持文档原始白底 +TEST_F(UT_SearchResDelegate, UT_SearchResDelegate_paintLightThemeKeepsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard light(DGuiApplicationHelper::LightType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 20, 31); + + EXPECT_GT(center.lightness(), 239); +} + +// 深色主题:触发搜索后结果页小图同样反转为深色(走 NightFilter 主干滤镜), +// 不能再显示原始白底 +TEST_F(UT_SearchResDelegate, UT_SearchResDelegate_paintDarkThemeInvertsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintPixelAt(this, whiteThumb, 20, 31); + + EXPECT_LT(center.lightness(), 32); +} + +// 深色主题 + 图片对象蒙版:搜索结果页小图中照片区域保持原始像素,白底反转为深色 +TEST_F(UT_SearchResDelegate, UT_SearchResDelegate_paintDarkThemeWithNightMaskKeepsPhotoPixels) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + // 左半为纯色照片块(70,70,70),右半白底;蒙版罩住照片(源缩略图 174 像素坐标) + QPixmap mixed(174, 246); + mixed.fill(Qt::white); + QPainter p(&mixed); + p.fillRect(0, 0, 87, 246, QColor(70, 70, 70)); + p.end(); + + m_pView->getImageModel()->insertPageIndex(0); + m_sheet->setThumbnail(0, mixed, QVector() << QRectF(0, 0, 87, 246)); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 200, 300); + + QImage canvas(200, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + QPainter painter(&canvas); + m_tester->paint(&painter, option, index); + painter.end(); + + // 蒙版区域(照片中心,避开缩放采样边界)像素保持不变 + EXPECT_EQ(canvas.pixelColor(10 + 10, 150), QColor(70, 70, 70)); + // 蒙版外白底已反转为深色 + EXPECT_LT(canvas.pixelColor(10 + 35, 150).lightness(), 32); +} + TEST_F(UT_SearchResDelegate, UT_SearchResDelegate_sizeHint) { m_pView->getImageModel()->insertPageIndex(1); diff --git a/tests/sidebar/ut_sidebarimageviewmodel.cpp b/tests/sidebar/ut_sidebarimageviewmodel.cpp index 20b38cee0..1ce5ac713 100644 --- a/tests/sidebar/ut_sidebarimageviewmodel.cpp +++ b/tests/sidebar/ut_sidebarimageviewmodel.cpp @@ -195,6 +195,38 @@ TEST_F(TestSideBarImageViewModel, testhandleRenderThumbnail) m_tester->handleRenderThumbnail(0, QPixmap()); } +// handleRenderThumbnail 第三参(图片对象 bbox)需存入 DocSheet,供 data(IMAGE_NIGHT_MASK) 读取 +TEST_F(TestSideBarImageViewModel, testhandleRenderThumbnailStoresImageRects) +{ + const QVector rects { QRectF(1, 2, 3, 4), QRectF(5, 6, 7, 8) }; + QPixmap thumb(174, 174); + thumb.fill(Qt::white); + + m_tester->handleRenderThumbnail(0, thumb, rects); + + EXPECT_TRUE(m_sheet->thumbnailImageRects(0) == rects); + + // data(IMAGE_NIGHT_MASK) 返回同一份 bbox,供代理反色时跳过图片区域 + m_tester->insertPageIndex(0); + const QModelIndex index = m_tester->index(0, 0); + ASSERT_TRUE(index.isValid()); + const QVector got = + index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + EXPECT_TRUE(got == rects); +} + +// 未设置蒙版时 IMAGE_NIGHT_MASK 返回空列表(整页反色,不跳过图片区域) +TEST_F(TestSideBarImageViewModel, testImageNightMaskDefaultsEmpty) +{ + m_tester->insertPageIndex(0); + const QModelIndex index = m_tester->index(0, 0); + ASSERT_TRUE(index.isValid()); + const QVector got = + index.data(ImageinfoType_e::IMAGE_NIGHT_MASK).value>(); + EXPECT_TRUE(got.isEmpty()); + EXPECT_TRUE(m_sheet->thumbnailImageRects(0).isEmpty()); +} + TEST_F(TestSideBarImageViewModel, testonBatchUpdateTimer) { // Trigger onBatchUpdateTimer directly diff --git a/tests/sidebar/ut_thumbnaildelegate.cpp b/tests/sidebar/ut_thumbnaildelegate.cpp index b0f3be0d2..c8da55ee0 100644 --- a/tests/sidebar/ut_thumbnaildelegate.cpp +++ b/tests/sidebar/ut_thumbnaildelegate.cpp @@ -5,6 +5,7 @@ #include "ThumbnailDelegate.h" #include "DocSheet.h" +#include "EyeProtectionManager.h" #include "SideBarImageListview.h" #include "SideBarImageViewModel.h" @@ -14,6 +15,45 @@ #include #include #include +#include + +#include +#include + +DGUI_USE_NAMESPACE + +namespace { + +// 未打开文档时渲染器没有页面尺寸,桩掉以得到稳定的缩略图卡片区域 +QSizeF pageSizeByIndex_stub(DocSheet *, int) +{ + return QSizeF(210, 297); +} + +// 测试期间屏蔽 dtk 主题持久化,避免 setPaletteType 污染用户配置 +class ThemeGuard +{ +public: + explicit ThemeGuard(DGuiApplicationHelper::ColorType type) + { + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, true); + m_previous = DGuiApplicationHelper::instance()->themeType(); + DGuiApplicationHelper::instance()->setPaletteType(type); + } + ~ThemeGuard() + { + if (m_previous != DGuiApplicationHelper::UnknownType) + DGuiApplicationHelper::instance()->setPaletteType(m_previous); + else + DGuiApplicationHelper::instance()->setPaletteType(DGuiApplicationHelper::LightType); + DGuiApplicationHelper::setAttribute(DGuiApplicationHelper::DontSaveApplicationTheme, false); + } + +private: + DGuiApplicationHelper::ColorType m_previous = DGuiApplicationHelper::UnknownType; +}; + +} // namespace class UT_ThumbnailDelegate : public ::testing::Test { @@ -84,3 +124,156 @@ TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_sizeHint) QSize size = m_tester->sizeHint(option, index); EXPECT_FALSE(size.isEmpty()); } + +namespace { + +// 绘制到离屏画布,返回画面中心的像素(缩略图卡片正中) +QColor paintCenterPixel(UT_ThumbnailDelegate *fixture, const QPixmap &thumb) +{ + fixture->m_pView->getImageModel()->insertPageIndex(0); + fixture->m_sheet->setThumbnail(0, thumb); + + const QModelIndex index = fixture->m_pView->getImageModel()->index(0, 0); + if (!index.isValid()) + return QColor(); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 240, 300); + + QImage canvas(240, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); // 红底便于观察是否被绘制覆盖 + QPainter painter(&canvas); + fixture->m_tester->paint(&painter, option, index); + painter.end(); + return canvas.pixelColor(120, 150); +} + +} // namespace + +// 缩略图外观只跟随系统深浅主题(不再跟随护眼模式): +// 浅色主题保持文档原始白底,深色主题反转为深色底 +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintLightThemeKeepsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard light(DGuiApplicationHelper::LightType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintCenterPixel(this, whiteThumb); + + EXPECT_GT(center.lightness(), 239); +} + +// 深色主题下白底反转为深色(与 BookMark/Notes 列表观感一致) +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintDarkThemeInvertsWhitePage) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintCenterPixel(this, whiteThumb); + + EXPECT_LT(center.lightness(), 32); +} + +// 护眼模式不再影响缩略图:夜间护眼开启时浅色主题下仍绘制原始白底 +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintIgnoresEyeProtectionMode) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard light(DGuiApplicationHelper::LightType); + + const EyeProtectionManager::Mode previousMode = EyeProtectionManager::instance()->mode(); + EyeProtectionManager::instance()->setMode(EyeProtectionManager::Night); + + QPixmap whiteThumb(174, 246); + whiteThumb.fill(Qt::white); + const QColor center = paintCenterPixel(this, whiteThumb); + + EyeProtectionManager::instance()->setMode(previousMode); + + EXPECT_GT(center.lightness(), 239); +} + +// 深色主题 + 图片对象蒙版:蒙版区域(照片)保持原始像素,非蒙版区域(白底)反转为深色 +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintDarkThemeWithNightMaskKeepsPhotoPixels) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + // 左半为纯色照片块(70,70,70),右半白底;蒙版仅罩住照片,覆盖率 < 70% 不触发扫描页整页反色 + QPixmap mixed(174, 246); + mixed.fill(Qt::white); + QPainter p(&mixed); + p.fillRect(0, 0, 87, 246, QColor(70, 70, 70)); + p.end(); + + m_pView->getImageModel()->insertPageIndex(0); + m_sheet->setThumbnail(0, mixed, QVector() << QRectF(0, 0, 87, 246)); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 240, 300); + + QImage canvas(240, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + QPainter painter(&canvas); + m_tester->paint(&painter, option, index); + painter.end(); + + // 蒙版区域像素保持不变(imageDimFactor=1 时 dimPixelImage 不修改像素值) + EXPECT_EQ(canvas.pixelColor(60, 150), QColor(70, 70, 70)); + // 蒙版外白底已反转为深色 + EXPECT_LT(canvas.pixelColor(180, 150).lightness(), 32); +} + +// 深色主题 + 扫描页(图片 bbox 覆盖率 > 70%):整页反色,蒙版不生效 +TEST_F(UT_ThumbnailDelegate, UT_ThumbnailDelegate_paintDarkThemeScannedPageFullInvert) +{ + Stub s; + typedef QSizeF(*fptr)(DocSheet *, int); + fptr pageSizeFunc = (fptr)(&DocSheet::pageSizeByIndex); + s.set(pageSizeFunc, pageSizeByIndex_stub); + + ThemeGuard dark(DGuiApplicationHelper::DarkType); + + // 整页照片(米黄纸面色):bbox 覆盖率 100%,按扫描页整页反色,深色像素反转后提亮为白 + QPixmap scanned(174, 246); + scanned.fill(QColor(60, 60, 60)); + + m_pView->getImageModel()->insertPageIndex(0); + m_sheet->setThumbnail(0, scanned, QVector() << QRectF(0, 0, 174, 246)); + + const QModelIndex index = m_pView->getImageModel()->index(0, 0); + ASSERT_TRUE(index.isValid()); + + QStyleOptionViewItem option; + option.rect = QRect(0, 0, 240, 300); + + QImage canvas(240, 300, QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::red); + QPainter painter(&canvas); + m_tester->paint(&painter, option, index); + painter.end(); + + // 中心点:若蒙版生效则保持 (60,60,60);整页反色后亮度提升,应明显偏亮 + EXPECT_GT(canvas.pixelColor(120, 150).lightness(), 128); +} diff --git a/tests/uiframe/ut_docsheet.cpp b/tests/uiframe/ut_docsheet.cpp index 9049bd468..5a9370a78 100644 --- a/tests/uiframe/ut_docsheet.cpp +++ b/tests/uiframe/ut_docsheet.cpp @@ -728,6 +728,27 @@ TEST_F(TestDocSheet, UT_DocSheet_setThumbnail_001) EXPECT_TRUE(m_tester->m_thumbnailMap.size() == 1); } +// setThumbnail 带 bbox:thumbnailImageRects 返回同一份蒙版 +TEST_F(TestDocSheet, UT_DocSheet_thumbnailImageRects_001) +{ + EXPECT_TRUE(m_tester->thumbnailImageRects(1).isEmpty()); + + const QVector rects { QRectF(0, 0, 10, 20), QRectF(1, 1, 2, 2) }; + m_tester->setThumbnail(1, QPixmap(10, 20), rects); + EXPECT_TRUE(m_tester->thumbnailImageRects(1) == rects); +} + +// 不带 bbox 重设缩略图时,旧蒙版需被清空(避免残留旧页面蒙版) +TEST_F(TestDocSheet, UT_DocSheet_setThumbnailClearsStaleImageRects) +{ + const QVector rects { QRectF(0, 0, 10, 20) }; + m_tester->setThumbnail(2, QPixmap(10, 20), rects); + EXPECT_FALSE(m_tester->thumbnailImageRects(2).isEmpty()); + + m_tester->setThumbnail(2, QPixmap(10, 20)); + EXPECT_TRUE(m_tester->thumbnailImageRects(2).isEmpty()); +} + TEST_F(TestDocSheet, UT_DocSheet_setScaleMode_001) { m_tester->m_operation.scaleMode = Dr::FitToPageWorHMode;