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