PdfFeature owns the published Typst templates in App_Data/pdf that your App renders PDFs from - returned by an API, attached to an email or written to a stream from a background job.
Here's the end state: an ordinary ServiceStack API returning a real invoice, with no LLM anywhere near it.
public class InvoiceServices(IPdfRenderer pdf) : Service
{
public async Task<object> Any(GetOrderInvoice request)
{
var order = await Db.LoadSingleByIdAsync<Order>(request.Id);
return await pdf.PdfResultAsync(MapToInvoice(order), $"Invoice-{order.InvoiceNo}.pdf");
}
}
Two plugins, one boundary​
| Capability | Plugin | Runtime dependencies |
|---|---|---|
| AI-assisted authoring and live preview | ChatFeature PDF extension |
Typst + an AI provider |
| Published template management and rendering | PdfFeature |
Typst |
Rendering has no dependency on ChatFeature - only publishing does, since that's what reads the designer's folder. An organization can design documents in development with PDF Studio, then deploy only PdfFeature, Typst and the published artifacts.
services.AddPlugin(new PdfFeature());
Configuration​
| Property | Default | Description |
|---|---|---|
PdfPath |
~/App_Data/pdf |
Where published templates live |
TypstPath |
$TYPST_PATH → PATH |
Path to the typst CLI |
RenderTimeout |
30s |
How long a single compile may run |
PreviewTimeout |
60s |
Longer budget for rasterizing a preview |
PreviewPpi |
96 |
Resolution of {name}.preview.png thumbnails |
MaxConcurrentRenders |
ProcessorCount |
Concurrent typst processes |
MaxDataBytes |
64 KB |
Largest data JSON a render accepts |
ValidateOnPublish |
true |
Validate example/fixture data + model contracts before publishing |
ChatRoutePrefix |
/chat |
Where the Chat UI is mounted, for the Admin UI's Edit link |
ModelsPath |
resolved | Where the pdf AppTask writes generated models |
ModelsNamespace |
resolved | Namespace generated models are emitted into |
Renderer |
PdfRenderer |
Replaceable IPdfRenderer implementation |
PdfCodeGen |
null |
Code generation config |
IsAvailable is false when typst isn't installed - templates can still be listed and unpublished, but not rendered.
ModelsPath and ModelsNamespace are resolved automatically to a Pdf/ subfolder of the App's ServiceModel, covering both layouts ServiceStack Apps use: a ServiceModel folder in the host project, or the sibling MyApp.ServiceModel project the templates create.
Publish is a controlled promotion boundary​
Design work stays private until an administrator chooses Publish, which does substantially more than copy files:
- Follows JSON data, Typst includes, images, assets and versioned libraries
- Flattens folder-based authoring into a self-contained runtime artifact set
- Rewrites local references for the flattened result
- Validates example data and named fixtures against
.ui.json - Exercises C# model generation
- Checks statically visible data paths
- Compiles every fixture through the actual flattened Typst template
- Generates the gallery preview
- Records publisher and source metadata
- Prevents silent takeover of a name published by another user
- Rolls back the publish if validation or compilation fails
- Saves every successful publish as an immutable revision
This turns publishing from "copy whatever currently works on my machine" into a repeatable release step.
Contract fixtures​
One sample invoice cannot represent every production invoice. Add named fixtures beside the template:
At publish time each fixture is checked against the JSON Schema and rendered with the flattened template. A payload can be structurally valid yet still block publishing if long text, an empty collection or international characters trigger a Typst failure.
The built-in validator covers the schema features PDF Studio generates: types, required members, object properties, arrays, enums, numeric and string bounds, patterns, date formats, local references and schema composition.
services.AddPlugin(new PdfFeature { ValidateOnPublish = false }); // not recommended
Immutable history and rollback​
Every successful publish creates a revision:
Each revision holds the complete published artifact set, its preview and publishing metadata. Restoring a revision never edits or deletes history - it creates a new revision recording which version was restored, so rollback is itself auditable and reversible.
Unpublishing removes the live template but retains its history, giving a lightweight, database-free release history that backs up with the rest of App_Data/pdf.
Admin PDF​
/admin-ui/pdf is the Admin-only gallery of templates production can currently render, with publish-time thumbnails, search and sorting.
Selecting a template opens a two-pane workspace with editable data on one side and the real PDF rendered through the production renderer on the other:
| Tab | Purpose |
|---|---|
| Form | Schema-generated controls for testing without writing JSON |
| Data | Raw editable JSON for pasting real payloads |
| Code | Generated models and usage examples |
The preview uses pdf.js with live rendering, zoom, fit, page count and download.
Edit opens the published document back in PDF Studio - copying the published files into the current administrator's workspace first if another user created it. Unpublish removes the live runtime files whilst preserving revision history.
Admin PDF is the handoff between document authors and application developers, answering three questions with the real deployed artifact:
- What templates can production render?
- Does this template render with this data?
- What typed code should the App use?
Admin APIs​
Every Admin PDF API requires the Admin role:
| API | Purpose |
|---|---|
AdminPdfTemplates |
List published templates |
AdminGetPdfTemplate |
One template's files, data and schema |
AdminRenderPdfTemplate |
Render with supplied data |
AdminPdfTemplatePreview |
Publish-time thumbnail |
AdminPublishPdfTemplate |
Promote a Studio template |
AdminEditPdfTemplate |
Copy a published template back into a workspace |
AdminDeletePdfTemplate |
Unpublish |
AdminPdfTemplateVersions |
Revision history |
AdminRollbackPdfTemplate |
Restore a revision |
AdminPdfTemplateTypes |
Generate the typed model source |
Generate typed C# contracts​
The .ui.json schema generates strongly typed C# models - the same source dotnet run --AppTasks=pdf writes, so what you copy from the browser matches what lands in your project.
public class LineItem
{
[JsonPropertyName("description")]
public string Description { get; set; } = null!;
[JsonPropertyName("qty")]
public int Qty { get; set; }
[JsonPropertyName("rate")]
public decimal Rate { get; set; }
}
[Pdf("invoice")]
public class Invoice
{
[JsonPropertyName("items")]
public List<LineItem> Items { get; set; } = new();
}
[Pdf("invoice")] binds the root model to its published template, so the template name lives on the model rather than being repeated as a magic string at every call site:
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class PdfAttribute(string template) : AttributeBase
{
public string Template { get; set; } // published name, without .typ
public string? FileName { get; set; } // download name, default "{Template}.pdf"
}
Dates, UUIDs, decimals, enums, required members and documentation are inferred from the schema rather than guessed from sample JSON.
The pdf AppTask​
services.AddPlugin(new PdfFeature {
PdfCodeGen = new() {
Namespace = "MyApp.ServiceModel.Pdf",
OutputPath = Path.Combine(contentRootPath, "../MyApp.ServiceModel/Pdf"),
// Include = ["invoice"],
// Exclude = ["statement"],
}
});
AppTasks.Register("pdf", _ => appHost.GetPlugin<PdfFeature>().GeneratePdfs());
Regenerate after publishing template changes:
dotnet run --AppTasks=pdf
PdfCodeGenConfig |
Description |
|---|---|
Namespace |
Namespace to emit into. Defaults to the App's ServiceModel namespace + .Pdf |
OutputPath |
Folder to write to, created if missing |
Include |
Only these templates. Empty generates all |
Exclude |
Templates to leave alone |
ResolveFileName |
Names the file a template generates into. Default "invoice" → Invoice.cs |
PreserveModified |
A generated file that's been edited is skipped rather than overwritten |
Generated files contain content hashes, so if a developer adopts and edits one, later generation skips it instead of destroying their work.
INFO
Nothing is ever written outside GeneratePdfs() - code generation is an explicit task the App runs, in the same spirit as OrmLite Migrations, not something the server does behind your back.
Rendering from an API​
[Route("/orders/{Id}/invoice")]
public class GetOrderInvoice : IGet, IReturn<byte[]>
{
public int Id { get; set; }
}
public class InvoiceServices(IPdfRenderer pdf) : Service
{
public async Task<object> Any(GetOrderInvoice request)
{
var order = await Db.LoadSingleByIdAsync<Order>(request.Id);
var invoice = new Invoice {
InvoiceValue = new InvoiceDetails {
Number = order.InvoiceNo,
Date = order.OrderDate.ToString("d MMMM yyyy"),
Due = order.DueDate,
Currency = "$",
},
Items = order.Details.Map(x => new LineItem {
Description = x.ProductName,
Qty = x.Quantity,
Rate = x.UnitPrice,
}),
TaxRate = 0.10m,
};
return await pdf.PdfResultAsync(invoice, $"Invoice-{order.InvoiceNo}.pdf");
}
}
PdfResultAsync returns an HttpResult with application/pdf and the correct content-disposition filename. Pass inline: true to display it in the browser instead of downloading.
IPdfRenderer​
public interface IPdfRenderer
{
bool IsAvailable { get; }
List<string> GetTemplateNames();
string ResolvePath(string name, string ext = ".typ", bool mustExist = true);
Task<byte[]> RenderAsync(string name, string? dataJson = null,
PdfRenderOptions? options = null, CancellationToken token = default);
Task RenderToStreamAsync(string name, Stream output, string? dataJson = null,
PdfRenderOptions? options = null, CancellationToken token = default);
Task<byte[]> RenderPngAsync(string name, string? dataJson = null, int page = 1,
int? ppi = null, CancellationToken token = default);
}
Typed extension methods work from a [Pdf]-annotated model:
byte[] bytes = await pdf.RenderPdfAsync(invoice);
await pdf.RenderPdfAsync(invoice, outputStream);
HttpResult result = await pdf.PdfResultAsync(invoice, "Invoice-1001.pdf");
Render options​
PdfRenderOptions is passed to Typst as sys.inputs.options, letting templates and versioned libraries agree on their own rendering context without expanding the renderer API:
var bytes = await pdf.RenderPdfAsync(invoice, new PdfRenderOptions {
Language = "de",
Region = "DE",
});
How data reaches the template​
Data rides Typst's --input data=<json>, which the shared library's load-data() reads before falling back to the template's .json sidecar. Nothing is written to disk, so concurrent renders of the same template can't clobber each other - which is also why MaxDataBytes stays conservative at 64 KB: argv is capped well below 128 KB once the environment is counted.
Errors​
try
{
return await pdf.PdfResultAsync(invoice);
}
catch (PdfRenderException e)
{
Log.LogError("typst exit {Code}: {Diagnostics}", e.ExitCode, e.Diagnostics);
throw;
}
Diagnostics carries raw typst stderr, so an author can be shown the actual compile errors.
Rendering from background jobs​
[Worker("smtp")]
public class SendInvoiceEmailCommand(
IPdfRenderer pdf,
SmtpConfig config,
IDbConnectionFactory dbFactory)
: AsyncCommand<SendInvoiceEmail>
{
protected override async Task RunAsync(SendInvoiceEmail request, CancellationToken token)
{
using var db = await dbFactory.OpenAsync(token: token);
var order = await db.LoadSingleByIdAsync<Order>(request.OrderId, token: token);
var invoice = MapToInvoice(order);
var bytes = await pdf.RenderPdfAsync(invoice, token);
// Attach bytes to your email message and send it...
}
}
Queue an identifier, not rendered PDF bytes - the worker loads current data and renders inside the job, keeping persisted job messages small and retryable. This pattern suits scheduled statements, order confirmations, certificates and reports.
Deterministic by construction​
At runtime:
- The
[Pdf]model selects the template. - The model serializes to its JSON contract.
IPdfRendererinvokes the published Typst template.- No LLM is called.
- No personal Studio workspace is read.
- Only the live files in
App_Data/pdfare used.
Production controls​
PDF rendering starts external Typst processes, so PdfFeature includes practical operational limits: render and preview timeouts, maximum concurrent renders, maximum data payload size, a restricted Typst root directory, flat validated template names, Admin role requirements on every Admin PDF API, per-user path-checked Studio workspaces and publish-time validation on by default.
INFO
Pin the Typst version and deploy the same fonts used during validation. Put application fonts in App_Data/pdf/fonts and back up live artifacts, .published.json and .versions together.
Typst's root restriction limits document file access but is not an operating-system sandbox. Organizations compiling untrusted templates should isolate compilation in an appropriate container or worker.
Scope​
The built-in feature generates PDFs from Typst templates: authoring, AI editing, compilation, publishing, revisions, schema validation, code generation, PDF/PNG rendering and delivery.
It is not a general PDF manipulation library. Merging, splitting, encryption, digital signatures, PDF/A, AcroForm filling, OCR and structural import belong in a dedicated PDF library or a custom IPdfRenderer pipeline - which you can substitute wholesale:
services.AddPlugin(new PdfFeature {
Renderer = new MyPdfRenderer(),
});