Skip to content
Open
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
70 changes: 56 additions & 14 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,48 @@ export const AiRedactArgsSchema = z.object({
),
})

export const PageSizePresetSchema = z.enum([
'A0',
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'Letter',
'Legal',
])

export const HTMLLayoutSchema = z
.object({
orientation: z
.enum(['landscape', 'portrait'])
.optional()
.describe('Page orientation for HTML-to-PDF conversion.'),
size: z
.union([
PageSizePresetSchema,
z.object({
width: z.number().positive().describe('Page width in millimeters.'),
height: z.number().positive().describe('Page height in millimeters.'),
}),
])
.optional()
.describe('Page size as a preset name (e.g. "A4", "Letter") or custom dimensions in millimeters.'),
margin: z
.object({
left: z.number().min(0).describe('Left margin in millimeters.'),
top: z.number().min(0).describe('Top margin in millimeters.'),
right: z.number().min(0).describe('Right margin in millimeters.'),
bottom: z.number().min(0).describe('Bottom margin in millimeters.'),
})
.optional()
.describe('Page margins in millimeters.'),
})
.optional()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: We can remove this optional. It's up to the consumer to decide if the schema is optional, i.e. FilePartSchema.


export const FilePartSchema = z.object({
file: z
.string()
Expand All @@ -180,6 +222,10 @@ export const FilePartSchema = z.object({
.string()
.optional()
.describe("Used to determine the file type when the file content type is not available and can't be inferred."),
layout: HTMLLayoutSchema.optional().describe(
'Page layout options for HTML-to-PDF conversion. Only applies when the input is an HTML file. ' +
'Supports orientation, page size, and margins.',
),

// For simplicity, we do not allow actions to be performed on individual parts. Instead, actions can be performed on the resulting parts output.
// actions: z
Expand Down Expand Up @@ -254,20 +300,16 @@ export const BaseWatermarkPropertiesSchema = z.object({
.describe(
'For image watermarks, the path to the image file or a reference to a file in the multipart request. Resolves to sandbox path if enabled, otherwise resolves to the local file system.',
),

// For simplicity, we apply the watermark to the center of the page.
// top: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the top edge of a page.'),
// right: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the right edge of a page.'),
// bottom: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the bottom edge of a page.'),
// left: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the left edge of a page.'),

// For simplicity, we do not support custom fonts.
// fontFamily: z.string().optional().describe('The font to render the text.'),
// fontSize: z.number().optional().describe('Size of the text in points.'),
// fontStyle: z
// .array(z.enum(['bold', 'italic']))
// .optional()
// .describe('Text style. Can be only italic, only bold, italic and bold, or none of these.'),
top: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the top edge of a page.'),
right: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the right edge of a page.'),
bottom: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the bottom edge of a page.'),
left: WatermarkDimensionSchema.optional().describe('Offset of the watermark from the left edge of a page.'),
fontFamily: z.string().optional().describe('The font family to use for text watermarks.'),
fontSize: z.number().positive().optional().describe('Font size in points for text watermarks.'),
fontStyle: z
.array(z.enum(['bold', 'italic']))
.optional()
.describe('Font style for text watermarks. Can be italic, bold, or both.'),
Comment on lines +303 to +312
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new watermark positioning fields (top, right, bottom, left) and font customization fields (fontFamily, fontSize, fontStyle) are not covered by any tests. Consider adding test cases that use these new fields to verify they work correctly with the watermark action.

Copilot uses AI. Check for mistakes.
})

export const SearchPresetSchema = z.enum([
Expand Down
118 changes: 117 additions & 1 deletion tests/unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs, { Stats } from 'fs'
import { Readable } from 'stream'
import { AiRedactArgsSchema, Instructions, SignatureOptions } from '../src/schemas.js'
import type { Instructions, SignatureOptions } from '../src/schemas.js'
import { AiRedactArgsSchema, BaseWatermarkPropertiesSchema, FilePartSchema } from '../src/schemas.js'
import { config as dotenvConfig } from 'dotenv'
import { performBuildCall } from '../src/dws/build.js'
import { performSignCall } from '../src/dws/sign.js'
Expand Down Expand Up @@ -40,6 +41,121 @@ function createMockStream(content: string | Buffer): Readable {
return readable
}

describe('Schema validation', () => {
describe('HTMLLayoutSchema / FilePartSchema', () => {
it('accepts a valid layout with orientation, size, and margin', () => {
const result = FilePartSchema.safeParse({
file: '/test.html',
layout: {
orientation: 'portrait',
size: {
width: 210,
height: 297,
},
margin: {
left: 10,
top: 10,
right: 10,
bottom: 10,
},
},
})

expect(result.success).toBe(true)
})

it('allows omitting layout entirely', () => {
const result = FilePartSchema.safeParse({ file: '/test.html' })
expect(result.success).toBe(true)
})

it('rejects negative margins', () => {
const result = FilePartSchema.safeParse({
file: '/test.html',
layout: {
margin: {
left: -1,
top: 0,
right: 0,
bottom: 0,
},
},
})

expect(result.success).toBe(false)
})

it('rejects zero or negative custom page sizes', () => {
const zeroWidth = FilePartSchema.safeParse({
file: '/test.html',
layout: {
size: {
width: 0,
height: 100,
},
},
})

const negativeHeight = FilePartSchema.safeParse({
file: '/test.html',
layout: {
size: {
width: 100,
height: -1,
},
},
})

expect(zeroWidth.success).toBe(false)
expect(negativeHeight.success).toBe(false)
})
})

describe('Watermark positioning and font fields', () => {
it('accepts valid positioning and font fields', () => {
const result = BaseWatermarkPropertiesSchema.safeParse({
type: 'watermark',
watermarkType: 'text',
width: 100,
height: 50,
top: 10,
right: '5%',
bottom: 12,
left: 8,
text: 'DRAFT',
fontFamily: 'Helvetica',
fontSize: 12,
fontStyle: ['bold', 'italic'],
})

expect(result.success).toBe(true)
})

it('rejects invalid fontSize values (0 or negative)', () => {
const zero = BaseWatermarkPropertiesSchema.safeParse({
type: 'watermark',
watermarkType: 'text',
width: 100,
height: 50,
text: 'DRAFT',
fontSize: 0,
})

const negative = BaseWatermarkPropertiesSchema.safeParse({
type: 'watermark',
watermarkType: 'text',
width: 100,
height: 50,
text: 'DRAFT',
fontSize: -1,
})

expect(zero.success).toBe(false)
expect(negative.success).toBe(false)
})
})
})

describe('API Functions', () => {
const originalEnv = process.env

Expand Down