Pagination and right-to-left
Where real reports get ugly. A template that looks right in a browser is a single tall page; a report is a stack of fixed sheets, and everything that was fine while scrolling — a row, a picture, a wide table — now has to survive being cut at a page boundary.
Every rule below was measured against the renderer rather than recalled from a spec. The fixtures
live in 07-Paragon.Tests/PageFixtures.cs and the measurements in PaginationTests.cs and
RightToLeftTests.cs, so a claim here that stops being true fails a test.
Assume A4 portrait throughout. The engine prints every sheet with no page margin, so the content box is the whole 210mm × 297mm sheet and any whitespace at its edges is yours to write.
The page box
@page { size: 210mm 297mm; margin: 0; }
The engine writes this rule for you from the report's page size — don't write your own @page. The
margin is always zero, and deliberately: whitespace is a design decision, and a page margin is one
you cannot see while authoring, applies to every sheet whether that sheet wants it or not, and
competes with the padding you already wrote.
So the edges are yours. Give the content a box and pad it — and mark that box clone, or the
padding will appear on the first sheet and nowhere else:
.sheet {
padding: 15mm 12mm;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
That is the whole convention: .sheet wraps the page, and its padding is what the page margin used
to be — except it is visible in the editor, and it can differ per page. Two things to keep in mind.
Leave a real edge: with no page margin, content runs to the paper edge, and most printers
cannot print the last few millimetres. And size anything that must fit in mm, not % or vh — a
percentage resolves against a box you cannot see, and vh is meaningless in print.
Tables
Repeat the header on every page
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
This is the one that always earns its place: a 90-row table paginates, and every page carries the header. Without it, page two onwards is columns of numbers with nothing saying what they are.
Put it in every template that has a table. It costs nothing when the table fits on one page.
Keep a row whole
tr { break-inside: avoid; }
Measured: with 40mm rows in a 267mm content box, six rows fill the page and the seventh is the problem. Without the rule it is cut across the boundary; with it, the whole row moves to the next page and page one simply ends early.
The trade is real — you get pages that end 40mm short. Apply it to rows that are meaningless when halved (a line item, a signature block) rather than to every row in the document.
A wide table loses its far side
This is the one that surprises people. Print has no horizontal pagination. A table wider than
the sheet is not carried onto another sheet and is not scaled to fit — the columns past the paper
edge are simply not in the PDF. Measured with twelve
white-space: nowrap columns: the twelfth is gone, with nothing on the page to suggest it existed.
table { width: 100%; table-layout: fixed; }
td { overflow-wrap: break-word; }
table-layout: fixed is what stops the browser from sizing columns to their content and running
off the sheet. With it, the same twelve columns all render. When the content genuinely needs the
width, turn the page instead — set the report's orientation to landscape — rather than hoping.
Breaking where you mean to
section { break-before: page; }
section:first-of-type { break-before: auto; }
Three sections, three pages. The :first-of-type reset matters: without it the first section
breaks too and the report opens on a blank page.
From data, use the helper — it emits the same thing and needs no CSS:
{{#each invoices}}
<h1>{{number}}</h1>
{{#unless @last}}{{pageBreak}}{{/unless}}
{{/each}}
{{#unless @last}} is the part people forget: a break after the final item is a trailing blank
page.
Also available, and worth knowing: break-after: avoid on a heading keeps it with the paragraph
that follows, instead of stranding it alone at the foot of a page.
Images
Two behaviours, both measured, neither what people expect:
An image that fits a page but not the rest of the current one moves whole to the next page. A 260mm image under a paragraph leaves page one holding just that paragraph and 200mm of white. The report is correct and looks broken.
An image taller than the content box is sliced. A 400mm image fills one page and continues on the next, cut straight across. Nothing scales it down; nothing clips it away. A photograph can be halved mid-face.
Both are fixed the same way — cap the height in page units so an image can never be taller than the space it has:
img { max-width: 100%; max-height: 200mm; object-fit: contain; }
object-fit: contain keeps the aspect ratio while the cap does its work. With the cap in place the
same 400mm image renders on one page with the text after it.
Page X of Y
Page numbers cannot be written in CSS: nothing in a stylesheet knows how many sheets your table will produce. That number exists only after the document has been laid out, and only the browser is holding it. So a numbered footer is authored in the template as an inert island, and handed to Chromium to draw at print time:
<template id="report-footer">
<div style="font:9pt sans-serif; width:100%; text-align:center">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
</template>
<template> is not rendered and its contents are not parsed into the page, so it costs the page
nothing. A page may declare its own island to override the template's for that page alone — a cover
sheet with no footer, say.
report-header is the same island, handled differently. Its markup is placed in the page,
fixed to the top of the sheet, so it repeats on every one and keeps your stylesheet, your fonts
and your images. Only the footer goes to the print chrome, and only because of the number. Three
things to know about that, because each one produces a footer that is silently wrong rather than an
error:
- Nothing reserves room for it. It still prints at the foot of every sheet under a zero page
margin — measured, because the opposite is the reasonable guess — but it is painted over the
bottom of your content rather than below it. Leave it room in
.sheet's bottom padding. - It is a separate document, despite living in your template — the footer only. It is styled
from your stylesheet all the same: the sheets the template registers are read at export and sent
along with it, so a footer is styled in
report.csslike everything else. What it will not do is paint a background or draw an image — measured, and adata:URI does not get round it. Type, weight, colour, spacing, layout and direction all work. A logo goes in the header island instead, which is placed in the page and has none of these limits. - It has no direction of its own. An RTL report does not make its footer RTL — set
dir="rtl"on the footer's own element if it contains Arabic.
Right to left
The direction itself
One attribute, from the report's setting, applied by the engine to <html>. Measured: an RTL page
puts its text on the right of the sheet and an LTR page on the left, and a two-column table puts its
first column on the right under RTL. You do not need to mirror anything by hand — use logical
CSS properties (margin-inline-start, padding-inline-end, text-align: start) and the layout
follows the direction instead of fighting it.
A dir written into the template wins over the report setting, deliberately: the author saw it
rendered that way.
Fonts are the actual failure mode
Arabic renders as disconnected letters or boxes when the font is not there. Chromium shapes Arabic correctly with any font that has the glyphs — it has no fallback to reach for in a locked-down render, because the engine blocks every external request. Put the font in the project assets and reference it from the template. The exporter warns when a referenced family is not present.
Numbers and dates: what a culture does and does not do
The helpers take a culture — {{number x "N2" "ar-EG"}}, {{money x "ar-EG"}},
{{date x "dd MMMM yyyy" "ar-EG"}}. Measured across ar-EG, ar-SA, ar-AE and en-US:
Digits never become Arabic-Indic. Every Arabic culture formats 1234567.5 with Western digits.
What changes is the separators: ar-EG and ar-SA use ٬ and ٫, while ar-AE uses , and
. like English. If a report must show ١٢٣٤, that is a content decision — the data supplies them
or you map them yourself. No culture will do it for you.
ar-SA returns Hijri dates. This is the trap in this whole document:
{{date "2026-08-12" "dd MMMM yyyy" "ar-SA"}} → 29 صفر 1448
{{date "2026-08-12" "dd MMMM yyyy" "ar-EG"}} → 12 أغسطس 2026
ar-SA carries the Umm al-Qura calendar, so a Gregorian date formatted with it silently comes back
as a different year — on a page where every other number looks fine. Unless the report genuinely
wants a Hijri date, use ar-EG or ar-AE for Gregorian output in Arabic.
Formatted values carry invisible direction marks. Arabic currency and dates come back with
right-to-left marks (U+200F) embedded — that is what keeps ج.م. on the correct side of its
number. They are invisible, they count towards string length, and they survive into the PDF. Do not
slice, trim or compare such a string as though it were what you see.
{{number}} does not group by default. The default format is G, the round-trip format, so
1234567.5 renders as 1234567.5. Ask for the display format you want: {{number x "N2" "en-US"}}
→ 1,234,567.50.
Mixed runs
A Latin report code inside an Arabic line is ordinary and Chromium's bidi handles it. What it cannot guess is where a neutral character — a bracket, a slash, a trailing dot — belongs, and those are what end up on the wrong side of a code or a phone number. Isolate the foreign run:
<p>الفاتورة رقم <bdi>R-ACME-003</bdi> صدرت</p>
<bdi> (or unicode-bidi: isolate) tells the algorithm to resolve that run on its own and place
the result as a unit. Measured: an embedded Latin run does not pull the line off its side of the
page. The ordering inside the line is Chromium's, and is the one thing here checked by eye rather
than by assertion — the fixture is PageFixtures.MixedRun().
Charts
Two rules, both covered in depth elsewhere but they belong on any pagination checklist:
- Animation off, always. The engine disables CSS animation and transition, but a chart library's own animation is its own business — a chart that animates is captured mid-frame.
- A chart that draws late must say when it is done, and a report with several of them must use the
barrier rather than calling
ready()from each. See Waiting for several asynchronous things.
Checklist before publishing
- Every table has
thead { display: table-header-group } - Rows that must not be halved have
break-inside: avoid - No table is wider than the sheet — or the report is landscape
- Images have a
max-heightinmm - Forced breaks skip the first item and the last
- The page's own box pads its edges, and is marked
box-decoration-break: clone - A footer, if any, has bottom padding left for it to print over
- Arabic reports carry their font in the project assets
- No
ar-SAon a date that is meant to be Gregorian - Rendered at the report's real page size and read at 100% — not scrolled in a browser