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 cover page without header or footer
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>Variables in header and footer text
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 & Co <Ltd> 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-setandstring()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
pagesentries: each entry of Documents from pages is printed on its own, so its counters start again. - Bookmarks and accessibility tags: the PDF has neither.