Add a Table of Contents with Real Page Numbers to an HTML-to-PDF
HTML has no concept of print page numbers. A table of contents built with anchors and CSS cannot know that “Financial Audit” starts on page 4 until the document has actually been paginated. Estimating it with character counts or fixed heights breaks as soon as the content changes.
The reliable pattern is two-pass: render the document once, measure where every heading landed, then write those page numbers into the TOC and render again. Fork PDF does both passes inside one API call, so you do not manage a second Puppeteer run or parse the finished PDF.
1. Build fixed-height TOC rows
The TOC rows must occupy the same height before and after the numbers are filled. Otherwise enabling the numbers changes pagination and the measured values become stale.
<div class="toc-row">
<span class="toc-row-title">{{ section.title }}</span>
<span class="toc-page-num">
{% if toc and toc[loop.index0] %}{{ toc[loop.index0].page }}{% else %}—{% endif %}
</span>
</div>
<style>
.toc-row { height: 44px; border-bottom: 1px solid #e2e8f0; }
</style>
Give every section heading a queryable class and a stable identifier:
<h2 class="toc-heading" data-toc="executive-summary">Executive Summary</h2>
2. Measure the headings in a test case
The test case runs after the page is laid out. It can read the page number and bounding box of every heading, then return a patch that is merged into the template data.
const tocNums = doc.querySelectorAll('.toc-page-num');
const headings = doc.querySelectorAll('h2.toc-heading');
const tocReady = tocNums.length > 0 && tocNums[0].innerText.trim() !== '—';
const toc = [];
for (let i = 0; i < headings.length; i++) {
toc.push({
title: headings[i].innerText,
page: headings[i].pages[0] + 1
});
}
if (!tocReady) {
return { patch: { toc } };
}
for (let i = 0; i < headings.length; i++) {
const actual = headings[i].pages[0] + 1;
const shown = parseInt(tocNums[i].innerText, 10);
if (shown !== actual) {
return {
success: false,
reason: 'TOC mismatch for ' + headings[i].innerText + ': shown ' + shown + ', actual ' + actual
};
}
}
return { success: true, toc };
Why + 1? pages is 0-indexed. The first page is pages[0], so a heading on page 4 has pages[0] === 3. Add 1 for the human-readable TOC value.
3. Generate with the test case
const result = await client.generate({
templateId: 'YOUR_TEMPLATE_ID', // or htmlContent
format: 'A4',
data,
testCase: TEST_CASE,
downloadBuffer: true
});
console.log(result.testResults.toc);
// [{ title: 'Executive Summary', page: 3 }, ...]
If you also run a layout patch, measure the TOC after that patch. A compact table or a smaller chart can move every heading, and a TOC measured before the layout settles will be off by a page. The Q3 report example shows the full three-attempt order: layout patch, TOC patch, verification.
Common mistakes
- Measuring the TOC before the layout is final.
- Letting the TOC rows grow when the numbers are filled in.
- Using
text_contenton an element instead ofinnerText; the element may return an empty string. - Forgetting that
pagesis 0-indexed.
FAQ
Can I use this with a saved template?
Yes. Save the HTML as a dashboard template, keep the TOC placeholder, and pass the template ID with the same test case.
Does it work with more than one TOC?
Yes, but query the rows for each TOC separately and key the patch data by TOC name.
How many attempts does it take?
One attempt if the TOC is the only check. Two or three if a layout patch runs first, because the TOC is measured after the layout settles.
Try it on your own template
Generate a PDF with the TOC test case and inspect the returned page numbers.