diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8b42e09..1bc32019 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx new file mode 100644 index 00000000..297e1d42 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -0,0 +1,329 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"; +import { ScoreViewer } from "./ScoreViewer"; +import { loadScorePdf } from "./pdfjs"; + +vi.mock("./pdfjs", () => ({ + loadScorePdf: vi.fn() +})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewerEmpty: "No score PDF attached. Attach a validated score PDF to view it here.", + scoreViewerLoading: "Loading score PDF...", + scoreViewerFailedTitle: "Could not display the score", + scoreViewerRetry: "Retry", + scoreViewerPrevPage: "Previous page", + scoreViewerNextPage: "Next page", + scoreViewerPageIndicator: "Page {current} of {total}", + scoreViewerZoomIn: "Zoom in", + scoreViewerZoomOut: "Zoom out", + scoreViewerFitWidth: "Fit width" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createFakePage(renderPromise: Promise = Promise.resolve()) { + const renderTask = { promise: renderPromise, cancel: vi.fn() }; + return { + renderTask, + getViewport: vi.fn(({ scale }: { scale: number }) => ({ + width: 600 * scale, + height: 800 * scale + })), + render: vi.fn(() => renderTask) + }; +} + +function createFakeDocument(numPages = 3, page = createFakePage()) { + return { + page, + doc: { + numPages, + getPage: vi.fn(() => Promise.resolve(page)) + } as unknown as PDFDocumentProxy + }; +} + +function mockLoadTaskOnce( + promise: Promise, + destroy: () => Promise = () => Promise.resolve() +) { + const destroyMock = vi.fn(destroy); + vi.mocked(loadScorePdf).mockReturnValueOnce({ + promise, + destroy: destroyMock + } as unknown as PDFDocumentLoadingTask); + return { destroy: destroyMock }; +} + +const SAMPLE_BYTES = new Uint8Array([0x25, 0x50, 0x44, 0x46]); + +describe("ScoreViewer", () => { + beforeEach(() => { + vi.mocked(loadScorePdf).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the empty placeholder without loading when no data is attached", () => { + const onStatusChange = vi.fn(); + render(); + + expect( + screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.") + ).toBeInTheDocument(); + expect(loadScorePdf).not.toHaveBeenCalled(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it("transitions from LOADING to READY and renders the first page", async () => { + const deferred = createDeferred(); + mockLoadTaskOnce(deferred.promise); + const { doc, page } = createFakeDocument(3); + const onStatusChange = vi.fn(); + + render(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.getByText("Loading score PDF...")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("LOADING"); + expect(loadScorePdf).toHaveBeenCalledWith(SAMPLE_BYTES); + + await act(async () => { + deferred.resolve(doc); + }); + + expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("READY"); + await waitFor(() => { + expect(page.render).toHaveBeenCalled(); + }); + expect(page.getViewport).toHaveBeenCalledWith({ scale: 1 }); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); + }); + + it("shows the file name when provided", async () => { + const { doc } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("setlist-opener.pdf")).toBeInTheDocument(); + }); + + it("transitions to FAILED with the error message and recovers on retry", async () => { + mockLoadTaskOnce(Promise.reject(new Error("broken bytes"))); + const { doc } = createFakeDocument(2); + mockLoadTaskOnce(Promise.resolve(doc)); + const onStatusChange = vi.fn(); + + render(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Could not display the score")).toBeInTheDocument(); + expect(screen.getByText("broken bytes")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("FAILED"); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("Page 1 of 2")).toBeInTheDocument(); + expect(onStatusChange).toHaveBeenLastCalledWith("READY"); + expect(loadScorePdf).toHaveBeenCalledTimes(2); + }); + + it("stringifies non-Error load failures", async () => { + mockLoadTaskOnce(Promise.reject("password protected")); + + render(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("password protected")).toBeInTheDocument(); + }); + + it("navigates pages and clamps at both bounds", async () => { + const { doc } = createFakeDocument(3); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); + const previousButton = screen.getByRole("button", { name: "Previous page" }); + const nextButton = screen.getByRole("button", { name: "Next page" }); + expect(previousButton).toBeDisabled(); + + fireEvent.click(nextButton); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + + fireEvent.click(nextButton); + expect(screen.getByText("Page 3 of 3")).toBeInTheDocument(); + expect(nextButton).toBeDisabled(); + + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalledWith(3); + }); + + fireEvent.click(previousButton); + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalledWith(2); + }); + }); + + it("zooms in and out with clamping and returns to fit-width", async () => { + const { doc, page } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + const zoomInButton = screen.getByRole("button", { name: "Zoom in" }); + const zoomOutButton = screen.getByRole("button", { name: "Zoom out" }); + const fitWidthButton = screen.getByRole("button", { name: "Fit width" }); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(zoomInButton); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "false"); + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 1.25 }); + }); + + for (let clicks = 0; clicks < 8; clicks += 1) { + fireEvent.click(zoomInButton); + } + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 4 }); + }); + + for (let clicks = 0; clicks < 12; clicks += 1) { + fireEvent.click(zoomOutButton); + } + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + }); + + fireEvent.click(fitWidthButton); + expect(fitWidthButton).toHaveAttribute("aria-pressed", "true"); + }); + + it("re-renders at fit-width scale when the container resizes", async () => { + let resizeCallback: ResizeObserverCallback | null = null; + class FakeResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + + const { doc, page } = createFakeDocument(1); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + + act(() => { + resizeCallback?.( + [{ contentRect: { width: 300 } } as ResizeObserverEntry], + {} as ResizeObserver + ); + }); + + await waitFor(() => { + expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 }); + }); + }); + + it("keeps the READY layout when a page render is cancelled mid-flight", async () => { + const renderFailure = Promise.reject(new Error("Rendering cancelled")); + renderFailure.catch(() => undefined); + const page = createFakePage(renderFailure); + const { doc } = createFakeDocument(1, page); + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + await waitFor(() => { + expect(page.render).toHaveBeenCalled(); + }); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + + it("keeps the READY layout when fetching a page fails after load", async () => { + const doc = { + numPages: 1, + getPage: vi.fn(() => Promise.reject(new Error("destroyed"))) + } as unknown as PDFDocumentProxy; + mockLoadTaskOnce(Promise.resolve(doc)); + + render(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + await waitFor(() => { + expect(doc.getPage).toHaveBeenCalled(); + }); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + + it("destroys the loading task on unmount and ignores late results", async () => { + const deferred = createDeferred(); + const { destroy } = mockLoadTaskOnce(deferred.promise, () => + Promise.reject(new Error("already destroyed")) + ); + const onStatusChange = vi.fn(); + + const { unmount } = render( + + ); + unmount(); + + expect(destroy).toHaveBeenCalledTimes(1); + + const { doc } = createFakeDocument(1); + await act(async () => { + deferred.resolve(doc); + }); + expect(onStatusChange).not.toHaveBeenCalledWith("READY"); + }); + + it("ignores a late failure after unmount", async () => { + const deferred = createDeferred(); + mockLoadTaskOnce(deferred.promise); + const onStatusChange = vi.fn(); + + const { unmount } = render( + + ); + unmount(); + + await act(async () => { + deferred.reject(new Error("too late")); + }); + expect(onStatusChange).not.toHaveBeenCalledWith("FAILED"); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx new file mode 100644 index 00000000..82692469 --- /dev/null +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -0,0 +1,317 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; +import { + AlertCircle, + ChevronLeft, + ChevronRight, + FileMusic, + Loader2, + MoveHorizontal, + RotateCw, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { loadScorePdf } from "./pdfjs"; + +/** Viewer lifecycle states following the clearfolio LOADING/FAILED/READY contract. */ +export type ScoreViewerStatus = "LOADING" | "FAILED" | "READY"; + +/** Props accepted by the score PDF viewer. */ +export interface ScoreViewerProps { + /** + * Validated score PDF bytes to display, or `null` when no score is + * attached. Following the validated-resource-only rule the viewer never + * loads arbitrary URLs; callers (PR3 wires Tauri `read_score_pdf`) must + * hand it bytes they already validated. + */ + data: Uint8Array | null; + /** Optional display name of the attached score file. */ + fileName?: string; + /** Optional observer notified on every LOADING/FAILED/READY transition. */ + onStatusChange?: (status: ScoreViewerStatus) => void; +} + +const ZOOM_STEP = 1.25; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 4; + +/** + * Render a score PDF from validated in-memory bytes with pdf.js. + * + * Implements the clearfolio viewer state machine (LOADING spinner, FAILED + * error with retry, READY canvas) plus rehearsal-friendly page navigation + * and zoom in/out/fit-width controls. + */ +export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { + const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const [status, setStatus] = useState("LOADING"); + const [errorMessage, setErrorMessage] = useState(null); + const [pdfDocument, setPdfDocument] = useState(null); + const [pageNumber, setPageNumber] = useState(1); + const [pageCount, setPageCount] = useState(0); + const [zoom, setZoom] = useState(1); + const [fitWidth, setFitWidth] = useState(true); + const [containerWidth, setContainerWidth] = useState(0); + const [retryToken, setRetryToken] = useState(0); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (data !== null) { + onStatusChange?.(status); + } + }, [data, status, onStatusChange]); + + useEffect(() => { + if (data === null) { + return; + } + + let cancelled = false; + setStatus("LOADING"); + setErrorMessage(null); + setPdfDocument(null); + + const loadingTask = loadScorePdf(data); + loadingTask.promise + .then((loadedDocument) => { + if (cancelled) { + return; + } + setPdfDocument(loadedDocument); + setPageCount(loadedDocument.numPages); + setPageNumber(1); + setStatus("READY"); + }) + .catch((error: unknown) => { + if (cancelled) { + return; + } + setErrorMessage(error instanceof Error ? error.message : String(error)); + setStatus("FAILED"); + }); + + return () => { + cancelled = true; + void loadingTask.destroy().catch(() => undefined); + }; + }, [data, retryToken]); + + useEffect(() => { + const container = containerRef.current; + if (status !== "READY" || !container || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, [status]); + + useEffect(() => { + const canvas = canvasRef.current; + if (status !== "READY" || !pdfDocument || !canvas) { + return; + } + + let cancelled = false; + let renderTask: RenderTask | null = null; + + pdfDocument + .getPage(pageNumber) + .then((page) => { + if (cancelled) { + return; + } + const baseViewport = page.getViewport({ scale: 1 }); + const scale = + fitWidth && containerWidth > 0 ? containerWidth / baseViewport.width : zoom; + const viewport = page.getViewport({ scale }); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + renderTask = page.render({ canvas, viewport }); + renderTask.promise.catch(() => { + // Cancelled renders (rapid page/zoom changes) are expected. + }); + }) + .catch(() => { + // The document was destroyed mid-flight; the load effect owns errors. + }); + + return () => { + cancelled = true; + renderTask?.cancel(); + }; + }, [status, pdfDocument, pageNumber, zoom, fitWidth, containerWidth]); + + /** Move to the previous page, clamped at the first page. */ + const goToPreviousPage = () => { + setPageNumber((current) => Math.max(1, current - 1)); + }; + + /** Move to the next page, clamped at the last page. */ + const goToNextPage = () => { + setPageNumber((current) => Math.min(pageCount, current + 1)); + }; + + /** Switch to manual zoom and enlarge, clamped at the maximum scale. */ + const zoomIn = () => { + setFitWidth(false); + setZoom((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP)); + }; + + /** Switch to manual zoom and shrink, clamped at the minimum scale. */ + const zoomOut = () => { + setFitWidth(false); + setZoom((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP)); + }; + + /** Re-enable fit-width so the page tracks the container size. */ + const fitToWidth = () => { + setFitWidth(true); + }; + + /** Re-run the load state machine with the same validated bytes. */ + const retry = () => { + setRetryToken((current) => current + 1); + }; + + if (data === null) { + return ( + + +
+
+

{t("scoreViewerEmpty")}

+
+
+ ); + } + + if (status === "LOADING") { + return ( + + + + + ); + } + + if (status === "FAILED") { + return ( + + +
+
+

{t("scoreViewerFailedTitle")}

+ {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+
+ ); + } + + const pageIndicator = t("scoreViewerPageIndicator") + .replace("{current}", String(pageNumber)) + .replace("{total}", String(pageCount)); + + return ( + + +
+ {fileName && ( +
+
+ )} +
+ + + +
+
+
+ +
+
+ + + {pageIndicator} + + +
+
+
+ ); +} diff --git a/apps/desktop/src/features/score/pdfjs.ts b/apps/desktop/src/features/score/pdfjs.ts new file mode 100644 index 00000000..b62526c8 --- /dev/null +++ b/apps/desktop/src/features/score/pdfjs.ts @@ -0,0 +1,29 @@ +import { getDocument, GlobalWorkerOptions, type PDFDocumentLoadingTask } from "pdfjs-dist"; +import scorePdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; + +/** + * Point pdf.js at the locally bundled worker asset. + * + * The worker URL is resolved by Vite from the pinned `pdfjs-dist` package at + * build time and emitted as a same-origin asset, so it satisfies the Tauri + * `script-src 'self'` Content Security Policy. No CDN or remote script is + * ever referenced. + */ +export function configureScorePdfWorker(): void { + if (GlobalWorkerOptions.workerSrc !== scorePdfWorkerUrl) { + GlobalWorkerOptions.workerSrc = scorePdfWorkerUrl; + } +} + +/** + * Start parsing validated in-memory score PDF bytes with pdf.js. + * + * Only caller-provided bytes are accepted (validated-resource-only rule); + * this helper never fetches arbitrary URLs. The bytes are copied before they + * are handed to pdf.js because pdf.js transfers the underlying buffer to its + * worker, which would otherwise detach the caller's copy and break retries. + */ +export function loadScorePdf(data: Uint8Array): PDFDocumentLoadingTask { + configureScorePdfWorker(); + return getDocument({ data: new Uint8Array(data) }); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 4e098662..0366dd0e 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -62,6 +62,16 @@ "roleSwitcherTitle": "Role-specific View", "allRoles": "All Roles", "overlapWarning": "Clash warning", + "scoreViewerEmpty": "No score PDF attached. Attach a validated score PDF to view it here.", + "scoreViewerLoading": "Loading score PDF...", + "scoreViewerFailedTitle": "Could not display the score", + "scoreViewerRetry": "Retry", + "scoreViewerPrevPage": "Previous page", + "scoreViewerNextPage": "Next page", + "scoreViewerPageIndicator": "Page {current} of {total}", + "scoreViewerZoomIn": "Zoom in", + "scoreViewerZoomOut": "Zoom out", + "scoreViewerFitWidth": "Fit width", "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 2b59d5ee..a2725e62 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -62,6 +62,16 @@ "roleSwitcherTitle": "악기/보컬 역할", "allRoles": "전체 보기", "overlapWarning": "충돌 주의", + "scoreViewerEmpty": "첨부된 악보 PDF가 없습니다. 검증된 악보 PDF를 첨부하면 여기에 표시됩니다.", + "scoreViewerLoading": "악보 PDF를 불러오는 중...", + "scoreViewerFailedTitle": "악보를 표시할 수 없습니다", + "scoreViewerRetry": "다시 시도", + "scoreViewerPrevPage": "이전 페이지", + "scoreViewerNextPage": "다음 페이지", + "scoreViewerPageIndicator": "{total}페이지 중 {current}페이지", + "scoreViewerZoomIn": "확대", + "scoreViewerZoomOut": "축소", + "scoreViewerFitWidth": "폭 맞춤", "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 45902137..6cd70a20 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -19,7 +19,12 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: ["src/App.tsx", "src/lib/export.ts", "src/i18n/index.ts"], + include: [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx" + ], thresholds: { lines: 90, functions: 90, diff --git a/package-lock.json b/package-lock.json index 8a479475..f22ce9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", + "pdfjs-dist": "6.1.200", "react": "^19.2.4", "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", @@ -1018,6 +1019,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1140,9 +1391,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1160,9 +1408,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1180,9 +1425,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1200,9 +1442,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1220,9 +1459,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1240,9 +1476,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1482,9 +1715,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1502,9 +1732,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1522,9 +1749,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1542,9 +1766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3850,6 +4071,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",