Skip to Content
Multi-page PDFs

Multi-page PDFs

oJo prints your HTML with Chromium, so CSS paged media controls how a long document breaks into pages. Everything here works with Create PDF from HTML and Create PDF from Template. The {{ }} variables only work with templates.

For two complete examples, see Starter templates.

Page size and margins

@page { size: A4; /* or letter, legal, A5, 210mm 297mm, A4 landscape */ margin: 25mm 20mm; }

Headers and footers with page numbers

Text in the page margin goes in a margin box inside @page, such as @top-left, @top-right or @bottom-right. counter(page) is the current page and counter(pages) the total.

<html> <head> <style> @page { size: A4; margin: 25mm 20mm; @top-left { content: "Acme Ltd · Quarterly report"; font: 9pt Helvetica, Arial, sans-serif; color: #666; } @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt Helvetica, Arial, sans-serif; } } body { font: 11pt Helvetica, Arial, sans-serif; } </style> </head> <body> <!-- your content --> </body> </html>

Set a font in every margin box. Without one, the box prints in a serif font whatever the body uses. A web font in a margin box only loads when the body uses it too.

A named page with counter-reset: page 0 hides the footer on the cover and starts numbering on the page after it.

<html> <head> <style> @page { size: A4; margin: 25mm 20mm; @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt sans-serif; } } @page cover { margin: 0; counter-reset: page 0; @bottom-right { content: none; } } .cover { page: cover; height: 297mm; padding: 60mm 20mm 0; box-sizing: border-box; background: #1f2937; color: #fff; } body { margin: 0; font: 11pt sans-serif; } </style> </head> <body> <section class="cover"><h1>Quarterly report</h1></section> <main> <!-- your content --> </main> </body> </html>
ℹ️

counter(pages) still counts the cover. In a 4-page PDF the page after the cover reads “Page 1 of 4” and the last one “Page 3 of 4”. Leave out “of” and the total if that matters.

A different header per section, and landscape pages

Give a section its own named page to change its header, or its size. The footer from the plain @page rule carries on.

<html> <head> <style> @page { margin: 25mm 20mm; @top-left { content: "Quarterly report"; font: 9pt sans-serif; } @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt sans-serif; } } @page appendix { @top-left { content: "Appendix"; font: 9pt sans-serif; } } @page wide { size: A4 landscape; } .appendix { page: appendix; } .wide { page: wide; } body { font: 11pt sans-serif; } </style> </head> <body> <main><!-- your content --></main> <section class="wide"><!-- a wide table --></section> <section class="appendix"><!-- appendix content --></section> </body> </html>

A section with a different named page starts on a new page.

Keeping tables and blocks whole

A table’s <thead> repeats at the top of every page the table runs onto. break-inside: avoid keeps a row, figure or block on one page, and break-after: avoid keeps a heading with the text after it.

<html> <head> <style> @page { margin: 20mm; } body { font: 11pt sans-serif; } table { width: 100%; border-collapse: collapse; font: inherit; } th, td { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; } tr, figure, .keep-together { break-inside: avoid; } h2 { break-after: avoid; } .new-page { break-before: page; } </style> </head> <body> <table> <thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead> <tbody> <!-- one <tr> per line item --> </tbody> </table> <section class="new-page"><h2>Notes</h2><p>…</p></section> </body> </html>

Tables do not take the body’s font size on their own, so set font: inherit on table.

Charts

Add the class wait-for-render to <body> and oJo waits until your script adds render-complete before it prints. Turn chart animations off, or the PDF catches them halfway.

<html> <body class="wait-for-render"> <canvas id="revenue" width="640" height="320"></canvas> <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.5.1/chart.umd.min.js"></script> <script> new Chart(document.getElementById('revenue'), { type: 'bar', data: { labels: ['Q1', 'Q2', 'Q3', 'Q4'], datasets: [{ label: 'Revenue', data: [12, 19, 8, 15] }] }, options: { animation: false, responsive: false } }); document.body.classList.add('render-complete'); </script> </body> </html>

oJo waits up to 5 seconds for render-complete. After that it prints the page as it is.

QR codes

<html> <body class="wait-for-render"> <div id="qr"></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> <script> new QRCode(document.getElementById('qr'), { text: 'https://pay.example.com/invoices/1024', width: 128, height: 128 }); document.body.classList.add('render-complete'); </script> </body> </html>

Numbers, currencies and dates in a language

The number template helpers format in US English. For another language, put the raw value in a data- attribute and format it with Intl:

<html> <body> <p>Total: <span class="money" data-amount="1234.5" data-currency="EUR"></span></p> <p>Date: <span class="date" data-date="2026-10-05"></span></p> <script> const locale = 'de-DE'; document.querySelectorAll('.money').forEach((el) => { el.textContent = new Intl.NumberFormat(locale, { style: 'currency', currency: el.dataset.currency }).format(Number(el.dataset.amount)); }); document.querySelectorAll('.date').forEach((el) => { el.textContent = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(el.dataset.date)); }); </script> </body> </html>

This prints “Total: 1.234,50 €” and “Date: 5. Oktober 2026”.

One page per item

In a template, {{#each}} with break-before: page gives every item its own page:

<html> <head> <style> @page { margin: 20mm; @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9pt sans-serif; } } body { font: 11pt sans-serif; } .finding { break-before: page; } </style> </head> <body> <h1>{{title}}</h1> {{#each findings}} <section class="finding"> <h2>{{this.title}}</h2> <p>Severity: {{this.severity}}</p> <p>{{this.description}}</p> </section> {{/each}} </body> </html>

Pass the value through a CSS variable on <html> and use var() in the margin box:

<html style="--company: '{{company}}'"> <head> <style> @page { margin: 20mm; @top-left { content: var(--company); font: 9pt sans-serif; } } </style> </head>

With "Acme & Co <Ltd>" this prints Acme & Co <Ltd>. Writing {{company}} straight into content: "..." prints Acme &amp; Co &lt;Ltd&gt; instead.

⚠️

The value must not contain an apostrophe ('). The request fails with one.

Elements on every page

A position: fixed element prints on every page, which suits a watermark:

.watermark { position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: bold 80pt Helvetica, Arial, sans-serif; color: rgba(0, 0, 0, 0.08); transform: rotate(-30deg); }

Scripts in templates

The template engine reads {{ everywhere, scripts included. {{json data}} hands a template value to a script:

<script> const chart = {{json chart}}; </script>

Two opening braces in your own code end the request with a 422. Write { { with a space instead.

What does not work

  • Page numbers in a table of contents: target-counter() prints the entry without the number.
  • Running headers from the page content: string-set and string() print nothing.
  • Data in a <script type="application/json"> block: the request fails with a 422. Use {{json}} in a normal script as shown above.
  • One page count across pages entries: each entry of Documents from pages is printed on its own, so its counters start again.
  • Bookmarks and accessibility tags: the PDF has neither.