React and Puppeteer: Pdf generation (pdf generation api)
Tags: react, puppeteer, pdf, nx
Published: 2022-10-02 13:43:08


## 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

```ts
// 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>
    `;
};
```

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

```ts
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);
}
```

Lets also define a font we want to use

```ts
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',
    },
  ],
};
```

### Step 2

Lets create the service that will do the work.


```ts
// 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;
  }
}

```
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`

```ts
// 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);
  }
}
```

### 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

```ts
@Module({
  imports: [PdfModule], // <== here
  controllers: [AppController],
  providers: [AppService],
})
```

Thats it!

Now you can test the api by starting it

```bash
pnpm nx run pdf-server:serve
```

```bash
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>"
    }
}'
```