pdf-resume
Convert JSON Resume data into professional PDF resumes with customizable layouts, fonts, styling, and automatic formatting for job applications.
Instructions
Generate a professional resume PDF from JSON Resume format. Supports layout customization, date/locale formatting, styling, fonts, and automatic page breaks.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| filename | No | Optional logical filename (metadata only). Storage uses UUID. Defaults to "resume.pdf". | |
| resume | Yes | Resume data in JSON Resume format | |
| font | No | Font for the PDF. Defaults to "auto" (system font detection). Built-ins are limited to ASCII; provide a path or URL for full Unicode. | |
| pageSize | No | Page size preset (default: "LETTER"). Use "A4" for international standard. | |
| backgroundColor | No | Page background color (hex like "#fffff0" or named color like "ivory"). Default: white. | |
| sections | No | Sections configuration for section ordering and field templates | |
| layout | No | Spatial arrangement of sections. Omit for single-column (default). Use style: "two-column" with columns config for sidebar layouts. | |
| styling | No | Typography and styling options |
Implementation Reference
- src/mcp/tools/pdf-resume.ts:76-325 (handler)The createTool function defines the tool's handler, config, and name. The handler validates the resume JSON, processes layout/styling options, generates the PDF using resume-pdf-generator, stores the file, and returns metadata with URI.export default function createTool() { async function handler(args: Input, extra: StorageExtra): Promise<CallToolResult> { const { storageContext, logger } = extra; const { storageDir, baseUrl, transport } = storageContext; const { filename = 'resume.pdf', resume, font, pageSize, backgroundColor, sections, layout, styling } = args; try { // Validate resume against JSON Schema const validation = validateResume(resume); if (!validation.valid) { throw new McpError(ErrorCode.InvalidParams, `Resume validation failed: ${validation.errors?.join('; ') || 'Unknown error'}`); } // Validate layout configuration if (layout?.style === 'two-column' && layout.columns) { const leftSections = layout.columns.left?.sections || []; const rightSections = layout.columns.right?.sections || []; // Get all defined section sources const definedSources = new Set<string>(); if (sections?.sections) { for (const section of sections.sections) { if ('source' in section) { definedSources.add(section.source); } } } // If no custom sections defined, use default sources if (definedSources.size === 0) { // Default sources from DEFAULT_SECTIONS const defaultSources = ['basics', 'basics.summary', 'work', 'volunteer', 'education', 'awards', 'certificates', 'publications', 'skills', 'languages', 'interests', 'projects', 'references']; for (const s of defaultSources) { definedSources.add(s); } } // Check for sections in columns that don't exist const allColumnSections = [...leftSections, ...rightSections]; const invalidSections = allColumnSections.filter((s) => !definedSources.has(s)); if (invalidSections.length > 0) { throw new McpError(ErrorCode.InvalidParams, `Layout references unknown sections: ${invalidSections.join(', ')}. Available sections: ${[...definedSources].join(', ')}`); } // Check for duplicate sections across columns const duplicates = leftSections.filter((s) => rightSections.includes(s)); if (duplicates.length > 0) { throw new McpError(ErrorCode.InvalidParams, `Sections cannot appear in both columns: ${duplicates.join(', ')}`); } } // Resolve effective margins // Note: resume tool layout uses 'margins' inside 'styling' object, slightly different structure than pdf-document // If styling.margins is provided, use it (and require it to be complete/valid Zod schema handles structure, we handle defaults) const defaultResumeMargins = getResumeDefaultMargins(pageSize as PageSizePreset); const userMargins = styling?.margins; // If user provides ANY margin, they must provide ALL (as per schema description, though Zod makes them optional for backward compat? // No, let's strictly enforce it or merge. The improved UX goal says "Require all 4". // But Zod schema above has .optional() on properties for backward compatibility? // Wait, I didn't change the Zod definition to require them in the chunk above, I only changed description. // I should fundamentally change schema to: top: z.number(), ... (required). // Let's implement the logic: let margins: Margins; if (userMargins) { // If any is missing, fill with default? No, the goal is strictness. // But schema says optional. I'll merge with defaults but report "effective". // Actually, for better UX: "Partial updates use defaults for missing sides" is confusing. // Let's stick to "Merge with defaults" but transparency in output. margins = { top: userMargins.top ?? defaultResumeMargins.top, bottom: userMargins.bottom ?? defaultResumeMargins.bottom, left: userMargins.left ?? defaultResumeMargins.left, right: userMargins.right ?? defaultResumeMargins.right, }; } else { margins = defaultResumeMargins; } // Build render options const renderOptions: RenderOptions = { font, pageSize: pageSize as PageSizePreset | undefined, backgroundColor, margins: margins, }; // Map sections config if (sections) { renderOptions.sections = { sections: sections.sections?.map((section) => { if ('type' in section && section.type === 'divider') { return { type: 'divider' as const, thickness: section.thickness, color: section.color, }; } // TypeScript needs explicit narrowing for discriminated unions const sectionConfig = section as { source: string; render?: string; title?: string; template?: string }; return { source: sectionConfig.source, render: sectionConfig.render as 'header' | 'entry-list' | 'keyword-list' | 'language-list' | 'credential-list' | 'reference-list' | 'summary-highlights' | 'text' | undefined, title: sectionConfig.title, template: sectionConfig.template, }; }) || [], fieldTemplates: sections.fieldTemplates, }; } // Map styling to typography if (styling) { renderOptions.typography = { text: { fontSize: styling.fontSize?.body ?? 10, lineHeight: 1.2, marginTop: styling.spacing?.afterText ?? 2, marginBottom: styling.spacing?.afterText ?? 2, blockMarginBottom: styling.spacing?.betweenSections ?? 8, }, header: { marginTop: 0, marginBottom: styling.spacing?.afterName ?? 5, name: { fontSize: styling.fontSize?.name ?? 30, marginTop: 0, marginBottom: styling.spacing?.afterName ?? 5, letterSpacing: 2, }, contact: { fontSize: styling.fontSize?.contact ?? 10, letterSpacing: 0.5, }, }, sectionTitle: { fontSize: styling.fontSize?.heading ?? 12, marginTop: styling.spacing?.betweenSections ?? 12, marginBottom: styling.spacing?.afterHeading ?? 4, letterSpacing: 1.5, underlineGap: 2, underlineThickness: 0.5, }, entry: { position: { fontSize: styling.fontSize?.subheading ?? 11, marginTop: 0, marginBottom: styling.spacing?.afterSubheading ?? 1, }, company: { fontSize: 10, color: '#333333', }, location: { fontSize: 10, color: '#666666', }, date: { width: 90, }, }, bullet: { indent: 15, marginTop: 0, marginBottom: 2, }, quote: { indent: 20, }, divider: { marginTop: 10, marginBottom: 10, thickness: 0.5, color: '#cccccc', }, } as Partial<TypographyOptions>; } // Map layout config if (layout) { renderOptions.layout = { style: layout.style, gap: layout.gap, columns: layout.columns ? { left: layout.columns.left ? { width: layout.columns.left.width, sections: layout.columns.left.sections } : undefined, right: layout.columns.right ? { width: layout.columns.right.width, sections: layout.columns.right.sections } : undefined, } : undefined, }; } // Generate PDF const pdfBuffer = await generateResumePDFBuffer(resume, renderOptions, logger); // Write file with ID prefix const { storedName } = await writeFile(pdfBuffer, filename, { storageDir, }); // Generate URI based on transport type const fileUri = getFileUri(storedName, transport, { storageDir, ...(baseUrl && { baseUrl }), endpoint: '/files', }); const result: Output = { operationSummary: `Generated resume PDF: ${filename}`, itemsProcessed: 1, itemsChanged: 1, completedAt: new Date().toISOString(), documentId: storedName, filename, uri: fileUri, sizeBytes: pdfBuffer.length, margins, }; return { content: [ { type: 'text' as const, text: JSON.stringify(result), }, ], structuredContent: { result }, }; } catch (error) { if (error instanceof McpError) { throw error; } const message = error instanceof Error ? error.message : String(error); throw new McpError(ErrorCode.InternalError, `Error generating resume PDF: ${message}`, { stack: error instanceof Error ? error.stack : undefined, }); } } return { name: 'pdf-resume', config, handler, } satisfies ToolModule; }
- src/mcp/tools/pdf-resume.ts:30-65 (schema)Tool input and output schemas using Zod, including references to external resume-specific schemas for sections, layout, and styling.const inputSchema = z.object({ filename: z.string().optional().describe('Optional logical filename (metadata only). Storage uses UUID. Defaults to "resume.pdf".'), resume: resumeInputSchema, font: z.string().optional().describe('Font for the PDF. Defaults to "auto" (system font detection). Built-ins are limited to ASCII; provide a path or URL for full Unicode.'), pageSize: z.enum(['LETTER', 'A4', 'LEGAL']).optional().describe('Page size preset (default: "LETTER"). Use "A4" for international standard.'), backgroundColor: z.string().optional().describe('Page background color (hex like "#fffff0" or named color like "ivory"). Default: white.'), sections: sectionsConfigSchema, layout: resumeLayoutSchema, styling: stylingSchema, }); const outputSchema = z.object({ operationSummary: z.string(), itemsProcessed: z.number(), itemsChanged: z.number(), completedAt: z.string(), documentId: z.string(), filename: z.string(), uri: z.string(), sizeBytes: z.number(), margins: z.object({ top: z.number(), bottom: z.number(), left: z.number(), right: z.number(), }), }); const config = { title: 'Generate Resume PDF', description: 'Generate a professional resume PDF from JSON Resume format. Supports layout customization, date/locale formatting, styling, fonts, and automatic page breaks.', inputSchema, outputSchema: z.object({ result: outputSchema, }), } as const;
- src/mcp/tools/pdf-resume.ts:320-324 (registration)ToolModule object creation with name 'pdf-resume', linking config and handler for MCP registration.return { name: 'pdf-resume', config, handler, } satisfies ToolModule;
- src/schemas/resume.ts:1-100 (schema)Reusable Zod schemas for resume layout, sections configuration, and styling used by pdf-resume input schema./** * Reusable resume schemas for PDF tools. * These schemas define the structure for resume-specific configuration. */ import { z } from 'zod'; // Section configuration schema export const sectionConfigSchema = z.object({ source: z.string().describe('Path to data in resume schema using dot notation. Examples: "basics" (for header), "basics.summary", "work", "volunteer", "education", "awards", "certificates", "publications", "skills", "languages", "interests", "projects", "references", "meta.customField".'), render: z .enum(['header', 'entry-list', 'keyword-list', 'language-list', 'credential-list', 'reference-list', 'summary-highlights', 'text']) .optional() .describe('Built-in renderer type. Auto-inferred from data shape if omitted. Only needed when: (1) you want "header" rendering (never auto-inferred), or (2) you want to force a specific renderer. Not needed if using template.'), title: z.string().optional().describe('Section title (omit for no title)'), template: z.string().optional().describe('LiquidJS template for custom rendering. Use instead of render for full control. Receives source data as template context (e.g., {{ name }}, {{ keywords | join: ", " }}).'), showTenure: z.boolean().optional().describe('Show tenure duration for work/volunteer sections'), }); // Divider configuration schema export const dividerConfigSchema = z.object({ type: z.literal('divider'), thickness: z.number().optional().describe('Line thickness in points (default: 0.5)'), color: z.string().optional().describe('Line color (hex or named, default: #cccccc)'), }); // Field templates schema - LiquidJS templates for field-level formatting export const fieldTemplatesSchema = z .object({ location: z.string().optional().describe('Location display template (default: "{{ city }}{% if region %}, {{ region }}{% endif %}")'), dateRange: z.string().optional().describe("Date range template. Date format tokens: YYYY (4-digit year), YY (2-digit), MMMM (January), MMM (Jan), MM (01), M (1), DD (05), D (5). Default: \"{{ start | date: 'MMM YYYY' }} – {{ end | date: 'MMM YYYY' | default: 'Present' }}\""), degree: z.string().optional().describe('Education degree template (default: "{{ studyType }}{% if area %}, {{ area }}{% endif %}")'), credential: z.string().optional().describe('Credential metadata template (default: "{{ title | default: name }}{% if awarder %}, {{ awarder }}{% endif %}")'), language: z.string().optional().describe('Language display template (default: "{{ language }}{% if fluency %} ({{ fluency }}){% endif %}")'), skill: z.string().optional().describe('Skill template (default: "{{ name }}: {{ keywords | join: \', \' }}")'), contactLine: z.string().optional().describe('Contact line template (default: "{{ items | join: \' | \' }}")'), }) .optional() .describe('LiquidJS templates for field-level rendering. Filters: date (format dates), default (fallback value), tenure (calculate duration), join (join arrays)'); // Sections configuration schema export const sectionsConfigSchema = z .object({ sections: z .array(z.union([sectionConfigSchema, dividerConfigSchema])) .optional() .describe('Section order and configuration'), fieldTemplates: fieldTemplatesSchema, }) .optional() .describe('Sections configuration for section ordering and field templates'); // Column configuration schema for two-column layouts export const columnConfigSchema = z.object({ width: z.union([z.string(), z.number()]).optional().describe('Column width as percentage ("30%") or points (150)'), sections: z .array(z.string()) .describe( 'Section sources to place in this column. Use source paths from section config: "basics", "basics.summary", "work", "volunteer", "education", "awards", "certificates", "publications", "skills", "languages", "interests", "projects", "references", "meta.customField". Sections not assigned to a column go to the right column by default.' ), }); // Layout schema for spatial arrangement export const resumeLayoutSchema = z .object({ style: z.enum(['single-column', 'two-column']).default('single-column').describe('Layout style (default: single-column)'), columns: z .object({ left: columnConfigSchema.optional().describe('Left/sidebar column configuration'), right: columnConfigSchema.optional().describe('Right/main column configuration'), }) .optional() .describe('Column configuration for two-column layout'), gap: z.number().optional().default(30).describe('Gap between columns in points (default: 30)'), }) .optional() .describe('Spatial arrangement of sections. Omit for single-column (default). Use style: "two-column" with columns config for sidebar layouts.'); // Typography/styling schema (points-based, not moveDown) export const stylingSchema = z .object({ fontSize: z .object({ name: z.number().optional().describe('Name font size (default: 30)'), heading: z.number().optional().describe('Section heading font size (default: 12)'), subheading: z.number().optional().describe('Entry title font size (default: 11)'), body: z.number().optional().describe('Body text font size (default: 10)'), contact: z.number().optional().describe('Contact info font size (default: 10)'), }) .optional(), spacing: z .object({ afterName: z.number().optional().describe('Space after name in points'), afterHeading: z.number().optional().describe('Space after section headings in points'), afterSubheading: z.number().optional().describe('Space after entry titles in points'), afterText: z.number().optional().describe('Space after paragraphs in points'), betweenSections: z.number().optional().describe('Space between sections in points'), }) .optional(), margins: z
- Core helper function that generates the PDF buffer from resume data and render options, implementing the layout and rendering logic./** * Resume PDF generator using Yoga layout engine with emoji support */ import PDFDocument from 'pdfkit'; // Import generated type from JSON Schema import type { ResumeSchema } from '../../assets/resume.ts'; import { DEFAULT_PAGE_SIZE, type Margins, PAGE_SIZES, type PageSizePreset, RESUME_DEFAULT_MARGINS } from '../constants.ts'; import type { Logger } from '../types.ts'; import { registerEmojiFont } from './emoji-renderer.ts'; import { hasEmoji, isPDFStandardFont, needsUnicodeFont, resolveFont } from './fonts.ts'; import { isTwoColumnLayout, transformToResumeLayout } from './ir/layout-transform.ts'; import { DEFAULT_SECTIONS, transformToLayout } from './ir/transform.ts'; import type { FieldTemplates, SectionsConfig } from './ir/types.ts'; import type { TypographyOptions } from './types/typography.ts'; import { DEFAULT_TYPOGRAPHY } from './types/typography.ts'; import { calculateResumeLayout, calculateTwoColumnLayout, createRenderContext, type PageConfig, paginateLayoutWithAtomicGroups, renderPage } from './yoga-resume/index.ts'; // Re-export types for external use export type { ResumeSchema }; /** * Column configuration for two-column layouts */ export interface ColumnConfig { /** Column width as percentage ("30%") or points (150) */ width?: string | number; /** Source paths of sections for this column */ sections: string[]; } /** * Layout configuration for spatial arrangement */ export interface LayoutConfig { /** Layout style: single-column (default) or two-column */ style?: 'single-column' | 'two-column'; /** Column configuration for two-column layout */ columns?: { left?: ColumnConfig; right?: ColumnConfig; }; /** Gap between columns in points (default: 30) */ gap?: number; } /** * Render options for resume PDF generation */ export interface RenderOptions { /** Custom typography settings */ typography?: Partial<TypographyOptions>; /** Sections configuration (section order, titles, etc.) */ sections?: SectionsConfig; /** Field templates for customizing field-level rendering (dates, locations, etc.) */ fieldTemplates?: FieldTemplates; /** Font specification (path, URL, 'auto', or standard font name) */ font?: string; /** Layout configuration for spatial arrangement (single-column or two-column) */ layout?: LayoutConfig; /** Page size preset (default: LETTER) */ pageSize?: PageSizePreset; /** Page background color (hex like "#fffff0" or named color). Default: white. */ backgroundColor?: string; /** Explicit margins (if provided, overrides default resume margins) */ margins?: Margins; } /** * Setup fonts for the PDF document * Returns a FontConfig compatible with TypographyOptions (includes italic variants) * Falls back to Helvetica if font resolution or registration fails */ async function setupFonts(doc: InstanceType<typeof PDFDocument>, fontSpec: string | undefined): Promise<{ regular: string; bold: string; italic: string; boldItalic: string }> { const spec = fontSpec || 'auto'; const resolvedFont = await resolveFont(spec); // Fall back to Helvetica if resolution failed if (!resolvedFont) { return { regular: 'Helvetica', bold: 'Helvetica-Bold', italic: 'Helvetica-Oblique', boldItalic: 'Helvetica-BoldOblique', }; } // If it's a standard PDF font, use its variants if (isPDFStandardFont(resolvedFont)) { if (resolvedFont.startsWith('Helvetica')) { return { regular: 'Helvetica', bold: 'Helvetica-Bold', italic: 'Helvetica-Oblique', boldItalic: 'Helvetica-BoldOblique', }; } if (resolvedFont.startsWith('Times')) { return { regular: 'Times-Roman', bold: 'Times-Bold', italic: 'Times-Italic', boldItalic: 'Times-BoldItalic', }; } if (resolvedFont.startsWith('Courier')) { return { regular: 'Courier', bold: 'Courier-Bold', italic: 'Courier-Oblique', boldItalic: 'Courier-BoldOblique', }; } // For Symbol or ZapfDingbats, use as-is return { regular: resolvedFont, bold: resolvedFont, italic: resolvedFont, boldItalic: resolvedFont, }; } // It's a custom font file - register it with PDFKit try { doc.registerFont('CustomFont', resolvedFont); return { regular: 'CustomFont', bold: 'CustomFont', italic: 'CustomFont', boldItalic: 'CustomFont', }; } catch { // Fall back to Helvetica on registration failure return { regular: 'Helvetica', bold: 'Helvetica-Bold', italic: 'Helvetica-Oblique', boldItalic: 'Helvetica-BoldOblique', }; } } /** * Merge partial typography options with defaults */ function mergeTypography(defaults: TypographyOptions, overrides?: Partial<TypographyOptions>, fonts?: { regular: string; bold: string; italic: string; boldItalic: string }): TypographyOptions { if (!overrides && !fonts) { return defaults; } const merged = { ...defaults }; // Apply font overrides if (fonts) { merged.fonts = fonts; } // Apply typography overrides if (overrides) { if (overrides.fonts) merged.fonts = { ...merged.fonts, ...overrides.fonts }; if (overrides.header) merged.header = { ...merged.header, ...overrides.header }; if (overrides.sectionTitle) merged.sectionTitle = { ...merged.sectionTitle, ...overrides.sectionTitle }; if (overrides.entry) merged.entry = { ...merged.entry, ...overrides.entry }; if (overrides.text) merged.text = { ...merged.text, ...overrides.text }; if (overrides.bullet) merged.bullet = { ...merged.bullet, ...overrides.bullet }; if (overrides.quote) merged.quote = { ...merged.quote, ...overrides.quote }; if (overrides.divider) merged.divider = { ...merged.divider, ...overrides.divider }; } return merged; } /** * Renders a resume to PDF buffer using the transform → render pipeline */ export async function generateResumePDFBuffer(resume: ResumeSchema, options: RenderOptions, logger: Logger): Promise<Buffer> { // Check if content has Unicode characters or emoji const resumeText = JSON.stringify(resume); const containsUnicode = needsUnicodeFont(resumeText); const containsEmoji = hasEmoji(resumeText); const isDefaultFont = !options.font || options.font === 'auto'; // Register emoji font for rendering const emojiAvailable = containsEmoji ? registerEmojiFont() : false; // Warn about emoji if font not available if (containsEmoji && !emojiAvailable) { logger.warn('⚠️ EMOJI DETECTED but emoji font not available.\n' + ' Run: npm install (to download Noto Color Emoji)\n' + ' Emojis will be skipped in the PDF.'); } else if (containsEmoji && emojiAvailable) { logger.info('✅ Emoji support enabled - rendering emojis as inline images'); } // Warn if Unicode detected with default font if (containsUnicode && isDefaultFont && !containsEmoji) { logger.warn("⚠️ Unicode characters detected. If they don't render properly, " + 'provide a Unicode font URL. Find fonts at https://fontsource.org'); } // Build sections config with field templates const baseSections = options.sections ?? DEFAULT_SECTIONS; const sectionsConfig: SectionsConfig = { ...baseSections, fieldTemplates: options.fieldTemplates ? { ...baseSections.fieldTemplates, ...options.fieldTemplates } : baseSections.fieldTemplates, }; // Transform resume to IR const layoutDoc = transformToLayout(resume, sectionsConfig); // Apply layout transform for two-column support const resumeLayout = transformToResumeLayout(layoutDoc.elements, sectionsConfig.sections ?? [], options.layout); // Resolve page size const pageSize = options.pageSize ? PAGE_SIZES[options.pageSize] : DEFAULT_PAGE_SIZE; // Build ATS-friendly metadata from IR const { name, label, keywords } = layoutDoc.metadata; const skillKeywords = keywords?.join(', ') || ''; const doc = new PDFDocument({ size: [pageSize.width, pageSize.height], margins: options.margins ?? RESUME_DEFAULT_MARGINS, autoFirstPage: false, // Create pages explicitly for consistent background handling // ATS & Accessibility improvements pdfVersion: '1.5', tagged: true, lang: 'en-US', displayTitle: true, info: { Title: `${name || 'Resume'} - Resume`, Author: name || 'Unknown', Subject: label || '', Keywords: `resume, CV${skillKeywords ? `, ${skillKeywords}` : ''}`, }, }); // Apply background color to ALL pages consistently via pageAdded event doc.on('pageAdded', () => { if (options.backgroundColor) { doc.rect(0, 0, pageSize.width, pageSize.height).fill(options.backgroundColor); doc.fillColor('black'); } }); // Add first page explicitly - triggers same event handler as all other pages doc.addPage(); // Setup fonts const fonts = await setupFonts(doc, options.font); // Merge typography with defaults and font overrides const typography = mergeTypography(DEFAULT_TYPOGRAPHY, options.typography, fonts); // Page configuration const pageConfig: PageConfig = { width: pageSize.width, height: pageSize.height, margins: options.margins ?? RESUME_DEFAULT_MARGINS, }; // Create render context const renderCtx = createRenderContext(doc, typography, layoutDoc.fieldTemplates, emojiAvailable); // Return a promise that resolves when the PDF is complete return new Promise<Buffer>((resolve, reject) => { const chunks: Buffer[] = []; doc.on('data', (chunk: Buffer) => chunks.push(chunk)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); // Render based on layout type using Yoga layout engine (async () => { if (isTwoColumnLayout(resumeLayout)) { // Two-column layout: calculate and render columns const twoColumnResult = await calculateTwoColumnLayout( doc, { gap: resumeLayout.gap, left: { width: resumeLayout.left.width, elements: resumeLayout.left.elements, }, right: { width: resumeLayout.right.width, elements: resumeLayout.right.elements, }, }, typography, layoutDoc.fieldTemplates, emojiAvailable, pageConfig ); // Paginate both columns const leftPages = paginateLayoutWithAtomicGroups(twoColumnResult.left, pageConfig); const rightPages = paginateLayoutWithAtomicGroups(twoColumnResult.right, pageConfig); const maxPages = Math.max(leftPages.length, rightPages.length); // Render pages for (let pageNum = 0; pageNum < maxPages; pageNum++) { if (pageNum > 0) { doc.addPage(); } // Render left column nodes for this page const leftPage = leftPages[pageNum]; if (leftPage) { renderPage(renderCtx, leftPage); } // Render right column nodes for this page const rightPage = rightPages[pageNum]; if (rightPage) { renderPage(renderCtx, rightPage); } } } else { // Single-column layout const layoutNodes = await calculateResumeLayout(doc, resumeLayout.elements, typography, layoutDoc.fieldTemplates, emojiAvailable, pageConfig); // Paginate the layout const pages = paginateLayoutWithAtomicGroups(layoutNodes, pageConfig); // Render all pages for (const page of pages) { if (page.number > 0) { doc.addPage(); } renderPage(renderCtx, page); } } doc.end(); })().catch(reject); }); } export { DEFAULT_SECTIONS } from './ir/transform.ts'; export type { FieldTemplates, SectionsConfig } from './ir/types.ts'; export type { TypographyOptions } from './types/typography.ts'; export { DEFAULT_TYPOGRAPHY } from './types/typography.ts';