pdf-layout
Create PDFs with precise positioning using Yoga flexbox layout. Design dashboards, slides, certificates, and flyers with exact placement control.
Instructions
Create a PDF with precise positioning using Yoga flexbox layout.
Best for: Dashboards, slides, certificates, flyers, and designs requiring exact placement.
All items are positioned absolutely on specific pages. Use the "page" property to target different pages (e.g., page: 2 for multi-slide presentations). Pages are created as needed.
Use groups for flexbox containers - they support direction, gap, justify, alignItems, and alignment properties for sophisticated layouts.
Default margins: 0 (full canvas access for precise positioning).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| filename | No | Optional logical filename (metadata only). Storage uses UUID. Defaults to "document.pdf". | |
| title | No | Document title metadata | |
| author | No | Document author metadata | |
| font | No | Font strategy (default: auto). Built-ins: Helvetica, Times-Roman, Courier. Use a path or URL for Unicode. | |
| layout | No | Layout configuration for overflow handling | |
| pageSetup | No | Page configuration including size, margins, and background color. | |
| content | Yes |
Implementation Reference
- src/mcp/tools/pdf-layout.ts:87-400 (handler)Main execution logic: processes layout input, groups content by page, computes Yoga flexbox positions, renders text/images/shapes per page, generates PDF, stores file, returns URI.async function handler(args: Input, extra: StorageExtra): Promise<CallToolResult> { const { storageContext } = extra; const { storageDir, baseUrl, transport } = storageContext; const { filename = 'document.pdf', title, author, font, layout, pageSetup, content } = args; const overflowBehavior = layout?.overflow ?? 'auto'; try { // Create PDF document with shared utilities const contentText = JSON.stringify(content); const setup = await createPDFDocument( { title, author, subject: filename, pageSize: pageSetup?.size as PageSizePreset | [number, number] | undefined, margins: pageSetup?.margins ?? { top: 0, bottom: 0, left: 0, right: 0 }, backgroundColor: pageSetup?.backgroundColor, }, font, contentText ); const { doc, pdfPromise, fonts, emojiAvailable, warnings } = setup; const { regular: regularFont, bold: boldFont } = fonts; // Validate text content against font validateContentText(content as ContentItem[], regularFont, boldFont, warnings); // Get page dimensions and margins const pageWidth = doc.page.width; const pageHeight = doc.page.height; const margins = { top: doc.page.margins.top, right: doc.page.margins.right, bottom: doc.page.margins.bottom, left: doc.page.margins.left, }; // Height measurer for Yoga layout const measureHeight = (item: LayoutContent, availableWidth: number): number => { if (item.type === 'text' || item.type === 'heading') { if (!item.text) return 0; const fontSize = item.type === 'heading' ? ((item.fontSize as number) ?? DEFAULT_HEADING_FONT_SIZE) : ((item.fontSize as number) ?? DEFAULT_TEXT_FONT_SIZE); const fontName = item.type === 'heading' ? (item.bold !== false ? boldFont : regularFont) : item.bold ? boldFont : regularFont; let height = measureTextHeight(doc, item.text as string, fontSize, fontName, emojiAvailable, { width: availableWidth, indent: item.indent as number | undefined, lineGap: item.lineGap as number | undefined, }); const moveDown = item.moveDown as number | undefined; if (moveDown !== undefined && moveDown > 0) { doc.fontSize(fontSize).font(fontName); height += moveDown * doc.currentLineHeight(); } return height; } if (item.type === 'image') { const dimensions = resolveImageDimensions(item.imagePath as string, item.width as number | undefined, item.height as number | undefined); return dimensions.height; } if (item.type === 'rect') { return item.height as number; } if (item.type === 'circle') { return (item.radius as number) * 2; } if (item.type === 'line') { return Math.abs((item.y2 as number) - (item.y1 as number)); } return 0; }; // Render a base content item at computed position function renderBaseItem(item: BaseContentItem, computedX?: number, computedY?: number, computedWidth?: number) { switch (item.type) { case 'text': { const fontSize = item.fontSize ?? DEFAULT_TEXT_FONT_SIZE; const fnt = item.bold ? boldFont : regularFont; if (item.color) doc.fillColor(item.color); const options = extractTextOptions(item); if (computedX !== undefined) options.x = computedX; if (computedY !== undefined) options.y = computedY; if (computedWidth !== undefined) options.width = computedWidth; renderTextWithEmoji(doc, item.text ?? '', fontSize, fnt, emojiAvailable, options); if (item.color) doc.fillColor('black'); break; } case 'heading': { const fontSize = item.fontSize ?? DEFAULT_HEADING_FONT_SIZE; const fnt = item.bold !== false ? boldFont : regularFont; if (item.color) doc.fillColor(item.color); const options = extractTextOptions(item); if (computedX !== undefined) options.x = computedX; if (computedY !== undefined) options.y = computedY; if (computedWidth !== undefined) options.width = computedWidth; renderTextWithEmoji(doc, item.text ?? '', fontSize, fnt, emojiAvailable, options); if (item.color) doc.fillColor('black'); break; } case 'image': { const dimensions = resolveImageDimensions(item.imagePath, item.width as number | undefined, item.height as number | undefined); const opts = { width: dimensions.width, height: dimensions.height }; const imgX = computedX ?? item.left; const imgY = computedY ?? item.top; if (imgX !== undefined && imgY !== undefined) { doc.image(item.imagePath, imgX, imgY, opts); } else { doc.image(item.imagePath, opts); } break; } case 'rect': { const rectX = computedX ?? item.left; const rectY = computedY ?? item.top; const rectWidth = computedWidth ?? item.width; doc.rect(rectX, rectY, rectWidth, item.height); if (item.fillColor && item.strokeColor) { if (item.lineWidth) doc.lineWidth(item.lineWidth); doc.fillAndStroke(item.fillColor, item.strokeColor); } else if (item.fillColor) { doc.fill(item.fillColor); } else if (item.strokeColor) { if (item.lineWidth) doc.lineWidth(item.lineWidth); doc.stroke(item.strokeColor); } doc.fillColor('black'); break; } case 'circle': { const circleX = computedX ?? item.left; const circleY = computedY ?? item.top; doc.circle(circleX, circleY, item.radius); if (item.fillColor && item.strokeColor) { if (item.lineWidth) doc.lineWidth(item.lineWidth); doc.fillAndStroke(item.fillColor, item.strokeColor); } else if (item.fillColor) { doc.fill(item.fillColor); } else if (item.strokeColor) { if (item.lineWidth) doc.lineWidth(item.lineWidth); doc.stroke(item.strokeColor); } doc.fillColor('black'); break; } case 'line': { if (item.lineWidth) doc.lineWidth(item.lineWidth); doc .moveTo(item.x1, item.y1) .lineTo(item.x2, item.y2) .stroke(item.strokeColor || 'black'); break; } } } // Render a group with its visual properties function renderGroupVisuals(group: GroupItem, layoutNode: LayoutNode) { if (group.background) { doc.rect(layoutNode.x, layoutNode.y, layoutNode.width, layoutNode.height).fill(group.background); doc.fillColor('black'); } if (group.border) { doc.lineWidth(group.border.width); doc.rect(layoutNode.x, layoutNode.y, layoutNode.width, layoutNode.height).stroke(group.border.color); } } // Render a layout node tree function renderLayoutNode(node: LayoutNode) { const item = node.content as ContentItem; if (item.type === 'group') { const group = item as GroupItem; renderGroupVisuals(group, node); if (node.children) { for (const childNode of node.children) { renderLayoutNode(childNode); } } } else { renderBaseItem(item as BaseContentItem, node.x, node.y, node.width); } } // Helper to get page number for an item function getItemPage(item: ContentItem): number { if ('page' in item && typeof item.page === 'number') return item.page; return 1; } // Group content by page number function groupContentByPage(items: ContentItem[]): Map<number, ContentItem[]> { const pageGroups = new Map<number, ContentItem[]>(); for (const item of items) { const pageNum = getItemPage(item); const existing = pageGroups.get(pageNum) ?? []; existing.push(item); pageGroups.set(pageNum, existing); } return pageGroups; } // Group content by page const pageGroups = groupContentByPage(content as ContentItem[]); const maxPage = Math.max(...pageGroups.keys(), 1); // Width measurer for row layouts with space-between const measureWidth = createWidthMeasurer(doc, regularFont, boldFont, emojiAvailable); // Calculate layout and render per page for (let pageNum = 1; pageNum <= maxPage; pageNum++) { if (pageNum > 1) { doc.addPage(); } // Get content for this page const pageContent = pageGroups.get(pageNum) ?? []; if (pageContent.length === 0) continue; // Convert to LayoutContent for Yoga const layoutContent: LayoutContent[] = pageContent.map((item) => { const layoutItem = item as unknown as LayoutContent; // Root items in pdf-layout default to absolute IF they have explicit coordinates // Items without coordinates (pure flex layouts) remain relative // Users can override with explicit position property if (layoutItem.position !== undefined) { return layoutItem; // Explicit position, use as-is } // Default: absolute if coordinates exist, relative otherwise const hasCoordinates = layoutItem.left !== undefined && layoutItem.top !== undefined; return { ...layoutItem, position: hasCoordinates ? 'absolute' : 'relative', }; }); // Calculate layout for THIS page only const layoutNodes = await calculateLayout(layoutContent, pageWidth, pageHeight, measureHeight, margins, measureWidth); // Check for content overflow when warn mode is enabled if (overflowBehavior === 'warn') { const getMaxBottom = (node: LayoutNode): number => { let maxBottom = node.y + node.height; if (node.children) { for (const child of node.children) { maxBottom = Math.max(maxBottom, getMaxBottom(child)); } } return maxBottom; }; for (const node of layoutNodes) { const bottom = getMaxBottom(node); if (bottom > pageHeight) { warnings.push(`Page ${pageNum}: Content exceeds page height by ${Math.ceil(bottom - pageHeight)}px. Consider reducing font sizes or removing content.`); break; } } } // Render items for this page for (const node of layoutNodes) { renderLayoutNode(node); } } doc.end(); const pdfBuffer = await pdfPromise; // Write file const { storedName } = await writeFile(pdfBuffer, filename, { storageDir }); // Generate URI const fileUri = getFileUri(storedName, transport, { storageDir, ...(baseUrl && { baseUrl }), endpoint: '/files', }); const result: Output = { operationSummary: `Created PDF layout: ${filename}`, itemsProcessed: 1, itemsChanged: 1, completedAt: new Date().toISOString(), documentId: storedName, filename, uri: fileUri, sizeBytes: pdfBuffer.length, pageCount: setup.actualPageCount, margins: setup.doc.page.margins as Margins, ...(warnings.length > 0 && { warnings }), }; return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], structuredContent: { result }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new McpError(ErrorCode.InternalError, `Error creating PDF layout: ${message}`, { stack: error instanceof Error ? error.stack : undefined, }); } }
- src/mcp/tools/pdf-layout.ts:59-81 (schema)Tool configuration including inputSchema (with layout and pageSetup), outputSchema extending pdfOutputSchema with margins.const config = { title: 'Create PDF Layout', description: `Create a PDF with precise positioning using Yoga flexbox layout. Best for: Dashboards, slides, certificates, flyers, and designs requiring exact placement. All items are positioned absolutely on specific pages. Use the "page" property to target different pages (e.g., page: 2 for multi-slide presentations). Pages are created as needed. Use groups for flexbox containers - they support direction, gap, justify, alignItems, and alignment properties for sophisticated layouts. Default margins: 0 (full canvas access for precise positioning).`, inputSchema, outputSchema: z.object({ result: pdfOutputSchema.extend({ margins: z.object({ top: z.number(), bottom: z.number(), left: z.number(), right: z.number(), }), }), }), } as const;
- src/mcp/tools/pdf-layout.ts:402-406 (registration)Tool module registration: defines name 'pdf-layout', references config and handler.return { name: 'pdf-layout', config, handler, } satisfies ToolModule;
- src/mcp/tools/index.ts:3-3 (registration)Exports the pdf-layout tool factory (createTool) as pdfLayout for use in MCP server.export { default as pdfLayout } from './pdf-layout.ts';