DEV Community

Layton Whiteley
Layton Whiteley

Posted on

React and Puppeteer: Pdf generation (pdf generation api)

Generating the PDF

Goals:

  • Generate a pdf server-side
  • return the PDF file to the caller

Step 1

Lets create some helpers

We need a helper to render a full html document to register fonts and interpolate the body of the pdf

// file: apps/pdf-server/src/pdf/helpers/render-pdf-document.ts

import { Font, PDFData } from '@pdf-generation/constants';

const renderFontFaces = (font: Font) => {
  return (
    font?.fontFaces?.map((fontFace) => {
      const family = font.family ? `font-family: ${font.family};` : '';
      const style = fontFace.style ? `font-style: ${fontFace.style};` : '';
      const weight = fontFace.weight ? `font-weight: ${fontFace.weight};` : '';
      const format = fontFace.format ? `format(${fontFace.format})` : '';

      return `
        @font-face {
          ${family}
          ${style}
          ${weight}
          src: url(${fontFace.src}) ${format};

        }
      `;
    }) || ''
  );
};

export const renderPdfDocument = (data: PDFData, body: string) => {
  const font = renderFontFaces(data.font);

  return `
    <html>
      <head>
        <meta charset="utf-8" />
        <title>${data.metadata.title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />

        <style>
            ${font}
            body {
                -webkit-print-color-adjust: exact !important;
                color-adjust: exact !important;
                print-color-adjust: exact !important;
            }

        </style>
      </head>
      <body>
        ${body}
      </body>
    </html>
    `;
};
Enter fullscreen mode Exit fullscreen mode

Now we need a helper to set the metadata of the PDF file. Puppeteer, unfortunately, has no way to specify the metadata one may need. Therefore, we use pdf-lib below to set this metadata and return a Buffer for further use

import { PDFDocument } from 'pdf-lib';

const metadataSetters = {
  title: 'setTitle',
  author: 'setAuthor',
  creator: 'setCreator',
  producer: 'setProducer',
  subject: 'setSubject',
};

export async function setPdfMetadata(pdf: Buffer, metadata) {
  const setterEntries = Object.entries(metadataSetters);

  if (!setterEntries.some(([key]) => !!metadata[key])) return pdf;

  const document = await PDFDocument.load(pdf);

  setterEntries.forEach(([key, setter]) => {
    const value = metadata[key];
    if (value) document[setter](value);
  });

  const pdfBytes = await document.save();

  return Buffer.from(pdfBytes);
}
Enter fullscreen mode Exit fullscreen mode

Lets also define a font we want to use

export const DEFAULT_PDF_FONT = {
  family: 'Open Sans',
  familyDisplay: "'Open Sans', sans-serif",
  fontFaces: [
    {
      src: 'https://fonts.gstatic.com/s/opensans/v18/mem5YaGs126MiZpBA-UN_r8-VeJoCqeDjg.ttf',
      weight: 300,
    },
    {
      src: 'https://fonts.gstatic.com/s/opensans/v18/mem8YaGs126MiZpBA-U1UpcaXcl0Aw.ttf',
    },
    {
      src: 'https://fonts.gstatic.com/s/opensans/v18/mem6YaGs126MiZpBA-UFUJ0ef8xkA76a.ttf',
      style: 'italic',
    },
    {
      src: 'https://fonts.gstatic.com/s/opensans/v18/mem5YaGs126MiZpBA-UNirk-VeJoCqeDjg.ttf',
      weight: 600,
    },
    {
      src: 'https://fonts.gstatic.com/s/opensans/v18/memnYaGs126MiZpBA-UFUKXGUehsKKKTjrPW.ttf',
      weight: 600,
      style: 'italic',
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Step 2

Lets create the service that will do the work.

// file: apps/pdf-server/src/pdf/pdf.service.ts

import { Injectable } from '@nestjs/common';
import * as puppeteer from 'puppeteer';
import { writeFile } from 'fs/promises';

import { PDFDocumentData } from '@pdf-generation/constants';
import { PdfDocProps, renderPdfDoc } from '@pdf-generation/pdf-doc';
import { environment } from '../environments/environment';
import { renderPdfDocument, setPdfMetadata, DEFAULT_PDF_FONT } from './helpers';

@Injectable()
export class PdfService {
  async generatePdf(data: PDFDocumentData<PdfDocProps>) {
    const browser = await puppeteer.launch({
      /**
       * Adding `--no-sandbox` here is for easier deployment. Please see puppeteer docs about
       * this argument and why you may not want to use it.
       */
      args: [`--no-sandbox`],
    });

    const pdfData = {
      ...data,
      font: data.font ?? DEFAULT_PDF_FONT,
    };

    const documentBody = renderPdfDoc(pdfData.document, pdfData.font);
    const content = renderPdfDocument(pdfData, documentBody);
    const pdfOptions = pdfData?.options || {};

    const page = await browser.newPage();
    await page.setContent(content, {
      waitUntil: 'networkidle2',
    });
    const pdf = await page.pdf({
      format: 'a4',
      printBackground: true,
      preferCSSPageSize: true,
      ...pdfOptions,
      margin: {
        left: '40px',
        right: '40px',
        top: '40px',
        bottom: '40px',
        ...(pdfOptions?.margin || {}),
      },
    });

    // Give the buffer to pdf-lib
    const result = await setPdfMetadata(pdf, pdfData?.metadata);

// write to disk for debugging
    if (!environment.production) {
      const now = Date.now();
      const filePath = `${environment.tmpFolder}/${now}-tmp-file.pdf`;
      await page.screenshot({
        fullPage: true,
        path: `${environment.tmpFolder}/${now}-tmp-file.png`,
      });
      await writeFile(filePath, result);
    }

    await browser.close();

    return result;
  }
}

Enter fullscreen mode Exit fullscreen mode

Now whats happening here?

  • We are opening a headless chromium browser
  • We render the content of the pdf we generate with renderPdfDoc
  • We render the full html document with renderPdfDocument
  • We set the metadata with setPdfMetadata if available
  • We close the browser then return the Buffer

Step 3

Now we need to wire up the service so its accessible by updating the Controller

// file: apps/pdf-server/src/pdf/pdf.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { PdfService } from './pdf.service';

@Controller('pdf')
export class PdfController {
  constructor(private service: PdfService) {}

  @Post('/generate')
  async generatePdf(@Body() data) {
    return this.service.generatePdf(data);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4

Now finally, wire up the PDF module to the app.

in apps/pdf-server/src/app/app.module.ts

Add PdfModule to the imports array

@Module({
  imports: [PdfModule], // <== here
  controllers: [AppController],
  providers: [AppService],
})
Enter fullscreen mode Exit fullscreen mode

Thats it!

Now you can test the api by starting it

pnpm nx run pdf-server:serve
Enter fullscreen mode Exit fullscreen mode
curl --location --request POST 'localhost:3333/api/pdf/generate' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id": "sample",
    "metadata": {
        "title": "Some random pdf",
        "subject": "Some radom pdf",
        "author": "Statale",
        "producer": "pdf-generation-server",
        "creator": "My awesome creator"
    },
    "document": {
        "images": [
            {
                "id": 0,
                "url": "https://source.unsplash.com/category/technology/1600x900"
            },
            {
                "id": 1,
                "url": "https://source.unsplash.com/category/current-events/1600x900"
            }
        ],
        "title": "Some random pdf",
        "description": "<p>Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat,...</p>"
    }
}'
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
tahliamacghey profile image
TahliaMacghey

How to generate PDF from HTML in React / Node.js app? wiccan spell to break up a relationship

Collapse
 
lwhiteley profile image
Layton Whiteley • Edited

at the core of this article this is achieved by using puppeteer in a Nodejs app

const content = '< html content >'
const browser = await puppeteer.launch();
 const page = await browser.newPage();
    await page.setContent(content, {
      waitUntil: 'networkidle2',
    });

// alternatively: instead of setting the html content
//                       you can visit a page
//  await page.goto('https://example.com', {
//      waitUntil: 'networkidle2',
//    });

    const pdf = await page.pdf({
      format: 'a4',
      printBackground: true,
      preferCSSPageSize: true,
    });
 await browser.close();
Enter fullscreen mode Exit fullscreen mode

Im not sure i understood the question fully but let me know if this answers your question.

Please note that there are many alternatives to creating pdf documents and puppeteer is only one that this article focuses on

Collapse
 
renaud profile image
Renaud 🤖

Thanks for your article, I agree and think that Puppeteer is the best way to create HTML to PDF. Unfortunately it is sometimes a little complicated to achieve on a large scale... In the end I used a micro-saas: doppio.sh

Thread Thread
 
lwhiteley profile image
Layton Whiteley

Thanks for the input. I didn't know about this service. I'll check it out