Paragon

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:

  1. 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.
  2. 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.css like everything else. What it will not do is paint a background or draw an image — measured, and a data: 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.
  3. 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-height in mm
  • 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-SA on 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