diff --git a/devel/0408.md b/devel/0408.md new file mode 100644 index 0000000000..2d69c714ed --- /dev/null +++ b/devel/0408.md @@ -0,0 +1,115 @@ +# [0408] PDF 阅读器支持 macOS 触控板捏合缩放 + +## 1 相关文档 + +- [dddd.md](dddd.md) - 任务文档模板 + +## 2 任务相关的代码文件 + +- `src/Plugins/Qt/qt_pdf_reader_widget.cpp` +- `src/Plugins/Qt/qt_pdf_reader_widget.hpp` + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) + +```bash +xmake run qt_pdf_reader_widget_test +``` + +新增 TDD 测试: +- `test_pinchZoomIn`:验证捏合放大后 zoom factor 正确增加 +- `test_pinchZoomBlocksRender`:验证捏合过程中 `renderCallCount` 不增加(`blockRender_` 生效),手势结束后才触发重渲染 + +### 3.2 非确定性测试(文档验证) + +1. 打开 Mogan STEM,打开一个 PDF 文件。 +2. 在 PDF 阅读区域,使用 macOS 触控板双指捏合手势进行缩放。 +3. 确认捏合过程中页面平滑跟随缩放比例变化,UI 不卡顿。 +4. 确认松手后页面重新渲染为清晰版本(防抖触发)。 +5. 确认缩放到极端比例(最小/最大)时,缩放被正确限制在 `12% ~ 800%` 范围内。 +6. 对比修复前:捏合时会反复触发重渲染,导致明显卡顿。 + +## 4 如何提交 + +提交前执行以下最少步骤: + +```bash +xmake run qt_pdf_reader_widget_test +xmake b stem +``` + +## 5 What + +为 Mogan STEM 的 PDF 阅读器添加 macOS 触控板捏合(Pinch)缩放支持,并针对流畅性做深度优化: + +1. **macOS 原生手势优先**:在 macOS 上直接处理 `QNativeGestureEvent::ZoomNativeGesture`,绕过 Qt `QPinchGesture` 框架的中间层开销 +2. **双平台兼容**:保留 `QPinchGesture` 作为非 macOS 平台(触摸屏等)的 fallback +3. **高频事件节流(Throttle)**:使用 `QElapsedTimer` 限制 gesture 事件处理频率最多 ~60fps(16ms 间隔),避免 macOS trackpad 高频事件(120Hz+)堆积导致主线程阻塞 +4. **UI 刷新防抖(Debounce)**:使用 `QTimer`(50ms)限制 combo box 百分比更新最多 20fps,避免 `setCurrentText` 高频重绘造成卡顿 +5. **零渲染策略**:捏合过程中仅更新 `zoomFactor_`,完全不触碰页面布局、pixmap 或 MuPDF 渲染 +6. **手势结束统一刷新**:`EndNativeGesture` / `GestureFinished` / `GestureCanceled` 时统一调用 `setZoomFactor()` 触发防抖重渲染 +7. 缩放比例仍受 `MIN_ZOOM = 0.12` 和 `MAX_ZOOM = 8.0` 约束 + +## 6 Why + +此前 PDF 阅读器在 macOS 上仅支持: +- 工具栏按钮点击缩放 +- `Ctrl + 滚轮` 缩放(对应鼠标) +- 快捷键 `Ctrl + +/-` 缩放 + +缺少对触控板原生捏合手势的支持,这与 macOS 用户的直觉操作习惯不符。直接实现时,若在每次 `ScaleFactorChanged` 都调用 `setZoomFactor()`(内部会启动 200ms 防抖定时器并排队 `rebuildPages()`),会导致捏合过程中页面反复重渲染,造成明显卡顿。 + +## 7 How + +实现思路(参考 Okular `blockPixmapsRequest` + `zoomWithFixedCenter` 模式): + +### 7.1 Okular 流畅性秘诀 + +Okular 的 `zoomWithFixedCenter` 核心逻辑: +1. `d->blockPixmapsRequest = true` —— 阻止耗时 pixmap 请求 +2. 更新 zoom factor + 触发 `updateZoom()` —— 实时更新 layout(页面尺寸变化) +3. `d->blockPixmapsRequest = false` —— 恢复 +4. `paintEvent` 自动将旧 pixmap 绘制到新目标矩形 —— 零开销视觉缩放 + +### 7.2 我的适配方案(QLabel + setScaledContents) + +由于我用 QLabel 而非自定义 paintEvent,采用等效策略: +1. **Gesture 开始**:对所有 label `setScaledContents(true)`,启用 QLabel 内部自动 pixmap 缩放 +2. **Gesture 更新**:只更新 `zoomFactor_` + `setFixedSize()`(layout 变化),QLabel 自动缩放现有 pixmap 显示 +3. **Gesture 结束**:`setScaledContents(false)`,触发防抖重渲染,用 MuPDF 生成清晰 pixmap + +### 7.3 blockRender_ 机制 + +添加 `bool blockRender_` 标志,仿照 Okular 的 `blockPixmapsRequest`: +- `rebuildPages()` 中检查 `blockRender_`,为 true 时只做第一轮 layout(`setFixedSize`)后直接返回,跳过第二轮的 `renderPageToLabel()` +- `renderPageToLabel()` 开头增加 `renderCallCount_++` + `cout` 日志(`#ifdef LIII_DEBUG`),供测试和调试验证 + +### 7.4 移除 throttle/debounce + +之前尝试的 `QElapsedTimer` 16ms throttle 和 `QTimer` 50ms debounce 已被移除。Okular 没有这些机制,其流畅性完全来自 `blockPixmapsRequest`。多余的时间控制反而会造成: +- 事件丢弃导致缩放不跟手 +- timer 堆积导致 gesture 结束后延迟 + +### 7.5 双平台事件处理 + +- **macOS**:优先处理 `QNativeGestureEvent`(`BeginNativeGesture` / `ZoomNativeGesture` / `EndNativeGesture`) +- **非 macOS**:处理 `QGestureEvent`(`QPinchGesture`) +- 两者共用同一套 `blockRender_` + `setScaledContents` 机制 +- Gesture 开始时 `scroller_->stop()`,避免 QScroller 与 pinch 冲突 + +### 7.6 手势安全超时 + +添加 `gestureSafetyTimer_`(500ms 单发定时器),解决 **EndNativeGesture 丢失导致 blockRender_ 永久为 true** 的临界问题: +- 每次 gesture update(`ZoomNativeGesture` / `ScaleFactorChanged`)重启定时器 +- 正常 `EndNativeGesture` / `GestureFinished` 到达时停止定时器 +- 若 500ms 内未收到结束事件(如应用失焦、触控板异常),定时器触发 `finishPinchGesture()` 自动恢复状态 + +### 7.7 UI 刷新优化 + +捏合过程中不再调用 `updateZoomDisplay()`(避免 `QComboBox` 120Hz 重绘),仅在手势结束时统一更新百分比显示。 + +### 7.8 TDD 测试 + +- `test_pinchZoomIn`:验证 zoom factor 正确计算 +- `test_pinchZoomBlocksRender`:验证 5 次 gesture update 过程中 `renderCallCount` 不变,手势结束后才增加 diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 6646a31bbf..67ddac1f9f 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -11,13 +11,16 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -26,6 +29,10 @@ #include #include +#ifdef Q_OS_MACOS +#include +#endif + #include "MuPDF/mupdf_renderer.hpp" #include "qt_dpi_utils.hpp" #include "qt_utilities.hpp" @@ -37,6 +44,22 @@ namespace { constexpr float kRenderOversample= 1.5F; constexpr float kMinRenderScale = 0.1F; constexpr float kMaxRenderScale = 8.0F; + +/** + * @brief Check if the zoom modifier key is pressed. + * + * On macOS, the standard zoom modifier is Cmd (MetaModifier). + * We also accept Ctrl (ControlModifier) for compatibility. + * On other platforms, only Ctrl is accepted. + */ +bool +isZoomModifier (Qt::KeyboardModifiers modifiers) { +#ifdef Q_OS_MACOS + return (modifiers & Qt::MetaModifier) || (modifiers & Qt::ControlModifier); +#else + return modifiers & Qt::ControlModifier; +#endif +} } // namespace PDFReaderWidget::PDFReaderWidget (QWidget* parent) @@ -51,7 +74,9 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) pageCount_ (0), hasError_ (false), targetDpi_ (DEFAULT_DPI), zoomFactor_ (1.0), pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), overLink_ (false), zoomDebounceTimer_ (nullptr), - resizeDebounceTimer_ (nullptr) { + resizeDebounceTimer_ (nullptr), gestureSafetyTimer_ (nullptr), + inPinchGesture_ (false), blockRender_ (false), pinchStartZoom_ (1.0), + renderCallCount_ (0) { mainLayout_= new QVBoxLayout (this); mainLayout_->setContentsMargins (0, 0, 0, 0); @@ -104,6 +129,7 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) scrollArea_->viewport ()->installEventFilter (this); scrollArea_->viewport ()->setMouseTracking (true); scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); + grabGesture (Qt::PinchGesture); // 保持与 QScrollArea 内部一致的步长(Okular 同款 magic value) scrollArea_->verticalScrollBar ()->setSingleStep (20); @@ -143,6 +169,12 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) resizeDebounceTimer_->setInterval (RESIZE_DEBOUNCE_MS); connect (resizeDebounceTimer_, &QTimer::timeout, this, &PDFReaderWidget::rebuildPages); + + gestureSafetyTimer_= new QTimer (this); + gestureSafetyTimer_->setSingleShot (true); + gestureSafetyTimer_->setInterval (GESTURE_SAFETY_TIMEOUT_MS); + connect (gestureSafetyTimer_, &QTimer::timeout, this, + &PDFReaderWidget::finishPinchGesture); } PDFReaderWidget::~PDFReaderWidget () {} @@ -304,6 +336,89 @@ PDFReaderWidget::setupToolBar () { void PDFReaderWidget::updateZoomDisplay () { if (!zoomCombo_) return; + + int percent= qRound (zoomFactor_ * 100); + QString text = QString::number (percent) + "%"; + + bool blocked= zoomCombo_->blockSignals (true); + zoomCombo_->setText (text); + zoomCombo_->blockSignals (blocked); +} + +void +PDFReaderWidget::applyZoomToLabels () { + int width= pageWidth (); + if (width <= 0) return; + + int childCount= pageLayout_->count (); + for (int i= 0; i < childCount && i < pageCount_; ++i) { + QLayoutItem* item= pageLayout_->itemAt (i); + if (!item) continue; + QLabel* label= qobject_cast (item->widget ()); + if (!label) continue; + double aspect= (i < pageAspectRatios_.size ()) ? pageAspectRatios_[i] + : pageAspectRatio_; + if (aspect <= 0.0) aspect= 1.414; + int height= qMax (1, qRound (width * aspect)); + label->setFixedSize (width, height); + } +} + +void +PDFReaderWidget::startPinchGesture () { + if (inPinchGesture_) return; + inPinchGesture_= true; + blockRender_ = true; + pinchStartZoom_= zoomFactor_; + if (scroller_) scroller_->stop (); + int childCount= pageLayout_->count (); + for (int i= 0; i < childCount && i < pageCount_; ++i) { + QLayoutItem* item= pageLayout_->itemAt (i); + if (!item) continue; + QLabel* label= qobject_cast (item->widget ()); + if (label) label->setScaledContents (true); + } +} + +void +PDFReaderWidget::finishPinchGesture () { + if (!inPinchGesture_) return; + inPinchGesture_= false; + blockRender_ = false; + + // Sync-render the correctly-sized pixmap before turning off + // scaledContents, so the label does not flicker from the old + // stretched image back to a mismatched original pixmap. + if (!pdfData_.isEmpty () && pageCount_ > 0) rebuildPages (); + updateZoomDisplay (); + + int childCount= pageLayout_->count (); + for (int i= 0; i < childCount && i < pageCount_; ++i) { + QLayoutItem* item= pageLayout_->itemAt (i); + if (!item) continue; + QLabel* label= qobject_cast (item->widget ()); + if (label) label->setScaledContents (false); + } +} + +void +PDFReaderWidget::simulatePinchGesture (Qt::GestureState state, + double scaleFactor) { + if (state == Qt::GestureStarted) { + startPinchGesture (); + return; + } + if (state == Qt::GestureUpdated) { + double newZoom= qBound (MIN_ZOOM, pinchStartZoom_ * scaleFactor, MAX_ZOOM); + if (qAbs (newZoom - zoomFactor_) > 0.001) { + zoomFactor_= newZoom; + applyZoomToLabels (); + } + return; + } + if (state == Qt::GestureFinished || state == Qt::GestureCanceled) { + finishPinchGesture (); + } int percent= qRound (zoomFactor_ * 100); zoomCombo_->setText (QString::number (percent) + "%"); } @@ -608,6 +723,11 @@ PDFReaderWidget::pageWidth () const { bool PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, int targetWidth) { + ++renderCallCount_; +#ifdef LIII_DEBUG + cout << "renderPageToLabel page=" << pageNumber << " width=" << targetWidth + << "\n"; +#endif // 计算目标高度(优先使用预缓存的宽高比) double aspectRatio= pageAspectRatio_; if (pageNumber >= 0 && pageNumber < pageAspectRatios_.size ()) { @@ -808,6 +928,8 @@ PDFReaderWidget::rebuildPages () { int minY = scrollY - PRELOAD_MARGIN; int maxY = scrollY + viewportHeight + PRELOAD_MARGIN; + if (blockRender_) return; + // 第二轮:只渲染可见及预加载范围内的页面 for (int i= 0; i < childCount && i < pageCount_; ++i) { QLayoutItem* item= pageLayout_->itemAt (i); @@ -1192,7 +1314,7 @@ PDFReaderWidget::keyPressEvent (QKeyEvent* event) { return; } - if (event->modifiers () & Qt::ControlModifier) { + if (isZoomModifier (event->modifiers ())) { switch (event->key ()) { case Qt::Key_Plus: case Qt::Key_Equal: @@ -1213,6 +1335,78 @@ PDFReaderWidget::keyPressEvent (QKeyEvent* event) { QWidget::keyPressEvent (event); } +bool +PDFReaderWidget::event (QEvent* event) { + if (event->type () == QEvent::Gesture) { + QGestureEvent* gestureEvent= static_cast (event); + if (QPinchGesture* pinch= qobject_cast ( + gestureEvent->gesture (Qt::PinchGesture))) { + // Handle QPinchGesture on all platforms (including macOS). + // Qt 6 maps trackpad pinch to QPinchGesture on macOS as well. + if (pinch->state () == Qt::GestureStarted) { + startPinchGesture (); + gestureSafetyTimer_->start (); + gestureEvent->accept (pinch); + return true; + } + if (pinch->changeFlags () & QPinchGesture::ScaleFactorChanged) { + double newZoom= qBound ( + MIN_ZOOM, pinchStartZoom_ * pinch->totalScaleFactor (), MAX_ZOOM); + if (qAbs (newZoom - zoomFactor_) > 0.001) { + zoomFactor_= newZoom; + applyZoomToLabels (); + } + gestureSafetyTimer_->start (); + gestureEvent->accept (pinch); + return true; + } + if (pinch->state () == Qt::GestureFinished || + pinch->state () == Qt::GestureCanceled) { + gestureSafetyTimer_->stop (); + finishPinchGesture (); + gestureEvent->accept (pinch); + return true; + } + gestureEvent->accept (pinch); + return true; + } + } +#ifdef Q_OS_MACOS + if (event->type () == QEvent::NativeGesture) { + QNativeGestureEvent* nativeEvent= static_cast (event); + Qt::NativeGestureType gestureType= nativeEvent->gestureType (); + // If QPinchGesture is already handling the pinch, ignore native + // gesture to avoid double-scaling. + if (inPinchGesture_ && (gestureType == Qt::BeginNativeGesture || + gestureType == Qt::EndNativeGesture || + gestureType == Qt::ZoomNativeGesture)) { + return true; + } + if (gestureType == Qt::BeginNativeGesture) { + startPinchGesture (); + gestureSafetyTimer_->start (); + return true; + } + if (gestureType == Qt::EndNativeGesture) { + gestureSafetyTimer_->stop (); + finishPinchGesture (); + return true; + } + if (gestureType == Qt::ZoomNativeGesture) { + if (!inPinchGesture_) startPinchGesture (); + gestureSafetyTimer_->start (); + double delta= nativeEvent->value (); + if (qAbs (delta) > 0.001) { + zoomFactor_= qBound (MIN_ZOOM, zoomFactor_ * (1.0 + delta), MAX_ZOOM); + applyZoomToLabels (); + } + return true; + } + } +#endif + return QWidget::event (event); +} + bool PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { if (watched == scrollArea_->viewport ()) { @@ -1229,7 +1423,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { } if (event->type () == QEvent::Wheel) { QWheelEvent* wheelEvent= static_cast (event); - if (wheelEvent->modifiers () & Qt::ControlModifier) { + if (isZoomModifier (wheelEvent->modifiers ())) { int delta= wheelEvent->angleDelta ().y (); if (delta != 0) { double factor= 1.0 + static_cast (delta) / 500.0; diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 880116915e..6dd7979e8e 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -82,7 +82,9 @@ class PDFReaderWidget : public QWidget { bool isRectSelectMode () const; - // Link support (public for testing) + int renderCallCount () const { return renderCallCount_; } + void simulatePinchGesture (Qt::GestureState state, double scaleFactor); + void setTestLinks (int page, const QVector& links); bool isOverLink () const; @@ -98,12 +100,17 @@ private slots: void onRectSelectToggled (bool checked); void keyPressEvent (QKeyEvent* event) override; + bool event (QEvent* event) override; + private: + void startPinchGesture (); + void finishPinchGesture (); bool renderPageToLabel (int pageNumber, QLabel* label, int targetWidth); void rebuildPages (); int pageWidth () const; void setupToolBar (); void updateZoomDisplay (); + void applyZoomToLabels (); void finishRectSelect (const QPoint& viewportPos); QLabel* findPageLabelAt (const QPoint& contentPos) const; QPixmap extractSelectionPixmap (QLabel* label, @@ -169,14 +176,22 @@ private slots: // 防抖定时器 QTimer* zoomDebounceTimer_; QTimer* resizeDebounceTimer_; - - static constexpr int DEFAULT_DPI = 150; - static constexpr int PAGE_MARGIN = 16; - static constexpr int PRELOAD_MARGIN = 200; - static constexpr double MIN_ZOOM = 0.12; - static constexpr double MAX_ZOOM = 8.0; - static constexpr int ZOOM_DEBOUNCE_MS = 200; - static constexpr int RESIZE_DEBOUNCE_MS= 300; + QTimer* gestureSafetyTimer_; + + bool inPinchGesture_; + bool blockRender_; + double pinchStartZoom_; + + int renderCallCount_; + + static constexpr int DEFAULT_DPI = 150; + static constexpr int PAGE_MARGIN = 16; + static constexpr int PRELOAD_MARGIN = 200; + static constexpr double MIN_ZOOM = 0.12; + static constexpr double MAX_ZOOM = 8.0; + static constexpr int ZOOM_DEBOUNCE_MS = 200; + static constexpr int RESIZE_DEBOUNCE_MS = 300; + static constexpr int GESTURE_SAFETY_TIMEOUT_MS= 500; static constexpr int ZOOM_LEVEL_COUNT= 12; static constexpr double ZOOM_LEVELS[ZOOM_LEVEL_COUNT]{ diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index f9aa4bf12a..917cd997bf 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -12,7 +12,9 @@ #include "url.hpp" #include #include +#include #include +#include #include #include #include @@ -896,12 +898,73 @@ private slots: QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, end); QApplication::processEvents (); - // drag should NOT trigger link click QVERIFY (capturedUri.isEmpty ()); delete widget; } + void test_pinchZoomIn () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + double initialZoom= widget->zoomFactor (); + + widget->simulatePinchGesture (Qt::GestureStarted, 1.0); + QApplication::processEvents (); + + widget->simulatePinchGesture (Qt::GestureUpdated, 1.5); + QApplication::processEvents (); + + double newZoom= widget->zoomFactor (); + QVERIFY (newZoom > initialZoom); + + widget->simulatePinchGesture (Qt::GestureFinished, 1.5); + QApplication::processEvents (); + + delete widget; + } + + void test_pinchZoomBlocksRender () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + int initialRenderCount= widget->renderCallCount (); + + widget->simulatePinchGesture (Qt::GestureStarted, 1.0); + QApplication::processEvents (); + + for (int i= 0; i < 5; ++i) { + widget->simulatePinchGesture (Qt::GestureUpdated, 1.0 + (i + 1) * 0.1); + QApplication::processEvents (); + } + + QCOMPARE (widget->renderCallCount (), initialRenderCount); + + widget->simulatePinchGesture (Qt::GestureFinished, 1.5); + QApplication::processEvents (); + + QTest::qWait (250); + QApplication::processEvents (); + + QVERIFY (widget->renderCallCount () > initialRenderCount); + + delete widget; + } + // Test: simulate real mouse event flow that QScroller would NOT intercept // because there is no InputPress before the Move. // This verifies that the eventFilter actually receives the hover MoveEvent