import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; import { ReaderError } from "./ReaderError"; import { pdfDocumentOptions } from "./pdfDocumentOptions"; import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender"; import { configurePdfWorker } from "./pdfWorker"; import { clampReaderPage, readableViewportSize, readerPositionLabel, readerZoomFactor, type ReaderSize } from "./readerLayout"; import { isPageInRenderWindow, majorityVisiblePage, type ReaderMode } from "./readerScroll"; import type { ReaderControls } from "./ReaderShell"; configurePdfWorker(pdfjs); type PageCommitStrategy = "immediate" | "queued"; type PdfReaderProps = { url: string; page: number; backHref: string; zoom: number; mode: ReaderMode; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }; type PdfFailure = { detail: string; technicalDetail: string; }; type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed"; function pdfTechnicalMessage(error: unknown) { if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`; if (typeof error === "string" && error.trim()) return error; return "Erreur inconnue."; } function pdfFailure(source: string, detail: string, context: Record, error?: unknown): PdfFailure { return { detail, technicalDetail: JSON.stringify( { source, ...context, error: error === undefined ? undefined : pdfTechnicalMessage(error) }, null, 2 ) }; } function isPdfTransitionError(error: unknown) { return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled")); } function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { return element?.closest(".reader-stage") as HTMLElement | null; } function afterNextPaint() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function PdfPageCanvas({ documentProxy, pageNumber, viewportSize, zoom, active }: { documentProxy: pdfjs.PDFDocumentProxy; pageNumber: number; viewportSize: ReaderSize | null; zoom: number; active: boolean; }) { const canvasRef = useRef(null); const [loading, setLoading] = useState(false); const [rendered, setRendered] = useState(false); const [pageStatus, setPageStatus] = useState("idle"); const [pageError, setPageError] = useState(); const showCanvas = active && pdfCanvasVisible(rendered, Boolean(pageError)); useEffect(() => { if (!active || !viewportSize) return; const readerViewport = viewportSize; let cancelled = false; let renderTask: pdfjs.RenderTask | undefined; setLoading(true); setRendered(false); setPageStatus("loading"); setPageError(undefined); async function renderPage() { try { const pdfPage = await documentProxy.getPage(pageNumber); if (cancelled) return; const canvas = canvasRef.current; if (!canvas) return; const baseViewport = pdfPage.getViewport({ scale: 1 }); const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height }); const pixelRatio = Math.min(2, window.devicePixelRatio || 1); const viewport = pdfPage.getViewport({ scale: Math.max(0.25, fitScale * readerZoomFactor(zoom)) * pixelRatio }); const cssWidth = Math.floor(viewport.width / pixelRatio); const cssHeight = Math.floor(viewport.height / pixelRatio); canvas.width = Math.max(1, Math.floor(viewport.width)); canvas.height = Math.max(1, Math.floor(viewport.height)); canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("PDF canvas context unavailable"); context.fillStyle = pdfSentinelBackground(); context.fillRect(0, 0, canvas.width, canvas.height); renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); await renderTask.promise; await afterNextPaint(); if (cancelled) return; setPageStatus(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height))); setRendered(true); setLoading(false); } catch (error) { if (cancelled || isPdfTransitionError(error)) return; setPageStatus("render-failed"); setLoading(false); setPageError(`Page ${pageNumber} indisponible.`); } } void renderPage(); return () => { cancelled = true; renderTask?.cancel(); }; }, [active, documentProxy, pageNumber, viewportSize, zoom]); return (
{(!active || !viewportSize || loading || !rendered || pageError) && (
{pageError ?? `Page ${pageNumber}`}
)}
); } export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); const canvasRef = useRef(null); const previousModeRef = useRef(mode); const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); const [loadingDocument, setLoadingDocument] = useState(true); const [loadingPage, setLoadingPage] = useState(false); const [pageRendered, setPageRendered] = useState(false); const [pageStatus, setPageStatus] = useState("idle"); const [documentAttempt, setDocumentAttempt] = useState(0); const [pageAttempt, setPageAttempt] = useState(0); const [viewportSize, setViewportSize] = useState(null); const currentPage = clampReaderPage(page, pages); const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError)); const go = useCallback( (nextPage: number) => { const target = clampReaderPage(nextPage, pages); setPageError(undefined); if (mode === "vertical") { frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); } onPageCommit(target, pages, 1, "immediate"); }, [mode, onPageCommit, pages] ); useEffect(() => { onControlsChange({ canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1, canNext: !loadingDocument && !documentError && !pageError && currentPage < pages, positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages), onPrevious: () => go(currentPage - 1), onNext: () => go(currentPage + 1) }); }, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]); useEffect(() => { if (mode !== "vertical") { previousModeRef.current = mode; return; } if (previousModeRef.current !== "vertical") { requestAnimationFrame(() => { frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); }); } previousModeRef.current = mode; }, [currentPage, mode]); useEffect(() => { if (mode !== "vertical") return; const stage = scrollContainerFor(frameRef.current); if (!stage) return; let frameId = 0; const updateVisiblePage = () => { frameId = 0; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { const rect = element.getBoundingClientRect(); return { page: Number(element.dataset.readerPage), top: rect.top, bottom: rect.bottom }; }), stageRect.top, stageRect.bottom ); if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued"); }; const onScroll = () => { if (frameId) return; frameId = requestAnimationFrame(updateVisiblePage); }; stage.addEventListener("scroll", onScroll, { passive: true }); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); stage.removeEventListener("scroll", onScroll); }; }, [currentPage, mode, onPageCommit, pages]); useEffect(() => { const frame = frameRef.current; const stage = scrollContainerFor(frame); const observedElement = stage ?? frame; if (!observedElement) return; const updateSize = () => { const rect = observedElement.getBoundingClientRect(); const nextSize = readableViewportSize({ width: rect.width, height: rect.height }); if (nextSize) setViewportSize(nextSize); }; updateSize(); const frameId = requestAnimationFrame(updateSize); const observer = new ResizeObserver(updateSize); observer.observe(observedElement); return () => { cancelAnimationFrame(frameId); observer.disconnect(); }; }, []); useEffect(() => { let cancelled = false; let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined; setLoadingDocument(true); setDocumentError(undefined); setPageError(undefined); setPageRendered(false); setPageStatus("idle"); setDocumentProxy(null); async function loadDocument() { try { try { new URL(url, window.location.href); } catch (error) { throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error); } configurePdfWorker(pdfjs); loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser")); const loadedDocument = await loadingTask.promise; if (cancelled) { await loadingTask.destroy(); return; } setPages(loadedDocument.numPages); setDocumentProxy(loadedDocument); setLoadingDocument(false); } catch (error) { if (cancelled && isPdfTransitionError(error)) return; if (!cancelled) { setDocumentError( typeof error === "object" && error && "detail" in error && "technicalDetail" in error ? (error as PdfFailure) : pdfFailure( "getDocument", "PDF: getDocument() a échoué.", { url, resolvedUrl: (() => { try { return new URL(url, window.location.href).toString(); } catch { return null; } })(), workerSrc: pdfjs.GlobalWorkerOptions.workerSrc, hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort) }, error ) ); setLoadingDocument(false); } } } void loadDocument(); return () => { cancelled = true; void loadingTask?.destroy(); }; }, [documentAttempt, url]); useEffect(() => { if (mode === "vertical") { setPageError(undefined); return; } if (!documentProxy || !viewportSize) return; const loadedDocument = documentProxy; const readerViewport = viewportSize; let cancelled = false; let renderTask: pdfjs.RenderTask | undefined; setLoadingPage(true); setPageError(undefined); setPageRendered(false); setPageStatus("loading"); async function renderPage() { try { let pdfPage: pdfjs.PDFPageProxy; try { pdfPage = await loadedDocument.getPage(currentPage); } catch (error) { throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error); } if (cancelled) return; const canvas = canvasRef.current; if (!canvas) { throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage }); } const baseViewport = pdfPage.getViewport({ scale: 1 }); const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height }); const pixelRatio = Math.min(2, window.devicePixelRatio || 1); const zoomedScale = fitScale * readerZoomFactor(zoom); const renderScale = Math.max(0.25, zoomedScale) * pixelRatio; const viewport = pdfPage.getViewport({ scale: renderScale }); const cssWidth = Math.floor(viewport.width / pixelRatio); const cssHeight = Math.floor(viewport.height / pixelRatio); canvas.width = Math.max(1, Math.floor(viewport.width)); canvas.height = Math.max(1, Math.floor(viewport.height)); canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; const context = canvas.getContext("2d", { alpha: false }); if (!context) { throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, { pageNumber: currentPage, canvasWidth: canvas.width, canvasHeight: canvas.height }); } if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) { throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, { pageNumber: currentPage, canvasWidth: canvas.width, canvasHeight: canvas.height, cssWidth, cssHeight, readerViewport }); } context.fillStyle = pdfSentinelBackground(); context.fillRect(0, 0, canvas.width, canvas.height); try { renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); await renderTask.promise; } catch (error) { throw pdfFailure( "render-failed", `PDF: render() a échoué pour la page ${currentPage}.`, { result: "render-failed", pageNumber: currentPage, baseViewport: { width: baseViewport.width, height: baseViewport.height }, renderViewport: { width: viewport.width, height: viewport.height }, canvasWidth: canvas.width, canvasHeight: canvas.height, cssWidth, cssHeight, readerViewport }, error ); } await afterNextPaint(); if (cancelled) return; const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height)); if (renderResult === "blank-detected") { setPageStatus("blank-detected"); setPageRendered(true); setLoadingPage(false); return; } if (!cancelled) { setPageStatus("rendered-ok"); setPageRendered(true); setLoadingPage(false); } } catch (error) { if (cancelled || isPdfTransitionError(error)) return; setPageRendered(false); setPageStatus("render-failed"); setLoadingPage(false); setPageError( typeof error === "object" && error && "detail" in error && "technicalDetail" in error ? (error as PdfFailure) : pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error) ); } } void renderPage(); return () => { cancelled = true; renderTask?.cancel(); }; }, [currentPage, documentProxy, mode, pageAttempt, viewportSize, zoom]); if (documentError) { return (
setDocumentAttempt((value) => value + 1)} />
); } return (
{loadingDocument || !documentProxy ? (
Ouverture PDF
) : mode === "vertical" ? (
{Array.from({ length: pages }, (_, index) => { const pageNumber = index + 1; return ( ); })}
) : pageError ? (
{pageError.detail}
Détail technique
{pageError.technicalDetail}
) : (
{(!viewportSize || loadingPage || !pageRendered) && (
Page {currentPage}
)}
)}
); }