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) => } 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> | undefined; + let citations: Promise> | undefined; const getPageText = (page: number) => { let text = pageText.get(page); if (!text) { @@ -85,6 +143,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 +198,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 +221,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 +252,52 @@ 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; + // 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); + 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; + })(); // Warm every bibliography card as soon as the document opens. if (lookup) void getCatalog(); const routeIdFor = async (name: string) => { @@ -215,6 +320,10 @@ export function createPdfCitationResolver(pdf: PDFDocumentProxy, lookup?: PdfCit })); 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;