Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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 {
Expand All @@ -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' },
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/executor/variables/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"`')
Expand All @@ -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' })
})
Expand Down
34 changes: 26 additions & 8 deletions apps/sim/lib/api/contracts/workspace-files.ts
Original file line number Diff line number Diff line change
@@ -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'])

Expand Down Expand Up @@ -46,19 +46,22 @@ 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',
params: workspaceFilesParamsSchema,
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',
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading