Перейти до основного вмісту

Print forms

Use the shipped skill

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:

FlavorThe [PrintForm] class is…Rendered byFormat
DocumentA separate Dto built from a saved documentThe document Behavior's OnPrintAsyncPDF by default, opens in a new tab
ReportThe TData half of the report's result envelopeThe report Behavior's ExportCSV 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, a TypeNamePdfRenderer and a TypeNameCsvRenderer are emitted into your Acme.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 calls renderer.Render(form, localizer, timeZone, format, layoutTag ?? "default"), which returns a PrintRenderOutput (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; }
}
ScopeAttributeWhat 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 WidthPercent or all WidthMm. Mixing them is a compile-time generator error (KANDRAPRINT004).
  • [Totals] is for numeric columns only. Sum and Avg convert the column to decimal.
  • A bool [Format] can be a pair of resx keys. [Format("Submitted|Draft")] on a bool renders localized text instead of "True"/"False"; both keys must exist in your resx files. A bare bool with no such format renders through the shared Common_Yes/Common_No keys, and an enum renders 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.

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; }
}
A convention, not an engine type — and not in the bare template

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 DateTime column a [Format]. The renderer converts UTC to the user's local time zone regardless, but a [Format]-less DateTime falls 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 returns CodeNameRef records 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, not Item) — 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 PdfRenderer under Acme.Application/Generated.Net/ to see what was actually emitted, then fix the attributes.

Verifying

  1. 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].
  2. 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.
  3. Check the server log for exceptions, not just a 200 response — a lookup mistake throws at request time.

See also