| <script lang="ts"> |
| import { onMount, onDestroy } from 'svelte'; |
| import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url'; |
| import panzoom, { type PanZoom } from 'panzoom'; |
| import Spinner from './Spinner.svelte'; |
|
|
| export let url: string | null = null; |
| export let data: ArrayBuffer | Uint8Array | null = null; |
| export let className = 'w-full h-[70vh]'; |
|
|
| let outerContainer: HTMLDivElement; |
| let sceneElement: HTMLDivElement; |
| let loading = true; |
| let error = ''; |
| let pdfDoc: any = null; |
| let pzInstance: PanZoom | null = null; |
| let zoomLevel = 1; |
| let rerenderTimer: ReturnType<typeof setTimeout> | null = null; |
| let lastRenderedZoom = 1; |
|
|
| |
| let textLayerInstances: any[] = []; |
|
|
| const initPanzoom = () => { |
| if (pzInstance) { |
| pzInstance.dispose(); |
| } |
| if (sceneElement) { |
| pzInstance = panzoom(sceneElement, { |
| bounds: true, |
| boundsPadding: 0.1, |
| zoomSpeed: 0.065, |
| beforeWheel: (e) => { |
| |
| if (!e.ctrlKey && !e.metaKey) { |
| return true; |
| } |
| return false; |
| }, |
| beforeMouseDown: (e) => { |
| |
| const transform = pzInstance?.getTransform(); |
| if (transform && Math.abs(transform.scale - 1) < 0.01) { |
| return true; |
| } |
| return false; |
| } |
| }); |
| pzInstance.on('zoom', () => { |
| zoomLevel = pzInstance?.getTransform()?.scale ?? 1; |
| |
| if (rerenderTimer) clearTimeout(rerenderTimer); |
| rerenderTimer = setTimeout(() => { |
| if (Math.abs(zoomLevel - lastRenderedZoom) > 0.05) { |
| rerenderPages(zoomLevel); |
| } |
| }, 300); |
| }); |
| } |
| }; |
|
|
| const zoomIn = () => { |
| if (!pzInstance || !outerContainer) return; |
| const cx = outerContainer.clientWidth / 2; |
| const cy = outerContainer.clientHeight / 2; |
| pzInstance.zoomTo(cx, cy, 1.25); |
| zoomLevel = pzInstance.getTransform().scale; |
| }; |
|
|
| const zoomOut = () => { |
| if (!pzInstance || !outerContainer) return; |
| const cx = outerContainer.clientWidth / 2; |
| const cy = outerContainer.clientHeight / 2; |
| pzInstance.zoomTo(cx, cy, 0.8); |
| zoomLevel = pzInstance.getTransform().scale; |
| }; |
|
|
| export const resetView = () => { |
| if (pzInstance) { |
| pzInstance.moveTo(0, 0); |
| pzInstance.zoomAbs(0, 0, 1); |
| zoomLevel = 1; |
| rerenderPages(1); |
| } |
| }; |
|
|
| |
| const rerenderPages = async (forZoom: number) => { |
| if (!pdfDoc || !sceneElement) return; |
| const pdfjs = await import('pdfjs-dist'); |
| const dpr = window.devicePixelRatio || 1; |
| const containerWidth = outerContainer?.clientWidth || 800; |
|
|
| const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper'); |
|
|
| |
| for (const tl of textLayerInstances) { |
| try { |
| tl.cancel(); |
| } catch (_) {} |
| } |
| textLayerInstances = []; |
|
|
| for (let i = 0; i < pageWrappers.length; i++) { |
| const page = await pdfDoc.getPage(i + 1); |
| const viewport = page.getViewport({ scale: 1 }); |
| const cssScale = containerWidth / viewport.width; |
| const renderScale = cssScale * forZoom * dpr; |
| const scaledViewport = page.getViewport({ scale: renderScale }); |
| const cssViewport = page.getViewport({ scale: cssScale }); |
|
|
| const wrapper = pageWrappers[i] as HTMLElement; |
| |
| wrapper.style.setProperty('--scale-factor', String(cssViewport.scale)); |
|
|
| const canvas = wrapper.querySelector('canvas')!; |
| canvas.width = scaledViewport.width; |
| canvas.height = scaledViewport.height; |
|
|
| const ctx = canvas.getContext('2d'); |
| if (ctx) { |
| await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise; |
| } |
|
|
| |
| const textLayerDiv = wrapper.querySelector('.textLayer') as HTMLElement; |
| if (textLayerDiv) { |
| textLayerDiv.innerHTML = ''; |
|
|
| const textContent = await page.getTextContent(); |
| const textLayer = new pdfjs.TextLayer({ |
| textContentSource: textContent, |
| container: textLayerDiv, |
| viewport: cssViewport |
| }); |
| await textLayer.render(); |
| textLayerInstances.push(textLayer); |
| } |
| } |
| lastRenderedZoom = forZoom; |
| }; |
|
|
| const renderAllPages = async () => { |
| if (!pdfDoc || !sceneElement) return; |
|
|
| |
| sceneElement.innerHTML = ''; |
|
|
| |
| for (const tl of textLayerInstances) { |
| try { |
| tl.cancel(); |
| } catch (_) {} |
| } |
| textLayerInstances = []; |
|
|
| const pdfjs = await import('pdfjs-dist'); |
| const dpr = window.devicePixelRatio || 1; |
|
|
| for (let i = 1; i <= pdfDoc.numPages; i++) { |
| const page = await pdfDoc.getPage(i); |
| const viewport = page.getViewport({ scale: 1 }); |
|
|
| |
| const containerWidth = outerContainer?.clientWidth || 800; |
| const cssScale = containerWidth / viewport.width; |
| const renderScale = cssScale * dpr; |
| const scaledViewport = page.getViewport({ scale: renderScale }); |
| const cssViewport = page.getViewport({ scale: cssScale }); |
|
|
| |
| const wrapper = document.createElement('div'); |
| wrapper.className = 'pdf-page-wrapper'; |
| wrapper.style.position = 'relative'; |
| wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`; |
| wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`; |
| wrapper.style.display = 'block'; |
| |
| |
| wrapper.style.setProperty('--scale-factor', String(cssViewport.scale)); |
|
|
| if (i > 1) { |
| wrapper.style.marginTop = '4px'; |
| } |
|
|
| |
| const canvas = document.createElement('canvas'); |
| canvas.width = scaledViewport.width; |
| canvas.height = scaledViewport.height; |
| |
| canvas.style.width = `${Math.round(cssScale * viewport.width)}px`; |
| canvas.style.height = `${Math.round(cssScale * viewport.height)}px`; |
| canvas.style.display = 'block'; |
| wrapper.appendChild(canvas); |
|
|
| const ctx = canvas.getContext('2d'); |
| await page.render({ |
| canvasContext: ctx, |
| viewport: scaledViewport |
| }).promise; |
|
|
| |
| const textLayerDiv = document.createElement('div'); |
| textLayerDiv.className = 'textLayer'; |
| wrapper.appendChild(textLayerDiv); |
|
|
| const textContent = await page.getTextContent(); |
| const textLayer = new pdfjs.TextLayer({ |
| textContentSource: textContent, |
| container: textLayerDiv, |
| viewport: cssViewport |
| }); |
| await textLayer.render(); |
| textLayerInstances.push(textLayer); |
|
|
| sceneElement.appendChild(wrapper); |
| } |
|
|
| lastRenderedZoom = 1; |
| initPanzoom(); |
| }; |
|
|
| const loadPdf = async () => { |
| if (!url && !data) return; |
|
|
| loading = true; |
| error = ''; |
|
|
| try { |
| const pdfjs = await import('pdfjs-dist'); |
| pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; |
|
|
| let pdfData: ArrayBuffer | Uint8Array; |
| if (data) { |
| pdfData = data; |
| } else { |
| |
| const res = await fetch(url!, { credentials: 'include' }); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| pdfData = await res.arrayBuffer(); |
| } |
| pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise; |
| await renderAllPages(); |
| } catch (e) { |
| console.error('PDF render error:', e); |
| error = 'Failed to load PDF.'; |
| } finally { |
| loading = false; |
| } |
| }; |
|
|
| onMount(() => { |
| loadPdf(); |
| }); |
|
|
| onDestroy(() => { |
| if (rerenderTimer) clearTimeout(rerenderTimer); |
| pzInstance?.dispose(); |
| for (const tl of textLayerInstances) { |
| try { |
| tl.cancel(); |
| } catch (_) {} |
| } |
| textLayerInstances = []; |
| if (pdfDoc) { |
| pdfDoc.destroy(); |
| pdfDoc = null; |
| } |
| }); |
| </script> |
|
|
| <div class="relative {className}"> |
| {#if loading} |
| <div class="absolute inset-0 flex items-center justify-center"> |
| <Spinner className="size-5" /> |
| </div> |
| {:else if error} |
| <div class="absolute inset-0 flex items-center justify-center text-sm text-red-500"> |
| {error} |
| </div> |
| {/if} |
| |
| <div class="overflow-y-auto h-full" bind:this={outerContainer}> |
| <div bind:this={sceneElement} class="w-full"></div> |
| </div> |
| |
| {#if !loading && !error && pdfDoc} |
| <div |
| class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5" |
| > |
| <button |
| class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400" |
| on:click={zoomOut} |
| aria-label="Zoom out" |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 20 20" |
| fill="currentColor" |
| class="size-3.5" |
| > |
| <path |
| fill-rule="evenodd" |
| d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| </button> |
| <button |
| class="px-1.5 py-1 min-w-[3rem] text-center text-[11px] font-medium text-gray-500 dark:text-gray-400 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition tabular-nums" |
| on:click={resetView} |
| aria-label="Reset zoom" |
| > |
| {Math.round(zoomLevel * 100)}% |
| </button> |
| <button |
| class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400" |
| on:click={zoomIn} |
| aria-label="Zoom in" |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 20 20" |
| fill="currentColor" |
| class="size-3.5" |
| > |
| <path |
| d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" |
| /> |
| </svg> |
| </button> |
| </div> |
| {/if} |
| </div> |
|
|
| <style> |
| |
| |
| |
| |
| |
| |
| :global(.textLayer) { |
| position: absolute; |
| text-align: initial; |
| inset: 0; |
| overflow: clip; |
| opacity: 1; |
| line-height: 1; |
| -webkit-text-size-adjust: none; |
| -moz-text-size-adjust: none; |
| text-size-adjust: none; |
| forced-color-adjust: none; |
| transform-origin: 0 0; |
| caret-color: CanvasText; |
| z-index: 0; |
| } |
| |
| :global(.textLayer :is(span, br)) { |
| color: transparent; |
| position: absolute; |
| white-space: pre; |
| cursor: text; |
| transform-origin: 0% 0%; |
| } |
| |
| :global(.textLayer) { |
| |
| |
| --user-unit: 1; |
| --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); |
| --min-font-size: 1; |
| --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); |
| --min-font-size-inv: calc(1 / var(--min-font-size)); |
| } |
| |
| :global(.textLayer > :not(.markedContent)), |
| :global(.textLayer .markedContent span:not(.markedContent)) { |
| z-index: 1; |
| --font-height: 0; |
| font-size: calc(var(--text-scale-factor) * var(--font-height)); |
| --scale-x: 1; |
| --rotate: 0deg; |
| transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); |
| } |
| |
| :global(.textLayer .markedContent) { |
| display: contents; |
| } |
| |
| :global(.textLayer span[role='img']) { |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| user-select: none; |
| cursor: default; |
| } |
| |
| |
| :global(.textLayer ::-moz-selection) { |
| background: rgba(0, 0, 255, 0.25); |
| } |
| |
| :global(.textLayer ::selection) { |
| background: rgba(0, 0, 255, 0.25); |
| } |
| |
| :global(.textLayer br::-moz-selection) { |
| background: transparent; |
| } |
| |
| :global(.textLayer br::selection) { |
| background: transparent; |
| } |
| |
| :global(.textLayer .endOfContent) { |
| display: block; |
| position: absolute; |
| inset: 100% 0 0; |
| z-index: 0; |
| cursor: default; |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| user-select: none; |
| } |
| |
| :global(.textLayer.selecting .endOfContent) { |
| top: 0; |
| } |
| </style> |
|
|