Print forms
If you're working in a scaffolded configuration with Claude Code, prefer the create-print-form
skill (in your repo's .claude/skills/) over hand-rolling this. This page is the conceptual
background and the shared vocabulary; Document print forms and
Report export cover the two places a print form is actually wired.
A print form is a plain, attribute-decorated Dto. A compile-time generator turns it into a PDF renderer and a CSV renderer, so there is no hand-written export code — you never touch a PDF library, a CSV writer, or a column loop. You declare what the page looks like with attributes, fill an instance with data, and hand it to one service.
Two flavors exist, and they differ only in where the form comes from and who calls the renderer:
| Flavor | The [PrintForm] class is… | Rendered by | Format |
|---|---|---|---|
| Document | A separate Dto built from a saved document | The document Behavior's OnPrintAsync | PDF by default, opens in a new tab |
| Report | The TData half of the report's result envelope | The report Behavior's Export | CSV or PDF, chosen at export time |
What the engine gives you
You don't write or register any of these:
Kandra.Attributes.Printable— the attribute vocabulary below.Kandra.Printing— the rendering runtime (PdfPrintRenderTarget,CsvPrintRenderTarget,PrintValueFormatter). You never call these directly.- Generated renderers — for every
[PrintForm]class, aTypeNamePdfRendererand aTypeNameCsvRendererare emitted into yourAcme.Application/Generated.Net/. Inspect them there if the output looks wrong; never hand-edit them. - Automatic registration — a generated
AddGenerated<Config>PrintRenderers()call registers both renderers for every discovered class under the DI key"default". A new[PrintForm]class needs zero extra DI; it is picked up on the next build. IPrintRendererResolver— the one service you actually call. Your Behavior receives it as a parameter (it is not constructor-injected) and callsrenderer.Render(form, localizer, timeZone, format, layoutTag ?? "default"), which returns aPrintRenderOutput(bytes, content type, file extension).
The attribute vocabulary
Styles and sections are declared once, at class level, each with a Key; properties
reference them by key — the same "declare once, reference by key" shape as [FormTab]/[TabRef].
No attribute inherits from another. Everything lives directly on the Dto class in its normal
file; don't split a print form into a separate partial file.
[PrintForm] // marker — discovery does nothing without it
[PrintTitle("StockBalance")] // localized title (a resx key by default)
[Page(Size = PageSize.A4, Orientation = PageOrientation.Landscape, MarginsMm = new[] { 15, 10, 15, 10 })]
[PrintStyle("Number", Align = TextAlign.Right)] // named style, referenced by StyleKey below
[PrintSection("Header", Columns = 2)] // flow section: N label/value pairs per row
public sealed class StockBalanceDataDto
{
[PrintSectionRef("Header")] [Caption("DateAsOf")] [Format("d")]
public DateTime? DateAsOf { get; set; }
[Required] [PrintTable]
public required ICollection<StockBalanceLineDto> Lines { get; set; }
}
public sealed class StockBalanceLineDto
{
[Order(1)] [PrintColumn] [Caption("Item")]
public string ItemName { get; set; } = string.Empty;
[Order(2)] [PrintColumn(StyleKey = "Number")] [Caption("Quantity")] [Format("N3")] [Totals(Aggregate.Sum)]
public decimal Quantity { get; set; }
}
| Scope | Attribute | What it does |
|---|---|---|
| Class | [PrintForm] | Marker. Required for discovery. |
| Class | [PrintTitle(key)] | The document title. A resx key by default, same rule as [Caption]. |
| Class | [Page(Size, Orientation, MarginsMm)] | Page setup. Defaults to A4, portrait, no margins if omitted. |
| Class, repeatable | [PrintStyle(key)] | A named style: Align, FontSize, Bold, Italic, Color, BackgroundColor, Borders. Referenced by StyleKey; never stacked directly on a property. |
| Class, repeatable | [PrintSection(key)] | A flow section — an N-column label/value grid (Columns, optional StyleKey). |
| Class, repeatable | [FixedSection(key)] | An always-anchored rectangle for legally mandated layouts. Validated by the generator but has no live consumer yet — treat as unproven. |
| Property | [PrintSectionRef(key)] | Puts a scalar property into a section by key. |
| Property (collection) | [PrintTable] | Marks the collection of lines that becomes the table. |
| Property (line) | [PrintColumn] | One table column. WidthPercent or WidthMm, and StyleKey. |
| Property | [Format(fmt)] | A culture-aware format string: "N2" money, "N3" quantities, "d"/"g" dates. |
| Property | [Totals(Aggregate.…)] | Adds a bold cell to the totals row. |
| Property | [Order(n)] | Table-column order override; declaration order is the default. |
A few rules that bite:
- Column widths must be homogeneous within one table — all
WidthPercentor allWidthMm. Mixing them is a compile-time generator error (KANDRAPRINT004). [Totals]is for numeric columns only.SumandAvgconvert the column todecimal.- A
bool[Format]can be a pair of resx keys.[Format("Submitted|Draft")]on aboolrenders localized text instead of "True"/"False"; both keys must exist in your resx files. A bareboolwith no such format renders through the sharedCommon_Yes/Common_Nokeys, and anenumrenders through its own[EnumCaption]. - Discovery metadata goes on the form Dto, not the
[PrintForm]class.[SupportedPrintForm(typeof(...))],[SupportedExportFormats(...)]and[SupportedLayoutTag]sit on the[KandraDocumentForm]/[KandraReportForm]class.[SupportedPrintForm]and[SupportedExportFormats]are read by the UI generator (they decide whether a Print button or which export buttons appear);[SupportedLayoutTag]is purely declarative today. Only add a layout tag once a hand-registered alternate renderer exists to back it.
Header and footer placement
There is no header/footer attribute. A [PrintForm] class renders its properties in
declaration order, and that includes properties inherited from a base class (base-most first).
A [PrintSectionRef] property is header if it is declared before the [PrintTable]
property and footer if it is declared after. Consecutive properties that name the same
section key form one box.
Shared header fields
Nearly every print form wants "generated at", "generated by", and — for documents — a Submitted/Draft marker. Rather than redeclare them per form, Kandra's reference configuration factors them into two small base classes:
public abstract class PrintHeaderData
{
[PrintSectionRef("Header")] [Caption("GeneratedAt")] [Format("g")]
public DateTime GeneratedAt { get; set; }
[PrintSectionRef("Header")] [Caption("GeneratedBy")]
public string? GeneratedByUserName { get; set; }
}
public abstract class DocumentPrintHeaderData : PrintHeaderData
{
[PrintSectionRef("Header")] [Caption("Submitted")] [Format("Submitted|Draft")]
public bool IsSubmitted { get; set; }
}
PrintHeaderData and DocumentPrintHeaderData live in the reference configuration's Forms
project (KandraWms.Forms.Common); the engine knows nothing about them. A freshly scaffolded
dotnet new kandra-config repo does not contain them, even though the shipped
create-print-form and create-report skills refer to them. Either copy the two classes above into
your own Acme.Forms/Common/ (they work because the generator walks the base-type chain, and
each subclass declares its own [PrintSection("Header", ...)]), or declare the fields directly on
each form, as the Creating a report page does.
The "generated by" value is the same story: the reference configuration has a small internal
PrintHeaderUser.TryGetNameAsync(lookup, authorizationChecker, ct) helper that returns null
instead of throwing when the caller has no resolvable user (a bare API key, or a deleted user),
because this is cosmetic header metadata and should never fail the whole export. If you copy the
base classes, copy that helper too.
Fill the header in the Behavior with GeneratedAt = DateTime.UtcNow. The server stores and emits
UTC everywhere; the client's time zone reaches the renderer as the timeZone parameter, and the
value is converted when the form is rendered.
Styling and totals
A [PrintStyle] is applied by name from a section (StyleKey = "FooterNote") or a column
([PrintColumn(StyleKey = "Number")]), which is how a numeric column gets right-aligned. A
[Totals] cell renders as a bold last row of the table; put it on every column you want summed
(Sum, Count or Avg).
Gotchas common to both flavors
- CSV renders the table only. The CSV renderer writes a caption row and the data rows and
ignores everything else: the title, every flow and fixed section (so header and footer fields,
including resolved filter names) and the
[Totals]row all exist in PDF output only. That is by design, not a bug. - Give every
DateTimecolumn a[Format]. The renderer converts UTC to the user's local time zone regardless, but a[Format]-lessDateTimefalls back to a default string. (An early version skipped the conversion entirely for a[Format]-less column; that is fixed, but[Format("g")]is still the convention for a full timestamp.) - Resolve names, don't print Guids. For an id-only field, look the dictionary up through
ILookupRepository.GetDictionaryAsync<T>(ids, ct), which returnsCodeNameRefrecords the formatter already knows how to render as a joined "Code - Name" list. A hierarchical dictionary must be looked up by its root type (ItemNode, notItem) — a leaf type fails at request time with "No service for type", not at compile time. Details on the document page. - Every
[Caption]and[PrintTitle]key needs a resx entry in all three locale files. A missing key renders the raw key text rather than failing. - Never hand-edit the generated renderers. If output looks wrong, open the generated
PdfRendererunderAcme.Application/Generated.Net/to see what was actually emitted, then fix the attributes.
Verifying
dotnet build— open the generated PDF and CSV renderers and confirm every header field and column you expect is emitted, in order, with the right[Format].- Run the app and export: CSV should contain only the table columns; PDF should show the title,
header section, the table (with repeating headers on a long result), a bold totals row if
any column has
[Totals], and the footer section. - Check the server log for exceptions, not just a 200 response — a lookup mistake throws at request time.
See also
- Document print forms —
[SupportedPrintForm]andOnPrintAsync. - Report export — the result-envelope split and the Behavior's
Export. - Creating a document and Creating a report — where each flavor first appears in context.
- Localization — the resx-key rules every
[Caption]follows. - Reference: Attributes.