From b899989b3e599ab18e89e25450899a239dd29fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Nov 2025 11:13:09 +0100 Subject: [PATCH] feat: add comprehensive PDF support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add jsPDF for PDF generation from text/Markdown/HTML - Add PDF.js for PDF text extraction (read PDFs) - Support PDF → Text/Markdown conversions - Support Markdown/HTML/Text → PDF conversions - Implement page-by-page PDF text extraction - Automatic pagination and formatting for generated PDFs Supported PDF operations: - Extract text from PDF files (all pages) - Convert PDF to Markdown or plain text - Create formatted PDFs from Markdown, HTML, or plain text - Automatic text wrapping and page breaks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 14 +- lib/converters/pandocService.ts | 32 +++ lib/converters/pdfService.ts | 334 ++++++++++++++++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 278 ++++++++++++++++++++++++++ 5 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 lib/converters/pdfService.ts diff --git a/README.md b/README.md index c0f72ff..8681a4a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A modern, browser-based file conversion application built with Next.js 16, Tailw - **🎬 Video Conversion** - Convert between MP4, WebM, AVI, MOV, MKV, and GIF - **🎵 Audio Conversion** - Convert between MP3, WAV, OGG, AAC, and FLAC - **🖼️ Image Conversion** - Convert between PNG, JPG, WebP, GIF, BMP, TIFF, and SVG -- **📄 Document Conversion** - Convert between Markdown, HTML, and Plain Text +- **📄 Document Conversion** - Convert between PDF, Markdown, HTML, and Plain Text - **🔒 Privacy First** - All conversions happen locally in your browser, no server uploads - **⚡ Fast & Efficient** - Powered by WebAssembly for near-native performance - **🎨 Beautiful UI** - Modern, responsive design with dark/light theme support @@ -26,6 +26,8 @@ A modern, browser-based file conversion application built with Next.js 16, Tailw - **Marked** - Markdown to HTML conversion - **Turndown** - HTML to Markdown conversion - **DOMPurify** - HTML sanitization +- **jsPDF** - PDF generation +- **PDF.js** - PDF text extraction - **Fuse.js** - Fuzzy search for format selection - **Lucide React** - Beautiful icon library @@ -115,13 +117,21 @@ convert-ui/ - **Input/Output:** PNG, JPG, WebP, GIF, BMP, TIFF, SVG ### Documents +- **PDF → Text/Markdown** - Extract text from PDF files with page-by-page processing +- **Markdown/HTML/Text → PDF** - Generate formatted PDF documents - **Markdown → HTML** - Full GitHub Flavored Markdown support with styling - **HTML → Markdown** - Clean conversion with formatting preservation - **Markdown ↔ Plain Text** - Strip or add basic formatting - **HTML → Plain Text** - Extract text content - **Plain Text → HTML** - Convert to formatted HTML document -**Note:** Uses lightweight JavaScript libraries (marked, turndown) instead of Pandoc WASM for fast, reliable conversions. +**Supported PDF Operations:** +- Read PDFs and extract all text content +- Convert extracted text to Markdown or plain text +- Create PDFs from Markdown, HTML, or plain text +- Automatic pagination and formatting + +**Note:** Uses PDF.js for reading and jsPDF for generation. Lightweight JavaScript libraries (marked, turndown) used instead of Pandoc WASM for fast, reliable conversions. ## How It Works diff --git a/lib/converters/pandocService.ts b/lib/converters/pandocService.ts index db13f7a..56be258 100644 --- a/lib/converters/pandocService.ts +++ b/lib/converters/pandocService.ts @@ -1,6 +1,13 @@ import { marked } from 'marked'; import TurndownService from 'turndown'; import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/conversion'; +import { + pdfToText, + pdfToMarkdown, + markdownToPDF, + htmlToPDF, + plainTextToPDF, +} from './pdfService'; // Import DOMPurify only on client side let DOMPurify: any; @@ -34,6 +41,31 @@ export async function convertWithPandoc( if (onProgress) onProgress(50); + // Handle PDF conversions + if (inputExt === 'pdf') { + // PDF input + if (outputFormat === 'txt') { + return await pdfToText(file, onProgress); + } else if (outputFormat === 'md' || outputFormat === 'markdown') { + return await pdfToMarkdown(file, onProgress); + } else { + throw new Error(`Conversion from PDF to ${outputFormat} not supported`); + } + } + + // Handle conversions TO PDF + if (outputFormat === 'pdf') { + if (inputExt === 'md' || inputExt === 'markdown') { + return await markdownToPDF(file, onProgress); + } else if (inputExt === 'html' || inputExt === 'htm') { + return await htmlToPDF(file, onProgress); + } else if (inputExt === 'txt') { + return await plainTextToPDF(file, onProgress); + } else { + throw new Error(`Conversion from ${inputExt} to PDF not supported`); + } + } + // Perform conversion based on input and output formats if (inputExt === 'md' || inputExt === 'markdown') { // Markdown input diff --git a/lib/converters/pdfService.ts b/lib/converters/pdfService.ts new file mode 100644 index 0000000..d1fe91d --- /dev/null +++ b/lib/converters/pdfService.ts @@ -0,0 +1,334 @@ +import { jsPDF } from 'jspdf'; +import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/conversion'; + +/** + * Extract text from PDF file + */ +export async function extractTextFromPDF(file: File, onProgress?: ProgressCallback): Promise { + if (onProgress) onProgress(10); + + // Dynamically import pdfjs-dist (client-side only) + const pdfjsLib = await import('pdfjs-dist'); + + // Set worker source + pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`; + + if (onProgress) onProgress(20); + + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + if (onProgress) onProgress(30); + + // Load PDF document + const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); + const pdf = await loadingTask.promise; + + if (onProgress) onProgress(50); + + const numPages = pdf.numPages; + let fullText = ''; + + // Extract text from each page + for (let pageNum = 1; pageNum <= numPages; pageNum++) { + const page = await pdf.getPage(pageNum); + const textContent = await page.getTextContent(); + + // Combine text items + const pageText = textContent.items + .map((item: any) => item.str) + .join(' '); + + fullText += pageText + '\n\n'; + + // Update progress + if (onProgress) { + const progress = 50 + (pageNum / numPages) * 40; + onProgress(Math.round(progress)); + } + } + + if (onProgress) onProgress(100); + + return fullText.trim(); +} + +/** + * Convert PDF to text + */ +export async function pdfToText( + file: File, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + const text = await extractTextFromPDF(file, onProgress); + const blob = new Blob([text], { type: 'text/plain' }); + + return { + success: true, + blob, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error('[PDF Converter] PDF to text error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to extract text from PDF', + duration: Date.now() - startTime, + }; + } +} + +/** + * Convert PDF to Markdown + */ +export async function pdfToMarkdown( + file: File, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + const text = await extractTextFromPDF(file, (progress) => { + if (onProgress) onProgress(progress * 0.9); // Use 90% for extraction + }); + + // Basic text to markdown conversion (paragraphs) + const markdown = text + .split('\n\n') + .filter(p => p.trim()) + .join('\n\n'); + + if (onProgress) onProgress(100); + + const blob = new Blob([markdown], { type: 'text/markdown' }); + + return { + success: true, + blob, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error('[PDF Converter] PDF to markdown error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert PDF to Markdown', + duration: Date.now() - startTime, + }; + } +} + +/** + * Convert text to PDF + */ +export async function textToPDF( + text: string, + filename: string = 'document.pdf', + onProgress?: ProgressCallback +): Promise { + if (onProgress) onProgress(20); + + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + if (onProgress) onProgress(40); + + // Set font and size + doc.setFont('helvetica'); + doc.setFontSize(12); + + // Page dimensions + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 20; + const maxWidth = pageWidth - 2 * margin; + const lineHeight = 7; + let y = margin; + + if (onProgress) onProgress(60); + + // Split text into lines + const lines = doc.splitTextToSize(text, maxWidth); + + // Add lines to PDF + for (let i = 0; i < lines.length; i++) { + // Check if we need a new page + if (y + lineHeight > pageHeight - margin) { + doc.addPage(); + y = margin; + } + + doc.text(lines[i], margin, y); + y += lineHeight; + + // Update progress + if (onProgress && i % 10 === 0) { + const progress = 60 + (i / lines.length) * 30; + onProgress(Math.round(progress)); + } + } + + if (onProgress) onProgress(90); + + // Generate PDF blob + const pdfBlob = doc.output('blob'); + + if (onProgress) onProgress(100); + + return pdfBlob; +} + +/** + * Convert Markdown to PDF + */ +export async function markdownToPDF( + file: File, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + if (onProgress) onProgress(10); + + // Read markdown content + const markdown = await file.text(); + + if (onProgress) onProgress(20); + + // Import marked for markdown parsing + const { marked } = await import('marked'); + + // Parse markdown to HTML + const html = await marked.parse(markdown); + + if (onProgress) onProgress(40); + + // Strip HTML tags for plain text + const text = html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + + if (onProgress) onProgress(60); + + // Generate PDF + const pdfBlob = await textToPDF(text, file.name.replace(/\.md$/, '.pdf'), (progress) => { + if (onProgress) onProgress(60 + progress * 0.4); + }); + + return { + success: true, + blob: pdfBlob, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error('[PDF Converter] Markdown to PDF error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert Markdown to PDF', + duration: Date.now() - startTime, + }; + } +} + +/** + * Convert HTML to PDF + */ +export async function htmlToPDF( + file: File, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + if (onProgress) onProgress(10); + + // Read HTML content + const html = await file.text(); + + if (onProgress) onProgress(30); + + // Strip HTML tags for plain text + const text = html + .replace(/)<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/<[^>]*>/g, ' ') + .replace(/ /g, ' ') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/\s+/g, ' ') + .trim(); + + if (onProgress) onProgress(50); + + // Generate PDF + const pdfBlob = await textToPDF(text, file.name.replace(/\.html?$/, '.pdf'), (progress) => { + if (onProgress) onProgress(50 + progress * 0.5); + }); + + return { + success: true, + blob: pdfBlob, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error('[PDF Converter] HTML to PDF error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert HTML to PDF', + duration: Date.now() - startTime, + }; + } +} + +/** + * Convert plain text to PDF + */ +export async function plainTextToPDF( + file: File, + onProgress?: ProgressCallback +): Promise { + const startTime = Date.now(); + + try { + if (onProgress) onProgress(10); + + const text = await file.text(); + + if (onProgress) onProgress(30); + + const pdfBlob = await textToPDF(text, file.name.replace(/\.txt$/, '.pdf'), (progress) => { + if (onProgress) onProgress(30 + progress * 0.7); + }); + + return { + success: true, + blob: pdfBlob, + duration: Date.now() - startTime, + }; + } catch (error) { + console.error('[PDF Converter] Text to PDF error:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert text to PDF', + duration: Date.now() - startTime, + }; + } +} diff --git a/package.json b/package.json index a821438..7505ebf 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "clsx": "^2.1.1", "dompurify": "^3.2.2", "fuse.js": "^7.1.0", + "jspdf": "^2.5.2", "lucide-react": "^0.553.0", "marked": "^15.0.4", "next": "^16.0.0", + "pdfjs-dist": "^4.10.38", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad9756f..343887c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + jspdf: + specifier: ^2.5.2 + version: 2.5.2 lucide-react: specifier: ^0.553.0 version: 0.553.0(react@19.2.0) @@ -35,6 +38,9 @@ importers: next: specifier: ^16.0.0 version: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + pdfjs-dist: + specifier: ^4.10.38 + version: 4.10.38 react: specifier: ^19.0.0 version: 19.2.0 @@ -137,6 +143,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -383,6 +393,70 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@napi-rs/canvas-android-arm64@0.1.82': + resolution: {integrity: sha512-bvZhN0iI54ouaQOrgJV96H2q7J3ZoufnHf4E1fUaERwW29Rz4rgicohnAg4venwBJZYjGl5Yl3CGmlAl1LZowQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.82': + resolution: {integrity: sha512-InuBHKCyuFqhNwNr4gpqazo5Xp6ltKflqOLiROn4hqAS8u21xAHyYCJRgHwd+a5NKmutFTaRWeUIT/vxWbU/iw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.82': + resolution: {integrity: sha512-aQGV5Ynn96onSXcuvYb2y7TRXD/t4CL2EGmnGqvLyeJX1JLSNisKQlWN/1bPDDXymZYSdUqbXehj5qzBlOx+RQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.82': + resolution: {integrity: sha512-YIUpmHWeHGGRhWitT1KJkgj/JPXPfc9ox8oUoyaGPxolLGPp5AxJkq8wIg8CdFGtutget968dtwmx71m8o3h5g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.82': + resolution: {integrity: sha512-AwLzwLBgmvk7kWeUgItOUor/QyG31xqtD26w1tLpf4yE0hiXTGp23yc669aawjB6FzgIkjh1NKaNS52B7/qEBQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.82': + resolution: {integrity: sha512-moZWuqepAwWBffdF4JDadt8TgBD02iMhG6I1FHZf8xO20AsIp9rB+p0B8Zma2h2vAF/YMjeFCDmW5un6+zZz9g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.82': + resolution: {integrity: sha512-w9++2df2kG9eC9LWYIHIlMLuhIrKGQYfUxs97CwgxYjITeFakIRazI9LYWgVzEc98QZ9x9GQvlicFsrROV59MQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.82': + resolution: {integrity: sha512-lZulOPwrRi6hEg/17CaqdwWEUfOlIJuhXxincx1aVzsVOCmyHf+xFq4i6liJl1P+x2v6Iz2Z/H5zHvXJCC7Bwg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.82': + resolution: {integrity: sha512-Be9Wf5RTv1w6GXlTph55K3PH3vsAh1Ax4T1FQY1UYM0QfD0yrwGdnJ8/fhqw7dEgMjd59zIbjJQC8C3msbGn5g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.82': + resolution: {integrity: sha512-LN/i8VrvxTDmEEK1c10z2cdOTkWT76LlTGtyZe5Kr1sqoSomKeExAjbilnu1+oee5lZUgS5yfZ2LNlVhCeARuw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.82': + resolution: {integrity: sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -565,6 +639,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -796,6 +873,11 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atob@2.1.2: + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} + engines: {node: '>= 4.5.0'} + hasBin: true + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -811,6 +893,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.8.28: resolution: {integrity: sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==} hasBin: true @@ -830,6 +916,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + btoa@1.2.1: + resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} + engines: {node: '>= 0.4.0'} + hasBin: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -849,6 +940,10 @@ packages: caniuse-lite@1.0.30001755: resolution: {integrity: sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -873,10 +968,16 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.2: resolution: {integrity: sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==} @@ -931,6 +1032,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dompurify@2.5.8: + resolution: {integrity: sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==} + dompurify@3.3.0: resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} @@ -1133,6 +1237,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1256,6 +1363,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1423,6 +1534,9 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf@2.5.2: + resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1663,6 +1777,13 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1700,6 +1821,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-dom@19.2.0: resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} peerDependencies: @@ -1716,6 +1840,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -1740,6 +1867,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1814,6 +1945,10 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1870,6 +2005,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -1880,6 +2019,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1954,6 +2096,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2076,6 +2221,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -2300,6 +2447,50 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@napi-rs/canvas-android-arm64@0.1.82': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.82': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.82': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.82': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.82': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.82': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.82': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.82': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.82': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.82': + optional: true + + '@napi-rs/canvas@0.1.82': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.82 + '@napi-rs/canvas-darwin-arm64': 0.1.82 + '@napi-rs/canvas-darwin-x64': 0.1.82 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.82 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.82 + '@napi-rs/canvas-linux-arm64-musl': 0.1.82 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.82 + '@napi-rs/canvas-linux-x64-gnu': 0.1.82 + '@napi-rs/canvas-linux-x64-musl': 0.1.82 + '@napi-rs/canvas-win32-x64-msvc': 0.1.82 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -2441,6 +2632,9 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.5)': dependencies: '@types/react': 19.2.5 @@ -2698,6 +2892,8 @@ snapshots: async-function@1.0.0: {} + atob@2.1.2: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -2708,6 +2904,9 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: + optional: true + baseline-browser-mapping@2.8.28: {} brace-expansion@1.1.12: @@ -2731,6 +2930,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.28.0) + btoa@1.2.1: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2752,6 +2953,18 @@ snapshots: caniuse-lite@1.0.30001755: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.4 + '@types/raf': 3.4.3 + core-js: 3.46.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2771,12 +2984,20 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.46.0: + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + csstype@3.2.2: {} damerau-levenshtein@1.0.8: {} @@ -2827,6 +3048,9 @@ snapshots: dependencies: esutils: 2.0.3 + dompurify@2.5.8: + optional: true + dompurify@3.3.0: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3184,6 +3408,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3306,6 +3532,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3470,6 +3702,18 @@ snapshots: json5@2.2.3: {} + jspdf@2.5.2: + dependencies: + '@babel/runtime': 7.28.4 + atob: 2.1.2 + btoa: 1.2.1 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.46.0 + dompurify: 2.5.8 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -3692,6 +3936,13 @@ snapshots: path-parse@1.0.7: {} + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.82 + + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3724,6 +3975,11 @@ snapshots: queue-microtask@1.2.3: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-dom@19.2.0(react@19.2.0): dependencies: react: 19.2.0 @@ -3744,6 +4000,9 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: + optional: true + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -3771,6 +4030,9 @@ snapshots: reusify@1.1.0: {} + rgbcolor@1.0.1: + optional: true + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3892,6 +4154,9 @@ snapshots: stable-hash@0.0.5: {} + stackblur-canvas@2.7.0: + optional: true + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -3964,12 +4229,20 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + tailwind-merge@3.4.0: {} tailwindcss@4.1.17: {} tapable@2.3.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4089,6 +4362,11 @@ snapshots: dependencies: punycode: 2.3.1 + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0