diff --git a/cmd/gen-settings.ts b/cmd/gen-settings.ts index 4e3c362e49a..1e24f601545 100644 --- a/cmd/gen-settings.ts +++ b/cmd/gen-settings.ts @@ -746,6 +746,15 @@ const globalPrefs: Field[] = [ ), setVersion(mkField("ScrollbarInSinglePage", Bool, false, "if true, we show scrollbar in single page mode"), "3.6"), setVersion(mkField("SmoothScroll", Bool, false, "if true, implements smooth scrolling"), "3.6"), + setVersion( + mkField( + "EnableCitationHover", + Bool, + true, + "if true, hovering an internal-document link shows a popup rendering the destination region (citation entry, figure, footnote)", + ), + "3.7", + ), setVersion( mkField( "FastScrollOverScrollbar", diff --git a/docs/md/Advanced-options-settings.md b/docs/md/Advanced-options-settings.md index 9b405391aa6..956602d8640 100644 --- a/docs/md/Advanced-options-settings.md +++ b/docs/md/Advanced-options-settings.md @@ -140,6 +140,11 @@ ScrollbarInSinglePage = false ; if true, implements smooth scrolling (introduced in version 3.6) SmoothScroll = false +; if true, hovering an internal-document link shows a popup rendering the +; destination region (citation entry, figure, footnote) (introduced in version +; 3.7) +EnableCitationHover = true + ; if true, mouse wheel scrolling is faster when mouse is over a scrollbar ; (introduced in version 3.6) FastScrollOverScrollbar = false diff --git a/docs/md/Version-history.md b/docs/md/Version-history.md index f5ab5fbc8cd..d8811dc6036 100644 --- a/docs/md/Version-history.md +++ b/docs/md/Version-history.md @@ -61,6 +61,7 @@ Available in [pre-release](https://www.sumatrapdfreader.org/prerelease) builds. - fix Edit Annotations window not restoring to the correct monitor in multi-monitor setups - use `GetFileAttributesEx` instead of opening files for change detection on network drives, avoiding Windows Defender re-scans - fix toolbar page number misalignment when `PrinterAccess` is revoked in `sumatrapdfrestrict.ini` +- add citation/reference hover preview: hovering an internal-document link (e.g. a `[1]` citation, figure reference, or footnote marker) now shows a small popup rendering the destination region, so you can see the bibliography entry / figure / footnote without leaving the current page. Toggle with the `EnableCitationHover` advanced setting (fixes [#128](https://github.com/sumatrapdfreader/sumatrapdf/issues/128), [#4221](https://github.com/sumatrapdfreader/sumatrapdf/issues/4221)) ## 3.6.1 (2026-04-06) diff --git a/src/Canvas.cpp b/src/Canvas.cpp index 3344e44ec7b..a38415355a8 100644 --- a/src/Canvas.cpp +++ b/src/Canvas.cpp @@ -57,6 +57,8 @@ #include "Toolbar.h" #include "Translations.h" +#include "RefHover.h" + #include "utils/Log.h" // if set instead of trying to render pages we don't have, we simply do nothing @@ -793,6 +795,24 @@ static bool gShowAnnotationNotification = true; // Forward declaration static RectF CalculateResizedRect(MainWindow* win, int x, int y); +// Returns true when el is an internal-document link (not an external URL or +// file launch). Such links are eligible for RefHover destination preview. +static bool IsInternalLinkDest(IPageElement* el) { + if (!el || !el->Is(kindPageElementDest)) { + return false; + } + IPageDestination* dest = el->AsLink(); + if (!dest) { + return false; + } + Kind k = dest->GetKind(); + if (k == kindDestinationLaunchURL || k == kindDestinationLaunchFile) { + return false; + } + int destPage = PageDestGetPageNo(dest); + return destPage > 0; +} + static void OnMouseMove(MainWindow* win, int x, int y, WPARAM) { DisplayModel* dm = win->AsFixed(); // ReportIf(!dm); // can happen if reload fails, we delete DisplayModel @@ -874,13 +894,15 @@ static void OnMouseMove(MainWindow* win, int x, int y, WPARAM) { case MouseAction::None: { Annotation* annot = dm->GetAnnotationAtPos(pos, nullptr); Annotation* prev = win->annotationUnderCursor; + IPageElement* el = dm->GetElementAtPos(pos, nullptr); + bool hasInternalLink = IsInternalLinkDest(el); if (annot != prev) { #if 0 TempStr name = annot ? AnnotationReadableNameTemp(annot->type) : (TempStr) "none"; TempStr prevName = prev ? AnnotationReadableNameTemp(prev->type) : (TempStr) "none"; logf("different annot under cursor. prev: %s, new: %s\n", prevName, name); #endif - if (gShowAnnotationNotification) { + if (gShowAnnotationNotification && !hasInternalLink) { if (annot) { // auto r = annot->bounds; // logf("new pos: %d-%d, size: %d-%d\n", (int)r.x, (int)r.y, (int)r.dx, (int)r.dy); @@ -898,10 +920,45 @@ static void OnMouseMove(MainWindow* win, int x, int y, WPARAM) { } } } - if (!annot) { + if (!annot || hasInternalLink) { RemoveNotificationsForGroup(win->hwndCanvas, kNotifAnnotation); } win->annotationUnderCursor = annot; + + // RefHover: render the destination region of an internal link + // (bibliography entry, glossary, generic goto-link) into a popup. + if (gGlobalPrefs->enableCitationHover) { + if (!win->refHover) { + win->refHover = RefHoverCreate(win->hwndCanvas); + } + if (win->refHover && hasInternalLink) { + // request WM_MOUSELEAVE so popup hides when cursor leaves canvas + TrackMouseLeave(win->hwndCanvas); + IPageDestination* dest = el->AsLink(); + int destPage = PageDestGetPageNo(dest); + RectF destPt = PageDestGetDestPoint(dest); + float destZoom = PageDestGetZoom(dest); + Point screenPt = {x, y}; + ClientToScreen(win->hwndCanvas, (POINT*)&screenPt); + int srcPage = el->GetPageNo(); + RectF srcRect = el->GetRect(); + Rect pageScreenRect{}; + PageInfo* pi = (srcPage > 0) ? dm->GetPageInfo(srcPage) : nullptr; + if (pi && !pi->pageOnScreen.IsEmpty()) { + pageScreenRect = pi->pageOnScreen; + POINT topLeft = {pageScreenRect.x, pageScreenRect.y}; + ClientToScreen(win->hwndCanvas, &topLeft); + pageScreenRect.x = topLeft.x; + pageScreenRect.y = topLeft.y; + } + RefHoverSchedule(win->refHover, win->hwndCanvas, screenPt, destPage, destPt.x, destPt.y, destZoom, + srcPage, srcRect, pageScreenRect); + } else if (win->refHover) { + RefHoverHide(win->refHover, win->hwndCanvas); + } + } else if (win->refHover) { + RefHoverHide(win->refHover, win->hwndCanvas); + } break; } @@ -2093,6 +2150,29 @@ static LRESULT CanvasOnMouseWheel(MainWindow* win, UINT msg, WPARAM wp, LPARAM l return res; } + // Mouse-wheel on the citation-hover popup (cursor still on the citation + // link that opened it). Avoids moving the cursor onto the popup itself, + // which would dismiss the hover. + // plain wheel → scroll popup content (rolls over to prev/next page) + // ctrl+wheel → zoom popup content + if (win->refHover && win->refHover->hwndPopup && IsWindowVisible(win->refHover->hwndPopup)) { + DisplayModel* dmHover = win->AsFixed(); + if (dmHover) { + Point pt = HwndGetCursorPos(win->hwndCanvas); + IPageElement* elHover = dmHover->GetElementAtPos(pt, nullptr); + if (IsInternalLinkDest(elHover)) { + short delta = GET_WHEEL_DELTA_WPARAM(wp); + bool isCtrl = (LOWORD(wp) & MK_CONTROL) || IsCtrlPressed(); + if (isCtrl) { + RefHoverWheelZoom(win->refHover, dmHover->GetEngine(), delta); + } else { + RefHoverWheelScroll(win->refHover, dmHover->GetEngine(), delta); + } + return 0; + } + } + } + DisplayModel* dm = win->AsFixed(); // Note: not all mouse drivers correctly report the Ctrl key's state @@ -2603,6 +2683,12 @@ static LRESULT WndProcCanvasFixedPageUI(MainWindow* win, HWND hwnd, UINT msg, WP OnMouseMove(win, x, y, wp); return 0; + case WM_MOUSELEAVE: + if (win->refHover) { + RefHoverHide(win->refHover, win->hwndCanvas); + } + return 0; + case WM_LBUTTONDOWN: OnMouseLeftButtonDown(win, x, y, wp); return 0; @@ -2848,6 +2934,17 @@ static void OnTimer(MainWindow* win, HWND hwnd, WPARAM timerId) { } break; + case kRefHoverTimerID: { + DisplayModel* dm = win->AsFixed(); + EngineBase* engine = dm ? dm->GetEngine() : nullptr; + float pageZoom = 1.f; + if (dm && win->refHover && win->refHover->pending.destPage > 0) { + pageZoom = dm->GetZoomReal(win->refHover->pending.destPage); + } + RefHoverOnTimer(win->refHover, hwnd, engine, pageZoom); + break; + } + case HIDE_FWDSRCHMARK_TIMER_ID: win->fwdSearchMark.hideStep++; if (1 == win->fwdSearchMark.hideStep) { diff --git a/src/EngineBase.h b/src/EngineBase.h index f33254a2fe4..9e3801ccde9 100644 --- a/src/EngineBase.h +++ b/src/EngineBase.h @@ -91,6 +91,9 @@ struct IPageDestination : KindBase { virtual RectF GetRect2() { return rect; } // optional zoom level on the above returned page virtual float GetZoom2() { return zoom; } + // anchor point (x, y) on the destination page; rect's dx/dy may be 0. + // Default falls back to GetRect2 (callers should still tolerate (0,0)). + virtual RectF GetDestPoint2() { return GetRect2(); } // string value associated with the destination (e.g. a path or a URL) virtual char* GetValue2() { return nullptr; } @@ -119,6 +122,15 @@ static inline RectF PageDestGetRect(IPageDestination* dest) { return dest->GetRect2(); } +// anchor point on the destination page (x, y in user-space). Returns {0,0,0,0} +// when the destination has no specific anchor. +static inline RectF PageDestGetDestPoint(IPageDestination* dest) { + if (!dest) { + return {}; + } + return dest->GetDestPoint2(); +} + // optional zoom level on the above returned page static inline float PageDestGetZoom(IPageDestination* dest) { return dest->GetZoom2(); diff --git a/src/EngineMupdf.cpp b/src/EngineMupdf.cpp index 8a13b3784bd..be93de63b23 100644 --- a/src/EngineMupdf.cpp +++ b/src/EngineMupdf.cpp @@ -92,6 +92,14 @@ struct PageDestinationMupdf : IPageDestination { char* value = nullptr; char* name = nullptr; + // anchor (x, y) on the destination page resolved from the link URI; + // -1 means "not resolved" (e.g. external URL or file launch). + float destX = -1.f; + float destY = -1.f; + // /XYZ zoom level requested by the link (1.0 = 100%). 0 means + // "not specified" — caller should use document default. + float destZoom = 0.f; + PageDestinationMupdf(fz_link* l, fz_outline* o) { // exactly one must be provided kind = kindDestinationMupdf; @@ -107,6 +115,21 @@ struct PageDestinationMupdf : IPageDestination { } return rect; } + + RectF GetDestPoint2() override { + if (outline) { + return RectF{outline->x, outline->y, 0, 0}; + } + if (destY >= 0.f) { + return RectF{destX, destY, 0, 0}; + } + return {}; + } + + float GetZoom2() override { + return destZoom; + } + ~PageDestinationMupdf() override { str::Free(value); str::Free(name); @@ -145,27 +168,39 @@ static NO_INLINE RectF FzGetRectF(fz_link* link, fz_outline* outline) { return {}; } -static int ResolveLink(fz_context* ctx, fz_document* doc, const char* uri, float* xp, float* yp) { +static int ResolveLink(fz_context* ctx, fz_document* doc, const char* uri, float* xp, float* yp, + float* zoomp = nullptr) { if (!uri) { return -1; } int pageNo = -1; - fz_location loc; + fz_link_dest ldest{}; - fz_var(loc); + fz_var(ldest); fz_var(pageNo); fz_try(ctx) { - loc = fz_resolve_link(ctx, doc, uri, xp, yp); - pageNo = fz_page_number_from_location(ctx, doc, loc); + ldest = fz_resolve_link_dest(ctx, doc, uri); + pageNo = fz_page_number_from_location(ctx, doc, ldest.loc); } fz_catch(ctx) { - fz_warn(ctx, "fz_resolve_link failed"); + fz_warn(ctx, "fz_resolve_link_dest failed"); fz_report_error(ctx); pageNo = -1; } if (pageNo < 0) { return -1; } + if (xp) { + *xp = isnan(ldest.x) ? 0.f : ldest.x; + } + if (yp) { + *yp = isnan(ldest.y) ? 0.f : ldest.y; + } + if (zoomp) { + float z = isnan(ldest.zoom) ? 0.f : ldest.zoom; + // mupdf reports zoom as percentage (100 = 100%); we use 1.0 as 100%. + *zoomp = z / 100.f; + } return pageNo + 1; } @@ -223,7 +258,14 @@ static IPageDestination* NewPageDestinationMupdf(fz_context* ctx, fz_document* d auto dest = new PageDestinationMupdf(link, outline); dest->rect = FzGetRectF(link, outline); - dest->pageNo = FzGetPageNo(ctx, doc, link, outline); + { + float x = 0, y = 0, z = 0; + const char* destUri = link ? link->uri : (outline ? outline->uri : nullptr); + dest->pageNo = ResolveLink(ctx, doc, destUri, &x, &y, &z); + dest->destX = x; + dest->destY = y; + dest->destZoom = z; + } return dest; } diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6fc62f12788..393b08b1c33 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -33,6 +33,7 @@ #include "OverlayScrollbar.h" #include "SumatraPDF.h" #include "MainWindow.h" +#include "RefHover.h" #include "WindowTab.h" #include "TableOfContents.h" #include "resource.h" @@ -109,6 +110,7 @@ void CreateMovePatternLazy(MainWindow* win) { MainWindow::~MainWindow() { KillTimer(hwndCanvas, kSmoothScrollTimerID); + RefHoverDestroy(refHover); FinishStressTest(this); ReportIf(TabCount() > 0); diff --git a/src/MainWindow.h b/src/MainWindow.h index f9b811d1087..4e160099ad7 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -52,6 +52,7 @@ struct WindowTab; struct Annotation; struct ILinkHandler; +struct RefHoverState; // Current action being performed with a mouse enum class MouseAction { @@ -268,6 +269,7 @@ struct MainWindow { IPageElement* linkOnLastButtonDown = nullptr; AutoFreeStr urlOnLastButtonDown; Annotation* annotationUnderCursor = nullptr; + RefHoverState* refHover = nullptr; // highlight rectangle for element under cursor during context menu (in page coordinates) RectF contextMenuHighlightRect{}; int contextMenuHighlightPageNo = 0; diff --git a/src/RefHover.cpp b/src/RefHover.cpp new file mode 100644 index 00000000000..c1f8767b2c2 --- /dev/null +++ b/src/RefHover.cpp @@ -0,0 +1,805 @@ +/* Copyright 2026 the SumatraPDF project authors (see AUTHORS file). + License: GPLv3 */ + +// Citation / reference hover — manual test checklist. +// Each item names a hover target and the popup behavior we want. +// Verify by hovering the described link and watching the popup. +// +// Bibliography / reference list: +// [ ] [Nyg11]-style description-list (numeric "[1]" or alphanumeric +// "[Foo+09]"): popup shows just the one entry, not adjacent ones. +// [ ] Author-year hanging-indent bib ("Smith, J. (2020). ..."): popup +// shows just the entry, including all wrapped continuation lines. +// [ ] Single-line author-year entry: same as above. +// [ ] Abbreviation / glossary list ("JVM Java Virtual Machine. 19, 36"): +// popup shows just the one entry, even when single-line with no +// bracket prefix. +// [ ] Numbered footnotes / endnotes: popup shows just the one entry. +// +// Caption / heading / table / code-listing destination: +// [ ] "Figure N.M" / "Table N.M" reference whose dest lands at the +// caption text: landscape view (full page width) with caption + +// figure body. +// [ ] "Figure N.M" reference whose dest lands at a code-listing body +// above the caption: landscape view — popup includes the caption +// below the code. +// [ ] "Section N.M" / "Chapter N" reference (numbered heading): +// landscape view, anchored at the heading down to the page text. +// [ ] Table reference (rows arranged in columns at the dest): landscape. +// [ ] Trailing blank page margin trimmed off the popup bottom (region +// ends just below the last text glyph, not at the page boundary). +// +// Positioning / interaction: +// [ ] Popup not clipped at screen edges when cursor is near a corner +// and the popup has to flip to the alternate side. +// [ ] Two adjacent links to the same destination page but different +// positions each render their own content (no stale popup on the +// second hover). +// [ ] Popup hides on mouse-out, re-appears on re-enter. +// [ ] Mouse-wheel over popup scrolls; rolls over to the prev / next page +// when the viewport reaches a page edge. +// [ ] Ctrl+mouse-wheel over popup zooms in / out. +// [ ] Popup height grows on bigger monitors (capped at ~90% of work +// area, max 1400px). +// +// Page-level link destinations (no specific destY) — we extract the source +// link's text from its source rect and search the destination page for that +// text, using the leftmost match's Y as destY. This covers the common +// abbreviation / glossary case (hovering "AKM" in body text → popup crops +// to the AKM entry on the abbreviations page). +// +// Known limits: +// - Page-level link whose source rect doesn't isolate a unique key (e.g. +// a TOC line "1.2 Foo .... 12" — many such lines may share a prefix +// across the doc): falls back to full-page landscape view. +// - PDFs whose link destinations are authored at an unexpected Y (e.g. +// mid-paragraph): popup anchors there; this code can't fix a +// misauthored destination. + +#include "utils/BaseUtil.h" +#include "utils/WinUtil.h" + +#include "wingui/UIModels.h" + +#include "DocController.h" +#include "EngineBase.h" +#include "RefHover.h" +#include "RefHoverDetect.h" + +#define REF_HOVER_CLASS L"SumatraPDFRefHover" + +// upper bound for the auto-fit base zoom. We render at min(kRenderZoom, +// fit-to-popup-max), then multiply by RefHoverState::Displayed::userZoom +// (the mouse-wheel adjustment). +static constexpr float kRenderZoom = 1.5f; +// upper bounds for the popup window in screen pixels. +static constexpr int kMaxPopupWidth = 1200; +static constexpr int kMaxPopupHeight = 600; +static constexpr int kBorder = 4; +// user-zoom (mouse-wheel) bounds and step. +static constexpr float kMinUserZoom = 0.4f; +static constexpr float kMaxUserZoom = 3.0f; +static constexpr float kUserZoomStep = 1.15f; + +static bool gClassRegistered = false; + +static LRESULT CALLBACK RefHoverWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_PAINT) { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + + RECT rc; + GetClientRect(hwnd, &rc); + + HBRUSH hbg = CreateSolidBrush(RGB(255, 252, 200)); + FillRect(hdc, &rc, hbg); + DeleteObject(hbg); + + RefHoverState* s = (RefHoverState*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (s && s->bmp) { + // Draw the bitmap at native pixel size, top-left aligned. + // GDI clips at the popup edges when the bitmap exceeds the + // client area (which happens when the user wheel-zooms in — + // that's how zoomed-in content "fills" the previously blank + // bottom/right of the popup). + Size bmpSize = s->bmp->GetSize(); + HDC bmpDC = CreateCompatibleDC(hdc); + HGDIOBJ oldBmp = bmpDC ? SelectObject(bmpDC, s->bmp->GetBitmap()) : nullptr; + if (oldBmp) { + BitBlt(hdc, kBorder, kBorder, bmpSize.dx, bmpSize.dy, bmpDC, 0, 0, SRCCOPY); + SelectObject(bmpDC, oldBmp); + } + if (bmpDC) { + DeleteDC(bmpDC); + } + } + + EndPaint(hwnd, &ps); + return 0; + } + if (msg == WM_ERASEBKGND) { + return 1; + } + return DefWindowProc(hwnd, msg, wp, lp); +} + +static void RegisterClassIfNeeded() { + if (gClassRegistered) { + return; + } + WNDCLASSW wc{}; + wc.lpfnWndProc = RefHoverWndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = REF_HOVER_CLASS; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + RegisterClassW(&wc); + gClassRegistered = true; +} + +RefHoverState* RefHoverCreate(HWND hwndCanvas) { + RegisterClassIfNeeded(); + auto* s = new RefHoverState(); + HWND hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, REF_HOVER_CLASS, nullptr, WS_POPUP | WS_BORDER, 0, 0, 10, 10, + hwndCanvas, nullptr, GetModuleHandleW(nullptr), nullptr); + if (!hwnd) { + delete s; + return nullptr; + } + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)s); + s->hwndPopup = hwnd; + return s; +} + +void RefHoverDestroy(RefHoverState* s) { + if (!s) { + return; + } + if (s->hwndPopup) { + DestroyWindow(s->hwndPopup); + s->hwndPopup = nullptr; + } + delete s->bmp; + s->bmp = nullptr; + delete s; +} + +static void ShowPopup(RefHoverState* s, Point screenPt) { + if (!s || !s->hwndPopup || !s->bmp) { + return; + } + Size bmpSize = s->bmp->GetSize(); + int popupW = bmpSize.dx + 2 * kBorder; + int popupH = bmpSize.dy + 2 * kBorder; + + HMONITOR hmon = MonitorFromPoint({screenPt.x, screenPt.y}, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + GetMonitorInfoW(hmon, &mi); + + // Horizontal bounds: monitor work area (popup is allowed to extend + // into the gray margins beyond the page text column). + // Vertical bounds: monitor work area intersected with the page screen + // rect, so the popup stays within the visible page vertically and + // doesn't spill into the next-page area or the page header / footer + // gap above / below. + int leftBound = mi.rcWork.left; + int rightBound = mi.rcWork.right; + int topBound = mi.rcWork.top; + int bottomBound = mi.rcWork.bottom; + Rect pr = s->pending.pageScreenRect; + if (pr.dy > 0) { + if (pr.y > topBound) { + topBound = pr.y; + } + if (pr.y + pr.dy < bottomBound) { + bottomBound = pr.y + pr.dy; + } + } + int boundW = rightBound - leftBound; + int boundH = bottomBound - topBound; + if (popupW > boundW) { + popupW = boundW; + } + if (popupH > boundH) { + popupH = boundH; + } + + // Horizontally: center the popup on the source page (not the canvas / + // monitor edge) so when the popup is wider than the page text column it + // expands symmetrically into the gray margins. The popup's X is + // independent of the cursor so consecutive hovers on the same page + // don't make the popup jump horizontally. + int pageCenterX = (pr.dx > 0) ? (pr.x + pr.dx / 2) : screenPt.x; + int x = pageCenterX - popupW / 2; + // Vertically: gap above/below the cursor so 1-2 lines of context around + // the hovered word stay visible. Prefer below cursor; flip above if + // overflow. If neither side fits the full popup, shrink popupH to + // whichever side has more room — popup is cut (bitmap clipped at popup + // edges in WM_PAINT) rather than overlapping the cursor. + constexpr int kCursorPad = 30; + int spaceBelow = bottomBound - (screenPt.y + kCursorPad); + int spaceAbove = (screenPt.y - kCursorPad) - topBound; + int y; + if (spaceBelow >= popupH) { + y = screenPt.y + kCursorPad; + } else if (spaceAbove >= popupH) { + y = screenPt.y - popupH - kCursorPad; + } else if (spaceBelow >= spaceAbove) { + if (spaceBelow > 0) { + popupH = spaceBelow; + } + y = screenPt.y + kCursorPad; + } else { + if (spaceAbove > 0) { + popupH = spaceAbove; + } + y = screenPt.y - popupH - kCursorPad; + } + // Horizontal clamp to monitor work area. + if (x < leftBound) { + x = leftBound; + } + if (x + popupW > rightBound) { + x = rightBound - popupW; + } + // Final vertical clamp (defensive). + if (y < topBound) { + y = topBound; + } + if (y + popupH > bottomBound) { + popupH = bottomBound - y; + } + + SetWindowPos(s->hwndPopup, HWND_TOPMOST, x, y, popupW, popupH, SWP_NOACTIVATE | SWP_SHOWWINDOW); + InvalidateRect(s->hwndPopup, nullptr, TRUE); +} + +// Re-render at the adjusted zoom; popup window keeps its initial size. +// The rendered region is sized to exactly fill the popup at the new zoom +// (anchored at the original detected top-left), so wheel-down brings in +// new page content from below/right and wheel-up shows less of the page +// at higher detail. Either way the popup stays full — no blank area. +// Returns true if the zoom actually changed. +bool RefHoverWheelZoom(RefHoverState* s, EngineBase* engine, int wheelDelta) { + if (!s || !s->hwndPopup || s->displayed.destPage <= 0 || !engine) { + return false; + } + float factor = (wheelDelta > 0) ? kUserZoomStep : (1.f / kUserZoomStep); + float newZoom = s->displayed.userZoom * factor; + if (newZoom < kMinUserZoom) { + newZoom = kMinUserZoom; + } else if (newZoom > kMaxUserZoom) { + newZoom = kMaxUserZoom; + } + if (newZoom == s->displayed.userZoom) { + return false; + } + s->displayed.userZoom = newZoom; + + RECT rc; + GetClientRect(s->hwndPopup, &rc); + float clientW = (float)((rc.right - rc.left) - 2 * kBorder); + float clientH = (float)((rc.bottom - rc.top) - 2 * kBorder); + float zoom = s->displayed.baseZoom * s->displayed.userZoom; + if (zoom <= 0.f || clientW <= 0.f || clientH <= 0.f) { + return false; + } + + RectF mediabox = engine->PageMediabox(s->displayed.destPage); + RectF region = s->displayed.region; + region.dx = clientW / zoom; + region.dy = clientH / zoom; + // Clamp to page bounds. + if (region.x + region.dx > mediabox.dx) { + region.dx = mediabox.dx - region.x; + } + if (region.y + region.dy > mediabox.dy) { + region.dy = mediabox.dy - region.y; + } + if (region.dx <= 0.f || region.dy <= 0.f) { + return false; + } + + RenderPageArgs args(s->displayed.destPage, zoom, 0, ®ion); + RenderedBitmap* bmp = engine->RenderPage(args); + if (!bmp) { + return false; + } + delete s->bmp; + s->bmp = bmp; + // Persist the popup-fitting dx/dy so a subsequent scroll keeps rendering + // a bitmap that fills the popup. Without this, scroll would fall back to + // the original (smaller) entry-box dimensions and leave visible padding. + s->displayed.region = region; + InvalidateRect(s->hwndPopup, nullptr, TRUE); + return true; +} + +// Scroll the rendered region by one wheel notch. Rolls over to the previous +// or next page when the region would cross a page edge — the popup behaves +// like a small continuous-scroll viewport into the document. +bool RefHoverWheelScroll(RefHoverState* s, EngineBase* engine, int wheelDelta) { + if (!s || !s->hwndPopup || s->displayed.destPage <= 0 || !engine) { + return false; + } + float zoom = s->displayed.baseZoom * s->displayed.userZoom; + if (zoom <= 0.f) { + return false; + } + int pageCount = engine->PageCount(); + int page = s->displayed.destPage; + RectF region = s->displayed.region; + RectF mediabox = engine->PageMediabox(page); + if (mediabox.dx <= 0.f || mediabox.dy <= 0.f) { + return false; + } + + // ~60 screen pixels per WHEEL_DELTA notch, expressed in page points so the + // perceived scroll speed stays roughly constant across zoom levels. + // Positive wheelDelta → wheel forward → scroll toward earlier content + // (region.y decreases). Negative → later content (region.y increases). + float scrollPt = 60.f * ((float)wheelDelta / (float)WHEEL_DELTA) / zoom; + float newY = region.y - scrollPt; + + if (newY < 0.f) { + // Overflow off the page top — carry the remainder onto the previous + // page if there is one. Position the new region near the prev page's + // bottom and offset upward by the overflow so the seam between pages + // feels continuous across the wheel. + if (page > 1) { + float overflow = -newY; + page--; + mediabox = engine->PageMediabox(page); + newY = mediabox.dy - region.dy - overflow; + if (newY < 0.f) { + newY = 0.f; + } + } else { + newY = 0.f; + } + } else if (newY + region.dy > mediabox.dy) { + if (page < pageCount) { + float overflow = (newY + region.dy) - mediabox.dy; + page++; + mediabox = engine->PageMediabox(page); + newY = overflow; + if (newY + region.dy > mediabox.dy) { + newY = mediabox.dy - region.dy; + } + if (newY < 0.f) { + newY = 0.f; + } + } else { + newY = mediabox.dy - region.dy; + if (newY < 0.f) { + newY = 0.f; + } + } + } + + if (page == s->displayed.destPage && newY == region.y) { + return false; + } + region.y = newY; + if (region.dy > mediabox.dy) { + region.dy = mediabox.dy; + } + if (region.x + region.dx > mediabox.dx) { + region.x = mediabox.dx - region.dx; + if (region.x < 0.f) { + region.x = 0.f; + region.dx = mediabox.dx; + } + } + + RenderPageArgs args(page, zoom, 0, ®ion); + RenderedBitmap* bmp = engine->RenderPage(args); + if (!bmp) { + return false; + } + delete s->bmp; + s->bmp = bmp; + s->displayed.destPage = page; + s->displayed.region = region; + InvalidateRect(s->hwndPopup, nullptr, TRUE); + return true; +} + +void RefHoverSchedule(RefHoverState* s, HWND hwndCanvas, Point screenPt, int destPage, float destX, float destY, + float destZoom, int srcPage, RectF srcRect, Rect pageScreenRect) { + if (!s) { + return; + } + KillTimer(hwndCanvas, kRefHoverTimerID); + + if (IsWindowVisible(s->hwndPopup) && s->displayed.destPage == destPage && s->displayed.destX == destX && + s->displayed.destY == destY) { + return; + } + s->pending.screenPt = screenPt; + s->pending.destPage = destPage; + s->pending.destX = destX; + s->pending.destY = destY; + s->pending.destZoom = destZoom; + s->pending.srcPage = srcPage; + s->pending.srcRect = srcRect; + s->pending.pageScreenRect = pageScreenRect; + SetTimer(hwndCanvas, kRefHoverTimerID, kRefHoverDelayMs, nullptr); +} + +void RefHoverHide(RefHoverState* s, HWND hwndCanvas) { + if (!s) { + return; + } + KillTimer(hwndCanvas, kRefHoverTimerID); + s->pending.destPage = -1; + if (s->hwndPopup && IsWindowVisible(s->hwndPopup)) { + ShowWindow(s->hwndPopup, SW_HIDE); + s->displayed.destPage = -1; + } +} + +// When the link's destination is page-level (destY < 0), try to recover a +// specific Y by reading the source link's text from srcPage at srcRect, then +// searching for that text on destPage. Returns -1 if no match. +// +// Typical case: hovering an abbreviation in body text (e.g. "AKM") whose link +// points to the abbreviations page without a specific Y. Source rect covers +// "AKM"; we look for "AKM" at the leftmost position on destPage (the +// description-list entry start) and return its Y. With a recovered destY, +// DetectEntryBox can crop the popup to the matching entry. +static float ResolveDestYFromSourceText(EngineBase* engine, int srcPage, RectF srcRect, int destPage) { + if (srcPage <= 0 || destPage <= 0 || srcRect.dx <= 0.f || srcRect.dy <= 0.f) { + return -1.f; + } + int srcLen = 0; + Rect* srcCoords = nullptr; + const WCHAR* srcText = engine->GetTextForPage(srcPage, &srcLen, &srcCoords); + if (!srcText || srcLen <= 0 || !srcCoords) { + return -1.f; + } + // Tight margin: include glyphs partially overlapping the rect (handles + // 1-2pt slop from tool-authored rects) without sweeping in adjacent + // body text. A wider margin would let the longest-alnum heuristic + // latch onto unrelated nearby words ("ADR" near "linking" → picks + // "linking" → no match on dest page → fall to landscape view). + int srcL = (int)srcRect.x - 2; + int srcT = (int)srcRect.y - 2; + int srcR = (int)(srcRect.x + srcRect.dx) + 2; + int srcB = (int)(srcRect.y + srcRect.dy) + 2; + + WCHAR rawText[512]; + int rawLen = 0; + for (int i = 0; i < srcLen && rawLen < 511; i++) { + Rect r = srcCoords[i]; + // Include glyph if its bounding box overlaps the search rect — more + // forgiving than a center-in-rect check when the link rect is + // tightly authored or slightly offset from the actual glyph. + if (r.x + r.dx < srcL || r.x > srcR) { + continue; + } + if (r.y + r.dy < srcT || r.y > srcB) { + continue; + } + rawText[rawLen++] = srcText[i]; + } + + auto isAlnum = [](WCHAR c) { + return (c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || (c >= L'0' && c <= L'9'); + }; + + // Collect alphanumeric candidate runs from the source rect as search + // needles. Strips surrounding punctuation ("(AKM)" → "AKM"). Tokens + // flanked by parentheses (the typical "definition" convention for + // expanding a phrase, e.g. "Architectural Knowledge Management (AKM)") + // are preferred — that keeps the resolver from latching onto a citation + // key like "KLV06" inside "[KLV06]" when both appear near the link rect. + // Each candidate is tried against the dest page in priority order; + // first match wins. Trying multiple candidates handles cases where the + // longest run isn't on the dest page but a shorter run is (e.g. Bluey + // "Jump to all (a-z)" → "Jump" not on dest, "all" matches "All + // Characters"). + struct Cand { int start; int len; bool flanked; }; + constexpr int kMaxCands = 16; + Cand cands[kMaxCands]; + int ncands = 0; + int curStart = -1; + int curLen = 0; + for (int i = 0; i <= rawLen; i++) { + bool alnum = (i < rawLen) && isAlnum(rawText[i]); + if (alnum) { + if (curStart < 0) { + curStart = i; + } + curLen++; + } else { + if (curLen >= 2 && ncands < kMaxCands) { + bool flanked = (curStart > 0 && rawText[curStart - 1] == L'(' && i < rawLen && rawText[i] == L')'); + cands[ncands++] = {curStart, curLen, flanked}; + } + curStart = -1; + curLen = 0; + } + } + if (ncands == 0) { + return -1.f; + } + // Sort: parens-flanked first, then by length descending. + for (int i = 0; i < ncands - 1; i++) { + for (int j = i + 1; j < ncands; j++) { + bool swap = false; + if (cands[j].flanked && !cands[i].flanked) { + swap = true; + } else if (cands[j].flanked == cands[i].flanked && cands[j].len > cands[i].len) { + swap = true; + } + if (swap) { + Cand t = cands[i]; + cands[i] = cands[j]; + cands[j] = t; + } + } + } + + int destLen = 0; + Rect* destCoords = nullptr; + const WCHAR* destText = engine->GetTextForPage(destPage, &destLen, &destCoords); + if (!destText || destLen <= 0 || !destCoords) { + return -1.f; + } + // Prefer line-start matches (no other glyph at smaller x with same y) + // over mid-line matches. A "Figure 7.1" caption sits at line start; a + // body-text mention "in Figure 7.1 below" sits mid-line. Same logic + // benefits abbreviation entries vs. body mentions of an abbreviation. + auto isLineStartMatch = [&](int idx) -> bool { + int sy = destCoords[idx].y; + int sx = destCoords[idx].x; + for (int i = 0; i < destLen; i++) { + if (i == idx) { + continue; + } + if (destCoords[i].y != sy) { + continue; + } + WCHAR c = destText[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + if (destCoords[i].x < sx) { + return false; + } + } + return true; + }; + + for (int ci = 0; ci < ncands; ci++) { + int bestStart = cands[ci].start; + int bestLen = cands[ci].len; + auto matchAt = [&](int idx) -> bool { + if (idx + bestLen > destLen) { + return false; + } + for (int j = 0; j < bestLen; j++) { + WCHAR a = destText[idx + j]; + WCHAR b = rawText[bestStart + j]; + if (a >= L'A' && a <= L'Z') { + a = (WCHAR)(a + 32); + } + if (b >= L'A' && b <= L'Z') { + b = (WCHAR)(b + 32); + } + if (a != b) { + return false; + } + } + if (idx > 0 && isAlnum(destText[idx - 1])) { + return false; + } + if (idx + bestLen < destLen && isAlnum(destText[idx + bestLen])) { + return false; + } + return true; + }; + + int bestX_lineStart = INT_MAX; + int bestY_lineStart = -1; + int bestX_any = INT_MAX; + int bestY_any = -1; + for (int i = 0; i < destLen; i++) { + if (!matchAt(i)) { + continue; + } + Rect r = destCoords[i]; + if (isLineStartMatch(i)) { + if (r.x < bestX_lineStart) { + bestX_lineStart = r.x; + bestY_lineStart = r.y; + } + } else if (r.x < bestX_any) { + bestX_any = r.x; + bestY_any = r.y; + } + } + int bestY = (bestY_lineStart >= 0) ? bestY_lineStart : bestY_any; + if (bestY >= 0) { + return (float)bestY; + } + } + return -1.f; +} + +void RefHoverOnTimer(RefHoverState* s, HWND hwndCanvas, EngineBase* engine, float pageZoom) { + KillTimer(hwndCanvas, kRefHoverTimerID); + if (!s || !engine || s->pending.destPage <= 0) { + return; + } + int destPage = s->pending.destPage; + float destX = s->pending.destX; + float destY = s->pending.destY; + + RectF mediabox = engine->PageMediabox(destPage); + if (mediabox.dx <= 0.f || mediabox.dy <= 0.f) { + return; + } + // PageDestGetDestPoint returns {0,0,0,0} when the link has no specific + // anchor (page-level destination) — that's the typical case for body-text + // abbreviation / glossary links and for some TOC-derived bib refs. In + // those cases destY == 0 (not < 0), so we have to treat <= 0 as "no + // anchor" and try to recover a specific Y from the source link's text. + // Some PDFs author /XYZ with y just past the page bottom (negative in + // PDF user space, top-down flips it past mediabox.dy) when they mean + // "top of page" — e.g. Bluey.pdf "JUMP TO ALL (A-Z)" uses + // /XYZ 0 -2.58 0. Treat past-page-bottom destY the same as page-level. + if (destY <= 0.f || destY >= mediabox.dy - 1.f) { + destY = 0.f; + float resolved = ResolveDestYFromSourceText(engine, s->pending.srcPage, s->pending.srcRect, destPage); + if (resolved >= 0.f) { + destY = resolved; + if (destX < 0.f) { + destX = 0.f; + } + } + } + + // When the link supplies an /XYZ zoom hint, honour it: render the + // destination region anchored at the link's (destX, destY) at the + // requested zoom rather than auto-fitting a detected entry box. + // This matches the navigation behaviour (DisplayModel::ScrollTo + // also reads the link zoom). + float linkZoom = s->pending.destZoom; + bool useLinkZoom = (linkZoom > 0.f); + // Popup must not look smaller than the page does on screen — if the + // user is already viewing the document at a higher zoom than the + // link's /XYZ hint, render the popup at the current display zoom + // instead (still anchored at the link's top). Otherwise the preview + // would feel like a zoom-OUT, defeating the point of XYZ. + if (useLinkZoom && pageZoom > linkZoom) { + linkZoom = pageZoom; + } + + RectF region; + if (useLinkZoom) { + // Span full page width — strict /XYZ would crop at destX, but for + // a hover preview that just chops the left-most letters of the + // target lines. Top anchor (destY) is preserved. + // dx/dy placeholders; resized against popup caps below. + region = RectF{0.f, destY, mediabox.dx, mediabox.dy - destY}; + } else { + int textLen = 0; + Rect* coords = nullptr; + const WCHAR* text = engine->GetTextForPage(destPage, &textLen, &coords); + // Equation cross-reference: tight box around the labelled line. + // Falls through to DetectEntryBox when no eq label is found. + region = DetectEquationBox(text, coords, textLen, mediabox, destX, destY); + if (region.dx <= 0.f || region.dy <= 0.f) { + region = DetectEntryBox(text, coords, textLen, mediabox, destX, destY); + } + } + // New destination — reset user-driven zoom. baseZoom matches the + // document's current display zoom for the destination page, so popup + // text height is comparable to the visible page text. Shrink baseZoom + // if either dimension would exceed the popup max; landscape-style + // regions for non-reference targets are typically wider than tall, so + // the width cap matters here. + s->displayed.userZoom = 1.f; + float baseZoom = useLinkZoom ? linkZoom : ((pageZoom > 0.f) ? pageZoom : kRenderZoom); + // Popup max size: + // width ~95% of monitor work area — popup may span beyond the page + // text column into the surrounding gray margins so the + // rendered figure / caption / table is at a readable + // size, not shrunk to fit a narrow text column. + // height 45% of source page height — keeps the bottom of the page + // visible below the popup so the line under the cursor + // and surrounding context stay readable. + int popupWCap = kMaxPopupWidth; + { + POINT mp = {s->pending.screenPt.x, s->pending.screenPt.y}; + HMONITOR hmon = MonitorFromPoint(mp, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(hmon, &mi)) { + int monW = mi.rcWork.right - mi.rcWork.left; + int dyn = monW * 95 / 100; + if (dyn > popupWCap) { + popupWCap = dyn; + } + } + } + // Default popup height cap: 45% of source page height, fall back to + // kMaxPopupHeight when page rect is unknown. For tall regions (figure + // with caption), grow the cap into whichever side of the cursor has + // more available space within the page rect, so the figure body and + // its full caption fit. Cursor at the bottom of the page → popup + // expands upward into the page top; cursor at top → expands downward. + int popupHCap; + if (region.dy > 250.f && s->pending.pageScreenRect.dy > 0) { + Rect pr = s->pending.pageScreenRect; + int curY = s->pending.screenPt.y; + int spaceAbove = curY - pr.y - 30; + int spaceBelow = (pr.y + pr.dy) - curY - 30; + int maxSpace = (spaceAbove > spaceBelow) ? spaceAbove : spaceBelow; + if (maxSpace < 0) { + maxSpace = 0; + } + int pageBased = pr.dy * 75 / 100; + popupHCap = (pageBased > maxSpace) ? pageBased : maxSpace; + } else { + popupHCap = kMaxPopupHeight; + if (s->pending.pageScreenRect.dy > 0) { + int pageBased = s->pending.pageScreenRect.dy * 45 / 100; + if (pageBased < popupHCap) { + popupHCap = pageBased; + } + } + } + float availH = (float)(popupHCap - 2 * kBorder); + float availW = (float)(popupWCap - 2 * kBorder); + if (useLinkZoom) { + // Keep the link's requested zoom; size the region so it fills the + // popup without overflowing. Clamp to what's left of the page so + // we don't render past the mediabox edge. + float wantW = availW / baseZoom; + float wantH = availH / baseZoom; + float maxW = mediabox.dx - region.x; + float maxH = mediabox.dy - region.y; + if (wantW > maxW) { + wantW = maxW; + } + if (wantH > maxH) { + wantH = maxH; + } + if (wantW < 1.f) { + wantW = 1.f; + } + if (wantH < 1.f) { + wantH = 1.f; + } + region.dx = wantW; + region.dy = wantH; + } else { + if (region.dy > 0.f && region.dy * baseZoom > availH) { + baseZoom = availH / region.dy; + } + if (region.dx > 0.f && region.dx * baseZoom > availW) { + baseZoom = availW / region.dx; + } + } + if (baseZoom < kMinUserZoom) { + baseZoom = kMinUserZoom; + } + s->displayed.baseZoom = baseZoom; + RenderPageArgs args(destPage, s->displayed.baseZoom * s->displayed.userZoom, 0, ®ion); + RenderedBitmap* bmp = engine->RenderPage(args); + if (!bmp) { + return; + } + + delete s->bmp; + s->bmp = bmp; + s->displayed.destPage = destPage; + s->displayed.destX = destX; + s->displayed.destY = destY; + s->displayed.region = region; + + ShowPopup(s, s->pending.screenPt); +} diff --git a/src/RefHover.h b/src/RefHover.h new file mode 100644 index 00000000000..d21adfadac7 --- /dev/null +++ b/src/RefHover.h @@ -0,0 +1,79 @@ +/* Copyright 2026 the SumatraPDF project authors (see AUTHORS file). + License: GPLv3 */ + +class EngineBase; +struct RenderedBitmap; + +struct RefHoverState { + HWND hwndPopup = nullptr; + // currently shown rendered destination strip (owned) + RenderedBitmap* bmp = nullptr; + + // Pending hover request: set by RefHoverSchedule, consumed by + // RefHoverOnTimer when the hover-delay timer fires. + struct Pending { + Point screenPt{}; + int destPage = -1; + float destX = -1.f; + float destY = -1.f; + // /XYZ zoom hint from the link (1.0 = 100%). 0 means "no zoom hint"; + // RefHover then falls back to its auto-fit DetectEntryBox heuristic. + // When non-zero, the popup renders the destination region centred on + // (destX, destY) at this zoom — honouring the link author's intent + // (e.g. "goto top-left at 2x"). + float destZoom = 0.f; + // Source link location, used to recover a more specific destY when + // the PDF link is page-level (destY < 0). We extract the source + // link's text from srcPage at srcRect and search for that text on + // destPage to find the matching entry's Y. Without this, page-level + // abbreviation / glossary links render the whole abbreviations page + // from top. + int srcPage = -1; + RectF srcRect{}; + // Screen rect of the source page (visible portion). Used to clamp + // the popup so it stays within the document area and doesn't drift + // into the gray margins outside the page. + Rect pageScreenRect{}; + } pending; + + // Currently-displayed bitmap context. Compared against incoming hover + // requests to skip a re-render when the destination hasn't changed, and + // re-used by the wheel-zoom / wheel-scroll handlers so they can re-render + // without re-running detection. + struct Displayed { + int destPage = -1; + float destX = -1.f; + float destY = -1.f; + // Region of the page rendered into the popup bitmap, kept so the + // wheel handlers can shift / scale it without re-running detection. + RectF region{}; + // baseZoom matches the document's current page zoom on first show + // so popup text height is comparable to page text. userZoom is the + // multiplier driven by the user's mouse-wheel. + float baseZoom = 1.f; + float userZoom = 1.f; + } displayed; +}; + +constexpr int kRefHoverDelayMs = 300; +constexpr UINT_PTR kRefHoverTimerID = 9; + +RefHoverState* RefHoverCreate(HWND hwndCanvas); +void RefHoverDestroy(RefHoverState* s); +void RefHoverSchedule(RefHoverState* s, HWND hwndCanvas, Point screenPt, int destPage, float destX, float destY, + float destZoom, int srcPage, RectF srcRect, Rect pageScreenRect); +void RefHoverHide(RefHoverState* s, HWND hwndCanvas); +// pageZoom is the destination page's current display zoom (px-per-pt) — +// used as the initial render zoom so popup text height matches the page. +void RefHoverOnTimer(RefHoverState* s, HWND hwndCanvas, EngineBase* engine, float pageZoom); +// Re-render the popup at adjusted zoom in response to a mouse-wheel event. +// Popup window keeps its initial size; only the rendered content scales. +// Positive delta zooms in, negative zooms out. Returns true if the zoom +// changed and a re-render happened. +bool RefHoverWheelZoom(RefHoverState* s, EngineBase* engine, int wheelDelta); +// Scroll the popup's rendered region by a wheel notch. Positive delta scrolls +// toward earlier content (up); negative scrolls toward later content (down). +// Rolls over to the previous / next page when the viewport hits a page edge +// (continuous scrolling). Popup window keeps its initial size; only the +// rendered region's Y (and possibly page number) changes. +bool RefHoverWheelScroll(RefHoverState* s, EngineBase* engine, int wheelDelta); diff --git a/src/RefHoverDetect.cpp b/src/RefHoverDetect.cpp new file mode 100644 index 00000000000..35ad1bb8e94 --- /dev/null +++ b/src/RefHoverDetect.cpp @@ -0,0 +1,854 @@ +/* Copyright 2026 the SumatraPDF project authors (see AUTHORS file). + License: GPLv3 */ + +// Pure-function popup-region detectors used by RefHover. Kept in a separate +// translation unit so the heuristics can be unit-tested with synthetic glyph +// arrays (see src/utils/tests/RefHover_ut.cpp) without pulling in the engine, +// HWND, or rendering layers. + +#include "utils/BaseUtil.h" +#include "RefHoverDetect.h" + +static constexpr float kAnchorTopMarginPt = 6.f; +// pt of padding around the detected entry box. +static constexpr float kEntryPadPt = 6.f; + +static bool IsAsciiAlnum(WCHAR c) { + return (c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || (c >= L'0' && c <= L'9'); +} + +// Caption / heading keyword tables. Each entry is a lowercase word recognised +// at the start of a "Figure 1.2" / "Tableau 2" style label. Add a language by +// appending entries; the call sites loop the table so no other code changes. +// Trailing nullptr terminates the list. +// +// Entries are matched case-insensitively against the input glyph (via +// towlower) so capitalised or all-caps PDF text matches too. Accented dict +// words must be stored already-lowercased (NFC) — PDF text extraction +// produces NFC most of the time. +static const WCHAR* const kCaptionWords[] = { + // en + L"figure", L"table", L"listing", L"algorithm", + // de + L"abbildung", L"tabelle", L"algorithmus", + // es / it / pt (shared roots) + L"figura", L"tabla", L"algoritmo", + // fr + L"tableau", L"algorithme", + nullptr, +}; + +// Heading prefixes recognised at the start of a destination glyph run. Used +// to disambiguate a section heading / figure caption destination from a +// description-list bibliography entry. Superset of kCaptionWords — includes +// "section" / "chapter" / locale equivalents that aren't captions but are +// heading destinations. +static const WCHAR* const kHeadingPrefixWords[] = { + // en + L"figure", L"table", L"listing", L"section", L"chapter", L"algorithm", + // de + L"abbildung", L"tabelle", L"abschnitt", L"kapitel", L"algorithmus", + // es + L"figura", L"tabla", L"sección", L"capítulo", L"algoritmo", + // fr + L"tableau", L"chapitre", L"algorithme", + nullptr, +}; + +// Match `text[idx..]` case-insensitively against the lowercase dictionary +// word `w`. Returns true on full word match. When requireTrailingDigit is +// set, also requires optional whitespace then a digit immediately after the +// word (the "Figure 1.2" trailing-number constraint). +static bool MatchWordAt(const WCHAR* text, int textLen, int idx, const WCHAR* w, bool requireTrailingDigit) { + int n = 0; + while (w[n]) { + n++; + } + if (idx + n > textLen) { + return false; + } + if (requireTrailingDigit && idx + n + 1 >= textLen) { + return false; + } + for (int j = 0; j < n; j++) { + WCHAR c = (WCHAR)towlower(text[idx + j]); + if (c != w[j]) { + return false; + } + } + if (!requireTrailingDigit) { + return true; + } + int k = idx + n; + while (k < textLen && (text[k] == L' ' || text[k] == L'\t')) { + k++; + } + return k < textLen && text[k] >= L'0' && text[k] <= L'9'; +} + +// True if `text[idx..]` starts a caption label like "Figure 1.2", "Tableau 2". +// See kCaptionWords for the language list. Requires the previous glyph to be +// a word boundary and the word to be followed by whitespace and a digit. +static bool IsCaptionLabelAt(const WCHAR* text, int textLen, int idx) { + if (idx > 0 && IsAsciiAlnum(text[idx - 1])) { + return false; + } + for (int i = 0; kCaptionWords[i]; i++) { + if (MatchWordAt(text, textLen, idx, kCaptionWords[i], /*requireTrailingDigit=*/true)) { + return true; + } + } + return false; +} + +// Clip a region to the page mediabox: shifts a negative x/y to 0 (shrinking +// the box by the same amount) and trims any overhang past the right/bottom +// edge. Used everywhere a detected region must be passed to RenderPage. +static void ClipToMediabox(RectF& box, RectF mediabox) { + if (box.x < 0.f) { + box.dx += box.x; + box.x = 0.f; + } + if (box.y < 0.f) { + box.dy += box.y; + box.y = 0.f; + } + if (box.x + box.dx > mediabox.dx) { + box.dx = mediabox.dx - box.x; + } + if (box.y + box.dy > mediabox.dy) { + box.dy = mediabox.dy - box.y; + } +} + +// Used when the link doesn't resolve to a recognizable bibliography entry — +// TOC targets, topbar/section links, table or figure captions, image-only +// PDFs. Returns a region that spans the full page width and goes from the +// destination Y down to the bottom of the last text glyph on the page — +// captures table caption / figure caption / section content below the +// link target without leaving a long blank margin at the popup bottom. +// Auto-fit in RefHoverOnTimer + the monitor-based popup height cap keep +// the popup a sensible size; the user can wheel-zoom in if text is too +// small. +RectF LandscapeBox(RectF mediabox, float destX, float destY, const WCHAR* text, const Rect* coords, int textLen) { + (void)destX; + float ty = (destY >= 0.f) ? destY - kAnchorTopMarginPt : 0.f; + if (ty < 0.f) { + ty = 0.f; + } + // When destY anchors at a "Figure N.M" / "Abbildung N.M" / "Table N.M" + // caption line, the figure / table *body* sits above the caption — but + // ty currently starts at the caption. Extend upward so the popup + // includes the figure body, not just the caption + the paragraph + // following it. + bool destAtCaption = false; + if (text && coords && textLen > 0 && destY > 0.f) { + int dY = (int)destY; + for (int i = 0; i < textLen; i++) { + int gy = coords[i].y; + if (gy < dY - 5 || gy > dY + 15) { + continue; + } + if (IsCaptionLabelAt(text, textLen, i)) { + destAtCaption = true; + break; + } + } + } + if (destAtCaption) { + constexpr float kFigureBodyExtendPt = 250.f; + float newTy = ty - kFigureBodyExtendPt; + if (newTy < 0.f) { + newTy = 0.f; + } + ty = newTy; + } + float h = mediabox.dy - ty; + if (h <= 0.f) { + h = mediabox.dy; + ty = 0.f; + } + // Cap to a focused region size so the popup is wide and short rather + // than narrow and tall. Captions get a taller cap so the figure body + // above and the caption text below both fit. + constexpr float kMaxLandscapePt = 200.f; + constexpr float kMaxLandscapeCaptionPt = 360.f; + float maxLandscape = destAtCaption ? kMaxLandscapeCaptionPt : kMaxLandscapePt; + if (h > maxLandscape) { + h = maxLandscape; + } + // Caption extension: if a "Figure N.M" / "Table N.M" / "Listing N.M" / + // "Algorithm N.M" caption appears within ~250pt below the capped region + // bottom (typical figure body height), extend the region downward to + // include the full caption block. Necessary for image-only figures + // where the figure body has no extractable text at destY — the caller + // falls to LandscapeBox without ever running the caption-aware + // DetectEntryBox path. + if (text && coords && textLen > 0) { + // Search to end of page so tall figures with captions far below the + // initial 200pt cap still match. First "Figure N.M" line-start on the + // page below the cap wins — typically the relevant caption. + int searchTop = (int)(ty + h); + int searchBot = (int)mediabox.dy; + int capStartIdx = -1; + for (int i = 0; i < textLen; i++) { + if (coords[i].y < searchTop || coords[i].y > searchBot) { + continue; + } + if (IsCaptionLabelAt(text, textLen, i)) { + capStartIdx = i; + break; + } + } + if (capStartIdx >= 0) { + int capStartY = coords[capStartIdx].y; + int capLineH = coords[capStartIdx].dy; + if (capLineH < 10) { + capLineH = 12; + } + // Page right text margin: max right-X across all text glyphs on + // the page. A line reaching within ~30pt of pageRightX is at the + // column edge (justified body, or a hyphenated caption line). + int pageRightX = 0; + for (int j = 0; j < textLen; j++) { + int rx = coords[j].x + coords[j].dx; + if (rx > pageRightX) { + pageRightX = rx; + } + } + // Walk subsequent lines below capStartY. Stop when we hit a + // paragraph break (vertical gap above inter-line leading) or + // a body-shape line. Two signals to detect body: + // 1) gap > ~70% of capLineH (parskip / float-separator) = + // new paragraph. + // 2) a "short" caption line seen earlier and the current line + // fills the column (raggedright-then-justified transition). + // Either signal alone catches a common case; together they cover + // hyphenated multi-line German captions (e.g. "...Bo-/gner...") + // where every caption line happens to reach the right margin. + int captionEndY = capStartY + capLineH; + int prevLineBottom = capStartY + capLineH - 1; + bool seenShortLine = false; + for (int lineIdx = 0; lineIdx < 3; lineIdx++) { + int capTop, capBot; + if (lineIdx == 0) { + capTop = capStartY - 3; + capBot = capStartY + 3; + } else { + capTop = prevLineBottom + 1; + capBot = prevLineBottom + capLineH * 18 / 10; + } + bool foundLine = false; + int lineTopY = INT_MAX; + int lineBottomY = -1; + int lineRightX = 0; + for (int j = 0; j < textLen; j++) { + int gy = coords[j].y; + if (gy < capTop || gy > capBot) { + continue; + } + foundLine = true; + if (gy < lineTopY) { + lineTopY = gy; + } + int gb = gy + coords[j].dy; + if (gb > lineBottomY) { + lineBottomY = gb; + } + int rx = coords[j].x + coords[j].dx; + if (rx > lineRightX) { + lineRightX = rx; + } + } + if (!foundLine) { + break; + } + bool isShort = lineRightX < pageRightX - 30; + if (lineIdx >= 1) { + int gap = lineTopY - prevLineBottom; + if (gap > capLineH * 7 / 10) { + break; + } + if (!isShort && seenShortLine) { + break; + } + } + captionEndY = lineBottomY; + prevLineBottom = lineBottomY; + if (isShort) { + seenShortLine = true; + } + } + float extendedH = (float)captionEndY + kAnchorTopMarginPt - ty; + if (extendedH > h) { + h = extendedH; + } + } + } + // Trim trailing blank margin: find the bottom of the last text glyph + // inside the candidate region and end the region just below it so the + // popup doesn't render an empty trailing margin. + if (text && coords && textLen > 0) { + int boxTop = (int)ty; + int boxBottom = (int)(ty + h); + int lastTextBottom = boxTop; + for (int i = 0; i < textLen; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + if (r.y < boxTop || r.y >= boxBottom) { + continue; + } + int glyphBottom = r.y + r.dy; + if (glyphBottom > lastTextBottom) { + lastTextBottom = glyphBottom; + } + } + float trimmedH = (float)lastTextBottom + kAnchorTopMarginPt - ty; + if (trimmedH > 20.f && trimmedH < h) { + h = trimmedH; + } + } + return RectF{0.f, ty, mediabox.dx, h}; +} + +// Detect a labelled display equation at (destX, destY): a "(N)" or "(N.M)" +// glyph cluster sitting near the right column edge on or near destY, with +// no other text further right on that line. Returns the equation's tight +// bounding box (full page width, ~one eq line tall) when found, empty rect +// otherwise. Used to avoid the landscape-style 200pt slice that sweeps in +// the paragraph and the next equation below an equation cross-reference. +RectF DetectEquationBox(const WCHAR* text, const Rect* coords, int textLen, RectF mediabox, float destX, float destY) { + (void)destX; + RectF empty{}; + if (destY <= 0.f || !text || textLen <= 0 || !coords) { + return empty; + } + int dY = (int)destY; + + // Scan glyphs in a band around destY. Find a ')' whose right edge is the + // rightmost in its line, preceded by digits and an opening '('. + int bestLabelY = -1; + int bestLabelDy = 0; + int bestDist = INT_MAX; + for (int i = 0; i < textLen; i++) { + if (text[i] != L')') { + continue; + } + int ly = coords[i].y; + if (ly < dY - 40 || ly > dY + 40) { + continue; + } + // Walk backward through digits on the same line. + int p = i - 1; + int digits = 0; + while (p >= 0 && str::IsDigit(text[p]) && coords[p].y == ly) { + p--; + digits++; + } + if (digits == 0) { + continue; + } + // Optional ".M" form. + if (p >= 0 && text[p] == L'.' && coords[p].y == ly) { + p--; + int d2 = 0; + while (p >= 0 && str::IsDigit(text[p]) && coords[p].y == ly) { + p--; + d2++; + } + if (d2 == 0) { + continue; + } + } + if (p < 0 || text[p] != L'(' || coords[p].y != ly) { + continue; + } + int labelLeftX = coords[p].x; + int labelRightX = coords[i].x + coords[i].dx; + // Reject if any non-space glyph on the same line sits further right + // than the label — equation labels are line-trailing by construction. + bool hasRightOf = false; + for (int j = 0; j < textLen; j++) { + if (j >= p && j <= i) { + continue; + } + if (str::IsWs(text[j])) { + continue; + } + if (coords[j].y != ly) { + continue; + } + if (coords[j].x + coords[j].dx > labelRightX) { + hasRightOf = true; + break; + } + } + if (hasRightOf) { + continue; + } + // Reject if the label sits in the left half of the page (likely a + // body-text "(N)" footnote marker, not a display-eq label). + if (labelLeftX < (int)(mediabox.dx * 0.5f)) { + continue; + } + int dist = std::abs(ly - dY); + if (dist < bestDist) { + bestDist = dist; + bestLabelY = ly; + bestLabelDy = coords[i].dy; + } + } + if (bestLabelY < 0) { + return empty; + } + if (bestLabelDy <= 0) { + bestLabelDy = 12; + } + // Region: one eq line — labeled row + small vertical padding. Multi-row + // align environments are rare in cross-refs; a tight box is the right + // default and the user can wheel-scroll if context is needed. + float pad = (float)bestLabelDy + 6.f; + RectF box{0.f, (float)bestLabelY - pad, mediabox.dx, (float)bestLabelDy + 2.f * pad}; + ClipToMediabox(box, mediabox); + return box; +} + +// Find the bounding box of a single bibliography entry on the destination +// page. Uses per-glyph text+coords from the engine's text cache: +// 1. Locate the leftmost glyph with y in a small band around destY (entry start). +// 2. Scan forward; stop at "[N" near the same left margin (next entry) or +// a vertical paragraph gap. +// 3. Return the min/max bounding box of glyphs in [start, end), padded. +// Falls back to LandscapeBox() when the link is not a bibliography reference +// (TOC, topbar, cross-ref, table caption). The landscape box renders a half- +// page-tall slice of the page anchored on the destination so the user sees +// surrounding context (e.g. the table rows under a caption). +RectF DetectEntryBox(const WCHAR* text, const Rect* coords, int textLen, RectF mediabox, float destX, float destY) { + // Sparse-text dest page (image-only or near-image-only — e.g. a + // children's PDF overview with character thumbnails plus a single + // heading). Fitting to the heading line gives a thin sliver and hides + // the actual content. Show the whole page so the user sees what they + // would navigate to; the auto-fit in RefHoverOnTimer scales the bitmap + // to popup limits. + constexpr int kSparsePageTextLen = 50; + if (!text || textLen < kSparsePageTextLen || !coords) { + return RectF{0.f, 0.f, mediabox.dx, mediabox.dy}; + } + if (destY < 0.f) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + + int dY = (int)destY; + int dX = (int)destX; + // Constrain to the destination's column — for 2-column layouts this + // prevents the search from latching onto same-Y body text in another + // column. We allow a small left tolerance so a "[1]" whose [ starts + // a few pt left of destX still matches. + int columnLeft = (destX >= 0.f) ? dX - 15 : INT_MIN; + + // 1. Find the start glyph: top-left non-whitespace glyph with + // y in [destY-5, destY+30] and x at-or-right-of columnLeft. + int startIdx = -1; + int bestY = INT_MAX; + int bestX = INT_MAX; + for (int i = 0; i < textLen; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + if (r.y < dY - 5 || r.y > dY + 30) { + continue; + } + if (r.x < columnLeft) { + continue; + } + if (r.y < bestY || (r.y == bestY && r.x < bestX)) { + bestY = r.y; + bestX = r.x; + startIdx = i; + } + } + if (startIdx < 0) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + + // PDF link destX is unreliable: poorly-authored links carry the source + // page's body-text X, not the destination-page entry-start X. That lands + // startIdx mid-line on hanging-indent description-list bibs, dropping + // the leading "[KOS06]" / "Philippe Kruchten" portion from the popup. + // Walk to the leftmost glyph on the same line as startIdx so the entry + // bounds always include the line's left edge. + { + int sy = coords[startIdx].y; + int leftmostX = coords[startIdx].x; + int leftmostIdx = startIdx; + for (int i = 0; i < textLen; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + if (r.y < sy - 3 || r.y > sy + 3) { + continue; + } + if (r.x < leftmostX) { + leftmostX = r.x; + leftmostIdx = i; + } + } + startIdx = leftmostIdx; + } + + // Tight-y walk above can miss a "[VB25]"-style label that sits on a + // slightly different baseline than its body line 1 (description-list + // layouts where label and body use different fonts/sizes). If the + // current leftmost still isn't a "[", search for one within roughly a + // line height of destY at any smaller x — that's the bracket label of + // the entry the link points at. + if (text[startIdx] != L'[') { + int sy = coords[startIdx].y; + int sDy = coords[startIdx].dy; + int yTol = sDy > 10 ? sDy : 10; + int bracketIdx = -1; + int bracketX = coords[startIdx].x; + for (int i = 0; i < textLen; i++) { + if (text[i] != L'[') { + continue; + } + Rect r = coords[i]; + if (r.y < sy - yTol || r.y > sy + yTol) { + continue; + } + if (r.x >= bracketX) { + continue; + } + bracketX = r.x; + bracketIdx = i; + } + if (bracketIdx >= 0) { + startIdx = bracketIdx; + } + } + + int firstLineLeftX = coords[startIdx].x; + int firstLineY = coords[startIdx].y; + int firstLineDy = coords[startIdx].dy; + if (firstLineDy <= 0) { + firstLineDy = 12; + } + + // Bracket-style entry ("[ZM12]", "[1]", …): build the bounding box from + // a y-range whose upper bound is the next "[" at firstLineLeftX. The + // iterative scan below depends on text-array order, but some PDFs draw + // labels and body in non-monotonic order — that made rule (a) terminate + // on a *later* entry's "[" appearing early in the text array, before our + // entry's body lines 2+. The y-range approach is order-independent. + if (text[startIdx] == L'[') { + int entryYBoundary = (int)mediabox.dy; + for (int i = 0; i < textLen; i++) { + if (i == startIdx) { + continue; + } + if (text[i] != L'[') { + continue; + } + Rect r = coords[i]; + // Accept "[" up to 30pt right of firstLineLeftX: some layouts + // prefix entries with a page number or section index (e.g. a + // "2" left of "[VB25]"), so the label "[" isn't exactly at + // firstLineLeftX. Body-text "[…]" sits at indentX (≥ ~60pt + // right of firstLineLeftX) so it's still excluded. + if (r.x < firstLineLeftX - 5 || r.x > firstLineLeftX + 30) { + continue; + } + if (r.y <= firstLineY + firstLineDy) { + continue; + } + if (r.y < entryYBoundary) { + entryYBoundary = r.y; + } + } + // Cap to a reasonable entry height so a last-on-page entry (no next + // "[") doesn't sweep the page footer / page number into the popup. + constexpr int kMaxBracketEntryPt = 250; + int capY = firstLineY + kMaxBracketEntryPt; + if (capY < entryYBoundary) { + entryYBoundary = capY; + } + // Pull the boundary up by ~half a line height so the next entry's + // first line — whose glyph tops can round to within 1–2 pt of the + // "[" we picked — is reliably excluded. + entryYBoundary -= 6; + int bMinX = INT_MAX, bMinY = INT_MAX, bMaxX = INT_MIN, bMaxY = INT_MIN; + for (int i = 0; i < textLen; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + if (r.x < firstLineLeftX - 20) { + continue; + } + if (r.y < firstLineY - 5) { + continue; + } + if (r.y >= entryYBoundary) { + continue; + } + if (r.x < bMinX) { + bMinX = r.x; + } + if (r.y < bMinY) { + bMinY = r.y; + } + if (r.x + r.dx > bMaxX) { + bMaxX = r.x + r.dx; + } + if (r.y + r.dy > bMaxY) { + bMaxY = r.y + r.dy; + } + } + if (bMinX != INT_MAX && (bMaxX - bMinX) >= 50 && (bMaxY - bMinY) >= 12) { + RectF box{(float)bMinX - kEntryPadPt, (float)bMinY - kEntryPadPt, + (float)(bMaxX - bMinX) + 2.f * kEntryPadPt, (float)(bMaxY - bMinY) + 2.f * kEntryPadPt}; + ClipToMediabox(box, mediabox); + if (box.dx >= 50.f && box.dy >= 20.f) { + return box; + } + } + // Fall through to the iterative-scan logic on degenerate result. + } + + // 2. Scan forward to find the end of the entry. + int endIdx = textLen; + int prevY = firstLineY; + int prevBottom = firstLineY + firstLineDy; + int lineHeight = firstLineDy; + + // Track leftmost X on the current line vs the previous line so we can + // detect indent changes (the most reliable signal for author-year bibs). + int currentLineLeftX = firstLineLeftX; + int prevLineLeftX = INT_MAX; + // X of the entry's continuation lines (captured from line 2). -1 = unknown. + int indentX = -1; + // Set when we observe another sibling entry start at firstLineLeftX with + // no continuation indent in between — strong "this is a description list" + // signal (e.g. "JVM Java Virtual Machine. 19, 36" / "LLM Large Language + // Model. 45" abbreviation lists) that survives even when the current + // entry is a single line. + bool descListSibling = false; + + for (int i = startIdx + 1; i < textLen; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + + // Stop on column wrap: y goes significantly above the current row. + if (r.y < firstLineY - 5) { + endIdx = i; + break; + } + // Skip glyphs in other columns (left of the entry's column). + if (r.x < firstLineLeftX - 20) { + continue; + } + + bool isNewLine = (r.y > prevY + 2); + if (isNewLine) { + prevLineLeftX = currentLineLeftX; + currentLineLeftX = r.x; + } else if (r.x < currentLineLeftX) { + currentLineLeftX = r.x; + } + + bool pastFirstLine = (r.y > firstLineY + firstLineDy * 3 / 4 + 2); + bool atFirstLineLeftX = (r.x >= firstLineLeftX - 5 && r.x <= firstLineLeftX + 5); + + // Capture the continuation X from the entry's second line. + if (isNewLine && pastFirstLine && indentX < 0 && !atFirstLineLeftX) { + indentX = r.x; + } + + // (a) "[" at the entry's first-line X = next entry marker. Works for + // both numeric "[123]" and alphanumeric "[Foo+09]" / "[Bib05]" styles + // — body-text "[…]" can't trigger this because body sits at indentX, + // not firstLineLeftX. + if (c == L'[' && atFirstLineLeftX) { + descListSibling = true; + endIdx = i; + break; + } + + // (b) Indent change: a new line back at the entry's first-line X + // after a continuation line at a different X. Catches author-year + // hanging-indent bibliographies where there's no [N] marker — this + // is the primary signal for the *next* entry's start. + if (isNewLine && atFirstLineLeftX && pastFirstLine && prevLineLeftX != INT_MAX && + (prevLineLeftX < firstLineLeftX - 5 || prevLineLeftX > firstLineLeftX + 5)) { + descListSibling = true; + endIdx = i; + break; + } + + // (c) Vertical paragraph break (no-indent style fallback). When the + // glyph that triggered the gap is back at firstLineLeftX, the gap + // is a blank line between description-list siblings (typical + // abbreviation lists where each entry is separated by extra + // vertical space) — treat as a sibling entry boundary. + if (r.y > prevBottom + lineHeight * 5 / 4) { + if (atFirstLineLeftX) { + descListSibling = true; + } + endIdx = i; + break; + } + + // (d) Single-line-entry case: a new line back at firstLineLeftX before + // we discovered a continuation indent. The previous "entry" was one + // line. Common pattern: stacked numbered footnotes "¹url\n²url\n³url" + // or abbreviation lists ("JVM Java Virtual Machine. 19, 36"). + if (isNewLine && pastFirstLine && atFirstLineLeftX && indentX < 0 && prevLineLeftX != INT_MAX) { + descListSibling = true; + endIdx = i; + break; + } + + // Track current line height as we go (catches changing leading). + if (isNewLine) { + int dy = r.y - prevY; + if (dy > 4 && dy < 60) { + lineHeight = dy; + } + prevY = r.y; + prevBottom = r.y + r.dy; + } + } + + // 3. Compute bounding box of glyphs in [startIdx, endIdx). + int minX = INT_MAX, minY = INT_MAX, maxX = INT_MIN, maxY = INT_MIN; + for (int i = startIdx; i < endIdx; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + Rect r = coords[i]; + // Exclude glyphs that aren't in the entry's column. + if (r.x < firstLineLeftX - 20) { + continue; + } + if (r.y < firstLineY - 5) { + continue; + } + if (r.x < minX) { + minX = r.x; + } + if (r.y < minY) { + minY = r.y; + } + if (r.x + r.dx > maxX) { + maxX = r.x + r.dx; + } + if (r.y + r.dy > maxY) { + maxY = r.y + r.dy; + } + } + if (minX == INT_MAX) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + + RectF box{(float)minX - kEntryPadPt, (float)minY - kEntryPadPt, (float)(maxX - minX) + 2.f * kEntryPadPt, + (float)(maxY - minY) + 2.f * kEntryPadPt}; + ClipToMediabox(box, mediabox); + if (box.dx < 50.f || box.dy < 20.f) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + // "Figure N.M" / "Table N.M" / "Listing N.M" / "Algorithm N.M" caption + // anywhere below the detected box: the destination is a figure / table + // / listing body. Override all other heuristics so the popup uses the + // landscape view (caption included). Catches code/console listings + // where each line happens to start with "[TAG]" — those would otherwise + // be misclassified as description-list bibliography entries. + { + int boxBottomY = (int)(box.y + box.dy); + for (int i = 0; i < textLen; i++) { + if (coords[i].y <= boxBottomY) { + continue; + } + if (IsCaptionLabelAt(text, textLen, i)) { + // Let LandscapeBox handle the caption-extension — it has a + // tighter, line-count-capped walk that doesn't sweep into + // following body paragraphs. + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + } + } + // Description-list bibliography ("[Smith2020]", "[1]", …) — unambiguous, + // keep the fitted box. + if (text[startIdx] == L'[') { + return box; + } + // Tabular layout: continuation X far right of firstLineLeftX is a + // column gap, not a hanging indent. Detection terminated at the first + // data row; show the landscape view so the user sees the full table. + if (indentX > 0 && (indentX - firstLineLeftX) > 80) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + // Section heading or caption-style label. Body paragraph below the + // heading has first-line indent, so detection captures heading + body + // line 1 and `indentX` lands in the same range as a hanging-indent bib. + // Use the entry's first character / first word to disambiguate: real + // bibliographies rarely start with a digit or with a label word like + // "Figure"/"Table"/"Section". Catches "6.2 Foo", "Figure 2.2: …", etc. + WCHAR firstC = text[startIdx]; + bool digitStart = (firstC >= L'0' && firstC <= L'9'); + bool labelStart = false; + for (int i = 0; !labelStart && kHeadingPrefixWords[i]; i++) { + labelStart = MatchWordAt(text, textLen, startIdx, kHeadingPrefixWords[i], /*requireTrailingDigit=*/false); + } + if (digitStart || labelStart) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + // Code-listing detector: a high density of braces / semicolons / parens + // within the detected box means the destination is most likely a code + // listing presented as a figure. Bibliography prose almost never has + // these characters at this density. Show the landscape view so the + // popup also includes the figure caption below the code. + { + int codeChars = 0; + int totalChars = 0; + for (int i = startIdx; i < endIdx; i++) { + WCHAR c = text[i]; + if (c == L' ' || c == L'\t' || c == L'\n' || c == L'\r') { + continue; + } + totalChars++; + if (c == L'{' || c == L'}' || c == L';' || c == L'(' || c == L')') { + codeChars++; + } + } + if (totalChars > 50 && codeChars * 12 > totalChars) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + } + // Description-list / glossary / footnote-style entry: rule (a) or (d) + // fired, meaning we saw a *sibling* entry start at firstLineLeftX. That + // is a strong "this is a list of entries" signal even when the current + // entry is a single line (abbreviations: "JVM Java Virtual Machine."). + if (descListSibling) { + return box; + } + // Single-line entry with no continuation indent and no sibling entry + // detected — caption / heading / in-text cross-ref destination. + if (box.dy < 30.f && indentX < 0) { + return LandscapeBox(mediabox, destX, destY, text, coords, textLen); + } + // Default: looks like a multi-line author-year bibliography entry, + // keep the fitted box. + return box; +} diff --git a/src/RefHoverDetect.h b/src/RefHoverDetect.h new file mode 100644 index 00000000000..18e41b44bd3 --- /dev/null +++ b/src/RefHoverDetect.h @@ -0,0 +1,32 @@ +/* Copyright 2026 the SumatraPDF project authors (see AUTHORS file). + License: GPLv3 */ + +// Pure-function region detectors used by RefHover to decide what slice of the +// destination page to render into the hover popup. Kept engine-independent so +// the heuristics can be unit-tested with synthetic glyph arrays (see +// src/utils/tests/RefHover_ut.cpp). +// +// All three functions take: +// text — per-glyph WCHAR array (engine->GetTextForPage's first out-ptr) +// coords — per-glyph Rect array, parallel to `text` (second out-ptr) +// textLen — glyph count +// mediabox — page bounds in PDF user space +// destX, destY — link's destination coordinates (PDF user space) +// +// Returned RectF is in PDF user space, clipped to mediabox. + +// Landscape view: full page width strip anchored at destY, extending downward +// to the last text glyph or a recognised caption block. Fallback when no +// recognisable entry or equation is found. +RectF LandscapeBox(RectF mediabox, float destX, float destY, const WCHAR* text, const Rect* coords, int textLen); + +// Equation cross-ref: tight one-line box around a "(N)" or "(N.M)" label +// sitting at the right column edge near destY. Returns empty rect when no +// equation label is detected. +RectF DetectEquationBox(const WCHAR* text, const Rect* coords, int textLen, RectF mediabox, float destX, float destY); + +// Bibliography / glossary / abbreviation entry box. Tries bracket-style +// ("[Foo+09]"), hanging-indent author-year, and single-line description-list +// layouts. Falls back to LandscapeBox when the destination doesn't look like +// a list entry. +RectF DetectEntryBox(const WCHAR* text, const Rect* coords, int textLen, RectF mediabox, float destX, float destY); diff --git a/src/Settings.h b/src/Settings.h index b9c8906d46b..72ec43d2029 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -484,6 +484,9 @@ struct GlobalPrefs { bool scrollbarInSinglePage; // if true, implements smooth scrolling bool smoothScroll; + // if true, hovering an internal-document link shows a popup rendering + // the destination region (citation entry, figure, footnote) + bool enableCitationHover; // if true, mouse wheel scrolling is faster when mouse is over a // scrollbar bool fastScrollOverScrollbar; @@ -901,6 +904,7 @@ static const FieldInfo gGlobalPrefsFields[] = { {offsetof(GlobalPrefs, scrollbars), SettingType::String, (intptr_t)"windows"}, {offsetof(GlobalPrefs, scrollbarInSinglePage), SettingType::Bool, false}, {offsetof(GlobalPrefs, smoothScroll), SettingType::Bool, false}, + {offsetof(GlobalPrefs, enableCitationHover), SettingType::Bool, true}, {offsetof(GlobalPrefs, fastScrollOverScrollbar), SettingType::Bool, false}, {offsetof(GlobalPrefs, preventSleepInFullscreen), SettingType::Bool, true}, {offsetof(GlobalPrefs, tabWidth), SettingType::Int, 300}, @@ -961,17 +965,17 @@ static const FieldInfo gGlobalPrefsFields[] = { {(size_t)-1, SettingType::Comment, (intptr_t)"Settings below are not recognized by the current version"}, }; static const StructInfo gGlobalPrefsInfo = { - sizeof(GlobalPrefs), 90, gGlobalPrefsFields, + sizeof(GlobalPrefs), 91, gGlobalPrefsFields, "\0\0CheckForUpdates\0CustomScreenDPI\0DefaultDisplayMode\0DefaultZoom\0EnableTeXEnhancements\0EscToExit\0FullPathI" "nTitle\0InverseSearchCmdLine\0LazyLoading\0MainWindowBackground\0NoHomeTab\0HomePageSortByFrequentlyRead\0ReloadMo" "difiedDocuments\0RememberOpenedFiles\0RememberStatePerDocument\0RestoreSession\0ReuseInstance\0ShowMenubar\0ShowMe" "nubarWithTabs\0ShowTips\0CustomColors\0ShowToolbar\0ShowFavorites\0ShowToc\0ShowLinks\0ShowStartPage\0SidebarDx\0S" - "crollbars\0ScrollbarInSinglePage\0SmoothScroll\0FastScrollOverScrollbar\0PreventSleepInFullscreen\0TabWidth\0Theme" - "\0TocDy\0ToolbarSize\0TreeFontName\0TreeFontSize\0UIFontSize\0DisableAntiAlias\0UseSysColors\0UseTabs\0TabsMru\0Zo" - "omLevels\0ZoomIncrement\0\0FixedPageUI\0\0EBookUI\0\0ComicBookUI\0\0ImageUI\0\0ChmUI\0\0Annotations\0\0ExternalVie" - "wers\0\0ForwardSearch\0\0PrinterDefaults\0\0Fullscreen\0\0SelectionHandlers\0\0Shortcuts\0\0Themes\0\0TabGroups\0" - "\0\0DefaultPasswords\0UiLanguage\0VersionToSkip\0WindowState\0WindowPos\0FileStates\0SessionData\0ReopenOnce\0Time" - "OfLastUpdateCheck\0OpenCountWeek\0PropWinPos\0\0"}; + "crollbars\0ScrollbarInSinglePage\0SmoothScroll\0EnableCitationHover\0FastScrollOverScrollbar\0PreventSleepInFullsc" + "reen\0TabWidth\0Theme\0TocDy\0ToolbarSize\0TreeFontName\0TreeFontSize\0UIFontSize\0DisableAntiAlias\0UseSysColors" + "\0UseTabs\0TabsMru\0ZoomLevels\0ZoomIncrement\0\0FixedPageUI\0\0EBookUI\0\0ComicBookUI\0\0ImageUI\0\0ChmUI\0\0Anno" + "tations\0\0ExternalViewers\0\0ForwardSearch\0\0PrinterDefaults\0\0Fullscreen\0\0SelectionHandlers\0\0Shortcuts\0\0" + "Themes\0\0TabGroups\0\0\0DefaultPasswords\0UiLanguage\0VersionToSkip\0WindowState\0WindowPos\0FileStates\0SessionD" + "ata\0ReopenOnce\0TimeOfLastUpdateCheck\0OpenCountWeek\0PropWinPos\0\0"}; static const FieldInfo gTheme_1_Fields[] = { {offsetof(Theme, name), SettingType::String, (intptr_t)""}, {offsetof(Theme, textColor), SettingType::Color, (intptr_t)""}, diff --git a/src/tools/test_util.cpp b/src/tools/test_util.cpp index 81318a2ecdd..d2e43bf47e5 100644 --- a/src/tools/test_util.cpp +++ b/src/tools/test_util.cpp @@ -19,6 +19,7 @@ extern void FileUtilTest(); extern void HtmlPrettyPrintTest(); extern void HtmlPullParser_UnitTests(); extern void JsonTest(); +extern void RefHoverTest(); extern void SettingsUtilTest(); extern void SimpleLogTest(); extern void SquareTreeTest(); @@ -51,6 +52,7 @@ int main(int, char**) { HtmlPrettyPrintTest(); HtmlPullParser_UnitTests(); JsonTest(); + RefHoverTest(); SettingsUtilTest(); SimpleLogTest(); SquareTreeTest(); diff --git a/src/utils/tests/RefHover_ut.cpp b/src/utils/tests/RefHover_ut.cpp new file mode 100644 index 00000000000..60755389b6a --- /dev/null +++ b/src/utils/tests/RefHover_ut.cpp @@ -0,0 +1,198 @@ +/* Copyright 2026 the SumatraPDF project authors (see AUTHORS file). + License: GPLv3 */ + +// Unit tests for the popup region detectors in RefHoverDetect. Each test +// constructs a synthetic per-glyph (text + coords) array that mimics what +// EngineBase::GetTextForPage produces, then asserts the detector returns a +// region matching the documented behaviour. + +#include "utils/BaseUtil.h" +#include "RefHoverDetect.h" + +// must be last due to assert() over-write +#include "utils/UtAssert.h" + +constexpr float kPageW = 612.f; +constexpr float kPageH = 792.f; +constexpr int kCharW = 6; +constexpr int kLineH = 12; + +// Helper: append the WCHARs of `s` starting at (x, y) with fixed-width glyphs. +// Caller passes pre-allocated text/coords buffers and the current length. +static void AddText(WCHAR* text, Rect* coords, int& len, int cap, const WCHAR* s, int x, int y) { + for (int i = 0; s[i] && len < cap; i++) { + text[len] = s[i]; + coords[len] = Rect{x + i * kCharW, y, kCharW, kLineH}; + len++; + } +} + +static RectF Mediabox() { + return RectF{0.f, 0.f, kPageW, kPageH}; +} + +static bool IsEmpty(RectF r) { + return r.dx <= 0.f || r.dy <= 0.f; +} + +// (1) Sparse-text destination page (< 50 non-empty glyphs): DetectEntryBox +// returns the whole page so an image-heavy page renders fully. +static void SparseTextReturnsWholePage() { + WCHAR text[64]; + Rect coords[64]; + int len = 0; + AddText(text, coords, len, 64, L"Heading only", 100, 100); + RectF box = DetectEntryBox(text, coords, len, Mediabox(), 100.f, 100.f); + utassert(box.x == 0.f); + utassert(box.y == 0.f); + utassert(box.dx == kPageW); + utassert(box.dy == kPageH); +} + +// (2) destY < 0 with rich text: DetectEntryBox delegates to LandscapeBox, +// which returns a full-width strip anchored at the page top. +static void NegativeDestYFallsToLandscape() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + for (int i = 0; i < 10; i++) { + AddText(text, coords, len, 512, L"Body text line content here.", 72, 100 + i * 14); + } + RectF box = DetectEntryBox(text, coords, len, Mediabox(), 72.f, -1.f); + utassert(box.x == 0.f); + utassert(box.y == 0.f); + utassert(box.dx == kPageW); + utassert(box.dy > 0.f); + utassert(box.dy < kPageH); +} + +// (3) Bracket-style bibliography "[Foo10]" / "[Bar11]": DetectEntryBox fits +// to the first entry and does not include the second. +static void BracketEntryFitsToOneEntry() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + // Entry 1 at y=200, two lines (line 2 indented). + AddText(text, coords, len, 512, L"[Foo10] Smith J., 2010, Some title.", 72, 200); + AddText(text, coords, len, 512, L"continuation line of the entry.", 92, 215); + // Entry 2 at y=240, sibling start back at x=72. + AddText(text, coords, len, 512, L"[Bar11] Doe J., 2011, Another title.", 72, 240); + AddText(text, coords, len, 512, L"continuation line of the entry.", 92, 255); + RectF box = DetectEntryBox(text, coords, len, Mediabox(), 72.f, 200.f); + utassert(!IsEmpty(box)); + // Box should end before entry 2 starts at y=240. + utassert(box.y + box.dy < 240.f); + // Box should start at or after entry 1's first line. + utassert(box.y <= 200.f + 6.f); + utassert(box.y >= 200.f - 12.f); +} + +// (4) Equation label "(14)" at right column edge near destY: DetectEquationBox +// returns a tight, full-width strip near the label. +static void EquationLabelDetected() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + // Equation row at y=300, label "(14)" at x≈540 (right of mediabox.dx*0.5). + AddText(text, coords, len, 512, L"dH/dt = -sum( ... )", 200, 300); + AddText(text, coords, len, 512, L"(14)", 540, 300); + // A paragraph below. + for (int i = 0; i < 3; i++) { + AddText(text, coords, len, 512, L"Paragraph text below the equation here.", 72, 330 + i * 14); + } + RectF box = DetectEquationBox(text, coords, len, Mediabox(), 72.f, 300.f); + utassert(!IsEmpty(box)); + utassert(box.x == 0.f); + utassert(box.dx == kPageW); + // Tight vertical band around y=300 (within ~3 line heights). + utassert(box.y < 300.f); + utassert(box.y + box.dy < 300.f + 3 * (float)kLineH + 20.f); +} + +// (5) "(N)" sitting in the left half of the page (body-text marker, not a +// display-eq label): DetectEquationBox returns empty. +static void BodyTextParenRejected() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + // "(14)" at x=80 — left half of 612-wide page. + AddText(text, coords, len, 512, L"(14) inline text continuing past the label.", 80, 300); + RectF box = DetectEquationBox(text, coords, len, Mediabox(), 80.f, 300.f); + utassert(IsEmpty(box)); +} + +// (6) "(N)" with more text further right on the same line: DetectEquationBox +// returns empty (label must be line-trailing). +static void NonTrailingParenRejected() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + // Label "(14)" at x=540 followed by text at x=560 (further right). + AddText(text, coords, len, 512, L"dH/dt = ...", 200, 300); + AddText(text, coords, len, 512, L"(14)", 540, 300); + AddText(text, coords, len, 512, L"trail", 560, 300); + RectF box = DetectEquationBox(text, coords, len, Mediabox(), 200.f, 300.f); + utassert(IsEmpty(box)); +} + +// (7) Empty / null inputs: DetectEquationBox returns empty rect, never crashes. +static void EmptyInputsHandled() { + RectF box1 = DetectEquationBox(nullptr, nullptr, 0, Mediabox(), 0.f, 100.f); + utassert(IsEmpty(box1)); + WCHAR text[1] = {0}; + Rect coords[1]{}; + RectF box2 = DetectEquationBox(text, coords, 0, Mediabox(), 0.f, 100.f); + utassert(IsEmpty(box2)); + // destY <= 0 short-circuit. + RectF box3 = DetectEquationBox(text, coords, 0, Mediabox(), 0.f, -1.f); + utassert(IsEmpty(box3)); +} + +// (8a) Caption-detection table covers non-English label words: a "Tableau 2" +// French caption above the destination triggers the upward figure-body +// extension (region top moves above destY). +static void FrenchCaptionDetected() { + WCHAR text[512]; + Rect coords[512]; + int len = 0; + // Caption line "Tableau 2: Données" at y=300 — this is the destY. + AddText(text, coords, len, 512, L"Tableau 2: Donnees", 72, 300); + // Body paragraph below. + for (int i = 0; i < 5; i++) { + AddText(text, coords, len, 512, L"Paragraphe de texte courant.", 72, 320 + i * 14); + } + RectF box = LandscapeBox(Mediabox(), 72.f, 300.f, text, coords, len); + // destAtCaption pulls region top above destY (figure body extension). + utassert(box.y < 300.f - 50.f); + utassert(box.dx == kPageW); +} + +// (9) LandscapeBox: returned region spans the full mediabox width, starts at +// destY-margin, height is positive and bounded. +static void LandscapeBoxBasicShape() { + WCHAR text[256]; + Rect coords[256]; + int len = 0; + for (int i = 0; i < 5; i++) { + AddText(text, coords, len, 256, L"some body content.", 72, 400 + i * 14); + } + RectF box = LandscapeBox(Mediabox(), 72.f, 400.f, text, coords, len); + utassert(box.x == 0.f); + utassert(box.dx == kPageW); + utassert(box.y >= 0.f); + utassert(box.y < 400.f); + utassert(box.dy > 0.f); + utassert(box.y + box.dy <= kPageH); +} + +void RefHoverTest() { + BodyTextParenRejected(); + BracketEntryFitsToOneEntry(); + EmptyInputsHandled(); + EquationLabelDetected(); + FrenchCaptionDetected(); + LandscapeBoxBasicShape(); + NegativeDestYFallsToLandscape(); + NonTrailingParenRejected(); + SparseTextReturnsWholePage(); +} diff --git a/vs2022/SumatraPDF-dll.vcxproj b/vs2022/SumatraPDF-dll.vcxproj index ffca30588d0..5ae05c4a5bb 100644 --- a/vs2022/SumatraPDF-dll.vcxproj +++ b/vs2022/SumatraPDF-dll.vcxproj @@ -1244,6 +1244,8 @@ cd ../out/rel64_prefast_asan & ..\..\bin\MakeLZSA.exe InstallerData.dat libm + + diff --git a/vs2022/SumatraPDF-dll.vcxproj.filters b/vs2022/SumatraPDF-dll.vcxproj.filters index 0e7c9f63093..ec65a79595f 100644 --- a/vs2022/SumatraPDF-dll.vcxproj.filters +++ b/vs2022/SumatraPDF-dll.vcxproj.filters @@ -234,6 +234,9 @@ src + + src + src @@ -551,6 +554,9 @@ src + + src + src diff --git a/vs2022/SumatraPDF.vcxproj b/vs2022/SumatraPDF.vcxproj index 696c433c017..ff2e3b84a3d 100644 --- a/vs2022/SumatraPDF.vcxproj +++ b/vs2022/SumatraPDF.vcxproj @@ -1227,6 +1227,8 @@ + + diff --git a/vs2022/SumatraPDF.vcxproj.filters b/vs2022/SumatraPDF.vcxproj.filters index 73392dde4c2..9a197404e4a 100644 --- a/vs2022/SumatraPDF.vcxproj.filters +++ b/vs2022/SumatraPDF.vcxproj.filters @@ -234,6 +234,9 @@ src + + src + src @@ -548,6 +551,9 @@ src + + src + src diff --git a/vs2022/test_util.vcxproj b/vs2022/test_util.vcxproj index 85a94a11367..0bcdacdd4a4 100644 --- a/vs2022/test_util.vcxproj +++ b/vs2022/test_util.vcxproj @@ -910,6 +910,7 @@ + @@ -958,6 +959,7 @@ +