From 8d80229b8cbac18a5bfc4856eccf21af2c188d2a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 17:13:48 -0700 Subject: [PATCH 01/13] feat(files): add Markdown PDF download --- .../files/export/[id]/markdown-pdf.test.ts | 60 +++ .../api/files/export/[id]/markdown-pdf.tsx | 447 ++++++++++++++++++ .../app/api/files/export/[id]/route.test.ts | 77 ++- apps/sim/app/api/files/export/[id]/route.ts | 38 +- .../workspace/[workspaceId]/files/files.tsx | 21 +- .../sim/lib/api/contracts/storage-transfer.ts | 5 + apps/sim/lib/uploads/client/download.ts | 15 +- apps/sim/package.json | 3 + bun.lock | 89 ++++ 9 files changed, 745 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts create mode 100644 apps/sim/app/api/files/export/[id]/markdown-pdf.tsx 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..af3d81cfbd5 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { PDFDocument } from 'pdf-lib' +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' +import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' + +describe('Markdown PDF rendering', () => { + it('creates a valid multi-page PDF with GFM and an embedded image', async () => { + 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”, Greek Ξ© stays readable, and unsupported emoji πŸš€ falls back safely. + +- First item +- Second item + +| Name | Value | +| --- | ---: | +| Alpha | 1 | +| Beta | 2 | + +\`\`\`ts +const exported = true +\`\`\` + +![Embedded image](/api/files/view/image-1) + +${repeatedParagraphs}` + + const buffer = await renderMarkdownPdf({ + markdown, + title: 'Export title', + images: new Map([['image-1', 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) + }) +}) 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..50768fbae48 --- /dev/null +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -0,0 +1,447 @@ +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 { marked, type Token, type Tokens } from 'marked' +import sharp from 'sharp' + +type PdfImage = { data: Buffer; format: 'png' } + +interface GlyphFont { + hasGlyphForCodePoint(codePoint: number): boolean +} + +const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') +const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') +const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') + +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 }, + ], +}) + +const require = createRequire(import.meta.url) +const { openSync } = require('fontkit') as { openSync(path: string): GlyphFont } +const geistGlyphs = openSync(GEIST_REGULAR) + +export interface MarkdownPdfInput { + markdown: string + title: string + images?: ReadonlyMap +} + +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' }, + inlineCode: { + backgroundColor: '#f1f3f5', + color: '#24292f', + fontFamily: 'Geist', + fontSize: 9, + }, + link: { color: '#0969da', textDecoration: 'underline' }, + blockquote: { + borderLeftColor: '#b6bec8', + borderLeftWidth: 2, + color: '#4b5563', + marginBottom: 9, + paddingLeft: 10, + }, + codeBlock: { + backgroundColor: '#f6f8fa', + borderColor: '#d0d7de', + borderRadius: 3, + borderWidth: 0.5, + color: '#24292f', + fontFamily: 'Geist', + 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, + }, + 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, + }, + htmlFallback: { color: '#57606a', marginBottom: 9 }, +}) + +function safeText(value: string): string { + // React PDF's standard fonts can map a missing glyph to an unrelated visible character. + // Use the same bundled font for measurement and rendering, with an explicit readable fallback. + let safe = '' + for (const character of value) { + const codePoint = character.codePointAt(0) + safe += codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) ? character : '?' + } + return safe +} + +function plainHtml(value: string): string { + return safeText(value.replace(/<[^>]*>/g, '').trim()) +} + +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 embeddedImageId(href: string): string | undefined { + const match = + href.match(/\/api\/files\/view\/([^/?#]+)/) ?? + href.match(/\/workspace\/[^/]+\/files\/([^/?#]+)/) + if (!match?.[1]) return undefined + try { + return decodeURIComponent(match[1]) + } catch { + return match[1] + } +} + +function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { + return tokens.map((token, index) => { + const key = `${keyPrefix}-${index}` + switch (token.type) { + case 'text': + return token.tokens?.length ? renderInline(token.tokens, key) : safeText(token.text) + case 'escape': + return safeText(token.text) + case 'strong': { + const strong = token as Tokens.Strong + return ( + + {renderInline(strong.tokens, key)} + + ) + } + case 'em': { + const emphasis = token as Tokens.Em + return ( + + {renderInline(emphasis.tokens, key)} + + ) + } + case 'del': { + const deleted = token as Tokens.Del + return ( + + {renderInline(deleted.tokens, key)} + + ) + } + case 'codespan': + return ( + + {safeText(token.text)} + + ) + case 'br': + return '\n' + case 'link': { + const link = token as Tokens.Link + const href = safeLink(link.href) + const content = renderInline(link.tokens, key) + return href ? ( + + {content} + + ) : ( + + {content} + + ) + } + case 'image': + return safeText(token.text || token.href) + case 'html': + return plainHtml(token.text) + case 'checkbox': + return token.checked ? '[x] ' : '[ ] ' + default: + return 'text' in token && typeof token.text === 'string' ? safeText(token.text) : '' + } + }) +} + +function directImage(token: Token): Tokens.Image | undefined { + if (token.type === 'image') return token as Tokens.Image + if (token.type === 'link') { + const link = token as Tokens.Link + if (link.tokens.length === 1 && link.tokens[0]?.type === 'image') { + return link.tokens[0] as Tokens.Image + } + } + return undefined +} + +function renderImage(token: Tokens.Image, images: ReadonlyMap, key: string) { + const id = embeddedImageId(token.href) + const image = id ? images.get(id) : undefined + if (!image) { + return ( + + {token.text ? `Image: ${safeText(token.text)}` : 'Image unavailable'} + + ) + } + return ( + + + + ) +} + +function renderParagraph( + tokens: Token[], + images: ReadonlyMap, + keyPrefix: string +): ReactNode[] { + const output: ReactNode[] = [] + let inline: Token[] = [] + + const flushInline = () => { + if (inline.length === 0) return + output.push( + + {renderInline(inline, `${keyPrefix}-inline-${output.length}`)} + + ) + inline = [] + } + + for (const token of tokens) { + const image = directImage(token) + if (!image) { + inline.push(token) + continue + } + flushInline() + output.push(renderImage(image, images, `${keyPrefix}-image-${output.length}`)) + } + flushInline() + return output +} + +function renderList(token: Tokens.List, images: ReadonlyMap, key: string) { + const start = typeof token.start === 'number' ? token.start : 1 + return ( + + {token.items.map((item, index) => { + const marker = item.task + ? item.checked + ? '[x]' + : '[ ]' + : token.ordered + ? `${start + index}.` + : '-' + return ( + + {marker} + + {item.tokens.map((itemToken, tokenIndex) => + itemToken.type === 'text' ? ( + + {renderInline(itemToken.tokens ?? [itemToken], `${key}-${index}-${tokenIndex}`)} + + ) : ( + renderBlock(itemToken, images, `${key}-${index}-${tokenIndex}`) + ) + )} + + + ) + })} + + ) +} + +function renderTable(token: Tokens.Table, key: string) { + const row = (cells: Tokens.TableCell[], rowKey: string, header: boolean) => ( + + {cells.map((cell, index) => ( + + {renderInline(cell.tokens, `${rowKey}-${index}`)} + + ))} + + ) + + return ( + + {row(token.header, `${key}-header`, true)} + {token.rows.map((cells, index) => row(cells, `${key}-row-${index}`, false))} + + ) +} + +function renderBlock(token: Token, images: ReadonlyMap, key: string): ReactNode { + switch (token.type) { + case 'space': + case 'def': + return null + case 'heading': { + const heading = token as Tokens.Heading + const headingStyle = [styles.h1, styles.h2, styles.h3, styles.h4, styles.h5, styles.h6][ + Math.min(Math.max(heading.depth, 1), 6) - 1 + ] + return ( + + {renderInline(heading.tokens, key)} + + ) + } + case 'paragraph': { + const paragraph = token as Tokens.Paragraph + return {renderParagraph(paragraph.tokens, images, key)} + } + case 'text': + return ( + + {renderInline(token.tokens ?? [token], key)} + + ) + case 'code': + return ( + + {safeText(token.text)} + + ) + case 'blockquote': { + const blockquote = token as Tokens.Blockquote + return ( + + {blockquote.tokens.map((child, index) => renderBlock(child, images, `${key}-${index}`))} + + ) + } + case 'list': + return renderList(token as Tokens.List, images, key) + case 'table': + return renderTable(token as Tokens.Table, key) + case 'hr': + return + case 'html': { + const text = plainHtml(token.text) + return text ? ( + + {text} + + ) : null + } + default: + return 'text' in token && typeof token.text === 'string' ? ( + + {safeText(token.text)} + + ) : null + } +} + +function MarkdownDocument({ + markdown, + title, + images, +}: { + markdown: string + title: string + images: ReadonlyMap +}) { + const tokens = marked.lexer(markdown, { gfm: true }) + return ( + + + {tokens.map((token, index) => renderBlock(token, images, `block-${index}`))} + + + ) +} + +async function normalizeImages( + images: ReadonlyMap +): Promise> { + const normalized = new Map() + for (const [id, buffer] of images) { + try { + normalized.set(id, { data: await sharp(buffer).rotate().png().toBuffer(), 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) + 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..78ddfe77f84 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,14 @@ const { mockVerifyFileAccess, mockDownloadFile, mockExtractEmbeddedImageIds, + mockRenderMarkdownPdf, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), mockExtractEmbeddedImageIds: vi.fn(), + mockRenderMarkdownPdf: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -29,6 +31,9 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloa vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ extractEmbeddedImageIds: mockExtractEmbeddedImageIds, })) +vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ + renderMarkdownPdf: mockRenderMarkdownPdf, +})) vi.mock('@sim/audit', () => ({ recordAudit: vi.fn(), AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, @@ -42,8 +47,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=${format}` : '' + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/files/export/${DOC_ID}${query}` + ) } function assetRecord(id: string, size: number) { @@ -78,6 +89,68 @@ describe('markdown export bundling', () => { ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) mockExtractEmbeddedImageIds.mockReturnValue([]) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + }) + + it('returns the stored Markdown unchanged when no format is requested', async () => { + 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('# Doc\n') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + + it('renders Markdown as a directly downloadable PDF', 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(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) + }) + + it('passes only authorized, readable embedded images to the PDF renderer', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['good', 'secret', 'broken']) + mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key.endsWith('doc.md')) return Buffer.from('![image](/api/files/view/good)') + if (key.endsWith('broken')) throw new Error('storage down') + return Buffer.from('png-bytes') + }) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(Array.from(images.keys())).toEqual(['good']) + expect(images.get('good')).toEqual(Buffer.from('png-bytes')) + }) + + 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('rejects on declared asset bytes before downloading any of them', async () => { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 0e578ada87b..8bbf7460def 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -19,6 +19,7 @@ import { downloadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' +import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') @@ -66,6 +67,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) { @@ -90,7 +92,7 @@ export const GET = withRouteHandler( * markdown, or bundled zip) so a mid-export failure never logs a download * that never happened. */ - const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { + const auditExport = (format: 'file' | 'markdown' | 'pdf' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, actorId: userId, @@ -121,6 +123,12 @@ export const GET = withRouteHandler( } if (!isMarkdown(record.originalName, record.contentType)) { + if (format === 'pdf') { + return NextResponse.json( + { error: 'PDF export is only available for Markdown files.' }, + { status: 400 } + ) + } const storagePrefix = getServeStoragePrefix() const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` auditExport('file', 0) @@ -151,9 +159,29 @@ export const GET = withRouteHandler( const imageIds = extractEmbeddedImageIds(mdContent) - logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + logger.info('Exporting markdown', { + id, + format: format ?? 'source', + imageCount: imageIds.length, + }) + + const respondWithPdf = async (images: ReadonlyMap) => { + const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') + const pdfName = safeFilename(`${title}.pdf`) + const pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + auditExport('pdf', 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 (imageIds.length === 0) { + if (format === 'pdf') return respondWithPdf(new Map()) const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') auditExport('markdown', 0) @@ -234,6 +262,12 @@ export const GET = withRouteHandler( assetMap.set(imageId, { filename, buffer }) } + if (format === 'pdf') { + return respondWithPdf( + new Map(Array.from(assetMap, ([imageId, asset]) => [imageId, asset.buffer])) + ) + } + for (const [imageId, asset] of assetMap) { const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const replacement = `./assets/${asset.filename}` diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..020447f5651 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' @@ -1062,9 +1062,9 @@ export function Files() { } const handleDownload = useCallback( - async (file: WorkspaceFileRecord) => { + async (file: WorkspaceFileRecord, format?: 'pdf') => { try { - await triggerFileDownload(file) + await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, is_bulk: false, @@ -1161,6 +1161,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 +1632,15 @@ export function Files() { icon: Download, onSelect: handleDownloadSelected, }, + ...(isInlineMarkdown + ? [ + { + text: 'Download PDF', + icon: FileText, + onSelect: handleDownloadPdfSelected, + }, + ] + : []), ...(canEdit ? [ { @@ -1650,6 +1664,7 @@ export function Files() { handleCyclePreviewMode, handleTogglePreview, handleDownloadSelected, + handleDownloadPdfSelected, handleShareSelected, handleDeleteSelected, ]) 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..8c8e519b5c2 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -30,21 +30,30 @@ 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' && !isMarkdown) { + throw new Error('PDF export is only available for Markdown files') + } + const url = isMarkdown - ? `/api/files/export/${encodeURIComponent(record.id)}` + ? `/api/files/export/${encodeURIComponent(record.id)}${options?.format === 'pdf' ? '?format=pdf' : ''}` : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` // boundary-raw-fetch: binary download read as a blob; these paths have no contract const response = await fetch(url, { cache: 'no-store' }) if (!response.ok) throw new Error(`Failed to download "${record.name}"`) - saveBlob(await response.blob(), fileNameFromDisposition(response, record.name)) + const fallbackName = + options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name + saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) } /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..2814cb16910 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -105,6 +105,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 +166,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", @@ -188,6 +190,7 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", + "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", diff --git a/bun.lock b/bun.lock index 6fdf0020645..099f6fab5fb 100644 --- a/bun.lock +++ b/bun.lock @@ -208,6 +208,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 +269,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", @@ -291,6 +293,7 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", + "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", @@ -1644,6 +1647,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 +2299,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 +2411,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 +2433,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 +2523,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 +2539,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 +2751,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 +2831,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 +2991,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 +3133,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 +3173,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 +3249,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 +3277,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 +3287,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 +3503,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 +3697,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 +3773,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 +3851,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 +3905,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 +3981,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 +4081,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 +4317,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 +4451,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 +4509,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 +4597,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 +4865,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 +5037,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=="], From 538beb6815e7bfb92f3f290916a924e8da2bb1a6 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 17:39:20 -0700 Subject: [PATCH 02/13] style(files): name Markdown PDF props --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 50768fbae48..c4fcfd79078 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -402,15 +402,13 @@ function renderBlock(token: Token, images: ReadonlyMap, key: s } } -function MarkdownDocument({ - markdown, - title, - images, -}: { +interface MarkdownDocumentProps { markdown: string title: string images: ReadonlyMap -}) { +} + +function MarkdownDocument({ markdown, title, images }: MarkdownDocumentProps) { const tokens = marked.lexer(markdown, { gfm: true }) return ( From 78cebab81b7f42952fa27a68c8c2c255024eb3a2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 18:14:28 -0700 Subject: [PATCH 03/13] fix(files): harden Markdown PDF export --- .../files/export/[id]/markdown-pdf.test.ts | 17 ++- .../api/files/export/[id]/markdown-pdf.tsx | 72 +++++++++-- .../app/api/files/export/[id]/route.test.ts | 117 +++++++++++++++++- apps/sim/app/api/files/export/[id]/route.ts | 42 +++++-- .../workspace/[workspaceId]/files/files.tsx | 15 +++ apps/sim/lib/uploads/client/download.ts | 21 ++-- 6 files changed, 257 insertions(+), 27 deletions(-) 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 index af3d81cfbd5..b6afb082a81 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -40,7 +40,7 @@ Smart quotes β€œwork”, Greek Ξ© stays readable, and unsupported emoji πŸš€ fal const exported = true \`\`\` -![Embedded image](/api/files/view/image-1) +![Embedded image](/workspace/ws-1/files/image-1) ${repeatedParagraphs}` @@ -57,4 +57,19 @@ ${repeatedParagraphs}` expect(document.getTitle()).toBe('Export title') expect(document.getPageCount()).toBeGreaterThan(1) }) + + 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([['image-1', oversizedSvg]]), + }) + + expect(buffer.subarray(0, 4).toString()).toBe('%PDF') + expect((await PDFDocument.load(buffer)).getPageCount()).toBe(1) + }) }) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index c4fcfd79078..9157d116c87 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -14,6 +14,7 @@ import { } from '@react-pdf/renderer' import { marked, type Token, type Tokens } from 'marked' import sharp from 'sharp' +import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' type PdfImage = { data: Buffer; format: 'png' } @@ -25,6 +26,18 @@ const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') +/** + * PDF images never render wider than the A4 content box, so retaining camera-resolution + * rasters only increases Sharp and React PDF work. The dimension matches the app's existing + * inline-image preparation ceiling; the aggregate budgets bound work across a document. + */ +const MAX_PDF_IMAGE_DIMENSION = 1568 +const MAX_PDF_IMAGE_INPUT_PIXELS = 268_402_689 +const MAX_PDF_TOTAL_INPUT_PIXELS = 268_402_689 +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 + Font.register({ family: 'Geist', fonts: [ @@ -148,15 +161,8 @@ function safeLink(href: string): string | undefined { } function embeddedImageId(href: string): string | undefined { - const match = - href.match(/\/api\/files\/view\/([^/?#]+)/) ?? - href.match(/\/workspace\/[^/]+\/files\/([^/?#]+)/) - if (!match?.[1]) return undefined - try { - return decodeURIComponent(match[1]) - } catch { - return match[1] - } + const ref = extractEmbeddedFileRef(href) + return ref && 'fileId' in ref ? ref.fileId : undefined } function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { @@ -423,9 +429,55 @@ async function normalizeImages( images: ReadonlyMap ): Promise> { const normalized = new Map() + let totalInputPixels = 0 + let totalOutputPixels = 0 + let totalImageBytes = 0 + for (const [id, buffer] of images) { try { - normalized.set(id, { data: await sharp(buffer).rotate().png().toBuffer(), format: 'png' }) + const pipeline = sharp(buffer, { limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS }) + const metadata = await pipeline.metadata() + if (!metadata.width || !metadata.height) continue + + const inputPixels = metadata.width * metadata.height + if ( + !Number.isSafeInteger(inputPixels) || + totalInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS + ) { + continue + } + totalInputPixels += inputPixels + + 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() + if ( + data.length > MAX_PDF_IMAGE_BYTES || + totalImageBytes + data.length > MAX_PDF_TOTAL_IMAGE_BYTES + ) { + continue + } + + totalOutputPixels += outputPixels + totalImageBytes += data.length + normalized.set(id, { data, format: 'png' }) } catch { // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. } 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 78ddfe77f84..ed85e2e2f59 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -13,6 +13,9 @@ const { mockDownloadFile, mockExtractEmbeddedImageIds, mockRenderMarkdownPdf, + mockEnforceUserRateLimit, + mockRecordAudit, + mockCaptureServerEvent, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), @@ -20,6 +23,9 @@ const { mockDownloadFile: vi.fn(), mockExtractEmbeddedImageIds: vi.fn(), mockRenderMarkdownPdf: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -34,12 +40,15 @@ vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ renderMarkdownPdf: mockRenderMarkdownPdf, })) +vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) 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' @@ -90,6 +99,7 @@ describe('markdown export bundling', () => { mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) mockExtractEmbeddedImageIds.mockReturnValue([]) mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + mockEnforceUserRateLimit.mockResolvedValue(null) }) it('returns the stored Markdown unchanged when no format is requested', async () => { @@ -100,6 +110,7 @@ describe('markdown export bundling', () => { expect(response.headers.get('Content-Disposition')).toContain('doc.md') expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('# Doc\n') expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + expect(mockEnforceUserRateLimit).not.toHaveBeenCalled() }) it('renders Markdown as a directly downloadable PDF', async () => { @@ -115,6 +126,23 @@ describe('markdown export bundling', () => { images: expect.any(Map), }) expect(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) + expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { + maxTokens: 3, + refillRate: 3, + refillIntervalMs: 60_000, + }) + }) + + 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('passes only authorized, readable embedded images to the PDF renderer', async () => { @@ -134,6 +162,37 @@ describe('markdown export bundling', () => { expect(images.get('good')).toEqual(Buffer.from('png-bytes')) }) + it('records an image-containing PDF as one downloaded file', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + + await GET(request('pdf'), context) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ assetCount: 1, format: 'pdf' }), + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 1, is_bulk: false }), + { groups: { workspace: 'ws-1' } } + ) + }) + + it('keeps image-containing ZIP telemetry bulk', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + + await GET(request(), context) + + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + expect.objectContaining({ file_count: 2, is_bulk: true }), + { groups: { workspace: 'ws-1' } } + ) + }) + it('rejects PDF format for a non-Markdown file', async () => { mockGetFileMetadataById.mockResolvedValue({ id: DOC_ID, @@ -197,6 +256,25 @@ describe('markdown export bundling', () => { expect(bodyCall?.[0].maxBytes).toBe(250 * MB) }) + it('uses a smaller document limit for PDF rendering', async () => { + await GET(request('pdf'), context) + + const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) + expect(bodyCall?.[0].maxBytes).toBe(256 * 1024) + }) + + it('reports an oversized PDF body with the PDF-specific limit', async () => { + mockDownloadFile.mockRejectedValue( + new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('256 KB PDF export limit') + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + it('reports an oversized body as a size rejection, not a server error', async () => { mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( @@ -221,6 +299,41 @@ describe('markdown export bundling', () => { expect(assetCall?.[0].maxBytes).toBe(25 * MB) }) + it('uses a smaller per-asset limit for PDF rendering', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['a']) + + await GET(request('pdf'), context) + + const assetCall = mockDownloadFile.mock.calls.find( + ([options]) => options.key === 'workspace/ws-1/a' + ) + expect(assetCall?.[0].maxBytes).toBe(10 * MB) + }) + + it('rejects PDF source material above its aggregate input limit', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b']) + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 30 * 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(mockDownloadFile).toHaveBeenCalledTimes(1) + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + }) + it('drops an unreadable asset instead of failing the whole export', async () => { mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 8bbf7460def..a3ea5e8c110 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' @@ -35,6 +37,17 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 +/** Matches the editor's p99-plus document ceiling; larger files would create an unbounded PDF layout tree. */ +const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 +const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 +const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 + +/** PDF rendering is CPU-bound and buffers its result, so it gets a narrower request bucket than downloads. */ +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']) @@ -110,13 +123,14 @@ export const GET = withRouteHandler( }, request, }) + const downloadedFileCount = format === 'zip' ? 1 + assetCount : 1 captureServerEvent( userId, 'file_downloaded', { ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), - is_bulk: assetCount > 0, - file_count: 1 + assetCount, + is_bulk: downloadedFileCount > 1, + file_count: downloadedFileCount, }, record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined ) @@ -135,22 +149,35 @@ export const GET = withRouteHandler( return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) } + if (format === 'pdf') { + const rateLimited = await enforceUserRateLimit( + 'markdown-pdf-export', + userId, + PDF_EXPORT_RATE_LIMIT + ) + if (rateLimited) return rateLimited + } + // Capped like everything else in the bundle: the document body is usually the // largest single entry, so leaving it unbounded left the export limit unenforced // against the one item most able to exceed it. A body that alone exceeds the limit // is a size rejection, so it reports as one rather than as a server error. let mdBuffer: Buffer + const documentLimit = format === 'pdf' ? MAX_PDF_MARKDOWN_BYTES : MAX_EXPORT_TOTAL_BYTES try { mdBuffer = await downloadFile({ key: record.key, context: record.context as StorageContext, - maxBytes: MAX_EXPORT_TOTAL_BYTES, + maxBytes: documentLimit, }) } catch (error) { if (!isPayloadSizeLimitError(error)) throw error return NextResponse.json( { - error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + error: + format === 'pdf' + ? `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.` + : `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, }, { status: 400 } ) @@ -218,10 +245,11 @@ export const GET = withRouteHandler( // limit that measured only the attachments would not describe the archive produced. const bundleBytes = mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) - if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) { + const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES + if (bundleBytes > bundleLimit) { return NextResponse.json( { - error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(bundleLimit)} ${format === 'pdf' ? 'PDF ' : ''}export limit.`, }, { status: 400 } ) @@ -235,7 +263,7 @@ export const GET = withRouteHandler( const buffer = await downloadFile({ key: imgRecord.key, context: imgRecord.context as StorageContext, - maxBytes: MAX_EXPORT_ASSET_BYTES, + maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, }) return { imageId, originalName: imgRecord.originalName, buffer } } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 020447f5651..681061e9435 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -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()) @@ -1063,6 +1065,12 @@ export function Files() { const handleDownload = useCallback( async (file: WorkspaceFileRecord, format?: 'pdf') => { + const isPdf = format === 'pdf' + if (isPdf) { + if (pdfDownloadPendingRef.current) return + pdfDownloadPendingRef.current = true + setPdfDownloadPending(true) + } try { await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { @@ -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] @@ -1638,6 +1651,7 @@ export function Files() { text: 'Download PDF', icon: FileText, onSelect: handleDownloadPdfSelected, + disabled: pdfDownloadPending, }, ] : []), @@ -1667,6 +1681,7 @@ export function Files() { handleDownloadPdfSelected, handleShareSelected, handleDeleteSelected, + pdfDownloadPending, ]) const listRenameRef = useRef(listRename) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 8c8e519b5c2..97655058229 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' @@ -43,13 +44,19 @@ export async function triggerFileDownload( throw new Error('PDF export is only available for Markdown files') } - const url = isMarkdown - ? `/api/files/export/${encodeURIComponent(record.id)}${options?.format === 'pdf' ? '?format=pdf' : ''}` - : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` - - // boundary-raw-fetch: binary download read as a blob; these paths have no contract - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + let response: Response + if (isMarkdown) { + response = await requestRaw( + fileExportContract, + { params: { id: record.id }, query: { format: options?.format } }, + { cache: 'no-store' } + ) + } else { + const url = `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + // boundary-raw-fetch: legacy binary serve URL includes context and cache-busting query fields outside the serve contract + response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + } const fallbackName = options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name From aa8ddeb0ac388097af6adfe671ceecf6e3de643d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 19:38:23 -0700 Subject: [PATCH 04/13] fix(files): align PDF export with Markdown editor --- .../files/export/[id]/markdown-pdf.test.ts | 81 ++- .../api/files/export/[id]/markdown-pdf.tsx | 628 ++++++++++++------ .../app/api/files/export/[id]/route.test.ts | 175 +++-- apps/sim/app/api/files/export/[id]/route.ts | 100 +-- apps/sim/lib/collab-doc/server-markdown.ts | 22 + .../lib/uploads/server/inline-image.test.ts | 4 + apps/sim/lib/uploads/server/inline-image.ts | 12 +- .../uploads/utils/embedded-image-ref.test.ts | 32 + .../lib/uploads/utils/embedded-image-ref.ts | 27 +- apps/sim/next.config.ts | 1 + apps/sim/package.json | 5 +- bun.lock | 13 +- docker/app.Dockerfile | 5 + 13 files changed, 781 insertions(+), 324 deletions(-) create mode 100644 apps/sim/lib/collab-doc/server-markdown.ts 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 index b6afb082a81..53c88adfffd 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -2,12 +2,30 @@ * @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 { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' +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, @@ -26,7 +44,9 @@ describe('Markdown PDF rendering', () => { > A useful blockquote with a [link](https://sim.ai). -Smart quotes β€œwork”, Greek Ξ© stays readable, and unsupported emoji πŸš€ falls back safely. +Smart quotes β€œwork” and Greek Ξ© stays readable. + +δΈ­ζ–‡ζŽ’η‰ˆεΊ”θ―₯ζΈ…ζ™°ζ˜“θ―»γ€‚ Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ© يجب Ψ£Ω† ΨͺΩƒΩˆΩ† Ω…ΨͺΨ΅Ω„Ψ© ΩˆΩ…Ω‚Ψ±ΩˆΨ‘Ψ©. ΰ€Ήΰ€Ώΰ€¨ΰ₯ΰ€¦ΰ₯€ ΰ€ͺΰ€Ύΰ€  ΰ€Έΰ₯ΰ€ͺΰ€·ΰ₯ΰ€Ÿ ΰ€”ΰ€° ΰ€ͺΰ€ ΰ€¨ΰ₯€ΰ€― ΰ€Ήΰ₯‹ΰ€¨ΰ€Ύ ΰ€šΰ€Ύΰ€Ήΰ€Ώΰ€ΰ₯€ Χ’Χ‘Χ¨Χ™Χͺ Χ¦Χ¨Χ™Χ›Χ” ΧœΧ”Χ™Χ•Χͺ Χ‘Χ¨Χ•Χ¨Χ” וקריאה. - First item - Second item @@ -40,14 +60,16 @@ Smart quotes β€œwork”, Greek Ξ© stays readable, and unsupported emoji πŸš€ fal const exported = true \`\`\` -![Embedded image](/workspace/ws-1/files/image-1) +![Embedded image](${imageUrl}) + +Resized image ${repeatedParagraphs}` const buffer = await renderMarkdownPdf({ markdown, title: 'Export title', - images: new Map([['image-1', image]]), + images: new Map([[`key:${imageKey}`, image]]), }) expect(buffer.subarray(0, 4).toString()).toBe('%PDF') @@ -56,6 +78,46 @@ ${repeatedParagraphs}` 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).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('keeps long table rows together and repeats the header across table pages', 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(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) }) it('falls back instead of decoding an image above the pixel ceiling', async () => { @@ -66,10 +128,19 @@ ${repeatedParagraphs}` const buffer = await renderMarkdownPdf({ markdown: '![Too large](/api/files/view/image-1)', title: 'Bounded image', - images: new Map([['image-1', oversizedSvg]]), + 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 index 9157d116c87..eb14fa5e6dd 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import type { ReactNode } from 'react' @@ -12,9 +13,11 @@ import { Text, View, } from '@react-pdf/renderer' -import { marked, type Token, type Tokens } from 'marked' +import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' -import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' +import { embeddedFileRefKey, extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' type PdfImage = { data: Buffer; format: 'png' } @@ -22,21 +25,80 @@ interface GlyphFont { hasGlyphForCodePoint(codePoint: number): boolean } -const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') -const GEIST_REGULAR = join(FONT_DIR, 'Geist-Regular.ttf') -const GEIST_MEDIUM = join(FONT_DIR, 'Geist-Medium.ttf') +interface PdfFont { + family: string + glyphs: GlyphFont +} + +const require = createRequire(import.meta.url) + +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, so retaining camera-resolution - * rasters only increases Sharp and React PDF work. The dimension matches the app's existing - * inline-image preparation ceiling; the aggregate budgets bound work across a document. + * 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 = 268_402_689 -const MAX_PDF_TOTAL_INPUT_PIXELS = 268_402_689 +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 MAX_UNBREAKABLE_TABLE_HEIGHT = 620 +const PDF_TABLE_CONTENT_WIDTH = 499 Font.register({ family: 'Geist', @@ -47,10 +109,31 @@ Font.register({ { 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 require = createRequire(import.meta.url) 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 @@ -58,6 +141,29 @@ export interface MarkdownPdfInput { 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 +} + +interface TableChunk { + rows: JSONContent[] + unbreakable: boolean +} + const styles = StyleSheet.create({ page: { backgroundColor: '#ffffff', @@ -79,13 +185,14 @@ const styles = StyleSheet.create({ strong: { fontWeight: 700 }, emphasis: { fontStyle: 'italic' }, deleted: { textDecoration: 'line-through' }, + highlighted: { backgroundColor: '#fff3bf' }, inlineCode: { backgroundColor: '#f1f3f5', color: '#24292f', - fontFamily: 'Geist', fontSize: 9, }, link: { color: '#0969da', textDecoration: 'underline' }, + mention: { backgroundColor: '#f1f3f5', color: '#404040' }, blockquote: { borderLeftColor: '#b6bec8', borderLeftWidth: 2, @@ -99,7 +206,6 @@ const styles = StyleSheet.create({ borderRadius: 3, borderWidth: 0.5, color: '#24292f', - fontFamily: 'Geist', fontSize: 8.5, lineHeight: 1.35, marginBottom: 10, @@ -133,22 +239,95 @@ const styles = StyleSheet.create({ marginBottom: 9, padding: 8, }, - htmlFallback: { color: '#57606a', marginBottom: 9 }, + sourceFallback: { + backgroundColor: '#f6f8fa', + color: '#57606a', + fontSize: 8.5, + marginBottom: 9, + padding: 8, + }, }) -function safeText(value: string): string { - // React PDF's standard fonts can map a missing glyph to an unrelated visible character. - // Use the same bundled font for measurement and rendering, with an explicit readable fallback. - let safe = '' - for (const character of value) { +function fontRuns(value: string): FontRun[] { + const characters = Array.from(value, (character) => { const codePoint = character.codePointAt(0) - safe += codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) ? character : '?' + const fallback = + codePoint === undefined + ? undefined + : (staticFallbackFonts.find(({ glyphs }) => glyphs.hasGlyphForCodePoint(codePoint)) ?? + (unifont.glyphs.hasGlyphForCodePoint(codePoint) ? unifont : undefined)) + const family = + codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) + ? 'Geist' + : (fallback?.family ?? 'Unifont') + return { + family, + neutral: /^[\p{N}\p{P}\p{Z}\s]$/u.test(character), + text: family === 'Geist' || fallback ? character : 'οΏ½', + } + }) + + for (const [index, character] of characters.entries()) { + if (character.family !== 'Geist' || !character.neutral) continue + + let previous = index - 1 + while (previous >= 0 && characters[previous].neutral) previous -= 1 + let next = index + 1 + while (next < characters.length && characters[next].neutral) next += 1 + const previousFamily = characters[previous]?.family + const nextFamily = characters[next]?.family + if (previousFamily && previousFamily !== 'Geist' && previousFamily === nextFamily) { + character.family = previousFamily + } else if (previousFamily && previousFamily !== 'Geist' && next >= characters.length) { + character.family = previousFamily + } + } + + 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) } - return safe + visit(document) } -function plainHtml(value: string): string { - return safeText(value.replace(/<[^>]*>/g, '').trim()) +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 { @@ -160,159 +339,131 @@ function safeLink(href: string): string | undefined { } } -function embeddedImageId(href: string): string | undefined { - const ref = extractEmbeddedFileRef(href) - return ref && 'fileId' in ref ? ref.fileId : 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) -function renderInline(tokens: Token[], keyPrefix: string): ReactNode[] { - return tokens.map((token, index) => { - const key = `${keyPrefix}-${index}` - switch (token.type) { - case 'text': - return token.tokens?.length ? renderInline(token.tokens, key) : safeText(token.text) - case 'escape': - return safeText(token.text) - case 'strong': { - const strong = token as Tokens.Strong - return ( - - {renderInline(strong.tokens, key)} - - ) - } - case 'em': { - const emphasis = token as Tokens.Em - return ( - - {renderInline(emphasis.tokens, key)} - - ) - } - case 'del': { - const deleted = token as Tokens.Del - return ( - - {renderInline(deleted.tokens, key)} - - ) - } - case 'codespan': - return ( - - {safeText(token.text)} - - ) - case 'br': - return '\n' - case 'link': { - const link = token as Tokens.Link - const href = safeLink(link.href) - const content = renderInline(link.tokens, key) - return href ? ( - - {content} - - ) : ( - - {content} - - ) - } - case 'image': - return safeText(token.text || token.href) - case 'html': - return plainHtml(token.text) - case 'checkbox': - return token.checked ? '[x] ' : '[ ] ' - default: - return 'text' in token && typeof token.text === 'string' ? safeText(token.text) : '' + 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 directImage(token: Token): Tokens.Image | undefined { - if (token.type === 'image') return token as Tokens.Image - if (token.type === 'link') { - const link = token as Tokens.Link - if (link.tokens.length === 1 && link.tokens[0]?.type === 'image') { - return link.tokens[0] as Tokens.Image - } - } - return undefined +function renderInline(keyPrefix: string, nodes: JSONContent[] = []): ReactNode[] { + return nodes.map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`)) } -function renderImage(token: Tokens.Image, images: ReadonlyMap, key: string) { - const id = embeddedImageId(token.href) - const image = id ? images.get(id) : undefined +function renderImage( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + const src = stringAttr(node, 'src') ?? '' + const ref = extractEmbeddedFileRef(src) + const image = ref ? images.get(embeddedFileRefKey(ref)) : undefined if (!image) { + const alt = stringAttr(node, 'alt') return ( - {token.text ? `Image: ${safeText(token.text)}` : 'Image unavailable'} + {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 return ( - + ) } -function renderParagraph( - tokens: Token[], +function renderList( + node: JSONContent, images: ReadonlyMap, - keyPrefix: string -): ReactNode[] { - const output: ReactNode[] = [] - let inline: Token[] = [] - - const flushInline = () => { - if (inline.length === 0) return - output.push( - - {renderInline(inline, `${keyPrefix}-inline-${output.length}`)} - - ) - inline = [] - } - - for (const token of tokens) { - const image = directImage(token) - if (!image) { - inline.push(token) - continue - } - flushInline() - output.push(renderImage(image, images, `${keyPrefix}-image-${output.length}`)) - } - flushInline() - return output -} - -function renderList(token: Tokens.List, images: ReadonlyMap, key: string) { - const start = typeof token.start === 'number' ? token.start : 1 + 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 ( - {token.items.map((item, index) => { - const marker = item.task - ? item.checked + {(node.content ?? []).map((item, index) => { + const marker = task + ? item.attrs?.checked ? '[x]' : '[ ]' - : token.ordered + : ordered ? `${start + index}.` : '-' return ( {marker} - {item.tokens.map((itemToken, tokenIndex) => - itemToken.type === 'text' ? ( - - {renderInline(itemToken.tokens ?? [itemToken], `${key}-${index}-${tokenIndex}`)} + {(item.content ?? []).map((child, childIndex) => + child.type === 'paragraph' ? ( + + {renderInline(`${key}-${index}-${childIndex}`, child.content)} ) : ( - renderBlock(itemToken, images, `${key}-${index}-${tokenIndex}`) + renderBlock(child, images, `${key}-${index}-${childIndex}`) ) )} @@ -323,103 +474,154 @@ function renderList(token: Tokens.List, images: ReadonlyMap, k ) } -function renderTable(token: Tokens.Table, key: string) { - const row = (cells: Tokens.TableCell[], rowKey: string, header: boolean) => ( - - {cells.map((cell, index) => ( +function estimateTableRowHeight(row: JSONContent, columnCount: number): number { + const columnWidth = PDF_TABLE_CONTENT_WIDTH / Math.max(columnCount, 1) + const charactersPerLine = Math.max(8, Math.floor(columnWidth / 4.8)) + const lines = Math.max( + 1, + ...(row.content ?? []).map((cell) => + Math.ceil(Math.max(nodeText(cell).length, 1) / charactersPerLine) + ) + ) + return 10 + lines * 12.5 +} + +function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { + const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 + const headerHeight = estimateTableRowHeight(header, columnCount) + const chunks: TableChunk[] = [] + let current: JSONContent[] = [] + let currentHeight = headerHeight + + const flush = () => { + if (current.length === 0) return + chunks.push({ rows: current, unbreakable: currentHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }) + current = [] + currentHeight = headerHeight + } + + for (const row of rows) { + const rowHeight = estimateTableRowHeight(row, columnCount) + if (current.length > 0 && currentHeight + rowHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() + current.push(row) + currentHeight += rowHeight + if (rowHeight + headerHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() + } + flush() + return chunks +} + +function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { + return ( + + {(row.content ?? []).map((cell, index) => ( - {renderInline(cell.tokens, `${rowKey}-${index}`)} + {(cell.content ?? []).map((child, childIndex) => ( + + {childIndex > 0 ? '\n' : null} + {renderInline(`${key}-${index}-${childIndex}`, child.content)} + + ))} ))} ) +} - return ( - - {row(token.header, `${key}-header`, true)} - {token.rows.map((cells, index) => row(cells, `${key}-row-${index}`, false))} +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 + const header = hasHeader ? firstRow : { type: 'tableRow', content: [] } + const bodyRows = hasHeader ? rows.slice(1) : rows + const chunks = hasHeader + ? chunkTableRows(header, bodyRows) + : [{ rows: bodyRows, unbreakable: false }] + + return chunks.map((chunk, index) => ( + + {hasHeader ? renderTableRow(header, `${key}-${index}-header`, true) : null} + {chunk.rows.map((row, rowIndex) => + renderTableRow(row, `${key}-${index}-row-${rowIndex}`, false) + )} - ) + )) } -function renderBlock(token: Token, images: ReadonlyMap, key: string): ReactNode { - switch (token.type) { - case 'space': - case 'def': - return null +function renderBlock( + node: JSONContent, + images: ReadonlyMap, + key: string +): ReactNode { + switch (node.type) { + case 'paragraph': + return ( + + {renderInline(key, node.content)} + + ) case 'heading': { - const heading = token as Tokens.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(heading.depth, 1), 6) - 1 + Math.min(Math.max(level, 1), 6) - 1 ] return ( - {renderInline(heading.tokens, key)} + {renderInline(key, node.content)} ) } - case 'paragraph': { - const paragraph = token as Tokens.Paragraph - return {renderParagraph(paragraph.tokens, images, key)} - } - case 'text': + case 'bulletList': + case 'orderedList': + case 'taskList': + return renderList(node, images, key) + case 'blockquote': return ( - - {renderInline(token.tokens ?? [token], key)} - + + {(node.content ?? []).map((child, index) => + renderBlock(child, images, `${key}-${index}`) + )} + ) - case 'code': + case 'codeBlock': return ( - {safeText(token.text)} + {renderText(nodeText(node), key)} ) - case 'blockquote': { - const blockquote = token as Tokens.Blockquote - return ( - - {blockquote.tokens.map((child, index) => renderBlock(child, images, `${key}-${index}`))} - - ) - } - case 'list': - return renderList(token as Tokens.List, images, key) case 'table': - return renderTable(token as Tokens.Table, key) - case 'hr': + return renderTable(node, key) + case 'horizontalRule': return - case 'html': { - const text = plainHtml(token.text) - return text ? ( - - {text} + case 'image': + return renderImage(node, images, key) + case 'rawHtmlBlock': + case 'footnoteDef': + return ( + + {renderText(nodeText(node), key)} - ) : null - } - default: - return 'text' in token && typeof token.text === 'string' ? ( + ) + default: { + const text = nodeText(node) + return text ? ( - {safeText(token.text)} + {renderText(text, key)} ) : null + } } } -interface MarkdownDocumentProps { - markdown: string - title: string - images: ReadonlyMap -} - -function MarkdownDocument({ markdown, title, images }: MarkdownDocumentProps) { - const tokens = marked.lexer(markdown, { gfm: true }) +function MarkdownDocument({ document, title, images }: MarkdownDocumentProps) { return ( - + - {tokens.map((token, index) => renderBlock(token, images, `block-${index}`))} + {(document.content ?? []).map((node, index) => renderBlock(node, images, `block-${index}`))} ) @@ -433,9 +635,12 @@ async function normalizeImages( let totalOutputPixels = 0 let totalImageBytes = 0 - for (const [id, buffer] of images) { + for (const [imageKey, buffer] of images) { try { - const pipeline = sharp(buffer, { limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS }) + const pipeline = sharp(buffer, { + limitInputPixels: MAX_PDF_IMAGE_INPUT_PIXELS, + sequentialRead: true, + }) const metadata = await pipeline.metadata() if (!metadata.width || !metadata.height) continue @@ -477,7 +682,7 @@ async function normalizeImages( totalOutputPixels += outputPixels totalImageBytes += data.length - normalized.set(id, { data, format: 'png' }) + normalized.set(imageKey, { data, format: 'png' }) } catch { // Keep the PDF usable when an otherwise downloadable attachment is not a renderable image. } @@ -491,7 +696,10 @@ export async function renderMarkdownPdf({ images = new Map(), }: MarkdownPdfInput): Promise { const normalizedImages = await normalizeImages(images) + const { body } = splitFrontmatter(markdown) + const document = parseServerMarkdownToDoc(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 ed85e2e2f59..5f19013ff16 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -11,21 +11,23 @@ const { mockGetFileMetadataById, mockVerifyFileAccess, mockDownloadFile, - mockExtractEmbeddedImageIds, + mockResolveWorkspaceInlineImage, mockRenderMarkdownPdf, mockEnforceUserRateLimit, mockRecordAudit, mockCaptureServerEvent, + MockMarkdownPdfLimitError, } = vi.hoisted(() => ({ mockCheckAuth: vi.fn(), mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), - mockExtractEmbeddedImageIds: vi.fn(), + mockResolveWorkspaceInlineImage: vi.fn(), mockRenderMarkdownPdf: vi.fn(), mockEnforceUserRateLimit: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), + MockMarkdownPdfLimitError: class extends Error {}, })) vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckAuth })) @@ -34,11 +36,12 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) -vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ - extractEmbeddedImageIds: mockExtractEmbeddedImageIds, +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveWorkspaceInlineImage, })) vi.mock('@/app/api/files/export/[id]/markdown-pdf', () => ({ renderMarkdownPdf: mockRenderMarkdownPdf, + MarkdownPdfLimitError: MockMarkdownPdfLimitError, })) vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, @@ -66,38 +69,40 @@ function request(format?: 'pdf') { ) } -function assetRecord(id: string, size: number) { +function inlineImage(id: string, size = 1 * MB) { return { - id, key: `workspace/ws-1/${id}`, - originalName: `${id}.png`, + filename: id.endsWith('.png') ? id : `${id}.png`, contentType: 'image/png', - context: 'workspace', size, - workspaceId: 'ws-1', } } +function markdownWithIds(...ids: string[]): Buffer { + return Buffer.from(ids.map((id) => `![${id}](/api/files/view/${id})`).join('\n')) +} + describe('markdown export bundling', () => { beforeEach(() => { vi.clearAllMocks() mockCheckAuth.mockResolvedValue({ success: true, userId: 'user-1' }) mockVerifyFileAccess.mockResolvedValue(true) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 1 * MB) + mockGetFileMetadataById.mockResolvedValue({ + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + }) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId?: string; key?: string }) => { + const id = ref.fileId ?? ref.key?.split('/').at(-1) ?? 'image' + return inlineImage(id) + } ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) - mockExtractEmbeddedImageIds.mockReturnValue([]) mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) mockEnforceUserRateLimit.mockResolvedValue(null) }) @@ -145,11 +150,22 @@ describe('markdown export bundling', () => { expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) + it('returns a clear rejection when the parsed document exceeds renderer limits', 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() + }) + it('passes only authorized, readable embedded images to the PDF renderer', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['good', 'secret', 'broken']) mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return Buffer.from('![image](/api/files/view/good)') + if (key.endsWith('doc.md')) return markdownWithIds('good', 'secret', 'broken') if (key.endsWith('broken')) throw new Error('storage down') return Buffer.from('png-bytes') }) @@ -158,12 +174,30 @@ describe('markdown export bundling', () => { expect(response.status).toBe(200) const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(Array.from(images.keys())).toEqual(['good']) - expect(images.get('good')).toEqual(Buffer.from('png-bytes')) + expect(Array.from(images.keys())).toEqual(['id:good']) + expect(images.get('id:good')).toEqual(Buffer.from('png-bytes')) + }) + + it('resolves the key-based image URL emitted by the Files editor', async () => { + const key = 'workspace/ws-1/editor-image.png' + mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => + requestedKey.endsWith('doc.md') + ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + : Buffer.from('png-bytes') + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceInlineImage).toHaveBeenCalledWith('ws-1', { key }) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(images.get(`key:${key}`)).toEqual(Buffer.from('png-bytes')) }) it('records an image-containing PDF as one downloaded file', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') + ) await GET(request('pdf'), context) @@ -181,7 +215,9 @@ describe('markdown export bundling', () => { }) it('keeps image-containing ZIP telemetry bulk', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['image-1']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') + ) await GET(request(), context) @@ -213,19 +249,9 @@ describe('markdown export bundling', () => { }) it('rejects on declared asset bytes before downloading any of them', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c']) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 100 * MB) + mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b', 'c')) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 100 * MB) ) const response = await GET(request(), context) @@ -238,8 +264,9 @@ describe('markdown export bundling', () => { it('counts the document body against the export limit, not just its assets', async () => { // Assets alone sit under the cap; the body is what carries the bundle over it. - mockExtractEmbeddedImageIds.mockReturnValue(['a']) - mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB)) + const body = Buffer.alloc(250 * MB) + markdownWithIds('a').copy(body) + mockDownloadFile.mockResolvedValue(body) const response = await GET(request(), context) @@ -248,8 +275,6 @@ describe('markdown export bundling', () => { }) it('caps the document body read rather than loading it unbounded', async () => { - mockExtractEmbeddedImageIds.mockReturnValue([]) - await GET(request(), context) const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) @@ -276,7 +301,6 @@ describe('markdown export bundling', () => { }) it('reports an oversized body as a size rejection, not a server error', async () => { - mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) ) @@ -289,7 +313,9 @@ describe('markdown export bundling', () => { }) it('caps each asset download rather than trusting its declared size', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') + ) await GET(request(), context) @@ -300,7 +326,9 @@ describe('markdown export bundling', () => { }) it('uses a smaller per-asset limit for PDF rendering', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') + ) await GET(request('pdf'), context) @@ -311,19 +339,9 @@ describe('markdown export bundling', () => { }) it('rejects PDF source material above its aggregate input limit', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b']) - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === DOC_ID - ? { - id: DOC_ID, - key: 'workspace/ws-1/doc.md', - originalName: 'doc.md', - contentType: 'text/markdown', - context: 'workspace', - size: 1024, - workspaceId: 'ws-1', - } - : assetRecord(id, 30 * MB) + mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b')) + mockResolveWorkspaceInlineImage.mockImplementation( + async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 30 * MB) ) const response = await GET(request('pdf'), context) @@ -334,10 +352,37 @@ describe('markdown export bundling', () => { expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) + it('enforces the aggregate PDF budget against downloaded bytes, not only metadata', async () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f'] + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds(...ids) : Buffer.alloc(10 * MB) + ) + + const response = await GET(request('pdf'), context) + + expect(response.status).toBe(200) + const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map + expect(images.size).toBe(4) + }) + + it('rewrites the editor key URL when producing a Markdown asset ZIP', async () => { + const key = 'workspace/ws-1/editor-image.png' + mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => + requestedKey.endsWith('doc.md') + ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + : Buffer.from('png-bytes') + ) + + const response = await GET(request(), context) + + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/editor-image.png)') + expect(zip.file('assets/editor-image.png')).not.toBeNull() + }) + it('drops an unreadable asset instead of failing the whole export', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return Buffer.from('# Doc\n![x](/api/files/view/good)\n') + if (key.endsWith('doc.md')) return markdownWithIds('good', 'bad') if (key.endsWith('bad')) throw new Error('storage down') return Buffer.from('png-bytes') }) @@ -351,8 +396,10 @@ describe('markdown export bundling', () => { }) it('skips an asset the caller cannot read', async () => { - mockExtractEmbeddedImageIds.mockReturnValue(['secret']) mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key.endsWith('doc.md') ? markdownWithIds('secret') : Buffer.from('asset') + ) const response = await GET(request(), context) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index a3ea5e8c110..a92ad0fa9bc 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -8,7 +8,6 @@ import { NextResponse } from 'next/server' 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' @@ -18,10 +17,16 @@ 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 { + embeddedFileRefKey, + extractEmbeddedFileRefs, + type ResolvedEmbeddedFileRef, + replaceEmbeddedFileRefs, +} from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' -import { renderMarkdownPdf } from '@/app/api/files/export/[id]/markdown-pdf' import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') @@ -37,7 +42,7 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 -/** Matches the editor's p99-plus document ceiling; larger files would create an unbounded PDF layout tree. */ +/** PDF-specific document ceiling that bounds parser and layout-tree work. */ const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 @@ -65,13 +70,13 @@ function safeFilename(name: string): string { .replace(/[\r\n\t]/g, '') } -function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { +function deduplicatedFilename(preferred: string, existing: Set): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) - const short = `${base}_${imageId.slice(0, 8)}${ext}` - if (!existing.has(short)) return short - return `${base}_${imageId}${ext}` + let suffix = 2 + while (existing.has(`${base}_${suffix}${ext}`)) suffix += 1 + return `${base}_${suffix}${ext}` } export const GET = withRouteHandler( @@ -184,18 +189,33 @@ export const GET = withRouteHandler( } let mdContent = mdBuffer.toString('utf-8') - const imageIds = extractEmbeddedImageIds(mdContent) + const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) + const imageRefs: ResolvedEmbeddedFileRef[] = [ + ...imageKeys.map((key) => ({ key })), + ...imageIds.map((fileId) => ({ fileId })), + ] logger.info('Exporting markdown', { id, format: format ?? 'source', - imageCount: imageIds.length, + imageCount: imageRefs.length, }) const respondWithPdf = async (images: ReadonlyMap) => { + const { MarkdownPdfLimitError, renderMarkdownPdf } = await import( + '@/app/api/files/export/[id]/markdown-pdf' + ) const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') const pdfName = safeFilename(`${title}.pdf`) - const pdfBuffer = await renderMarkdownPdf({ markdown: mdContent, title, images }) + 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 + } auditExport('pdf', images.size) return new NextResponse(new Uint8Array(pdfBuffer), { status: 200, @@ -207,7 +227,7 @@ export const GET = withRouteHandler( }) } - if (imageIds.length === 0) { + if (imageRefs.length === 0) { if (format === 'pdf') return respondWithPdf(new Map()) const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') @@ -225,15 +245,15 @@ export const GET = withRouteHandler( // Metadata first: declared sizes bound the download before a byte is read, and the // authorization check costs nothing to run here. const assetTargets = ( - await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => { + await mapWithConcurrency(imageRefs, MATERIALIZE_CONCURRENCY, async (ref) => { try { - const imgRecord = await getFileMetadataById(imageId) - if (!imgRecord) return null - if (!(await verifyFileAccess(imgRecord.key, userId))) return null - return { imageId, record: imgRecord } + if (!record.workspaceId) return null + const image = await resolveWorkspaceInlineImage(record.workspaceId, ref) + if (!image || !(await verifyFileAccess(image.key, userId))) return null + return { imageKey: embeddedFileRefKey(ref), image } } catch (error) { logger.warn('Failed to resolve asset for export', { - imageId, + imageRef: embeddedFileRefKey(ref), error: toError(error).message, }) return null @@ -244,7 +264,7 @@ export const GET = withRouteHandler( // The body counts against the same budget as its assets β€” the zip holds both, so a // limit that measured only the attachments would not describe the archive produced. const bundleBytes = - mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) + mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.image.size, 0) const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES if (bundleBytes > bundleLimit) { return NextResponse.json( @@ -255,22 +275,33 @@ export const GET = withRouteHandler( ) } + let actualBundleBytes = mdBuffer.length const fetched = await mapWithConcurrency( assetTargets, - MATERIALIZE_CONCURRENCY, - async ({ imageId, record: imgRecord }) => { + // PDF assets stay sequential so the actual-byte budget also bounds peak retained buffers; + // ZIP keeps the existing shared materialization concurrency. + format === 'pdf' ? 1 : MATERIALIZE_CONCURRENCY, + async ({ imageKey, image }) => { try { const buffer = await downloadFile({ - key: imgRecord.key, - context: imgRecord.context as StorageContext, + key: image.key, + context: 'workspace', maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, }) - return { imageId, originalName: imgRecord.originalName, buffer } + if (actualBundleBytes + buffer.length > bundleLimit) { + logger.warn('Skipping asset that exceeds the actual export byte budget', { + imageRef: imageKey, + bundleLimit, + }) + return null + } + actualBundleBytes += buffer.length + return { imageKey, originalName: image.filename, buffer } } catch (error) { // A single unreadable or oversized asset drops out of the bundle rather than // failing the whole export; the markdown keeps its original link. logger.warn('Failed to fetch asset for export', { - imageId, + imageRef: imageKey, error: toError(error).message, }) return null @@ -283,28 +314,23 @@ export const GET = withRouteHandler( for (const result of fetched) { if (!result) continue - const { imageId, originalName, buffer } = result + const { imageKey, originalName, buffer } = result const preferred = safeFilename(originalName) - const filename = deduplicatedFilename(preferred, usedFilenames, imageId) + const filename = deduplicatedFilename(preferred, usedFilenames) usedFilenames.add(filename) - assetMap.set(imageId, { filename, buffer }) + assetMap.set(imageKey, { filename, buffer }) } if (format === 'pdf') { return respondWithPdf( - new Map(Array.from(assetMap, ([imageId, asset]) => [imageId, asset.buffer])) + new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, asset.buffer])) ) } - for (const [imageId, asset] of assetMap) { - const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const replacement = `./assets/${asset.filename}` - // Rewrite both embed spellings the extractor resolves to this id β€” the view URL and the in-app - // `/workspace//files/` path β€” so a bundled asset never leaves a broken link in the export. - mdContent = mdContent - .replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement) - .replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement) - } + mdContent = replaceEmbeddedFileRefs( + mdContent, + new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) + ) const zip = new JSZip() zip.file(safeFilename(record.originalName), mdContent) diff --git a/apps/sim/lib/collab-doc/server-markdown.ts b/apps/sim/lib/collab-doc/server-markdown.ts new file mode 100644 index 00000000000..62f519d1331 --- /dev/null +++ b/apps/sim/lib/collab-doc/server-markdown.ts @@ -0,0 +1,22 @@ +import type { JSONContent } from '@tiptap/core' +import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + +/** + * Installs the minimal DOM globals needed by the canonical TipTap Markdown engine in a server + * process. The single jsdom window is reused for every parse/serialize call in that process. + */ +export function ensureServerMarkdownDom(): void { + if (typeof window !== 'undefined' && typeof document !== 'undefined') return + const { JSDOM } = require('jsdom') as typeof import('jsdom') + const { window: jsdomWindow } = new JSDOM('') + const globals = globalThis as unknown as Record + globals.window = jsdomWindow + globals.document = jsdomWindow.document + globals.navigator ??= jsdomWindow.navigator +} + +/** Parse Markdown with the exact extension set and schema used by the Files editor. */ +export function parseServerMarkdownToDoc(markdown: string): JSONContent { + ensureServerMarkdownDom() + return parseMarkdownToDoc(markdown) +} diff --git a/apps/sim/lib/uploads/server/inline-image.test.ts b/apps/sim/lib/uploads/server/inline-image.test.ts index ba774a3e8f1..db5eb6da131 100644 --- a/apps/sim/lib/uploads/server/inline-image.test.ts +++ b/apps/sim/lib/uploads/server/inline-image.test.ts @@ -21,6 +21,7 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', type: 'image/png', name: 'x.png', + size: 123, }) const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' }) expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a') @@ -28,6 +29,7 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', + size: 123, }) }) @@ -42,12 +44,14 @@ describe('resolveWorkspaceInlineImage', () => { workspaceId: 'ws-1', contentType: 'image/png', originalName: 'x.png', + size: 456, }) const out = await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-1/x.png' }) expect(out).toEqual({ key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', + size: 456, }) }) diff --git a/apps/sim/lib/uploads/server/inline-image.ts b/apps/sim/lib/uploads/server/inline-image.ts index b44b9e08e4a..918e2a9b54d 100644 --- a/apps/sim/lib/uploads/server/inline-image.ts +++ b/apps/sim/lib/uploads/server/inline-image.ts @@ -15,6 +15,7 @@ export interface ResolvedInlineImage { key: string contentType: string filename: string + size: number } /** @@ -30,12 +31,19 @@ export async function resolveWorkspaceInlineImage( ): Promise { if (ref.fileId) { const file = await getWorkspaceFile(workspaceId, ref.fileId) - return file ? { key: file.key, contentType: file.type, filename: file.name } : null + return file + ? { key: file.key, contentType: file.type, filename: file.name, size: file.size } + : null } if (ref.key) { const record = await getFileMetadataByKey(ref.key, 'workspace') if (!record || record.workspaceId !== workspaceId) return null - return { key: record.key, contentType: record.contentType, filename: record.originalName } + return { + key: record.key, + contentType: record.contentType, + filename: record.originalName, + size: record.size, + } } return null } diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts index bec0f9e936b..e13bf554052 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + embeddedFileRefKey, extractEmbeddedFileRef, extractEmbeddedFileRefs, + replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' @@ -31,6 +33,13 @@ describe('extractEmbeddedFileRef', () => { }) }) +describe('embeddedFileRefKey', () => { + it('keeps storage keys and file ids in distinct map namespaces', () => { + expect(embeddedFileRefKey({ key: KEY })).toBe(`key:${KEY}`) + expect(embeddedFileRefKey({ fileId: 'wf_abc' })).toBe('id:wf_abc') + }) +}) + describe('extractEmbeddedFileRefs', () => { it('collects de-duplicated keys and ids from a document via the shared parser', () => { const content = ` @@ -39,6 +48,7 @@ describe('extractEmbeddedFileRefs', () => { ![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc) ![dup](/api/files/serve/s3/${ENCODED}) ![ext](https://cdn.example.com/x.png) + ![absolute](https://sim.ai/api/files/view/wf_external) ![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) ` const { keys, ids } = extractEmbeddedFileRefs(content) @@ -59,3 +69,25 @@ describe('extractEmbeddedFileRefs', () => { expect(k.length + d.length).toBe(50) }) }) + +describe('replaceEmbeddedFileRefs', () => { + it('rewrites key and id spellings without touching absolute URLs', () => { + const content = [ + `![key](/api/files/serve/${ENCODED}?context=workspace)`, + '![id](/api/files/view/wf_abc)', + '![absolute](https://sim.ai/api/files/view/wf_abc)', + ].join('\n') + const replacements = new Map([ + [`key:${KEY}`, './assets/key.png'], + ['id:wf_abc', './assets/id.png'], + ]) + + expect(replaceEmbeddedFileRefs(content, replacements)).toBe( + [ + '![key](./assets/key.png)', + '![id](./assets/id.png)', + '![absolute](https://sim.ai/api/files/view/wf_abc)', + ].join('\n') + ) + }) +}) diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index 7e780362ea5..c4dfea38ab8 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -10,17 +10,24 @@ /** A reference parsed from an embed `src`: a workspace storage key, a workspace file id, or neither. */ export type EmbeddedFileRef = { key: string } | { fileId: string } | null +export type ResolvedEmbeddedFileRef = Exclude /** Hard cap on embedded images resolved from one document β€” bounds export bundles and the cascade. */ export const MAX_EMBEDDED_IMAGES = 50 /** * Candidate embed URL substrings in document text: a serve URL, a view URL, or the in-app workspace - * path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left - * to {@link extractEmbeddedFileRef}. + * path. A required start/delimiter prevents matching the path portion of an absolute URL; the captured + * run stops at Markdown/HTML delimiters so authoritative parsing is left to + * {@link extractEmbeddedFileRef}. */ const EMBED_URL_RE = - /(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g + /(^|[\s("'<>])((?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>]*)/gm + +/** Stable map key shared by routes and renderers for either supported reference spelling. */ +export function embeddedFileRefKey(ref: ResolvedEmbeddedFileRef): string { + return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` +} /** * Parse a single embed `src` into the workspace file it references, normalizing the spellings the @@ -65,7 +72,7 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: const keys = new Set() const ids = new Set() for (const match of content.matchAll(EMBED_URL_RE)) { - const ref = extractEmbeddedFileRef(match[0]) + const ref = extractEmbeddedFileRef(match[2]) if (!ref) continue if ('key' in ref) keys.add(ref.key) else ids.add(ref.fileId) @@ -73,3 +80,15 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: } return { keys: [...keys], ids: [...ids] } } + +/** Rewrite authorized embedded references while leaving external and unmatched URLs untouched. */ +export function replaceEmbeddedFileRefs( + content: string, + replacements: ReadonlyMap +): string { + return content.replace(EMBED_URL_RE, (_match, prefix: string, candidate: string) => { + const ref = extractEmbeddedFileRef(candidate) + const replacement = ref ? replacements.get(embeddedFileRefKey(ref)) : undefined + return `${prefix}${replacement ?? candidate}` + }) +} 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 2814cb16910..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", @@ -190,7 +194,6 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", diff --git a/bun.lock b/bun.lock index 099f6fab5fb..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", @@ -293,7 +297,6 @@ "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "marked": "17.0.6", "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", @@ -1155,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=="], 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 From ba3a4f067fbc67a090e37ca55d444cbc10803014 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 20:12:27 -0700 Subject: [PATCH 05/13] fix(files): address PDF renderer edge cases --- .../files/export/[id]/markdown-pdf.test.ts | 11 +++++ .../api/files/export/[id]/markdown-pdf.tsx | 42 +++++++++++++------ apps/sim/app/api/files/export/[id]/route.ts | 13 +----- apps/sim/lib/uploads/client/download.ts | 6 +-- apps/sim/lib/uploads/utils/file-utils.test.ts | 3 +- apps/sim/lib/uploads/utils/file-utils.ts | 8 ++-- 6 files changed, 50 insertions(+), 33 deletions(-) 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 index 53c88adfffd..058068ce397 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -120,6 +120,17 @@ ${repeatedParagraphs}` expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) }) + 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('falls back instead of decoding an image above the pixel ceiling', async () => { const oversizedSvg = Buffer.from( '' diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index eb14fa5e6dd..2d8539e9211 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -267,19 +267,31 @@ function fontRuns(value: string): FontRun[] { } }) - for (const [index, character] of characters.entries()) { - if (character.family !== 'Geist' || !character.neutral) continue - - let previous = index - 1 - while (previous >= 0 && characters[previous].neutral) previous -= 1 - let next = index + 1 - while (next < characters.length && characters[next].neutral) next += 1 - const previousFamily = characters[previous]?.family - const nextFamily = characters[next]?.family - if (previousFamily && previousFamily !== 'Geist' && previousFamily === nextFamily) { - character.family = previousFamily - } else if (previousFamily && previousFamily !== 'Geist' && next >= characters.length) { - character.family = previousFamily + 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 + } + } } } @@ -489,6 +501,10 @@ function estimateTableRowHeight(row: JSONContent, columnCount: number): number { function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 const headerHeight = estimateTableRowHeight(header, columnCount) + if (rows.length === 0) { + return [{ rows: [], unbreakable: headerHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }] + } + const chunks: TableChunk[] = [] let current: JSONContent[] = [] let currentHeight = headerHeight diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index a92ad0fa9bc..d4520f2f559 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -25,7 +25,7 @@ import { type ResolvedEmbeddedFileRef, replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' -import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { formatFileSize, isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { encodeFilenameForHeader } from '@/app/api/files/utils' @@ -54,15 +54,6 @@ const PDF_EXPORT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) -const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) - -function isMarkdown(originalName: string, contentType: string): boolean { - if (MARKDOWN_MIME_TYPES.has(contentType)) return true - const ext = originalName.split('.').pop()?.toLowerCase() ?? '' - return MARKDOWN_EXTENSIONS.has(ext) -} - function safeFilename(name: string): string { return path .basename(name) @@ -141,7 +132,7 @@ export const GET = withRouteHandler( ) } - if (!isMarkdown(record.originalName, record.contentType)) { + if (!isMarkdownFile({ name: record.originalName, type: record.contentType })) { if (format === 'pdf') { return NextResponse.json( { error: 'PDF export is only available for Markdown files.' }, diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 97655058229..a6ad0fd0f24 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -2,6 +2,7 @@ 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' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' export function saveBlob(blob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(blob) @@ -35,10 +36,7 @@ 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) + const isMarkdown = isMarkdownFile(record) if (options?.format === 'pdf' && !isMarkdown) { throw new Error('PDF export is only available for Markdown files') diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 79032282bcf..4521dc6bcf4 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -27,9 +27,10 @@ describe('isMarkdownFile', () => { expect(isMarkdownFile({ name: 'doc.markdown' })).toBe(true) }) - it('is true for a text/markdown MIME even without a .md name', () => { + it('is true for Markdown MIME types even without a .md name', () => { expect(isMarkdownFile({ type: 'text/markdown', name: 'notes' })).toBe(true) expect(isMarkdownFile({ type: 'text/markdown', name: 'doc.txt' })).toBe(true) + expect(isMarkdownFile({ type: 'text/x-markdown', name: 'legacy' })).toBe(true) }) it('is false for non-markdown files', () => { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index da2c51c0f91..d3c0c9fbb79 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -213,12 +213,12 @@ export function getFileExtension(filename: string): string { /** * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by - * its `text/markdown` MIME *or* a `.md`/`.markdown` extension β€” MIME first, matching the client β€” so a - * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc - * merge) to exactly the files that can be open in that editor. + * its Markdown MIME *or* a `.md`/`.markdown` extension β€” MIME first, matching the client β€” so a + * Markdown file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc merge) + * to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { - if (file.type === 'text/markdown') return true + if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } From 24808d2acec70c01ecb659fea2062779cc34d2d5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 20:56:46 -0700 Subject: [PATCH 06/13] fix(files): harden Markdown PDF export --- apps/realtime/src/handlers/file-doc.test.ts | 52 +++++++- apps/realtime/src/handlers/file-doc.ts | 124 ++++++++++++++---- .../files/export/[id]/markdown-pdf.test.ts | 23 +++- .../api/files/export/[id]/markdown-pdf.tsx | 92 ++++--------- apps/sim/app/api/files/export/[id]/route.ts | 14 +- .../file-viewer/file-category.test.ts | 8 +- .../components/file-viewer/file-category.ts | 5 +- .../file-viewer/preview-panel.test.ts | 16 +++ .../components/file-viewer/preview-panel.tsx | 14 +- .../collaboration/file-doc-provider.test.ts | 30 ++++- .../collaboration/file-doc-provider.ts | 24 ++++ .../workspace/[workspaceId]/files/files.tsx | 6 +- .../realtime-protocol/src/file-doc.test.ts | 2 + packages/realtime-protocol/src/file-doc.ts | 18 ++- 14 files changed, 314 insertions(+), 114 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 938092d4484..4dc9db77d4c 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -42,7 +42,7 @@ import { } from '@/handlers/file-doc' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Handler = (payload?: unknown) => Promise | void +type Handler = (...args: unknown[]) => Promise | void const ROOM_NAME = 'workspace-file-doc:file-1' @@ -368,6 +368,56 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocPersist).toHaveBeenCalled() }) + it('acknowledges an export flush only after the latest live edit is persisted', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + mockFetchFileDocPersist.mockClear() + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) + + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + expect(acknowledge).toHaveBeenCalledWith({ ok: true }) + }) + + it('rejects an export flush when the live document cannot be persisted', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-1', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await flushMicrotasks() + + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => + syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) + ) + ) + await flushMicrotasks() + mockFetchFileDocPersist.mockResolvedValueOnce({ status: 'conflict' }) + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) + + expect(acknowledge).toHaveBeenCalledWith({ + ok: false, + error: 'Unable to save the latest document changes for export', + }) + }) + it('drops document frames and evicts once the editor loses write access mid-session', async () => { // The join-time check is not a standing right: a collaborator downgraded to `read` // (or removed) must stop landing durable edits on the socket they already hold. diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index a0152fd85f6..63c1b2eb00d 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -31,6 +31,8 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocPresenceUser, + type FlushFileDocPayload, + type FlushFileDocResult, type JoinFileDocPayload, type LeaveFileDocPayload, toFileDocBytes, @@ -81,6 +83,16 @@ const PERSIST_MAX_WAIT_MS = 20_000 const FINAL_VERSION_RETRIES = 2 const FINAL_VERSION_RETRY_MS = 100 +type PersistMode = 'debounced' | 'final' | 'requested' +type PersistOutcome = + | 'unchanged' + | 'persisted' + | 'missing' + | 'deferred' + | 'conflict' + | 'deduplicated' + | 'failed' + /** Cross-task merge lock. The TTL must exceed the whole critical section it guards β€” stream fold + * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish β€” so the lock never expires * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom. @@ -260,27 +272,31 @@ function schedulePersist(name: string, room: FileDocRoom): void { room.persistTimer = setTimeout(() => { room.persistTimer = null room.persistDeadline = null - void flushPersist(name, room, false) + void flushPersist(name, room, 'debounced') }, delay) } /** - * Project the live doc to markdown and write it durably via the app. `final` (last collaborator - * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW + * Project the live doc to markdown and write it durably via the app. A final or explicitly requested + * flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks - * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure - * is retried on the next debounce; the stream holds the state meanwhile). + * editing the same file don't each write a redundant blob version. Returns an outcome so an export can + * wait for durable success; background callers still treat failures as best-effort. * * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge β€” or * a peer's edit β€” published by another task may not be integrated into `room.doc` yet (and the stream * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a - * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the + * fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. */ -async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { +async function flushPersist( + name: string, + room: FileDocRoom, + mode: PersistMode +): Promise { // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). - if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return + if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return 'unchanged' const store = getFileDocStore() const workspaceId = room.workspaceId const userId = room.lastEditorUserId @@ -290,7 +306,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the - // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state. + // local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately + // preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can + // already be in the stream but not this task's doc. The CRDT union covers both without another save + // path or waiting on the normal debounce. const captureState = async (): Promise => { if (!store.enabled) { // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the @@ -302,12 +321,23 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr : localState } try { - return (await store.getStreamState(name)) ?? localState + const sharedState = await store.getStreamState(name) + if (mode !== 'requested' || !sharedState || !localState) return sharedState ?? localState + + const merged = new Y.Doc() + try { + Y.applyUpdate(merged, sharedState) + Y.applyUpdate(merged, localState) + return Y.encodeStateAsUpdate(merged) + } finally { + merged.destroy() + } } catch (streamError) { // A transient Redis read must NOT drop the write when we already hold a valid local snapshot β€” - // else the last-disconnect flush loses the session's edits as the room is torn down. But once a - // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a - // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. + // else the last-disconnect flush loses the session's edits as the room is torn down. An explicit + // export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state + // is temporarily unavailable. + if (mode === 'requested') throw streamError if (!localState) throw streamError logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { error: getErrorMessage(streamError), @@ -327,8 +357,11 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr } try { - if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) - return + if ( + mode === 'debounced' && + !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)) + ) + return 'deduplicated' // The If-Match token: the durable content version the live doc is synced to. let ifMatch = await currentVersion() @@ -338,7 +371,7 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // unset version never appears, and the flush must not stall teardown. for ( let i = 0; - ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES; + ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES; i++ ) { await sleep(FINAL_VERSION_RETRY_MS) @@ -349,19 +382,24 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // still at the version the live doc synced from, so a projection can never silently clobber an // out-of-band edit. A single attempt β€” on conflict we STOP rather than retry (see below). const docState = await captureState() - if (!docState) return // nothing seeded/authoritative to persist yet + if (!docState) return 'unchanged' // nothing seeded/authoritative to persist yet + // Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish + // is fire-and-forget, so append the converged snapshot and await Redis before updating the durable + // blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay + // persist could overwrite from an incomplete stream. + if (mode === 'requested' && store.enabled) await store.publishAndWait(name, docState) const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - if (result.status === 'missing') return // the file was deleted; nothing to write + if (result.status === 'missing') return 'missing' // the file was deleted; nothing to write if (result.status === 'deferred') { // No version token available (momentarily β€” a Redis blip on a peer-seeded task). Leave the edits in // the stream; a later persist writes them once the version is re-established. logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`) - return + return 'deferred' } if (result.status === 'persisted') { room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version) void store.setSyncedVersion(name, result.version) - return + return 'persisted' } // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge @@ -375,8 +413,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr logger.warn( `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` ) + return 'conflict' } catch (error) { logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) + return 'failed' } } @@ -446,7 +486,7 @@ function destroyRoomIfIdle(name: string) { } // Final durable flush BEFORE teardown β€” `flushPersist` encodes the doc synchronously (before the // destroy below) and awaits the write in the background. Best-effort; never throws. - void flushPersist(name, room, true) + void flushPersist(name, room, 'final') getFileDocStore().detachRoom(name) room.awareness.destroy() room.doc.destroy() @@ -461,9 +501,9 @@ function destroyRoomIfIdle(name: string) { * process is exiting); only their durable state is secured. */ export async function flushAllFileDocRooms(): Promise { - const flushes: Promise[] = [] + const flushes: Promise[] = [] for (const [name, room] of fileDocRooms) { - if (room.edited) flushes.push(flushPersist(name, room, true)) + if (room.edited) flushes.push(flushPersist(name, room, 'final')) } await Promise.all(flushes) } @@ -1229,6 +1269,44 @@ export function setupWorkspaceFileDocHandlers( } }) + socket.on( + FILE_DOC_EVENTS.FLUSH, + async (payload: FlushFileDocPayload, acknowledge?: (result: FlushFileDocResult) => void) => { + if (typeof acknowledge !== 'function') return + if (!payload || typeof payload.fileId !== 'string' || payload.fileId.length === 0) { + acknowledge({ ok: false, error: 'Invalid file document flush request' }) + return + } + + const name = socketToRoomName.get(socket.id) + const requestedName = roomName(fileDocRoom(payload.fileId)) + // A file with no live editor on this socket has no pending client edits to flush; its durable + // blob is already the export source. This also keeps cold-load and read-only exports immediate. + if (name !== requestedName) { + acknowledge({ ok: true }) + return + } + + const room = fileDocRooms.get(name) + if (!room || !isFileDocWriteAllowed(socket, io, name)) { + acknowledge({ ok: false, error: 'Unable to prepare the current document for export' }) + return + } + + // Socket.IO preserves event order on one connection, so all Yjs update frames emitted before + // this request have already been applied. Replace the pending debounce with this awaited write. + if (room.persistTimer) clearTimeout(room.persistTimer) + room.persistTimer = null + room.persistDeadline = null + const outcome = await flushPersist(name, room, 'requested') + if (outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing') { + acknowledge({ ok: true }) + return + } + acknowledge({ ok: false, error: 'Unable to save the latest document changes for export' }) + } + ) + socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data)) socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { 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 index 058068ce397..3b6157af616 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -46,6 +46,8 @@ describe('Markdown PDF rendering', () => { Smart quotes β€œwork” and Greek Ξ© stays readable. +Common emoji stay readable too: πŸš€ πŸ˜€ + δΈ­ζ–‡ζŽ’η‰ˆεΊ”θ―₯ζΈ…ζ™°ζ˜“θ―»γ€‚ Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ© يجب Ψ£Ω† ΨͺΩƒΩˆΩ† Ω…ΨͺΨ΅Ω„Ψ© ΩˆΩ…Ω‚Ψ±ΩˆΨ‘Ψ©. ΰ€Ήΰ€Ώΰ€¨ΰ₯ΰ€¦ΰ₯€ ΰ€ͺΰ€Ύΰ€  ΰ€Έΰ₯ΰ€ͺΰ€·ΰ₯ΰ€Ÿ ΰ€”ΰ€° ΰ€ͺΰ€ ΰ€¨ΰ₯€ΰ€― ΰ€Ήΰ₯‹ΰ€¨ΰ€Ύ ΰ€šΰ€Ύΰ€Ήΰ€Ώΰ€ΰ₯€ Χ’Χ‘Χ¨Χ™Χͺ Χ¦Χ¨Χ™Χ›Χ” ΧœΧ”Χ™Χ•Χͺ Χ‘Χ¨Χ•Χ¨Χ” וקריאה. - First item @@ -85,6 +87,9 @@ ${repeatedParagraphs}` // 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 @@ -103,7 +108,7 @@ ${repeatedParagraphs}` } }) - it('keeps long table rows together and repeats the header across table pages', async () => { + it('lets a long table paginate without moving the whole table to a later page', async () => { const rows = Array.from( { length: 90 }, (_, index) => @@ -117,7 +122,21 @@ ${repeatedParagraphs}` const pages = await pdfPagesText(buffer) const tablePages = pages.filter((page) => page.includes('Row ')) expect(tablePages.length).toBeGreaterThan(1) - expect(tablePages.every((page) => page.includes('Name') && page.includes('Value'))).toBe(true) + 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 () => { diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 2d8539e9211..a2535ad1c9c 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -97,7 +97,6 @@ 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 MAX_UNBREAKABLE_TABLE_HEIGHT = 620 const PDF_TABLE_CONTENT_WIDTH = 499 Font.register({ @@ -159,11 +158,6 @@ interface FontRun { text: string } -interface TableChunk { - rows: JSONContent[] - unbreakable: boolean -} - const styles = StyleSheet.create({ page: { backgroundColor: '#ffffff', @@ -256,14 +250,20 @@ function fontRuns(value: string): FontRun[] { ? undefined : (staticFallbackFonts.find(({ glyphs }) => glyphs.hasGlyphForCodePoint(codePoint)) ?? (unifont.glyphs.hasGlyphForCodePoint(codePoint) ? unifont : undefined)) - const family = - codePoint !== undefined && geistGlyphs.hasGlyphForCodePoint(codePoint) - ? 'Geist' - : (fallback?.family ?? 'Unifont') + 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), - text: family === 'Geist' || fallback ? 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()}]` + : 'οΏ½', } }) @@ -486,50 +486,9 @@ function renderList( ) } -function estimateTableRowHeight(row: JSONContent, columnCount: number): number { - const columnWidth = PDF_TABLE_CONTENT_WIDTH / Math.max(columnCount, 1) - const charactersPerLine = Math.max(8, Math.floor(columnWidth / 4.8)) - const lines = Math.max( - 1, - ...(row.content ?? []).map((cell) => - Math.ceil(Math.max(nodeText(cell).length, 1) / charactersPerLine) - ) - ) - return 10 + lines * 12.5 -} - -function chunkTableRows(header: JSONContent, rows: JSONContent[]): TableChunk[] { - const columnCount = header.content?.length ?? rows[0]?.content?.length ?? 1 - const headerHeight = estimateTableRowHeight(header, columnCount) - if (rows.length === 0) { - return [{ rows: [], unbreakable: headerHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }] - } - - const chunks: TableChunk[] = [] - let current: JSONContent[] = [] - let currentHeight = headerHeight - - const flush = () => { - if (current.length === 0) return - chunks.push({ rows: current, unbreakable: currentHeight <= MAX_UNBREAKABLE_TABLE_HEIGHT }) - current = [] - currentHeight = headerHeight - } - - for (const row of rows) { - const rowHeight = estimateTableRowHeight(row, columnCount) - if (current.length > 0 && currentHeight + rowHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() - current.push(row) - currentHeight += rowHeight - if (rowHeight + headerHeight > MAX_UNBREAKABLE_TABLE_HEIGHT) flush() - } - flush() - return chunks -} - function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { return ( - + {(row.content ?? []).map((cell, index) => ( cell.type === 'tableHeader') ?? false - const header = hasHeader ? firstRow : { type: 'tableRow', content: [] } - const bodyRows = hasHeader ? rows.slice(1) : rows - const chunks = hasHeader - ? chunkTableRows(header, bodyRows) - : [{ rows: bodyRows, unbreakable: false }] - - return chunks.map((chunk, index) => ( - - {hasHeader ? renderTableRow(header, `${key}-${index}-header`, true) : null} - {chunk.rows.map((row, rowIndex) => - renderTableRow(row, `${key}-${index}-row-${rowIndex}`, false) + return ( + + {rows.map((row, index) => + renderTableRow(row, `${key}-row-${index}`, hasHeader && index === 0) )} - )) + ) } function renderBlock( @@ -647,7 +599,7 @@ async function normalizeImages( images: ReadonlyMap ): Promise> { const normalized = new Map() - let totalInputPixels = 0 + let totalDecodedInputPixels = 0 let totalOutputPixels = 0 let totalImageBytes = 0 @@ -663,11 +615,10 @@ async function normalizeImages( const inputPixels = metadata.width * metadata.height if ( !Number.isSafeInteger(inputPixels) || - totalInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS + totalDecodedInputPixels + inputPixels > MAX_PDF_TOTAL_INPUT_PIXELS ) { continue } - totalInputPixels += inputPixels const scale = Math.min( 1, @@ -689,6 +640,11 @@ async function normalizeImages( }) .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 diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index d4520f2f559..5f75aef2d74 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -300,6 +300,14 @@ export const GET = withRouteHandler( } ) + if (format === 'pdf') { + return respondWithPdf( + new Map( + fetched.flatMap((result) => (result ? [[result.imageKey, result.buffer] as const] : [])) + ) + ) + } + const assetMap = new Map() const usedFilenames = new Set() @@ -312,12 +320,6 @@ export const GET = withRouteHandler( assetMap.set(imageKey, { filename, buffer }) } - if (format === 'pdf') { - return respondWithPdf( - new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, asset.buffer])) - ) - } - mdContent = replaceEmbeddedFileRefs( mdContent, new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index 00480f609dc..af19cb6d3f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -12,6 +12,11 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({ const lastDot = filename.lastIndexOf('.') return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' }, + isMarkdownFile: (file: { type?: string | null; name: string }): boolean => { + if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true + const extension = file.name.split('.').at(-1)?.toLowerCase() + return extension === 'md' || extension === 'markdown' + }, })) import { resolveFileCategory } from './file-category' @@ -21,6 +26,7 @@ describe('resolveFileCategory β€” MIME type routing', () => { it.each([ 'text/plain', 'text/markdown', + 'text/x-markdown', 'application/json', 'application/x-yaml', 'text/csv', @@ -130,7 +136,7 @@ describe('resolveFileCategory β€” MIME type routing', () => { describe('resolveFileCategory β€” extension fallback', () => { describe('text-editable extensions', () => { - it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( + it.each(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( '.%s β†’ text-editable', (ext) => { expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index 485ebdeda56..6683bdeabd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -1,8 +1,7 @@ -import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { getFileExtension, isMarkdownFile } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' const TEXT_EDITABLE_MIME_TYPES = new Set([ - 'text/markdown', 'text/plain', 'application/json', 'application/x-yaml', @@ -23,7 +22,6 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ ]) const TEXT_EDITABLE_EXTENSIONS = new Set([ - 'md', 'txt', 'json', 'yaml', @@ -128,6 +126,7 @@ export type FileCategory = | 'unsupported' export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { + if (isMarkdownFile({ type: mimeType, name: filename })) return 'text-editable' if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts new file mode 100644 index 00000000000..35a79f13eef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts @@ -0,0 +1,16 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolvePreviewType } from './preview-panel' + +describe('resolvePreviewType', () => { + it.each([ + ['text/markdown', 'notes.txt'], + ['text/x-markdown', 'notes.txt'], + [null, 'notes.md'], + [null, 'notes.markdown'], + ])('uses the shared Markdown eligibility for %s / %s', (mimeType, filename) => { + expect(resolvePreviewType(mimeType, filename)).toBe('markdown') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 2b5b9c997d0..c932b70d63b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -3,7 +3,10 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import '@sim/emcn/components/code/code.css' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' -import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { + getFileExtension, + isMarkdownFile as isSharedMarkdownFile, +} from '@/lib/uploads/utils/file-utils' import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import' import { DataTable } from './data-table' import { MermaidDiagram } from './mermaid-diagram' @@ -12,7 +15,6 @@ import { ZoomablePreview } from './zoomable-preview' type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null const PREVIEWABLE_MIME_TYPES: Record = { - 'text/markdown': 'markdown', 'text/html': 'html', 'text/csv': 'csv', 'image/svg+xml': 'svg', @@ -20,7 +22,6 @@ const PREVIEWABLE_MIME_TYPES: Record = { } const PREVIEWABLE_EXTENSIONS: Record = { - md: 'markdown', html: 'html', htm: 'html', csv: 'csv', @@ -29,9 +30,14 @@ const PREVIEWABLE_EXTENSIONS: Record = { } /** All extensions that have a rich preview renderer. */ -export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS)) +export const RICH_PREVIEWABLE_EXTENSIONS = new Set([ + ...Object.keys(PREVIEWABLE_EXTENSIONS), + 'md', + 'markdown', +]) export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType { + if (isSharedMarkdownFile({ type: mimeType, name: filename })) return 'markdown' if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType] const ext = getFileExtension(filename) return PREVIEWABLE_EXTENSIONS[ext] ?? null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 689c2ee6c77..351c3052006 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -13,7 +13,7 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' -import { FileDocProvider } from './file-doc-provider' +import { FileDocProvider, flushFileDocForExport } from './file-doc-provider' /** A minimal fake Socket.IO client whose serverβ†’client events can be fired in tests. */ function createSocket(connected = true) { @@ -57,6 +57,34 @@ function emittedMessages(emit: ReturnType) { .map(([, payload]) => payload as Uint8Array) } +describe('flushFileDocForExport', () => { + it('resolves only after the relay acknowledges a durable flush', async () => { + const emit = vi.fn( + (event: string, payload: { fileId: string }, acknowledge: (result: { ok: true }) => void) => { + expect(event).toBe(FILE_DOC_EVENTS.FLUSH) + expect(payload).toEqual({ fileId: 'file-1' }) + acknowledge({ ok: true }) + } + ) + const socket = { connected: true, emit } as unknown as Socket + + await expect(flushFileDocForExport(socket, 'file-1')).resolves.toBeUndefined() + }) + + it('surfaces a failed flush and a disconnected socket', async () => { + const emit = vi.fn( + (_event: string, _payload: unknown, acknowledge: (result: unknown) => void) => + acknowledge({ ok: false, error: 'Persist failed' }) + ) + await expect( + flushFileDocForExport({ connected: true, emit } as unknown as Socket, 'file-1') + ).rejects.toThrow('Persist failed') + await expect(flushFileDocForExport(null, 'file-1')).rejects.toThrow( + 'Connect to realtime before exporting your latest changes.' + ) + }) +}) + describe('FileDocProvider', () => { it('joins immediately with its client id when the socket is already connected', () => { const { doc, emit } = createProvider(true) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 84bab0733bc..c4b338ce897 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -7,6 +7,7 @@ import { FILE_DOC_MESSAGE_TYPE, FILE_DOC_SEED, FILE_DOC_TIMEOUTS, + type FlushFileDocResult, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, @@ -46,6 +47,29 @@ interface FileDocProviderEvents { */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs +/** + * Wait for the realtime relay to persist every file-doc update emitted before this call. Exports read + * the durable file blob, so editable clients use this acknowledgement immediately before requesting an + * export rather than racing the relay's normal edit debounce. + */ +export function flushFileDocForExport(socket: Socket | null, fileId: string): Promise { + if (!socket?.connected) { + return Promise.reject(new Error('Connect to realtime before exporting your latest changes.')) + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out while saving the latest document changes.')) + }, FILE_DOC_TIMEOUTS.flushAckMs) + + socket.emit(FILE_DOC_EVENTS.FLUSH, { fileId }, (result: FlushFileDocResult) => { + clearTimeout(timer) + if (result.ok) resolve() + else reject(new Error(result.error)) + }) + }) +} + /** * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 681061e9435..9528472fc02 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -86,6 +86,7 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' +import { flushFileDocForExport } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider' import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' @@ -106,6 +107,7 @@ import { import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useSocket } from '@/app/workspace/providers/socket-provider' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { @@ -239,6 +241,7 @@ export function Files() { typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true + const { socket } = useSocket() const { config: permissionConfig } = usePermissionConfig() // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the @@ -1072,6 +1075,7 @@ export function Files() { setPdfDownloadPending(true) } try { + if (isPdf && canEdit) await flushFileDocForExport(socket, file.id) await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, @@ -1088,7 +1092,7 @@ export function Files() { } } }, - [workspaceId] + [canEdit, socket, workspaceId] ) const deleteTargetRef = useRef(deleteTarget) diff --git a/packages/realtime-protocol/src/file-doc.test.ts b/packages/realtime-protocol/src/file-doc.test.ts index 48fd7db5b8a..93eb16745bd 100644 --- a/packages/realtime-protocol/src/file-doc.test.ts +++ b/packages/realtime-protocol/src/file-doc.test.ts @@ -9,5 +9,7 @@ describe('FILE_DOC_TIMEOUTS ordering invariants', () => { // The relay's `/seed` fetch must finish before the client's readiness deadline lapses into its // read-only fallback, or a late-but-successful seed can never reach the client. expect(FILE_DOC_TIMEOUTS.seedRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.readinessDeadlineMs) + // An export flush waits for the relay's durable `/persist` request to finish before acking. + expect(FILE_DOC_TIMEOUTS.persistRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.flushAckMs) }) }) diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index 3e50cf097df..1fccbddb8be 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -23,6 +23,8 @@ export const FILE_DOC_EVENTS = { JOIN_ERROR: 'join-file-doc-error', /** Client β†’ server: leave the session ({@link LeaveFileDocPayload}). */ LEAVE: 'leave-file-doc', + /** Client β†’ server: durably persist the current live document before export. */ + FLUSH: 'flush-file-doc', /** Both directions: a framed Yjs message (binary), tagged by {@link FILE_DOC_MESSAGE_TYPE}. */ MESSAGE: 'file-doc-message', /** @@ -102,10 +104,9 @@ export const FILE_DOC_SEED = { * The seed request gets more headroom than the merge because it reads a (possibly cold) blob before * converting; the merge is a pure in-memory conversion the caller fully supplies. * - * `persistRequestMs` (relay β†’ app `/persist`) stands alone β€” no client waits on it (the relay flushes - * the live doc to durable markdown debounced during editing and on the last collaborator leaving), so - * it forms no ordering invariant. It gets seed-level headroom because, like the seed, it crosses a - * durable blob write (Yjs β†’ markdown β†’ storage), not just an in-memory conversion. + * `flushAckMs` (client β†’ relay `flush`) wraps `persistRequestMs` (relay β†’ app `/persist`), so + * `persistRequestMs < flushAckMs`. The acknowledged flush is used before exporting a live document; + * ordinary editing still persists on the relay's debounce and last-collaborator leave. */ export const FILE_DOC_TIMEOUTS = { seedRequestMs: 8_000, @@ -113,6 +114,7 @@ export const FILE_DOC_TIMEOUTS = { applyEditMs: 6_000, readinessDeadlineMs: 12_000, persistRequestMs: 8_000, + flushAckMs: 10_000, } as const /** Client β†’ server join request. `fileId` is the `workspace_files.id`. */ @@ -144,6 +146,14 @@ export interface LeaveFileDocPayload { fileId: string } +/** Client β†’ server request to durably persist a joined live document before an export reads it. */ +export interface FlushFileDocPayload { + fileId: string +} + +/** Server acknowledgement for a {@link FlushFileDocPayload}. */ +export type FlushFileDocResult = { ok: true } | { ok: false; error: string } + /** One collaborator session in a {@link FileDocPresence} roster β€” server-authenticated identity. * Keyed per socket (session), not per user: the client excludes its OWN `socketId` and then * dedupes the rest per user for the avatar stack, so a second tab of the same account still From 6c9b3e405f6dcf9125193dc853b9c99a4c00bdc5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:07:24 -0700 Subject: [PATCH 07/13] refactor(files): align PDF export with Markdown download --- apps/realtime/src/handlers/file-doc.test.ts | 52 +------- apps/realtime/src/handlers/file-doc.ts | 124 ++++-------------- .../collaboration/file-doc-provider.test.ts | 30 +---- .../collaboration/file-doc-provider.ts | 24 ---- .../workspace/[workspaceId]/files/files.tsx | 6 +- .../realtime-protocol/src/file-doc.test.ts | 2 - packages/realtime-protocol/src/file-doc.ts | 18 +-- 7 files changed, 30 insertions(+), 226 deletions(-) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 4dc9db77d4c..938092d4484 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -42,7 +42,7 @@ import { } from '@/handlers/file-doc' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Handler = (...args: unknown[]) => Promise | void +type Handler = (payload?: unknown) => Promise | void const ROOM_NAME = 'workspace-file-doc:file-1' @@ -368,56 +368,6 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocPersist).toHaveBeenCalled() }) - it('acknowledges an export flush only after the latest live edit is persisted', async () => { - mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) - const { io } = createIo() - const { handlers } = setup('socket-1', io) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) - await flushMicrotasks() - - const edit = new Y.Doc() - edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') - handlers[FILE_DOC_EVENTS.MESSAGE]( - frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => - syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) - ) - ) - await flushMicrotasks() - mockFetchFileDocPersist.mockClear() - const acknowledge = vi.fn() - - await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) - - expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) - expect(acknowledge).toHaveBeenCalledWith({ ok: true }) - }) - - it('rejects an export flush when the live document cannot be persisted', async () => { - mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) - const { io } = createIo() - const { handlers } = setup('socket-1', io) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) - await flushMicrotasks() - - const edit = new Y.Doc() - edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit') - handlers[FILE_DOC_EVENTS.MESSAGE]( - frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => - syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit)) - ) - ) - await flushMicrotasks() - mockFetchFileDocPersist.mockResolvedValueOnce({ status: 'conflict' }) - const acknowledge = vi.fn() - - await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge) - - expect(acknowledge).toHaveBeenCalledWith({ - ok: false, - error: 'Unable to save the latest document changes for export', - }) - }) - it('drops document frames and evicts once the editor loses write access mid-session', async () => { // The join-time check is not a standing right: a collaborator downgraded to `read` // (or removed) must stop landing durable edits on the socket they already hold. diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index 63c1b2eb00d..a0152fd85f6 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -31,8 +31,6 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocPresenceUser, - type FlushFileDocPayload, - type FlushFileDocResult, type JoinFileDocPayload, type LeaveFileDocPayload, toFileDocBytes, @@ -83,16 +81,6 @@ const PERSIST_MAX_WAIT_MS = 20_000 const FINAL_VERSION_RETRIES = 2 const FINAL_VERSION_RETRY_MS = 100 -type PersistMode = 'debounced' | 'final' | 'requested' -type PersistOutcome = - | 'unchanged' - | 'persisted' - | 'missing' - | 'deferred' - | 'conflict' - | 'deduplicated' - | 'failed' - /** Cross-task merge lock. The TTL must exceed the whole critical section it guards β€” stream fold + * `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish β€” so the lock never expires * mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom. @@ -272,31 +260,27 @@ function schedulePersist(name: string, room: FileDocRoom): void { room.persistTimer = setTimeout(() => { room.persistTimer = null room.persistDeadline = null - void flushPersist(name, room, 'debounced') + void flushPersist(name, room, false) }, delay) } /** - * Project the live doc to markdown and write it durably via the app. A final or explicitly requested - * flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW + * Project the live doc to markdown and write it durably via the app. `final` (last collaborator + * leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks - * editing the same file don't each write a redundant blob version. Returns an outcome so an export can - * wait for durable success; background callers still treat failures as best-effort. + * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure + * is retried on the next debounce; the stream holds the state meanwhile). * * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge β€” or * a peer's edit β€” published by another task may not be integrated into `room.doc` yet (and the stream * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a - * fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the + * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. */ -async function flushPersist( - name: string, - room: FileDocRoom, - mode: PersistMode -): Promise { +async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). - if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return 'unchanged' + if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return const store = getFileDocStore() const workspaceId = room.workspaceId const userId = room.lastEditorUserId @@ -306,10 +290,7 @@ async function flushPersist( // Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's // edit published by another task may not be integrated into THIS task's `room.doc` yet), else the - // local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately - // preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can - // already be in the stream but not this task's doc. The CRDT union covers both without another save - // path or waiting on the normal debounce. + // local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state. const captureState = async (): Promise => { if (!store.enabled) { // Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the @@ -321,23 +302,12 @@ async function flushPersist( : localState } try { - const sharedState = await store.getStreamState(name) - if (mode !== 'requested' || !sharedState || !localState) return sharedState ?? localState - - const merged = new Y.Doc() - try { - Y.applyUpdate(merged, sharedState) - Y.applyUpdate(merged, localState) - return Y.encodeStateAsUpdate(merged) - } finally { - merged.destroy() - } + return (await store.getStreamState(name)) ?? localState } catch (streamError) { // A transient Redis read must NOT drop the write when we already hold a valid local snapshot β€” - // else the last-disconnect flush loses the session's edits as the room is torn down. An explicit - // export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state - // is temporarily unavailable. - if (mode === 'requested') throw streamError + // else the last-disconnect flush loses the session's edits as the room is torn down. But once a + // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a + // failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot. if (!localState) throw streamError logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, { error: getErrorMessage(streamError), @@ -357,11 +327,8 @@ async function flushPersist( } try { - if ( - mode === 'debounced' && - !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)) - ) - return 'deduplicated' + if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) + return // The If-Match token: the durable content version the live doc is synced to. let ifMatch = await currentVersion() @@ -371,7 +338,7 @@ async function flushPersist( // unset version never appears, and the flush must not stall teardown. for ( let i = 0; - ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES; + ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES; i++ ) { await sleep(FINAL_VERSION_RETRY_MS) @@ -382,24 +349,19 @@ async function flushPersist( // still at the version the live doc synced from, so a projection can never silently clobber an // out-of-band edit. A single attempt β€” on conflict we STOP rather than retry (see below). const docState = await captureState() - if (!docState) return 'unchanged' // nothing seeded/authoritative to persist yet - // Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish - // is fire-and-forget, so append the converged snapshot and await Redis before updating the durable - // blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay - // persist could overwrite from an incomplete stream. - if (mode === 'requested' && store.enabled) await store.publishAndWait(name, docState) + if (!docState) return // nothing seeded/authoritative to persist yet const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - if (result.status === 'missing') return 'missing' // the file was deleted; nothing to write + if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { // No version token available (momentarily β€” a Redis blip on a peer-seeded task). Leave the edits in // the stream; a later persist writes them once the version is re-established. logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`) - return 'deferred' + return } if (result.status === 'persisted') { room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version) void store.setSyncedVersion(name, result.version) - return 'persisted' + return } // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge @@ -413,10 +375,8 @@ async function flushPersist( logger.warn( `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` ) - return 'conflict' } catch (error) { logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) - return 'failed' } } @@ -486,7 +446,7 @@ function destroyRoomIfIdle(name: string) { } // Final durable flush BEFORE teardown β€” `flushPersist` encodes the doc synchronously (before the // destroy below) and awaits the write in the background. Best-effort; never throws. - void flushPersist(name, room, 'final') + void flushPersist(name, room, true) getFileDocStore().detachRoom(name) room.awareness.destroy() room.doc.destroy() @@ -501,9 +461,9 @@ function destroyRoomIfIdle(name: string) { * process is exiting); only their durable state is secured. */ export async function flushAllFileDocRooms(): Promise { - const flushes: Promise[] = [] + const flushes: Promise[] = [] for (const [name, room] of fileDocRooms) { - if (room.edited) flushes.push(flushPersist(name, room, 'final')) + if (room.edited) flushes.push(flushPersist(name, room, true)) } await Promise.all(flushes) } @@ -1269,44 +1229,6 @@ export function setupWorkspaceFileDocHandlers( } }) - socket.on( - FILE_DOC_EVENTS.FLUSH, - async (payload: FlushFileDocPayload, acknowledge?: (result: FlushFileDocResult) => void) => { - if (typeof acknowledge !== 'function') return - if (!payload || typeof payload.fileId !== 'string' || payload.fileId.length === 0) { - acknowledge({ ok: false, error: 'Invalid file document flush request' }) - return - } - - const name = socketToRoomName.get(socket.id) - const requestedName = roomName(fileDocRoom(payload.fileId)) - // A file with no live editor on this socket has no pending client edits to flush; its durable - // blob is already the export source. This also keeps cold-load and read-only exports immediate. - if (name !== requestedName) { - acknowledge({ ok: true }) - return - } - - const room = fileDocRooms.get(name) - if (!room || !isFileDocWriteAllowed(socket, io, name)) { - acknowledge({ ok: false, error: 'Unable to prepare the current document for export' }) - return - } - - // Socket.IO preserves event order on one connection, so all Yjs update frames emitted before - // this request have already been applied. Replace the pending debounce with this awaited write. - if (room.persistTimer) clearTimeout(room.persistTimer) - room.persistTimer = null - room.persistDeadline = null - const outcome = await flushPersist(name, room, 'requested') - if (outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing') { - acknowledge({ ok: true }) - return - } - acknowledge({ ok: false, error: 'Unable to save the latest document changes for export' }) - } - ) - socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data)) socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 351c3052006..689c2ee6c77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -13,7 +13,7 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown' -import { FileDocProvider, flushFileDocForExport } from './file-doc-provider' +import { FileDocProvider } from './file-doc-provider' /** A minimal fake Socket.IO client whose serverβ†’client events can be fired in tests. */ function createSocket(connected = true) { @@ -57,34 +57,6 @@ function emittedMessages(emit: ReturnType) { .map(([, payload]) => payload as Uint8Array) } -describe('flushFileDocForExport', () => { - it('resolves only after the relay acknowledges a durable flush', async () => { - const emit = vi.fn( - (event: string, payload: { fileId: string }, acknowledge: (result: { ok: true }) => void) => { - expect(event).toBe(FILE_DOC_EVENTS.FLUSH) - expect(payload).toEqual({ fileId: 'file-1' }) - acknowledge({ ok: true }) - } - ) - const socket = { connected: true, emit } as unknown as Socket - - await expect(flushFileDocForExport(socket, 'file-1')).resolves.toBeUndefined() - }) - - it('surfaces a failed flush and a disconnected socket', async () => { - const emit = vi.fn( - (_event: string, _payload: unknown, acknowledge: (result: unknown) => void) => - acknowledge({ ok: false, error: 'Persist failed' }) - ) - await expect( - flushFileDocForExport({ connected: true, emit } as unknown as Socket, 'file-1') - ).rejects.toThrow('Persist failed') - await expect(flushFileDocForExport(null, 'file-1')).rejects.toThrow( - 'Connect to realtime before exporting your latest changes.' - ) - }) -}) - describe('FileDocProvider', () => { it('joins immediately with its client id when the socket is already connected', () => { const { doc, emit } = createProvider(true) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index c4b338ce897..84bab0733bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -7,7 +7,6 @@ import { FILE_DOC_MESSAGE_TYPE, FILE_DOC_SEED, FILE_DOC_TIMEOUTS, - type FlushFileDocResult, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, @@ -47,29 +46,6 @@ interface FileDocProviderEvents { */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs -/** - * Wait for the realtime relay to persist every file-doc update emitted before this call. Exports read - * the durable file blob, so editable clients use this acknowledgement immediately before requesting an - * export rather than racing the relay's normal edit debounce. - */ -export function flushFileDocForExport(socket: Socket | null, fileId: string): Promise { - if (!socket?.connected) { - return Promise.reject(new Error('Connect to realtime before exporting your latest changes.')) - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out while saving the latest document changes.')) - }, FILE_DOC_TIMEOUTS.flushAckMs) - - socket.emit(FILE_DOC_EVENTS.FLUSH, { fileId }, (result: FlushFileDocResult) => { - clearTimeout(timer) - if (result.ok) resolve() - else reject(new Error(result.error)) - }) - }) -} - /** * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 9528472fc02..681061e9435 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -86,7 +86,6 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' -import { flushFileDocForExport } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider' import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' @@ -107,7 +106,6 @@ import { import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { useSocket } from '@/app/workspace/providers/socket-provider' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { @@ -241,7 +239,6 @@ export function Files() { typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() const canEdit = userPermissions.canEdit === true - const { socket } = useSocket() const { config: permissionConfig } = usePermissionConfig() // Joined for the live file tree: a `workspace-files-changed` broadcast invalidates the @@ -1075,7 +1072,6 @@ export function Files() { setPdfDownloadPending(true) } try { - if (isPdf && canEdit) await flushFileDocForExport(socket, file.id) await triggerFileDownload(file, format ? { format } : undefined) captureEvent(posthogRef.current, 'file_downloaded', { workspace_id: workspaceId, @@ -1092,7 +1088,7 @@ export function Files() { } } }, - [canEdit, socket, workspaceId] + [workspaceId] ) const deleteTargetRef = useRef(deleteTarget) diff --git a/packages/realtime-protocol/src/file-doc.test.ts b/packages/realtime-protocol/src/file-doc.test.ts index 93eb16745bd..48fd7db5b8a 100644 --- a/packages/realtime-protocol/src/file-doc.test.ts +++ b/packages/realtime-protocol/src/file-doc.test.ts @@ -9,7 +9,5 @@ describe('FILE_DOC_TIMEOUTS ordering invariants', () => { // The relay's `/seed` fetch must finish before the client's readiness deadline lapses into its // read-only fallback, or a late-but-successful seed can never reach the client. expect(FILE_DOC_TIMEOUTS.seedRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.readinessDeadlineMs) - // An export flush waits for the relay's durable `/persist` request to finish before acking. - expect(FILE_DOC_TIMEOUTS.persistRequestMs).toBeLessThan(FILE_DOC_TIMEOUTS.flushAckMs) }) }) diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index 1fccbddb8be..3e50cf097df 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -23,8 +23,6 @@ export const FILE_DOC_EVENTS = { JOIN_ERROR: 'join-file-doc-error', /** Client β†’ server: leave the session ({@link LeaveFileDocPayload}). */ LEAVE: 'leave-file-doc', - /** Client β†’ server: durably persist the current live document before export. */ - FLUSH: 'flush-file-doc', /** Both directions: a framed Yjs message (binary), tagged by {@link FILE_DOC_MESSAGE_TYPE}. */ MESSAGE: 'file-doc-message', /** @@ -104,9 +102,10 @@ export const FILE_DOC_SEED = { * The seed request gets more headroom than the merge because it reads a (possibly cold) blob before * converting; the merge is a pure in-memory conversion the caller fully supplies. * - * `flushAckMs` (client β†’ relay `flush`) wraps `persistRequestMs` (relay β†’ app `/persist`), so - * `persistRequestMs < flushAckMs`. The acknowledged flush is used before exporting a live document; - * ordinary editing still persists on the relay's debounce and last-collaborator leave. + * `persistRequestMs` (relay β†’ app `/persist`) stands alone β€” no client waits on it (the relay flushes + * the live doc to durable markdown debounced during editing and on the last collaborator leaving), so + * it forms no ordering invariant. It gets seed-level headroom because, like the seed, it crosses a + * durable blob write (Yjs β†’ markdown β†’ storage), not just an in-memory conversion. */ export const FILE_DOC_TIMEOUTS = { seedRequestMs: 8_000, @@ -114,7 +113,6 @@ export const FILE_DOC_TIMEOUTS = { applyEditMs: 6_000, readinessDeadlineMs: 12_000, persistRequestMs: 8_000, - flushAckMs: 10_000, } as const /** Client β†’ server join request. `fileId` is the `workspace_files.id`. */ @@ -146,14 +144,6 @@ export interface LeaveFileDocPayload { fileId: string } -/** Client β†’ server request to durably persist a joined live document before an export reads it. */ -export interface FlushFileDocPayload { - fileId: string -} - -/** Server acknowledgement for a {@link FlushFileDocPayload}. */ -export type FlushFileDocResult = { ok: true } | { ok: false; error: string } - /** One collaborator session in a {@link FileDocPresence} roster β€” server-authenticated identity. * Keyed per socket (session), not per user: the client excludes its OWN `socketId` and then * dedupes the rest per user for the avatar stack, so a second tab of the same account still From 9b975dd7a6a4d6f50341ada09b036ec03076f33c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:16:40 -0700 Subject: [PATCH 08/13] test(files): remove preview panel coverage --- .../components/file-viewer/preview-panel.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts deleted file mode 100644 index 35a79f13eef..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { resolvePreviewType } from './preview-panel' - -describe('resolvePreviewType', () => { - it.each([ - ['text/markdown', 'notes.txt'], - ['text/x-markdown', 'notes.txt'], - [null, 'notes.md'], - [null, 'notes.markdown'], - ])('uses the shared Markdown eligibility for %s / %s', (mimeType, filename) => { - expect(resolvePreviewType(mimeType, filename)).toBe('markdown') - }) -}) From 06b88959f63589a52bb9adc827ec5e7886838f96 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 10 Aug 2026 23:33:52 -0700 Subject: [PATCH 09/13] refactor(files): isolate PDF export from Markdown behavior --- .../api/files/export/[id]/markdown-pdf.tsx | 13 +- .../app/api/files/export/[id]/route.test.ts | 450 ++++++++++-------- apps/sim/app/api/files/export/[id]/route.ts | 283 ++++++----- .../file-viewer/file-category.test.ts | 8 +- .../components/file-viewer/file-category.ts | 5 +- .../components/file-viewer/preview-panel.tsx | 14 +- apps/sim/lib/uploads/client/download.ts | 38 +- .../lib/uploads/server/inline-image.test.ts | 4 - apps/sim/lib/uploads/server/inline-image.ts | 12 +- .../uploads/utils/embedded-image-ref.test.ts | 32 -- .../lib/uploads/utils/embedded-image-ref.ts | 27 +- apps/sim/lib/uploads/utils/file-utils.test.ts | 3 +- apps/sim/lib/uploads/utils/file-utils.ts | 8 +- 13 files changed, 461 insertions(+), 436 deletions(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index a2535ad1c9c..283cd6dbd35 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -16,10 +16,19 @@ import { import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' -import { embeddedFileRefKey, extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +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' 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 @@ -426,7 +435,7 @@ function renderImage( ): ReactNode { const src = stringAttr(node, 'src') ?? '' const ref = extractEmbeddedFileRef(src) - const image = ref ? images.get(embeddedFileRefKey(ref)) : undefined + const image = ref ? images.get(markdownPdfImageKey(ref)) : undefined if (!image) { const alt = stringAttr(node, 'alt') return ( 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 5f19013ff16..132d635a54c 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -11,9 +11,11 @@ const { mockGetFileMetadataById, mockVerifyFileAccess, mockDownloadFile, + mockExtractEmbeddedImageIds, + mockExtractEmbeddedFileRefs, mockResolveWorkspaceInlineImage, - mockRenderMarkdownPdf, mockEnforceUserRateLimit, + mockRenderMarkdownPdf, mockRecordAudit, mockCaptureServerEvent, MockMarkdownPdfLimitError, @@ -22,9 +24,11 @@ const { mockGetFileMetadataById: vi.fn(), mockVerifyFileAccess: vi.fn(), mockDownloadFile: vi.fn(), + mockExtractEmbeddedImageIds: vi.fn(), + mockExtractEmbeddedFileRefs: vi.fn(), mockResolveWorkspaceInlineImage: vi.fn(), - mockRenderMarkdownPdf: vi.fn(), mockEnforceUserRateLimit: vi.fn(), + mockRenderMarkdownPdf: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), MockMarkdownPdfLimitError: class extends Error {}, @@ -36,16 +40,24 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/app/api/files/authorization', () => ({ verifyFileAccess: mockVerifyFileAccess })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) +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('@/app/api/files/export/[id]/markdown-pdf', () => ({ - renderMarkdownPdf: mockRenderMarkdownPdf, - MarkdownPdfLimitError: MockMarkdownPdfLimitError, -})) 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: mockRecordAudit, AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, @@ -60,7 +72,7 @@ const DOC_ID = 'doc-1' const context = { params: Promise.resolve({ id: DOC_ID }) } function request(format?: 'pdf') { - const query = format ? `?format=${format}` : '' + const query = format ? '?format=pdf' : '' return createMockRequest( 'GET', undefined, @@ -69,189 +81,118 @@ function request(format?: 'pdf') { ) } -function inlineImage(id: string, size = 1 * MB) { +function assetRecord(id: string, size: number) { return { + id, key: `workspace/ws-1/${id}`, - filename: id.endsWith('.png') ? id : `${id}.png`, + originalName: `${id}.png`, contentType: 'image/png', + context: 'workspace', size, + workspaceId: 'ws-1', } } -function markdownWithIds(...ids: string[]): Buffer { - return Buffer.from(ids.map((id) => `![${id}](/api/files/view/${id})`).join('\n')) -} - describe('markdown export bundling', () => { 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', - }) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId?: string; key?: string }) => { - const id = ref.fileId ?? ref.key?.split('/').at(-1) ?? 'image' - return inlineImage(id) - } + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 1 * MB) ) mockDownloadFile.mockResolvedValue(Buffer.from('# Doc\n')) - mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) + mockExtractEmbeddedImageIds.mockReturnValue([]) + mockExtractEmbeddedFileRefs.mockReturnValue({ keys: [], ids: [] }) + mockResolveWorkspaceInlineImage.mockResolvedValue(null) mockEnforceUserRateLimit.mockResolvedValue(null) + mockRenderMarkdownPdf.mockResolvedValue(Buffer.from('%PDF-generated')) }) - it('returns the stored Markdown unchanged when no format is requested', async () => { + 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('# Doc\n') + 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('renders Markdown as a directly downloadable PDF', 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(mockRenderMarkdownPdf.mock.calls[0][0].images.size).toBe(0) - expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('markdown-pdf-export', 'user-1', { - maxTokens: 3, - refillRate: 3, - refillIntervalMs: 60_000, - }) - }) - - 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('returns a clear rejection when the parsed document exceeds renderer limits', 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() - }) - - it('passes only authorized, readable embedded images to the PDF renderer', async () => { - mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return markdownWithIds('good', 'secret', 'broken') - if (key.endsWith('broken')) throw new Error('storage down') - return Buffer.from('png-bytes') - }) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(200) - const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(Array.from(images.keys())).toEqual(['id:good']) - expect(images.get('id:good')).toEqual(Buffer.from('png-bytes')) - }) - - it('resolves the key-based image URL emitted by the Files editor', async () => { - const key = 'workspace/ws-1/editor-image.png' - mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => - requestedKey.endsWith('doc.md') - ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) + 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('pdf'), context) + const response = await GET(request(), context) expect(response.status).toBe(200) - expect(mockResolveWorkspaceInlineImage).toHaveBeenCalledWith('ws-1', { key }) - const images = mockRenderMarkdownPdf.mock.calls[0][0].images as Map - expect(images.get(`key:${key}`)).toEqual(Buffer.from('png-bytes')) - }) - - it('records an image-containing PDF as one downloaded file', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') - ) - - await GET(request('pdf'), context) - - expect(mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ assetCount: 1, format: 'pdf' }), - }) - ) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'file_downloaded', - expect.objectContaining({ file_count: 1, is_bulk: false }), - { groups: { workspace: 'ws-1' } } - ) - }) - - it('keeps image-containing ZIP telemetry bulk', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('image-1') : Buffer.from('png-bytes') - ) - - await GET(request(), context) - + 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('rejects PDF format for a non-Markdown file', async () => { + it('preserves the non-Markdown serve redirect without a format', async () => { mockGetFileMetadataById.mockResolvedValue({ id: DOC_ID, - key: 'workspace/ws-1/doc.txt', - originalName: 'doc.txt', - contentType: 'text/plain', + key: 'workspace/ws-1/image.png', + originalName: 'image.png', + contentType: 'image/png', context: 'workspace', size: 1024, workspaceId: 'ws-1', }) - const response = await GET(request('pdf'), context) + const response = await GET(request(), context) - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('only available for Markdown') + 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 () => { - mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b', 'c')) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 100 * MB) + mockExtractEmbeddedImageIds.mockReturnValue(['a', 'b', 'c']) + mockGetFileMetadataById.mockImplementation(async (id: string) => + id === DOC_ID + ? { + id: DOC_ID, + key: 'workspace/ws-1/doc.md', + originalName: 'doc.md', + contentType: 'text/markdown', + context: 'workspace', + size: 1024, + workspaceId: 'ws-1', + } + : assetRecord(id, 100 * MB) ) const response = await GET(request(), context) @@ -264,9 +205,8 @@ describe('markdown export bundling', () => { it('counts the document body against the export limit, not just its assets', async () => { // Assets alone sit under the cap; the body is what carries the bundle over it. - const body = Buffer.alloc(250 * MB) - markdownWithIds('a').copy(body) - mockDownloadFile.mockResolvedValue(body) + mockExtractEmbeddedImageIds.mockReturnValue(['a']) + mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB)) const response = await GET(request(), context) @@ -275,32 +215,16 @@ describe('markdown export bundling', () => { }) it('caps the document body read rather than loading it unbounded', async () => { + mockExtractEmbeddedImageIds.mockReturnValue([]) + await GET(request(), context) const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) expect(bodyCall?.[0].maxBytes).toBe(250 * MB) }) - it('uses a smaller document limit for PDF rendering', async () => { - await GET(request('pdf'), context) - - const bodyCall = mockDownloadFile.mock.calls.find(([options]) => options.key.endsWith('doc.md')) - expect(bodyCall?.[0].maxBytes).toBe(256 * 1024) - }) - - it('reports an oversized PDF body with the PDF-specific limit', async () => { - mockDownloadFile.mockRejectedValue( - new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) - ) - - const response = await GET(request('pdf'), context) - - expect(response.status).toBe(400) - expect((await response.json()).error).toContain('256 KB PDF export limit') - expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() - }) - it('reports an oversized body as a size rejection, not a server error', async () => { + mockExtractEmbeddedImageIds.mockReturnValue([]) mockDownloadFile.mockRejectedValue( new PayloadSizeLimitError({ label: 'storage file download', maxBytes: 1 }) ) @@ -313,9 +237,7 @@ describe('markdown export bundling', () => { }) it('caps each asset download rather than trusting its declared size', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') - ) + mockExtractEmbeddedImageIds.mockReturnValue(['a']) await GET(request(), context) @@ -325,88 +247,194 @@ describe('markdown export bundling', () => { expect(assetCall?.[0].maxBytes).toBe(25 * MB) }) - it('uses a smaller per-asset limit for PDF rendering', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => - key.endsWith('doc.md') ? markdownWithIds('a') : Buffer.from('asset') - ) + it('drops an unreadable asset instead of failing the whole export', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['good', 'bad']) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key.endsWith('doc.md')) return Buffer.from('# Doc\n![x](/api/files/view/good)\n') + if (key.endsWith('bad')) throw new Error('storage down') + return Buffer.from('png-bytes') + }) - await GET(request('pdf'), context) + const response = await GET(request(), context) - const assetCall = mockDownloadFile.mock.calls.find( - ([options]) => options.key === 'workspace/ws-1/a' - ) - expect(assetCall?.[0].maxBytes).toBe(10 * MB) + expect(response.status).toBe(200) + const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) + expect(zip.file('assets/good.png')).not.toBeNull() + expect(zip.file('assets/bad.png')).toBeNull() }) - it('rejects PDF source material above its aggregate input limit', async () => { - mockDownloadFile.mockResolvedValue(markdownWithIds('a', 'b')) - mockResolveWorkspaceInlineImage.mockImplementation( - async (_workspaceId: string, ref: { fileId: string }) => inlineImage(ref.fileId, 30 * MB) + it('skips an asset the caller cannot read', async () => { + mockExtractEmbeddedImageIds.mockReturnValue(['secret']) + mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + + const response = await GET(request(), context) + + expect(response.status).toBe(200) + // Authorization is settled during metadata resolution, before any asset read. + expect(mockDownloadFile.mock.calls.some(([options]) => options.key.endsWith('secret'))).toBe( + false ) + }) +}) + +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(400) - expect((await response.json()).error).toContain('50 MB PDF export limit') - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() + 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('enforces the aggregate PDF budget against downloaded bytes, not only metadata', async () => { - const ids = ['a', 'b', 'c', 'd', 'e', 'f'] + 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') ? markdownWithIds(...ids) : Buffer.alloc(10 * MB) + 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.size).toBe(4) + 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('rewrites the editor key URL when producing a Markdown asset ZIP', async () => { - const key = 'workspace/ws-1/editor-image.png' - mockDownloadFile.mockImplementation(async ({ key: requestedKey }: { key: string }) => - requestedKey.endsWith('doc.md') - ? Buffer.from(`![image](/api/files/serve/${encodeURIComponent(key)}?context=workspace)`) - : Buffer.from('png-bytes') + 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(), context) + const response = await GET(request('pdf'), context) - const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) - expect(await zip.file('doc.md')?.async('string')).toBe('![image](./assets/editor-image.png)') - expect(zip.file('assets/editor-image.png')).not.toBeNull() + expect(response.status).toBe(429) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockRenderMarkdownPdf).not.toHaveBeenCalled() }) - it('drops an unreadable asset instead of failing the whole export', async () => { - mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { - if (key.endsWith('doc.md')) return markdownWithIds('good', 'bad') - if (key.endsWith('bad')) throw new Error('storage down') - return Buffer.from('png-bytes') + 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(), context) + const response = await GET(request('pdf'), context) expect(response.status).toBe(200) - const zip = await JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) - expect(zip.file('assets/good.png')).not.toBeNull() - expect(zip.file('assets/bad.png')).toBeNull() + 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('skips an asset the caller cannot read', async () => { - mockVerifyFileAccess.mockImplementation(async (key: string) => !key.endsWith('secret')) + 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') ? markdownWithIds('secret') : Buffer.from('asset') + key.endsWith('doc.md') ? Buffer.from('# Doc\n') : Buffer.alloc(26 * MB) ) - const response = await GET(request(), context) + const response = await GET(request('pdf'), context) - expect(response.status).toBe(200) - // Authorization is settled during metadata resolution, before any asset read. - expect(mockDownloadFile.mock.calls.some(([options]) => options.key.endsWith('secret'))).toBe( - false + 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 5f75aef2d74..3af7478852a 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -8,6 +8,7 @@ import { NextResponse } from 'next/server' 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' @@ -19,13 +20,8 @@ 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 { - embeddedFileRefKey, - extractEmbeddedFileRefs, - type ResolvedEmbeddedFileRef, - replaceEmbeddedFileRefs, -} from '@/lib/uploads/utils/embedded-image-ref' -import { formatFileSize, isMarkdownFile } from '@/lib/uploads/utils/file-utils' +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' @@ -42,18 +38,25 @@ const logger = createLogger('FilesExportAPI') */ const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 -/** PDF-specific document ceiling that bounds parser and layout-tree work. */ + const MAX_PDF_MARKDOWN_BYTES = 256 * 1024 const MAX_PDF_ASSET_BYTES = 10 * 1024 * 1024 const MAX_PDF_TOTAL_SOURCE_BYTES = 50 * 1024 * 1024 - -/** PDF rendering is CPU-bound and buffers its result, so it gets a narrower request bucket than downloads. */ 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']) + +function isMarkdown(originalName: string, contentType: string): boolean { + if (MARKDOWN_MIME_TYPES.has(contentType)) return true + const ext = originalName.split('.').pop()?.toLowerCase() ?? '' + return MARKDOWN_EXTENSIONS.has(ext) +} + function safeFilename(name: string): string { return path .basename(name) @@ -61,13 +64,13 @@ function safeFilename(name: string): string { .replace(/[\r\n\t]/g, '') } -function deduplicatedFilename(preferred: string, existing: Set): string { +function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { if (!existing.has(preferred)) return preferred const ext = path.extname(preferred) const base = path.basename(preferred, ext) - let suffix = 2 - while (existing.has(`${base}_${suffix}${ext}`)) suffix += 1 - return `${base}_${suffix}${ext}` + const short = `${base}_${imageId.slice(0, 8)}${ext}` + if (!existing.has(short)) return short + return `${base}_${imageId}${ext}` } export const GET = withRouteHandler( @@ -101,7 +104,7 @@ export const GET = withRouteHandler( * markdown, or bundled zip) so a mid-export failure never logs a download * that never happened. */ - const auditExport = (format: 'file' | 'markdown' | 'pdf' | 'zip', assetCount: number) => { + const auditExport = (format: 'file' | 'markdown' | 'zip', assetCount: number) => { recordAudit({ workspaceId: record.workspaceId ?? null, actorId: userId, @@ -119,83 +122,123 @@ export const GET = withRouteHandler( }, request, }) - const downloadedFileCount = format === 'zip' ? 1 + assetCount : 1 captureServerEvent( userId, 'file_downloaded', { ...(record.workspaceId ? { workspace_id: record.workspaceId } : {}), - is_bulk: downloadedFileCount > 1, - file_count: downloadedFileCount, + is_bulk: assetCount > 0, + file_count: 1 + assetCount, + }, + record.workspaceId ? { groups: { workspace: record.workspaceId } } : undefined + ) + } + + 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 (!isMarkdownFile({ name: record.originalName, type: record.contentType })) { - if (format === 'pdf') { + if (format === 'pdf') { + if (!isMarkdown(record.originalName, record.contentType)) { return NextResponse.json( { error: 'PDF export is only available for Markdown files.' }, { status: 400 } ) } - const storagePrefix = getServeStoragePrefix() - const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` - auditExport('file', 0) - return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) - } - if (format === 'pdf') { const rateLimited = await enforceUserRateLimit( 'markdown-pdf-export', userId, PDF_EXPORT_RATE_LIMIT ) if (rateLimited) return rateLimited - } - // Capped like everything else in the bundle: the document body is usually the - // largest single entry, so leaving it unbounded left the export limit unenforced - // against the one item most able to exceed it. A body that alone exceeds the limit - // is a size rejection, so it reports as one rather than as a server error. - let mdBuffer: Buffer - const documentLimit = format === 'pdf' ? MAX_PDF_MARKDOWN_BYTES : MAX_EXPORT_TOTAL_BYTES - try { - mdBuffer = await downloadFile({ - key: record.key, - context: record.context as StorageContext, - maxBytes: documentLimit, - }) - } catch (error) { - if (!isPayloadSizeLimitError(error)) throw error - return NextResponse.json( - { - error: - format === 'pdf' - ? `This document exceeds the ${formatFileSize(MAX_PDF_MARKDOWN_BYTES)} PDF export limit.` - : `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, - }, - { status: 400 } + 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' ) - } - let mdContent = mdBuffer.toString('utf-8') + const images = new Map() + let sourceBytes = mdBuffer.length - const { keys: imageKeys, ids: imageIds } = extractEmbeddedFileRefs(mdContent) - const imageRefs: ResolvedEmbeddedFileRef[] = [ - ...imageKeys.map((key) => ({ key })), - ...imageIds.map((fileId) => ({ fileId })), - ] + if (record.workspaceId) { + for (const imageRef of imageRefs) { + try { + const image = await resolveWorkspaceInlineImage(record.workspaceId, imageRef) + if (!image || !(await verifyFileAccess(image.key, userId))) continue - logger.info('Exporting markdown', { - id, - format: format ?? 'source', - imageCount: imageRefs.length, - }) + 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 respondWithPdf = async (images: ReadonlyMap) => { - const { MarkdownPdfLimitError, renderMarkdownPdf } = await import( - '@/app/api/files/export/[id]/markdown-pdf' - ) const title = record.originalName.replace(/\.(?:md|markdown)$/i, '') const pdfName = safeFilename(`${title}.pdf`) let pdfBuffer: Buffer @@ -207,7 +250,8 @@ export const GET = withRouteHandler( } throw error } - auditExport('pdf', images.size) + + auditPdfExport(images.size) return new NextResponse(new Uint8Array(pdfBuffer), { status: 200, headers: { @@ -218,8 +262,40 @@ export const GET = withRouteHandler( }) } - if (imageRefs.length === 0) { - if (format === 'pdf') return respondWithPdf(new Map()) + if (!isMarkdown(record.originalName, record.contentType)) { + const storagePrefix = getServeStoragePrefix() + const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` + auditExport('file', 0) + return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + } + + // Capped like everything else in the bundle: the document body is usually the + // largest single entry, so leaving it unbounded left the export limit unenforced + // against the one item most able to exceed it. A body that alone exceeds the limit + // is a size rejection, so it reports as one rather than as a server error. + let mdBuffer: Buffer + try { + mdBuffer = await downloadFile({ + key: record.key, + context: record.context as StorageContext, + maxBytes: MAX_EXPORT_TOTAL_BYTES, + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return NextResponse.json( + { + error: `This document exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, + }, + { status: 400 } + ) + } + let mdContent = mdBuffer.toString('utf-8') + + const imageIds = extractEmbeddedImageIds(mdContent) + + logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + + if (imageIds.length === 0) { const mdName = safeFilename(record.originalName) const mdBytes = Buffer.from(mdContent, 'utf-8') auditExport('markdown', 0) @@ -236,15 +312,15 @@ export const GET = withRouteHandler( // Metadata first: declared sizes bound the download before a byte is read, and the // authorization check costs nothing to run here. const assetTargets = ( - await mapWithConcurrency(imageRefs, MATERIALIZE_CONCURRENCY, async (ref) => { + await mapWithConcurrency(imageIds, MATERIALIZE_CONCURRENCY, async (imageId) => { try { - if (!record.workspaceId) return null - const image = await resolveWorkspaceInlineImage(record.workspaceId, ref) - if (!image || !(await verifyFileAccess(image.key, userId))) return null - return { imageKey: embeddedFileRefKey(ref), image } + const imgRecord = await getFileMetadataById(imageId) + if (!imgRecord) return null + if (!(await verifyFileAccess(imgRecord.key, userId))) return null + return { imageId, record: imgRecord } } catch (error) { logger.warn('Failed to resolve asset for export', { - imageRef: embeddedFileRefKey(ref), + imageId, error: toError(error).message, }) return null @@ -255,44 +331,32 @@ export const GET = withRouteHandler( // The body counts against the same budget as its assets β€” the zip holds both, so a // limit that measured only the attachments would not describe the archive produced. const bundleBytes = - mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.image.size, 0) - const bundleLimit = format === 'pdf' ? MAX_PDF_TOTAL_SOURCE_BYTES : MAX_EXPORT_TOTAL_BYTES - if (bundleBytes > bundleLimit) { + mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.record.size, 0) + if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) { return NextResponse.json( { - error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(bundleLimit)} ${format === 'pdf' ? 'PDF ' : ''}export limit.`, + error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, }, { status: 400 } ) } - let actualBundleBytes = mdBuffer.length const fetched = await mapWithConcurrency( assetTargets, - // PDF assets stay sequential so the actual-byte budget also bounds peak retained buffers; - // ZIP keeps the existing shared materialization concurrency. - format === 'pdf' ? 1 : MATERIALIZE_CONCURRENCY, - async ({ imageKey, image }) => { + MATERIALIZE_CONCURRENCY, + async ({ imageId, record: imgRecord }) => { try { const buffer = await downloadFile({ - key: image.key, - context: 'workspace', - maxBytes: format === 'pdf' ? MAX_PDF_ASSET_BYTES : MAX_EXPORT_ASSET_BYTES, + key: imgRecord.key, + context: imgRecord.context as StorageContext, + maxBytes: MAX_EXPORT_ASSET_BYTES, }) - if (actualBundleBytes + buffer.length > bundleLimit) { - logger.warn('Skipping asset that exceeds the actual export byte budget', { - imageRef: imageKey, - bundleLimit, - }) - return null - } - actualBundleBytes += buffer.length - return { imageKey, originalName: image.filename, buffer } + return { imageId, originalName: imgRecord.originalName, buffer } } catch (error) { // A single unreadable or oversized asset drops out of the bundle rather than // failing the whole export; the markdown keeps its original link. logger.warn('Failed to fetch asset for export', { - imageRef: imageKey, + imageId, error: toError(error).message, }) return null @@ -300,30 +364,27 @@ export const GET = withRouteHandler( } ) - if (format === 'pdf') { - return respondWithPdf( - new Map( - fetched.flatMap((result) => (result ? [[result.imageKey, result.buffer] as const] : [])) - ) - ) - } - const assetMap = new Map() const usedFilenames = new Set() for (const result of fetched) { if (!result) continue - const { imageKey, originalName, buffer } = result + const { imageId, originalName, buffer } = result const preferred = safeFilename(originalName) - const filename = deduplicatedFilename(preferred, usedFilenames) + const filename = deduplicatedFilename(preferred, usedFilenames, imageId) usedFilenames.add(filename) - assetMap.set(imageKey, { filename, buffer }) + assetMap.set(imageId, { filename, buffer }) } - mdContent = replaceEmbeddedFileRefs( - mdContent, - new Map(Array.from(assetMap, ([imageKey, asset]) => [imageKey, `./assets/${asset.filename}`])) - ) + for (const [imageId, asset] of assetMap) { + const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const replacement = `./assets/${asset.filename}` + // Rewrite both embed spellings the extractor resolves to this id β€” the view URL and the in-app + // `/workspace//files/` path β€” so a bundled asset never leaves a broken link in the export. + mdContent = mdContent + .replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement) + .replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement) + } const zip = new JSZip() zip.file(safeFilename(record.originalName), mdContent) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts index af19cb6d3f3..00480f609dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -12,11 +12,6 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({ const lastDot = filename.lastIndexOf('.') return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' }, - isMarkdownFile: (file: { type?: string | null; name: string }): boolean => { - if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true - const extension = file.name.split('.').at(-1)?.toLowerCase() - return extension === 'md' || extension === 'markdown' - }, })) import { resolveFileCategory } from './file-category' @@ -26,7 +21,6 @@ describe('resolveFileCategory β€” MIME type routing', () => { it.each([ 'text/plain', 'text/markdown', - 'text/x-markdown', 'application/json', 'application/x-yaml', 'text/csv', @@ -136,7 +130,7 @@ describe('resolveFileCategory β€” MIME type routing', () => { describe('resolveFileCategory β€” extension fallback', () => { describe('text-editable extensions', () => { - it.each(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( + it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( '.%s β†’ text-editable', (ext) => { expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index 6683bdeabd8..485ebdeda56 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -1,7 +1,8 @@ -import { getFileExtension, isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' const TEXT_EDITABLE_MIME_TYPES = new Set([ + 'text/markdown', 'text/plain', 'application/json', 'application/x-yaml', @@ -22,6 +23,7 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ ]) const TEXT_EDITABLE_EXTENSIONS = new Set([ + 'md', 'txt', 'json', 'yaml', @@ -126,7 +128,6 @@ export type FileCategory = | 'unsupported' export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { - if (isMarkdownFile({ type: mimeType, name: filename })) return 'text-editable' if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index c932b70d63b..2b5b9c997d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -3,10 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import '@sim/emcn/components/code/code.css' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' -import { - getFileExtension, - isMarkdownFile as isSharedMarkdownFile, -} from '@/lib/uploads/utils/file-utils' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import' import { DataTable } from './data-table' import { MermaidDiagram } from './mermaid-diagram' @@ -15,6 +12,7 @@ import { ZoomablePreview } from './zoomable-preview' type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null const PREVIEWABLE_MIME_TYPES: Record = { + 'text/markdown': 'markdown', 'text/html': 'html', 'text/csv': 'csv', 'image/svg+xml': 'svg', @@ -22,6 +20,7 @@ const PREVIEWABLE_MIME_TYPES: Record = { } const PREVIEWABLE_EXTENSIONS: Record = { + md: 'markdown', html: 'html', htm: 'html', csv: 'csv', @@ -30,14 +29,9 @@ const PREVIEWABLE_EXTENSIONS: Record = { } /** All extensions that have a rich preview renderer. */ -export const RICH_PREVIEWABLE_EXTENSIONS = new Set([ - ...Object.keys(PREVIEWABLE_EXTENSIONS), - 'md', - 'markdown', -]) +export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS)) export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType { - if (isSharedMarkdownFile({ type: mimeType, name: filename })) return 'markdown' if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType] const ext = getFileExtension(filename) return PREVIEWABLE_EXTENSIONS[ext] ?? null diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index a6ad0fd0f24..8995a7effe6 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -2,7 +2,6 @@ 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' -import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' export function saveBlob(blob: Blob, fileName: string): void { const objectUrl = URL.createObjectURL(blob) @@ -36,29 +35,32 @@ export async function triggerFileDownload( record: WorkspaceFileRecord, options?: { format?: 'pdf' } ): Promise { - const isMarkdown = isMarkdownFile(record) + const isMarkdown = + record.type === 'text/markdown' || + record.type === 'text/x-markdown' || + /\.(?:md|markdown)$/i.test(record.name) - if (options?.format === 'pdf' && !isMarkdown) { - throw new Error('PDF export is only available for Markdown files') - } - - let response: Response - if (isMarkdown) { - response = await requestRaw( + 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: options?.format } }, + { params: { id: record.id }, query: { format: 'pdf' } }, { cache: 'no-store' } ) - } else { - const url = `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` - // boundary-raw-fetch: legacy binary serve URL includes context and cache-busting query fields outside the serve contract - response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + const fallbackName = `${record.name.replace(/\.[^.]+$/, '')}.pdf` + saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) + return } - const fallbackName = - options?.format === 'pdf' ? `${record.name.replace(/\.[^.]+$/, '')}.pdf` : record.name - saveBlob(await response.blob(), fileNameFromDisposition(response, fallbackName)) + const url = isMarkdown + ? `/api/files/export/${encodeURIComponent(record.id)}` + : `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}` + + // boundary-raw-fetch: binary download read as a blob; these paths have no contract + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Failed to download "${record.name}"`) + + saveBlob(await response.blob(), fileNameFromDisposition(response, record.name)) } /** diff --git a/apps/sim/lib/uploads/server/inline-image.test.ts b/apps/sim/lib/uploads/server/inline-image.test.ts index db5eb6da131..ba774a3e8f1 100644 --- a/apps/sim/lib/uploads/server/inline-image.test.ts +++ b/apps/sim/lib/uploads/server/inline-image.test.ts @@ -21,7 +21,6 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', type: 'image/png', name: 'x.png', - size: 123, }) const out = await resolveWorkspaceInlineImage('ws-1', { fileId: 'wf_a' }) expect(mockGetWorkspaceFile).toHaveBeenCalledWith('ws-1', 'wf_a') @@ -29,7 +28,6 @@ describe('resolveWorkspaceInlineImage', () => { key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', - size: 123, }) }) @@ -44,14 +42,12 @@ describe('resolveWorkspaceInlineImage', () => { workspaceId: 'ws-1', contentType: 'image/png', originalName: 'x.png', - size: 456, }) const out = await resolveWorkspaceInlineImage('ws-1', { key: 'workspace/ws-1/x.png' }) expect(out).toEqual({ key: 'workspace/ws-1/x.png', contentType: 'image/png', filename: 'x.png', - size: 456, }) }) diff --git a/apps/sim/lib/uploads/server/inline-image.ts b/apps/sim/lib/uploads/server/inline-image.ts index 918e2a9b54d..b44b9e08e4a 100644 --- a/apps/sim/lib/uploads/server/inline-image.ts +++ b/apps/sim/lib/uploads/server/inline-image.ts @@ -15,7 +15,6 @@ export interface ResolvedInlineImage { key: string contentType: string filename: string - size: number } /** @@ -31,19 +30,12 @@ export async function resolveWorkspaceInlineImage( ): Promise { if (ref.fileId) { const file = await getWorkspaceFile(workspaceId, ref.fileId) - return file - ? { key: file.key, contentType: file.type, filename: file.name, size: file.size } - : null + return file ? { key: file.key, contentType: file.type, filename: file.name } : null } if (ref.key) { const record = await getFileMetadataByKey(ref.key, 'workspace') if (!record || record.workspaceId !== workspaceId) return null - return { - key: record.key, - contentType: record.contentType, - filename: record.originalName, - size: record.size, - } + return { key: record.key, contentType: record.contentType, filename: record.originalName } } return null } diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts index e13bf554052..bec0f9e936b 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { - embeddedFileRefKey, extractEmbeddedFileRef, extractEmbeddedFileRefs, - replaceEmbeddedFileRefs, } from '@/lib/uploads/utils/embedded-image-ref' const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' @@ -33,13 +31,6 @@ describe('extractEmbeddedFileRef', () => { }) }) -describe('embeddedFileRefKey', () => { - it('keeps storage keys and file ids in distinct map namespaces', () => { - expect(embeddedFileRefKey({ key: KEY })).toBe(`key:${KEY}`) - expect(embeddedFileRefKey({ fileId: 'wf_abc' })).toBe('id:wf_abc') - }) -}) - describe('extractEmbeddedFileRefs', () => { it('collects de-duplicated keys and ids from a document via the shared parser', () => { const content = ` @@ -48,7 +39,6 @@ describe('extractEmbeddedFileRefs', () => { ![c](/workspace/W1/files/4bdaf6c4-072e-464e-891d-b6af3b5fe2cc) ![dup](/api/files/serve/s3/${ENCODED}) ![ext](https://cdn.example.com/x.png) - ![absolute](https://sim.ai/api/files/view/wf_external) ![pub](/api/files/serve/profile-pictures%2Fu1%2Favatar.png) ` const { keys, ids } = extractEmbeddedFileRefs(content) @@ -69,25 +59,3 @@ describe('extractEmbeddedFileRefs', () => { expect(k.length + d.length).toBe(50) }) }) - -describe('replaceEmbeddedFileRefs', () => { - it('rewrites key and id spellings without touching absolute URLs', () => { - const content = [ - `![key](/api/files/serve/${ENCODED}?context=workspace)`, - '![id](/api/files/view/wf_abc)', - '![absolute](https://sim.ai/api/files/view/wf_abc)', - ].join('\n') - const replacements = new Map([ - [`key:${KEY}`, './assets/key.png'], - ['id:wf_abc', './assets/id.png'], - ]) - - expect(replaceEmbeddedFileRefs(content, replacements)).toBe( - [ - '![key](./assets/key.png)', - '![id](./assets/id.png)', - '![absolute](https://sim.ai/api/files/view/wf_abc)', - ].join('\n') - ) - }) -}) diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index c4dfea38ab8..7e780362ea5 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -10,24 +10,17 @@ /** A reference parsed from an embed `src`: a workspace storage key, a workspace file id, or neither. */ export type EmbeddedFileRef = { key: string } | { fileId: string } | null -export type ResolvedEmbeddedFileRef = Exclude /** Hard cap on embedded images resolved from one document β€” bounds export bundles and the cascade. */ export const MAX_EMBEDDED_IMAGES = 50 /** * Candidate embed URL substrings in document text: a serve URL, a view URL, or the in-app workspace - * path. A required start/delimiter prevents matching the path portion of an absolute URL; the captured - * run stops at Markdown/HTML delimiters so authoritative parsing is left to - * {@link extractEmbeddedFileRef}. + * path. The captured run stops at whitespace/quote/paren/angle/query so authoritative parsing is left + * to {@link extractEmbeddedFileRef}. */ const EMBED_URL_RE = - /(^|[\s("'<>])((?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>]*)/gm - -/** Stable map key shared by routes and renderers for either supported reference spelling. */ -export function embeddedFileRefKey(ref: ResolvedEmbeddedFileRef): string { - return 'key' in ref ? `key:${ref.key}` : `id:${ref.fileId}` -} + /(?:\/api\/files\/(?:serve|view)\/|\/workspace\/[A-Za-z0-9-]+\/files\/)[^\s)"'<>?]*/g /** * Parse a single embed `src` into the workspace file it references, normalizing the spellings the @@ -72,7 +65,7 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: const keys = new Set() const ids = new Set() for (const match of content.matchAll(EMBED_URL_RE)) { - const ref = extractEmbeddedFileRef(match[2]) + const ref = extractEmbeddedFileRef(match[0]) if (!ref) continue if ('key' in ref) keys.add(ref.key) else ids.add(ref.fileId) @@ -80,15 +73,3 @@ export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: } return { keys: [...keys], ids: [...ids] } } - -/** Rewrite authorized embedded references while leaving external and unmatched URLs untouched. */ -export function replaceEmbeddedFileRefs( - content: string, - replacements: ReadonlyMap -): string { - return content.replace(EMBED_URL_RE, (_match, prefix: string, candidate: string) => { - const ref = extractEmbeddedFileRef(candidate) - const replacement = ref ? replacements.get(embeddedFileRefKey(ref)) : undefined - return `${prefix}${replacement ?? candidate}` - }) -} diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts index 4521dc6bcf4..79032282bcf 100644 --- a/apps/sim/lib/uploads/utils/file-utils.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.test.ts @@ -27,10 +27,9 @@ describe('isMarkdownFile', () => { expect(isMarkdownFile({ name: 'doc.markdown' })).toBe(true) }) - it('is true for Markdown MIME types even without a .md name', () => { + it('is true for a text/markdown MIME even without a .md name', () => { expect(isMarkdownFile({ type: 'text/markdown', name: 'notes' })).toBe(true) expect(isMarkdownFile({ type: 'text/markdown', name: 'doc.txt' })).toBe(true) - expect(isMarkdownFile({ type: 'text/x-markdown', name: 'legacy' })).toBe(true) }) it('is false for non-markdown files', () => { diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index d3c0c9fbb79..da2c51c0f91 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -213,12 +213,12 @@ export function getFileExtension(filename: string): string { /** * Whether a file renders in the collaborative rich markdown editor. Server-safe counterpart to the * client's `isMarkdownFile` (which uses `resolvePreviewType`): the editor treats a file as markdown by - * its Markdown MIME *or* a `.md`/`.markdown` extension β€” MIME first, matching the client β€” so a - * Markdown file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc merge) - * to exactly the files that can be open in that editor. + * its `text/markdown` MIME *or* a `.md`/`.markdown` extension β€” MIME first, matching the client β€” so a + * `text/markdown` file with a non-`.md` name still counts. Used to gate server work (e.g. the live-doc + * merge) to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { - if (file.type === 'text/markdown' || file.type === 'text/x-markdown') return true + if (file.type === 'text/markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } From 2b17c81fa65d42dc07a9fea2087c49a3d5e7f243 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 09:33:30 -0700 Subject: [PATCH 10/13] refactor(files): keep PDF parsing local --- .../api/files/export/[id]/markdown-pdf.tsx | 22 +++++++++++++++++-- apps/sim/lib/collab-doc/server-markdown.ts | 22 ------------------- 2 files changed, 20 insertions(+), 24 deletions(-) delete mode 100644 apps/sim/lib/collab-doc/server-markdown.ts diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 283cd6dbd35..ccd22a9d512 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -15,12 +15,12 @@ import { } from '@react-pdf/renderer' import type { JSONContent } from '@tiptap/core' import sharp from 'sharp' -import { parseServerMarkdownToDoc } from '@/lib/collab-doc/server-markdown' 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 @@ -41,6 +41,23 @@ interface PdfFont { 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), @@ -678,7 +695,8 @@ export async function renderMarkdownPdf({ }: MarkdownPdfInput): Promise { const normalizedImages = await normalizeImages(images) const { body } = splitFrontmatter(markdown) - const document = parseServerMarkdownToDoc(body) + ensureDomForMarkdownPdf() + const document = parseMarkdownToDoc(body) assertDocumentWithinLimits(document) return renderToBuffer( diff --git a/apps/sim/lib/collab-doc/server-markdown.ts b/apps/sim/lib/collab-doc/server-markdown.ts deleted file mode 100644 index 62f519d1331..00000000000 --- a/apps/sim/lib/collab-doc/server-markdown.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { JSONContent } from '@tiptap/core' -import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' - -/** - * Installs the minimal DOM globals needed by the canonical TipTap Markdown engine in a server - * process. The single jsdom window is reused for every parse/serialize call in that process. - */ -export function ensureServerMarkdownDom(): void { - if (typeof window !== 'undefined' && typeof document !== 'undefined') return - const { JSDOM } = require('jsdom') as typeof import('jsdom') - const { window: jsdomWindow } = new JSDOM('') - const globals = globalThis as unknown as Record - globals.window = jsdomWindow - globals.document = jsdomWindow.document - globals.navigator ??= jsdomWindow.navigator -} - -/** Parse Markdown with the exact extension set and schema used by the Files editor. */ -export function parseServerMarkdownToDoc(markdown: string): JSONContent { - ensureServerMarkdownDom() - return parseMarkdownToDoc(markdown) -} From afa6302d09e1f8678ff087fecf8e34c61b3e82de Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 10:44:20 -0700 Subject: [PATCH 11/13] chore: retrigger security checks --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index ccd22a9d512..29f4dd95a5a 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -22,6 +22,7 @@ import { 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' +// Temporary no-op marker to retrigger external security checks. type PdfImage = { data: Buffer; format: 'png' } type ResolvedPdfImageRef = Exclude From 2779c642541929f6b581355a60a309ef95b48ba7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 10:44:31 -0700 Subject: [PATCH 12/13] chore: remove security check trigger --- apps/sim/app/api/files/export/[id]/markdown-pdf.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index 29f4dd95a5a..ccd22a9d512 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -22,7 +22,6 @@ import { 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' -// Temporary no-op marker to retrigger external security checks. type PdfImage = { data: Buffer; format: 'png' } type ResolvedPdfImageRef = Exclude From 8e30176f4b1410146099abdf6c590c3f2d361d1a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 11 Aug 2026 12:27:17 -0700 Subject: [PATCH 13/13] fix(files): preserve PDF image links and table alignment --- .../files/export/[id]/markdown-pdf.test.ts | 55 ++++++++++++++++ .../api/files/export/[id]/markdown-pdf.tsx | 66 +++++++++++++++---- 2 files changed, 108 insertions(+), 13 deletions(-) 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 index 3b6157af616..236ea5e51d3 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.test.ts @@ -150,6 +150,61 @@ ${repeatedParagraphs}` 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( '' diff --git a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx index ccd22a9d512..f50ca816652 100644 --- a/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx +++ b/apps/sim/app/api/files/export/[id]/markdown-pdf.tsx @@ -249,6 +249,8 @@ const styles = StyleSheet.create({ 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%' }, @@ -451,10 +453,24 @@ function renderImage( 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)} @@ -466,6 +482,18 @@ function renderImage( const width = Number.isFinite(requestedWidth) ? Math.min(Math.max(requestedWidth, 1), PDF_TABLE_CONTENT_WIDTH) : undefined + const renderedImage = ( + + + + ) + if (href) { + return ( + + {renderedImage} + + ) + } return ( @@ -515,19 +543,31 @@ function renderList( function renderTableRow(row: JSONContent, key: string, header: boolean): ReactNode { return ( - {(row.content ?? []).map((cell, index) => ( - - {(cell.content ?? []).map((child, childIndex) => ( - - {childIndex > 0 ? '\n' : null} - {renderInline(`${key}-${index}-${childIndex}`, child.content)} - - ))} - - ))} + {(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)} + + ))} + + ) + })} ) }