Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion frontend/src/components/pdf/PdfPaperLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }));
Expand Down Expand Up @@ -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(<QueryClientProvider client={client}><MemoryRouter>
<PdfBibliographyEntryLink name="cite.lin2014" routeId="1405.0312" box={[100, 100, 400, 140]} citations={citations} onShowCitation={onShowCitation} />
</MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={client}><MemoryRouter>
<PdfBibliographyEntryLink name="cite.lin2014" routeId="1405.0312" box={[100, 100, 400, 140]} citations={[]} onShowCitation={vi.fn()} />
</MemoryRouter></QueryClientProvider>);
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();
});
});
20 changes: 17 additions & 3 deletions frontend/src/components/pdf/PdfPaperLink.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 <div
className="pdf-bibliography-link"
style={{ left: `${x0 / 10}%`, top: `${y0 / 10}%`, width: `${(x1 - x0) / 10}%`, height: `${(y1 - y0) / 10}%` }}
Expand All @@ -54,6 +61,13 @@ export function PdfBibliographyEntryLink({ routeId, box: [x0, y0, x1, y1] }: Pdf
title={/^\d+$/.test(routeId) ? "Preview paper" : `Preview arXiv:${routeId}`}
ariaLabel={`Reference ${routeId}`}
cardOpensPaper
previewFooter={() => <div className="pdf-paper-preview-actions">
<a href={`/paper/${encodeURIComponent(routeId)}`} target="_blank" rel="noopener noreferrer">Open paper ↗</a>
{citation && onShowCitation && <button type="button" onClick={() => {
onShowCitation(citation);
setNext(next + 1);
}}>{citations.length > 1 ? `Jump to citation ${next % citations.length + 1} of ${citations.length}` : "Jump to citation"}</button>}
</div>}
>{null}</PaperPreviewLink>
</div>;
}
36 changes: 26 additions & 10 deletions frontend/src/components/pdf/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
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 {
Expand Down Expand Up @@ -105,7 +105,7 @@
})[character] ?? character);
}

export function highlightPdfText(text: string, query: string, active: boolean): string {

Check warning on line 108 in frontend/src/components/pdf/PdfViewer.tsx

View workflow job for this annotation

GitHub Actions / frontend-profile (production)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const needle = query.trim().toLocaleLowerCase();
if (!needle) return escapeHtml(text);
const haystack = text.toLocaleLowerCase();
Expand All @@ -132,7 +132,8 @@
resolveCitation,
warmPreviews,
highlightBoxes,
highlightNonce,
highlightKey,
onShowCitation,
}: {
page: number;
width: number;
Expand All @@ -142,19 +143,24 @@
resolveCitation?: PdfCitationResolver;
warmPreviews?: (routeIds: string[]) => void;
highlightBoxes?: number[][];
highlightNonce?: number;
highlightKey?: string;
onShowCitation?: (citation: PdfHighlightBox[]) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(page <= 2);
const { paperLinks, onAnnotations } = usePdfPaperLinks(ref, visible, resolveCitation, warmPreviews);
const [bibliographyLinks, setBibliographyLinks] = useState<PdfBibliographyLink[]>([]);
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]);
Expand Down Expand Up @@ -249,9 +255,9 @@
})}
{snapshot && !painted && <img className="pdf-page-snapshot" src={snapshot} alt="" aria-hidden="true" />}
{visible && paperLinks.map(link => <PdfPaperLink key={link.anchor.parentElement?.dataset.annotationId} {...link} />)}
{visible && resolveCitation && bibliographyLinks.map(link => <PdfBibliographyEntryLink key={link.name} {...link} />)}
{visible && resolveCitation && bibliographyLinks.map(link => <PdfBibliographyEntryLink key={link.name} {...link} onShowCitation={onShowCitation} />)}
{highlightBoxes?.map(([x0, y0, x1, y1], index) => <div
key={`${highlightNonce}-${index}`}
key={`${highlightKey}-${index}`}
className="pdf-citation-highlight"
aria-hidden="true"
style={{ left: `${x0 / 10}%`, top: `${y0 / 10}%`, width: `${(x1 - x0) / 10}%`, height: `${(y1 - y0) / 10}%` }}
Expand Down Expand Up @@ -314,11 +320,20 @@
onClose,
loadErrorMessage,
chrome = "reader",
highlight,
highlight: citedHighlight,
}: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(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<PdfHighlight>();
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<PDFDocumentProxy | null>(null);
const textIndex = useRef<PdfTextIndex>();
const [resolveCitation, setResolveCitation] = useState<PdfCitationResolver>();
Expand Down Expand Up @@ -653,7 +668,7 @@
// 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<HTMLElement>(`[data-page="${entry.page}"]`);
Expand Down Expand Up @@ -786,7 +801,8 @@
resolveCitation={resolveCitation}
warmPreviews={previewCache.warm}
highlightBoxes={highlight?.boxes.filter((box) => box.page === pageNumber).map((box) => box.bbox)}
highlightNonce={highlight?.nonce}
highlightKey={highlightKey}
onShowCitation={showCitation}
/>)}
</div>
</Document>}
Expand Down
50 changes: 48 additions & 2 deletions frontend/src/components/pdf/pdfCitationLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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<number, unknown[] | string> = {
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<number, unknown[]> = {
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),
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading