import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getDocument, GlobalWorkerOptions, RenderingCancelledException, TextLayer, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist"; import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import { getPdfCanvasRenderScale, getPdfPageCssDimensions } from "./pdf-page-geometry"; import { PdfPageRenderQueue } from "./pdf-render-queue"; GlobalWorkerOptions.workerSrc = pdfWorkerUrl; interface PdfPageDescriptor { pageNumber: number; width: number; height: number; } export interface PrecisePdfPreviewProps { blob: Blob; zoomPercent: number; onPageCount?: (pageCount: number) => void; } interface PrecisePdfPageProps { document: PDFDocumentProxy; descriptor: PdfPageDescriptor; renderQueue: PdfPageRenderQueue; zoomPercent: number; } function useNearViewport(elementRef: React.RefObject) { const [nearViewport, setNearViewport] = useState(false); const releaseTimerRef = useRef(undefined); useEffect(() => { const element = elementRef.current; if (!element) { return; } if (!("IntersectionObserver" in window)) { setNearViewport(true); return; } const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) { if (releaseTimerRef.current !== undefined) { window.clearTimeout(releaseTimerRef.current); releaseTimerRef.current = undefined; } setNearViewport(true); return; } releaseTimerRef.current = window.setTimeout(() => { releaseTimerRef.current = undefined; setNearViewport(false); }, 1_500); }, { rootMargin: "1200px 0px" } ); observer.observe(element); return () => { observer.disconnect(); if (releaseTimerRef.current !== undefined) { window.clearTimeout(releaseTimerRef.current); } }; }, [elementRef]); return nearViewport; } function PrecisePdfPage({ document, descriptor, renderQueue, zoomPercent }: PrecisePdfPageProps) { const containerRef = useRef(null); const canvasRef = useRef(null); const textLayerRef = useRef(null); const [displayWidth, setDisplayWidth] = useState(0); const [renderError, setRenderError] = useState(""); const [renderState, setRenderState] = useState< "idle" | "queued" | "rendering" | "rendered" >("idle"); const nearViewport = useNearViewport(containerRef); const dimensions = getPdfPageCssDimensions( descriptor.width, descriptor.height, zoomPercent / 100 ); const pageStyle = { width: `${dimensions.width}px`, maxWidth: `${zoomPercent}%`, aspectRatio: `${descriptor.width} / ${descriptor.height}` } satisfies CSSProperties; useEffect(() => { const container = containerRef.current; if (!container) { return; } const updateWidth = () => setDisplayWidth(container.clientWidth); updateWidth(); const observer = new ResizeObserver(updateWidth); observer.observe(container); return () => observer.disconnect(); }, []); const updateVisibility = useCallback( (visible: boolean) => { renderQueue.setVisible(descriptor.pageNumber, visible); }, [descriptor.pageNumber, renderQueue] ); useEffect(() => { const container = containerRef.current; if (!container) { return; } if (!("IntersectionObserver" in window)) { updateVisibility(true); return () => updateVisibility(false); } const observer = new IntersectionObserver(([entry]) => { updateVisibility(Boolean(entry?.isIntersecting)); }); observer.observe(container); return () => { observer.disconnect(); updateVisibility(false); }; }, [updateVisibility]); useEffect(() => { const canvas = canvasRef.current; const textContainer = textLayerRef.current; if ( !nearViewport || displayWidth <= 0 || !canvas || !textContainer ) { return; } let disposed = false; let page: PDFPageProxy | undefined; let renderTask: RenderTask | undefined; let textLayer: TextLayer | undefined; setRenderError(""); setRenderState("queued"); const cancelQueuedRender = renderQueue.schedule( descriptor.pageNumber, async () => { try { if (disposed) { return; } setRenderState("rendering"); const loadedPage = await document.getPage( descriptor.pageNumber ); page = loadedPage; const outputScale = Math.min( window.devicePixelRatio || 1, 2 ); const renderScale = getPdfCanvasRenderScale( descriptor.width, displayWidth, outputScale ); const displayScale = displayWidth / descriptor.width; const renderViewport = page.getViewport({ scale: renderScale }); const displayViewport = page.getViewport({ scale: displayScale }); const context = canvas.getContext("2d"); if (!context) { throw new Error("浏览器无法创建 PDF Canvas"); } canvas.width = Math.ceil(renderViewport.width); canvas.height = Math.ceil(renderViewport.height); canvas.style.width = `${displayViewport.width}px`; canvas.style.height = `${displayViewport.height}px`; textContainer.replaceChildren(); renderTask = page.render({ canvas, canvasContext: context, viewport: renderViewport }); await renderTask.promise; if (disposed) { return; } setRenderState("rendered"); textLayer = new TextLayer({ container: textContainer, textContentSource: page.streamTextContent(), viewport: displayViewport }); await textLayer.render(); } catch (reason: unknown) { if ( disposed || reason instanceof RenderingCancelledException ) { return; } setRenderError( reason instanceof Error ? reason.message : "PDF 页面渲染失败" ); } } ); return () => { disposed = true; cancelQueuedRender(); renderTask?.cancel(); textLayer?.cancel(); page?.cleanup(); canvas.width = 0; canvas.height = 0; textContainer.replaceChildren(); setRenderState("idle"); }; }, [ descriptor.height, descriptor.pageNumber, descriptor.width, displayWidth, document, nearViewport, renderQueue ]); return (
); } export function PrecisePdfPreview({ blob, zoomPercent, onPageCount }: PrecisePdfPreviewProps) { const [document, setDocument] = useState(); const [pages, setPages] = useState([]); const [error, setError] = useState(""); const renderQueue = useMemo( () => new PdfPageRenderQueue(1), [document] ); useEffect(() => () => renderQueue.dispose(), [renderQueue]); useEffect(() => { let disposed = false; let loadingTask: | ReturnType | undefined; setDocument(undefined); setPages([]); setError(""); void blob .arrayBuffer() .then((buffer) => { if (disposed) { return undefined; } loadingTask = getDocument({ data: new Uint8Array(buffer) }); return loadingTask.promise; }) .then(async (loadedDocument) => { if (!loadedDocument || disposed) { return; } const descriptors = await Promise.all( Array.from( { length: loadedDocument.numPages }, async (_, index) => { const page = await loadedDocument.getPage(index + 1); const viewport = page.getViewport({ scale: 1 }); page.cleanup(); return { pageNumber: index + 1, width: viewport.width, height: viewport.height }; } ) ); if (disposed) { return; } setDocument(loadedDocument); setPages(descriptors); onPageCount?.(loadedDocument.numPages); }) .catch((reason: unknown) => { if (!disposed) { setError( reason instanceof Error ? reason.message : "无法读取 PDF" ); } }); return () => { disposed = true; void loadingTask?.destroy(); }; }, [blob, onPageCount]); if (error) { return
{error}
; } if (!document || pages.length === 0) { return
正在读取精确预览…
; } return (
{pages.map((page) => ( ))}
); }