Adds idempotency test: calling init() twice must invoke libLoader only once. Adds `if (pdfjsReady) return;` guard to satisfy the contract. Addresses Felix Brandt round-4 suggestion on PR #536. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
4.5 KiB
TypeScript
207 lines
4.5 KiB
TypeScript
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
|
|
|
export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
|
|
|
|
const defaultLibLoader: LibLoader = () =>
|
|
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
|
|
|
|
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
|
|
// Reactive state — exposed via getters
|
|
let currentPage = $state(1);
|
|
let totalPages = $state(0);
|
|
let scale = $state(1.5);
|
|
let loading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let pdfjsReady = $state(false);
|
|
|
|
// Internal mutable refs — NOT $state to avoid reactive loops
|
|
let pdfDoc: PDFDocumentProxy | null = null;
|
|
let canvasEl: HTMLCanvasElement | null = null;
|
|
let textLayerEl: HTMLDivElement | null = null;
|
|
let renderTask: RenderTask | null = null;
|
|
let textLayerInstance: { cancel: () => void } | null = null;
|
|
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
|
|
|
async function init(): Promise<void> {
|
|
if (pdfjsReady) return;
|
|
const [lib, { default: workerUrl }] = await libLoader();
|
|
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
|
pdfjsLib = lib;
|
|
pdfjsReady = true;
|
|
}
|
|
|
|
function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void {
|
|
canvasEl = canvas;
|
|
textLayerEl = textLayer;
|
|
}
|
|
|
|
async function loadDocument(src: string): Promise<void> {
|
|
if (!pdfjsLib) return;
|
|
loading = true;
|
|
error = null;
|
|
pdfDoc = null;
|
|
currentPage = 1;
|
|
totalPages = 0;
|
|
|
|
try {
|
|
const loadingTask = pdfjsLib.getDocument(src);
|
|
const doc = await loadingTask.promise;
|
|
pdfDoc = doc;
|
|
totalPages = doc.numPages;
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function renderCurrentPage(): Promise<void> {
|
|
if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return;
|
|
|
|
if (renderTask) {
|
|
renderTask.cancel();
|
|
renderTask = null;
|
|
}
|
|
if (textLayerInstance) {
|
|
textLayerInstance.cancel();
|
|
textLayerInstance = null;
|
|
}
|
|
|
|
let page;
|
|
try {
|
|
page = await pdfDoc.getPage(currentPage);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const viewport = page.getViewport({ scale: scale * dpr });
|
|
|
|
const canvas = canvasEl;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
canvas.style.width = `${viewport.width / dpr}px`;
|
|
canvas.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
|
renderTask = task;
|
|
|
|
try {
|
|
await task.promise;
|
|
} catch (e: unknown) {
|
|
if (
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'name' in e &&
|
|
(e as { name: string }).name === 'RenderingCancelledException'
|
|
)
|
|
return;
|
|
return;
|
|
}
|
|
renderTask = null;
|
|
|
|
const textDiv = textLayerEl;
|
|
if (!textDiv) return;
|
|
textDiv.innerHTML = '';
|
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const tl = new pdfjsLib.TextLayer({
|
|
textContentSource: page.streamTextContent(),
|
|
container: textDiv,
|
|
viewport
|
|
});
|
|
textLayerInstance = tl;
|
|
try {
|
|
await tl.render();
|
|
} catch {
|
|
// cancelled
|
|
}
|
|
}
|
|
|
|
async function prerender(): Promise<void> {
|
|
if (!pdfDoc) return;
|
|
const neighbors = [currentPage - 1, currentPage + 1].filter(
|
|
(n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0)
|
|
);
|
|
for (const n of neighbors) {
|
|
try {
|
|
await pdfDoc.getPage(n);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function prevPage(): void {
|
|
if (currentPage > 1) currentPage -= 1;
|
|
}
|
|
|
|
function nextPage(): void {
|
|
if (currentPage < totalPages) currentPage += 1;
|
|
}
|
|
|
|
function goToPage(n: number): void {
|
|
if (n >= 1 && n <= totalPages) currentPage = n;
|
|
}
|
|
|
|
function zoomIn(): void {
|
|
scale += 0.25;
|
|
}
|
|
|
|
function zoomOut(): void {
|
|
if (scale > 0.5) scale -= 0.25;
|
|
}
|
|
|
|
function destroy(): void {
|
|
if (renderTask) {
|
|
renderTask.cancel();
|
|
renderTask = null;
|
|
}
|
|
if (textLayerInstance) {
|
|
textLayerInstance.cancel();
|
|
textLayerInstance = null;
|
|
}
|
|
pdfDoc?.destroy();
|
|
pdfDoc = null;
|
|
}
|
|
|
|
return {
|
|
get currentPage() {
|
|
return currentPage;
|
|
},
|
|
get totalPages() {
|
|
return totalPages;
|
|
},
|
|
get scale() {
|
|
return scale;
|
|
},
|
|
get loading() {
|
|
return loading;
|
|
},
|
|
get error() {
|
|
return error;
|
|
},
|
|
get isLoaded() {
|
|
return totalPages > 0;
|
|
},
|
|
get pdfjsReady() {
|
|
return pdfjsReady;
|
|
},
|
|
setElements,
|
|
init,
|
|
loadDocument,
|
|
renderCurrentPage,
|
|
prerender,
|
|
prevPage,
|
|
nextPage,
|
|
goToPage,
|
|
zoomIn,
|
|
zoomOut,
|
|
destroy
|
|
};
|
|
}
|