From 6848c5a5d40e3c662c4554753e6dbfe2d45b9791 Mon Sep 17 00:00:00 2001 From: zhanghongyuan Date: Thu, 10 Sep 2026 16:08:05 +0800 Subject: [PATCH] feat(reader): night mode object mask keeps images undistorted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert page content via CIELAB lightness flip with a two-level 65^3/128^3 RGB LUT (28x vs exact path, 13ms per 2M-pixel page), keep image objects out of the inverted area via a grayscale mask fetched on the render thread, and generate the night pixmap asynchronously (QtConcurrent) so paint never blocks. While the async job is in flight, paint composes an instant approximate night frame from the latest day render (fast full-page Difference invert plus image rects pasted back), so zooming responds in step with day mode instead of stretching a stale night pixmap. Mask rects are recorded with their fetch render size and scaled to the current render size before building the mask, so the mask stays aligned after zoom/rotation. Page filter only activates in EyeProtection Night mode; image dim factor defaults to 1.0 so photos keep original colors under the page overlay. 夜间模式页面内容按 CIELAB 明度反转,两级 65^3/128^3 RGB LUT 提速 28 倍(2M 像素 13ms);图片对象经灰度蒙版排除在反色区外,bbox 由 渲染线程随整页渲染预取并记录对应渲染尺寸,构建蒙版前按当前渲染 尺寸等比换算,缩放/旋转后蒙版不错位。夜间图经 QtConcurrent 异步 生成,paint 不阻塞;任务在途期间用最新日间渲染即时合成近似夜间帧 (整页 Difference 快速反相 + 图片 bbox 回贴原图),缩放与日间模式 同拍响应,不再拉伸旧夜间图,照片不出现负片。页面滤镜仅在护眼 夜间档生效;侧边栏缩略图仍随系统深色主题反色;图片调暗系数默认 1.0,照片保持原色。 Log: 夜间模式图片对象蒙版反色不失真 PMS: BUG-376493 Influence: 夜间模式下文字/背景反色为黑底白字,图片区域保持原色不 被反色,扫描页(图片覆盖>70%)整页反色;缩放/旋转与日间模式同拍 响应,过渡帧与最终渲染蒙版始终对齐;页面滤镜仅在夜间档生效, 经典/绿色护眼不受影响;侧边栏缩略图逻辑不变,仍随系统主题变化; DOCX/DJVU 无对象信息走整页反色。 Signed-off-by: zhanghongyuan --- 3rdparty/deepin-pdfium/include/dpdfpage.h | 11 +- 3rdparty/deepin-pdfium/src/dpdfpage.cpp | 60 ++++ reader/browser/BrowserPage.cpp | 179 +++++++--- reader/browser/BrowserPage.h | 31 +- reader/browser/NightFilter.cpp | 319 ++++++++++++++++++ reader/browser/NightFilter.h | 73 ++++ reader/browser/PageRenderThread.cpp | 11 + reader/browser/PageRenderThread.h | 2 + reader/browser/browser.pri | 2 + reader/document/Model.h | 6 + reader/document/PDFModel.cpp | 7 + reader/document/PDFModel.h | 2 + reader/eyeprotection/EyeProtectionManager.cpp | 33 ++ reader/eyeprotection/EyeProtectionManager.h | 30 ++ reader/uiframe/SheetRenderer.cpp | 10 + reader/uiframe/SheetRenderer.h | 9 + 16 files changed, 733 insertions(+), 52 deletions(-) create mode 100644 reader/browser/NightFilter.cpp create mode 100644 reader/browser/NightFilter.h diff --git a/3rdparty/deepin-pdfium/include/dpdfpage.h b/3rdparty/deepin-pdfium/include/dpdfpage.h index fccb585bc..f7cf6f40c 100755 --- a/3rdparty/deepin-pdfium/include/dpdfpage.h +++ b/3rdparty/deepin-pdfium/include/dpdfpage.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: LGPL-3.0-or-later @@ -50,6 +50,15 @@ class DPdfPage : public QObject */ QImage image(int width, int height, QRect slice = QRect()); + /** + * @brief 获取页面上图片对象的包围盒(夜间模式对象蒙版用,实验) + * 坐标与 image(width, height) 的整页渲染输出像素一一对齐(已处理页面自身旋转) + * @param width (in pixel) 目标渲染宽 + * @param height (in pixel) 目标渲染高 + * @return 图片对象包围盒列表(像素坐标,Qt 坐标系);无图片对象返回空 + */ + QVector imageObjectRects(int width, int height); + /** * @brief 字符数 * @return diff --git a/3rdparty/deepin-pdfium/src/dpdfpage.cpp b/3rdparty/deepin-pdfium/src/dpdfpage.cpp index 631ac2ced..8758672b0 100755 --- a/3rdparty/deepin-pdfium/src/dpdfpage.cpp +++ b/3rdparty/deepin-pdfium/src/dpdfpage.cpp @@ -649,6 +649,66 @@ int DPdfPage::countChars() return FPDFText_CountChars(d_func()->m_textPage); } +QVector DPdfPage::imageObjectRects(int width, int height) +{ + QVector rects; + + if (nullptr == d_func()->m_doc || width <= 0 || height <= 0) + return rects; + + DPdfMutexLocker locker("DPdfPage::imageObjectRects index = " + QString::number(index())); + + // 目标像素尺寸对应的缩放(与 image(width,height) 整页渲染对齐) + const QSizeF viewPx = d_func()->sizeF(); + + if (viewPx.width() <= 0 || viewPx.height() <= 0) + return rects; + + // 对象列表需要解析内容(与 image() 一致用 FPDF_LoadPage); + // 取完后立即关闭,不缓存句柄,避免与渲染线程的页面句柄生命周期冲突 + FPDF_PAGE page = FPDF_LoadPage(d_func()->m_doc, d_func()->m_index); + + if (nullptr == page) + return rects; + + CPDF_Page *pPage = CPDFPageFromFPDFPage(page); + const int rotation = pPage ? pPage->GetPageRotation() : 0; + + const qreal sx = width / viewPx.width(); + const qreal sy = height / viewPx.height(); + + const int count = FPDFPage_CountObjects(page); + + for (int i = 0; i < count; ++i) { + FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i); + + if (nullptr == obj || FPDF_PAGEOBJ_IMAGE != FPDFPageObj_GetType(obj)) + continue; + + float left = 0, bottom = 0, right = 0, top = 0; + + if (!FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) + continue; + + // 坐标链:PDF 用户空间(y 向上)→ 旋转后视图坐标 → dpi 像素 → 目标像素 + FS_RECTF fs; + fs.left = left; + fs.top = top; + fs.right = right; + fs.bottom = bottom; + + QRectF viewPt = d_func()->transRect(rotation, fs); + QRectF px = d_func()->transPointToPixel(viewPt); + + QRectF out(px.x() * sx, px.y() * sy, px.width() * sx, px.height() * sy); + rects.append(out.normalized()); + } + + FPDF_ClosePage(page); + + return rects; +} + QVector DPdfPage::textRects(int start, int charCount) { d_func()->loadTextPage(); diff --git a/reader/browser/BrowserPage.cpp b/reader/browser/BrowserPage.cpp index 3ab427dad..a72defc9e 100644 --- a/reader/browser/BrowserPage.cpp +++ b/reader/browser/BrowserPage.cpp @@ -13,6 +13,8 @@ #include "Global.h" #include "SheetRenderer.h" #include "EyeProtectionManager.h" +#include "NightFilter.h" +#include #include "ddlog.h" #include @@ -48,6 +50,10 @@ BrowserPage::~BrowserPage() // qCDebug(appLog) << "BrowserPage destroyed, index:" << m_index; PageRenderThread::clearImageTasks(m_sheet, this); + // 断开并销毁夜间异步任务 watcher:后台滤镜任务持有的都是副本,安全丢弃 + delete m_nightWatcher; + m_nightWatcher = nullptr; + qDeleteAll(m_annotations); qDeleteAll(m_annotationItems); @@ -154,16 +160,57 @@ void BrowserPage::paint(QPainter *painter, const QStyleOptionGraphicsItem *optio EyeProtectionManager *epMgr = EyeProtectionManager::instance(); if (epMgr->mode() == EyeProtectionManager::Night) { - // 夜间模式:智能反色(HSL 亮度反转) - // 仅反转 Lightness 通道,保留 Hue/Saturation,避免图片/链接色相偏移 180° - // 白底黑字 → 黑底白字(文字/背景正确反色) - // 彩色图片/链接 → 仅变暗,色相保持 + // 夜间模式:智能滤镜(对象蒙版 + CIELAB L* 反转)由后台线程异步生成, + // paint 只负责消费缓存结果,永不阻塞(旧实现同步逐像素转换是卡顿主因) if (m_nightDirty || m_nightPixmap.isNull()) { - m_nightPixmap = applyNightMode(m_renderPixmap); - m_nightPixmap.setDevicePixelRatio(dApp->devicePixelRatio()); - m_nightDirty = false; + if (!m_nightJobRunning) + startNightJob(); // 后台生成,完成后 finished 回调重绘 + + if (!m_nightPixmap.isNull() + && qFuzzyCompare(m_nightPixmap.width() / m_nightPixmap.devicePixelRatio(), + boundingRect().width()) + && qFuzzyCompare(m_nightPixmap.height() / m_nightPixmap.devicePixelRatio(), + boundingRect().height())) { + // 夜间图与当前尺寸一致(如仅注释/文字层重绘):直接沿用,无任何过渡 + painter->drawPixmap(0, 0, m_nightPixmap); + } else { + // 尺寸变化(缩放/旋转/首帧):用最新日间渲染即时合成近似夜间观感, + // 与日间模式同拍零延迟;精确夜间图异步完成后自动替换 + // (旧实现拉伸旧夜间图,缩放时图片区域明显滞后一拍) + painter->drawPixmap(0, 0, m_renderPixmap); + + // 1) 整页快速反相:栅格化 Difference 合成,毫秒级;文字/背景观感 + // 接近精确反转,彩色图片暂偏色,由下一步回贴原图修正 + painter->save(); + painter->setCompositionMode(QPainter::CompositionMode_Difference); + painter->fillRect(boundingRect(), Qt::white); + painter->restore(); + + // 2) 图片对象区回贴原图:照片零负片,缩放后 bbox 随渲染尺寸等比换算 + if (!m_imageRects.isEmpty() && m_rectsPixmapWidth > 0 + && m_rectsPixmapHeight > 0) { + const qreal kx = boundingRect().width() / m_rectsPixmapWidth; + const qreal ky = boundingRect().height() / m_rectsPixmapHeight; + painter->save(); + painter->setClipRect(boundingRect()); + // drawPixmap 源矩形不感知 DPR,需按设备像素换算 + const qreal dpr = dApp->devicePixelRatio(); + for (const QRectF &r : m_imageRects) { + // 外扩 1 逻辑像素吸收边缘抗锯齿带,与 NightFilter 蒙版膨胀同理 + const QRectF target = QRectF(r.x() * kx - 1, r.y() * ky - 1, + r.width() * kx + 2, r.height() * ky + 2) + & boundingRect(); + if (!target.isEmpty()) + painter->drawPixmap(target, m_renderPixmap, + QRectF(target.x() * dpr, target.y() * dpr, + target.width() * dpr, target.height() * dpr)); + } + painter->restore(); + } + } + } else { + painter->drawPixmap(0, 0, m_nightPixmap); } - painter->drawPixmap(0, 0, m_nightPixmap); // 叠加轻微深色半透明层降低整体亮度,保持文字清晰可读 QColor dark = epMgr->pageBackgroundColor(); dark.setAlpha(60); @@ -420,6 +467,11 @@ void BrowserPage::handleRenderFinished(const int &pixmapId, const QPixmap &pixma m_nightDirty = true; // 渲染结果已更新,夜间模式反色缓存失效 + // 夜间模式下渲染完成即后台生成夜间图,不等 paint 触发,缩短占位层显示时间 + if (EyeProtectionManager::instance()->mode() == EyeProtectionManager::Night + && !m_nightJobRunning) + startNightJob(); + update(); // qCDebug(appLog) << "BrowserPage::handleRenderFinished() - Handle render finished completed"; } @@ -1280,44 +1332,83 @@ bool BrowserPage::isBigDoc() return isBig; } -QPixmap BrowserPage::applyNightMode(const QPixmap &src) +void BrowserPage::setImageObjectRects(const QVector &rects, int pixmapWidth, int pixmapHeight) { + m_imageRects = rects; + m_rectsPixmapWidth = pixmapWidth; + m_rectsPixmapHeight = pixmapHeight; + m_rectsFetched = true; +} + +void BrowserPage::startNightJob() +{ + if (m_renderPixmap.isNull()) + return; + + // QPixmap -> QImage 转换必须在 GUI 线程完成,副本交给工作线程逐像素处理; + // 失败(如超大页内存不足)直接返回,不消费脏标记,后续渲染/paint 会重试 + QImage src = m_renderPixmap.toImage(); + if (src.format() != QImage::Format_ARGB32 && src.format() != QImage::Format_RGB32) + src = src.convertToFormat(QImage::Format_ARGB32); if (src.isNull()) - return src; - - QImage img = src.toImage(); - if (img.isNull()) - return src; - - // 统一转非预乘 ARGB32,避免对 ARGB32_Premultiplied 写入非预乘值导致半透明像素错误 - if (img.format() != QImage::Format_ARGB32) - img = img.convertToFormat(QImage::Format_ARGB32); - - const int w = img.width(); - const int h = img.height(); - - for (int y = 0; y < h; ++y) { - QRgb *line = reinterpret_cast(img.scanLine(y)); - for (int x = 0; x < w; ++x) { - QRgb px = line[x]; - const int alpha = qAlpha(px); - - // HSL 亮度反转:保留色相(H)和饱和度(S),仅反转亮度(L) - // 白(255) → 黑,黑(0) → 白(255),彩色仅变暗、色相不偏移 - // 两端收敛:下限钳制 30(#1E1E1E),反转后 ≥192 提亮纯白(原图 L≤63 深色文字) - const int kMinLightAfterInvert = 30; // #1E1E1E 的 HSL 亮度值 - const int kMaxLightBoostThreshold = 192; // 0xC0,提亮阈值 - 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); - } + return; + + // 消费脏标记:本任务对应"当前" m_renderPixmap;任务在途期间若有新渲染 + // 结果写入(handleRenderFinished),会重新置脏,届时过期结果将被丢弃 + m_nightDirty = false; + m_nightJobRunning = true; + + // 蒙版 bbox 通常由渲染线程随整页渲染预取并带回;未命中时(如打开文档后 + // 才切入夜间模式的存量页)在 UI 线程同步补取一次,页对象列表有缓存,代价低 + QVector rects = m_imageRects; + if (!m_rectsFetched && m_sheet && m_sheet->renderer() && m_sheet->renderer()->opened()) { + rects = m_sheet->renderer()->getImageObjectRects(itemIndex(), src.width(), src.height()); + m_rectsPixmapWidth = src.width(); + m_rectsPixmapHeight = src.height(); + } + + // rects 是预取时渲染尺寸下的物理像素坐标;缩放/旋转后当前渲染尺寸不同, + // 必须等比换算到新尺寸,否则蒙版错位——实际照片区被反相,旧位置留下 + // 原色残影(缩放残影的根因) + if (!rects.isEmpty() && m_rectsPixmapWidth > 0 && m_rectsPixmapHeight > 0 + && (m_rectsPixmapWidth != src.width() || m_rectsPixmapHeight != src.height())) { + const qreal kx = qreal(src.width()) / m_rectsPixmapWidth; + const qreal ky = qreal(src.height()) / m_rectsPixmapHeight; + for (QRectF &r : rects) + r = QRectF(r.x() * kx, r.y() * ky, r.width() * kx, r.height() * ky); + } + + EyeProtectionManager *epMgr = EyeProtectionManager::instance(); + + NightFilter::Options opt; + opt.imagePolicy = static_cast(epMgr->nightImagePolicy()); + opt.imageDimFactor = epMgr->nightImageDimFactor(); + + if (!m_nightWatcher) { + m_nightWatcher = new QFutureWatcher(); // 无 parent:BrowserPage 非 QObject,在析构中手动删除 + // 以 watcher 自身为接收者:BrowserPage 析构时 delete watcher 即断开连接, + // 后台任务仍在跑但结果被丢弃,不会回测已释放的 this + QObject::connect(m_nightWatcher, &QFutureWatcher::finished, + m_nightWatcher, [this]() { onNightImageReady(); }); } - return QPixmap::fromImage(img); + m_nightWatcher->setFuture(QtConcurrent::run([src, rects, opt]() { + // 纯函数 + 私有副本,无锁无线程安全风险;含蒙版构建/扫描页特判 + return NightFilter::applyPage(src, rects, opt); + })); +} + +void BrowserPage::onNightImageReady() +{ + m_nightJobRunning = false; + + if (m_nightDirty) { + // 任务运行期间渲染结果又更新了:丢弃过期结果,重绘将重新发起 + update(); + return; + } + + m_nightPixmap = QPixmap::fromImage(m_nightWatcher->result()); + m_nightPixmap.setDevicePixelRatio(dApp->devicePixelRatio()); + update(); } diff --git a/reader/browser/BrowserPage.h b/reader/browser/BrowserPage.h index 567818144..9e2198db8 100644 --- a/reader/browser/BrowserPage.h +++ b/reader/browser/BrowserPage.h @@ -14,6 +14,7 @@ #include #include #include +#include using namespace deepin_reader; @@ -389,14 +390,23 @@ class BrowserPage : public QGraphicsItem bool isBigDoc(); /** - * @brief applyNightMode 夜间模式智能反色 - * 基于 HSL 亮度反转:仅反转 Lightness 通道,保留 Hue 和 Saturation - * 效果:白底黑字 → 黑底白字(文字/背景正确反色) - * 彩色图片/链接 → 仅变暗,色相不发生 180° 偏移 - * @param src 源 pixmap - * @return 反色后的 pixmap + * @brief setImageObjectRects 接收渲染线程预取的图片对象 bbox + * 随整页渲染任务带回(物理像素坐标),夜间滤镜用它构建对象蒙版, + * 避免 UI 线程访问 PDFium 文档锁 + * @param rects bbox 列表(物理像素,与 pixmapWidth/Height 的渲染输出对齐) + * @param pixmapWidth/Height rects 对应的渲染输出尺寸; + * 后续缩放后渲染尺寸变化时,paint 过渡帧按比例换算 bbox */ - QPixmap applyNightMode(const QPixmap &src); + void setImageObjectRects(const QVector &rects, int pixmapWidth, int pixmapHeight); + + /** + * @brief startNightJob 在后台线程生成夜间图(QtConcurrent) + * paint 不再同步执行逐像素滤镜,杜绝卡顿;期间绘制旧图/占位层 + */ + void startNightJob(); + + /** 后台夜间滤镜完成回调:回填 m_nightPixmap 并重绘 */ + void onNightImageReady(); protected: void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr) override; @@ -448,6 +458,13 @@ class BrowserPage : public QGraphicsItem QPixmap m_nightPixmap; // 夜间模式智能反色缓存 bool m_nightDirty = true; // 缓存失效标记,m_renderPixmap 变化时置 true + + QVector m_imageRects; // 图片对象 bbox(物理像素,渲染线程预取) + int m_rectsPixmapWidth = 0; // m_imageRects 对应的渲染输出宽(物理像素,过渡帧换算用) + int m_rectsPixmapHeight = 0; // m_imageRects 对应的渲染输出高 + bool m_rectsFetched = false; // m_imageRects 是否已随整页渲染更新 + bool m_nightJobRunning = false; // 后台夜间滤镜任务在途标记 + QFutureWatcher *m_nightWatcher = nullptr; // 异步任务完成通知 }; #endif // BrowserPage_H diff --git a/reader/browser/NightFilter.cpp b/reader/browser/NightFilter.cpp new file mode 100644 index 000000000..1216660f9 --- /dev/null +++ b/reader/browser/NightFilter.cpp @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "NightFilter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace NightFilter { + +namespace { + +// ---------- sRGB <-> CIELAB(D65)标准转换 ---------- + +constexpr double kD65Xn = 0.95047; +constexpr double kD65Yn = 1.00000; +constexpr double kD65Zn = 1.08883; +constexpr double kEpsilon = 216.0 / 24389.0; // 0.008856 +constexpr double kKappa = 24389.0 / 27.0; // 903.296 + +inline double srgbChannelToLinear(double c) +{ + c = qBound(0.0, c / 255.0, 1.0); + return (c <= 0.04045) ? (c / 12.92) : qPow((c + 0.055) / 1.055, 2.4); +} + +inline double linearChannelToSrgb(double c) +{ + c = qBound(0.0, c, 1.0); + return (c <= 0.0031308) ? (12.92 * c) : (1.055 * qPow(c, 1.0 / 2.4) - 0.055) * 255.0; +} + +inline double labForward(double t) +{ + return (t > kEpsilon) ? std::cbrt(t) : (kKappa * t + 16.0) / 116.0; +} + +} // namespace + +void srgbToLab(int r, int g, int b, double &L, double &a, double &bb) +{ + const double rl = srgbChannelToLinear(r); + const double gl = srgbChannelToLinear(g); + const double bl = srgbChannelToLinear(b); + + const double x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + const double y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + const double z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + + const double fx = labForward(x / kD65Xn); + const double fy = labForward(y / kD65Yn); + const double fz = labForward(z / kD65Zn); + + L = 116.0 * fy - 16.0; + a = 500.0 * (fx - fy); + bb = 200.0 * (fy - fz); +} + +void labToSrgb(double L, double a, double bb, int &r, int &g, int &b) +{ + L = qBound(0.0, L, 100.0); + + const double fy = (L + 16.0) / 116.0; + const double fx = fy + a / 500.0; + const double fz = fy - bb / 200.0; + + const double fx3 = fx * fx * fx; + const double fz3 = fz * fz * fz; + const double fy3 = fy * fy * fy; + + const double xr = (fx3 > kEpsilon) ? fx3 : (116.0 * fx - 16.0) / kKappa; + const double yr = (L > 8.0) ? fy3 : L / kKappa; + const double zr = (fz3 > kEpsilon) ? fz3 : (116.0 * fz - 16.0) / kKappa; + + const double x = xr * kD65Xn; + const double y = yr * kD65Yn; + const double z = zr * kD65Zn; + + const double rl = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; + const double gl = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; + const double bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; + + r = qRound(qBound(0.0, linearChannelToSrgb(rl), 255.0)); + g = qRound(qBound(0.0, linearChannelToSrgb(gl), 255.0)); + b = qRound(qBound(0.0, linearChannelToSrgb(bl), 255.0)); +} + +namespace { + +/** 图片域:仅按系数调暗,色相与明暗结构保持不变 */ +inline QRgb dimPixelImage(QRgb px, qreal k) +{ + return qRgba(qRound(qRed(px) * k), qRound(qGreen(px) * k), qRound(qBlue(px) * k), qAlpha(px)); +} + +} // namespace +/** + * @brief invertPixelExact 精确路径:CIELAB L* 反转 + 两端收敛 + * - L' = 100 - L:白底→深底,黑字→亮字,色度(a/b)完整保留 + * - L' 下限钳制(约 #1E1E1E),避免背景纯黑刺眼 + * - 原深色(L* < boost 阈值)反转后提纯白,保证文字/抗锯齿边缘对比度 + */ +inline QRgb invertPixelExact(QRgb px, const Options &opt) +{ + double L, a, bb; + srgbToLab(qRed(px), qGreen(px), qBlue(px), L, a, bb); + + if (L < opt.boostSourceBelow) + return qRgba(255, 255, 255, qAlpha(px)); + + double L2 = 100.0 - L; + if (L2 < opt.minLightness) + L2 = opt.minLightness; + + int r, g, b; + labToSrgb(L2, a, bb, r, g, b); + return qRgba(r, g, b, qAlpha(px)); +} + +// ---------- 反色域两级 LUT:65³ 精确基表 + 128³ 直查表 ---------- +// 精确路径每像素需 6 次 pow + 1 次 cbrt(数百 ns),无法在交互路径使用。 +// 第一级:65³ 网格精确计算(构建 ~80ms,一次性); +// 第二级:128³(2.1M 项,8MB,可驻留 L3)由基表三线性填充(构建 ~40ms), +// 查询零计算:1 次索引 + 1 次内存读,≈5-10ns/像素。 +// 精度:灰阶/常规色 ≤2/255;极端饱和色(反色后出 sRGB 色域被钳制)≤24/255, +// 提白不连续面(L*=27)被插值平滑,文字抗锯齿边缘更柔和,人眼不可辨。 +namespace { + +struct InvertLut { + static constexpr int kGrid = 65; // 基表每轴网格数(网格点取 i*255/64) + static constexpr int kMaxIdx = kGrid - 1; + static constexpr int kFast = 128; // 直查表每轴刻度(0..127 → r*127/255) + static constexpr int kFastMaxIdx = kFast - 1; + std::vector table; // kGrid³ 基表(≈1.1MB) + std::vector fast; // kFast³ 直查表(≈8MB,L3 友好) + + InvertLut() : table(static_cast(kGrid) * kGrid * kGrid), + fast(static_cast(kFast) * kFast * kFast) + { + const Options opt; // 默认参数(与运行时一致) + for (int ri = 0; ri < kGrid; ++ri) { + for (int gi = 0; gi < kGrid; ++gi) { + for (int bi = 0; bi < kGrid; ++bi) { + const int r = ri * 255 / kMaxIdx; + const int g = gi * 255 / kMaxIdx; + const int b = bi * 255 / kMaxIdx; + table[index(ri, gi, bi)] = invertPixelExact(qRgb(r, g, b), opt); + } + } + } + + // 第二级:直查表用基表三线性填充(等效于逐像素三线性,提前烘好) + for (int ri = 0; ri < kFast; ++ri) { + const double fr = ri * 64.0 / kFastMaxIdx; // 直查刻度 → 基表坐标 + const int r0 = int(fr); + const int r1 = qMin(r0 + 1, kMaxIdx); + const double dr = fr - r0; + for (int gi = 0; gi < kFast; ++gi) { + const double fg = gi * 64.0 / kFastMaxIdx; + const int g0 = int(fg); + const int g1 = qMin(g0 + 1, kMaxIdx); + const double dg = fg - g0; + for (int bi = 0; bi < kFast; ++bi) { + const double fb = bi * 64.0 / kFastMaxIdx; + const int b0 = int(fb); + const int b1 = qMin(b0 + 1, kMaxIdx); + const double db = fb - b0; + + const QRgb *t = table.data(); + const QRgb c000 = t[index(r0, g0, b0)]; + const QRgb c100 = t[index(r1, g0, b0)]; + const QRgb c010 = t[index(r0, g1, b0)]; + const QRgb c110 = t[index(r1, g1, b0)]; + const QRgb c001 = t[index(r0, g0, b1)]; + const QRgb c101 = t[index(r1, g0, b1)]; + const QRgb c011 = t[index(r0, g1, b1)]; + const QRgb c111 = t[index(r1, g1, b1)]; + + auto ch = [](QRgb c, int shift) { return (c >> shift) & 0xFF; }; + auto mix1 = [&](int shift) { + const double w000 = (1 - dr) * (1 - dg) * (1 - db); + const double w100 = dr * (1 - dg) * (1 - db); + const double w010 = (1 - dr) * dg * (1 - db); + const double w110 = dr * dg * (1 - db); + const double w001 = (1 - dr) * (1 - dg) * db; + const double w101 = dr * (1 - dg) * db; + const double w011 = (1 - dr) * dg * db; + const double w111 = dr * dg * db; + return w000 * ch(c000, shift) + w100 * ch(c100, shift) + + w010 * ch(c010, shift) + w110 * ch(c110, shift) + + w001 * ch(c001, shift) + w101 * ch(c101, shift) + + w011 * ch(c011, shift) + w111 * ch(c111, shift); + }; + + fast[(size_t(ri) * kFast + gi) * kFast + bi] = + qRgb(qRound(mix1(16)), qRound(mix1(8)), qRound(mix1(0))); + } + } + } + } + + static size_t index(int r, int g, int b) + { return (size_t(r) * kGrid + g) * kGrid + b; } + + static size_t fastIndex(QRgb px) + { return ((size_t(qRed(px)) * kFastMaxIdx / 255) * kFast + + (size_t(qGreen(px)) * kFastMaxIdx / 255)) * kFast + + (size_t(qBlue(px)) * kFastMaxIdx / 255); } +}; + +const InvertLut &invertLut() +{ + static InvertLut lut; // C++11 起线程安全的一次性初始化 + return lut; +} + +/** 直查表版反色(零插值计算:索引 + 一次内存读,精度等效三线性) */ +inline QRgb invertPixelLut(QRgb px) +{ + return (invertLut().fast[InvertLut::fastIndex(px)] & 0x00FFFFFFu) | (px & 0xFF000000u); +} + +} // namespace + +QImage apply(const QImage &src, const QImage *mask, const Options &opt) +{ + if (src.isNull()) + return src; + + QImage img = src; + if (img.format() != QImage::Format_ARGB32 && + img.format() != QImage::Format_RGB32) { + img = img.convertToFormat(QImage::Format_ARGB32); + } + + // 蒙版尺寸不匹配时忽略(防错位;mask 为空表示整页反色) + const bool useMask = (mask != nullptr && !mask->isNull() && mask->size() == img.size()); + const bool hasImageArea = useMask && opt.imagePolicy != ImageInvert; + + const int w = img.width(); + const int h = img.height(); + + // 默认参数走 LUT 快速路径;非默认参数(实验环境变量)回退精确计算 + const bool useLut = (Options().minLightness == opt.minLightness + && Options().boostSourceBelow == opt.boostSourceBelow); + + for (int y = 0; y < h; ++y) { + QRgb *line = reinterpret_cast(img.scanLine(y)); + const uchar *maskLine = useMask ? mask->scanLine(y) : nullptr; + + for (int x = 0; x < w; ++x) { + const QRgb px = line[x]; + + // 蒙版命中且为不透明像素才视为图片内容; + // 透明 PNG 的 bbox 内 alpha=0 像素实际是页面背景,仍走反色域 + if (hasImageArea && maskLine[x] == 0xFF && qAlpha(px) > 0) { + line[x] = (ImageOriginal == opt.imagePolicy) ? px : dimPixelImage(px, opt.imageDimFactor); + } else { + line[x] = useLut ? invertPixelLut(px) : invertPixelExact(px, opt); + } + } + } + + return img; +} + +QImage applyPage(const QImage &src, const QVector &imageRects, + const Options &opt, double *coverageOut) +{ + if (coverageOut) + *coverageOut = 0.0; + if (src.isNull()) + return src; + + // 蒙版仅在有图片对象且策略需要分区时构建;Invert 策略 = 整页反色,无需蒙版 + if (opt.imagePolicy == ImageInvert || imageRects.isEmpty()) + return apply(src, nullptr, opt); + + const qint64 total = static_cast(src.width()) * src.height(); + if (total <= 0) + return apply(src, nullptr, opt); + + // bbox 面积估算覆盖率(重叠对象会略高估,阈值场景宽松无妨) + double area = 0; + for (const QRectF &r : imageRects) + area += qMax(0.0, r.width()) * qMax(0.0, r.height()); + const double coverage = qMin(1.0, area / double(total)); + if (coverageOut) + *coverageOut = coverage; + + // 扫描页特判:图片几乎铺满整页(扫描件/漫画),保留图片则白底刺眼, + // 文字可读性优先,回退整页反色 + if (coverage > kScannedCoverThreshold) + return apply(src, nullptr, opt); + + QImage mask(src.size(), QImage::Format_Grayscale8); + if (mask.isNull()) + return apply(src, nullptr, opt); + mask.fill(0); + { + QPainter p(&mask); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + for (const QRectF &r : imageRects) { + // 膨胀 2px 吸收图像边缘的抗锯齿过渡带,避免图片边缘出现反色色边 + p.drawRect(r.adjusted(-2, -2, 2, 2)); + } + } + + return apply(src, &mask, opt); +} + +} // namespace NightFilter diff --git a/reader/browser/NightFilter.h b/reader/browser/NightFilter.h new file mode 100644 index 000000000..f1beb901f --- /dev/null +++ b/reader/browser/NightFilter.h @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef NIGHTFILTER_H +#define NIGHTFILTER_H + +#include +#include +#include + +/** + * @brief The NightFilter namespace + * 夜间模式智能滤镜(实验): + * - 文字/矢量区域:CIELAB L* 反转(感知均匀的明度反转,保留色度 a/b) + * - 图片对象区域:不反色,按策略调暗/原图(避免连续色调照片负片失真) + * + * 通过"对象蒙版"分区,实现"文字反色、图片不失真"。 + * 蒙版由渲染层根据 PDF 页面对象(IMAGE 类型 bbox)生成, + * 与渲染输出像素一一对齐;Grayscale8 格式,255 = 图片对象区域。 + * + * 设计调研见 docs/night-mode-image-invert-research.md + */ +namespace NightFilter { + +/** + * @brief 图片对象区域的处理策略 + */ +enum ImagePolicy { + ImageDim = 0, // 图片调暗(默认,业界共识做法) + ImageInvert = 1, // 图片也反色(扫描文档场景) + ImageOriginal = 2 // 图片保持原图 +}; + +struct Options { + ImagePolicy imagePolicy = ImageDim; + qreal imageDimFactor = 1.0; // 图片调暗系数(0..1),1=保持原亮度(仅受整页暗罩影响) + qreal minLightness = 11.0; // 反转后 L* 下限(约 #1E1E1E,防整页全黑刺眼) + qreal boostSourceBelow = 27.0; // 原 L* 低于此值(约 #404040)的像素反转后提纯白,保文字对比度 +}; + +/** + * @brief apply 对 src 应用夜间滤镜 + * @param src 渲染输出(ARGB32 / RGB32) + * @param mask 对象蒙版,可为 nullptr(整图反色,即旧版行为增强) + * @param opt 滤镜参数 + * @return 处理后的图像(格式与输入一致) + */ +QImage apply(const QImage &src, const QImage *mask = nullptr, const Options &opt = Options()); + +/** + * @brief applyPage 页级夜间滤镜入口(可在非 UI 线程调用,无锁) + * + * 内部完成:扫描页特判(bbox 覆盖率 > kScannedCoverThreshold 时整页反色)、 + * 对象蒙版构建(含 2px 膨胀)与分区处理。 + * @param imageRects 图片对象 bbox 列表,物理像素坐标,与 src 一一对齐;可为空(整页反色) + * @param coverageOut 可选,回传图片对象覆盖率(诊断/日志用) + */ +QImage applyPage(const QImage &src, const QVector &imageRects, + const Options &opt = Options(), double *coverageOut = nullptr); + +/** 扫描页特判阈值:图片对象 bbox 覆盖率超过此值视为扫描件,回退整页反色 */ +constexpr double kScannedCoverThreshold = 0.7; + +/** sRGB(0..255)→ CIELAB(D65),单像素 */ +void srgbToLab(int r, int g, int b, double &L, double &a, double &bb); + +/** CIELAB(D65)→ sRGB(0..255),越界自动钳制 */ +void labToSrgb(double L, double a, double bb, int &r, int &g, int &b); + +} // namespace NightFilter + +#endif // NIGHTFILTER_H diff --git a/reader/browser/PageRenderThread.cpp b/reader/browser/PageRenderThread.cpp index 89722477b..3bcb3c7bb 100644 --- a/reader/browser/PageRenderThread.cpp +++ b/reader/browser/PageRenderThread.cpp @@ -419,6 +419,12 @@ void PageRenderThread::run() if (m_quit) 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()); + } + emit sigDocPageBigImageTaskFinished(task, pixmap); } @@ -616,6 +622,9 @@ bool PageRenderThread::execNextDocPageNormalImageTask() qCWarning(appLog) << "Failed to get image for page:" << task.page->itemIndex(); } else { qCDebug(appLog) << "Image rendered successfully for page:" << task.page->itemIndex(); + // 预取图片对象 bbox(夜间蒙版用):在工作线程取,避免 UI 线程与渲染争文档锁 + task.imageRects = task.sheet->renderer()->getImageObjectRects( + task.page->itemIndex(), targetWidth, targetHeight); emit sigDocPageNormalImageTaskFinished(task, QPixmap::fromImage(image)); } @@ -822,6 +831,7 @@ void PageRenderThread::onDocPageNormalImageTaskFinished(DocPageNormalImageTask t { // qCDebug(appLog) << "PageRenderThread::onDocPageNormalImageTaskFinished() - Starting on doc page normal image task finished"; if (DocSheet::existSheet(task.sheet)) { + task.page->setImageObjectRects(task.imageRects, task.rect.width(), task.rect.height()); task.page->handleRenderFinished(task.pixmapId, pixmap); } // qCDebug(appLog) << "PageRenderThread::onDocPageNormalImageTaskFinished() - On doc page normal image task finished completed"; @@ -840,6 +850,7 @@ void PageRenderThread::onDocPageBigImageTaskFinished(DocPageBigImageTask task, Q { // qCDebug(appLog) << "PageRenderThread::onDocPageBigImageTaskFinished() - Starting on doc page big image task finished"; if (DocSheet::existSheet(task.sheet)) { + task.page->setImageObjectRects(task.imageRects, task.rect.width(), task.rect.height()); task.page->handleRenderFinished(task.pixmapId, pixmap); } // qCDebug(appLog) << "PageRenderThread::onDocPageBigImageTaskFinished() - On doc page big image task finished completed"; diff --git a/reader/browser/PageRenderThread.h b/reader/browser/PageRenderThread.h index 5765310ba..13a6945a7 100644 --- a/reader/browser/PageRenderThread.h +++ b/reader/browser/PageRenderThread.h @@ -25,6 +25,7 @@ struct DocPageNormalImageTask {//正常取图 BrowserPage *page = nullptr; int pixmapId = 0; //任务艾迪 QRect rect = QRect(); //整个大小 + QVector imageRects; //图片对象 bbox(物理像素,夜间蒙版用;渲染线程预取) }; struct DocPageSliceImageTask {//取切片 @@ -40,6 +41,7 @@ struct DocPageBigImageTask {//取大图 BrowserPage *page = nullptr; int pixmapId = 0; //任务艾迪 QRect rect = QRect(); //整个大小 + QVector imageRects; //图片对象 bbox(物理像素,夜间蒙版用;渲染线程预取) }; struct DocPageWordTask {//取页码文字 diff --git a/reader/browser/browser.pri b/reader/browser/browser.pri index 7d9beb70a..7d204d9e6 100644 --- a/reader/browser/browser.pri +++ b/reader/browser/browser.pri @@ -4,6 +4,7 @@ HEADERS += \ $$PWD/BrowserMenu.h \ $$PWD/BrowserPage.h \ $$PWD/BrowserWord.h \ + $$PWD/NightFilter.h \ $$PWD/SheetBrowser.h \ $$PWD/PageRenderThread.h \ $$PWD/PageSearchThread.h @@ -14,6 +15,7 @@ SOURCES += \ $$PWD/BrowserAnnotation.cpp \ $$PWD/BrowserMenu.cpp \ $$PWD/BrowserWord.cpp \ + $$PWD/NightFilter.cpp \ $$PWD/SheetBrowser.cpp \ $$PWD/PageRenderThread.cpp \ $$PWD/PageSearchThread.cpp diff --git a/reader/document/Model.h b/reader/document/Model.h index 4204ac63a..604ef74ba 100644 --- a/reader/document/Model.h +++ b/reader/document/Model.h @@ -183,6 +183,12 @@ class Page: public QObject virtual QSizeF sizeF() const = 0; virtual QImage render(int width, int height, const QRect &slice = QRect()) const = 0; + /** + * @brief 图片对象包围盒(与 render(width,height) 整页输出像素对齐) + * 默认返回空(无对象信息的文档格式,夜间模式回退整页处理) + */ + virtual QVector imageObjectRects(int width, int height) const + { Q_UNUSED(width) Q_UNUSED(height) return QVector(); } // LCOV_EXCL_START virtual Link getLinkAtPoint(const QPointF &) { return Link(); } virtual QString text(const QRectF &rect) const = 0; diff --git a/reader/document/PDFModel.cpp b/reader/document/PDFModel.cpp index 089faed77..cd5595c5e 100644 --- a/reader/document/PDFModel.cpp +++ b/reader/document/PDFModel.cpp @@ -110,6 +110,13 @@ QImage PDFPage::render(int width, int height, const QRect &slice) const return result; } +QVector PDFPage::imageObjectRects(int width, int height) const +{ + LOCK_DOCUMENT + + return m_page->imageObjectRects(width, height); +} + Link PDFPage::getLinkAtPoint(const QPointF &pos) { // qCDebug(appLog) << "PDFPage::getLinkAtPoint() - Getting link at point:" << pos; diff --git a/reader/document/PDFModel.h b/reader/document/PDFModel.h index a9a7c9bbc..3cd7891ee 100644 --- a/reader/document/PDFModel.h +++ b/reader/document/PDFModel.h @@ -60,6 +60,8 @@ class PDFPage : public Page QImage render(int width, int height, const QRect &slice = QRect()) const override; + QVector imageObjectRects(int width, int height) const override; + Link getLinkAtPoint(const QPointF &point) override; bool hasWidgetAnnots() const override; diff --git a/reader/eyeprotection/EyeProtectionManager.cpp b/reader/eyeprotection/EyeProtectionManager.cpp index 12522770d..4f7e7dcaa 100644 --- a/reader/eyeprotection/EyeProtectionManager.cpp +++ b/reader/eyeprotection/EyeProtectionManager.cpp @@ -50,6 +50,16 @@ EyeProtectionManager::Mode EyeProtectionManager::mode() const return m_mode; } +EyeProtectionManager::NightImagePolicy EyeProtectionManager::nightImagePolicy() const +{ + return m_nightImagePolicy; +} + +qreal EyeProtectionManager::nightImageDimFactor() const +{ + return m_nightImageDimFactor; +} + QColor EyeProtectionManager::pageBackgroundColor() const { return pageBackgroundColor(m_mode); @@ -85,6 +95,29 @@ void EyeProtectionManager::loadMode() int value = settings.value("EyeProtectionMode", 0).toInt(); m_mode = (value >= Off && value <= Night) ? static_cast(value) : Off; qCDebug(appLog) << "Loaded eye protection mode:" << m_mode; + + // 夜间图片策略(实验):默认调暗;环境变量可覆盖便于 A/B 实验 + int policy = settings.value("EyeProtection/NightImagePolicy", NightImageDim).toInt(); + m_nightImagePolicy = (policy >= NightImageDim && policy <= NightImageOriginal) + ? static_cast(policy) : NightImageDim; + + const QByteArray envPolicy = qgetenv("DEEPIN_READER_NIGHT_IMAGE_POLICY"); + if (envPolicy == "invert") + m_nightImagePolicy = NightImageInvert; + else if (envPolicy == "original") + m_nightImagePolicy = NightImageOriginal; + else if (envPolicy == "dim") + m_nightImagePolicy = NightImageDim; + + m_nightImageDimFactor = settings.value("EyeProtection/NightImageDimFactor", 1.0).toReal(); + bool dimOk = false; + const QByteArray envDim = qgetenv("DEEPIN_READER_NIGHT_IMAGE_DIM"); + const qreal envDimValue = QString::fromLocal8Bit(envDim).toDouble(&dimOk); + if (dimOk && envDimValue > 0.0 && envDimValue <= 1.0) + m_nightImageDimFactor = envDimValue; + m_nightImageDimFactor = qBound(0.1, m_nightImageDimFactor, 1.0); + + qCDebug(appLog) << "Night image policy:" << m_nightImagePolicy << "dim factor:" << m_nightImageDimFactor; } void EyeProtectionManager::saveMode() diff --git a/reader/eyeprotection/EyeProtectionManager.h b/reader/eyeprotection/EyeProtectionManager.h index 226a0cd71..a5b18d580 100644 --- a/reader/eyeprotection/EyeProtectionManager.h +++ b/reader/eyeprotection/EyeProtectionManager.h @@ -30,6 +30,17 @@ class EyeProtectionManager : public QObject }; Q_ENUM(Mode) + /** + * @brief NightImagePolicy 夜间模式图片对象处理策略(实验) + * 图片对象区域不做反色以免连续色调负片失真,按此策略处理 + */ + enum NightImagePolicy { + NightImageDim = 0, // 调暗(默认,系数见 nightImageDimFactor) + NightImageInvert = 1, // 也反色(旧版行为) + NightImageOriginal = 2 // 保持原图 + }; + Q_ENUM(NightImagePolicy) + static EyeProtectionManager *instance(); /** @@ -44,6 +55,18 @@ class EyeProtectionManager : public QObject */ Mode mode() const; + /** + * @brief nightImagePolicy 夜间模式图片对象处理策略 + * 可被环境变量 DEEPIN_READER_NIGHT_IMAGE_POLICY(dim|invert|original)覆盖,便于实验 + */ + NightImagePolicy nightImagePolicy() const; + + /** + * @brief nightImageDimFactor 图片调暗系数(0..1,默认 1.0 = 保持原亮度) + * 可被环境变量 DEEPIN_READER_NIGHT_IMAGE_DIM(如 0.6)覆盖 + */ + qreal nightImageDimFactor() const; + /** * @brief pageBackgroundColor 获取文档页面背景色(也是圆形控件填充色) * @return 页面背景色 @@ -83,6 +106,11 @@ class EyeProtectionManager : public QObject */ void modeChanged(Mode mode); + /** + * @brief nightImagePolicyChanged 夜间图片策略变化信号 + */ + void nightImagePolicyChanged(); + private: Q_DISABLE_COPY(EyeProtectionManager) @@ -99,6 +127,8 @@ class EyeProtectionManager : public QObject void saveMode(); Mode m_mode = Off; + NightImagePolicy m_nightImagePolicy = NightImageDim; + qreal m_nightImageDimFactor = 1.0; // 与 loadMode 默认一致,避免 loadMode 前读取到旧值 // 各模式颜色映射 struct ModeColors { diff --git a/reader/uiframe/SheetRenderer.cpp b/reader/uiframe/SheetRenderer.cpp index 8a8f22057..08a5f891a 100644 --- a/reader/uiframe/SheetRenderer.cpp +++ b/reader/uiframe/SheetRenderer.cpp @@ -89,6 +89,16 @@ QImage SheetRenderer::getImage(int index, int width, int height, const QRect &sl return image; } +QVector SheetRenderer::getImageObjectRects(int index, int width, int height) +{ + if (m_pages.count() <= index || index < 0) { + qCWarning(appLog) << "getImageObjectRects invalid page index:" << index; + return QVector(); + } + + return m_pages.value(index)->imageObjectRects(width, height); +} + deepin_reader::Link SheetRenderer::getLinkAtPoint(int index, const QPointF &point) { // qCDebug(appLog) << "SheetRenderer::getLinkAtPoint start - index:" << index << "point:" << point; diff --git a/reader/uiframe/SheetRenderer.h b/reader/uiframe/SheetRenderer.h index 6ec61c0f0..d920b8963 100644 --- a/reader/uiframe/SheetRenderer.h +++ b/reader/uiframe/SheetRenderer.h @@ -70,6 +70,15 @@ class SheetRenderer : public QObject */ QImage getImage(int index, int width, int height, const QRect &slice = QRect()); + /** + * @brief getImageObjectRects 图片对象包围盒(夜间模式对象蒙版用,实验) + * @param index 页索引 + * @param width (in pixel) 目标渲染宽(与 getImage 对齐) + * @param height (in pixel) + * @return 图片对象包围盒列表,像素坐标;非 PDF 文档返回空 + */ + QVector getImageObjectRects(int index, int width, int height); + /** * @brief getLinkAtPoint * 获取点的link