/** * @file PdfView.tsx * @description A PDF drawn by pdf.js onto canvases, page after page — the * Artifacts preview's PDF viewer. The browser's own viewer cannot run inside * the sandbox the preview keeps (it blocks plugins), and opening a shared PDF * on the dashboard's origin would be the risk the sandbox exists for; pdf.js * only DRAWS the file (no scripting sandbox loaded, no XFA, no links * followed), so nothing in it runs. pdf.js and its worker are loaded the * first time a PDF is opened (a separate chunk), never with the dashboard. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Loader2 } from "lucide-react"; /** Pages drawn at most: a 500-page export would otherwise hold every canvas. */ const MAX_PAGES = 200; const MAX_BYTES = 64 * 1024 * 1024; async function loadPdfJs() { // The legacy build: the modern one needs `Promise.try` and friends, which a // browser a year old lacks — the PDF would then just fail to open. const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); if (!pdfjs.GlobalWorkerOptions.workerSrc) { pdfjs.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url ).toString(); } return pdfjs; } export function PdfView({ url, title }: { url: string; title: string }) { const { t } = useTranslation("artifacts"); const box = useRef(null); const [state, setState] = useState<"loading" | "ready" | "failed">("loading"); const [pages, setPages] = useState(0); useEffect(() => { let live = true; let destroy: (() => void) | null = null; setState("loading"); (async () => { const res = await fetch(url, { credentials: "same-origin" }); if (!res.ok) throw new Error(String(res.status)); const data = new Uint8Array(await res.arrayBuffer()); if (data.byteLength > MAX_BYTES) throw new Error("too big"); const pdfjs = await loadPdfJs(); const task = pdfjs.getDocument({ data, enableXfa: false, disableAutoFetch: true, }); destroy = () => void task.destroy(); const doc = await task.promise; if (!live || !box.current) return; setPages(doc.numPages); const width = Math.max(320, box.current.clientWidth - 32); const ratio = window.devicePixelRatio || 1; box.current.replaceChildren(); for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) { const page = await doc.getPage(n); if (!live || !box.current) return; const base = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: (width / base.width) * ratio }); const canvas = document.createElement("canvas"); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); canvas.style.width = `${Math.floor(viewport.width / ratio)}px`; canvas.className = "mx-auto mb-3 block bg-white shadow"; canvas.setAttribute("aria-label", t("pdf.page", { n })); box.current.appendChild(canvas); await page.render({ canvas, viewport }).promise; if (n === 1 && live) setState("ready"); } })().catch(() => live && setState("failed")); return () => { live = false; destroy?.(); }; }, [url, t]); return (
{state === "loading" && (
)} {state === "failed" && (

{t("pdf.failed")}

)}
{pages > MAX_PAGES && (

{t("pdf.more", { shown: MAX_PAGES, total: pages })}

)}
); }