Skip to main content
Glama

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
NameRequiredDescriptionDefault
filenameNoOptional logical filename (metadata only). Storage uses UUID. Defaults to "resume.pdf".
resumeYesResume data in JSON Resume format
fontNoFont for the PDF. Defaults to "auto" (system font detection). Built-ins are limited to ASCII; provide a path or URL for full Unicode.
pageSizeNoPage size preset (default: "LETTER"). Use "A4" for international standard.
backgroundColorNoPage background color (hex like "#fffff0" or named color like "ivory"). Default: white.
sectionsNoSections configuration for section ordering and field templates
layoutNoSpatial arrangement of sections. Omit for single-column (default). Use style: "two-column" with columns config for sidebar layouts.
stylingNoTypography and styling options

Implementation Reference

  • 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;
    }
  • 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;
  • ToolModule object creation with name 'pdf-resume', linking config and handler for MCP registration.
    return {
      name: 'pdf-resume',
      config,
      handler,
    } satisfies ToolModule;
  • 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';

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mcp-z/mcp-pdf'

If you have feedback or need assistance with the MCP directory API, please join our Discord server