diff --git a/apps/examples/e2e/tests/test-routes.spec.ts b/apps/examples/e2e/tests/test-routes.spec.ts index 567bf3a70..d1cbb9ae4 100644 --- a/apps/examples/e2e/tests/test-routes.spec.ts +++ b/apps/examples/e2e/tests/test-routes.spec.ts @@ -11,6 +11,8 @@ const examplesWithoutCanvas = [ 'yjs', // starts by asking the user to select an image 'image-annotator', + // starts by asking the user to open a pdf + 'pdf-editor', ] const exampelsToTest = examplesFolderList.filter((route) => !examplesWithoutCanvas.includes(route)) diff --git a/apps/examples/package.json b/apps/examples/package.json index 468f01258..578995d19 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -40,6 +40,8 @@ "classnames": "^2.3.2", "lazyrepo": "0.0.0-alpha.27", "lodash": "^4.17.21", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^4.0.379", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.17.0", diff --git a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx index 57ab46e73..827a545a3 100644 --- a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx +++ b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx @@ -22,6 +22,7 @@ import { AnnotatorImage } from './types' // TODO: // - prevent changing pages (create page, change page, move shapes to new page) // - prevent locked shape context menu +// - inertial scrolling for constrained camera export function ImageAnnotationEditor({ image, onDone, diff --git a/apps/examples/src/examples/pdf-editor/ExportPdfButton.tsx b/apps/examples/src/examples/pdf-editor/ExportPdfButton.tsx new file mode 100644 index 000000000..cf6067acd --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/ExportPdfButton.tsx @@ -0,0 +1,89 @@ +import { PDFDocument } from 'pdf-lib' +import { useState } from 'react' +import { Editor, assert, exportToBlob, useEditor } from 'tldraw' +import { Pdf } from './PdfPicker' + +export function ExportPdfButton({ pdf }: { pdf: Pdf }) { + const [exportProgress, setExportProgress] = useState(null) + const editor = useEditor() + + return ( + + ) +} + +async function exportPdf( + editor: Editor, + { name, source, pages }: Pdf, + onProgress: (progress: number) => void +) { + const totalThings = pages.length * 2 + 2 + let progressCount = 0 + const tickProgress = () => { + progressCount++ + onProgress(progressCount / totalThings) + } + + const pdf = await PDFDocument.load(source) + tickProgress() + + const pdfPages = pdf.getPages() + assert(pdfPages.length === pages.length, 'PDF page count mismatch') + + const pageShapeIds = new Set(pages.map((page) => page.shapeId)) + const allIds = Array.from(editor.getCurrentPageShapeIds()).filter((id) => !pageShapeIds.has(id)) + + for (let i = 0; i < pages.length; i++) { + const page = pages[i] + const pdfPage = pdfPages[i] + + const bounds = page.bounds + const shapesInBounds = allIds.filter((id) => { + const shapePageBounds = editor.getShapePageBounds(id) + if (!shapePageBounds) return false + return shapePageBounds.collides(bounds) + }) + + if (shapesInBounds.length === 0) { + tickProgress() + tickProgress() + continue + } + + const exportedPng = await exportToBlob({ + editor, + ids: allIds, + format: 'png', + opts: { background: false, bounds: page.bounds, padding: 0, scale: 2 }, + }) + tickProgress() + + pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), { + x: 0, + y: 0, + width: pdfPage.getWidth(), + height: pdfPage.getHeight(), + }) + tickProgress() + } + + const url = URL.createObjectURL(new Blob([await pdf.save()], { type: 'application/pdf' })) + tickProgress() + const a = document.createElement('a') + a.href = url + a.download = name + a.click() + URL.revokeObjectURL(url) +} diff --git a/apps/examples/src/examples/pdf-editor/PdfEditor.tsx b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx new file mode 100644 index 000000000..8a56ea07f --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { + Box, + SVGContainer, + TLImageShape, + TLShapeId, + TLShapePartial, + Tldraw, + clamp, + compact, + getIndicesBetween, + react, + sortByIndex, + track, + useBreakpoint, + useEditor, +} from 'tldraw' +import { PORTRAIT_BREAKPOINT } from 'tldraw/src/lib/ui/constants' +import { ExportPdfButton } from './ExportPdfButton' +import { Pdf } from './PdfPicker' + +// TODO: +// - prevent changing pages (create page, change page, move shapes to new page) +// - prevent locked shape context menu +// - inertial scrolling for constrained camera +// - render pages on-demand instead of all at once. +export function PdfEditor({ pdf }: { pdf: Pdf }) { + const pdfShapeIds = useMemo(() => pdf.pages.map((page) => page.shapeId), [pdf.pages]) + return ( + { + editor.updateInstanceState({ isDebugMode: false }) + editor.setCamera({ x: 1000, y: 1000, z: 1 }) + + editor.createAssets( + pdf.pages.map((page) => ({ + id: page.assetId, + typeName: 'asset', + type: 'image', + meta: {}, + props: { + w: page.bounds.w, + h: page.bounds.h, + mimeType: 'image/png', + src: page.src, + name: 'page', + isAnimated: false, + }, + })) + ) + + editor.createShapes( + pdf.pages.map( + (page): TLShapePartial => ({ + id: page.shapeId, + type: 'image', + x: page.bounds.x, + y: page.bounds.y, + props: { + assetId: page.assetId, + w: page.bounds.w, + h: page.bounds.h, + }, + }) + ) + ) + }} + components={{ + PageMenu: null, + InFrontOfTheCanvas: useCallback(() => { + return + }, [pdf]), + SharePanel: useCallback(() => { + return + }, [pdf]), + }} + > + + + + + ) +} + +const PageOverlayScreen = track(function PageOverlayScreen({ pdf }: { pdf: Pdf }) { + const editor = useEditor() + + const viewportPageBounds = editor.getViewportPageBounds() + const viewportScreenBounds = editor.getViewportScreenBounds() + + const relevantPageBounds = compact( + pdf.pages.map((page) => { + if (!viewportPageBounds.collides(page.bounds)) return null + const topLeft = editor.pageToViewport(page.bounds) + const bottomRight = editor.pageToViewport({ x: page.bounds.maxX, y: page.bounds.maxY }) + return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) + }) + ) + + function pathForPageBounds(bounds: Box) { + return `M ${bounds.x} ${bounds.y} L ${bounds.maxX} ${bounds.y} L ${bounds.maxX} ${bounds.maxY} L ${bounds.x} ${bounds.maxY} Z` + } + + const viewportPath = `M 0 0 L ${viewportScreenBounds.w} 0 L ${viewportScreenBounds.w} ${viewportScreenBounds.h} L 0 ${viewportScreenBounds.h} Z` + + return ( + <> + + + + {relevantPageBounds.map((bounds, i) => ( +
+ ))} + + ) +}) + +function ConstrainCamera({ pdf }: { pdf: Pdf }) { + const editor = useEditor() + const breakpoint = useBreakpoint() + const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM + + useEffect(() => { + const marginTop = 64 + const marginSide = isMobile ? 16 : 164 + const marginBottom = 80 + + const targetBounds = pdf.pages.reduce( + (acc, page) => acc.union(page.bounds), + pdf.pages[0].bounds.clone() + ) + + function constrainCamera(camera: { x: number; y: number; z: number }): { + x: number + y: number + z: number + } { + const viewportBounds = editor.getViewportScreenBounds() + + const usableViewport = new Box( + marginSide, + marginTop, + viewportBounds.w - marginSide * 2, + viewportBounds.h - marginTop - marginBottom + ) + + const minZoom = Math.min( + usableViewport.w / targetBounds.w, + usableViewport.h / targetBounds.h, + 1 + ) + const zoom = Math.max(minZoom, camera.z) + + const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom + const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom + + const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom) + const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom) + + return { + x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2), + y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2), + z: zoom, + } + } + + const removeOnChange = editor.sideEffects.registerBeforeChangeHandler( + 'camera', + (_prev, next) => { + const constrained = constrainCamera(next) + if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z) + return next + return { ...next, ...constrained } + } + ) + + const removeReaction = react('update camera when viewport/shape changes', () => { + const original = editor.getCamera() + const constrained = constrainCamera(original) + if ( + original.x === constrained.x && + original.y === constrained.y && + original.z === constrained.z + ) { + return + } + + // this needs to be in a microtask for some reason, but idk why + queueMicrotask(() => editor.setCamera(constrained)) + }) + + return () => { + removeOnChange() + removeReaction() + } + }, [editor, isMobile, pdf.pages]) + + return null +} + +function KeepShapesLocked({ shapeIds }: { shapeIds: TLShapeId[] }) { + const editor = useEditor() + + useEffect(() => { + const shapeIdSet = new Set(shapeIds) + + for (const shapeId of shapeIdSet) { + const shape = editor.getShape(shapeId)! + editor.updateShape({ + id: shape.id, + type: shape.type, + isLocked: true, + }) + } + + const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => { + if (!shapeIdSet.has(next.id)) return next + if (next.isLocked) return next + return { ...prev, isLocked: true } + }) + + return () => { + removeOnChange() + } + }, [editor, shapeIds]) + + return null +} + +function KeepShapesAtBottomOfCurrentPage({ shapeIds }: { shapeIds: TLShapeId[] }) { + const editor = useEditor() + + useEffect(() => { + const shapeIdSet = new Set(shapeIds) + + function makeSureShapesAreAtBottom() { + const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex) + const pageId = editor.getCurrentPageId() + + const siblings = editor.getSortedChildIdsForParent(pageId) + const currentBottomShapes = siblings.slice(0, shapes.length).map((id) => editor.getShape(id)!) + + if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return + + const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id)) + const bottomSibling = otherSiblings[0] + const lowestIndex = editor.getShape(bottomSibling)!.index + + const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length) + editor.updateShapes( + shapes.map((shape, i) => ({ + id: shape.id, + type: shape.type, + isLocked: shape.isLocked, + index: indexes[i], + })) + ) + } + + makeSureShapesAreAtBottom() + + const removeOnCreate = editor.sideEffects.registerAfterCreateHandler( + 'shape', + makeSureShapesAreAtBottom + ) + const removeOnChange = editor.sideEffects.registerAfterChangeHandler( + 'shape', + makeSureShapesAreAtBottom + ) + + return () => { + removeOnCreate() + removeOnChange() + } + }, [editor, shapeIds]) + + return null +} diff --git a/apps/examples/src/examples/pdf-editor/PdfEditorExample.tsx b/apps/examples/src/examples/pdf-editor/PdfEditorExample.tsx new file mode 100644 index 000000000..87a137001 --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/PdfEditorExample.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react' +import { PdfEditor } from './PdfEditor' +import { Pdf, PdfPicker } from './PdfPicker' +import './pdf-editor.css' + +type State = + | { + phase: 'pick' + } + | { + phase: 'edit' + pdf: Pdf + } + +export default function PdfEditorWrapper() { + const [state, setState] = useState({ phase: 'pick' }) + + switch (state.phase) { + case 'pick': + return ( +
+ setState({ phase: 'edit', pdf })} /> +
+ ) + case 'edit': + return ( +
+ +
+ ) + } +} diff --git a/apps/examples/src/examples/pdf-editor/PdfPicker.tsx b/apps/examples/src/examples/pdf-editor/PdfPicker.tsx new file mode 100644 index 000000000..ecadb2557 --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/PdfPicker.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' +import { AssetRecordType, Box, TLAssetId, TLShapeId, assertExists, createShapeId } from 'tldraw' +import tldrawPdf from './assets/tldraw.pdf' + +export type PdfPage = { + src: string + bounds: Box + assetId: TLAssetId + shapeId: TLShapeId +} + +export type Pdf = { + name: string + pages: PdfPage[] + source: string | ArrayBuffer +} + +const pageSpacing = 32 + +export function PdfPicker({ onOpenPdf }: { onOpenPdf: (pdf: Pdf) => void }) { + const [isLoading, setIsLoading] = useState(false) + + async function loadPdf(name: string, source: ArrayBuffer): Promise { + const PdfJS = await import('pdfjs-dist') + PdfJS.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url + ).toString() + const pdf = await PdfJS.getDocument(source.slice(0)).promise + const pages: PdfPage[] = [] + + const canvas = window.document.createElement('canvas') + const context = assertExists(canvas.getContext('2d')) + + const visualScale = 1.5 + const scale = window.devicePixelRatio + + let top = 0 + let widest = 0 + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i) + const viewport = page.getViewport({ scale: scale * visualScale }) + canvas.width = viewport.width + canvas.height = viewport.height + const renderContext = { + canvasContext: context, + viewport, + } + await page.render(renderContext).promise + + const width = viewport.width / scale + const height = viewport.height / scale + pages.push({ + src: canvas.toDataURL(), + bounds: new Box(0, top, width, height), + assetId: AssetRecordType.createId(), + shapeId: createShapeId(), + }) + top += height + pageSpacing + widest = Math.max(widest, width) + } + canvas.width = 0 + canvas.height = 0 + + for (const page of pages) { + page.bounds.x = (widest - page.bounds.width) / 2 + } + + return { + name, + pages, + source, + } + } + + function onClickOpenPdf() { + const input = window.document.createElement('input') + input.type = 'file' + input.accept = 'application/pdf' + input.addEventListener('change', async (e) => { + const fileList = (e.target as HTMLInputElement).files + if (!fileList || fileList.length === 0) return + const file = fileList[0] + + setIsLoading(true) + try { + const pdf = await loadPdf(file.name, await file.arrayBuffer()) + onOpenPdf(pdf) + } finally { + setIsLoading(false) + } + }) + input.click() + } + + async function onClickUseExample() { + setIsLoading(true) + try { + const result = await fetch(tldrawPdf) + const pdf = await loadPdf('tldraw.pdf', await result.arrayBuffer()) + onOpenPdf(pdf) + } finally { + setIsLoading(false) + } + } + + if (isLoading) { + return
Loading...
+ } + + return ( +
+ +
or
+ +
+ ) +} diff --git a/apps/examples/src/examples/pdf-editor/README.md b/apps/examples/src/examples/pdf-editor/README.md new file mode 100644 index 000000000..91bb68fe4 --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/README.md @@ -0,0 +1,8 @@ +--- +title: PDF editor +component: ./PdfEditorExample.tsx +category: use-cases +priority: 1 +--- + +A very basic PDF editor built with tldraw diff --git a/apps/examples/src/examples/pdf-editor/assets/tldraw.pdf b/apps/examples/src/examples/pdf-editor/assets/tldraw.pdf new file mode 100644 index 000000000..6ade60e6a Binary files /dev/null and b/apps/examples/src/examples/pdf-editor/assets/tldraw.pdf differ diff --git a/apps/examples/src/examples/pdf-editor/pdf-editor.css b/apps/examples/src/examples/pdf-editor/pdf-editor.css new file mode 100644 index 000000000..845c16391 --- /dev/null +++ b/apps/examples/src/examples/pdf-editor/pdf-editor.css @@ -0,0 +1,66 @@ +.PdfEditor { + position: absolute; + inset: 0; +} + +.PdfEditor .PdfPicker { + position: absolute; + inset: 1rem; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + flex-direction: column; + gap: 1rem; +} +.PdfEditor .PdfPicker button { + padding: 0.5rem 1rem; + border: none; + background: #eee; + cursor: pointer; + font: inherit; +} +.PdfEditor .PdfPicker button:hover { + opacity: 0.9; +} + +.PdfEditor .PdfBgRenderer { + position: absolute; + pointer-events: none; +} +.PdfEditor .PdfBgRenderer img { + position: absolute; +} + +.PdfEditor .PageOverlayScreen-screen { + pointer-events: none; + z-index: -1; + fill: var(--color-background); + fill-opacity: 0.8; + stroke: none; +} +.PdfEditor .PageOverlayScreen-outline { + position: absolute; + pointer-events: none; + z-index: -1; + /* border: 1px solid var(--color-overlay); */ + box-shadow: var(--shadow-2); +} +.PdfEditor .ExportPdfButton { + font: inherit; + background: var(--color-primary); + border: none; + color: var(--color-selected-contrast); + font-size: 1rem; + padding: 0.5rem 1rem; + border-radius: 6px; + margin: 6px; + margin-bottom: 0; + pointer-events: all; + z-index: var(--layer-panels); + border: 2px solid var(--color-background); + cursor: pointer; +} +.PdfEditor .ExportPdfButton:hover { + filter: brightness(1.1); +} diff --git a/apps/examples/vite.config.ts b/apps/examples/vite.config.ts index 052af1cb4..648787582 100644 --- a/apps/examples/vite.config.ts +++ b/apps/examples/vite.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ build: { outDir: path.join(__dirname, 'dist'), assetsInlineLimit: 0, + target: 'es2022', + }, + esbuild: { + target: 'es2022', }, server: { port: 5420, @@ -16,6 +20,9 @@ export default defineConfig({ clearScreen: false, optimizeDeps: { exclude: ['@tldraw/assets'], + esbuildOptions: { + target: 'es2022', + }, }, define: { 'process.env.TLDRAW_ENV': JSON.stringify(process.env.VERCEL_ENV ?? 'development'), diff --git a/package.json b/package.json index 4e540c10d..a9e48d80f 100644 --- a/package.json +++ b/package.json @@ -104,10 +104,15 @@ "typescript": "^5.3.3", "vercel": "^28.16.15" }, + "// resolutions.canvas": [ + "our examples app depenends on pdf.js which pulls in canvas as an optional dependency.", + "it slows down installs quite a bit though, so we replace it with an empty package." + ], "resolutions": { "@microsoft/api-extractor@^7.35.4": "patch:@microsoft/api-extractor@npm%3A7.35.4#./.yarn/patches/@microsoft-api-extractor-npm-7.35.4-5f4f0357b4.patch", "vectra@^0.4.4": "patch:vectra@npm%3A0.4.4#./.yarn/patches/vectra-npm-0.4.4-6aac3f6c29.patch", - "domino@^2.1.6": "patch:domino@npm%3A2.1.6#./.yarn/patches/domino-npm-2.1.6-b0dc3de857.patch" + "domino@^2.1.6": "patch:domino@npm%3A2.1.6#./.yarn/patches/domino-npm-2.1.6-b0dc3de857.patch", + "canvas": "npm:empty-npm-package@1.0.0" }, "dependencies": { "@sentry/cli": "^2.25.0", diff --git a/yarn.lock b/yarn.lock index 8d7c66e8c..a1dffab9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4711,6 +4711,24 @@ __metadata: languageName: node linkType: hard +"@pdf-lib/standard-fonts@npm:^1.0.0": + version: 1.0.0 + resolution: "@pdf-lib/standard-fonts@npm:1.0.0" + dependencies: + pako: "npm:^1.0.6" + checksum: 0dfcedbd16e3f48afcac321f153d81f49ea6030030fa1e05a07bde359629949e27b3d77b39a5c7b0b0ff8af09912765f62a911cd67d488d4dc0b3c1c5ca106f0 + languageName: node + linkType: hard + +"@pdf-lib/upng@npm:^1.0.1": + version: 1.0.1 + resolution: "@pdf-lib/upng@npm:1.0.1" + dependencies: + pako: "npm:^1.0.10" + checksum: eecac72f36d51b67acb09f61a7bbb55577ceb6232aa0c1a827aea003126787cede07ad391c0c789ab36164a9a86d1634768c2cf941148743e155de7ed6c34fc3 + languageName: node + linkType: hard + "@peculiar/asn1-schema@npm:^2.3.6": version: 2.3.8 resolution: "@peculiar/asn1-schema@npm:2.3.8" @@ -10267,6 +10285,13 @@ __metadata: languageName: node linkType: hard +"canvas@npm:empty-npm-package@1.0.0": + version: 1.0.0 + resolution: "empty-npm-package@npm:1.0.0" + checksum: 745b1e85c1c3f42d5960fc5729e6ad6114a41af9425a673b1f3493c0fb431273d48e30170bcfaf8141feca15f95ca141c8237aefee8ee84fd34586f06ee62368 + languageName: node + linkType: hard + "capnp-ts@npm:^0.7.0": version: 0.7.0 resolution: "capnp-ts@npm:0.7.0" @@ -13275,6 +13300,8 @@ __metadata: dotenv: "npm:^16.3.1" lazyrepo: "npm:0.0.0-alpha.27" lodash: "npm:^4.17.21" + pdf-lib: "npm:^1.17.1" + pdfjs-dist: "npm:^4.0.379" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-router-dom: "npm:^6.17.0" @@ -19663,6 +19690,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:^1.0.10, pako@npm:^1.0.11, pako@npm:^1.0.6": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 1ad07210e894472685564c4d39a08717e84c2a68a70d3c1d9e657d32394ef1670e22972a433cbfe48976cb98b154ba06855dcd3fcfba77f60f1777634bec48c0 + languageName: node + linkType: hard + "pako@npm:~0.2.0": version: 0.2.9 resolution: "pako@npm:0.2.9" @@ -19907,6 +19941,13 @@ __metadata: languageName: node linkType: hard +"path2d-polyfill@npm:^2.0.1": + version: 2.0.1 + resolution: "path2d-polyfill@npm:2.0.1" + checksum: 82b26ef1a737560bc7a974919aec5d1c316d923b39cdf3aae2f20cd77b08d6c5256a5605f178d7dc010de7c886eeac0c263a590653687f8aea60fabbc0582890 + languageName: node + linkType: hard + "pathe@npm:^1.1.0, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" @@ -19914,6 +19955,33 @@ __metadata: languageName: node linkType: hard +"pdf-lib@npm:^1.17.1": + version: 1.17.1 + resolution: "pdf-lib@npm:1.17.1" + dependencies: + "@pdf-lib/standard-fonts": "npm:^1.0.0" + "@pdf-lib/upng": "npm:^1.0.1" + pako: "npm:^1.0.11" + tslib: "npm:^1.11.1" + checksum: ea8c2c0c813d89ca359a0c831cb2e8a581c569ed44b074316f917942b5baa101f878ce922f1cb87e6f41a035008ba75b52267480aee5d65a5e68c445ee83bc37 + languageName: node + linkType: hard + +"pdfjs-dist@npm:^4.0.379": + version: 4.0.379 + resolution: "pdfjs-dist@npm:4.0.379" + dependencies: + canvas: "npm:^2.11.2" + path2d-polyfill: "npm:^2.0.1" + dependenciesMeta: + canvas: + optional: true + path2d-polyfill: + optional: true + checksum: 9abea0cd20e3a209537be81092055e247dc10cadc08c840ad4b2495e1e6d512b3f3208dfe12f47389af6b9f078a6e00b7bb9d950fe4dbe9f214ef726f8c2815f + languageName: node + linkType: hard + "peek-stream@npm:^1.1.0": version: 1.1.3 resolution: "peek-stream@npm:1.1.3"