Document Generation

Generating paginated PDF reports with Puppeteer and Paged.js

David White

Near-Earth Object Weekly Briefing

Generating paginated PDF reports with Puppeteer and Paged.js

The most common way of programmatically generating PDF reports is to use Puppeteer, the headless Chrome browser. Puppeteer is primarily a browser automation tool, not a typesetter, and by default you won’t get page numbers, running headers, table of contents, and intentional pagination. That’s where Paged.js comes in.

There is a CSS spec designed for print: it’s called the Paged Media specification, and Chromium still have yet to support it all. In particular, it’s still missing running strings, page number cross references, and footnotes. Paged.js to the rescue - a polyfill that runs inside the browser, supports the above, performs a kind of pagination, and implements some useful utilities to support document generation.

In this tutorial we’ll generate a simple quarterly business review by building a complete report-generation pipeline. We’ll also encounter a few gotchas along the way, and find ways to overcome them.

Setup

Create a directory, setup npm and install puppeteer, pagedjs, and chart.js:

mkdir pdf-reports && cd pdf-reports
npm init -y
npm install puppeteer pagedjs chart.js
npm pkg set type

Installing Puppeteer will also download Chromium.

We’ll create three files in our report system:


HTML with a CSS print layout

We generate reports using standard HTML, and then bolt-on print layout using CSS Paged Media rules.

Let’s style the page:

@page {

  size: A4;
  margin: 25mm 20mm 25mm 20mm;

  /* Margin "Boxes" -- content boxes at edges of page */
  @top-left {
    content: string(chapter);       /* running header, see below */
    font-size: 9px; color: #666;
  }

  @top-right {
    content: "Northwind Trading — Q2 2026";
    font-size: 9px; color: #666;
  }

  @bottom-center {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 9px; color: #666;
  }

}

/* The cover is a named page with no chrome at all. */
@page cover {
  margin: 0;
  @top-left { content: none; }
  @top-right { content: none; }
  @bottom-center { content: none; }
}

.cover { page: cover; height: 100%; }

The "margin boxes" @top-left, @bottom-center etc. are print CSS's header/footer system. counter(page) and counter(pages) give you "Page 3 of 7".

For the running header, each chapter heading publishes its own text into a named string, and the page margin subscribes to it in order to access it:

section.chapter h2 { string-set: chapter content(text); }
/* ...and in @page:  @top-left { content: string(chapter); } */

To build our table of contents, target-counter() resolves a link to the page number its target landed on:

.toc a::after {
   content: target-counter(attr(href url), page);
   float: right;
}

The table of contents is a list of links, where Paged.js will fill in the numbers after pagination.

Let’s use CSS rules to ensure new chapters start new pages, and avoid lone headings at the bottom a page:

section.chapter { break-before: page; }   /* chapters start fresh pages */
h2, h3 { break-after: avoid; }            /* no headings stranded at page bottom */
figure  { break-inside: avoid; }
p { orphans: 3; widows: 3; }

Our CSS will also set the typography and page and table formatting.

The full report.html for this tutorial has a cover, table of contents, four chapters, and a table. You can find the file in this post’s git repo.

The render script

render.js launches and controls Chromium, runs the polyfill, and generates the PDF.

First, the script launches Puppeteer:

const browser = await puppeteer.launch({
	args: ["--font-render-hinting=none"],
});

Next, we open the file in the browser, and wait for the network to die down:

const page = await browser.newPage();
page.on("console", (msg) => console.log("  [page]", msg.text()));
page.on("pageerror", (err) => console.error("  [page error]", err.message));
await page.goto("file://" + INPUT, { waitUntil: "networkidle0" });

Then run any pre-pagination work the document defines

await page.evaluate(async () => {
  if (window.__prepareDocument) await window.__prepareDocument();
});

Load the polyfill paged.js and our handlers:

await page.addScriptTag({
  path: path.join(__dirname, "node_modules/pagedjs/dist/paged.polyfill.js"),
});
await page.addScriptTag({ path: path.join(__dirname, "handlers.js") });

Paginate the page, and await the promise Paged.js returns:

const total = await page.evaluate(async () => {
  const flow = await window.PagedPolyfill.preview();
  return flow.total;
});

Finally, generate the PDF:

await page.pdf({
	path: OUTPUT,
	preferCSSPageSize: true,
	printBackground: true,
});

We’ve added two parameters here:

  1. In the document, we set window.PagedConfig = { auto: false } and then explicitly call PagedPolyfill.preview()in render.js. This helps avoid race conditions caused by resources in the page taking time to load.

  2. Awaiting the flow object returned by preview() rather than using a timeout (as was previously the standard approach)

Gotcha #1: your charts will silently disappear

If we include a Chart.js chart there's a good chance you will see a “fail silent” empty gap where the chart should be in the final PDF.

This problem occurs because Paged.js paginates by moving DOM nodes into page containers. When it clones the elements during the move, the chart render is lost. The solution is to freeze the canvas into an image before pagination runs:

function renderChartThenFreeze() {
  return new Promise((resolve) => {
    const ctx = document.getElementById("revChart");
    const chart = new Chart(ctx, {
      type: "line",
      data: { /* ... */ },
      options: {
        responsive: false,
        animation: false,     // essential: no async animation frames
        devicePixelRatio: 2,  // crisp at print resolution
      },
    });
  const img = new Image();
  img.onload = () => { ctx.replaceWith(img); resolve(); };
  img.src = chart.toBase64Image("image/png", 1);
  });
}
window.__prepareDocument = renderChartThenFreeze;

You’ll need to implement the same freeze-to-image pattern to anything that uses the canvas, like Chart.js, canvas-based D3 and many other visualisation libraries.

Gotcha #2: table headers stop repeating

By default, the Chromium browser will repeat table headers at the top of the page. When you add Paged.js, the behaviour strangely disappears.

There is another issue caused by pagination. As Paged.js assumes responsibility for pagination, the browser’s heading repeat feature can’t be used. The standard workaround is to use a callback to patch pages as they’re produced, by detecting a table split and cloning the table header into the appropriate place. (Note: this workaround is somewhat dependent on the particular version of Paged.js you’re using).

// handlers.js
class RepeatingTableHeaders extends Paged.Handler {
  constructor(chunker, polisher, caller) {
     super(chunker, polisher, caller);
   }

   afterPageLayout(pageElement, page, breakToken, chunker) {
     const tables = pageElement.querySelectorAll("table[data-split-from]");
     tables.forEach((table) => {
       const ref = table.getAttribute("data-ref");
       const sourceTable = chunker.source.querySelector(`[data-ref='${ref}']`);
       const header = sourceTable && sourceTable.querySelector("thead");
       if (header) {
         table.insertBefore(header.cloneNode(true), table.firstChild);
       }
     });
   }
 }

This Paged.js callback is widely used to work around missing print features in HTML.

Run it!

You can generate the PDF via node render.js

You should see a pause, then a message:
Wrote report.pdf in 1393ms total

Verify the output - don't trust the preview

I’d recommend adding end to end tests for the PDF generation in your CI, and then implementing as many checks as possible to ensure that an update to the Chromium browser, Puppeteer, or your reporting code doesn’t break the document. diff-pdf is a useful tool for comparing PDFs

First, ensure you test with a variety of content payloads in different shapes and sizes: for example, a table that just fits on a page, another that takes exactly two pages, etc.

You can then assert for:

  • The number of pages remaining consistent.

  • That all text content actually makes it to the PDF (use pdftotext).

  • That the table of contents contains valid numbers (no unresolved or zero pages).

A change to the Chromium browser can sometimes result in unwanted changes to your generated documents, so it’s key to test extensively and catch any such problems before they reach your customers.

The limitations of Paged.js

Paged.js is a great project, and for a lot of teams it can perform a decent job: it’s free, uses familiar HTML and CSS, and you can with some effort re-use the same libraries in your web app.

Like most pragmatic solutions, it has its limitations:

  • It's a chunker, not a typesetter. Paged.js walks the DOM and moves overflow to the next page, taking the first break that fits. There's no lookahead or optimisation pass. As a result, pagination quality is more likely to be “acceptable” than “great”.

  • Fragility scales with content variety. Dynamic content, supplied by users or increasingly by LLMs, tend to cause overflow issues and problems with page breaking. The failure modes are typically blank pages and silently missing content that can be difficult to debug.

  • The cost per document includes browser rendering. Chromium is substantially heavier than a purpose-built document renderer, and you’ll need to manage browser processes, concurrency and memory carefully.

  • Hooks proliferate. Continued-table captions, proper footnotes, baseline grids, decent hyphenation are all implemented with handlers, which essentially creates a bespoke layout engine you now maintain that is subject to changes to Chromium and Paged.js.

If your document needs are modest in volume and complexity, Puppeteer + Paged.js can be a workable solution. If you're generating documents at volume, from variable or machine-generated content, or with typographic expectations beyond "printed webpage”, then expect to hit a wall at some point.

The complete working example — report.html*,* render.js*,* handlers.js * - is available on the git repository: https://github.com/papermillio/blog-puppeteer-pagedjs

Generating paginated PDF reports with Puppeteer and Paged.js

The most common way of programmatically generating PDF reports is to use Puppeteer, the headless Chrome browser. Puppeteer is primarily a browser automation tool, not a typesetter, and by default you won’t get page numbers, running headers, table of contents, and intentional pagination. That’s where Paged.js comes in.

There is a CSS spec designed for print: it’s called the Paged Media specification, and Chromium still have yet to support it all. In particular, it’s still missing running strings, page number cross references, and footnotes. Paged.js to the rescue - a polyfill that runs inside the browser, supports the above, performs a kind of pagination, and implements some useful utilities to support document generation.

In this tutorial we’ll generate a simple quarterly business review by building a complete report-generation pipeline. We’ll also encounter a few gotchas along the way, and find ways to overcome them.

Setup

Create a directory, setup npm and install puppeteer, pagedjs, and chart.js:

mkdir pdf-reports && cd pdf-reports
npm init -y
npm install puppeteer pagedjs chart.js
npm pkg set type

Installing Puppeteer will also download Chromium.

We’ll create three files in our report system:


HTML with a CSS print layout

We generate reports using standard HTML, and then bolt-on print layout using CSS Paged Media rules.

Let’s style the page:

@page {

  size: A4;
  margin: 25mm 20mm 25mm 20mm;

  /* Margin "Boxes" -- content boxes at edges of page */
  @top-left {
    content: string(chapter);       /* running header, see below */
    font-size: 9px; color: #666;
  }

  @top-right {
    content: "Northwind Trading — Q2 2026";
    font-size: 9px; color: #666;
  }

  @bottom-center {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 9px; color: #666;
  }

}

/* The cover is a named page with no chrome at all. */
@page cover {
  margin: 0;
  @top-left { content: none; }
  @top-right { content: none; }
  @bottom-center { content: none; }
}

.cover { page: cover; height: 100%; }

The "margin boxes" @top-left, @bottom-center etc. are print CSS's header/footer system. counter(page) and counter(pages) give you "Page 3 of 7".

For the running header, each chapter heading publishes its own text into a named string, and the page margin subscribes to it in order to access it:

section.chapter h2 { string-set: chapter content(text); }
/* ...and in @page:  @top-left { content: string(chapter); } */

To build our table of contents, target-counter() resolves a link to the page number its target landed on:

.toc a::after {
   content: target-counter(attr(href url), page);
   float: right;
}

The table of contents is a list of links, where Paged.js will fill in the numbers after pagination.

Let’s use CSS rules to ensure new chapters start new pages, and avoid lone headings at the bottom a page:

section.chapter { break-before: page; }   /* chapters start fresh pages */
h2, h3 { break-after: avoid; }            /* no headings stranded at page bottom */
figure  { break-inside: avoid; }
p { orphans: 3; widows: 3; }

Our CSS will also set the typography and page and table formatting.

The full report.html for this tutorial has a cover, table of contents, four chapters, and a table. You can find the file in this post’s git repo.

The render script

render.js launches and controls Chromium, runs the polyfill, and generates the PDF.

First, the script launches Puppeteer:

const browser = await puppeteer.launch({
	args: ["--font-render-hinting=none"],
});

Next, we open the file in the browser, and wait for the network to die down:

const page = await browser.newPage();
page.on("console", (msg) => console.log("  [page]", msg.text()));
page.on("pageerror", (err) => console.error("  [page error]", err.message));
await page.goto("file://" + INPUT, { waitUntil: "networkidle0" });

Then run any pre-pagination work the document defines

await page.evaluate(async () => {
  if (window.__prepareDocument) await window.__prepareDocument();
});

Load the polyfill paged.js and our handlers:

await page.addScriptTag({
  path: path.join(__dirname, "node_modules/pagedjs/dist/paged.polyfill.js"),
});
await page.addScriptTag({ path: path.join(__dirname, "handlers.js") });

Paginate the page, and await the promise Paged.js returns:

const total = await page.evaluate(async () => {
  const flow = await window.PagedPolyfill.preview();
  return flow.total;
});

Finally, generate the PDF:

await page.pdf({
	path: OUTPUT,
	preferCSSPageSize: true,
	printBackground: true,
});

We’ve added two parameters here:

  1. In the document, we set window.PagedConfig = { auto: false } and then explicitly call PagedPolyfill.preview()in render.js. This helps avoid race conditions caused by resources in the page taking time to load.

  2. Awaiting the flow object returned by preview() rather than using a timeout (as was previously the standard approach)

Gotcha #1: your charts will silently disappear

If we include a Chart.js chart there's a good chance you will see a “fail silent” empty gap where the chart should be in the final PDF.

This problem occurs because Paged.js paginates by moving DOM nodes into page containers. When it clones the elements during the move, the chart render is lost. The solution is to freeze the canvas into an image before pagination runs:

function renderChartThenFreeze() {
  return new Promise((resolve) => {
    const ctx = document.getElementById("revChart");
    const chart = new Chart(ctx, {
      type: "line",
      data: { /* ... */ },
      options: {
        responsive: false,
        animation: false,     // essential: no async animation frames
        devicePixelRatio: 2,  // crisp at print resolution
      },
    });
  const img = new Image();
  img.onload = () => { ctx.replaceWith(img); resolve(); };
  img.src = chart.toBase64Image("image/png", 1);
  });
}
window.__prepareDocument = renderChartThenFreeze;

You’ll need to implement the same freeze-to-image pattern to anything that uses the canvas, like Chart.js, canvas-based D3 and many other visualisation libraries.

Gotcha #2: table headers stop repeating

By default, the Chromium browser will repeat table headers at the top of the page. When you add Paged.js, the behaviour strangely disappears.

There is another issue caused by pagination. As Paged.js assumes responsibility for pagination, the browser’s heading repeat feature can’t be used. The standard workaround is to use a callback to patch pages as they’re produced, by detecting a table split and cloning the table header into the appropriate place. (Note: this workaround is somewhat dependent on the particular version of Paged.js you’re using).

// handlers.js
class RepeatingTableHeaders extends Paged.Handler {
  constructor(chunker, polisher, caller) {
     super(chunker, polisher, caller);
   }

   afterPageLayout(pageElement, page, breakToken, chunker) {
     const tables = pageElement.querySelectorAll("table[data-split-from]");
     tables.forEach((table) => {
       const ref = table.getAttribute("data-ref");
       const sourceTable = chunker.source.querySelector(`[data-ref='${ref}']`);
       const header = sourceTable && sourceTable.querySelector("thead");
       if (header) {
         table.insertBefore(header.cloneNode(true), table.firstChild);
       }
     });
   }
 }

This Paged.js callback is widely used to work around missing print features in HTML.

Run it!

You can generate the PDF via node render.js

You should see a pause, then a message:
Wrote report.pdf in 1393ms total

Verify the output - don't trust the preview

I’d recommend adding end to end tests for the PDF generation in your CI, and then implementing as many checks as possible to ensure that an update to the Chromium browser, Puppeteer, or your reporting code doesn’t break the document. diff-pdf is a useful tool for comparing PDFs

First, ensure you test with a variety of content payloads in different shapes and sizes: for example, a table that just fits on a page, another that takes exactly two pages, etc.

You can then assert for:

  • The number of pages remaining consistent.

  • That all text content actually makes it to the PDF (use pdftotext).

  • That the table of contents contains valid numbers (no unresolved or zero pages).

A change to the Chromium browser can sometimes result in unwanted changes to your generated documents, so it’s key to test extensively and catch any such problems before they reach your customers.

The limitations of Paged.js

Paged.js is a great project, and for a lot of teams it can perform a decent job: it’s free, uses familiar HTML and CSS, and you can with some effort re-use the same libraries in your web app.

Like most pragmatic solutions, it has its limitations:

  • It's a chunker, not a typesetter. Paged.js walks the DOM and moves overflow to the next page, taking the first break that fits. There's no lookahead or optimisation pass. As a result, pagination quality is more likely to be “acceptable” than “great”.

  • Fragility scales with content variety. Dynamic content, supplied by users or increasingly by LLMs, tend to cause overflow issues and problems with page breaking. The failure modes are typically blank pages and silently missing content that can be difficult to debug.

  • The cost per document includes browser rendering. Chromium is substantially heavier than a purpose-built document renderer, and you’ll need to manage browser processes, concurrency and memory carefully.

  • Hooks proliferate. Continued-table captions, proper footnotes, baseline grids, decent hyphenation are all implemented with handlers, which essentially creates a bespoke layout engine you now maintain that is subject to changes to Chromium and Paged.js.

If your document needs are modest in volume and complexity, Puppeteer + Paged.js can be a workable solution. If you're generating documents at volume, from variable or machine-generated content, or with typographic expectations beyond "printed webpage”, then expect to hit a wall at some point.

The complete working example — report.html*,* render.js*,* handlers.js * - is available on the git repository: https://github.com/papermillio/blog-puppeteer-pagedjs

Generating paginated PDF reports with Puppeteer and Paged.js

The most common way of programmatically generating PDF reports is to use Puppeteer, the headless Chrome browser. Puppeteer is primarily a browser automation tool, not a typesetter, and by default you won’t get page numbers, running headers, table of contents, and intentional pagination. That’s where Paged.js comes in.

There is a CSS spec designed for print: it’s called the Paged Media specification, and Chromium still have yet to support it all. In particular, it’s still missing running strings, page number cross references, and footnotes. Paged.js to the rescue - a polyfill that runs inside the browser, supports the above, performs a kind of pagination, and implements some useful utilities to support document generation.

In this tutorial we’ll generate a simple quarterly business review by building a complete report-generation pipeline. We’ll also encounter a few gotchas along the way, and find ways to overcome them.

Setup

Create a directory, setup npm and install puppeteer, pagedjs, and chart.js:

mkdir pdf-reports && cd pdf-reports
npm init -y
npm install puppeteer pagedjs chart.js
npm pkg set type

Installing Puppeteer will also download Chromium.

We’ll create three files in our report system:


HTML with a CSS print layout

We generate reports using standard HTML, and then bolt-on print layout using CSS Paged Media rules.

Let’s style the page:

@page {

  size: A4;
  margin: 25mm 20mm 25mm 20mm;

  /* Margin "Boxes" -- content boxes at edges of page */
  @top-left {
    content: string(chapter);       /* running header, see below */
    font-size: 9px; color: #666;
  }

  @top-right {
    content: "Northwind Trading — Q2 2026";
    font-size: 9px; color: #666;
  }

  @bottom-center {
    content: "Page " counter(page) " of " counter(pages);
    font-size: 9px; color: #666;
  }

}

/* The cover is a named page with no chrome at all. */
@page cover {
  margin: 0;
  @top-left { content: none; }
  @top-right { content: none; }
  @bottom-center { content: none; }
}

.cover { page: cover; height: 100%; }

The "margin boxes" @top-left, @bottom-center etc. are print CSS's header/footer system. counter(page) and counter(pages) give you "Page 3 of 7".

For the running header, each chapter heading publishes its own text into a named string, and the page margin subscribes to it in order to access it:

section.chapter h2 { string-set: chapter content(text); }
/* ...and in @page:  @top-left { content: string(chapter); } */

To build our table of contents, target-counter() resolves a link to the page number its target landed on:

.toc a::after {
   content: target-counter(attr(href url), page);
   float: right;
}

The table of contents is a list of links, where Paged.js will fill in the numbers after pagination.

Let’s use CSS rules to ensure new chapters start new pages, and avoid lone headings at the bottom a page:

section.chapter { break-before: page; }   /* chapters start fresh pages */
h2, h3 { break-after: avoid; }            /* no headings stranded at page bottom */
figure  { break-inside: avoid; }
p { orphans: 3; widows: 3; }

Our CSS will also set the typography and page and table formatting.

The full report.html for this tutorial has a cover, table of contents, four chapters, and a table. You can find the file in this post’s git repo.

The render script

render.js launches and controls Chromium, runs the polyfill, and generates the PDF.

First, the script launches Puppeteer:

const browser = await puppeteer.launch({
	args: ["--font-render-hinting=none"],
});

Next, we open the file in the browser, and wait for the network to die down:

const page = await browser.newPage();
page.on("console", (msg) => console.log("  [page]", msg.text()));
page.on("pageerror", (err) => console.error("  [page error]", err.message));
await page.goto("file://" + INPUT, { waitUntil: "networkidle0" });

Then run any pre-pagination work the document defines

await page.evaluate(async () => {
  if (window.__prepareDocument) await window.__prepareDocument();
});

Load the polyfill paged.js and our handlers:

await page.addScriptTag({
  path: path.join(__dirname, "node_modules/pagedjs/dist/paged.polyfill.js"),
});
await page.addScriptTag({ path: path.join(__dirname, "handlers.js") });

Paginate the page, and await the promise Paged.js returns:

const total = await page.evaluate(async () => {
  const flow = await window.PagedPolyfill.preview();
  return flow.total;
});

Finally, generate the PDF:

await page.pdf({
	path: OUTPUT,
	preferCSSPageSize: true,
	printBackground: true,
});

We’ve added two parameters here:

  1. In the document, we set window.PagedConfig = { auto: false } and then explicitly call PagedPolyfill.preview()in render.js. This helps avoid race conditions caused by resources in the page taking time to load.

  2. Awaiting the flow object returned by preview() rather than using a timeout (as was previously the standard approach)

Gotcha #1: your charts will silently disappear

If we include a Chart.js chart there's a good chance you will see a “fail silent” empty gap where the chart should be in the final PDF.

This problem occurs because Paged.js paginates by moving DOM nodes into page containers. When it clones the elements during the move, the chart render is lost. The solution is to freeze the canvas into an image before pagination runs:

function renderChartThenFreeze() {
  return new Promise((resolve) => {
    const ctx = document.getElementById("revChart");
    const chart = new Chart(ctx, {
      type: "line",
      data: { /* ... */ },
      options: {
        responsive: false,
        animation: false,     // essential: no async animation frames
        devicePixelRatio: 2,  // crisp at print resolution
      },
    });
  const img = new Image();
  img.onload = () => { ctx.replaceWith(img); resolve(); };
  img.src = chart.toBase64Image("image/png", 1);
  });
}
window.__prepareDocument = renderChartThenFreeze;

You’ll need to implement the same freeze-to-image pattern to anything that uses the canvas, like Chart.js, canvas-based D3 and many other visualisation libraries.

Gotcha #2: table headers stop repeating

By default, the Chromium browser will repeat table headers at the top of the page. When you add Paged.js, the behaviour strangely disappears.

There is another issue caused by pagination. As Paged.js assumes responsibility for pagination, the browser’s heading repeat feature can’t be used. The standard workaround is to use a callback to patch pages as they’re produced, by detecting a table split and cloning the table header into the appropriate place. (Note: this workaround is somewhat dependent on the particular version of Paged.js you’re using).

// handlers.js
class RepeatingTableHeaders extends Paged.Handler {
  constructor(chunker, polisher, caller) {
     super(chunker, polisher, caller);
   }

   afterPageLayout(pageElement, page, breakToken, chunker) {
     const tables = pageElement.querySelectorAll("table[data-split-from]");
     tables.forEach((table) => {
       const ref = table.getAttribute("data-ref");
       const sourceTable = chunker.source.querySelector(`[data-ref='${ref}']`);
       const header = sourceTable && sourceTable.querySelector("thead");
       if (header) {
         table.insertBefore(header.cloneNode(true), table.firstChild);
       }
     });
   }
 }

This Paged.js callback is widely used to work around missing print features in HTML.

Run it!

You can generate the PDF via node render.js

You should see a pause, then a message:
Wrote report.pdf in 1393ms total

Verify the output - don't trust the preview

I’d recommend adding end to end tests for the PDF generation in your CI, and then implementing as many checks as possible to ensure that an update to the Chromium browser, Puppeteer, or your reporting code doesn’t break the document. diff-pdf is a useful tool for comparing PDFs

First, ensure you test with a variety of content payloads in different shapes and sizes: for example, a table that just fits on a page, another that takes exactly two pages, etc.

You can then assert for:

  • The number of pages remaining consistent.

  • That all text content actually makes it to the PDF (use pdftotext).

  • That the table of contents contains valid numbers (no unresolved or zero pages).

A change to the Chromium browser can sometimes result in unwanted changes to your generated documents, so it’s key to test extensively and catch any such problems before they reach your customers.

The limitations of Paged.js

Paged.js is a great project, and for a lot of teams it can perform a decent job: it’s free, uses familiar HTML and CSS, and you can with some effort re-use the same libraries in your web app.

Like most pragmatic solutions, it has its limitations:

  • It's a chunker, not a typesetter. Paged.js walks the DOM and moves overflow to the next page, taking the first break that fits. There's no lookahead or optimisation pass. As a result, pagination quality is more likely to be “acceptable” than “great”.

  • Fragility scales with content variety. Dynamic content, supplied by users or increasingly by LLMs, tend to cause overflow issues and problems with page breaking. The failure modes are typically blank pages and silently missing content that can be difficult to debug.

  • The cost per document includes browser rendering. Chromium is substantially heavier than a purpose-built document renderer, and you’ll need to manage browser processes, concurrency and memory carefully.

  • Hooks proliferate. Continued-table captions, proper footnotes, baseline grids, decent hyphenation are all implemented with handlers, which essentially creates a bespoke layout engine you now maintain that is subject to changes to Chromium and Paged.js.

If your document needs are modest in volume and complexity, Puppeteer + Paged.js can be a workable solution. If you're generating documents at volume, from variable or machine-generated content, or with typographic expectations beyond "printed webpage”, then expect to hit a wall at some point.

The complete working example — report.html*,* render.js*,* handlers.js * - is available on the git repository: https://github.com/papermillio/blog-puppeteer-pagedjs

Like this article? Share it.

Start generating documents today

Get your API key and generate your first PDF in under five minutes.