Quick start for a consuming application
You have been handed a zip from the designer — one per report, named after its code. Unzip them side
by side into a ParagonTemplates folder. That folder is everything needed to turn them into PDFs.
Fifteen minutes, four steps, no database and no network.
1. Reference the package
<PackageReference Include="Paragon" Version="0.1.0" />
The feed is GitHub Packages under the Insighta-Product organisation, so a nuget.config with a
personal access token carrying read:packages is needed:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="insighta" value="https://nuget.pkg.github.com/Insighta-Product/index.json" />
</packageSources>
<packageSourceCredentials>
<insighta>
<add key="Username" value="YOUR_GITHUB_USERNAME" />
<add key="ClearTextPassword" value="YOUR_TOKEN" />
</insighta>
</packageSourceCredentials>
</configuration>
This is the step that trips most teams. If restore cannot find Paragon, it is the token.
2. Drop the folder in and copy it to output
Put the ParagonTemplates folder in your project, and make sure it travels with the build:
<ItemGroup>
<Content Include="ParagonTemplates\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
The folder holds one sub-folder per report, named after its code, and each is self-contained — its
template, its own assets tree, and one file per page:
ParagonTemplates/
└── R-ACME-003/
├── template.html
├── assets/
├── p1.html
└── p2.html
That is exactly what one zip from the designer unzips to — the folder is named after the report code, so there is nothing to rename and nothing to arrange.
You never open any of it — the engine resolves a report by its code, which is the folder's name.
3. Register the engine
builder.Services.AddParagon(options =>
{
options.RootPath = "ParagonTemplates"; // relative to the application's base directory
options.ValidateOnStartup = true; // every template checked before serving traffic
options.VerifyTemplateIntegrity = true; // refuse a template changed since it was exported
options.VerifyAssetHashes = true;
});
ValidateOnStartup is worth the milliseconds: a missing asset becomes a failed startup instead of a
blank chart in a document already sent to a customer.
4. Generate
public sealed class ReportsController(IParagonEngine engine) : ControllerBase
{
[HttpPost("invoices/{id}/pdf")]
public async Task<IActionResult> Invoice(int id, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(await BuildInvoiceModelAsync(id, cancellationToken));
var report = await engine.GenerateAsync("R-ACME-003", json, cancellationToken);
return File(report.Bytes, report.ContentType, report.FileName);
}
}
That is the whole API surface for generating: a report code and a JSON payload. The report itself decides whether it produces PDF, Word or PowerPoint, along with its page size and direction — none of that is the caller's business, and none of it needs a deployment to change.
GetAvailableReports() lists what the folder holds, if you need to build a menu.
Publishing the browser with your application
Rendering uses headless Chromium via Playwright. Publish it alongside the app so the target server needs no browser installed and no internet at deploy time:
<PropertyGroup>
<PlaywrightBrowsersPath>0</PlaywrightBrowsersPath>
</PropertyGroup>
Setting PLAYWRIGHT_BROWSERS_PATH=0 puts the browser inside the application's own folder. The
Chromium version is pinned deliberately — it decides where pages break, so an unreviewed upgrade
changes your documents.
On a slim Linux container, Chromium needs its usual shared libraries (libnss3, libatk,
libgbm, fonts). The Playwright base images already have them.
What to expect
- A render takes hundreds of milliseconds to a few seconds. Do not hold a request thread on one under load — queue it and let the client collect the file. See Performance and operations.
- Nothing is written to disk and nothing leaves the machine. No database, no connection string, no outbound request: everything a template asks for is served from its own folder or blocked.
- Every generated file carries its provenance — template version, content hash, engine and browser version — so a document can always be traced back to what produced it.
When something goes wrong
| Symptom | Cause |
|---|---|
ReportNotFoundException |
The code does not match any file. It suggests the nearest one — usually a typo or a folder that was not copied to output. |
MissingReportAssetException at startup |
The template needs an asset the folder does not have. The assets zip and the template came from different exports. |
TemplateSchemaVersionException |
The template is newer than the package. Upgrade Paragon. |
TamperedTemplateException |
The template changed after export. Re-export it, or turn VerifyTemplateIntegrity off if the edit was intended. |
ReportRenderTimeoutException |
The template never finished — usually a chart that never signalled readiness. See Waiting for several asynchronous things. |
| A blank or half-drawn chart | The report was captured before the chart drew. Same document. |
| A field prints empty | The payload does not contain that path. There is no schema by design, so it renders empty rather than failing — check the field name against the report's sample data. |
Wire an IGenerationObserver to see all of this in your own telemetry, and watch BlockedRequests:
a non-empty list means a template reached for something outside itself, so the document rendered but
may be missing a font or a script.
Where to go next
- Report template format — schema 2.0 — what a template file is, and its manifest
- Template helper reference — everything a template can write
- Pagination and right-to-left — tables, page breaks, images, Arabic
- Security — read before accepting templates from anyone
- Performance and operations — running it under load