mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(sandbox): expand document generation — style extraction, sandbox hardening, OOM errors, task guards (#4526)
* improvement(sandbox): expand document generation — style extraction, sandbox hardening, OOM errors, PPTX/DOCX/PDF task guards * fix(style): make pptx aspect-ratio regex attribute-order independent * fix(sandbox): clarify pptx null-guard message; fix bold=false inheritance sentinel in docx style extractor * chore(lint): suppress noTemplateCurlyInString in resolver tests — strings intentionally assert template literal preservation * fix(contracts): export ListWorkspaceFilesResponse type from workspace-files contract
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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(<Producer.result>)}`' },
|
||||
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' })
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -46,6 +46,12 @@ const workspaceFileSuccessSchema = z.object({
|
||||
success: z.boolean(),
|
||||
})
|
||||
|
||||
const listWorkspaceFilesResponseSchema = workspaceFileSuccessSchema.extend({
|
||||
files: z.array(workspaceFileRecordSchema),
|
||||
})
|
||||
|
||||
export type ListWorkspaceFilesResponse = z.output<typeof listWorkspaceFilesResponseSchema>
|
||||
|
||||
export const listWorkspaceFilesContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/files',
|
||||
@@ -53,12 +59,9 @@ export const listWorkspaceFilesContract = defineRouteContract({
|
||||
query: listWorkspaceFilesQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: workspaceFileSuccessSchema.extend({
|
||||
files: z.array(workspaceFileRecordSchema),
|
||||
}),
|
||||
schema: listWorkspaceFilesResponseSchema,
|
||||
},
|
||||
})
|
||||
export type ListWorkspaceFilesResponse = ContractJsonResponse<typeof listWorkspaceFilesContract>
|
||||
|
||||
export const renameWorkspaceFileContract = defineRouteContract({
|
||||
method: 'PATCH',
|
||||
@@ -108,15 +111,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()
|
||||
|
||||
|
||||
@@ -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<ThemeColors>
|
||||
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 = /<a:clrScheme[^>]*name="([^"]*)"/.exec(xml)
|
||||
function parseThemeXml(xml: string): NonNullable<DocumentStyleSummary['theme']> {
|
||||
const slots: Array<keyof ThemeColors> = [
|
||||
'dk1',
|
||||
'lt1',
|
||||
@@ -82,63 +99,296 @@ function parseThemeXml(xml: string): DocumentStyleSummary['theme'] {
|
||||
'accent4',
|
||||
'accent5',
|
||||
'accent6',
|
||||
'hlink',
|
||||
'folHlink',
|
||||
]
|
||||
const colors: Partial<ThemeColors> = {}
|
||||
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) }
|
||||
}
|
||||
|
||||
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('<w:style ')
|
||||
for (const block of blocks.slice(1)) {
|
||||
const styleId = attr(block, 'w:styleId')
|
||||
const styleType = attr(block, 'w:type')
|
||||
if (!targetIds.has(styleId) && !styleId.startsWith('Heading')) continue
|
||||
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 resolveThemeFont(themeFont: string, themeFonts: { major: string; minor: string }): string {
|
||||
const ref = themeFont.toLowerCase()
|
||||
return ref.includes('major') ? themeFonts.major : themeFonts.minor
|
||||
}
|
||||
|
||||
function parseFontAttrs(
|
||||
fontAttrsXml: string,
|
||||
themeFonts?: { major: string; minor: string }
|
||||
): { font?: string; themeFont?: string } {
|
||||
const asciiLit = /\bw:ascii="([^"]+)"/.exec(fontAttrsXml)
|
||||
// LibreOffice DOCX files may put a semicolon-separated fallback list in w:ascii — take the first
|
||||
if (asciiLit) return { font: asciiLit[1].split(';')[0].trim() || asciiLit[1] }
|
||||
const themeRef = /\bw:asciiTheme="([^"]+)"/.exec(fontAttrsXml)
|
||||
if (!themeRef) return {}
|
||||
// Resolve immediately if theme fonts are available, otherwise defer
|
||||
if (themeFonts) return { font: resolveThemeFont(themeRef[1], themeFonts) }
|
||||
return { themeFont: themeRef[1] }
|
||||
}
|
||||
|
||||
function parseDocxStyles(
|
||||
xml: string,
|
||||
themeFonts?: { major: string; minor: string }
|
||||
): {
|
||||
styles: NonNullable<DocumentStyleSummary['styles']>
|
||||
defaults?: DocumentStyleSummary['defaults']
|
||||
} {
|
||||
// Extract document-default run properties (the baseline for body text)
|
||||
const defaults: DocumentStyleSummary['defaults'] = {}
|
||||
const docDefaultsBlock = between(xml, '<w:docDefaults>', '</w:docDefaults>')
|
||||
if (docDefaultsBlock) {
|
||||
const rPrBlock = between(docDefaultsBlock, '<w:rPrDefault>', '</w:rPrDefault>')
|
||||
if (rPrBlock) {
|
||||
const szMatch = /<w:sz w:val="(\d+)"/.exec(rPrBlock)
|
||||
if (szMatch) defaults.fontSize = Math.round(Number.parseInt(szMatch[1]) / 2)
|
||||
const fontAttrMatch = /<w:rFonts([^>]*)>/.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<string, StyleRaw>()
|
||||
for (const block of xml.split('<w:style ').slice(1)) {
|
||||
const id = attr(block, 'w:styleId')
|
||||
if (!id) continue
|
||||
const type = attr(block, 'w:type')
|
||||
const nameMatch = /<w:name w:val="([^"]*)"/.exec(block)
|
||||
const name = nameMatch?.[1] ?? styleId
|
||||
const basedOnMatch = /<w:basedOn w:val="([^"]*)"/.exec(block)
|
||||
const szMatch = /<w:sz w:val="(\d+)"/.exec(block)
|
||||
const fontSize = szMatch ? Math.round(Number.parseInt(szMatch[1]) / 2) : undefined
|
||||
const bold = /<w:b\b(?:\s[^/]*)?\/?>/.test(block) && !/<w:b w:val="0"/.test(block)
|
||||
const colorMatch = /<w:color w:val="([A-Fa-f0-9]{6})"/.exec(block)
|
||||
const color = colorMatch?.[1]?.toUpperCase()
|
||||
const fontMatch = /<w:rFonts[^>]*w:ascii="([^"]*)"/.exec(block)
|
||||
const font = fontMatch?.[1]
|
||||
results.push({
|
||||
id: styleId,
|
||||
name,
|
||||
type: styleType,
|
||||
...(fontSize !== undefined && { fontSize }),
|
||||
...(bold && { bold }),
|
||||
...(color && { color }),
|
||||
const fontAttrMatch = /<w:rFonts([^>]*)>/.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) }),
|
||||
...(/<w:b\b(?:\s[^/]*)?\/?>/.test(block) && {
|
||||
bold: !/<w:b\b[^>]*\bw:val=["'](0|false)["']/.test(block),
|
||||
}),
|
||||
...(colorMatch && { color: colorMatch[1].toUpperCase() }),
|
||||
...(font && { font }),
|
||||
...(themeFont && { themeFont }),
|
||||
})
|
||||
}
|
||||
return results
|
||||
|
||||
function resolveInheritance(id: string, visited = new Set<string>()): 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<DocumentStyleSummary['styles']> = []
|
||||
const seen = new Set<string>()
|
||||
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 !== undefined && { 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<DocumentStyleSummary | null> {
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFDict } = await import('pdf-lib')
|
||||
|
||||
let doc: Awaited<ReturnType<typeof PDFDocument.load>>
|
||||
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<string>()
|
||||
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, '<p:sldIdLst>', '</p:sldIdLst>')
|
||||
const slideCount = (sldIdLst.match(/<p:sldId\b/g) ?? []).length
|
||||
|
||||
// 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 = /<p:sldSz\b[^>]*\bcx="(\d+)"/.exec(xml)
|
||||
const cyMatch = /<p:sldSz\b[^>]*\bcy="(\d+)"/.exec(xml)
|
||||
let aspectRatio: '16:9' | '4:3' | 'custom' = 'custom'
|
||||
if (cxMatch && cyMatch) {
|
||||
const cx = Number.parseInt(cxMatch[1])
|
||||
const cy = Number.parseInt(cyMatch[1])
|
||||
const ratio = cx / cy
|
||||
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, '<p:bg>', '</p:bg>')
|
||||
if (!bgBlock) return undefined
|
||||
// solidFill with srgbClr
|
||||
const srgbMatch = /<a:srgbClr\b[^>]*\bval="([A-Fa-f0-9]{6})"/.exec(bgBlock)
|
||||
if (srgbMatch) return srgbMatch[1].toUpperCase()
|
||||
// solidFill with sysClr fallback
|
||||
const sysMatch = /<a:sysClr\b[^>]*\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<DocumentStyleSummary | null> {
|
||||
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 +400,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ function getStaticComponentFiles(): Map<string, string> {
|
||||
* 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,9 @@ export const docxGenerateTask = defineSandboxTask<SandboxTaskInput>({
|
||||
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<SandboxTaskInput>({
|
||||
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+/';
|
||||
|
||||
@@ -60,6 +60,9 @@ export const pptxGenerateTask = defineSandboxTask<SandboxTaskInput>({
|
||||
};
|
||||
`,
|
||||
finalize: `
|
||||
if (!globalThis.pptx) {
|
||||
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;
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user