diff --git a/.env.example b/.env.example index 3753740..1215f00 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,4 @@ R2_SECRET_ACCESS_KEY = "your_secret_key" NPM_REGISTRY = "https://registry.npmmirror.com" -DATABASE_URL = "your neon database url" \ No newline at end of file +DATABASE_URL = "your neon database url" diff --git a/__mocks__/markdown-plugin-mock.js b/__mocks__/markdown-plugin-mock.js new file mode 100644 index 0000000..7587e68 --- /dev/null +++ b/__mocks__/markdown-plugin-mock.js @@ -0,0 +1,4 @@ +const markdownPluginMock = () => {}; + +module.exports = markdownPluginMock; +module.exports.default = markdownPluginMock; diff --git a/__mocks__/styleMock.js b/__mocks__/style-mock.js similarity index 100% rename from __mocks__/styleMock.js rename to __mocks__/style-mock.js diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx new file mode 100644 index 0000000..f623a4d --- /dev/null +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -0,0 +1,75 @@ +import jotaiStore, { workspaceMarkdownContentAtom } from "@/atoms"; +import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import MarkdownPreview from "./markdown-preview-panel"; + +jest.mock("@/lib/markdown-export", () => ({ + exportMarkdownFile: jest.fn(), + exportMarkdownPdf: jest.fn(async () => "refract-markdown.pdf"), +})); + +jest.mock("react-markdown", () => ({ + __esModule: true, + default: ({ children }: { children: string }) =>
{children}
, +})); + +jest.mock("./mermaid-chart", () => ({ + __esModule: true, + default: ({ chart }: { chart: string }) =>
{chart}
, +})); + +const exportMarkdownFileMock = exportMarkdownFile as jest.MockedFunction< + typeof exportMarkdownFile +>; +const exportMarkdownPdfMock = exportMarkdownPdf as jest.MockedFunction< + typeof exportMarkdownPdf +>; + +describe("MarkdownPreview", () => { + beforeEach(() => { + act(() => { + jotaiStore.set(workspaceMarkdownContentAtom, "# Report\n\nHello export"); + }); + }); + + afterEach(() => { + act(() => { + jotaiStore.set(workspaceMarkdownContentAtom, ""); + }); + jest.clearAllMocks(); + }); + + it("exports the raw markdown content from the export menu", async () => { + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByRole("menuitem", { name: /markdown/i })); + + expect(exportMarkdownFileMock).toHaveBeenCalledWith( + "# Report\n\nHello export", + ); + }); + + it("downloads the rendered markdown preview as a pdf", async () => { + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByRole("menuitem", { name: /pdf/i })); + + await waitFor(() => + expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ + sourceElement: expect.any(HTMLElement), + }), + ); + }); +}); diff --git a/app/chat/components/markdown-preview-panel.tsx b/app/chat/components/markdown-preview-panel.tsx index 7843402..a1f4be9 100644 --- a/app/chat/components/markdown-preview-panel.tsx +++ b/app/chat/components/markdown-preview-panel.tsx @@ -3,10 +3,19 @@ import "@/styles/markdown-preview.css"; import "katex/dist/katex.min.css"; import { workspaceMarkdownContentAtom } from "@/atoms"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; import { useAtomValue } from "jotai"; +import { ChevronDown, Download, FileText } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { memo } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -50,22 +59,80 @@ const MarkdownPreBlock = ({ const MarkdownPreview = () => { const markdownContent = useAtomValue(workspaceMarkdownContentAtom); + const previewRef = useRef(null); + const [isExportingPdf, setIsExportingPdf] = useState(false); + const hasMarkdownContent = markdownContent.trim().length > 0; + + const handleExportMarkdown = useCallback(() => { + exportMarkdownFile(markdownContent); + }, [markdownContent]); + + const handleExportPdf = useCallback(async () => { + if (isExportingPdf || !hasMarkdownContent || !previewRef.current) { + return; + } + + setIsExportingPdf(true); + + try { + await exportMarkdownPdf({ + sourceElement: previewRef.current, + }); + } finally { + setIsExportingPdf(false); + } + }, [hasMarkdownContent, isExportingPdf]); return ( - -
- +
+ + + + + + + + {isExportingPdf ? "PDF..." : "PDF"} + + + + Markdown + + + +
+ +
- {markdownContent} - -
-
+ + {markdownContent} + +
+
+ ); }; diff --git a/jest.config.ts b/jest.config.ts index 4f9f9c3..c05f416 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,8 +9,12 @@ const customJestConfig: Config = { setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy", - "^.+\\.(css|sass|scss)$": "/__mocks__/styleMock.js", + "^.+\\.(css|sass|scss)$": "/__mocks__/style-mock.js", "^@/(.*)$": "/$1", + "^rehype-katex$": "/__mocks__/markdown-plugin-mock.js", + "^rehype-raw$": "/__mocks__/markdown-plugin-mock.js", + "^remark-gfm$": "/__mocks__/markdown-plugin-mock.js", + "^remark-math$": "/__mocks__/markdown-plugin-mock.js", }, testEnvironment: "jest-environment-jsdom", testPathIgnorePatterns: ["/tests/e2e/"], diff --git a/jest.setup.ts b/jest.setup.ts index b6cc22d..f9fcdd3 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,11 @@ import "@testing-library/jest-dom"; +import { + ReadableStream, + TransformStream, + WritableStream, +} from "node:stream/web"; +import { TextDecoder, TextEncoder } from "node:util"; +import { MessageChannel, MessagePort } from "node:worker_threads"; import type { ReactNode } from "react"; import enMessages from "./messages/en.json"; @@ -43,36 +50,21 @@ jest.mock("next-intl", () => ({ if (typeof globalThis.TransformStream === "undefined") { Object.defineProperty(globalThis, "TransformStream", { - value: class TransformStream { - readable: unknown; - writable: unknown; - constructor() { - this.readable = {}; - this.writable = {}; - } - }, + value: TransformStream, writable: true, }); } if (typeof globalThis.ReadableStream === "undefined") { Object.defineProperty(globalThis, "ReadableStream", { - value: class ReadableStream { - getReader() { - return this; - } - }, + value: ReadableStream, writable: true, }); } if (typeof globalThis.WritableStream === "undefined") { Object.defineProperty(globalThis, "WritableStream", { - value: class WritableStream { - getWriter() { - return this; - } - }, + value: WritableStream, writable: true, }); } @@ -82,10 +74,40 @@ if (typeof globalThis.fetch === "undefined") { value: jest.fn(async () => ({ ok: true, status: 200, - headers: new Headers(), + headers: { + get: () => null, + }, json: async () => ({}), text: async () => "", })), writable: true, }); } + +if (typeof globalThis.TextDecoder === "undefined") { + Object.defineProperty(globalThis, "TextDecoder", { + value: TextDecoder, + writable: true, + }); +} + +if (typeof globalThis.TextEncoder === "undefined") { + Object.defineProperty(globalThis, "TextEncoder", { + value: TextEncoder, + writable: true, + }); +} + +if (typeof globalThis.MessageChannel === "undefined") { + Object.defineProperty(globalThis, "MessageChannel", { + value: MessageChannel, + writable: true, + }); +} + +if (typeof globalThis.MessagePort === "undefined") { + Object.defineProperty(globalThis, "MessagePort", { + value: MessagePort, + writable: true, + }); +} diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts new file mode 100644 index 0000000..eace7ff --- /dev/null +++ b/lib/markdown-export.test.ts @@ -0,0 +1,188 @@ +import { + createMarkdownBlob, + exportMarkdownFile, + exportMarkdownPdf, + withFileExtension, +} from "./markdown-export"; + +const readBlobText = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("load", () => { + resolve(String(reader.result)); + }); + reader.addEventListener("error", () => { + reject(reader.error); + }); + reader.readAsText(blob); + }); + +describe("markdown export helpers", () => { + it("adds the requested extension when filename has no extension", () => { + expect(withFileExtension("analysis-report", "md")).toBe( + "analysis-report.md", + ); + }); + + it("replaces a different extension with the requested extension", () => { + expect(withFileExtension("analysis-report.pdf", "md")).toBe( + "analysis-report.md", + ); + }); + + it("creates a markdown blob with utf-8 content type", async () => { + const blob = createMarkdownBlob("# Report\n\nHello"); + + expect(blob.type).toBe("text/markdown;charset=utf-8"); + await expect(readBlobText(blob)).resolves.toBe("# Report\n\nHello"); + }); + + it("downloads the raw markdown file", () => { + const createObjectURL = jest.fn(() => "blob:markdown"); + const revokeObjectURL = jest.fn(); + const originalCreateElement = document.createElement.bind(document); + const anchor = document.createElement("a"); + const clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => {}); + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation(((tagName: string) => + tagName === "a" + ? anchor + : originalCreateElement(tagName)) as typeof document.createElement); + + const filename = exportMarkdownFile("# Report", { + document, + filename: "analysis-report", + url: { + createObjectURL, + revokeObjectURL, + }, + }); + + expect(filename).toBe("analysis-report.md"); + expect(createObjectURL).toHaveBeenCalled(); + expect(anchor.download).toBe("analysis-report.md"); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:markdown"); + + createElementSpy.mockRestore(); + clickSpy.mockRestore(); + }); + + it("captures the preview with snapdom and downloads a pdf", async () => { + const createObjectURL = jest.fn(() => "blob:pdf"); + const revokeObjectURL = jest.fn(); + const originalCreateElement = document.createElement.bind(document); + const anchor = document.createElement("a"); + const drawImage = jest.fn(); + const sourceElement = document.createElement("article"); + const canvas = document.createElement("canvas"); + const clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => {}); + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation(((tagName: string) => + tagName === "a" + ? anchor + : originalCreateElement(tagName)) as typeof document.createElement); + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toBlobSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toBlob") + .mockImplementation((callback) => { + callback?.(new Blob(["png"], { type: "image/png" })); + }); + const captureElement = jest.fn(async () => ({ + toCanvas: async () => canvas, + })); + const addPage = jest.fn(() => ({ + drawImage: jest.fn(), + })); + const embedPng = jest.fn(async () => ({})); + const save = jest.fn(async () => new Uint8Array([4, 5, 6])); + const createPdfDocument = jest.fn(async () => ({ + addPage, + embedPng, + save, + })); + + sourceElement.innerHTML = "

Report

"; + canvas.width = 200; + canvas.height = 400; + + const filename = await exportMarkdownPdf({ + captureElement, + createPdfDocument, + document, + filename: "analysis-report", + sourceElement, + url: { + createObjectURL, + revokeObjectURL, + }, + }); + + expect(captureElement).toHaveBeenCalledWith( + sourceElement, + expect.objectContaining({ + backgroundColor: "#ffffff", + embedFonts: true, + scale: 2, + }), + ); + expect(createPdfDocument).toHaveBeenCalled(); + expect(embedPng).toHaveBeenCalled(); + expect(save).toHaveBeenCalled(); + expect(filename).toBe("analysis-report.pdf"); + expect(anchor.download).toBe("analysis-report.pdf"); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:pdf"); + + createElementSpy.mockRestore(); + getContextSpy.mockRestore(); + toBlobSpy.mockRestore(); + clickSpy.mockRestore(); + }); + + it("returns false when the snapdom source element is empty", async () => { + const sourceElement = document.createElement("article"); + + await expect( + exportMarkdownPdf({ + filename: "analysis-report", + sourceElement, + }), + ).resolves.toBe(false); + }); + + it("throws when snapdom export cannot create a png blob", async () => { + const captureElement = jest.fn(async () => ({ + toCanvas: async () => document.createElement("canvas"), + })); + const sourceElement = document.createElement("article"); + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ + drawImage: jest.fn(), + } as unknown as CanvasRenderingContext2D); + const toBlobSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toBlob") + .mockImplementation((callback) => { + callback?.(null); + }); + + sourceElement.innerHTML = "

Report

"; + + await expect( + exportMarkdownPdf({ + captureElement, + filename: "analysis-report", + sourceElement, + }), + ).rejects.toThrow("Failed to convert SnapDOM canvas to PNG."); + + getContextSpy.mockRestore(); + toBlobSpy.mockRestore(); + }); +}); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts new file mode 100644 index 0000000..ec54ddd --- /dev/null +++ b/lib/markdown-export.ts @@ -0,0 +1,265 @@ +const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; +const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; +const PDF_PAGE_HEIGHT = 841.89; +const PDF_PAGE_MARGIN = 36; +const PDF_PAGE_WIDTH = 595.28; +const SNAPDOM_PDF_SCALE = 2; + +type FileExtension = "md" | "pdf"; + +type DownloadBlobOptions = { + document?: Document; + url?: Pick; +}; + +type MarkdownFileExportOptions = DownloadBlobOptions & { + filename?: string; +}; + +type SnapdomCanvasCapture = { + toCanvas: () => Promise; +}; + +type SnapdomCaptureElement = ( + element: HTMLElement, + options: Record, +) => Promise; + +type SnapdomPdfDocumentPage = { + drawImage: ( + image: unknown, + options: { + height: number; + width: number; + x: number; + y: number; + }, + ) => void; +}; + +type SnapdomPdfDocument = { + addPage: (size: [number, number]) => SnapdomPdfDocumentPage; + embedPng: (bytes: Uint8Array) => Promise; + save: () => Promise; +}; + +type MarkdownPdfExportOptions = DownloadBlobOptions & { + captureElement?: SnapdomCaptureElement; + createPdfDocument?: () => Promise; + filename?: string; + sourceElement: HTMLElement; +}; + +const withFileExtension = (filename: string, extension: FileExtension) => { + const cleanFilename = filename.trim() || MARKDOWN_EXPORT_BASENAME; + const normalizedExtension = extension.replace(/^\./, ""); + const filenameWithoutExtension = cleanFilename.replace(/\.[^/.]+$/, ""); + + return `${filenameWithoutExtension}.${normalizedExtension}`; +}; + +const createMarkdownBlob = (content: string) => + new Blob([content], { type: MARKDOWN_MIME_TYPE }); + +const downloadBlob = ( + blob: Blob, + filename: string, + options: DownloadBlobOptions = {}, +) => { + const targetDocument = options.document ?? document; + const urlApi = options.url ?? URL; + const objectUrl = urlApi.createObjectURL(blob); + const link = targetDocument.createElement("a"); + + link.href = objectUrl; + link.download = filename; + link.rel = "noopener"; + + targetDocument.body.append(link); + link.click(); + link.remove(); + urlApi.revokeObjectURL(objectUrl); +}; + +const exportMarkdownFile = ( + content: string, + options: MarkdownFileExportOptions = {}, +) => { + const filename = withFileExtension( + options.filename ?? MARKDOWN_EXPORT_BASENAME, + "md", + ); + const blob = createMarkdownBlob(content); + + downloadBlob(blob, filename, options); + + return filename; +}; + +const hasExportableElementContent = (sourceElement: HTMLElement) => { + const visibleText = + "innerText" in sourceElement && typeof sourceElement.innerText === "string" + ? sourceElement.innerText + : (sourceElement.textContent ?? ""); + + return Boolean(visibleText.trim() || sourceElement.innerHTML.trim()); +}; + +const createCanvasSlice = ( + sourceCanvas: HTMLCanvasElement, + offsetY: number, + sliceHeight: number, +) => { + const ownerDocument = sourceCanvas.ownerDocument ?? document; + const sliceCanvas = ownerDocument.createElement("canvas"); + const context = sliceCanvas.getContext("2d"); + + sliceCanvas.width = sourceCanvas.width; + sliceCanvas.height = sliceHeight; + + if (!context) { + throw new Error("Unable to create SnapDOM PDF canvas context."); + } + + context.drawImage( + sourceCanvas, + 0, + offsetY, + sourceCanvas.width, + sliceHeight, + 0, + 0, + sourceCanvas.width, + sliceHeight, + ); + + return sliceCanvas; +}; + +const createCanvasBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("Failed to convert SnapDOM canvas to PNG.")); + return; + } + + resolve(blob); + }, "image/png"); + }); + +const readBlobBytes = async (blob: Blob) => { + if (typeof blob.arrayBuffer === "function") { + return new Uint8Array(await blob.arrayBuffer()); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("load", () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }); + reader.addEventListener("error", () => { + reject(reader.error); + }); + reader.readAsArrayBuffer(blob); + }); +}; + +const createCanvasBytes = async (canvas: HTMLCanvasElement) => + readBlobBytes(await createCanvasBlob(canvas)); + +const addCanvasPagesToPdf = async ( + canvas: HTMLCanvasElement, + pdfDocument: SnapdomPdfDocument, +) => { + const printableHeight = PDF_PAGE_HEIGHT - PDF_PAGE_MARGIN * 2; + const printableWidth = PDF_PAGE_WIDTH - PDF_PAGE_MARGIN * 2; + const pageCanvasHeight = Math.floor( + (printableHeight * canvas.width) / printableWidth, + ); + + for (let offsetY = 0; offsetY < canvas.height; offsetY += pageCanvasHeight) { + const sliceHeight = Math.min(pageCanvasHeight, canvas.height - offsetY); + const pageCanvas = createCanvasSlice(canvas, offsetY, sliceHeight); + const pageBytes = await createCanvasBytes(pageCanvas); + const pageImage = await pdfDocument.embedPng(pageBytes); + const page = pdfDocument.addPage([PDF_PAGE_WIDTH, PDF_PAGE_HEIGHT]); + const pageImageHeight = (sliceHeight * printableWidth) / canvas.width; + + page.drawImage(pageImage, { + height: pageImageHeight, + width: printableWidth, + x: PDF_PAGE_MARGIN, + y: PDF_PAGE_HEIGHT - PDF_PAGE_MARGIN - pageImageHeight, + }); + } +}; + +const createSnapdomCaptureElement: SnapdomCaptureElement = async ( + element, + options, +) => { + const { snapdom } = await import("@zumer/snapdom"); + return snapdom(element, options) as Promise; +}; + +const createSnapdomPdfDocument = async (): Promise => { + const { PDFDocument } = await import("pdf-lib"); + return (await PDFDocument.create()) as unknown as SnapdomPdfDocument; +}; + +const exportMarkdownPdf = async ({ + captureElement = createSnapdomCaptureElement, + createPdfDocument = createSnapdomPdfDocument, + document: targetDocument, + filename, + sourceElement, + url, +}: MarkdownPdfExportOptions) => { + if (!hasExportableElementContent(sourceElement)) { + return false; + } + + const capture = await captureElement(sourceElement, { + backgroundColor: "#ffffff", + embedFonts: true, + fast: false, + scale: SNAPDOM_PDF_SCALE, + }); + const canvas = await capture.toCanvas(); + const pdfDocument = await createPdfDocument(); + + await addCanvasPagesToPdf(canvas, pdfDocument); + + const pdfBytes = await pdfDocument.save(); + const normalizedPdfBytes = new Uint8Array(pdfBytes); + const resolvedFilename = withFileExtension( + filename ?? MARKDOWN_EXPORT_BASENAME, + "pdf", + ); + + downloadBlob( + new Blob([normalizedPdfBytes], { type: "application/pdf" }), + resolvedFilename, + { + document: targetDocument, + url, + }, + ); + + return resolvedFilename; +}; + +export { + createMarkdownBlob, + exportMarkdownFile, + exportMarkdownPdf, + withFileExtension, +}; +export type { + MarkdownFileExportOptions, + MarkdownPdfExportOptions, + SnapdomCaptureElement, + SnapdomPdfDocument, +}; diff --git a/package.json b/package.json index da9494d..f774b52 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack -p 3001", "build": "next build --turbopack", "start": "next start", "db:generate": "dotenv -e .env.local -- drizzle-kit generate", @@ -41,6 +41,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.96.2", "@tanstack/react-virtual": "^3.13.23", + "@zumer/snapdom": "^2.9.0", "ai": "^6.0.146", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", @@ -58,6 +59,7 @@ "next": "16.1.1", "next-intl": "^4.9.0", "next-themes": "^0.4.6", + "pdf-lib": "^1.17.1", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3471ae..f50bce1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@zumer/snapdom': + specifier: ^2.9.0 + version: 2.9.0 ai: specifier: ^6.0.146 version: 6.0.146(zod@4.3.6) @@ -119,6 +122,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2251,6 +2257,12 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@peculiar/asn1-android@2.6.0': resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} @@ -4154,6 +4166,9 @@ packages: '@types/react': optional: true + '@zumer/snapdom@2.9.0': + resolution: {integrity: sha512-ksDM5gXQmu1isBof/YumD2HeIHf4ImTMfba9xE4JyDbARhcZCWHFdjW8oZV3hSKdWpQILEspNq5MrAfwEfzpIw==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -6637,6 +6652,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6708,6 +6726,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -10217,6 +10238,14 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@peculiar/asn1-android@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -12318,6 +12347,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.0 + '@zumer/snapdom@2.9.0': {} + abstract-logging@2.0.1: {} accepts@2.0.0: @@ -13937,7 +13968,7 @@ snapshots: '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -14531,7 +14562,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 make-error@1.3.6: {} @@ -15219,6 +15250,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -15285,6 +15318,13 @@ snapshots: pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + peberminta@0.9.0: {} pg-cloudflare@1.3.0: