From 9b48266596ee252ff6df8783f97d8a9c5f3e3730 Mon Sep 17 00:00:00 2001 From: Niels Rogge Date: Tue, 27 Oct 2026 15:22:42 +0000 Subject: [PATCH 1/3] Jump from a PDF bibliography card to where the paper cites it Bibliography cards could open the cited paper but not lead back into the text. The citation resolver now also indexes every in-text citation of each entry: hyperref links to its cite.* destination, or for numbered PDFs without them, "[n]", "[2, 7]" and "[4-6]" labels printed before the bibliography. Author/year links split across "Chen et al." and "2017", or wrapped onto the next line, count as one citation. The card footer gains "Jump to citation" (with "n of m" when cited more than once, stepping through each in turn), which scrolls to the citation and flashes the existing chat-citation highlight over it. Co-Authored-By: Claude Opus 5.5 --- .../src/components/pdf/PdfPaperLink.test.tsx | 28 +++- frontend/src/components/pdf/PdfPaperLink.tsx | 19 ++- frontend/src/components/pdf/PdfViewer.tsx | 28 ++-- .../components/pdf/pdfCitationLinks.test.ts | 53 ++++++- .../src/components/pdf/pdfCitationLinks.ts | 130 ++++++++++++++++-- 5 files changed, 230 insertions(+), 28 deletions(-) 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..bae5fc03 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,13 @@ 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 & { + 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..681467e7 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,7 +143,8 @@ 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); @@ -249,9 +251,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) => } diff --git a/frontend/src/components/pdf/pdfCitationLinks.test.ts b/frontend/src/components/pdf/pdfCitationLinks.test.ts index 66bb65d5..f8a80658 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([ @@ -85,18 +85,37 @@ describe('arXiv PDF links', () => { const lookup = vi.fn().mockResolvedValue(new Map()); const resolve = createPdfCitationResolver(pdf, lookup); expect(await resolve.bibliographyLinks(12)).toEqual([ - { name: 'cite.lin2014', routeId: '1405.0312', box: [142, 188, 475, 218] }, - { name: 'cite.right', routeId: '2501.33333', box: [533, 188, 867, 203] }, + { name: 'cite.lin2014', routeId: '1405.0312', box: [142, 188, 475, 218], citations: [] }, + { name: 'cite.right', routeId: '2501.33333', box: [533, 188, 867, 203], citations: [] }, ]); 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', 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] }], + 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) => ({ + 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 links = await createPdfCitationResolver(pdf).bibliographyLinks(12); + expect(links.map(({ name, citations }) => [name, citations])).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 +146,32 @@ 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()); + const links = await resolve.bibliographyLinks(2); + expect(links.find(link => link.name === 'text:1')?.citations).toEqual([]); + // The in-text "[2, 7]" cites [2]; "[9]" inside the bibliography is no citation. + expect(links.find(link => link.name === 'text:2')?.citations).toEqual([[{ page: 1, bbox: [142, 240, 176, 253] }]]); + expect((await resolve.bibliographyLinks(3)).map(link => link.citations)).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..874ac09a 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; @@ -65,8 +66,66 @@ function textLines(items: TextContent['items']): TextLine[] { interface ReferenceEntry { arxivId: string | null; titles: string[]; box: number[] | null } 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[] } +/** + * A resolved bibliography entry on one page; box is [x0, y0, x1, y1] on a + * 0–1000 page grid, and citations are where the text cites it, in reading + * order, each as the boxes of its one or more printed lines. + */ +export interface PdfBibliographyLink { name: string; routeId: string; box: number[]; citations: PdfHighlightBox[][] } + +// 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 @@ -77,6 +136,7 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit const pageText = new Map>(); const entries = new Map>(); let catalog: Promise> | undefined; + let citations: Promise> | undefined; const getPageText = (page: number) => { let text = pageText.get(page); if (!text) { @@ -85,6 +145,14 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit } return text; }; + const getPageLinks = (page: number) => { + let annotations = pageLinks.get(page); + if (!annotations) { + annotations = pdf.getPage(page).then(proxy => proxy.getAnnotations()); + pageLinks.set(page, annotations); + } + return annotations; + }; const getReferences = () => references ??= (async () => { const destinations = await pdf.getDestinations(); const positions: ReferencePosition[] = []; @@ -132,14 +200,9 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit const column = samePage.filter(position => Math.abs(position.x - start.x) < 24); const bottom = Math.max(0, ...column.filter(position => position.y < start.y - 1).map(position => position.y)); const right = Math.min(Infinity, ...samePage.filter(position => position.x > start.x + 24).map(position => position.x - 4)); - let annotations = pageLinks.get(start.page); - if (!annotations) { - annotations = pdf.getPage(start.page).then(page => page.getAnnotations()); - pageLinks.set(start.page, annotations); - } const ids = new Set(); const insideEntry = (x: number, y: number) => x >= start.x - 4 && x < right && y <= start.y + 3 && y > bottom; - for (const annotation of await annotations) { + for (const annotation of await getPageLinks(start.page)) { if (!annotation.url || !annotation.rect) continue; const id = arxivIdFromUrl(annotation.url); if (!id) continue; @@ -160,9 +223,7 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit if (gap > 0) printed.length = gap; let box: number[] | null = null; if (printed.length) { - const [left, bottomEdge, right, top] = view; - const x = (value: number) => Math.round((value - left) / (right - left) * 1000); - const y = (value: number) => Math.round((top - value) / (top - bottomEdge) * 1000); + const { x, y } = pageGrid(view); box = [ x(Math.min(...printed.map(item => item.transform[4]))), y(Math.max(...printed.map(item => item.transform[5] + item.height))), @@ -193,6 +254,49 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit return matches; } })(); + // Where the text cites each entry: hyperref links to its destination, or for + // numbered PDFs without them, "[n]" labels printed before the bibliography. + const getCitations = () => citations ??= (async () => { + const byName = new Map(); + const add = (name: string, page: number, bbox: number[]) => { + const citations = byName.get(name) ?? []; + const boxes = citations[citations.length - 1]; + const last = boxes?.[boxes.length - 1]; + // Author/year citations link "Chen et al." and "2017" separately: side + // by side on one line, or wrapped onto the start of the next, they are + // one citation. + if (last?.page === page && Math.abs(last.bbox[1] - bbox[1]) < 5 && bbox[0] >= last.bbox[0] && bbox[0] - last.bbox[2] < 15) { + last.bbox = [last.bbox[0], Math.min(last.bbox[1], bbox[1]), Math.max(last.bbox[2], bbox[2]), Math.max(last.bbox[3], bbox[3])]; + } else if (last?.page === page && bbox[1] > last.bbox[1] && bbox[1] - last.bbox[3] < 10 && bbox[0] < last.bbox[0]) { + boxes.push({ page, bbox }); + } else { + byName.set(name, [...citations, [{ page, bbox }]]); + } + }; + const positions = await getReferences(); + if (!positions.length) return byName; + if (positions[0].name.startsWith('text:')) { + const first = positions[0]; + for (let page = 1; page <= first.page; page++) { + const { view, content } = await getPageText(page); + const items = page < first.page ? content.items : content.items.filter(item => 'str' in item && item.transform[5] > first.y + 1); + for (const [number, boxes] of numberedCitationBoxes(items, view)) { + for (const bbox of boxes) add(`text:${number}`, page, bbox); + } + } + return byName; + } + for (let page = 1; page <= pdf.numPages; page++) { + const annotations = await getPageLinks(page); + if (!annotations.some(annotation => typeof annotation.dest === 'string' && /^cite[.:]/i.test(annotation.dest))) continue; + const { x, y } = pageGrid((await pdf.getPage(page)).view); + for (const { dest, rect } of annotations) { + if (typeof dest !== 'string' || !/^cite[.:]/i.test(dest) || !rect) continue; + add(dest, page, [x(rect[0]), y(rect[3]), x(rect[2]), y(rect[1])]); + } + } + return byName; + })(); // Warm every bibliography card as soon as the document opens. if (lookup) void getCatalog(); const routeIdFor = async (name: string) => { @@ -209,9 +313,11 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit // preview targets from the same entries the in-text citations resolve to. const bibliographyLinks = async (page: number): Promise => { const onPage = (await getReferences()).filter(position => position.page === page); + if (!onPage.length) return []; + const cited = await getCitations().catch(() => new Map()); const links = await Promise.all(onPage.map(async ({ name }) => { const [entry, routeId] = await Promise.all([getEntry(name), routeIdFor(name)]); - return entry?.box && routeId ? { name, routeId, box: entry.box } : null; + return entry?.box && routeId ? { name, routeId, box: entry.box, citations: cited.get(name) ?? [] } : null; })); return links.filter((link): link is PdfBibliographyLink => link !== null); }; From 6d2990c7b95dd9d40e674535a64016982eb827dc Mon Sep 17 00:00:00 2001 From: Niels Rogge Date: Tue, 27 Oct 2026 15:34:30 +0000 Subject: [PATCH 2/3] Show bibliography cards before the citation scan finishes Bibliography links no longer wait for the whole-document citation index; pages fill in each card's jump once citationsOf resolves, and the scan reads pages in parallel. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/pdf/PdfPaperLink.tsx | 3 +- frontend/src/components/pdf/PdfViewer.tsx | 8 +++- .../components/pdf/pdfCitationLinks.test.ts | 16 +++---- .../src/components/pdf/pdfCitationLinks.ts | 42 +++++++++---------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/pdf/PdfPaperLink.tsx b/frontend/src/components/pdf/PdfPaperLink.tsx index bae5fc03..b8db9f75 100644 --- a/frontend/src/components/pdf/PdfPaperLink.tsx +++ b/frontend/src/components/pdf/PdfPaperLink.tsx @@ -43,7 +43,8 @@ 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. 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 & { +export function PdfBibliographyEntryLink({ routeId, box: [x0, y0, x1, y1], citations = [], onShowCitation }: PdfBibliographyLink & { + citations?: PdfHighlightBox[][]; onShowCitation?: (citation: PdfHighlightBox[]) => void; }) { const [next, setNext] = useState(0); diff --git a/frontend/src/components/pdf/PdfViewer.tsx b/frontend/src/components/pdf/PdfViewer.tsx index 681467e7..2099da44 100644 --- a/frontend/src/components/pdf/PdfViewer.tsx +++ b/frontend/src/components/pdf/PdfViewer.tsx @@ -149,14 +149,18 @@ function LazyPdfPage({ 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]); diff --git a/frontend/src/components/pdf/pdfCitationLinks.test.ts b/frontend/src/components/pdf/pdfCitationLinks.test.ts index f8a80658..6e1dc41d 100644 --- a/frontend/src/components/pdf/pdfCitationLinks.test.ts +++ b/frontend/src/components/pdf/pdfCitationLinks.test.ts @@ -85,8 +85,8 @@ describe('arXiv PDF links', () => { const lookup = vi.fn().mockResolvedValue(new Map()); const resolve = createPdfCitationResolver(pdf, lookup); expect(await resolve.bibliographyLinks(12)).toEqual([ - { name: 'cite.lin2014', routeId: '1405.0312', box: [142, 188, 475, 218], citations: [] }, - { name: 'cite.right', routeId: '2501.33333', box: [533, 188, 867, 203], citations: [] }, + { name: 'cite.lin2014', routeId: '1405.0312', box: [142, 188, 475, 218] }, + { name: 'cite.right', routeId: '2501.33333', box: [533, 188, 867, 203] }, ]); expect(await resolve.bibliographyLinks(3)).toEqual([]); }); @@ -103,8 +103,9 @@ describe('arXiv PDF links', () => { 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 links = await createPdfCitationResolver(pdf).bibliographyLinks(12); - expect(links.map(({ name, citations }) => [name, citations])).toEqual([ + 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] }]]], ]); @@ -149,11 +150,10 @@ describe('PDFs without bibliography destinations', () => { it('finds numbered citations in the text before the bibliography', async () => { const resolve = createPdfCitationResolver(wordExport()); - const links = await resolve.bibliographyLinks(2); - expect(links.find(link => link.name === 'text:1')?.citations).toEqual([]); + expect(await resolve.citationsOf('text:1')).toEqual([]); // The in-text "[2, 7]" cites [2]; "[9]" inside the bibliography is no citation. - expect(links.find(link => link.name === 'text:2')?.citations).toEqual([[{ page: 1, bbox: [142, 240, 176, 253] }]]); - expect((await resolve.bibliographyLinks(3)).map(link => link.citations)).toEqual([[]]); + expect(await resolve.citationsOf('text:2')).toEqual([[{ page: 1, bbox: [142, 240, 176, 253] }]]); + expect(await resolve.citationsOf('text:4')).toEqual([]); }); }); diff --git a/frontend/src/components/pdf/pdfCitationLinks.ts b/frontend/src/components/pdf/pdfCitationLinks.ts index 874ac09a..5fb53686 100644 --- a/frontend/src/components/pdf/pdfCitationLinks.ts +++ b/frontend/src/components/pdf/pdfCitationLinks.ts @@ -66,12 +66,8 @@ function textLines(items: TextContent['items']): TextLine[] { interface ReferenceEntry { arxivId: string | null; titles: string[]; box: number[] | null } 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, and citations are where the text cites it, in reading - * order, each as the boxes of its one or more printed lines. - */ -export interface PdfBibliographyLink { name: string; routeId: string; box: number[]; citations: PdfHighlightBox[][] } +/** 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[]) { @@ -275,25 +271,27 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit }; const positions = await getReferences(); if (!positions.length) return byName; + // Pages load in parallel; citations are then added in page order. + const pages = (count: number) => Array.from({ length: count }, (_, index) => index + 1); if (positions[0].name.startsWith('text:')) { const first = positions[0]; - for (let page = 1; page <= first.page; page++) { - const { view, content } = await getPageText(page); + const texts = await Promise.all(pages(first.page).map(getPageText)); + texts.forEach(({ view, content }, index) => { + const page = index + 1; const items = page < first.page ? content.items : content.items.filter(item => 'str' in item && item.transform[5] > first.y + 1); for (const [number, boxes] of numberedCitationBoxes(items, view)) { for (const bbox of boxes) add(`text:${number}`, page, bbox); } - } + }); return byName; } - for (let page = 1; page <= pdf.numPages; page++) { - const annotations = await getPageLinks(page); - if (!annotations.some(annotation => typeof annotation.dest === 'string' && /^cite[.:]/i.test(annotation.dest))) continue; - const { x, y } = pageGrid((await pdf.getPage(page)).view); - for (const { dest, rect } of annotations) { - if (typeof dest !== 'string' || !/^cite[.:]/i.test(dest) || !rect) continue; - add(dest, page, [x(rect[0]), y(rect[3]), x(rect[2]), y(rect[1])]); - } + const links = await Promise.all(pages(pdf.numPages).map(async page => { + const annotations = (await getPageLinks(page)).filter(annotation => typeof annotation.dest === 'string' && /^cite[.:]/i.test(annotation.dest) && annotation.rect); + return { page, annotations, view: annotations.length ? (await pdf.getPage(page)).view : [] }; + })); + for (const { page, annotations, view } of links) { + const { x, y } = pageGrid(view); + for (const { dest, rect } of annotations) add(dest as string, page, [x(rect![0]), y(rect![3]), x(rect![2]), y(rect![1])]); } return byName; })(); @@ -313,14 +311,16 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit // preview targets from the same entries the in-text citations resolve to. const bibliographyLinks = async (page: number): Promise => { const onPage = (await getReferences()).filter(position => position.page === page); - if (!onPage.length) return []; - const cited = await getCitations().catch(() => new Map()); const links = await Promise.all(onPage.map(async ({ name }) => { const [entry, routeId] = await Promise.all([getEntry(name), routeIdFor(name)]); - return entry?.box && routeId ? { name, routeId, box: entry.box, citations: cited.get(name) ?? [] } : null; + return entry?.box && routeId ? { name, routeId, box: entry.box } : null; })); return links.filter((link): link is PdfBibliographyLink => link !== null); }; - return Object.assign(resolve, { bibliographyLinks }); + // Where the text cites an entry, in reading order, each citation as the boxes + // of its one or more printed lines. Scans the whole document once, so cards + // never wait for it. + const citationsOf = async (name: string): Promise => (await getCitations()).get(name) ?? []; + return Object.assign(resolve, { bibliographyLinks, citationsOf }); } export type PdfCitationResolver = ReturnType; From 4f2914dbe7754a0a1d56fdab8bcde08c33b72182 Mon Sep 17 00:00:00 2001 From: Niels Rogge Date: Tue, 27 Oct 2026 15:42:01 +0000 Subject: [PATCH 3/3] Read citation pages a few at a time and skip unreadable ones Range-loaded PDFs fetch pages on demand, so the citation index now reads six pages ahead like the text search instead of the whole document at once, and a page that fails to load contributes no citations rather than failing the scan. Co-Authored-By: Claude Opus 5.5 --- .../components/pdf/pdfCitationLinks.test.ts | 7 +-- .../src/components/pdf/pdfCitationLinks.ts | 43 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/pdf/pdfCitationLinks.test.ts b/frontend/src/components/pdf/pdfCitationLinks.test.ts index 6e1dc41d..bb14c35e 100644 --- a/frontend/src/components/pdf/pdfCitationLinks.test.ts +++ b/frontend/src/components/pdf/pdfCitationLinks.test.ts @@ -91,13 +91,14 @@ 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', async () => { + 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 = { + 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) => ({ + 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 }] : [] }), diff --git a/frontend/src/components/pdf/pdfCitationLinks.ts b/frontend/src/components/pdf/pdfCitationLinks.ts index 5fb53686..426e1093 100644 --- a/frontend/src/components/pdf/pdfCitationLinks.ts +++ b/frontend/src/components/pdf/pdfCitationLinks.ts @@ -24,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 @@ -271,27 +273,28 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit }; const positions = await getReferences(); if (!positions.length) return byName; - // Pages load in parallel; citations are then added in page order. - const pages = (count: number) => Array.from({ length: count }, (_, index) => index + 1); - if (positions[0].name.startsWith('text:')) { - const first = positions[0]; - const texts = await Promise.all(pages(first.page).map(getPageText)); - texts.forEach(({ view, content }, index) => { - const page = index + 1; + // Citations of one page as [name, box] pairs; an unreadable page has none. + const first = positions[0]; + const numbered = first.name.startsWith('text:'); + const pageCitations = async (page: number): Promise<[string, number[]][]> => { + if (numbered) { + const { view, content } = await getPageText(page); const items = page < first.page ? content.items : content.items.filter(item => 'str' in item && item.transform[5] > first.y + 1); - for (const [number, boxes] of numberedCitationBoxes(items, view)) { - for (const bbox of boxes) add(`text:${number}`, page, bbox); - } - }); - return byName; - } - const links = await Promise.all(pages(pdf.numPages).map(async page => { - const annotations = (await getPageLinks(page)).filter(annotation => typeof annotation.dest === 'string' && /^cite[.:]/i.test(annotation.dest) && annotation.rect); - return { page, annotations, view: annotations.length ? (await pdf.getPage(page)).view : [] }; - })); - for (const { page, annotations, view } of links) { - const { x, y } = pageGrid(view); - for (const { dest, rect } of annotations) add(dest as string, page, [x(rect![0]), y(rect![3]), x(rect![2]), y(rect![1])]); + return [...numberedCitationBoxes(items, view)].flatMap(([number, boxes]) => boxes.map(box => [`text:${number}`, box] as [string, number[]])); + } + const annotations = (await getPageLinks(page)).filter(annotation => typeof annotation.dest === 'string' && /^cite[.:]/i.test(annotation.dest)); + if (!annotations.length) return []; + const { x, y } = pageGrid((await pdf.getPage(page)).view); + return annotations.flatMap(({ dest, rect }) => rect ? [[dest as string, [x(rect[0]), y(rect[3]), x(rect[2]), y(rect[1])]] as [string, number[]]] : []); + }; + // Like the text search, read a few pages ahead rather than the whole + // range-loaded document at once, and add citations in page order. + const last = numbered ? first.page : pdf.numPages; + const scans: Promise<[string, number[]][]>[] = []; + const scan = (page: number) => scans[page] ??= pageCitations(page).catch(() => []); + for (let page = 1; page <= last; page++) { + for (let ahead = page + 1; ahead <= Math.min(last, page + CITATION_READ_AHEAD); ahead++) void scan(ahead); + for (const [name, bbox] of await scan(page)) add(name, page, bbox); } return byName; })();