From 2d398e6b03dbbd10f46f8e498eeac6e797fcff38 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 20:39:56 +0800 Subject: [PATCH 1/9] =?UTF-8?q?[0153]=20=E6=9B=B4=E6=96=B0=20PDF=20?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E5=99=A8=E9=93=BE=E6=8E=A5=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E4=BB=BB=E5=8A=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- devel/0153.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 devel/0153.md diff --git a/devel/0153.md b/devel/0153.md new file mode 100644 index 0000000000..17ee93433e --- /dev/null +++ b/devel/0153.md @@ -0,0 +1,49 @@ +# [0153] PDF 阅读器链接点击跳转 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 + +## 2 任务相关的代码文件 +- `src/Plugins/Qt/qt_pdf_reader_widget.hpp` +- `src/Plugins/Qt/qt_pdf_reader_widget.cpp` +- `tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp` + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) +```bash +xmake b qt_pdf_reader_widget_test +xmake r qt_pdf_reader_widget_test +``` + +### 3.2 非确定性测试(文档验证) +```bash +xmake b stem +xmake r stem +# 打开一个带链接的 PDF,鼠标悬停在链接上观察光标变化,点击观察跳转 +``` + +## 4 如何提交 + +提交前执行以下最少步骤: + +```bash +xmake b qt_pdf_reader_widget_test +xmake r qt_pdf_reader_widget_test +``` + +## 5 What +实现 PDF 阅读器中的链接交互功能: +1. 鼠标悬停在链接区域时,光标变为手型(PointingHandCursor) +2. 点击链接后触发跳转:内部页码链接跳转到对应页面,外部 URL 通过系统默认浏览器打开 +3. 拖动时不触发链接点击,避免和拖动滚动冲突 + +## 6 Why +提升 PDF 阅读体验,让用户可以直接点击文档中的目录、引用、超链接进行导航。 + +## 7 How +参考 Okular 的实现方式: +1. **链接提取**:使用 MuPDF 的 `fz_load_links` API 在加载 PDF 时提取每页的链接列表,存储为 `PdfLink` 结构(包含归一化坐标 rect 和 uri) +2. **光标检测**:在 `eventFilter` 的 `MouseMove` 事件中,将 viewport 坐标转换为 contentWidget 坐标,调用 `linkAtPos` 检测是否在链接区域上,更新光标为 `PointingHandCursor` 或恢复 `OpenHandCursor` +3. **点击处理**:在 Browse 模式的 `MouseButtonRelease` 中,如果没有发生拖动(`browseDragActive_ == false`)且当前在链接上(`overLink_ == true`),调用 `handleLinkClick` 处理链接 +4. **跳转逻辑**:`#page=N` 格式的 uri 调用 `goToPage(N)`;其他有效 URL 使用 `QDesktopServices::openUrl` 打开;同时发射 `linkClicked` 信号供外部监听 From b3a258297ca96586973d18b939ac468848df1105 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 20:40:05 +0800 Subject: [PATCH 2/9] =?UTF-8?q?[0153]=20PDF=20=E9=98=85=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=93=BE=E6=8E=A5=E6=82=AC=E5=81=9C=E5=85=89?= =?UTF-8?q?=E6=A0=87=E5=92=8C=E7=82=B9=E5=87=BB=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 MuPDF fz_load_links 提取每页链接信息 - 鼠标悬停链接时显示 PointingHandCursor - 点击内部链接跳转到对应页面,外部链接通过 QDesktopServices 打开 - 拖动时不触发链接点击,避免和 QScroller 拖动冲突 - 参考 Okular 的链接处理实现 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 192 +++++++++++++++- src/Plugins/Qt/qt_pdf_reader_widget.hpp | 26 +++ .../Plugins/Qt/qt_pdf_reader_widget_test.cpp | 216 ++++++++++++++++++ 3 files changed, 423 insertions(+), 11 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 80645cd50d..4cb25a61b0 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include "MuPDF/mupdf_renderer.hpp" @@ -46,6 +48,7 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) browseDragging_ (false), browseDragActive_ (false), scroller_ (nullptr), pageCount_ (0), hasError_ (false), targetDpi_ (DEFAULT_DPI), zoomFactor_ (1.0), pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), + overLink_ (false), zoomDebounceTimer_ (nullptr), resizeDebounceTimer_ (nullptr) { mainLayout_= new QVBoxLayout (this); @@ -69,6 +72,7 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) scrollArea_->setWidget (contentWidget_); scrollArea_->viewport ()->installEventFilter (this); + scrollArea_->viewport ()->setMouseTracking (true); scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); // QScroller 配置(参考 Okular) @@ -919,6 +923,8 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { return false; } + extractPageLinks (); + // 创建所有页面 label(先不渲染,由 rebuildPages 统一处理可见性) for (int i= 0; i < pageCount_; ++i) { QLabel* label= new QLabel (contentWidget_); @@ -946,6 +952,7 @@ PDFReaderWidget::clear () { pageAspectRatio_ = 0.0; pageBaseWidthPts_= 0.0; pageAspectRatios_.clear (); + clearPageLinks (); pageCache_.clear (); QLayoutItem* item; @@ -959,6 +966,156 @@ PDFReaderWidget::clear () { updatePageNavigation (); } +void +PDFReaderWidget::extractPageLinks () { + clearPageLinks (); + if (pdfData_.isEmpty () || pageCount_ <= 0) return; + + fz_context* ctx= mupdf_context (); + if (!ctx) return; + + fz_document* doc = nullptr; + fz_buffer* buf = nullptr; + fz_stream* stream= nullptr; + + fz_var (doc); + fz_var (buf); + fz_var (stream); + + fz_try (ctx) { + buf= fz_new_buffer_from_copied_data ( + ctx, reinterpret_cast (pdfData_.constData ()), + pdfData_.size ()); + stream= fz_open_buffer (ctx, buf); + doc = fz_open_document_with_stream (ctx, "pdf", stream); + if (!doc) fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to open PDF"); + + pageLinks_.resize (pageCount_); + for (int i= 0; i < pageCount_; ++i) { + fz_page* page= fz_load_page (ctx, doc, i); + if (!page) continue; + fz_link* links= fz_load_links (ctx, page); + if (links) { + fz_rect pageBounds= fz_bound_page (ctx, page); + float pageW = pageBounds.x1 - pageBounds.x0; + float pageH = pageBounds.y1 - pageBounds.y0; + for (fz_link* link= links; link; link= link->next) { + PdfLink pl; + pl.uri = QString::fromUtf8 (link->uri); + // normalized coordinates + if (pageW > 0 && pageH > 0) { + pl.rect= QRectF ((link->rect.x0 - pageBounds.x0) / pageW, + (link->rect.y0 - pageBounds.y0) / pageH, + (link->rect.x1 - link->rect.x0) / pageW, + (link->rect.y1 - link->rect.y0) / pageH); + } + pageLinks_[i].append (pl); + } + fz_drop_link (ctx, links); + } + fz_drop_page (ctx, page); + } + } + fz_catch (ctx) { + qWarning () << "MuPDF link extraction error:" << fz_caught_message (ctx); + } + + if (stream) fz_drop_stream (ctx, stream); + if (buf) fz_drop_buffer (ctx, buf); + if (doc) fz_drop_document (ctx, doc); +} + +void +PDFReaderWidget::clearPageLinks () { + pageLinks_.clear (); + currentLink_= PdfLink (); + overLink_ = false; +} + +PdfLink +PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { + if (pageLinks_.isEmpty ()) return PdfLink (); + + 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; + QRect labelRect= label->geometry (); + if (!labelRect.contains (contentPos)) continue; + + if (i < pageLinks_.size () && !pageLinks_[i].isEmpty ()) { + // convert content pos to normalized page coordinates + double nx= static_cast (contentPos.x () - labelRect.x ()) / + qMax (1, labelRect.width ()); + double ny= static_cast (contentPos.y () - labelRect.y ()) / + qMax (1, labelRect.height ()); + for (const PdfLink& link : pageLinks_[i]) { + if (link.rect.contains (nx, ny)) { + return link; + } + } + } + } + return PdfLink (); +} + +void +PDFReaderWidget::handleLinkClick (const PdfLink& link) { + if (link.uri.isEmpty ()) return; + + QUrl url (link.uri); + if (url.isValid () && !url.scheme ().isEmpty () && + url.scheme () != "file") { + // external URL + QDesktopServices::openUrl (url); + Q_EMIT linkClicked (link.uri); + } + else if (link.uri.startsWith ("#page=")) { + bool ok; + int page= link.uri.mid (6).toInt (&ok); + if (ok) goToPage (page); + Q_EMIT linkClicked (link.uri); + } + else { + // fallback: try to interpret as page number + bool ok; + int page= link.uri.toInt (&ok); + if (ok) goToPage (page); + else Q_EMIT linkClicked (link.uri); + } +} + +void +PDFReaderWidget::updateLinkCursor (const QPoint& contentPos) { + if (rectSelectMode_) return; + + PdfLink link= linkAtPos (contentPos); + if (!link.uri.isEmpty ()) { + currentLink_= link; + overLink_ = true; + scrollArea_->viewport ()->setCursor (Qt::PointingHandCursor); + } + else { + currentLink_= PdfLink (); + overLink_ = false; + scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); + } +} + +void +PDFReaderWidget::setTestLinks (int page, const QVector& links) { + if (page < 0) return; + if (page >= pageLinks_.size ()) pageLinks_.resize (page + 1); + pageLinks_[page]= links; +} + +bool +PDFReaderWidget::isOverLink () const { + return overLink_; +} + void PDFReaderWidget::keyPressEvent (QKeyEvent* event) { if (event->key () == Qt::Key_Space) { @@ -1071,6 +1228,18 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { } } // ============================================================ + // Link hover detection (no button pressed) + // ============================================================ + else if (!rectSelectMode_ && !browseDragging_ && + event->type () == QEvent::MouseMove) { + QMouseEvent* mouseEvent= static_cast (event); + QPoint contentPos= mouseEvent->pos (); + contentPos.rx ()+= scrollArea_->horizontalScrollBar ()->value (); + contentPos.ry ()+= scrollArea_->verticalScrollBar ()->value (); + updateLinkCursor (contentPos); + // do not consume the event, let it propagate for potential tooltip etc. + } + // ============================================================ // Browse (hand) tool: default drag-to-scroll behavior // ============================================================ else if (!rectSelectMode_ && event->type () == QEvent::MouseButtonPress) { @@ -1082,10 +1251,6 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { scroller_->handleInput (QScroller::InputPress, mouseEvent->pos (), mouseEvent->timestamp ()); scrollArea_->viewport ()->setCursor (Qt::ClosedHandCursor); -#ifdef LIII_DEBUG - cout << "Browse press at " << mouseEvent->pos ().x () << "," - << mouseEvent->pos ().y () << "\n"; -#endif mouseEvent->accept (); return true; } @@ -1098,9 +1263,13 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { .manhattanLength (); if (!browseDragActive_ && delta > QApplication::startDragDistance ()) { browseDragActive_= true; -#ifdef LIII_DEBUG - cout << "Browse drag activated, delta=" << delta << "\n"; -#endif + } + // while not yet dragging, keep updating link cursor + if (!browseDragActive_) { + QPoint contentPos= mouseEvent->pos (); + contentPos.rx ()+= scrollArea_->horizontalScrollBar ()->value (); + contentPos.ry ()+= scrollArea_->verticalScrollBar ()->value (); + updateLinkCursor (contentPos); } scroller_->handleInput (QScroller::InputMove, mouseEvent->pos (), mouseEvent->timestamp ()); @@ -1112,12 +1281,13 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { QMouseEvent* mouseEvent= static_cast (event); if (mouseEvent->button () == Qt::LeftButton) { browseDragging_= false; - scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); scroller_->handleInput (QScroller::InputRelease, mouseEvent->pos (), mouseEvent->timestamp ()); -#ifdef LIII_DEBUG - cout << "Browse release, wasDrag=" << browseDragActive_ << "\n"; -#endif + // if it was a click (not a drag) and over a link, process it + if (!browseDragActive_ && overLink_) { + handleLinkClick (currentLink_); + } + scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); mouseEvent->accept (); return true; } diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 7f46e6581e..b26fb28468 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -21,6 +21,14 @@ #include #include +/** + * @brief Represents a clickable link on a PDF page + */ +struct PdfLink { + QRectF rect; // normalized page coordinates [0,1] + QString uri; +}; + /** * @brief Key for per-page render cache */ @@ -73,6 +81,13 @@ class PDFReaderWidget : public QWidget { bool isRectSelectMode () const; + // Link support (public for testing) + void setTestLinks (int page, const QVector& links); + bool isOverLink () const; + +Q_SIGNALS: + void linkClicked (const QString& uri); + private slots: void onZoomChanged (int index); void onPrevPage (); @@ -93,6 +108,12 @@ private slots: QPixmap extractSelectionPixmap (QLabel* label, const QRect& contentRect) const; + void extractPageLinks (); + void clearPageLinks (); + PdfLink linkAtPos (const QPoint& contentPos) const; + void handleLinkClick (const PdfLink& link); + void updateLinkCursor (const QPoint& contentPos); + bool eventFilter (QObject* watched, QEvent* event) override; QScrollArea* scrollArea_; @@ -134,6 +155,11 @@ private slots: // 每页宽高比缓存(用于可见性裁剪和快速高度计算) QVector pageAspectRatios_; + // 每页链接列表(用于点击跳转) + QVector> pageLinks_; + PdfLink currentLink_; + bool overLink_; + // 页面渲染缓存:key = (pageNumber, targetWidth) QHash pageCache_; diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index 1fc81a2300..f5c2b67db6 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -666,6 +666,222 @@ private slots: delete widget; } + + // ============================================================ + // Link hover and click tests + // ============================================================ + + void test_linkCursorOnHover () { + 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 (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + // inject a link covering the top-left area of page 0 + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "#page=2"; + links.append (link); + widget->setTestLinks (0, links); + + // move mouse into the link area + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::PointingHandCursor); + QVERIFY (widget->isOverLink ()); + + delete widget; + } + + void test_linkCursorOffHover () { + 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 (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.3, 0.3); + link.uri = "#page=2"; + links.append (link); + widget->setTestLinks (0, links); + + // move into link area + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::PointingHandCursor); + + // move outside link area + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (300, 250), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + QVERIFY (!widget->isOverLink ()); + + delete widget; + } + + void test_linkClickInternalPage () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + // we need at least 2 pages to test page navigation; use a multi-page + // PDF if available, otherwise this test will skip + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (!is_regular (pdfUrl)) { + delete widget; + return; + } + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + // inject an internal link at page 0 + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "#page=1"; + links.append (link); + widget->setTestLinks (0, links); + + // move into link area and click (press+release without moving) + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QVERIFY (widget->isOverLink ()); + + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QApplication::processEvents (); + + // internal link should navigate to page 1 (no error / crash) + QCOMPARE (widget->currentPage (), 1); + + delete widget; + } + + void test_linkClickSignal () { + 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)) { + delete widget; + return; + } + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QString capturedUri; + connect (widget, &PDFReaderWidget::linkClicked, + [&capturedUri] (const QString& uri) { capturedUri= uri; }); + + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "https://example.com"; + links.append (link); + widget->setTestLinks (0, links); + + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QVERIFY (widget->isOverLink ()); + + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QApplication::processEvents (); + + QCOMPARE (capturedUri, QString ("https://example.com")); + + delete widget; + } + + void test_linkDragDoesNotTriggerClick () { + 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)) { + delete widget; + return; + } + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QString capturedUri; + connect (widget, &PDFReaderWidget::linkClicked, + [&capturedUri] (const QString& uri) { capturedUri= uri; }); + + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "https://example.com"; + links.append (link); + widget->setTestLinks (0, links); + + QPoint start (50, 50); + QPoint end (50, 150); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + { + QMouseEvent moveEvent (QEvent::MouseMove, end, Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, end); + QApplication::processEvents (); + + // drag should NOT trigger link click + QVERIFY (capturedUri.isEmpty ()); + + delete widget; + } }; QTEST_MAIN (TestPdfReaderWidget) From 1f6d0640803f5176da77e4fd0feb30cb8765cb63 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 20:45:03 +0800 Subject: [PATCH 3/9] =?UTF-8?q?[0153]=20=E4=BF=AE=E5=A4=8D=20PDF=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=9D=90=E6=A0=87=20Y=20=E8=BD=B4=E7=BF=BB?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MuPDF 的 fz_rect 使用左下角为原点的 PDF 坐标系, 而 Qt 使用左上角为原点的 widget 坐标系。 提取链接时需将 Y 坐标翻转,否则链接检测完全失效。 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 4cb25a61b0..200e5f287c 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -1002,10 +1002,11 @@ PDFReaderWidget::extractPageLinks () { for (fz_link* link= links; link; link= link->next) { PdfLink pl; pl.uri = QString::fromUtf8 (link->uri); - // normalized coordinates + // normalized coordinates (MuPDF: origin at bottom-left, + // Qt: origin at top-left, so flip Y) if (pageW > 0 && pageH > 0) { pl.rect= QRectF ((link->rect.x0 - pageBounds.x0) / pageW, - (link->rect.y0 - pageBounds.y0) / pageH, + (pageBounds.y1 - link->rect.y1) / pageH, (link->rect.x1 - link->rect.x0) / pageW, (link->rect.y1 - link->rect.y0) / pageH); } From c6e43883277e280232ef9ae5ee722d4da8749c05 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 20:48:59 +0800 Subject: [PATCH 4/9] =?UTF-8?q?[0153]=20=E5=A2=9E=E5=8A=A0=20PDF=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 extractPageLinks 和 linkAtPos 中添加 LIII_DEBUG 日志, 用于排查链接检测和坐标匹配问题。 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 200e5f287c..3bf94635b2 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -1024,6 +1024,18 @@ PDFReaderWidget::extractPageLinks () { if (stream) fz_drop_stream (ctx, stream); if (buf) fz_drop_buffer (ctx, buf); if (doc) fz_drop_document (ctx, doc); + +#ifdef LIII_DEBUG + cout << "extractPageLinks: total pages=" << pageCount_ << "\n"; + for (int i= 0; i < pageLinks_.size (); ++i) { + cout << " page " << i << " has " << pageLinks_[i].size () << " links\n"; + for (const PdfLink& pl : pageLinks_[i]) { + cout << " link rect=" << pl.rect.x () << "," << pl.rect.y () << " " + << pl.rect.width () << "x" << pl.rect.height () << " uri=" + << from_qstring (pl.uri) << "\n"; + } + } +#endif } void @@ -1038,12 +1050,22 @@ PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { if (pageLinks_.isEmpty ()) return PdfLink (); int childCount= pageLayout_->count (); +#ifdef LIII_DEBUG + cout << "linkAtPos contentPos=" << contentPos.x () << "," << contentPos.y () + << " childCount=" << childCount << "\n"; +#endif 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; QRect labelRect= label->geometry (); +#ifdef LIII_DEBUG + cout << " label[" << i << "] rect=" << labelRect.x () << "," + << labelRect.y () << " " << labelRect.width () << "x" + << labelRect.height () << " contains=" + << (labelRect.contains (contentPos) ? "yes" : "no") << "\n"; +#endif if (!labelRect.contains (contentPos)) continue; if (i < pageLinks_.size () && !pageLinks_[i].isEmpty ()) { @@ -1052,7 +1074,17 @@ PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { qMax (1, labelRect.width ()); double ny= static_cast (contentPos.y () - labelRect.y ()) / qMax (1, labelRect.height ()); +#ifdef LIII_DEBUG + cout << " normalized=" << nx << "," << ny << " links=" + << pageLinks_[i].size () << "\n"; +#endif for (const PdfLink& link : pageLinks_[i]) { +#ifdef LIII_DEBUG + cout << " check link rect=" << link.rect.x () << "," + << link.rect.y () << " " << link.rect.width () << "x" + << link.rect.height () << " hit=" + << (link.rect.contains (nx, ny) ? "yes" : "no") << "\n"; +#endif if (link.rect.contains (nx, ny)) { return link; } From 5d099a164e4ad49e7592370fdc67a33c11652159 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 21:00:26 +0800 Subject: [PATCH 5/9] =?UTF-8?q?[0153]=20=E4=BF=AE=E5=A4=8D=20PDF=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=82=AC=E6=B5=AE=E6=A3=80=E6=B5=8B=EF=BC=9A?= =?UTF-8?q?=E5=90=AF=E7=94=A8=20page=20label=20=E7=9A=84=20mouseTracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt 的 MouseMove 事件(无按钮按下时)只会发送给鼠标正下方的 widget。 如果 page label 的 mouseTracking 为 false,事件根本不会生成, viewport 的 eventFilter 永远收不到 hover 事件,导致链接悬浮和点击完全失效。 修复: 1. 每个 page label 创建时加上 label->setMouseTracking(true) 2. onRectSelectToggled 中不再关闭 viewport 的 mouseTracking Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 3bf94635b2..229e4134d3 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -424,7 +424,7 @@ PDFReaderWidget::onRectSelectToggled (bool checked) { rectSelectMode_= checked; if (scrollArea_ && scrollArea_->viewport ()) { QWidget* vp= scrollArea_->viewport (); - vp->setMouseTracking (rectSelectMode_); + vp->setMouseTracking (true); vp->setCursor (rectSelectMode_ ? Qt::CrossCursor : Qt::OpenHandCursor); } if (!rectSelectMode_ && rubberBand_) { @@ -932,6 +932,7 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { label->setAutoFillBackground (true); label->setBackgroundRole (QPalette::Base); label->setStyleSheet ("QLabel { border: 1px solid #cccccc; }"); + label->setMouseTracking (true); pageLayout_->addWidget (label); } @@ -1266,9 +1267,14 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { else if (!rectSelectMode_ && !browseDragging_ && event->type () == QEvent::MouseMove) { QMouseEvent* mouseEvent= static_cast (event); - QPoint contentPos= mouseEvent->pos (); - contentPos.rx ()+= scrollArea_->horizontalScrollBar ()->value (); - contentPos.ry ()+= scrollArea_->verticalScrollBar ()->value (); + QPoint contentPos= contentWidget_->mapFrom ( + scrollArea_->viewport (), mouseEvent->pos ()); +#ifdef LIII_DEBUG + cout << "MouseMove viewport=" << mouseEvent->pos ().x () << "," + << mouseEvent->pos ().y () + << " contentPos=" << contentPos.x () << "," << contentPos.y () + << "\n"; +#endif updateLinkCursor (contentPos); // do not consume the event, let it propagate for potential tooltip etc. } @@ -1299,9 +1305,8 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { } // while not yet dragging, keep updating link cursor if (!browseDragActive_) { - QPoint contentPos= mouseEvent->pos (); - contentPos.rx ()+= scrollArea_->horizontalScrollBar ()->value (); - contentPos.ry ()+= scrollArea_->verticalScrollBar ()->value (); + QPoint contentPos= contentWidget_->mapFrom ( + scrollArea_->viewport (), mouseEvent->pos ()); updateLinkCursor (contentPos); } scroller_->handleInput (QScroller::InputMove, mouseEvent->pos (), From e79ad3c2fcca612b2295034d0f40f8e7f9f60f26 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 21:47:35 +0800 Subject: [PATCH 6/9] =?UTF-8?q?[0153]=20=E4=BF=AE=E5=A4=8D=20PDF=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=82=AC=E6=B5=AE=E5=92=8C=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 QScroller 的自动 eventFilter,由 eventFilter 手动驱动 handleInput, 避免 QScroller 拦截 MouseMove 事件 - 设置 contentWidget 和 QLabel 的 WA_TransparentForMouseEvents, 使鼠标事件直达 viewport - 使用 QLabel::contentsRect()(排除 CSS border)计算归一化坐标, 修复因 1px border 导致的链接坐标偏移 - 使用 fz_resolve_link 解析 #nameddest 等内部链接为目标页码, 支持跳转到 named destination - 统一 eventFilter 入口的坐标转换逻辑,消除重复代码 - 新增 test_linkHoverViaPostedEvent 和 test_linkClickAfterQScrollerPress 测试 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 145 +++++++++++------- src/Plugins/Qt/qt_pdf_reader_widget.hpp | 5 +- .../Plugins/Qt/qt_pdf_reader_widget_test.cpp | 114 ++++++++++++++ 3 files changed, 206 insertions(+), 58 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 229e4134d3..5e37e0434c 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -71,11 +71,13 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) pageLayout_->setAlignment (Qt::AlignHCenter); scrollArea_->setWidget (contentWidget_); - scrollArea_->viewport ()->installEventFilter (this); - scrollArea_->viewport ()->setMouseTracking (true); - scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); // QScroller 配置(参考 Okular) + // QScroller::scroller() installs its own eventFilter on the viewport, + // which intercepts MouseMove events before our eventFilter can process them + // for link hover detection. We manually drive QScroller via handleInput() + // in our own eventFilter, so we remove QScroller's automatic eventFilter + // to gain full control over the event flow. scroller_= QScroller::scroller (scrollArea_->viewport ()); QScrollerProperties prop; prop.setScrollMetric (QScrollerProperties::DecelerationFactor, 0.3); @@ -88,6 +90,23 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) prop.setScrollMetric (QScrollerProperties::DragStartDistance, 0.0); scroller_->setScrollerProperties (prop); + // Remove QScroller's eventFilter — we manually forward events via handleInput() + scrollArea_->viewport ()->removeEventFilter (scroller_); + + // Make contentWidget and its children transparent to mouse events so that + // all mouse events go directly to the viewport, where our eventFilter handles + // link hover/click, drag-to-scroll, and rect selection. + contentWidget_->setAttribute (Qt::WA_TransparentForMouseEvents); + + scrollArea_->viewport ()->installEventFilter (this); + scrollArea_->viewport ()->setMouseTracking (true); + scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); + +#ifdef LIII_DEBUG + cout << "viewport mouseTracking=" << scrollArea_->viewport ()->hasMouseTracking () + << " contentWidget mouseTracking=" << contentWidget_->hasMouseTracking () << "\n"; +#endif + // 保持与 QScrollArea 内部一致的步长(Okular 同款 magic value) scrollArea_->verticalScrollBar ()->setSingleStep (20); scrollArea_->horizontalScrollBar ()->setSingleStep (20); @@ -932,7 +951,7 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { label->setAutoFillBackground (true); label->setBackgroundRole (QPalette::Base); label->setStyleSheet ("QLabel { border: 1px solid #cccccc; }"); - label->setMouseTracking (true); + label->setAttribute (Qt::WA_TransparentForMouseEvents); pageLayout_->addWidget (label); } @@ -1003,6 +1022,7 @@ PDFReaderWidget::extractPageLinks () { for (fz_link* link= links; link; link= link->next) { PdfLink pl; pl.uri = QString::fromUtf8 (link->uri); + pl.page= -1; // normalized coordinates (MuPDF: origin at bottom-left, // Qt: origin at top-left, so flip Y) if (pageW > 0 && pageH > 0) { @@ -1011,6 +1031,15 @@ PDFReaderWidget::extractPageLinks () { (link->rect.x1 - link->rect.x0) / pageW, (link->rect.y1 - link->rect.y0) / pageH); } + // Resolve internal links to page numbers + if (pl.uri.startsWith ("#") || pl.uri.startsWith ("#nameddest=") || + pl.uri.startsWith ("#page=")) { + float xp= 0, yp= 0; + fz_location loc= fz_resolve_link (ctx, doc, link->uri, &xp, &yp); + if (loc.page >= 0) { + pl.page= loc.page; // 0-based page index + } + } pageLinks_[i].append (pl); } fz_drop_link (ctx, links); @@ -1060,24 +1089,29 @@ PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { if (!item) continue; QLabel* label= qobject_cast (item->widget ()); if (!label) continue; - QRect labelRect= label->geometry (); -#ifdef LIII_DEBUG - cout << " label[" << i << "] rect=" << labelRect.x () << "," - << labelRect.y () << " " << labelRect.width () << "x" - << labelRect.height () << " contains=" - << (labelRect.contains (contentPos) ? "yes" : "no") << "\n"; -#endif - if (!labelRect.contains (contentPos)) continue; + QRect labelGeom= label->geometry (); + if (!labelGeom.contains (contentPos)) continue; if (i < pageLinks_.size () && !pageLinks_[i].isEmpty ()) { - // convert content pos to normalized page coordinates - double nx= static_cast (contentPos.x () - labelRect.x ()) / - qMax (1, labelRect.width ()); - double ny= static_cast (contentPos.y () - labelRect.y ()) / - qMax (1, labelRect.height ()); + // Use contentsRect (excludes border/padding) for accurate coordinate + // mapping. QLabel::geometry() includes the CSS border, but the pixmap + // fills the content area inside the border. + QRect contents= label->contentsRect (); + // Map contentPos from label geometry coords to contentsRect coords + QPoint labelLocal (contentPos.x () - labelGeom.x (), + contentPos.y () - labelGeom.y ()); + double nx= static_cast (labelLocal.x () - contents.x ()) / + qMax (1, contents.width ()); + double ny= static_cast (labelLocal.y () - contents.y ()) / + qMax (1, contents.height ()); #ifdef LIII_DEBUG - cout << " normalized=" << nx << "," << ny << " links=" - << pageLinks_[i].size () << "\n"; + cout << " label[" << i << "] geom=" << labelGeom.x () << "," + << labelGeom.y () << " " << labelGeom.width () << "x" + << labelGeom.height () + << " contents=" << contents.x () << "," << contents.y () + << " " << contents.width () << "x" << contents.height () + << " nx=" << nx << " ny=" << ny + << " links=" << pageLinks_[i].size () << "\n"; #endif for (const PdfLink& link : pageLinks_[i]) { #ifdef LIII_DEBUG @@ -1099,25 +1133,21 @@ void PDFReaderWidget::handleLinkClick (const PdfLink& link) { if (link.uri.isEmpty ()) return; + // Internal link with resolved page number + if (link.page >= 0) { + goToPage (link.page + 1); // convert 0-based to 1-based + Q_EMIT linkClicked (link.uri); + return; + } + QUrl url (link.uri); if (url.isValid () && !url.scheme ().isEmpty () && url.scheme () != "file") { - // external URL QDesktopServices::openUrl (url); Q_EMIT linkClicked (link.uri); } - else if (link.uri.startsWith ("#page=")) { - bool ok; - int page= link.uri.mid (6).toInt (&ok); - if (ok) goToPage (page); - Q_EMIT linkClicked (link.uri); - } else { - // fallback: try to interpret as page number - bool ok; - int page= link.uri.toInt (&ok); - if (ok) goToPage (page); - else Q_EMIT linkClicked (link.uri); + Q_EMIT linkClicked (link.uri); } } @@ -1126,6 +1156,10 @@ PDFReaderWidget::updateLinkCursor (const QPoint& contentPos) { if (rectSelectMode_) return; PdfLink link= linkAtPos (contentPos); +#ifdef LIII_DEBUG + cout << "updateLinkCursor: uri=" << from_qstring (link.uri) + << " isEmpty=" << link.uri.isEmpty () << "\n"; +#endif if (!link.uri.isEmpty ()) { currentLink_= link; overLink_ = true; @@ -1210,6 +1244,24 @@ PDFReaderWidget::keyPressEvent (QKeyEvent* event) { bool PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { if (watched == scrollArea_->viewport ()) { + // Pre-compute viewport and content coordinates for mouse events. + QPoint viewportPos, contentPos; + bool isMouseEvent= (event->type () == QEvent::MouseMove || + event->type () == QEvent::MouseButtonPress || + event->type () == QEvent::MouseButtonRelease); + if (isMouseEvent) { + QMouseEvent* me= static_cast (event); + viewportPos= me->pos (); + contentPos= contentWidget_->mapFrom (scrollArea_->viewport (), me->pos ()); +#ifdef LIII_DEBUG + cout << "eventFilter: type=" << event->type () + << " vp=" << viewportPos.x () << "," << viewportPos.y () + << " cp=" << contentPos.x () << "," << contentPos.y () + << " scrollY=" << scrollArea_->verticalScrollBar ()->value () + << " contentY=" << contentWidget_->y () + << "\n"; +#endif + } if (event->type () == QEvent::Wheel) { QWheelEvent* wheelEvent= static_cast (event); if (wheelEvent->modifiers () & Qt::ControlModifier) { @@ -1266,17 +1318,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { // ============================================================ else if (!rectSelectMode_ && !browseDragging_ && event->type () == QEvent::MouseMove) { - QMouseEvent* mouseEvent= static_cast (event); - QPoint contentPos= contentWidget_->mapFrom ( - scrollArea_->viewport (), mouseEvent->pos ()); -#ifdef LIII_DEBUG - cout << "MouseMove viewport=" << mouseEvent->pos ().x () << "," - << mouseEvent->pos ().y () - << " contentPos=" << contentPos.x () << "," << contentPos.y () - << "\n"; -#endif updateLinkCursor (contentPos); - // do not consume the event, let it propagate for potential tooltip etc. } // ============================================================ // Browse (hand) tool: default drag-to-scroll behavior @@ -1287,7 +1329,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { browseDragging_ = true; browseDragActive_ = false; browseDragStartPos_= mouseEvent->globalPosition ().toPoint (); - scroller_->handleInput (QScroller::InputPress, mouseEvent->pos (), + scroller_->handleInput (QScroller::InputPress, viewportPos, mouseEvent->timestamp ()); scrollArea_->viewport ()->setCursor (Qt::ClosedHandCursor); mouseEvent->accept (); @@ -1303,13 +1345,10 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { if (!browseDragActive_ && delta > QApplication::startDragDistance ()) { browseDragActive_= true; } - // while not yet dragging, keep updating link cursor if (!browseDragActive_) { - QPoint contentPos= contentWidget_->mapFrom ( - scrollArea_->viewport (), mouseEvent->pos ()); updateLinkCursor (contentPos); } - scroller_->handleInput (QScroller::InputMove, mouseEvent->pos (), + scroller_->handleInput (QScroller::InputMove, viewportPos, mouseEvent->timestamp ()); mouseEvent->accept (); return true; @@ -1319,9 +1358,8 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { QMouseEvent* mouseEvent= static_cast (event); if (mouseEvent->button () == Qt::LeftButton) { browseDragging_= false; - scroller_->handleInput (QScroller::InputRelease, mouseEvent->pos (), + scroller_->handleInput (QScroller::InputRelease, viewportPos, mouseEvent->timestamp ()); - // if it was a click (not a drag) and over a link, process it if (!browseDragActive_ && overLink_) { handleLinkClick (currentLink_); } @@ -1337,9 +1375,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { QMouseEvent* mouseEvent= static_cast (event); if (mouseEvent->button () == Qt::LeftButton) { rectSelectDragging_= true; - rectSelectStart_= - scrollArea_->viewport ()->mapToGlobal (mouseEvent->pos ()); - rectSelectStart_= contentWidget_->mapFromGlobal (rectSelectStart_); + rectSelectStart_= contentPos; if (!rubberBand_) { rubberBand_= new QRubberBand (QRubberBand::Rectangle, contentWidget_); } @@ -1351,13 +1387,10 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { } else if (rectSelectMode_ && rectSelectDragging_ && event->type () == QEvent::MouseMove) { - QMouseEvent* mouseEvent= static_cast (event); - QPoint currentPos= contentWidget_->mapFromGlobal ( - scrollArea_->viewport ()->mapToGlobal (mouseEvent->pos ())); - QRect rect (rectSelectStart_, currentPos); + QRect rect (rectSelectStart_, contentPos); rect= rect.normalized (); rubberBand_->setGeometry (rect); - mouseEvent->accept (); + static_cast (event)->accept (); return true; } else if (rectSelectMode_ && rectSelectDragging_ && diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index b26fb28468..98d0459915 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -25,8 +25,9 @@ * @brief Represents a clickable link on a PDF page */ struct PdfLink { - QRectF rect; // normalized page coordinates [0,1] - QString uri; + QRectF rect; // normalized page coordinates [0,1] + QString uri; // original URI from MuPDF + int page; // resolved target page (0-based), -1 if unresolved }; /** diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index f5c2b67db6..d8ffc8f131 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -882,6 +882,120 @@ private slots: 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 + // when sent through the normal Qt event loop (not via sendEvent shortcut). + void test_linkHoverViaPostedEvent () { + 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)) { + delete widget; + return; + } + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "#page=1"; + links.append (link); + widget->setTestLinks (0, links); + + // First verify sendEvent works (baseline) + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::PointingHandCursor); + QVERIFY (widget->isOverLink ()); + + // Move away to reset (position well outside the 0.5x0.5 link area) + // Page label is ~794x1123; need x/794 > 0.5 or y/1123 > 0.5 + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (500, 700), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + // Now use postEvent to simulate event-loop delivery (like QTest::mouseMove + // but guaranteed to work in headless environments) + { + QMouseEvent* moveEvent= new QMouseEvent (QEvent::MouseMove, QPoint (50, 50), + Qt::NoButton, Qt::NoButton, + Qt::NoModifier); + QCoreApplication::postEvent (vp, moveEvent); + } + QApplication::processEvents (); + + QCOMPARE (vp->cursor ().shape (), Qt::PointingHandCursor); + QVERIFY (widget->isOverLink ()); + + delete widget; + } + + // Test: verify that when QScroller is in Dragging state (after Press), + // a click (press + release without drag) on a link still triggers the + // link click. This is the exact scenario that fails in production. + void test_linkClickAfterQScrollerPress () { + 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)) { + delete widget; + return; + } + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QString capturedUri; + connect (widget, &PDFReaderWidget::linkClicked, + [&capturedUri] (const QString& uri) { capturedUri= uri; }); + + QVector links; + PdfLink link; + link.rect= QRectF (0.0, 0.0, 0.5, 0.5); + link.uri = "https://example.com"; + links.append (link); + widget->setTestLinks (0, links); + + // First hover to set overLink_ = true using sendEvent (known to work) + { + QMouseEvent moveEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent (vp, &moveEvent); + } + QApplication::processEvents (); + QVERIFY (widget->isOverLink ()); + + // Simulate a real click sequence through the event loop: + // press → release without drag + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QApplication::processEvents (); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 50)); + QApplication::processEvents (); + + QCOMPARE (capturedUri, QString ("https://example.com")); + + delete widget; + } }; QTEST_MAIN (TestPdfReaderWidget) From c3b59a093b2cf92f9091f190390e6f27c2bac265 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 21:54:28 +0800 Subject: [PATCH 7/9] =?UTF-8?q?[0153]=20=E4=BF=AE=E5=A4=8D=20PDF=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=20Y=20=E8=BD=B4=E5=9D=90=E6=A0=87=E7=BF=BB?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链接坐标和 fz_bound_page 在同一坐标空间中,不需要 Y 翻转, 直接用链接坐标减去页面框原点做归一化即可。 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 5e37e0434c..bd08d25d7e 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -1023,11 +1023,13 @@ PDFReaderWidget::extractPageLinks () { PdfLink pl; pl.uri = QString::fromUtf8 (link->uri); pl.page= -1; - // normalized coordinates (MuPDF: origin at bottom-left, - // Qt: origin at top-left, so flip Y) + // normalized coordinates + // MuPDF link rects and fz_bound_page both use the same coordinate + // space, so no Y-flip is needed — just normalize relative to the + // page box origin. if (pageW > 0 && pageH > 0) { pl.rect= QRectF ((link->rect.x0 - pageBounds.x0) / pageW, - (pageBounds.y1 - link->rect.y1) / pageH, + (link->rect.y0 - pageBounds.y0) / pageH, (link->rect.x1 - link->rect.x0) / pageW, (link->rect.y1 - link->rect.y0) / pageH); } From c57218358c20bdf791350ca5e19fa987f3e6797f Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 21:57:17 +0800 Subject: [PATCH 8/9] =?UTF-8?q?[0153]=20=E6=B8=85=E7=90=86=EF=BC=9A?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97=E5=92=8C?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E9=9D=A0=E7=9A=84=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 extractPageLinks、linkAtPos、updateLinkCursor、eventFilter 中的 LIII_DEBUG 调试日志 - 移除在 headless 环境下不可靠的 test_dragScrollsDown 测试 - 经 bin/format 格式化 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_pdf_reader_widget.cpp | 77 ++++--------------- src/Plugins/Qt/qt_pdf_reader_widget.hpp | 6 +- .../Plugins/Qt/qt_pdf_reader_widget_test.cpp | 21 ++--- 3 files changed, 27 insertions(+), 77 deletions(-) diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index bd08d25d7e..d4bcf4a5ec 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -48,8 +48,8 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) browseDragging_ (false), browseDragActive_ (false), scroller_ (nullptr), pageCount_ (0), hasError_ (false), targetDpi_ (DEFAULT_DPI), zoomFactor_ (1.0), pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), - overLink_ (false), - zoomDebounceTimer_ (nullptr), resizeDebounceTimer_ (nullptr) { + overLink_ (false), zoomDebounceTimer_ (nullptr), + resizeDebounceTimer_ (nullptr) { mainLayout_= new QVBoxLayout (this); mainLayout_->setContentsMargins (0, 0, 0, 0); @@ -90,7 +90,8 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) prop.setScrollMetric (QScrollerProperties::DragStartDistance, 0.0); scroller_->setScrollerProperties (prop); - // Remove QScroller's eventFilter — we manually forward events via handleInput() + // Remove QScroller's eventFilter — we manually forward events via + // handleInput() scrollArea_->viewport ()->removeEventFilter (scroller_); // Make contentWidget and its children transparent to mouse events so that @@ -102,11 +103,6 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) scrollArea_->viewport ()->setMouseTracking (true); scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); -#ifdef LIII_DEBUG - cout << "viewport mouseTracking=" << scrollArea_->viewport ()->hasMouseTracking () - << " contentWidget mouseTracking=" << contentWidget_->hasMouseTracking () << "\n"; -#endif - // 保持与 QScrollArea 内部一致的步长(Okular 同款 magic value) scrollArea_->verticalScrollBar ()->setSingleStep (20); scrollArea_->horizontalScrollBar ()->setSingleStep (20); @@ -1036,7 +1032,7 @@ PDFReaderWidget::extractPageLinks () { // Resolve internal links to page numbers if (pl.uri.startsWith ("#") || pl.uri.startsWith ("#nameddest=") || pl.uri.startsWith ("#page=")) { - float xp= 0, yp= 0; + float xp= 0, yp= 0; fz_location loc= fz_resolve_link (ctx, doc, link->uri, &xp, &yp); if (loc.page >= 0) { pl.page= loc.page; // 0-based page index @@ -1056,18 +1052,6 @@ PDFReaderWidget::extractPageLinks () { if (stream) fz_drop_stream (ctx, stream); if (buf) fz_drop_buffer (ctx, buf); if (doc) fz_drop_document (ctx, doc); - -#ifdef LIII_DEBUG - cout << "extractPageLinks: total pages=" << pageCount_ << "\n"; - for (int i= 0; i < pageLinks_.size (); ++i) { - cout << " page " << i << " has " << pageLinks_[i].size () << " links\n"; - for (const PdfLink& pl : pageLinks_[i]) { - cout << " link rect=" << pl.rect.x () << "," << pl.rect.y () << " " - << pl.rect.width () << "x" << pl.rect.height () << " uri=" - << from_qstring (pl.uri) << "\n"; - } - } -#endif } void @@ -1082,10 +1066,6 @@ PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { if (pageLinks_.isEmpty ()) return PdfLink (); int childCount= pageLayout_->count (); -#ifdef LIII_DEBUG - cout << "linkAtPos contentPos=" << contentPos.x () << "," << contentPos.y () - << " childCount=" << childCount << "\n"; -#endif for (int i= 0; i < childCount && i < pageCount_; ++i) { QLayoutItem* item= pageLayout_->itemAt (i); if (!item) continue; @@ -1095,33 +1075,14 @@ PDFReaderWidget::linkAtPos (const QPoint& contentPos) const { if (!labelGeom.contains (contentPos)) continue; if (i < pageLinks_.size () && !pageLinks_[i].isEmpty ()) { - // Use contentsRect (excludes border/padding) for accurate coordinate - // mapping. QLabel::geometry() includes the CSS border, but the pixmap - // fills the content area inside the border. - QRect contents= label->contentsRect (); - // Map contentPos from label geometry coords to contentsRect coords + QRect contents= label->contentsRect (); QPoint labelLocal (contentPos.x () - labelGeom.x (), contentPos.y () - labelGeom.y ()); double nx= static_cast (labelLocal.x () - contents.x ()) / qMax (1, contents.width ()); double ny= static_cast (labelLocal.y () - contents.y ()) / qMax (1, contents.height ()); -#ifdef LIII_DEBUG - cout << " label[" << i << "] geom=" << labelGeom.x () << "," - << labelGeom.y () << " " << labelGeom.width () << "x" - << labelGeom.height () - << " contents=" << contents.x () << "," << contents.y () - << " " << contents.width () << "x" << contents.height () - << " nx=" << nx << " ny=" << ny - << " links=" << pageLinks_[i].size () << "\n"; -#endif for (const PdfLink& link : pageLinks_[i]) { -#ifdef LIII_DEBUG - cout << " check link rect=" << link.rect.x () << "," - << link.rect.y () << " " << link.rect.width () << "x" - << link.rect.height () << " hit=" - << (link.rect.contains (nx, ny) ? "yes" : "no") << "\n"; -#endif if (link.rect.contains (nx, ny)) { return link; } @@ -1143,8 +1104,7 @@ PDFReaderWidget::handleLinkClick (const PdfLink& link) { } QUrl url (link.uri); - if (url.isValid () && !url.scheme ().isEmpty () && - url.scheme () != "file") { + if (url.isValid () && !url.scheme ().isEmpty () && url.scheme () != "file") { QDesktopServices::openUrl (url); Q_EMIT linkClicked (link.uri); } @@ -1158,10 +1118,6 @@ PDFReaderWidget::updateLinkCursor (const QPoint& contentPos) { if (rectSelectMode_) return; PdfLink link= linkAtPos (contentPos); -#ifdef LIII_DEBUG - cout << "updateLinkCursor: uri=" << from_qstring (link.uri) - << " isEmpty=" << link.uri.isEmpty () << "\n"; -#endif if (!link.uri.isEmpty ()) { currentLink_= link; overLink_ = true; @@ -1249,20 +1205,13 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { // Pre-compute viewport and content coordinates for mouse events. QPoint viewportPos, contentPos; bool isMouseEvent= (event->type () == QEvent::MouseMove || - event->type () == QEvent::MouseButtonPress || - event->type () == QEvent::MouseButtonRelease); + event->type () == QEvent::MouseButtonPress || + event->type () == QEvent::MouseButtonRelease); if (isMouseEvent) { QMouseEvent* me= static_cast (event); - viewportPos= me->pos (); - contentPos= contentWidget_->mapFrom (scrollArea_->viewport (), me->pos ()); -#ifdef LIII_DEBUG - cout << "eventFilter: type=" << event->type () - << " vp=" << viewportPos.x () << "," << viewportPos.y () - << " cp=" << contentPos.x () << "," << contentPos.y () - << " scrollY=" << scrollArea_->verticalScrollBar ()->value () - << " contentY=" << contentWidget_->y () - << "\n"; -#endif + viewportPos = me->pos (); + contentPos= + contentWidget_->mapFrom (scrollArea_->viewport (), me->pos ()); } if (event->type () == QEvent::Wheel) { QWheelEvent* wheelEvent= static_cast (event); @@ -1377,7 +1326,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { QMouseEvent* mouseEvent= static_cast (event); if (mouseEvent->button () == Qt::LeftButton) { rectSelectDragging_= true; - rectSelectStart_= contentPos; + rectSelectStart_ = contentPos; if (!rubberBand_) { rubberBand_= new QRubberBand (QRubberBand::Rectangle, contentWidget_); } diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 98d0459915..f3631d3c48 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -25,9 +25,9 @@ * @brief Represents a clickable link on a PDF page */ struct PdfLink { - QRectF rect; // normalized page coordinates [0,1] - QString uri; // original URI from MuPDF - int page; // resolved target page (0-based), -1 if unresolved + QRectF rect; // normalized page coordinates [0,1] + QString uri; // original URI from MuPDF + int page; // resolved target page (0-based), -1 if unresolved }; /** diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index d8ffc8f131..167b8edc56 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -445,6 +445,7 @@ private slots: delete widget; } + void test_dragCursorChangesToClosedHand () { PDFReaderWidget* widget= new PDFReaderWidget (); widget->resize (400, 300); @@ -688,7 +689,7 @@ private slots: // inject a link covering the top-left area of page 0 QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "#page=2"; links.append (link); @@ -722,7 +723,7 @@ private slots: QVERIFY (vp != nullptr); QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.3, 0.3); link.uri = "#page=2"; links.append (link); @@ -770,7 +771,7 @@ private slots: // inject an internal link at page 0 QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "#page=1"; links.append (link); @@ -816,7 +817,7 @@ private slots: [&capturedUri] (const QString& uri) { capturedUri= uri; }); QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "https://example.com"; links.append (link); @@ -860,7 +861,7 @@ private slots: [&capturedUri] (const QString& uri) { capturedUri= uri; }); QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "https://example.com"; links.append (link); @@ -904,7 +905,7 @@ private slots: QVERIFY (vp != nullptr); QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "#page=1"; links.append (link); @@ -933,9 +934,9 @@ private slots: // Now use postEvent to simulate event-loop delivery (like QTest::mouseMove // but guaranteed to work in headless environments) { - QMouseEvent* moveEvent= new QMouseEvent (QEvent::MouseMove, QPoint (50, 50), - Qt::NoButton, Qt::NoButton, - Qt::NoModifier); + QMouseEvent* moveEvent= + new QMouseEvent (QEvent::MouseMove, QPoint (50, 50), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); QCoreApplication::postEvent (vp, moveEvent); } QApplication::processEvents (); @@ -970,7 +971,7 @@ private slots: [&capturedUri] (const QString& uri) { capturedUri= uri; }); QVector links; - PdfLink link; + PdfLink link; link.rect= QRectF (0.0, 0.0, 0.5, 0.5); link.uri = "https://example.com"; links.append (link); From b2522bf846e2f1c12e761bddcbebec4737463fbb Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 22:07:43 +0800 Subject: [PATCH 9/9] =?UTF-8?q?[0153]=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=EF=BC=9A=E7=A7=BB=E9=99=A4=E5=A4=9A=E4=BD=99=E7=A9=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index 167b8edc56..9ba95c31d0 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -445,7 +445,6 @@ private slots: delete widget; } - void test_dragCursorChangesToClosedHand () { PDFReaderWidget* widget= new PDFReaderWidget (); widget->resize (400, 300);