From bde9c400baea8d6d497587b1e5373dbc23de70f2 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Mon, 11 May 2026 20:29:48 -0700 Subject: [PATCH] Improve desktop PDF reader --- packages/desktop/package.json | 1 + .../src/components/artifacts/PdfRenderer.tsx | 464 +++++++++++++++++- packages/desktop/src/index.css | 210 +++++++- pnpm-lock.yaml | 129 +++++ 4 files changed, 780 insertions(+), 24 deletions(-) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 80825b8a..fad8bca4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -24,6 +24,7 @@ "docx-preview": "^0.3.7", "framer-motion": "^12.38.0", "lucide-react": "^0.577.0", + "pdfjs-dist": "5.4.296", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/packages/desktop/src/components/artifacts/PdfRenderer.tsx b/packages/desktop/src/components/artifacts/PdfRenderer.tsx index 55860e12..69fb915b 100644 --- a/packages/desktop/src/components/artifacts/PdfRenderer.tsx +++ b/packages/desktop/src/components/artifacts/PdfRenderer.tsx @@ -1,20 +1,59 @@ -import { Loader2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight, Download, Loader2, Minus, Plus, RotateCw } from 'lucide-react' +import { + GlobalWorkerOptions, + type PDFDocumentProxy, + type PDFPageProxy, + type RenderTask, + TextLayer, + getDocument, +} from 'pdfjs-dist' +import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url' +import { + type FormEvent, + type UIEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useWorkspaceBytes } from './useWorkspaceBytes.js' +GlobalWorkerOptions.workerSrc = pdfWorkerUrl + interface Props { sourcePath: string filename?: string } -/** - * Renders a PDF via Chromium's native viewer. No JS library needed — - * Electron's renderer handles `` natively, - * including text selection, search (Cmd/Ctrl-F), and pagination. - */ +type FitMode = 'width' | 'page' + +const ZOOM_MIN = 0.5 +const ZOOM_MAX = 2.5 +const ZOOM_STEP = 0.15 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function downloadName(filename: string | undefined, sourcePath: string) { + const name = filename || sourcePath.split('/').pop() || 'document.pdf' + return name.toLowerCase().endsWith('.pdf') ? name : `${name}.pdf` +} + export function PdfRenderer({ sourcePath, filename }: Props) { const { bytes, loading, error, mimeType } = useWorkspaceBytes(sourcePath) const [objectUrl, setObjectUrl] = useState(null) + const [pdf, setPdf] = useState(null) + const [loadError, setLoadError] = useState(null) + const [currentPage, setCurrentPage] = useState(1) + const [pageInput, setPageInput] = useState('1') + const [zoom, setZoom] = useState(1) + const [fitMode, setFitMode] = useState('width') + const [rotation, setRotation] = useState(0) + const [viewerSize, setViewerSize] = useState({ width: 0, height: 0 }) + const scrollerRef = useRef(null) + const scrollFrameRef = useRef(null) useEffect(() => { if (!bytes) { @@ -27,29 +66,420 @@ export function PdfRenderer({ sourcePath, filename }: Props) { return () => URL.revokeObjectURL(url) }, [bytes, mimeType]) - if (error) { + useEffect(() => { + if (!bytes) { + setPdf(null) + setLoadError(null) + return + } + + let disposed = false + let loadedPdf: PDFDocumentProxy | null = null + setPdf(null) + setLoadError(null) + setCurrentPage(1) + setPageInput('1') + + const task = getDocument({ + data: bytes.slice(), + useSystemFonts: true, + }) + + task.promise + .then((document) => { + loadedPdf = document + if (disposed) { + void document.destroy() + return + } + setPdf(document) + }) + .catch((reason: unknown) => { + if (!disposed) setLoadError(reason instanceof Error ? reason.message : String(reason)) + }) + + return () => { + disposed = true + if (loadedPdf) void loadedPdf.destroy() + else void task.destroy() + } + }, [bytes]) + + useEffect(() => { + const el = scrollerRef.current + if (!el || !pdf) return + + const measure = () => { + setViewerSize({ width: el.clientWidth, height: el.clientHeight }) + } + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(el) + return () => resizeObserver.disconnect() + }, [pdf]) + + useEffect(() => { + setPageInput(String(currentPage)) + }, [currentPage]) + + useEffect(() => { + return () => { + if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current) + } + }, []) + + const pages = useMemo( + () => (pdf ? Array.from({ length: pdf.numPages }, (_, index) => index + 1) : []), + [pdf], + ) + + const goToPage = useCallback( + (page: number) => { + if (!pdf) return + const nextPage = clamp(Math.round(page), 1, pdf.numPages) + const pageEl = scrollerRef.current?.querySelector( + `[data-pdf-page="${nextPage}"]`, + ) + pageEl?.scrollIntoView({ block: 'start' }) + setCurrentPage(nextPage) + }, + [pdf], + ) + + const updatePageFromScroll = useCallback(() => { + const scroller = scrollerRef.current + if (!scroller) return + + const scrollerRect = scroller.getBoundingClientRect() + const focusLine = scrollerRect.top + scroller.clientHeight * 0.42 + let bestPage = 1 + const pageEls = scroller.querySelectorAll('[data-pdf-page]') + + for (const pageEl of pageEls) { + const pageNumber = Number(pageEl.dataset.pdfPage) + if (!Number.isFinite(pageNumber)) continue + const { top, bottom } = pageEl.getBoundingClientRect() + if (focusLine >= top && focusLine <= bottom) bestPage = pageNumber + else if (top <= focusLine) bestPage = pageNumber + } + + setCurrentPage(bestPage) + }, []) + + const handleScroll = useCallback( + (_event: UIEvent) => { + if (scrollFrameRef.current !== null) return + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null + updatePageFromScroll() + }) + }, + [updatePageFromScroll], + ) + + const submitPage = useCallback( + (event: FormEvent) => { + event.preventDefault() + const parsed = Number.parseInt(pageInput, 10) + if (Number.isFinite(parsed)) goToPage(parsed) + else setPageInput(String(currentPage)) + }, + [currentPage, goToPage, pageInput], + ) + + if (error || loadError) { return (
Couldn't open this PDF.
-
{error}
+
{error || loadError}
{filename || sourcePath.split('/').pop()}
) } - if (loading || !objectUrl) { + + if (loading || !bytes || !pdf) { return (
- Loading PDF… + Loading PDF...
) } + + return ( +
+
+
+ +
+ setPageInput(event.currentTarget.value)} + inputMode="numeric" + aria-label="Page" + /> + / {pdf.numPages} +
+ +
+ +
+ + + + + {objectUrl ? ( + + + + ) : null} +
+
+ +
+
+ {pages.map((pageNumber) => ( + + ))} +
+
+
+ ) +} + +interface PdfPageCanvasProps { + pdf: PDFDocumentProxy + pageNumber: number + fitMode: FitMode + zoom: number + rotation: number + viewerSize: { width: number; height: number } +} + +function PdfPageCanvas({ + pdf, + pageNumber, + fitMode, + zoom, + rotation, + viewerSize, +}: PdfPageCanvasProps) { + const wrapperRef = useRef(null) + const canvasRef = useRef(null) + const textLayerRef = useRef(null) + const renderTaskRef = useRef(null) + const textLayerTaskRef = useRef(null) + const [shouldRender, setShouldRender] = useState(false) + const [page, setPage] = useState(null) + const [status, setStatus] = useState<'idle' | 'loading' | 'rendering' | 'ready' | 'error'>('idle') + const [pageSize, setPageSize] = useState({ width: 612, height: 792 }) + + useEffect(() => { + const node = wrapperRef.current + if (!node) return + + const root = node.closest('.pdf-reader__scroller') + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return + setShouldRender(true) + observer.disconnect() + }, + { root, rootMargin: '1200px 0px' }, + ) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!shouldRender) return + + let disposed = false + setStatus('loading') + pdf + .getPage(pageNumber) + .then((loadedPage) => { + if (disposed) return + const viewport = loadedPage.getViewport({ scale: 1, rotation }) + setPageSize({ width: viewport.width, height: viewport.height }) + setPage(loadedPage) + }) + .catch(() => { + if (!disposed) setStatus('error') + }) + + return () => { + disposed = true + } + }, [pageNumber, pdf, rotation, shouldRender]) + + useEffect(() => { + const canvas = canvasRef.current + const textLayerNode = textLayerRef.current + if (!page || !canvas || !textLayerNode || viewerSize.width <= 0) return + + renderTaskRef.current?.cancel() + textLayerTaskRef.current?.cancel() + renderTaskRef.current = null + textLayerTaskRef.current = null + + const unscaledViewport = page.getViewport({ scale: 1, rotation }) + const availableWidth = Math.max(280, viewerSize.width - 56) + const availableHeight = Math.max(360, viewerSize.height - 72) + const fitScale = + fitMode === 'page' + ? Math.min( + availableWidth / unscaledViewport.width, + availableHeight / unscaledViewport.height, + ) + : availableWidth / unscaledViewport.width + const scale = clamp(fitScale * zoom, 0.2, 4) + const viewport = page.getViewport({ scale, rotation }) + const outputScale = clamp(window.devicePixelRatio || 1, 1, 2) + const width = Math.floor(viewport.width) + const height = Math.floor(viewport.height) + + setPageSize({ width, height }) + setStatus('rendering') + + canvas.width = Math.floor(viewport.width * outputScale) + canvas.height = Math.floor(viewport.height * outputScale) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + textLayerNode.innerHTML = '' + textLayerNode.style.width = `${width}px` + textLayerNode.style.height = `${height}px` + + const context = canvas.getContext('2d', { alpha: false }) + if (!context) { + setStatus('error') + return + } + + const transform: [number, number, number, number, number, number] | undefined = + outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined + const renderTask = page.render({ + canvas: null, + canvasContext: context, + viewport, + transform, + background: '#ffffff', + }) + const textLayer = new TextLayer({ + textContentSource: page.streamTextContent({ includeMarkedContent: true }), + container: textLayerNode, + viewport, + }) + renderTaskRef.current = renderTask + textLayerTaskRef.current = textLayer + + let disposed = false + Promise.all([renderTask.promise, textLayer.render()]) + .then(() => { + if (!disposed) setStatus('ready') + }) + .catch((reason: unknown) => { + if (disposed) return + if (reason instanceof Error && reason.name === 'RenderingCancelledException') return + setStatus('error') + }) + + return () => { + disposed = true + renderTask.cancel() + textLayer.cancel() + } + }, [fitMode, page, rotation, viewerSize.height, viewerSize.width, zoom]) + + const isLoading = status === 'idle' || status === 'loading' || status === 'rendering' + return ( - +
+ +
+ {isLoading ? ( +
+ +
+ ) : null} + {status === 'error' ? ( +
+ Page {pageNumber} couldn't render. +
+ ) : null} +
) } diff --git a/packages/desktop/src/index.css b/packages/desktop/src/index.css index a17cad5d..6941854a 100644 --- a/packages/desktop/src/index.css +++ b/packages/desktop/src/index.css @@ -22413,13 +22413,18 @@ button { height: 100%; width: 100%; } -.fl-preview__body--doc .art-panel__loading, -.fl-preview__body--doc .art-panel__failure { +.fl-preview__body--doc .art-panel__loading { height: 100%; - display: grid; - place-items: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; padding: 24px; } +.fl-preview__body--doc .art-panel__failure { + margin: 24px auto; +} .fl-preview__center { height: 100%; display: grid; @@ -25270,6 +25275,195 @@ button { background: #fff; min-height: 600px; } +.pdf-reader { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg); + color: var(--text); +} +.pdf-reader__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 46px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev-1); + flex-shrink: 0; +} +.pdf-reader__control-group { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.pdf-reader__control-group--right { + justify-content: flex-end; + margin-left: auto; + overflow-x: auto; + scrollbar-width: none; +} +.pdf-reader__control-group--right::-webkit-scrollbar { + display: none; +} +.pdf-reader__icon-btn, +.pdf-reader__mode-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-3); + cursor: pointer; + font-family: inherit; + transition: background .12s, border-color .12s, color .12s; +} +.pdf-reader__icon-btn { + width: 28px; + flex: 0 0 28px; + padding: 0; + text-decoration: none; +} +.pdf-reader__mode-btn { + min-width: 78px; + padding: 0 9px; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; +} +.pdf-reader__icon-btn:hover, +.pdf-reader__mode-btn:hover { + background: var(--bg-elev-2); + border-color: var(--border); + color: var(--text); +} +.pdf-reader__icon-btn:disabled { + cursor: default; + opacity: 0.42; +} +.pdf-reader__icon-btn:disabled:hover { + background: transparent; + border-color: transparent; + color: var(--text-3); +} +.pdf-reader__page-form { + display: inline-flex; + align-items: center; + height: 28px; + min-width: 84px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} +.pdf-reader__page-input { + width: 36px; + border: 0; + outline: none; + background: transparent; + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.pdf-reader__page-total { + color: var(--text-4); + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.pdf-reader__scroller { + flex: 1; + min-height: 0; + overflow: auto; + background: color-mix(in oklch, var(--bg) 88%, #000); +} +.pdf-reader__pages { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + width: max-content; + min-width: 100%; + min-height: 100%; + padding: 24px; +} +.pdf-reader__page { + position: relative; + flex: 0 0 auto; + overflow: hidden; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 3px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34); +} +.pdf-reader__canvas { + position: relative; + z-index: 1; + display: block; + background: #fff; +} +.pdf-reader__text-layer { + position: absolute; + inset: 0; + z-index: 2; + overflow: clip; + text-align: initial; + line-height: 1; + -webkit-text-size-adjust: none; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; +} +.pdf-reader__text-layer :is(span, br) { + position: absolute; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0 0; +} +.pdf-reader__text-layer span[role="img"] { + user-select: none; + cursor: default; +} +.pdf-reader__text-layer ::selection { + background: color-mix(in srgb, AccentColor, transparent 75%); +} +.pdf-reader__page-status { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.72); + color: #3f3f46; + font-size: 12px; +} +.pdf-reader__page-status--error { + padding: 16px; + color: var(--danger); + text-align: center; +} +@media (max-width: 680px) { + .pdf-reader__toolbar { + gap: 6px; + padding: 7px; + } + .pdf-reader__mode-btn { + min-width: 70px; + padding: 0 7px; + } + .pdf-reader__pages { + padding: 16px; + } +} .art-panel__svg { padding: 32px; display: grid; @@ -27845,9 +28039,11 @@ button { .art-panel__docx-overlay { position: absolute; inset: 0; - display: grid; - place-items: center; - gap: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; background: rgba(31, 31, 31, 0.72); color: rgba(255, 255, 255, 0.72); font-size: 12px; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ab9642..e9c1323f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) + pdfjs-dist: + specifier: 5.4.296 + version: 5.4.296 react: specifier: ^19.0.0 version: 19.2.4 @@ -1862,6 +1865,76 @@ packages: '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} @@ -5345,6 +5418,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -8387,6 +8464,54 @@ snapshots: - bufferutil - utf-8-validate + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@next/env@14.2.35': {} '@opentelemetry/api@1.9.0': {} @@ -12612,6 +12737,10 @@ snapshots: pathval@2.0.1: {} + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + picocolors@1.1.1: {} picomatch@2.3.2: {}