From 105c65fb5ef6c56993985ed092a3e9ebc49dd741 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 8 May 2026 16:53:06 -0700 Subject: [PATCH 1/5] =?UTF-8?q?improvement(sandbox):=20expand=20document?= =?UTF-8?q?=20generation=20=E2=80=94=20style=20extraction,=20sandbox=20har?= =?UTF-8?q?dening,=20OOM=20errors,=20PPTX/DOCX/PDF=20task=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/files/[fileId]/style/route.ts | 37 +- apps/sim/lib/api/contracts/workspace-files.ts | 24 +- apps/sim/lib/copilot/vfs/document-style.ts | 371 +++++++++++++++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 8 +- apps/sim/lib/execution/isolated-vm-worker.cjs | 68 ++-- apps/sim/sandbox-tasks/docx-generate.ts | 10 +- apps/sim/sandbox-tasks/pptx-generate.ts | 3 + 7 files changed, 415 insertions(+), 106 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index c30d0e9723f..cc68e4dc348 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -16,21 +16,23 @@ const logger = createLogger('WorkspaceFileStyleAPI') /** * GET /api/workspaces/[id]/files/[fileId]/style - * Extract a compact JSON style summary from an uploaded .docx or .pptx file. - * Uses OOXML theme XML to return theme colors, font pair, and named styles. - * Only works on binary OOXML files (ZIP format) — not on JS source files. + * Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file. + * OOXML files return theme colors, font pair, and named styles. + * PDF files return page dimensions and embedded font names. */ +const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB + export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const parsed = await parseRequest(workspaceFileStyleContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(workspaceFileStyleContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) if (!membership) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) @@ -42,13 +44,20 @@ export const GET = withRouteHandler( } const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx') { + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { return NextResponse.json( - { error: 'Style extraction only supports .docx and .pptx files' }, + { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, + { status: 422 } + ) + } + const ext: 'docx' | 'pptx' | 'pdf' = rawExt + + if (fileRecord.size > MAX_STYLE_FILE_BYTES) { + return NextResponse.json( + { error: 'File is too large for style extraction (limit: 100 MB)' }, { status: 422 } ) } - const ext: 'docx' | 'pptx' = rawExt let buffer: Buffer try { @@ -66,17 +75,13 @@ export const GET = withRouteHandler( return NextResponse.json( { error: - 'File is not a compiled binary document — style extraction requires an uploaded or compiled .docx/.pptx file', + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', }, { status: 422 } ) } - logger.info('Extracted style summary via API', { - fileId, - format: ext, - themeName: summary.theme.name, - }) + logger.info('Extracted style summary via API', { fileId, format: ext }) return NextResponse.json(summary, { headers: { 'Cache-Control': 'private, max-age=300' }, diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index b999b6ac2a5..6f5d1009084 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all']) @@ -58,7 +58,6 @@ export const listWorkspaceFilesContract = defineRouteContract({ }), }, }) -export type ListWorkspaceFilesResponse = ContractJsonResponse export const renameWorkspaceFileContract = defineRouteContract({ method: 'PATCH', @@ -108,15 +107,30 @@ export const updateWorkspaceFileContentContract = defineRouteContract({ const documentStyleSummarySchema = z .object({ - format: z.enum(['docx', 'pptx']), + format: z.enum(['docx', 'pptx', 'pdf']), + // OOXML theme — present for pptx, present for docx when theme1.xml exists, absent for pdf theme: z .object({ - name: z.string(), colors: z.record(z.string(), z.string()), fonts: z.object({ major: z.string(), minor: z.string() }), }) - .passthrough(), + .optional(), + // docx only styles: z.array(z.object({}).passthrough()).optional(), + defaults: z.object({ fontSize: z.number().optional(), font: z.string().optional() }).optional(), + // pdf only + pageSize: z + .object({ + preset: z.enum(['A4', 'letter', 'custom']), + widthPt: z.number().optional(), + heightPt: z.number().optional(), + }) + .optional(), + fonts: z.array(z.string()).optional(), + // pptx only + slideCount: z.number().optional(), + aspectRatio: z.enum(['16:9', '4:3', 'custom']).optional(), + background: z.string().optional(), }) .passthrough() diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index 3c1ebac6c57..eead749a2c9 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -17,17 +17,16 @@ interface ThemeColors { accent4: string accent5: string accent6: string - hlink: string - folHlink: string } export interface DocumentStyleSummary { - format: 'docx' | 'pptx' - theme: { - name: string + format: 'docx' | 'pptx' | 'pdf' + /** OOXML theme — present for pptx; present for docx when theme1.xml exists; absent for pdf */ + theme?: { colors: Partial fonts: { major: string; minor: string } } + /** Named paragraph/character styles — docx only */ styles?: Array<{ id: string name: string @@ -37,6 +36,25 @@ export interface DocumentStyleSummary { color?: string font?: string }> + /** Document-wide default run properties (body text baseline) — docx only */ + defaults?: { + fontSize?: number + font?: string + } + /** Page dimensions — pdf only. widthPt/heightPt present only when preset is 'custom' */ + pageSize?: { + preset: 'A4' | 'letter' | 'custom' + widthPt?: number + heightPt?: number + } + /** Embedded font names extracted from page resource dictionaries — pdf only */ + fonts?: string[] + /** Number of slides — pptx only */ + slideCount?: number + /** Slide aspect ratio — pptx only */ + aspectRatio?: '16:9' | '4:3' | 'custom' + /** Slide master background hex color (no #) — pptx only, absent when background is transparent/image */ + background?: string } function attr(xml: string, name: string): string { @@ -69,8 +87,7 @@ function parseFontScheme(xml: string): { major: string; minor: string } { return { major: attr(major, 'typeface') || '', minor: attr(minor, 'typeface') || '' } } -function parseThemeXml(xml: string): DocumentStyleSummary['theme'] { - const clrSchemeMatch = /]*name="([^"]*)"/.exec(xml) +function parseThemeXml(xml: string): NonNullable { const slots: Array = [ 'dk1', 'lt1', @@ -82,63 +99,295 @@ function parseThemeXml(xml: string): DocumentStyleSummary['theme'] { 'accent4', 'accent5', 'accent6', - 'hlink', - 'folHlink', ] const colors: Partial = {} for (const slot of slots) { const hex = parseColorSlot(xml, slot) if (hex) colors[slot] = hex } - return { name: clrSchemeMatch?.[1] ?? '', colors, fonts: parseFontScheme(xml) } + return { colors, fonts: parseFontScheme(xml) } +} + +type StyleRaw = { + id: string + name: string + type: string + basedOn?: string + fontSize?: number + bold?: boolean + color?: string + font?: string + /** Raw w:asciiTheme value — resolved to a font name after parsing */ + themeFont?: string } -function parseDocxStyles(xml: string): DocumentStyleSummary['styles'] { - const targetIds = new Set([ - 'Normal', - 'DefaultParagraphFont', - 'Heading1', - 'Heading2', - 'Heading3', - 'Title', - 'Subtitle', - ]) - const results: DocumentStyleSummary['styles'] = [] - const blocks = xml.split(' + defaults?: DocumentStyleSummary['defaults'] +} { + // Extract document-default run properties (the baseline for body text) + const defaults: DocumentStyleSummary['defaults'] = {} + const docDefaultsBlock = between(xml, '', '') + if (docDefaultsBlock) { + const rPrBlock = between(docDefaultsBlock, '', '') + if (rPrBlock) { + const szMatch = /]*)>/.exec(rPrBlock) + if (fontAttrMatch) { + const { font } = parseFontAttrs(fontAttrMatch[1], themeFonts) + if (font) defaults.font = font + } + } + } + + // Build a full style map for basedOn inheritance resolution + const styleMap = new Map() + for (const block of xml.split('/.test(block) && !/]*w:ascii="([^"]*)"/.exec(block) - const font = fontMatch?.[1] - results.push({ - id: styleId, - name, - type: styleType, - ...(fontSize !== undefined && { fontSize }), - ...(bold && { bold }), - ...(color && { color }), + const fontAttrMatch = /]*)>/.exec(block) + const { font, themeFont } = fontAttrMatch ? parseFontAttrs(fontAttrMatch[1], themeFonts) : {} + + styleMap.set(id, { + id, + name: nameMatch?.[1] ?? id, + type, + ...(basedOnMatch && { basedOn: basedOnMatch[1] }), + ...(szMatch && { fontSize: Math.round(Number.parseInt(szMatch[1]) / 2) }), + ...(//.test(block) && + !/]*\bw:val=["'](0|false)["']/.test(block) && { bold: true }), + ...(colorMatch && { color: colorMatch[1].toUpperCase() }), ...(font && { font }), + ...(themeFont && { themeFont }), }) } - return results + + function resolveInheritance(id: string, visited = new Set()): StyleRaw | undefined { + if (visited.has(id)) return undefined + visited.add(id) + const s = styleMap.get(id) + if (!s) return undefined + if (!s.basedOn) return s + const parent = resolveInheritance(s.basedOn, visited) + if (!parent) return s + // Own properties override parent; undefined falls through to parent + return { + ...parent, + ...s, + fontSize: s.fontSize ?? parent.fontSize, + bold: s.bold ?? parent.bold, + color: s.color ?? parent.color, + font: s.font ?? parent.font, + themeFont: s.themeFont ?? parent.themeFont, + } + } + + // Target paragraph styles (character styles excluded — generation works at paragraph level) + const targetIds: string[] = ['Normal', 'BodyText', 'Body Text', 'Title', 'Subtitle'] + for (const id of styleMap.keys()) { + // Match both 'Heading1' (Office) and 'heading1' (LibreOffice) style IDs + if (/^[Hh]eading\d/.test(id) && !targetIds.includes(id)) targetIds.push(id) + } + + const styles: NonNullable = [] + const seen = new Set() + for (const id of targetIds) { + if (seen.has(id)) continue + seen.add(id) + const resolved = resolveInheritance(id) + if (!resolved || resolved.type !== 'paragraph') continue + + // Deferred theme font resolution (only reached when themeFonts was unavailable during parse) + let resolvedFont = resolved.font + if (!resolvedFont && resolved.themeFont && themeFonts) { + resolvedFont = resolveThemeFont(resolved.themeFont, themeFonts) + } + + styles.push({ + id: resolved.id, + name: resolved.name, + type: resolved.type, + ...(resolved.fontSize !== undefined && { fontSize: resolved.fontSize }), + ...(resolved.bold && { bold: resolved.bold }), + ...(resolved.color && { color: resolved.color }), + ...(resolvedFont && { font: resolvedFont }), + }) + } + + return { + styles, + ...(Object.keys(defaults).length > 0 && { defaults }), + } +} + +async function extractPdfStyle(buffer: Buffer): Promise { + try { + const { PDFDocument, PDFName, PDFDict } = await import('pdf-lib') + + let doc: Awaited> + try { + doc = await PDFDocument.load(buffer, { updateMetadata: false }) + } catch { + // Encrypted or corrupt + return null + } + + const pages = doc.getPages() + if (pages.length === 0) return null + + // Page dimensions (first page is canonical for preset detection) + const { width: widthPt, height: heightPt } = pages[0].getSize() + let preset: 'A4' | 'letter' | 'custom' = 'custom' + if (Math.abs(widthPt - 595.28) < 5 && Math.abs(heightPt - 841.89) < 5) preset = 'A4' + else if (Math.abs(widthPt - 612) < 5 && Math.abs(heightPt - 792) < 5) preset = 'letter' + + // Font names from page resource dictionaries (first 10 pages to bound cost) + const rawFontNames = new Set() + const pagesToScan = Math.min(pages.length, 10) + for (let i = 0; i < pagesToScan; i++) { + try { + const resourcesRef = pages[i].node.get(PDFName.of('Resources')) + if (!resourcesRef) continue + const resources = doc.context.lookup(resourcesRef, PDFDict) + if (!resources) continue + const fontDictRef = resources.get(PDFName.of('Font')) + if (!fontDictRef) continue + const fontDict = doc.context.lookup(fontDictRef, PDFDict) + if (!fontDict) continue + for (const key of fontDict.keys()) { + try { + const fontRef = fontDict.get(key) + if (!fontRef) continue + const fontObj = doc.context.lookup(fontRef, PDFDict) + if (!fontObj) continue + const baseFontRef = fontObj.get(PDFName.of('BaseFont')) + if (!baseFontRef) continue + // Format: "/ABCDEF+FontName" (subset) or "/FontName" (full embed) + const raw = baseFontRef + .toString() + .replace(/^\//, '') + .replace(/^[A-Z]{6}\+/, '') + if (raw) rawFontNames.add(raw) + } catch {} + } + } catch {} + } + + // Normalize to unique font family names by stripping PostScript weight/style suffixes. + // Apply the strip in a loop to handle compound suffixes (e.g. SemiBoldItalic, LightOblique). + // BoldMT must precede Bold, Oblique must precede the simple form, etc. + const SUFFIX_RX = + /[-]?(BoldMT|BoldOblique|BoldItalic|SemiBoldItalic|ExtraBoldItalic|LightItalic|LightOblique|MediumItalic|Regular|ExtraBold|SemiBold|Medium|Black|Light|Bold|Italic|Oblique|Condensed|Expanded|MT)$/i + const familyNames = [ + ...new Set( + [...rawFontNames].map((name) => { + let n = name + // Strip up to 3 suffix components to handle compound PostScript names + for (let i = 0; i < 3; i++) { + const stripped = n.replace(SUFFIX_RX, '').trim() + if (stripped === n) break + n = stripped + } + return n + }) + ), + ].filter(Boolean) + + // Omit exact dimensions when the preset already encodes the page size + const pageSize: DocumentStyleSummary['pageSize'] = + preset === 'custom' + ? { widthPt: Math.round(widthPt), heightPt: Math.round(heightPt), preset } + : { preset } + + return { + format: 'pdf', + pageSize, + ...(familyNames.length > 0 && { fonts: familyNames }), + } + } catch (err) { + logger.warn('Failed to extract PDF style', { error: toError(err).message }) + return null + } +} + +function parsePptxPresentation(xml: string): { + slideCount: number + aspectRatio: '16:9' | '4:3' | 'custom' +} { + // Count sldId elements inside sldIdLst + const sldIdLst = between(xml, '', '') + const slideCount = (sldIdLst.match(/]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(xml) + let aspectRatio: '16:9' | '4:3' | 'custom' = 'custom' + if (sldSzMatch) { + const cx = Number.parseInt(sldSzMatch[1]) + const cy = Number.parseInt(sldSzMatch[2]) + const ratio = cx / cy + // 16:9 ≈ 1.7778 (covers both 9144000×5143500 and 12192000×6858000) + // 4:3 ≈ 1.3333 (9144000×6858000 or 10×7.5 inches) + if (Math.abs(ratio - 16 / 9) < 0.01) aspectRatio = '16:9' + else if (Math.abs(ratio - 4 / 3) < 0.01) aspectRatio = '4:3' + } + + return { slideCount, aspectRatio } +} + +function parseSlideMasterBackground(xml: string): string | undefined { + // Look for a solid fill color in the slide master background + const bgBlock = between(xml, '', '') + if (!bgBlock) return undefined + // solidFill with srgbClr + const srgbMatch = /]*\bval="([A-Fa-f0-9]{6})"/.exec(bgBlock) + if (srgbMatch) return srgbMatch[1].toUpperCase() + // solidFill with sysClr fallback + const sysMatch = /]*\blastClr="([A-Fa-f0-9]{6})"/.exec(bgBlock) + if (sysMatch) return sysMatch[1].toUpperCase() + return undefined } /** - * Extract a compact style summary from a binary OOXML (.docx or .pptx) buffer. - * Returns null if the buffer is not a valid ZIP/OOXML file. + * Extract a compact style summary from a binary document buffer. + * Supports .docx and .pptx (OOXML/ZIP) and .pdf. + * Returns null if the buffer cannot be parsed or yields no useful data. */ export async function extractDocumentStyle( buffer: Buffer, - ext: 'docx' | 'pptx' + ext: 'docx' | 'pptx' | 'pdf' ): Promise { + if (ext === 'pdf') { + return extractPdfStyle(buffer) + } + if (buffer.length < 4) return null for (let i = 0; i < 4; i++) { if (buffer[i] !== ZIP_MAGIC[i]) return null @@ -150,16 +399,42 @@ export async function extractDocumentStyle( const themePath = ext === 'docx' ? 'word/theme/theme1.xml' : 'ppt/theme/theme1.xml' const themeFile = zip.file(themePath) - if (!themeFile) return null - const theme = parseThemeXml(await themeFile.async('string')) - const summary: DocumentStyleSummary = { format: ext, theme } + let theme: DocumentStyleSummary['theme'] + if (themeFile) { + theme = parseThemeXml(await themeFile.async('string')) + } else if (ext === 'pptx') { + // PPTX without a theme is malformed — nothing useful to return + return null + } + // DOCX without a theme is valid (e.g. LibreOffice-generated); continue with styles only + + const summary: DocumentStyleSummary = { format: ext, ...(theme && { theme }) } if (ext === 'docx') { const stylesFile = zip.file('word/styles.xml') if (stylesFile) { - const styles = parseDocxStyles(await stylesFile.async('string')) - if (styles && styles.length > 0) summary.styles = styles + const { styles, defaults } = parseDocxStyles(await stylesFile.async('string'), theme?.fonts) + if (styles.length > 0) summary.styles = styles + if (defaults) summary.defaults = defaults + } + // If there's neither a theme nor any styles, there's nothing useful to return + if (!theme && !summary.styles?.length) return null + } + + if (ext === 'pptx') { + const presFile = zip.file('ppt/presentation.xml') + if (presFile) { + const { slideCount, aspectRatio } = parsePptxPresentation(await presFile.async('string')) + if (slideCount > 0) summary.slideCount = slideCount + summary.aspectRatio = aspectRatio + } + const masterFile = + zip.file('ppt/slideMasters/slideMaster1.xml') ?? + zip.file('ppt/slidemaster/slidemaster1.xml') + if (masterFile) { + const bg = parseSlideMasterBackground(await masterFile.async('string')) + if (bg) summary.background = bg } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 5ab975876c7..6e5cd70bb7d 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -316,7 +316,7 @@ function getStaticComponentFiles(): Map { * tables/{name}/meta.json * files/{name}/meta.json * files/by-id/{id}/meta.json - * files/by-id/{id}/style (dynamic — OOXML theme/font extraction for .docx/.pptx) + * files/by-id/{id}/style (dynamic — style extraction for .docx/.pptx/.pdf) * files/by-id/{id}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?}) * jobs/{title}/meta.json * jobs/{title}/history.json @@ -457,7 +457,7 @@ export class WorkspaceVFS { * Attempt to read dynamic workspace file content from storage. * Handles images (base64), parseable documents (PDF, etc.), and text files. * Also handles: - * `files/by-id/{id}/style` — OOXML theme/style extraction (.docx / .pptx only) + * `files/by-id/{id}/style` — style extraction (.docx / .pptx / .pdf) * `files/by-id/{id}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. */ @@ -518,8 +518,8 @@ export class WorkspaceVFS { const record = await getWorkspaceFile(this._workspaceId, fileId) if (!record) return null const rawExt = record.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx') return null - const ext: 'docx' | 'pptx' = rawExt + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null + const ext: 'docx' | 'pptx' | 'pdf' = rawExt const buffer = await fetchWorkspaceFileBuffer(record) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index aa23858e151..5f43c731402 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -376,6 +376,24 @@ async function executeCode(request, executionId) { stack: err.stack, } + // OOM check must run before the isDisposed guard: isolate OOM auto-disposes + // the isolate (isDisposed becomes true), so the cancel branch would fire first + // and mask the real cause. Message-based detection disambiguates the two. + if ( + err.message.includes('Array buffer allocation failed') || + err.message.includes('memory limit') + ) { + return { + result: null, + stdout, + error: { + message: + 'Execution exceeded memory limit (256 MB). Reduce image sizes or split the work into smaller batches.', + name: 'MemoryLimitError', + }, + } + } + // Host sent a `cancel` IPC which called `isolate.dispose()`. Any // in-flight compileScript/run then throws; detect that authoritatively // via the isolate flag rather than fuzzy-matching the error message. @@ -398,21 +416,6 @@ async function executeCode(request, executionId) { } } - if ( - err.message.includes('Array buffer allocation failed') || - err.message.includes('memory limit') - ) { - return { - result: null, - stdout, - error: { - message: - 'Execution exceeded memory limit (256 MB). Reduce image sizes or split the work into smaller batches.', - name: 'MemoryLimitError', - }, - } - } - return { result: null, stdout, @@ -930,6 +933,25 @@ async function executeTask(request, executionId) { timings.total = Date.now() - tStart if (err instanceof Error) { const errorInfo = { message: err.message, name: err.name, stack: err.stack } + // OOM check must run before the isDisposed guard: isolate OOM auto-disposes + // the isolate (isDisposed becomes true), so the cancel branch would fire first + // and mask the real cause. Message-based detection disambiguates the two. + if ( + err.message?.includes('Array buffer allocation failed') || + err.message?.includes('memory limit') + ) { + return { + result: null, + stdout, + error: { + message: + 'Execution exceeded memory limit (256 MB). Reduce image sizes or split the work into smaller batches.', + name: 'MemoryLimitError', + }, + timings, + } + } + // Cancellation: host sent `cancel` IPC which called `isolate.dispose()`. // Detect authoritatively via the isolate flag so we don't depend on // isolated-vm's internal error wording. @@ -953,22 +975,6 @@ async function executeTask(request, executionId) { } } - if ( - err.message?.includes('Array buffer allocation failed') || - err.message?.includes('memory limit') - ) { - return { - result: null, - stdout, - error: { - message: - 'Execution exceeded memory limit (256 MB). Reduce image sizes or split the work into smaller batches.', - name: 'MemoryLimitError', - }, - timings, - } - } - return { result: null, stdout, diff --git a/apps/sim/sandbox-tasks/docx-generate.ts b/apps/sim/sandbox-tasks/docx-generate.ts index 214b9f8f41f..d93954d923c 100644 --- a/apps/sim/sandbox-tasks/docx-generate.ts +++ b/apps/sim/sandbox-tasks/docx-generate.ts @@ -15,6 +15,9 @@ export const docxGenerateTask = defineSandboxTask({ globalThis.addSection = (section) => { globalThis.__docxSections.push(section); }; + // Set globalThis.__docxDocOptions = { styles: {...}, numbering: {...} } in chunk 1 + // to configure document-wide styles and numbering in chunked (addSection) mode. + globalThis.__docxDocOptions = null; // Page geometry constants (twips, 1 twip = 1/1440 inch) for US Letter globalThis.PAGE_W = 12240; // 8.5" @@ -79,10 +82,13 @@ export const docxGenerateTask = defineSandboxTask({ finalize: ` let doc = globalThis.doc; if (!doc && globalThis.__docxSections.length > 0) { - doc = new globalThis.docx.Document({ sections: globalThis.__docxSections }); + doc = new globalThis.docx.Document({ + ...(globalThis.__docxDocOptions || {}), + sections: globalThis.__docxSections, + }); } if (!doc) { - throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set doc = new docx.Document({...}) for a single write.'); + throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set globalThis.doc = new docx.Document({...}) for a single write.'); } const b64 = await globalThis.docx.Packer.toBase64String(doc); const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; diff --git a/apps/sim/sandbox-tasks/pptx-generate.ts b/apps/sim/sandbox-tasks/pptx-generate.ts index 986954da8d6..8319eb37948 100644 --- a/apps/sim/sandbox-tasks/pptx-generate.ts +++ b/apps/sim/sandbox-tasks/pptx-generate.ts @@ -60,6 +60,9 @@ export const pptxGenerateTask = defineSandboxTask({ }; `, finalize: ` + if (!globalThis.pptx) { + throw new Error('No presentation found. Do not overwrite globalThis.pptx — call globalThis.pptx.addSlide() directly.'); + } const bytes = await globalThis.pptx.write({ outputType: 'uint8array' }); return bytes; `, From 19bceaef193fe070578d240b02b03270015c17ba Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 8 May 2026 17:05:57 -0700 Subject: [PATCH 2/5] fix(style): make pptx aspect-ratio regex attribute-order independent --- apps/sim/lib/copilot/vfs/document-style.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index eead749a2c9..29218b568ba 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -346,15 +346,15 @@ function parsePptxPresentation(xml: string): { const sldIdLst = between(xml, '', '') const slideCount = (sldIdLst.match(/]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(xml) + // Slide size in EMU — 1 inch = 914400 EMU. Capture cx/cy independently so + // attribute order (LibreOffice/Google Slides may write cy before cx) doesn't matter. + const cxMatch = /]*\bcx="(\d+)"/.exec(xml) + const cyMatch = /]*\bcy="(\d+)"/.exec(xml) let aspectRatio: '16:9' | '4:3' | 'custom' = 'custom' - if (sldSzMatch) { - const cx = Number.parseInt(sldSzMatch[1]) - const cy = Number.parseInt(sldSzMatch[2]) + if (cxMatch && cyMatch) { + const cx = Number.parseInt(cxMatch[1]) + const cy = Number.parseInt(cyMatch[1]) const ratio = cx / cy - // 16:9 ≈ 1.7778 (covers both 9144000×5143500 and 12192000×6858000) - // 4:3 ≈ 1.3333 (9144000×6858000 or 10×7.5 inches) if (Math.abs(ratio - 16 / 9) < 0.01) aspectRatio = '16:9' else if (Math.abs(ratio - 4 / 3) < 0.01) aspectRatio = '4:3' } From d251915f2002d339e27977dc8a3cb8770067069a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 8 May 2026 17:07:43 -0700 Subject: [PATCH 3/5] fix(sandbox): clarify pptx null-guard message; fix bold=false inheritance sentinel in docx style extractor --- apps/sim/lib/copilot/vfs/document-style.ts | 7 ++++--- apps/sim/sandbox-tasks/pptx-generate.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index 29218b568ba..7edbe202fc2 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -182,8 +182,9 @@ function parseDocxStyles( type, ...(basedOnMatch && { basedOn: basedOnMatch[1] }), ...(szMatch && { fontSize: Math.round(Number.parseInt(szMatch[1]) / 2) }), - ...(//.test(block) && - !/]*\bw:val=["'](0|false)["']/.test(block) && { bold: true }), + ...(//.test(block) && { + bold: !/]*\bw:val=["'](0|false)["']/.test(block), + }), ...(colorMatch && { color: colorMatch[1].toUpperCase() }), ...(font && { font }), ...(themeFont && { themeFont }), @@ -236,7 +237,7 @@ function parseDocxStyles( name: resolved.name, type: resolved.type, ...(resolved.fontSize !== undefined && { fontSize: resolved.fontSize }), - ...(resolved.bold && { bold: resolved.bold }), + ...(resolved.bold !== undefined && { bold: resolved.bold }), ...(resolved.color && { color: resolved.color }), ...(resolvedFont && { font: resolvedFont }), }) diff --git a/apps/sim/sandbox-tasks/pptx-generate.ts b/apps/sim/sandbox-tasks/pptx-generate.ts index 8319eb37948..f31fcb9a1f1 100644 --- a/apps/sim/sandbox-tasks/pptx-generate.ts +++ b/apps/sim/sandbox-tasks/pptx-generate.ts @@ -61,7 +61,7 @@ export const pptxGenerateTask = defineSandboxTask({ `, finalize: ` if (!globalThis.pptx) { - throw new Error('No presentation found. Do not overwrite globalThis.pptx — call globalThis.pptx.addSlide() directly.'); + throw new Error('No presentation found. globalThis.pptx was overwritten — use the pre-initialized instance and call addSlide() on it to build your presentation.'); } const bytes = await globalThis.pptx.write({ outputType: 'uint8array' }); return bytes; From c233d41ed6ff7c228008581b55cd59ef18dedda5 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 8 May 2026 17:15:57 -0700 Subject: [PATCH 4/5] =?UTF-8?q?chore(lint):=20suppress=20noTemplateCurlyIn?= =?UTF-8?q?String=20in=20resolver=20tests=20=E2=80=94=20strings=20intentio?= =?UTF-8?q?nally=20assert=20template=20literal=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/executor/variables/resolver.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 8f255269661..9fe0e6273fd 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -127,6 +127,7 @@ describe('VariableResolver function block inputs', () => { ) expect(result.resolvedInputs.code).toBe( + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved 'return `value: ${JSON.stringify(globalThis["__blockRef_0"])}`' ) expect(result.displayInputs.code).toBe('return `value: "hello world"`') @@ -139,11 +140,14 @@ describe('VariableResolver function block inputs', () => { const result = resolver.resolveInputsForFunctionBlock( ctx, 'function', + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved { code: 'return `${String()}`' }, block ) + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved expect(result.resolvedInputs.code).toBe('return `${String(globalThis["__blockRef_0"])}`') + // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — asserting template literal is preserved expect(result.displayInputs.code).toBe('return `${String("hello world")}`') expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) }) From b8664d3fbde9a1e23fe2cf7af2976d395e2792df Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 8 May 2026 17:25:56 -0700 Subject: [PATCH 5/5] fix(contracts): export ListWorkspaceFilesResponse type from workspace-files contract --- apps/sim/lib/api/contracts/workspace-files.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 6f5d1009084..3fc1a57d6e4 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -46,6 +46,12 @@ const workspaceFileSuccessSchema = z.object({ success: z.boolean(), }) +const listWorkspaceFilesResponseSchema = workspaceFileSuccessSchema.extend({ + files: z.array(workspaceFileRecordSchema), +}) + +export type ListWorkspaceFilesResponse = z.output + export const listWorkspaceFilesContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files', @@ -53,9 +59,7 @@ export const listWorkspaceFilesContract = defineRouteContract({ query: listWorkspaceFilesQuerySchema, response: { mode: 'json', - schema: workspaceFileSuccessSchema.extend({ - files: z.array(workspaceFileRecordSchema), - }), + schema: listWorkspaceFilesResponseSchema, }, })