diff --git a/frontend/src/components/pdf/PdfPaperLink.test.tsx b/frontend/src/components/pdf/PdfPaperLink.test.tsx
index cf28d37f..02fe8097 100644
--- a/frontend/src/components/pdf/PdfPaperLink.test.tsx
+++ b/frontend/src/components/pdf/PdfPaperLink.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { PdfPaperLink } from './PdfPaperLink';
+import { PdfBibliographyEntryLink, PdfPaperLink } from './PdfPaperLink';
const get = vi.hoisted(() => vi.fn());
vi.mock('@/lib/api/papers', () => ({ papersApi: { get } }));
@@ -57,4 +57,30 @@ describe('PDF paper previews', () => {
await waitFor(() => expect(screen.getByText('Preview unavailable')).toBeInTheDocument());
expect(screen.queryByRole('link', { name: 'Open paper ↗' })).not.toBeInTheDocument();
});
+
+ it('steps a bibliography card through the places the text cites it', async () => {
+ get.mockResolvedValue({ title: 'Microsoft COCO', authors: ['Tsung-Yi Lin'], arxiv_id: '1405.0312', tasks: [] });
+ const citations = [[{ page: 2, bbox: [10, 20, 30, 40] }], [{ page: 5, bbox: [50, 60, 70, 80] }, { page: 5, bbox: [5, 82, 20, 95] }]];
+ const onShowCitation = vi.fn();
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+ );
+ await userEvent.hover(screen.getByRole('link', { name: 'Reference 1405.0312' }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Jump to citation 1 of 2' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Jump to citation 2 of 2' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Jump to citation 1 of 2' }));
+ expect(onShowCitation.mock.calls.map(([citation]) => citation)).toEqual([citations[0], citations[1], citations[0]]);
+ });
+
+ it('offers no jump for an entry the text never cites', async () => {
+ get.mockResolvedValue({ title: 'Microsoft COCO', authors: ['Tsung-Yi Lin'], arxiv_id: '1405.0312', tasks: [] });
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+ );
+ await userEvent.hover(screen.getByRole('link', { name: 'Reference 1405.0312' }));
+ expect(await screen.findByRole('link', { name: 'Open paper ↗' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Jump to citation/ })).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/pdf/PdfPaperLink.tsx b/frontend/src/components/pdf/PdfPaperLink.tsx
index 69a81190..b8db9f75 100644
--- a/frontend/src/components/pdf/PdfPaperLink.tsx
+++ b/frontend/src/components/pdf/PdfPaperLink.tsx
@@ -1,7 +1,8 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { PaperPreviewLink } from "@/components/paper-preview/PaperPreviewLink";
import type { PdfBibliographyLink } from "./pdfCitationLinks";
+import type { PdfHighlightBox } from "./pdfCitations";
export interface PdfPaperLinkTarget {
anchor: HTMLAnchorElement;
@@ -40,8 +41,14 @@ export function PdfPaperLink({ anchor, routeId, internal }: PdfPaperLinkTarget)
}
// A bibliography entry has no PDF link of its own, so its preview target is
-// positioned over the entry's text on the page's 0–1000 grid.
-export function PdfBibliographyEntryLink({ routeId, box: [x0, y0, x1, y1] }: PdfBibliographyLink) {
+// positioned over the entry's text on the page's 0–1000 grid. Its card can jump
+// back to where the text cites it, stepping through each citation in turn.
+export function PdfBibliographyEntryLink({ routeId, box: [x0, y0, x1, y1], citations = [], onShowCitation }: PdfBibliographyLink & {
+ citations?: PdfHighlightBox[][];
+ onShowCitation?: (citation: PdfHighlightBox[]) => void;
+}) {
+ const [next, setNext] = useState(0);
+ const citation = citations[next % citations.length];
return
+
Open paper ↗
+ {citation && onShowCitation &&
}
+
}
>{null}
;
}
diff --git a/frontend/src/components/pdf/PdfViewer.tsx b/frontend/src/components/pdf/PdfViewer.tsx
index 21c62f64..2099da44 100644
--- a/frontend/src/components/pdf/PdfViewer.tsx
+++ b/frontend/src/components/pdf/PdfViewer.tsx
@@ -18,7 +18,7 @@ import { usePdfPaperLinks } from "./usePdfPaperLinks";
import { PdfOutline } from "./PdfOutline";
import { clampZoom, stepZoom } from "./pdfZoom";
import { activeSectionIndex, loadPdfOutline, pdfSectionFromHash, type PdfOutlineEntry } from "./pdfOutline";
-import type { PdfHighlight } from "./pdfCitations";
+import type { PdfHighlight, PdfHighlightBox } from "./pdfCitations";
import { countMatches, createPdfTextIndex, type PdfTextIndex } from "./pdfTextIndex";
interface PdfViewerProps {
@@ -132,7 +132,8 @@ function LazyPdfPage({
resolveCitation,
warmPreviews,
highlightBoxes,
- highlightNonce,
+ highlightKey,
+ onShowCitation,
}: {
page: number;
width: number;
@@ -142,19 +143,24 @@ function LazyPdfPage({
resolveCitation?: PdfCitationResolver;
warmPreviews?: (routeIds: string[]) => void;
highlightBoxes?: number[][];
- highlightNonce?: number;
+ highlightKey?: string;
+ onShowCitation?: (citation: PdfHighlightBox[]) => void;
}) {
const ref = useRef(null);
const [visible, setVisible] = useState(page <= 2);
const { paperLinks, onAnnotations } = usePdfPaperLinks(ref, visible, resolveCitation, warmPreviews);
- const [bibliographyLinks, setBibliographyLinks] = useState([]);
+ const [bibliographyLinks, setBibliographyLinks] = useState<(PdfBibliographyLink & { citations?: PdfHighlightBox[][] })[]>([]);
useEffect(() => {
if (!visible || !resolveCitation) return;
let current = true;
- void resolveCitation.bibliographyLinks(page).then(links => {
+ void resolveCitation.bibliographyLinks(page).then(async links => {
if (!current) return;
if (links.length) warmPreviews?.([...new Set(links.map(link => link.routeId))]);
setBibliographyLinks(links);
+ if (!links.length) return;
+ // Cards appear first; their jumps to in-text citations follow the scan.
+ const citations = await Promise.all(links.map(link => resolveCitation.citationsOf(link.name)));
+ if (current) setBibliographyLinks(links.map((link, index) => ({ ...link, citations: citations[index] })));
}).catch(() => undefined);
return () => { current = false; };
}, [page, resolveCitation, visible, warmPreviews]);
@@ -249,9 +255,9 @@ function LazyPdfPage({
})}
{snapshot && !painted &&
}
{visible && paperLinks.map(link => )}
- {visible && resolveCitation && bibliographyLinks.map(link => )}
+ {visible && resolveCitation && bibliographyLinks.map(link => )}
{highlightBoxes?.map(([x0, y0, x1, y1], index) => (null);
const scrollRef = useRef
(null);
const pagesRef = useRef(null);
+ // A bibliography card's jump to its in-text citation reuses the chat
+ // citation highlight; whichever was requested last is shown.
+ const [citationJump, setCitationJump] = useState();
+ useEffect(() => setCitationJump(undefined), [citedHighlight?.nonce, url]);
+ const highlight = citationJump ?? citedHighlight;
+ const highlightKey = highlight && `${citationJump ? "jump" : "cited"}-${highlight.nonce}`;
+ const showCitation = useCallback((citation: PdfHighlightBox[]) => {
+ setCitationJump((previous) => ({ boxes: citation, nonce: (previous?.nonce ?? 0) + 1 }));
+ }, []);
const documentRef = useRef(null);
const textIndex = useRef();
const [resolveCitation, setResolveCitation] = useState();
@@ -653,7 +668,7 @@ export function PdfViewer({
// Re-run per request, once pages exist and once the real page aspect is
// measured (placeholder heights change then), not on scroll-state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [highlight?.nonce, pages, pageAspect]);
+ }, [highlightKey, pages, pageAspect]);
const jumpToSection = (entry: PdfOutlineEntry, behavior?: ScrollBehavior) => {
const scroller = scrollRef.current;
const target = scroller?.querySelector(`[data-page="${entry.page}"]`);
@@ -786,7 +801,8 @@ export function PdfViewer({
resolveCitation={resolveCitation}
warmPreviews={previewCache.warm}
highlightBoxes={highlight?.boxes.filter((box) => box.page === pageNumber).map((box) => box.bbox)}
- highlightNonce={highlight?.nonce}
+ highlightKey={highlightKey}
+ onShowCitation={showCitation}
/>)}
}
diff --git a/frontend/src/components/pdf/pdfCitationLinks.test.ts b/frontend/src/components/pdf/pdfCitationLinks.test.ts
index 66bb65d5..bb14c35e 100644
--- a/frontend/src/components/pdf/pdfCitationLinks.test.ts
+++ b/frontend/src/components/pdf/pdfCitationLinks.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
-import { arxivIdFromUrl, createPdfCitationResolver, referenceTitleCandidates } from './pdfCitationLinks';
+import { arxivIdFromUrl, createPdfCitationResolver, numberedCitationBoxes, referenceTitleCandidates } from './pdfCitationLinks';
describe('arXiv PDF links', () => {
it.each([
@@ -90,13 +90,34 @@ describe('arXiv PDF links', () => {
]);
expect(await resolve.bibliographyLinks(3)).toEqual([]);
});
+
+ it('lists where the text cites each entry, from links to its destination, joining split and wrapped author/year links, past an unreadable page', async () => {
+ const { pdf, getPage } = document();
+ const pages: Record = {
+ 2: [{ dest: 'cite.lin2014', rect: [120, 700, 132, 710] }, { dest: 'section.2', rect: [60, 600, 80, 610] }, { url: 'https://example.com', rect: [0, 0, 1, 1] }],
+ 3: 'unreadable',
+ 5: [{ dest: 'cite.right', rect: [300, 400, 312, 410] }, { dest: 'cite.lin2014', rect: [60, 200, 72, 210] }, { dest: 'cite.lin2014', rect: [75, 200, 90, 210] }, { dest: 'cite.lin2014', rect: [30, 190, 42, 200] }],
+ };
+ getPage.mockImplementation(async (page: number) => pages[page] === 'unreadable' ? Promise.reject(new Error('range request failed')) : ({
+ view: [0, 0, 600, 800],
+ getAnnotations: async () => page === 12 ? [{ url: 'https://arxiv.org/abs/1405.0312', rect: [85, 610, 150, 620] }, { url: 'https://arxiv.org/abs/2501.33333', rect: [320, 610, 400, 620] }] : pages[page] ?? [],
+ getTextContent: async () => ({ items: page === 12 ? [{ str: '[1] T. Lin.', transform: [10, 0, 0, 10, 85, 640], width: 200, height: 10 }, { str: '[3] Right.', transform: [10, 0, 0, 10, 320, 640], width: 200, height: 10 }] : [] }),
+ }));
+ Object.assign(pdf, { numPages: 12 });
+ const resolve = createPdfCitationResolver(pdf);
+ const names = (await resolve.bibliographyLinks(12)).map(link => link.name);
+ expect(await Promise.all(names.map(async name => [name, await resolve.citationsOf(name)]))).toEqual([
+ ['cite.lin2014', [[{ page: 2, bbox: [200, 113, 220, 125] }], [{ page: 5, bbox: [100, 738, 150, 750] }, { page: 5, bbox: [50, 750, 70, 763] }]]],
+ ['cite.right', [[{ page: 5, bbox: [500, 488, 520, 500] }]]],
+ ]);
+ });
});
describe('PDFs without bibliography destinations', () => {
const item = (str: string, x: number, y: number, hasEOL = false) => ({ str, transform: [8, 0, 0, 8, x, y], width: 100, height: 8, hasEOL });
function wordExport() {
const pages: Record = {
- 1: [item('Prior work [7] shows this.', 47, 600, true), item('References', 47, 500, true)],
+ 1: [item('Prior work [2, 7] shows this.', 47, 600, true), item('References', 47, 500, true)],
2: [
item('', 47, 700, true), item('[', 47, 700), item('1]', 50, 700), item(' A. Author. A first paper.', 60, 700, true),
item('arXiv:2501.11111', 57, 691, true),
@@ -127,6 +148,31 @@ describe('PDFs without bibliography destinations', () => {
expect(await resolve.bibliographyLinks(1)).toEqual([]);
expect(lookup.mock.calls[0][0].map((query: { key: string }) => query.key)).toEqual(['text:1', 'text:2', 'text:3', 'text:4']);
});
+
+ it('finds numbered citations in the text before the bibliography', async () => {
+ const resolve = createPdfCitationResolver(wordExport());
+ expect(await resolve.citationsOf('text:1')).toEqual([]);
+ // The in-text "[2, 7]" cites [2]; "[9]" inside the bibliography is no citation.
+ expect(await resolve.citationsOf('text:2')).toEqual([[{ page: 1, bbox: [142, 240, 176, 253] }]]);
+ expect(await resolve.citationsOf('text:4')).toEqual([]);
+ });
+});
+
+describe('numbered in-text citations', () => {
+ const item = (str: string, x: number, width: number, y = 500, hasEOL = false) => ({ str, dir: 'ltr', fontName: 'f', transform: [10, 0, 0, 10, x, y], width, height: 10, hasEOL });
+ it('boxes single labels, lists, ranges, and labels split across items', () => {
+ const boxes = numberedCitationBoxes([
+ item('Shown in [2] and', 100, 160),
+ item(' [3, 5-7].', 260, 100, 500, true),
+ item('Split [', 100, 70, 480),
+ item('12]', 170, 30, 480),
+ item('Not a citation [abc] or [2023a].', 100, 300, 460),
+ ], [0, 0, 1000, 1000]);
+ expect(boxes.get(2)).toEqual([[190, 490, 220, 503]]);
+ expect([...boxes.keys()].sort((a, b) => a - b)).toEqual([2, 3, 5, 6, 7, 12]);
+ expect(boxes.get(6)).toEqual(boxes.get(3));
+ expect(boxes.get(12)).toEqual([[160, 510, 200, 523]]);
+ });
});
describe('bibliography title candidates', () => {
diff --git a/frontend/src/components/pdf/pdfCitationLinks.ts b/frontend/src/components/pdf/pdfCitationLinks.ts
index 2ff1f34f..426e1093 100644
--- a/frontend/src/components/pdf/pdfCitationLinks.ts
+++ b/frontend/src/components/pdf/pdfCitationLinks.ts
@@ -1,5 +1,6 @@
import type { PDFDocumentProxy, TextContent, TextItem } from "pdfjs-dist/types/src/display/api";
import type { PaperReferenceQuery } from "@/lib/api/papers.types";
+import type { PdfHighlightBox } from "./pdfCitations";
export interface PdfLinkAnnotation {
id: string;
@@ -23,6 +24,8 @@ export function arxivIdFromUrl(value: string): string | null {
const MAX_TITLE_CANDIDATES = 8;
const REFERENCE_HEADING_SCAN_PAGES = 12;
const MAX_TITLE_LENGTH = 300;
+// Pages whose citations are read ahead of the one being indexed.
+const CITATION_READ_AHEAD = 6;
// Sentences of a bibliography entry that could be its title. The backend only
// accepts an exact letters-and-digits match, so author lists and venues are
@@ -68,6 +71,60 @@ interface PageText { view: number[]; content: TextContent }
/** A resolved bibliography entry on one page; box is [x0, y0, x1, y1] on a 0–1000 page grid. */
export interface PdfBibliographyLink { name: string; routeId: string; box: number[] }
+// Converts PDF user-space coordinates to the 0–1000 grid of a page's view box.
+function pageGrid([left, bottom, right, top]: number[]) {
+ return {
+ x: (value: number) => Math.round((value - left) / (right - left) * 1000),
+ y: (value: number) => Math.round((top - value) / (top - bottom) * 1000),
+ };
+}
+
+// Numbered in-text citations such as "[3]", "[2, 7]", or "[4-6]".
+const NUMBERED_CITATION = /\[(\d{1,3}(?:\s*[,;\u2013-]\s*\d{1,3})*)\]/g;
+
+function citedNumbers(group: string): number[] {
+ return group.split(/[,;]/).flatMap(part => {
+ const [from, to = from] = part.split(/[\u2013-]/).map(Number);
+ return to >= from && to - from < 50 ? Array.from({ length: to - from + 1 }, (_, index) => from + index) : [from];
+ });
+}
+
+// Boxes of the numbered citations printed in text items, keyed by number. Items
+// of a line are joined first because "[20]" may arrive as "[" + "20]".
+export function numberedCitationBoxes(items: TextContent['items'], view: number[]): Map {
+ const grid = pageGrid(view);
+ const boxes = new Map();
+ const lines: { text: string; parts: { start: number; item: TextItem }[] }[] = [];
+ let line: (typeof lines)[number] | undefined;
+ for (const item of items) {
+ if (!('str' in item)) continue;
+ if (!line || Math.abs(item.transform[5] - line.parts[0].item.transform[5]) > 1) {
+ line = { text: '', parts: [] };
+ lines.push(line);
+ }
+ line.parts.push({ start: line.text.length, item });
+ line.text += item.str;
+ if (item.hasEOL) line = undefined;
+ }
+ for (const { text, parts } of lines) {
+ const partAt = (offset: number) => parts.filter(part => part.start <= offset).pop() ?? parts[0];
+ // Character positions inside an item are estimated from its width.
+ const xAt = (offset: number) => {
+ const { start, item } = partAt(offset);
+ return item.transform[4] + item.width * Math.min(1, (offset - start) / Math.max(item.str.length, 1));
+ };
+ for (const match of text.matchAll(NUMBERED_CITATION)) {
+ const index = match.index ?? 0;
+ const { item } = partAt(index);
+ const baseline = item.transform[5];
+ const height = item.height || Math.abs(item.transform[3]);
+ const box = [grid.x(xAt(index)), grid.y(baseline + height), grid.x(xAt(index + match[0].length)), grid.y(baseline) + 3];
+ for (const number of citedNumbers(match[1])) boxes.set(number, [...boxes.get(number) ?? [], box]);
+ }
+ }
+ return boxes;
+}
+
// Named bibliography destinations delimit each entry. An explicit arXiv link or
// identifier inside the entry wins; otherwise the entry's sentences go to the
// catalog for exact title matching. Author names alone never resolve anything.
@@ -77,6 +134,7 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit
const pageText = new Map>();
const entries = new Map>();
let catalog: Promise