diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts new file mode 100644 index 00000000000..236ea5e51d3 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import { PDFDocument } from 'pdf-lib' +import { getDocument, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs' +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' +import { MarkdownPdfLimitError, renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' + +async function pdfPagesText(buffer: Buffer): Promise { + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise + try { + return await Promise.all( + Array.from({ length: document.numPages }, async (_, index) => { + const page = await document.getPage(index + 1) + const content = await page.getTextContent() + return content.items.map((item) => ('str' in item ? item.str : '')).join(' ') + }) + ) + } finally { + await document.destroy() + } +} + +describe('Markdown PDF rendering', () => { + it('creates a valid multi-page PDF with GFM and an embedded image', async () => { + const imageKey = 'workspace/ws-1/editor-image.png' + const imageUrl = `/api/files/serve/${encodeURIComponent(imageKey)}?context=workspace` + const image = await sharp({ + create: { + width: 120, + height: 60, + channels: 3, + background: '#4f46e5', + }, + }) + .png() + .toBuffer() + const repeatedParagraphs = Array.from( + { length: 70 }, + (_, index) => `Paragraph ${index + 1} with **bold**, _italic_, and \`inline code\`.` + ).join('\n\n') + const markdown = `# Export title + +> A useful blockquote with a [link](https://sim.ai). + +Smart quotes “work” and Greek Ω stays readable. + +Common emoji stay readable too: 🚀 😀 + +中文排版应该清晰易读。 العربية يجب أن تكون متصلة ومقروءة. हिन्दी पाठ स्पष्ट और पठनीय होना चाहिए। עברית צריכה להיות ברורה וקריאה. + +- First item +- Second item + +| Name | Value | +| --- | ---: | +| Alpha | 1 | +| Beta | 2 | + +\`\`\`ts +const exported = true +\`\`\` + +![Embedded image](${imageUrl}) + +Resized image + +${repeatedParagraphs}` + + const buffer = await renderMarkdownPdf({ + markdown, + title: 'Export title', + images: new Map([[`key:${imageKey}`, image]]), + }) + + expect(buffer.subarray(0, 4).toString()).toBe('%PDF') + expect(buffer.length).toBeGreaterThan(1_000) + + const document = await PDFDocument.load(buffer) + expect(document.getTitle()).toBe('Export title') + expect(document.getPageCount()).toBeGreaterThan(1) + + const text = (await pdfPagesText(buffer)).join(' ') + expect(text).toContain('中文排版应该清晰易读') + expect(text).toContain('العربية') + // PDF extractors expose visually positioned Indic vowel marks before their base character. + expect(text).toMatch(/[\u0900-\u097f]{4,}/u) + expect(text).toContain('עברית') + expect(text).toContain('[emoji U+1F680]') + expect(text).toContain('[emoji U+1F600]') + expect(text).not.toContain('�') + expect(text).not.toContain('Image: Embedded image') + + const parsed = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }).promise + try { + let imagePaints = 0 + for (let pageNumber = 1; pageNumber <= parsed.numPages; pageNumber += 1) { + const operators = await (await parsed.getPage(pageNumber)).getOperatorList() + imagePaints += operators.fnArray.filter( + (operator) => + operator === OPS.paintImageXObject || operator === OPS.paintInlineImageXObject + ).length + } + expect(imagePaints).toBeGreaterThanOrEqual(2) + } finally { + await parsed.destroy() + } + }) + + it('lets a long table paginate without moving the whole table to a later page', async () => { + const rows = Array.from( + { length: 90 }, + (_, index) => + `| Row ${index + 1} | Description ${index + 1} with enough text to exercise wrapping |` + ).join('\n') + const buffer = await renderMarkdownPdf({ + markdown: `# Table report\n\n| Name | Value |\n| --- | --- |\n${rows}`, + title: 'Table report', + }) + + const pages = await pdfPagesText(buffer) + const tablePages = pages.filter((page) => page.includes('Row ')) + expect(tablePages.length).toBeGreaterThan(1) + expect(pages[0]).toContain('Row 1') + expect(pages.join(' ')).toContain('Row 90') + }) + + it('allows a table row taller than a page to wrap without losing its content', async () => { + const cell = `ROW-START ${'wrapping table content '.repeat(900)} ROW-END` + const buffer = await renderMarkdownPdf({ + markdown: `| Name | Value |\n| --- | --- |\n| Tall row | ${cell} |`, + title: 'Tall table row', + }) + + const pages = await pdfPagesText(buffer) + expect(pages.length).toBeGreaterThan(1) + expect(pages.join(' ')).toContain('ROW-START') + expect(pages.join(' ')).toContain('ROW-END') + }) + + it('renders a table that contains only a header', async () => { + const buffer = await renderMarkdownPdf({ + markdown: '| Name | Value |\n| --- | --- |', + title: 'Header-only table', + }) + + const text = (await pdfPagesText(buffer)).join(' ') + expect(text).toContain('Name') + expect(text).toContain('Value') + }) + + it('preserves links on linked-image fallbacks', async () => { + const destination = 'https://sim.ai/docs' + const buffer = await renderMarkdownPdf({ + markdown: `[![Documentation badge](https://example.com/badge.svg)](${destination})`, + title: 'Linked image', + }) + + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }) + .promise + try { + const annotations = await (await document.getPage(1)).getAnnotations() + expect(annotations.some((annotation) => annotation.url === destination)).toBe(true) + } finally { + await document.destroy() + } + }) + + it('preserves GFM table-cell alignment', async () => { + const buffer = await renderMarkdownPdf({ + markdown: `| LEFTVALUE | +| :--- | +| left | + +| CENTERVALUE | +| :---: | +| center | + +| RIGHTVALUE | +| ---: | +| right |`, + title: 'Aligned tables', + }) + + const document = await getDocument({ data: new Uint8Array(buffer), disableWorker: true }) + .promise + try { + const content = await (await document.getPage(1)).getTextContent() + const textX = (value: string) => { + const item = content.items.find( + (candidate) => 'str' in candidate && candidate.str === value + ) + expect(item && 'transform' in item).toBe(true) + return item && 'transform' in item ? item.transform[4] : 0 + } + const leftX = textX('LEFTVALUE') + const centerX = textX('CENTERVALUE') + const rightX = textX('RIGHTVALUE') + + expect(centerX - leftX).toBeGreaterThan(100) + expect(rightX - centerX).toBeGreaterThan(100) + } finally { + await document.destroy() + } + }) + + it('falls back instead of decoding an image above the pixel ceiling', async () => { + const oversizedSvg = Buffer.from( + '' + ) + + const buffer = await renderMarkdownPdf({ + markdown: '![Too large](/api/files/view/image-1)', + title: 'Bounded image', + images: new Map([['id:image-1', oversizedSvg]]), + }) + + expect(buffer.subarray(0, 4).toString()).toBe('%PDF') + expect((await PDFDocument.load(buffer)).getPageCount()).toBe(1) + expect((await pdfPagesText(buffer)).join(' ')).toContain('Image: Too large') + }) + + it('rejects a pathological number of document blocks before PDF layout', async () => { + const markdown = Array.from({ length: 3_001 }, (_, index) => `Paragraph ${index}`).join('\n\n') + + await expect(renderMarkdownPdf({ markdown, title: 'Too many blocks' })).rejects.toBeInstanceOf( + MarkdownPdfLimitError + ) + }) +}) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx new file mode 100644 index 00000000000..f50ca816652 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -0,0 +1,744 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import type { ReactNode } from 'react' +import { + Document, + Font, + Image, + Link, + Page, + renderToBuffer, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer' +import type { JSONContent } from '@tiptap/core' +import sharp from 'sharp' +import { + type EmbeddedFileRef, + extractEmbeddedFileRef, +} from '@/lib/uploads/utils/embedded-image-ref' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +type PdfImage = { data: Buffer; format: 'png' } +type ResolvedPdfImageRef = Exclude + +/** PDF-local map key for either embedded workspace-image reference spelling. */ +export function markdownPdfImageKey(ref: ResolvedPdfImageRef): string { + return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` +} + +interface GlyphFont { + hasGlyphForCodePoint(codePoint: number): boolean +} + +interface PdfFont { + family: string + glyphs: GlyphFont +} + +const require = createRequire(import.meta.url) + +/** + * Ensure a DOM exists for the TipTap Markdown parser used by this PDF-only server module. The + * renderer is loaded lazily by the PDF export branch, so this setup never affects ordinary exports. + * Re-check both globals on every call to avoid accepting the partial `document` exposed by Next. + */ +function ensureDomForMarkdownPdf(): void { + if (typeof window !== 'undefined' && typeof document !== 'undefined') return + const { JSDOM } = require('jsdom') as typeof import('jsdom') + const { window: jsdomWindow } = new JSDOM('') + // double-cast-allowed: assigning the jsdom shims onto the global needs an + // index-signature view of `globalThis`, whose declared type has none. + const globals = globalThis as unknown as Record + globals.window = jsdomWindow + globals.document = jsdomWindow.document + globals.navigator ??= jsdomWindow.navigator +} + +function resolveBrandFont(filename: string): string { + const candidates = [ + join(process.cwd(), 'public', 'brand', 'fonts', filename), + join(process.cwd(), 'apps', 'sim', 'public', 'brand', 'fonts', filename), + ] + const font = candidates.find(existsSync) + if (!font) throw new Error(`PDF font not found: ${filename}`) + return font +} + +function resolveDependencyFont(packageName: string, filename: string): string { + const relativePath = join(packageName, 'files', filename) + const candidates = [ + join(process.cwd(), 'node_modules', relativePath), + join(process.cwd(), '..', '..', 'node_modules', relativePath), + join(process.cwd(), 'apps', 'sim', 'node_modules', relativePath), + ] + const font = candidates.find(existsSync) + if (!font) throw new Error(`PDF dependency font not found: ${filename}`) + return font +} + +const GEIST_REGULAR = resolveBrandFont('Geist-Regular.ttf') +const GEIST_MEDIUM = resolveBrandFont('Geist-Medium.ttf') +const UNIFONT_REGULAR = resolveDependencyFont( + '@fontsource/unifont', + 'unifont-latin-400-normal.woff' +) +const NOTO_ARABIC = resolveDependencyFont( + '@fontsource/noto-sans-arabic', + 'noto-sans-arabic-arabic-400-normal.woff' +) +const NOTO_ARABIC_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-arabic', + 'noto-sans-arabic-arabic-700-normal.woff' +) +const NOTO_DEVANAGARI = resolveDependencyFont( + '@fontsource/noto-sans-devanagari', + 'noto-sans-devanagari-devanagari-400-normal.woff' +) +const NOTO_DEVANAGARI_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-devanagari', + 'noto-sans-devanagari-devanagari-700-normal.woff' +) +const NOTO_HEBREW = resolveDependencyFont( + '@fontsource/noto-sans-hebrew', + 'noto-sans-hebrew-hebrew-400-normal.woff' +) +const NOTO_HEBREW_BOLD = resolveDependencyFont( + '@fontsource/noto-sans-hebrew', + 'noto-sans-hebrew-hebrew-700-normal.woff' +) + +/** + * PDF images never render wider than the A4 content box. These PDF-specific ceilings reject + * decompression bombs well below Sharp's broad application default, then bound normalized output. + */ +const MAX_PDF_IMAGE_DIMENSION = 1568 +const MAX_PDF_IMAGE_INPUT_PIXELS = 40_000_000 +const MAX_PDF_TOTAL_INPUT_PIXELS = 80_000_000 +const MAX_PDF_TOTAL_OUTPUT_PIXELS = 25_000_000 +const MAX_PDF_IMAGE_BYTES = 12 * 1024 * 1024 +const MAX_PDF_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024 +const MAX_PDF_DOCUMENT_NODES = 20_000 +const MAX_PDF_TOP_LEVEL_BLOCKS = 3_000 +const PDF_TABLE_CONTENT_WIDTH = 499 + +Font.register({ + family: 'Geist', + fonts: [ + { src: GEIST_REGULAR, fontStyle: 'normal', fontWeight: 400 }, + { src: GEIST_REGULAR, fontStyle: 'italic', fontWeight: 400 }, + { src: GEIST_MEDIUM, fontStyle: 'normal', fontWeight: 700 }, + { src: GEIST_MEDIUM, fontStyle: 'italic', fontWeight: 700 }, + ], +}) +function registerFallbackFont(family: string, src: string, boldSrc = src): void { + Font.register({ + family, + fonts: [ + { src, fontStyle: 'normal', fontWeight: 400 }, + { src, fontStyle: 'italic', fontWeight: 400 }, + { src: boldSrc, fontStyle: 'normal', fontWeight: 700 }, + { src: boldSrc, fontStyle: 'italic', fontWeight: 700 }, + ], + }) +} + +registerFallbackFont('NotoSansArabic', NOTO_ARABIC, NOTO_ARABIC_BOLD) +registerFallbackFont('NotoSansDevanagari', NOTO_DEVANAGARI, NOTO_DEVANAGARI_BOLD) +registerFallbackFont('NotoSansHebrew', NOTO_HEBREW, NOTO_HEBREW_BOLD) +registerFallbackFont('Unifont', UNIFONT_REGULAR) + +const { openSync } = require('fontkit') as { openSync(path: string): GlyphFont } +const geistGlyphs = openSync(GEIST_REGULAR) +const staticFallbackFonts: PdfFont[] = [ + { family: 'NotoSansArabic', glyphs: openSync(NOTO_ARABIC) }, + { family: 'NotoSansDevanagari', glyphs: openSync(NOTO_DEVANAGARI) }, + { family: 'NotoSansHebrew', glyphs: openSync(NOTO_HEBREW) }, +] +const unifont: PdfFont = { family: 'Unifont', glyphs: openSync(UNIFONT_REGULAR) } + +export interface MarkdownPdfInput { + markdown: string + title: string + images?: ReadonlyMap +} + +export class MarkdownPdfLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'MarkdownPdfLimitError' + } +} + +interface MarkdownDocumentProps { + document: JSONContent + title: string + images: ReadonlyMap +} + +interface FontRun { + family: string + text: string +} + +const styles = StyleSheet.create({ + page: { + backgroundColor: '#ffffff', + color: '#171717', + fontFamily: 'Geist', + fontSize: 10.5, + lineHeight: 1.45, + paddingBottom: 48, + paddingHorizontal: 48, + paddingTop: 48, + }, + paragraph: { marginBottom: 9 }, + h1: { fontSize: 23, fontWeight: 700, lineHeight: 1.2, marginBottom: 12, marginTop: 4 }, + h2: { fontSize: 19, fontWeight: 700, lineHeight: 1.25, marginBottom: 10, marginTop: 8 }, + h3: { fontSize: 16, fontWeight: 700, lineHeight: 1.3, marginBottom: 8, marginTop: 7 }, + h4: { fontSize: 13.5, fontWeight: 700, lineHeight: 1.35, marginBottom: 7, marginTop: 6 }, + h5: { fontSize: 11.5, fontWeight: 700, marginBottom: 6, marginTop: 5 }, + h6: { color: '#404040', fontSize: 10.5, fontWeight: 700, marginBottom: 5, marginTop: 4 }, + strong: { fontWeight: 700 }, + emphasis: { fontStyle: 'italic' }, + deleted: { textDecoration: 'line-through' }, + highlighted: { backgroundColor: '#fff3bf' }, + inlineCode: { + backgroundColor: '#f1f3f5', + color: '#24292f', + fontSize: 9, + }, + link: { color: '#0969da', textDecoration: 'underline' }, + mention: { backgroundColor: '#f1f3f5', color: '#404040' }, + blockquote: { + borderLeftColor: '#b6bec8', + borderLeftWidth: 2, + color: '#4b5563', + marginBottom: 9, + paddingLeft: 10, + }, + codeBlock: { + backgroundColor: '#f6f8fa', + borderColor: '#d0d7de', + borderRadius: 3, + borderWidth: 0.5, + color: '#24292f', + fontSize: 8.5, + lineHeight: 1.35, + marginBottom: 10, + padding: 9, + }, + list: { marginBottom: 8 }, + listItem: { flexDirection: 'row', marginBottom: 3 }, + listMarker: { flexShrink: 0, width: 22 }, + listBody: { flexBasis: 0, flexGrow: 1 }, + listText: { marginBottom: 2 }, + rule: { borderBottomColor: '#d0d7de', borderBottomWidth: 0.75, marginBottom: 12, marginTop: 4 }, + table: { borderColor: '#b6bec8', borderLeftWidth: 0.5, borderTopWidth: 0.5, marginBottom: 11 }, + tableRow: { flexDirection: 'row' }, + tableCell: { + borderBottomWidth: 0.5, + borderColor: '#b6bec8', + borderRightWidth: 0.5, + flexBasis: 0, + flexGrow: 1, + fontSize: 8.5, + minWidth: 0, + padding: 5, + }, + tableCellCenter: { textAlign: 'center' }, + tableCellRight: { textAlign: 'right' }, + tableHeader: { backgroundColor: '#f1f3f5', fontWeight: 700 }, + imageBlock: { marginBottom: 11 }, + image: { maxHeight: 430, objectFit: 'contain', width: '100%' }, + imageFallback: { + backgroundColor: '#f6f8fa', + color: '#57606a', + fontStyle: 'italic', + marginBottom: 9, + padding: 8, + }, + sourceFallback: { + backgroundColor: '#f6f8fa', + color: '#57606a', + fontSize: 8.5, + marginBottom: 9, + padding: 8, + }, +}) + +function fontRuns(value: string): FontRun[] { + const characters = Array.from(value, (character) => { + const codePoint = character.codePointAt(0) + const fallback = + codePoint === undefined + ? undefined + : (staticFallbackFonts.find(({ glyphs }) => glyphs.hasGlyphForCodePoint(codePoint)) ?? + (unifont.glyphs.hasGlyphForCodePoint(codePoint) ? unifont : undefined)) + const hasGeistGlyph = codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) + const unsupported = !hasGeistGlyph && !fallback + const family = !hasGeistGlyph && fallback ? fallback.family : 'Geist' + return { + family, + neutral: /^[\p{N}\p{P}\p{Z}\s]$/u.test(character), + // React PDF/fontkit does not shape astral emoji correctly from bundled monochrome fonts and + // cannot embed the platform's color font. Preserve unsupported emoji as an explicit code-point + // label instead of silently corrupting it into an unrelated glyph or replacement character. + text: !unsupported + ? character + : codePoint !== undefined && codePoint > 0xffff + ? `[emoji U+${codePoint.toString(16).toUpperCase()}]` + : '�', + } + }) + + let index = 0 + while (index < characters.length) { + if (!characters[index].neutral) { + index += 1 + continue + } + + const start = index + while (index < characters.length && characters[index].neutral) index += 1 + + const previousFamily = characters[start - 1]?.family + const nextFamily = characters[index]?.family + const inheritedFamily = + previousFamily && + previousFamily !== 'Geist' && + (previousFamily === nextFamily || index === characters.length) + ? previousFamily + : undefined + + if (inheritedFamily) { + for (let neutralIndex = start; neutralIndex < index; neutralIndex += 1) { + if (characters[neutralIndex].family === 'Geist') { + characters[neutralIndex].family = inheritedFamily + } + } + } + } + + const runs: FontRun[] = [] + for (const { family, text } of characters) { + const current = runs.at(-1) + if (current?.family === family) current.text += text + else runs.push({ family, text }) + } + return runs +} + +function renderText(value: string, keyPrefix: string): ReactNode[] { + return fontRuns(value).map((run, index) => + run.family === 'Geist' ? ( + run.text + ) : ( + + {run.text} + + ) + ) +} + +function nodeText(node: JSONContent): string { + if (typeof node.text === 'string') return node.text + return (node.content ?? []).map(nodeText).join('') +} + +function assertDocumentWithinLimits(document: JSONContent): void { + if ((document.content?.length ?? 0) > MAX_PDF_TOP_LEVEL_BLOCKS) { + throw new MarkdownPdfLimitError('This document has too many blocks to export as PDF.') + } + + let nodeCount = 0 + const visit = (node: JSONContent): void => { + nodeCount += 1 + if (nodeCount > MAX_PDF_DOCUMENT_NODES) { + throw new MarkdownPdfLimitError('This document is too complex to export as PDF.') + } + for (const child of node.content ?? []) visit(child) + } + visit(document) +} + +function stringAttr(node: JSONContent, name: string): string | undefined { + const value = node.attrs?.[name] + return typeof value === 'string' ? value : undefined +} + +function safeLink(href: string): string | undefined { + try { + const url = new URL(href) + return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? href : undefined + } catch { + return undefined + } +} + +function renderInlineNode(node: JSONContent, key: string): ReactNode { + if (node.type === 'hardBreak') return '\n' + if (node.type === 'mention') { + return ( + + {renderText(stringAttr(node, 'label') ?? 'Mention', key)} + + ) + } + if (node.type === 'rawInlineHtml' || node.type === 'footnoteRef') { + return ( + + {renderText(nodeText(node), key)} + + ) + } + if (node.type !== 'text') return renderText(nodeText(node), key) + + const marks = node.marks ?? [] + const textStyles: Array< + | typeof styles.strong + | typeof styles.emphasis + | typeof styles.deleted + | typeof styles.inlineCode + | typeof styles.highlighted + > = [] + for (const mark of marks) { + switch (mark.type) { + case 'bold': + textStyles.push(styles.strong) + break + case 'italic': + textStyles.push(styles.emphasis) + break + case 'strike': + textStyles.push(styles.deleted) + break + case 'code': + textStyles.push(styles.inlineCode) + break + case 'highlight': + textStyles.push(styles.highlighted) + break + } + } + const content = renderText(node.text ?? '', key) + const linkMark = marks.find((mark) => mark.type === 'link') + const href = typeof linkMark?.attrs?.href === 'string' ? safeLink(linkMark.attrs.href) : undefined + if (href) { + return ( + + {content} + + ) + } + return textStyles.length > 0 ? ( + + {content} + + ) : ( + content + ) +} + +function renderInline(keyPrefix: string, nodes: JSONContent[] = []): ReactNode[] { + return nodes.map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`)) +} + +function renderImage( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + const src = stringAttr(node, 'src') ?? '' + const rawHref = stringAttr(node, 'href') + const href = rawHref ? safeLink(rawHref) : undefined + const ref = extractEmbeddedFileRef(src) + const image = ref ? images.get(markdownPdfImageKey(ref)) : undefined + if (!image) { + const alt = stringAttr(node, 'alt') + const fallback = ( + + {renderText(alt ? `Image: ${alt}` : 'Image unavailable', key)} + + ) + if (href) { + return ( + + {fallback} + + ) + } + return ( + + {renderText(alt ? `Image: ${alt}` : 'Image unavailable', key)} + + ) + } + + const requestedWidth = Number(stringAttr(node, 'width')) + const width = Number.isFinite(requestedWidth) + ? Math.min(Math.max(requestedWidth, 1), PDF_TABLE_CONTENT_WIDTH) + : undefined + const renderedImage = ( + + + + ) + if (href) { + return ( + + {renderedImage} + + ) + } + return ( + + + + ) +} + +function renderList( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + const ordered = node.type === 'orderedList' + const task = node.type === 'taskList' + const start = typeof node.attrs?.start === 'number' ? node.attrs.start : 1 + return ( + + {(node.content ?? []).map((item, index) => { + const marker = task + ? item.attrs?.checked + ? '[x]' + : '[ ]' + : ordered + ? `${start + index}.` + : '-' + return ( + + {marker} + + {(item.content ?? []).map((child, childIndex) => + child.type === 'paragraph' ? ( + + {renderInline(`${key}-${index}-${childIndex}`, child.content)} + + ) : ( + renderBlock(child, images, `${key}-${index}-${childIndex}`) + ) + )} + + + ) + })} + + ) +} + +function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { + return ( + + {(row.content ?? []).map((cell, index) => { + const alignmentStyle = + cell.attrs?.align === 'center' + ? styles.tableCellCenter + : cell.attrs?.align === 'right' + ? styles.tableCellRight + : undefined + return ( + + {(cell.content ?? []).map((child, childIndex) => ( + + {childIndex > 0 ? '\n' : null} + {renderInline(`${key}-${index}-${childIndex}`, child.content)} + + ))} + + ) + })} + + ) +} + +function renderTable(node: JSONContent, key: string): ReactNode { + const rows = (node.content ?? []).filter((child) => child.type === 'tableRow') + if (rows.length === 0) return null + const firstRow = rows[0] + const hasHeader = firstRow.content?.some((cell) => cell.type === 'tableHeader') ?? false + return ( + + {rows.map((row, index) => + renderTableRow(row, `${key}-row-${index}`, hasHeader && index === 0) + )} + + ) +} + +function renderBlock( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + switch (node.type) { + case 'paragraph': + return ( + + {renderInline(key, node.content)} + + ) + case 'heading': { + const level = typeof node.attrs?.level === 'number' ? node.attrs.level : 1 + const headingStyle = [styles.h1, styles.h2, styles.h3, styles.h4, styles.h5, styles.h6][ + Math.min(Math.max(level, 1), 6) - 1 + ] + return ( + + {renderInline(key, node.content)} + + ) + } + case 'bulletList': + case 'orderedList': + case 'taskList': + return renderList(node, images, key) + case 'blockquote': + return ( + + {(node.content ?? []).map((child, index) => + renderBlock(child, images, `${key}-${index}`) + )} + + ) + case 'codeBlock': + return ( + + {renderText(nodeText(node), key)} + + ) + case 'table': + return renderTable(node, key) + case 'horizontalRule': + return + case 'image': + return renderImage(node, images, key) + case 'rawHtmlBlock': + case 'footnoteDef': + return ( + + {renderText(nodeText(node), key)} + + ) + default: { + const text = nodeText(node) + return text ? ( + + {renderText(text, key)} + + ) : null + } + } +} + +function MarkdownDocument({ document, title, images }: MarkdownDocumentProps) { + return ( + + + {(document.content ?? []).map((node, index) => renderBlock(node, images, `block-${index}`))} + + + ) +} + +async function normalizeImages( + images: ReadonlyMap +): Promise> { + const normalized = new Map() + let totalDecodedInputPixels = 0 + let totalOutputPixels = 0 + let totalImageBytes = 0 + + for (const [imageKey, buffer] of images) { + try { + const pipeline = sharp(buffer, { + limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS, + sequentialRead: true, + }) + const metadata = await pipeline.metadata() + if (!metadata.width || !metadata.height) continue + + const inputPixels = metadata.width * metadata.height + if ( + !Number.isSafeInteger(inputPixels) || + totalDecodedInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS + ) { + continue + } + + const scale = Math.min( + 1, + MAX_PDF_IMAGE_DIMENSION / metadata.width, + MAX_PDF_IMAGE_DIMENSION / metadata.height + ) + const outputWidth = Math.max(1, Math.round(metadata.width * scale)) + const outputHeight = Math.max(1, Math.round(metadata.height * scale)) + const outputPixels = outputWidth * outputHeight + if (totalOutputPixels + outputPixels > MAX_PDF_TOTAL_OUTPUT_PIXELS) continue + + const data = await pipeline + .rotate() + .resize({ + width: MAX_PDF_IMAGE_DIMENSION, + height: MAX_PDF_IMAGE_DIMENSION, + fit: 'inside', + withoutEnlargement: true, + }) + .png() + .toBuffer() + // Count the expensive decode once it has happened even when the encoded PNG is later rejected; + // output pixels/bytes below count only images retained for React PDF layout. This distinction + // prevents repeated rejected images from escaping the CPU/memory budget without penalizing images + // skipped before decoding. + totalDecodedInputPixels += inputPixels + if ( + data.length > MAX_PDF_IMAGE_BYTES || + totalImageBytes + data.length > MAX_PDF_TOTAL_IMAGE_BYTES + ) { + continue + } + + totalOutputPixels += outputPixels + totalImageBytes += data.length + normalized.set(imageKey, { data, format: 'png' }) + } catch { + // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. + } + } + return normalized +} + +export async function renderMarkdownPdf({ + markdown, + title, + images = new Map(), +}: MarkdownPdfInput): Promise { + const normalizedImages = await normalizeImages(images) + const { body } = splitFrontmatter(markdown) + ensureDomForMarkdownPdf() + const document = parseMarkdownToDoc(body) + assertDocumentWithinLimits(document) + return renderToBuffer( + + ) +} diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index 069e1fc30a9..132d635a54c 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -12,12 +12,26 @@ const { mockVerifyFileAccess, mockDownloadFile, mockExtractEmbeddedImageIds, + mockExtractEmbeddedFileRefs, + mockResolveWorkspaceInlineImage, + mockEnforceUserRateLimit, + mockRenderMarkdownPdf, + mockRecordAudit, + mockCaptureServerEvent, + MockMarkdownPdfLimitError, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), mockExtractEmbeddedImageIds: vi.fn(), + mockExtractEmbeddedFileRefs: vi.fn(), + mockResolveWorkspaceInlineImage: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockRenderMarkdownPdf: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), + MockMarkdownPdfLimitError: class extends Error {}, })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -29,12 +43,27 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloa vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ extractEmbeddedImageIds: mockExtractEmbeddedImageIds, })) +vi.mock('@/lib/uploads/utils/embedded-image-ref', () => ({ + extractEmbeddedFileRefs: mockExtractEmbeddedFileRefs, +})) +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveWorkspaceInlineImage, +})) +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) +vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ + MarkdownPdfLimitError: MockMarkdownPdfLimitError, + markdownPdfImageKey: (ref: { key?: string; fileId?: string }) => + ref.key ? `key:${ref.key}` : `id:${ref.fileId}`, + renderMarkdownPdf: mockRenderMarkdownPdf, +})) vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), + recordAudit: mockRecordAudit, AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, AuditResourceType: { FILE: 'file' }, })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) import { GET } from '@/app/api/files/export/[id]/route' @@ -42,8 +71,14 @@ const MB = 1024 * 1024 const DOC_ID = 'doc-1' const context = { params: Promise.resolve({ id: DOC_ID }) } -function request() { - return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/files/export/${DOC_ID}`) +function request(format?: 'pdf') { + const query = format ? '?format=pdf' : '' + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/files/export/${DOC_ID}${query}` + ) } function assetRecord(id: string, size: number) { @@ -78,6 +113,70 @@ describe('markdown export bundling', () => { ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) mockExtractEmbeddedImageIds.mockReturnValue([]) + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: [] }) + mockResolveWorkspaceInlineImage.mockResolvedValue(null) + mockEnforceUserRateLimit.mockResolvedValue(null) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + }) + + it('returns stored Markdown unchanged when no embedded image IDs exist', async () => { + const markdown = '# Doc\n![editor image](/api/files/serve/workspace%2Fws-1%2Fimage.png)\n' + mockDownloadFile.mockResolvedValue(Buffer.from(markdown)) + + const response = await GET(request(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('doc.md') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe(markdown) + expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled() + expect(mockResolveWorkspaceInlineImage).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() + }) + + it('preserves image-ID ZIP rewriting and bulk telemetry without a format', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') + ? Buffer.from('![image](/api/files/view/image-1)') + : Buffer.from('png-bytes') + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/zip') + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/image-1.png)') + expect(zip.file('assets/image-1.png')).not.toBeNull() + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 2, is_bulk: true }), + { groups: { workspace: 'ws-1' } } + ) + expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled() + }) + + it('preserves the non-Markdown serve redirect without a format', async () => { + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/image.png', + originalName: 'image.png', + contentType: 'image/png', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + + const response = await GET(request(), context) + + expect(response.status).toBe(302) + expect(response.headers.get('Location')).toContain('/api/files/serve/') + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) it('rejects on declared asset bytes before downloading any of them', async () => { @@ -177,3 +276,165 @@ describe('markdown export bundling', () => { ) }) }) + +describe('markdown PDF export', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' }) + mockVerifyFileAccess.mockResolvedValue(true) + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) + mockExtractEmbeddedImageIds.mockReturnValue([]) + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: [] }) + mockResolveWorkspaceInlineImage.mockResolvedValue(null) + mockEnforceUserRateLimit.mockResolvedValue(null) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + }) + + it('renders a direct PDF attachment with the Markdown filename', async () => { + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('doc.pdf') + expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-generated') + expect(mockRenderMarkdownPdf).toHaveBeenCalledWith({ + markdown: '# Doc\n', + title: 'doc', + images: expect.any(Map), + }) + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 1, is_bulk: false }), + { groups: { workspace: 'ws-1' } } + ) + }) + + it('resolves authorized key- and ID-based images for the PDF renderer', async () => { + const imageKey = 'workspace/ws-1/editor-image.png' + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [imageKey], ids: ['image-1'] }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { key?: string; fileId?: string }) => ({ + key: ref.key ?? `workspace/ws-1/${ref.fileId}`, + filename: 'image.png', + contentType: 'image/png', + }) + ) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.from(`bytes:${key}`) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceInlineImage).toHaveBeenNthCalledWith(1, 'ws-1', { key: imageKey }) + expect(mockResolveWorkspaceInlineImage).toHaveBeenNthCalledWith(2, 'ws-1', { + fileId: 'image-1', + }) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(images.get(`key:${imageKey}`)).toEqual(Buffer.from(`bytes:${imageKey}`)) + expect(images.get('id:image-1')).toEqual(Buffer.from('bytes:workspace/ws-1/image-1')) + expect(mockExtractEmbeddedImageIds).not.toHaveBeenCalled() + }) + + it('rejects PDF format for a non-Markdown file', async () => { + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.txt', + originalName: 'doc.txt', + contentType: 'text/plain', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('only available for Markdown') + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('stops a rate-limited PDF export before reading the document', async () => { + mockEnforceUserRateLimit.mockResolvedValue( + new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(429) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('uses PDF-specific document and image byte limits', async () => { + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: ['image-1'] }) + mockResolveWorkspaceInlineImage.mockResolvedValue({ + key: 'workspace/ws-1/image-1', + filename: 'image.png', + contentType: 'image/png', + }) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.from('image') + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + const documentCall = mockDownloadFile.mock.calls.find(([options]) => + options.key.endsWith('doc.md') + ) + const imageCall = mockDownloadFile.mock.calls.find( + ([options]) => options.key === 'workspace/ws-1/image-1' + ) + expect(documentCall?.[0].maxBytes).toBe(256 * 1024) + expect(imageCall?.[0].maxBytes).toBe(10 * MB) + }) + + it('rejects actual downloaded image bytes above the aggregate PDF budget', async () => { + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: ['a', 'b'] }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => ({ + key: `workspace/ws-1/${ref.fileId}`, + filename: `${ref.fileId}.png`, + contentType: 'image/png', + }) + ) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.alloc(26 * MB) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('50 MB PDF export limit') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('returns renderer resource-limit errors as clear client errors', async () => { + mockRenderMarkdownPdf.mockRejectedValue( + new MockMarkdownPdfLimitError('This document is too complex to export as PDF.') + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('too complex') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 0e578ada87b..3af7478852a 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -9,6 +9,8 @@ import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -16,7 +18,9 @@ import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' import { getFileMetadataById } from '@/lib/uploads/server/metadata' +import { extractEmbeddedFileRefs } from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { encodeFilenameForHeader } from '@/app/api/files/utils' @@ -35,6 +39,15 @@ const logger = createLogger('FilesExportAPI') const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 +const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 +const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 +const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 +const PDF_EXPORT_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, +} + const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) @@ -66,6 +79,7 @@ export const GET = withRouteHandler( if (!parsed.success) return parsed.response const { id } = parsed.data.params + const { format } = parsed.data.query const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { @@ -120,6 +134,134 @@ export const GET = withRouteHandler( ) } + const auditPdfExport = (assetCount: number) => { + recordAudit({ + workspaceId: record.workspaceId ?? null, + actorId: userId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: record.id, + resourceName: record.originalName, + description: `Exported file "${record.originalName}"`, + metadata: { + fileId: record.id, + fileName: record.originalName, + bytes: record.size, + format: 'pdf', + assetCount, + }, + request, + }) + captureServerEvent( + userId, + 'file_downloaded', + { + ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), + is_bulk: false, + file_count: 1, + }, + record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined + ) + } + + if (format === 'pdf') { + if (!isMarkdown(record.originalName, record.contentType)) { + return NextResponse.json( + { error: 'PDF export is only available for Markdown files.' }, + { status: 400 } + ) + } + + const rateLimited = await enforceUserRateLimit( + 'markdown-pdf-export', + userId, + PDF_EXPORT_RATE_LIMIT + ) + if (rateLimited) return rateLimited + + let mdBuffer: Buffer + try { + mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + maxBytes: MAX_PDF_MARKDOWN_BYTES, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return NextResponse.json( + { + error: `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.`, + }, + { status: 400 } + ) + } + + const mdContent = mdBuffer.toString('utf-8') + const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) + const imageRefs: Array<{ key: string } | { fileId: string }> = [ + ...imageKeys.map((key) => ({ key })), + ...imageIds.map((fileId) => ({ fileId })), + ] + const { MarkdownPdfLimitError, markdownPdfImageKey, renderMarkdownPdf } = await import( + '@/app/api/files/export/[id]/markdown-pdf' + ) + const images = new Map() + let sourceBytes = mdBuffer.length + + if (record.workspaceId) { + for (const imageRef of imageRefs) { + try { + const image = await resolveWorkspaceInlineImage(record.workspaceId, imageRef) + if (!image || !(await verifyFileAccess(image.key, userId))) continue + + const buffer = await downloadFile({ + key: image.key, + context: 'workspace', + maxBytes: MAX_PDF_ASSET_BYTES, + }) + if (sourceBytes + buffer.length > MAX_PDF_TOTAL_SOURCE_BYTES) { + return NextResponse.json( + { + error: `This document and its embedded files exceed the ${formatFileSize(MAX_PDF_TOTAL_SOURCE_BYTES)} PDF export limit.`, + }, + { status: 400 } + ) + } + + sourceBytes += buffer.length + images.set(markdownPdfImageKey(imageRef), buffer) + } catch (error) { + logger.warn('Failed to fetch asset for PDF export', { + imageRef: markdownPdfImageKey(imageRef), + error: toError(error).message, + }) + } + } + } + + const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') + const pdfName = safeFilename(`${title}.pdf`) + let pdfBuffer: Buffer + try { + pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + } catch (error) { + if (error instanceof MarkdownPdfLimitError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + + auditPdfExport(images.size) + return new NextResponse(new Uint8Array(pdfBuffer), { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(pdfName)}`, + 'Content-Length': String(pdfBuffer.length), + }, + }) + } + if (!isMarkdown(record.originalName, record.contentType)) { const storagePrefix = getServeStoragePrefix() const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..681061e9435 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -17,7 +17,7 @@ import { toast, Upload, } from '@sim/emcn' -import { Download, Send } from '@sim/emcn/icons' +import { Download, FileText, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' @@ -345,6 +345,8 @@ export function Files() { ) const [creatingFile, setCreatingFile] = useState(false) + const [pdfDownloadPending, setPdfDownloadPending] = useState(false) + const pdfDownloadPendingRef = useRef(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) @@ -1062,9 +1064,15 @@ export function Files() { } const handleDownload = useCallback( - async (file: WorkspaceFileRecord) => { + async (file: WorkspaceFileRecord, format?: 'pdf') => { + const isPdf = format === 'pdf' + if (isPdf) { + if (pdfDownloadPendingRef.current) return + pdfDownloadPendingRef.current = true + setPdfDownloadPending(true) + } try { - await triggerFileDownload(file) + await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, is_bulk: false, @@ -1073,6 +1081,11 @@ export function Files() { } catch (err) { logger.error('Failed to download file:', err) toast.error(getErrorMessage(err, `Failed to download "${file.name}"`)) + } finally { + if (isPdf) { + pdfDownloadPendingRef.current = false + setPdfDownloadPending(false) + } } }, [workspaceId] @@ -1161,6 +1174,11 @@ export function Files() { if (file) handleDownload(file) }, [handleDownload]) + const handleDownloadPdfSelected = useCallback(() => { + const file = selectedFileRef.current + if (file) handleDownload(file, 'pdf') + }, [handleDownload]) + const handleDeleteSelected = useCallback(() => { const file = selectedFileRef.current if (file) { @@ -1627,6 +1645,16 @@ export function Files() { icon: Download, onSelect: handleDownloadSelected, }, + ...(isInlineMarkdown + ? [ + { + text: 'Download PDF', + icon: FileText, + onSelect: handleDownloadPdfSelected, + disabled: pdfDownloadPending, + }, + ] + : []), ...(canEdit ? [ { @@ -1650,8 +1678,10 @@ export function Files() { handleCyclePreviewMode, handleTogglePreview, handleDownloadSelected, + handleDownloadPdfSelected, handleShareSelected, handleDeleteSelected, + pdfDownloadPending, ]) const listRenameRef = useRef(listRename) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 2eb9e3b6a50..81047281341 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -291,6 +291,10 @@ export const fileExportParamsSchema = z.object({ id: workspaceFileIdSchema, }) +export const fileExportQuerySchema = z.object({ + format: z.literal('pdf').optional(), +}) + export const boxUploadContract = defineRouteContract({ method: 'POST', path: '/api/tools/box/upload', @@ -485,6 +489,7 @@ export const fileExportContract = defineRouteContract({ method: 'GET', path: '/api/files/export/[id]', params: fileExportParamsSchema, + query: fileExportQuerySchema, response: { mode: 'binary' }, }) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index ac873f66ae8..8995a7effe6 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -1,4 +1,5 @@ import { requestRaw } from '@/lib/api/client/request' +import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -30,12 +31,27 @@ function fileNameFromDisposition(response: Response, fallback: string): string { return disposition.match(/filename="([^"]+)"/)?.[1] ?? fallback } -export async function triggerFileDownload(record: WorkspaceFileRecord): Promise { +export async function triggerFileDownload( + record: WorkspaceFileRecord, + options?: { format?: 'pdf' } +): Promise { const isMarkdown = record.type === 'text/markdown' || record.type === 'text/x-markdown' || /\.(?:md|markdown)$/i.test(record.name) + if (options?.format === 'pdf') { + if (!isMarkdown) throw new Error('PDF export is only available for Markdown files') + const response = await requestRaw( + fileExportContract, + { params: { id: record.id }, query: { format: 'pdf' } }, + { cache: 'no-store' } + ) + const fallbackName = `${record.name.replace(/\.[^.]+$/, '')}.pdf` + saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) + return + } + const url = isMarkdown ? `/api/files/export/${encodeURIComponent(record.id)}` : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index fd95ec30a4a..39314f52420 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -168,6 +168,7 @@ const nextConfig: NextConfig = { '/api/internal/file-doc/seed': ['./node_modules/jsdom/**/*'], '/api/internal/file-doc/merge': ['./node_modules/jsdom/**/*'], '/api/internal/file-doc/persist': ['./node_modules/jsdom/**/*'], + '/api/files/export/*': ['./node_modules/jsdom/**/*'], /** * No `sharp`/`@img` entries: these globs resolve against apps/sim while both hoist to the * monorepo root, so they matched nothing. docker/app.Dockerfile copies them instead. diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..5f8687e20a1 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -72,6 +72,10 @@ "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", "@floating-ui/dom": "1.7.6", + "@fontsource/noto-sans-arabic": "5.3.0", + "@fontsource/noto-sans-devanagari": "5.3.0", + "@fontsource/noto-sans-hebrew": "5.3.0", + "@fontsource/unifont": "5.3.0", "@google-cloud/storage": "7.21.0", "@google/genai": "2.13.0", "@hookform/resolvers": "5.2.2", @@ -105,6 +109,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", + "@react-pdf/renderer": "4.5.1", "@sim/audit": "workspace:*", "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", @@ -165,6 +170,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", + "fontkit": "2.0.4", "framer-motion": "^12.5.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", diff --git a/bun.lock b/bun.lock index 6fdf0020645..3ca45640f4d 100644 --- a/bun.lock +++ b/bun.lock @@ -175,6 +175,10 @@ "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10", "@floating-ui/dom": "1.7.6", + "@fontsource/noto-sans-arabic": "5.3.0", + "@fontsource/noto-sans-devanagari": "5.3.0", + "@fontsource/noto-sans-hebrew": "5.3.0", + "@fontsource/unifont": "5.3.0", "@google-cloud/storage": "7.21.0", "@google/genai": "2.13.0", "@hookform/resolvers": "5.2.2", @@ -208,6 +212,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", + "@react-pdf/renderer": "4.5.1", "@sim/audit": "workspace:*", "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", @@ -268,6 +273,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "fluent-ffmpeg": "2.1.3", + "fontkit": "2.0.4", "framer-motion": "^12.5.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", @@ -1152,6 +1158,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.3.0", "", {}, "sha512-i3t6GR0LOReyJVEk+YfYCnxv53wuIelg7Y9NGNvOq4Diq/EP0YszaGXkQwfQNigLjfO4POrLaPGM0sNliGYk7A=="], + + "@fontsource/noto-sans-devanagari": ["@fontsource/noto-sans-devanagari@5.3.0", "", {}, "sha512-7khYmipS/5KAUUmrO1DKir8yL8XI00d0+ZE9BqbS8XF1jzObv6CpPOk3UeYxF+Pvi8bx9bBvprBCOcbx/eQWTQ=="], + + "@fontsource/noto-sans-hebrew": ["@fontsource/noto-sans-hebrew@5.3.0", "", {}, "sha512-7owtzuw9D+ipt0g8mcBGOm7MS/mv/REKsjSwEUe749UUsrnG54TSmusNn7oxv2GxbUORAzADLQ8ggdtt7oKkpg=="], + + "@fontsource/unifont": ["@fontsource/unifont@5.3.0", "", {}, "sha512-7cbWRgAV1JVpa6kgwtOcziEG7YiVOF9NGax6ZRhnmB76U5HHbITPD1t5BgeToxFt4De/WoPfG5wH4pWl6BhpNQ=="], + "@fumadocs/tailwind": ["@fumadocs/tailwind@0.0.5", "", { "peerDependencies": { "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@tailwindcss/oxide", "tailwindcss"] }, "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ=="], "@fumari/json-schema-ts": ["@fumari/json-schema-ts@0.0.2", "", { "dependencies": { "esrap": "^2.2.3" }, "peerDependencies": { "json-schema-typed": "^8.0.2" }, "optionalPeers": ["json-schema-typed"] }, "sha512-A2x8nj45r8Kc3Gqa+HpWRF9uzIMc9dySB6L2R2kiyjLHXWBsZUX99Atj5+Yup/iRQXQ9s8AX+uAPwPze7Xn05A=="], @@ -1644,6 +1658,32 @@ "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], + "@react-pdf/fns": ["@react-pdf/fns@3.1.3", "", {}, "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w=="], + + "@react-pdf/font": ["@react-pdf/font@4.0.8", "", { "dependencies": { "@react-pdf/pdfkit": "^5.1.1", "@react-pdf/types": "^2.11.1", "fontkit": "^2.0.2", "is-url": "^1.2.4" } }, "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA=="], + + "@react-pdf/image": ["@react-pdf/image@3.1.0", "", { "dependencies": { "@react-pdf/svg": "^1.1.0", "jay-peg": "^1.1.1", "png-js": "^2.0.0" } }, "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g=="], + + "@react-pdf/layout": ["@react-pdf/layout@4.6.1", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "@react-pdf/image": "^3.1.0", "@react-pdf/primitives": "^4.3.0", "@react-pdf/stylesheet": "^6.2.1", "@react-pdf/textkit": "^6.3.0", "@react-pdf/types": "^2.11.1", "emoji-regex-xs": "^1.0.0", "queue": "^6.0.1", "yoga-layout": "^3.2.1" } }, "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA=="], + + "@react-pdf/pdfkit": ["@react-pdf/pdfkit@5.1.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "browserify-zlib": "^0.2.0", "fontkit": "^2.0.2", "jay-peg": "^1.1.1", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^2.0.0", "vite-compatible-readable-stream": "^3.6.1" } }, "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg=="], + + "@react-pdf/primitives": ["@react-pdf/primitives@4.3.0", "", {}, "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A=="], + + "@react-pdf/reconciler": ["@react-pdf/reconciler@2.0.0", "", { "dependencies": { "object-assign": "^4.1.1", "scheduler": "0.25.0-rc-603e6108-20241029" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw=="], + + "@react-pdf/render": ["@react-pdf/render@4.5.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/primitives": "^4.3.0", "@react-pdf/textkit": "^6.3.0", "@react-pdf/types": "^2.11.1", "abs-svg-path": "^0.1.1", "color-string": "^2.1.4", "normalize-svg-path": "^1.1.0", "parse-svg-path": "^0.1.2", "svg-arc-to-cubic-bezier": "^3.2.0" } }, "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA=="], + + "@react-pdf/renderer": ["@react-pdf/renderer@4.5.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/font": "^4.0.8", "@react-pdf/layout": "^4.6.1", "@react-pdf/pdfkit": "^5.1.1", "@react-pdf/primitives": "^4.3.0", "@react-pdf/reconciler": "^2.0.0", "@react-pdf/render": "^4.5.1", "@react-pdf/types": "^2.11.1", "events": "^3.3.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", "queue": "^6.0.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q=="], + + "@react-pdf/stylesheet": ["@react-pdf/stylesheet@6.2.1", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "@react-pdf/types": "^2.11.1", "color-string": "^2.1.4", "hsl-to-hex": "^1.0.0", "media-engine": "^1.0.3", "postcss-value-parser": "^4.1.0" } }, "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A=="], + + "@react-pdf/svg": ["@react-pdf/svg@1.1.0", "", { "dependencies": { "@react-pdf/primitives": "^4.3.0" } }, "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA=="], + + "@react-pdf/textkit": ["@react-pdf/textkit@6.3.0", "", { "dependencies": { "@react-pdf/fns": "3.1.3", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", "unicode-properties": "^1.4.1" } }, "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ=="], + + "@react-pdf/types": ["@react-pdf/types@2.11.1", "", { "dependencies": { "@react-pdf/font": "^4.0.8", "@react-pdf/primitives": "^4.3.0", "@react-pdf/stylesheet": "^6.2.1" } }, "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw=="], + "@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="], "@reactflow/controls": ["@reactflow/controls@11.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw=="], @@ -2270,6 +2310,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -2380,6 +2422,8 @@ "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binary-extensions": ["binary-extensions@3.1.0", "", {}, "sha512-Jvvd9hy1w+xUad8+ckQsWA/V1AoyubOvqn0aygjMOVM4BfIaRav1NFS3LsTSDaV4n4FtcCtQXvzep1E6MboqwQ=="], @@ -2400,8 +2444,12 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="], + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="], "buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], @@ -2486,6 +2534,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -2500,6 +2550,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -2710,6 +2762,8 @@ "devtools-protocol": ["devtools-protocol@0.0.1464554", "", {}, "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], @@ -2788,6 +2842,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -2946,6 +3002,8 @@ "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], @@ -3086,6 +3144,10 @@ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "hsl-to-hex": ["hsl-to-hex@1.0.0", "", { "dependencies": { "hsl-to-rgb-for-reals": "^1.1.0" } }, "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA=="], + + "hsl-to-rgb-for-reals": ["hsl-to-rgb-for-reals@1.1.1", "", {}, "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], @@ -3122,6 +3184,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "hyphen": ["hyphen@1.14.1", "", {}, "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw=="], + "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], @@ -3196,6 +3260,8 @@ "is-unsafe": ["is-unsafe@1.0.1", "", {}, "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA=="], + "is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -3222,6 +3288,8 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jay-peg": ["jay-peg@1.1.1", "", { "dependencies": { "restructure": "^3.0.0" } }, "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], @@ -3230,6 +3298,8 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -3444,6 +3514,8 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-engine": ["media-engine@1.0.3", "", {}, "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], @@ -3636,6 +3708,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="], + "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], "notepack.io": ["notepack.io@3.0.1", "", {}, "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg=="], @@ -3710,6 +3784,8 @@ "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], + "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -3786,6 +3862,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "png-js": ["png-js@2.0.0", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA=="], + "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -3838,6 +3916,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], @@ -3912,6 +3992,8 @@ "react-hook-form": ["react-hook-form@7.79.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], "react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="], @@ -4010,6 +4092,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], @@ -4244,6 +4328,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="], + "svix": ["svix@1.88.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -4376,6 +4462,8 @@ "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -4432,6 +4520,8 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], @@ -4518,6 +4608,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="], "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], @@ -4784,6 +4876,12 @@ "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@react-pdf/pdfkit/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="], + "@reactflow/background/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@reactflow/controls/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -4950,6 +5048,8 @@ "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "color-string/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 28f6391b31d..f3b9218b76f 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -165,6 +165,11 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/y-protocols ./node_modu COPY --from=deps --chown=nextjs:nodejs /app/node_modules/sharp ./node_modules/sharp COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@img ./node_modules/@img +# Markdown PDF export resolves bundled Unicode fallbacks through these packages at runtime. The +# standalone tracer does not reliably retain fonts referenced through require.resolve, so copy the +# font packages explicitly just like the other runtime assets above. +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@fontsource ./node_modules/@fontsource + # Copy the isolated-vm worker script COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-worker.cjs ./apps/sim/lib/execution/isolated-vm-worker.cjs