Report template format — schema 2.0
The contract between the designer and the engine. A consuming application never reads this: it
copies the folder, references the package, and calls GenerateAsync(reportCode, json).
2.0 is a breaking change. A report used to be one file; it is now a folder of pages. Schema 1.x templates are refused with a message saying to re-export, rather than half-read into a document missing everything but its first page.
The folder
ParagonTemplates/ folder name is configurable (ParagonOptions.RootPath)
├── xyza123/ one folder per report, named after its code
│ ├── template.html what every page of this report shares
│ ├── assets/
│ │ ├── css/report.css created with the report, linked by the template
│ │ ├── fonts/Cairo-Regular.woff2
│ │ ├── images/logo.png
│ │ └── js/charts/index.js whatever chart library this report uses
│ ├── p1.html the pages, flat beside the assets they reference
│ ├── p2.html
│ └── p3.html
└── invoice-ar/
└── …
The folder name is the report code. Nothing parses a code back out of a file name, and the file
system enforces that two reports cannot claim one code — which is also why the template is simply
template.html in every report rather than carrying the code a second time.
ParagonOptions.TemplateFileNamePattern can change it, and a pattern containing {code} is
still substituted for deployments that set one.
Assets belong to the report. Each report folder is self-contained: copy it, hand it over, delete it, and nothing else is implicated. The cost is duplication — twenty reports using the same chart library hold twenty copies — and it is deliberate.
A new report starts with css, fonts, images and js already there, and an empty css/report.css
the template already links. Same three names in every report, so a path written into one report's
markup means the same thing in the next.
Report codes may contain letters, digits, dots, dashes and underscores, up to 100 characters, and are matched case-insensitively. Anything else is rejected before it reaches the file system.
Pages
A report is a list of pages, rendered in order and concatenated into one document. Each page is its own print job, which is what lets one page be landscape while the next is portrait.
A page is a unit of authoring, not a unit of output. A page whose content runs long paginates into two or three output pages — legitimately, and handled at generation time. The finished document is each page's output pages, in page order.
Every page is a complete, standalone HTML document. The designer authors a page as content and the template as the surroundings; at export the template is flattened into each page, so what ships is N self-describing files. A page can be opened in a browser on its own, and it carries the manifest that says which report it belongs to and where it sits.
Pages are numbered by position. p1.html, p2.html, and so on. Deleting a page renumbers the
ones after it; reordering renumbers whatever the move displaced. A page's identity is separate
from its file name and never changes — which is what keeps a page's own page setup with its content
when it moves.
Backgrounds must survive their own pagination
A background painted as an ordinary element paints once. If that page overflows onto a second
sheet, the second sheet comes out bare. Use position: fixed, which Chromium repeats on every
printed page:
.slide-background { position: fixed; inset: 0; z-index: -1; }
This is the single most likely thing to look right in preview and wrong in the delivered deck.
The template
A complete HTML page holding everything the report's pages share: stylesheets, scripts, fonts, the bound data island, and the manifest. Pages register nothing of their own — they inherit it.
<script src="assets/js/charts/index.js"></script>
<link rel="stylesheet" href="assets/css/site.css">
Assets are referenced with relative paths exactly as written, and pages sit beside the assets
folder rather than under a sub-folder, so those paths resolve when a page is opened straight from
disk. Nothing is rewritten at export.
The running header and footer
Two more inert islands, in the body beside the rest of the report's furniture. The template is where they belong for the same reason everything else here is: they are what every page shares.
<template id="report-header"></template>
<template id="report-footer">
<div style="font:9pt sans-serif">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>
</template>
A new report is created with both slots present and empty, because an empty island means none — so the two things a report can put on every sheet are visible in the file that owns them rather than being something you have to know to add.
A <template> element is not rendered and its contents are not parsed into the document, so it
costs the page nothing. At export the island is lifted into formats.pdf.footerHtml and removed
from the file that ships, so the markup exists once rather than twice. report-header is the
same. A page that declares its own island overrides the template's for that page alone.
The two are treated differently, because they are asked for different things.
The header is placed in the page, fixed to the top of the sheet — and a fixed element is repainted on every sheet, which is what repeats it. Being in the document, it is styled by the report's stylesheet, its images load, it inherits the report's direction, and the designer's preview shows the real thing.
The footer is handed to the browser's print chrome, because it is the one thing a page cannot
do for itself: a stylesheet cannot count sheets. It renders as a document of its own, so the
stylesheets the template registers are read at export and travel with it — meaning a footer is
styled in report.css like the rest of the report, rather than in inline attributes. What it will
not do is paint a background or draw an image: measured, and a data: URI does not get round it.
Text, in every way you can style text, is what a footer can hold. Put a logo in the header island,
which is placed in the page.
The manifest
An inert JSON island — browsers neither execute nor render it — carried by the template and by every page.
<script type="application/json" id="report-manifest">
{
"schemaVersion": "2.0",
"projectCode": "ERP",
"projectAssetsVersion": "3.0",
"reportCode": "xyza123",
"name": "Customer Invoice (Arabic)",
"templateVersion": 7,
"exportedOnUtc": "2026-08-14T10:22:00Z",
"direction": "rtl",
"outputFormat": "pdf",
"awaitsReadySignal": true,
"pageId": "a3f9c1d20e77",
"pages": [
{ "id": "a3f9c1d20e77", "file": "p1.html" },
{ "id": "b81e04aa9c31", "file": "p2.html",
"formats": { "pdf": { "orientation": "landscape", "pageSize": "A3" } } },
{ "id": "c72d55b1f004", "file": "p3.html" }
],
"requiredAssets": [
{ "path": "assets/js/charts/index.js", "sha256": "…" }
],
"formats": {
"pdf": { "pageSize": "A4", "orientation": "portrait",
"marginsMm": { "top": 15, "right": 12, "bottom": 15, "left": 12 },
"printBackground": true, "scale": 1.0, "footerHtml": "…" },
"word": { "inherits": "pdf" },
"powerpoint": { "inherits": "pdf" }
},
"sampleData": { "customer": { "name": "…" } },
"integrity": { "algorithm": "SHA-256", "templateHash": "…" }
}
</script>
| Field | Required | Notes |
|---|---|---|
schemaVersion |
yes | 2.0. See compatibility below |
reportCode |
yes | Should match the folder name; the folder name is what resolves |
pages |
yes | Ordered. id is durable identity, file is the positional name, formats overrides this page's setup alone |
pageId |
in a page | Which page this copy of the manifest is carried by. Absent in the template |
direction |
no | ltr (default) or rtl |
awaitsReadySignal |
no | true when a page finishes drawing asynchronously |
requiredAssets |
no | Verified at load, with a sha256 each when VerifyAssetHashes is on |
formats |
no | pdf, word, powerpoint. Page-level formats are layered over these |
sampleData |
no | Shape documentation for consumers; also the designer's preview payload |
integrity |
no | SHA-256 of the file, checked when VerifyTemplateIntegrity is on |
Page setup resolves nearest-first
page.formats[format] → its inherits chain → formats[format] → its inherits chain →
defaults (A4 portrait, 10mm margins). A page that specifies nothing renders exactly as the report
does.
Failures at load
All of these are raised when the report is loaded, never during rendering — a report that cannot be rendered correctly must not produce a document that looks fine and is wrong.
| Condition | Exception |
|---|---|
| No folder for the code | ReportNotFoundException (suggests the nearest code) |
| Code contains anything outside the allowed charset | InvalidReportCodeException |
| Two folders claim one code (case-sensitive file systems) | DuplicateReportCodeException |
| No manifest island, malformed JSON, or no pages declared | ReportManifestException |
| Report needs a newer schema, or is still on 1.x | TemplateSchemaVersionException |
| A page the manifest lists is not in the folder | MissingReportPageException (lists every one) |
| A page belongs to another report, another version, or a different page list | StaleReportPageException |
| A required asset is not on disk | MissingReportAssetException (lists every missing path) |
| An asset's hash differs from export | CorruptReportAssetException |
| Deployed assets are a different generation from the approved ones | AssetsVersionMismatchException |
StaleReportPageException is worth understanding: it catches the case where every file is present,
every file is valid, and the document assembled from them is still wrong — a page left behind by an
earlier export. Each page records the report and version it came from so this can be caught.
All derive from ParagonException.
Compatibility
The engine supports schema 2.0 and accepts anything at or below it within the same major. Within a major version new fields get defaults and nothing is renamed or removed, so a template exported today keeps working on later 2.x engines.
Going from 1.x to 2.0 means re-exporting: a report is now a folder, and there is no page list in a 1.x template to render.