0 / 0
Skip to content

How to Generate PDF from Your CV in Nuxt 3

In this tutorial, I’ll show you how to implement PDF generation for your CV using Nuxt 3 and Puppeteer. This approach allows you to create high-quality PDFs that maintain your CV’s styling and layout.

Prerequisites

  • Nuxt 3 project
  • Node.js installed
  • Basic understanding of Vue.js and Nuxt 3

Step 1: Install Dependencies

First, install Puppeteer:

bash
npm install puppeteer

Step 2: Create the PDF Generation API

Create a new file server/api/generate-pdf/[id].get.js:

javascript
import puppeteer from 'puppeteer'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineEventHandler(async event => {
  try {
    const id = event.context.params.id
    console.log('Generating PDF for ID:', id)

    // Get current date and time for filename
    const now = new Date()
    const dateStr = now
      .toISOString()
      .replace(/[-:]/g, '')
      .replace(/[T.]/g, '.')
      .replace('Z', '')
      .slice(0, 19)

    // Get the project root directory
    const tempDir = path.resolve(process.cwd(), '.temp')
    const tempFile = path.join(tempDir, `${id}.json`)

    if (!fs.existsSync(tempFile)) {
      throw createError({
        statusCode: 404,
        message: 'Print content not found'
      })
    }

    const data = JSON.parse(fs.readFileSync(tempFile, 'utf-8'))

    // Launch Puppeteer
    const browser = await puppeteer.launch({
      headless: 'new',
      args: ['--no-sandbox', '--disable-setuid-sandbox']
    })

    try {
      const page = await browser.newPage()

      // Set viewport to A4 size
      await page.setViewport({
        width: 794,  // A4 width in pixels at 96 DPI
        height: 1123 // A4 height in pixels at 96 DPI
      })

      // Set content type and encoding
      await page.setExtraHTTPHeaders({
        'Content-Type': 'text/html; charset=utf-8'
      })

      // Get the base URL from request headers
      const protocol = event.node.req.headers['x-forwarded-proto'] || 'http'
      const host = event.node.req.headers.host
      const baseUrl = `${protocol}://${host}`

      // Navigate to the print-content API endpoint
      const printContentUrl = `${baseUrl}/api/print-content/${id}`
      await page.goto(printContentUrl, { waitUntil: 'networkidle0' })

      // Wait for content to be rendered
      await page.waitForSelector('.print', { timeout: 5000 })

      // Wait a bit for any dynamic content
      await new Promise(resolve => setTimeout(resolve, 1000))

      // Generate PDF
      const pdfBuffer = await page.pdf({
        format: 'A4',
        printBackground: true,
        margin: {
          top: '10mm',
          right: '10mm',
          bottom: '10mm',
          left: '10mm'
        },
        preferCSSPageSize: true,
        displayHeaderFooter: false,
        timeout: 30000
      })

      // Set headers for PDF download
      setHeader(event, 'Content-Type', 'application/pdf')
      setHeader(
        event,
        'Content-Disposition',
        `attachment; filename="cv-${dateStr}.pdf"`
      )

      return pdfBuffer
    } finally {
      await browser.close()
    }
  } catch (error) {
    console.error('Error generating PDF:', error)
    throw createError({
      statusCode: error.statusCode || 500,
      message: `Failed to generate PDF: ${error.message}`
    })
  }
})

Step 3: Create the Print Content API

Create a new file server/api/print-content/[id].get.js:

javascript
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineEventHandler(async event => {
  try {
    const id = event.context.params.id
    const tempDir = path.resolve(process.cwd(), '.temp')
    const tempFile = path.join(tempDir, `${id}.json`)

    if (!fs.existsSync(tempFile)) {
      throw createError({
        statusCode: 404,
        message: 'Print content not found'
      })
    }

    const data = JSON.parse(fs.readFileSync(tempFile, 'utf-8'))

    // Create HTML with content and styles
    const html = `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="UTF-8">
          <style>
            ${data.styles || ''}
            @page {
              size: A4;
              margin: 20mm;
            }
            body {
              margin: 0;
              padding: 0;
              -webkit-print-color-adjust: exact;
              print-color-adjust: exact;
              background: white;
              font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            }
            .print {
              width: 210mm;
              min-height: 297mm;
              padding: 0;
              margin: 0 auto;
              background: white;
              box-sizing: border-box;
            }
            * {
              box-sizing: border-box;
            }
          </style>
        </head>
        <body>
          <div class="print">
            ${data.content || ''}
          </div>
        </body>
      </html>
    `

    setHeader(event, 'Content-Type', 'text/html')
    return html
  } catch (error) {
    console.error('Error reading print content:', error)
    throw createError({
      statusCode: error.statusCode || 500,
      message: `Failed to retrieve print content: ${error.message}`
    })
  }
})

Step 4: Create the Temp Content API

Create a new file server/api/temp-content.post.js:

javascript
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineEventHandler(async event => {
  try {
    const body = await readBody(event)
    const tempId = new Date()
      .toISOString()
      .replace(/[-:]/g, '')
      .replace(/[T.]/g, '.')
      .replace('Z', '')
      .slice(0, 19)

    // Get the project root directory
    const tempDir = path.resolve(process.cwd(), '.temp')

    // Create temp directory if it doesn’t exist
    if (!fs.existsSync(tempDir)) {
      fs.mkdirSync(tempDir)
    }

    // Save content to temp file
    const tempFile = path.join(tempDir, `${tempId}.json`)
    fs.writeFileSync(tempFile, JSON.stringify(body))

    return { tempId }
  } catch (error) {
    console.error('Error saving temp content:', error)
    throw createError({
      statusCode: error.statusCode || 500,
      message: `Failed to save temp content: ${error.message}`
    })
  }
})

Step 5: Implement the Download Function

In your CV component (e.g., components/layout/edit.vue), add the download function:

javascript
async onDownloadPDF() {
  try {
    // Store current edit mode
    const wasEditing = this.editMode
    // Switch to view mode for PDF generation
    this.editMode = false

    // Wait for view mode to render
    await this.$nextTick()

    // Get content to print
    const contentToPrint = document.querySelector('.print')
    if (!contentToPrint) {
      throw new Error('Print content not found')
    }

    // Get the full HTML structure including the print element itself
    const content = contentToPrint.outerHTML

    // Get all styles
    const styles = Array.from(document.styleSheets)
      .map(sheet => {
        try {
          return Array.from(sheet.cssRules)
            .map(rule => rule.cssText)
            .join('\n')
        } catch (e) {
          console.warn('Could not read stylesheet:', e)
          return ''
        }
      })
      .join('\n')

    // Get computed styles for each element
    const computedStyles = {}
    const elements = contentToPrint.getElementsByTagName('*')
    for (const element of elements) {
      const style = window.getComputedStyle(element)
      const elementId = element.id || element.className || element.tagName.toLowerCase()
      computedStyles[elementId] = {}
      for (const prop of style) {
        computedStyles[elementId][prop] = style.getPropertyValue(prop)
      }
    }

    // Create content object
    const contentData = {
      content,
      styles,
      computedStyles
    }

    // Upload content to temp storage
    const response = await fetch('/api/temp-content', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(contentData)
    })

    if (!response.ok) {
      const errorData = await response.json()
      throw new Error(errorData.message || 'Failed to upload content')
    }

    const { tempId } = await response.json()

    // Get current date and time for filename
    const now = new Date()
    const dateStr = now
      .toISOString()
      .replace(/[-:]/g, '')
      .replace(/[T.]/g, '.')
      .replace('Z', '')
      .slice(0, 19)

    // Generate PDF
    const pdfResponse = await fetch(`/api/generate-pdf/${tempId}`)
    if (!pdfResponse.ok) {
      const errorData = await pdfResponse.json()
      throw new Error(errorData.message || 'Failed to generate PDF')
    }

    // Download the PDF
    const pdfBlob = await pdfResponse.blob()
    const url = window.URL.createObjectURL(pdfBlob)
    const link = document.createElement('a')
    link.href = url
    link.download = `cv-${dateStr}.pdf`
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)

    // Restore edit mode
    this.editMode = wasEditing
  } catch (error) {
    console.error('Error preparing PDF:', error)
    alert(`Error preparing PDF: ${error.message}`)
  }
}

How It Works

  1. When the user clicks the download button:

    • The current content and styles are captured
    • The content is saved to a temporary file
    • A unique ID is generated for the content
  2. The PDF generation process:

    • Puppeteer launches a headless browser
    • Sets the viewport to A4 size
    • Loads the content with styles
    • Generates a PDF with proper margins and encoding
    • Downloads the PDF with a timestamped filename
  3. Key features:

    • Maintains all styles and layout
    • Proper character encoding
    • A4 format with custom margins
    • Timestamped filenames
    • Error handling

Tips for Best Results

  1. Use a .print class for your CV content container
  2. Ensure all styles are properly scoped
  3. Test with different content lengths
  4. Check character encoding for special characters
  5. Adjust margins as needed

Conclusion

This implementation provides a robust solution for generating PDFs from your CV in Nuxt 3. It maintains styling, handles special characters, and provides a good user experience with proper error handling.

Remember to:

  • Test thoroughly with different content
  • Adjust margins and dimensions as needed
  • Handle errors gracefully
  • Clean up temporary files if needed

Happy coding! 🚀