Quick start for a consuming application
Reports are authored into your own application, in wwwroot/ParagonTemplates, so in most cases they
are already there when you clone it and there is nothing to install. A report from a team outside
your repository arrives as a zip named after its code, and unzips into that same folder.
Fifteen minutes, five steps, no database and no network — then a sixth step for the designer your developers author in, which is the one part that wants SQL Server and the one part that never reaches production.
1. Reference the package
<PackageReference Include="PorticoSoft.Paragon" Version="1.0.2" />
Published to nuget.org by PorticoSoft, so nothing else is needed - no private feed, no token, no
nuget.config.
Your developers also install the designer, which is where reports are authored. It is a development dependency, does not belong in what you deploy, and is mounted in step 6:
<PackageReference Include="PorticoSoft.Paragon.Designer" Version="1.0.2" />
2. Drop in the licence
paragon.lic, the file handed over at purchase, beside the application — copied to output, the same
as any other content file. Point ParagonOptions.LicensePath elsewhere if it lives somewhere else.
Nothing refuses to start without it: taking a whole application down over a file is worse than
reports that will not generate. It is said in the log at startup, and GenerateAsync throws
ParagonLicenseException until the file is there. An expired licence keeps generating for a
fortnight's grace; the designer locks on the day.
3. Know where the reports are
wwwroot/ParagonTemplates, in your project. Nothing to configure and nothing to copy to output: the
files are part of the application, checked in beside the code and deployed by the same publish.
A project holds its reports and the assets they share. Each report is a folder named after its code,
holding its template, its own assets tree and one file per page:
wwwroot/ParagonTemplates/
└── Acme-ERP/ ← a project
├── shared-assets/ ← fonts, chart libraries, house CSS — used by every report below
│ ├── css/
│ └── fonts/
└── R-ACME-003/ ← a report, named by its code
├── template.html
├── assets/ ← this report's own; searched before shared-assets
├── p1.html
└── p2.html
A folder of reports with no project around them is read the same way, so an older deployment needs no rearranging.
The publish moves the whole thing out of the web root — wwwroot/ParagonTemplates in the project
becomes ParagonTemplates beside the application — because a report is engine input and nothing
should be able to fetch one over HTTP.
You never open any of it — the engine resolves a report by its code, which is the folder's name.
4. Register the engine
builder.Services.AddParagon(options =>
{
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.
5. 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, PowerPoint or Excel, along with its page size and direction — none of that is the caller's business, and none of it needs a deployment to change.
It is also not the caller's business how the report was written. A folder holding a template.docx
or a template.pptx was authored in Word or PowerPoint rather than as a page, and is unzipped,
referenced and called exactly like any other — see Reports authored in Word or PowerPoint.
GetAvailableReports() lists what the folder holds, if you need to build a menu.
6. Mount the designer
Steps 1 to 5 are the engine, and the engine needs nothing else. This step is for the developers who write the reports, and it is the one half of Paragon that wants a database.
Development only. AddParagonDesigner takes the environment and refuses to mount in Production
whatever the configuration says — a customer's live application is not where reports are authored.
var paragon = builder.Configuration.GetSection("Paragon");
builder.Services.AddParagon(options => paragon.Bind(options)); // step 4, bound from configuration
builder.Services.AddParagonDesigner(builder.Environment, options =>
{
paragon.Bind(options);
options.ConnectionString = builder.Configuration.GetConnectionString("Paragon");
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapParagonDesigner();
One section holds both halves: each binds the settings it recognises out of Paragon and ignores
the rest, so there is one list of settings rather than two.
// appsettings.json
"ConnectionStrings": { "Paragon": "Server=.;Database=Paragon;Trusted_Connection=True" },
"Paragon": {
"RootPath": "wwwroot/ParagonTemplates", // the engine reads it, the designer writes it
"MountPath": "/paragon", // where the designer sits in your URL space
"AuthorizationPolicy": "ParagonAuthors" // your own policy; without one, any authenticated user
}
// appsettings.Development.json — and nowhere else
"Paragon": { "Enabled": true }
Enabled is off unless you turn it on, so nothing is mounted until that line exists. When the
designer is not mounted the startup log says which of the three reasons it was — not enabled,
Production, or a licence past the day it expired.
MapParagonDesigner() maps Razor Pages and controllers for you. An application that already calls
MapRazorPages() and MapControllers() does not need it.
The database. SQL Server, for what the designer catalogues. Its tables live in a Paragon
schema of their own, so they can share a database you already have. Run
scripts/paragon-schema.sql from the package — it is idempotent — or set MigrateOnStartup where
the application's login is allowed to change the schema at startup.
Authorization is yours. Name a policy in AuthorizationPolicy and it is applied to the
designer's pages and nothing else. With none named an authenticated user is still required, which is
the fail-closed default; AllowAnonymous is the way out of it for a developer running with no
authentication configured at all. Audit fields come from the claims your application already issues
— set IdClaims and DisplayNameClaims if your provider names them differently.
A Content-Security-Policy of your own must leave the designer out. The designer sends its own
policy on its pages — the code editor needs inline styles and a blob: worker, and the preview runs
the report's scripts. A browser enforces every policy a page receives, so a strict one your
application adds everywhere (script-src 'self'; style-src 'self') wins: the editor loads blank and
the preview never draws, with the console full of CSP errors. The designer cannot take your header
off — a header your server adds is added after the application answers — so leave it off the
designer's path where you set it:
<!-- IIS, web.config: your policy for the application, none below MountPath -->
<location path="paragon">
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="Content-Security-Policy" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location>
Keep the application's own settings directly under <configuration>. Under IIS Express, settings
wrapped in <location path="." inheritInChildApplications="false"> stop applying below a path that
has a <location> of its own, and every designer page becomes IIS's own 404. Behind a reverse proxy
the same rule applies: set the header on every path except MountPath. Your policy stays exactly
as strict everywhere else, and the designer only ever runs in Development.
Then start the application and browse to /paragon, or wherever MountPath put it. That is
where Authoring a report begins.
The rendering browser
Rendering uses Paragon's own pinned build of headless Chromium - never an installed Chrome, so the server needs no Chrome at all. There is nothing to set up:
- Building downloads it once per machine (about 100 MB), into a Paragon cache in your user profile. Every later build finds it there. Running the application from the IDE uses that copy.
- Publishing copies it into
paragon-browserbeside the application. The server needs no browser installed, no internet and no environment variable. - Publishing for another operating system carries that system's browser:
dotnet publish -r linux-x64on Windows bundles the Linux build. A publish without-rcarries the build machine's own, so give-rwhenever the server runs a different system. - If it is missing anyway, the first render downloads it. Set
DownloadBrowserIfMissing = falseon a server that must never reach the internet, and a missing browser fails at once instead.
The Chromium version is pinned deliberately: it decides where pages break, so an unreviewed upgrade
changes your documents. ParagonBundleBrowser=false in the project file turns the bundling off,
for a deployment that provides the browser some other way.
On Linux, Chromium needs the system's own libraries, which no publish can carry. On Ubuntu 24.04
- the base of the .NET 10 images - that is:
RUN apt-get update && apt-get install -y --no-install-recommends libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 fonts-liberation fonts-noto-core && rm -rf /var/lib/apt/lists/*
Without them, BrowserUnavailableException says the browser was found but cannot run. The
Playwright base images have them already.
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. That is the engine — what you deploy. The designer of step 6 is the half that has a database, and it is not there in production.
- 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 |
|---|---|
ParagonLicenseException |
No licence, or one that has expired past its grace window. The startup log says which. |
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. |
BrowserUnavailableException |
Chromium could not be started. The message names both ways to install it; the browser's own error is the inner exception. |
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. |
ReportDataException |
The data you passed is not valid JSON. The message carries the parser's own reason and position, and the JsonException is the inner exception. Nothing about the template is wrong. |
TemplateBindingException |
The template calls a helper that does not exist, and it names it. An unknown path renders empty by design; an unknown helper name fails the report. See Template helper reference. |
ReportRenderTimeoutException |
The template never finished — usually a chart that never signalled readiness. See Waiting for several asynchronous things. |
EmptyWorkbookException |
An Excel report whose pages hold no <table>. A workbook is its tables, so it fails here rather than returning a file that opens on an empty grid. See Excel reports. |
| 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
- Authoring a report — writing a report, once the designer is mounted
- Report template format — schema 1.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