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 @@
+