📄 Print forms
Updated
A print form is the MetaPrintForm entity (Name + Caption), a data script,
and — for ZuloReport — LayoutJson. Short recipe: Print a form.
ZuloReport does not fill from one SlimTable. ZuloReportForm.PrintAsync writes a catalog of cells and tables,
and the layout binds to those names. Html and Xlsx still use
PrintFormBase.GetDataTemplate / GetDataAsync (SlimTable / SlimRow). The list is
/printforms (the Reporting ribbon), the Print forms block on a document
subtype's card (the Subtypes tab, above the commands) and the Print forms tab
on a dictionary. The list shows Caption (falling back to Name).
The workspace: Documents/<Doc>/PrintForms/<Name>.printform.json or
Dictionaries/<Dict>/PrintForms/<Name>.printform.json (the form + its bindings
LayoutJson), with the data script alongside (*.script.json+*.cs). Without those files the form lives in the database only and is lost when the model is shipped or imported.
There are three engines. ZuloReport (Zr) is the canonical name for the
banded JSON layout, HTML renderer, and visual designer (Tool Box, canvas,
property window, Preview). Zr is the only accepted engine value for this
layout. Html and Xlsx keep their existing behavior.
LayoutJson uses control names such as ZRLabel and ZRBarCode directly.
The parser accepts only ZR* controls. Report scripts inherit from
ZuloReport and use ZR* control names. The Zr engine name is required.
Implementation classes and source files use the Zr* prefix.
The engines#
Engine |
Data | Layout | Rendering |
|---|---|---|---|
Html |
GetDataAsync → a table, otherwise Render |
none | GET /api/documents/{type}/{id}/print?formId= |
Xlsx |
the header + table parts | an Excel resource {Field} / {{Part.Field}} |
GET /api/metadata/print-forms/{id}/render/{documentId} |
Zr |
ZuloReportForm.PrintAsync writes catalog cells and table rows |
LayoutJson |
the same /print?formId= or …/preview/{documentId} |
Creating a form (ZuloReport/Html) immediately creates a PrintForm script and writes
ScriptMetaId. A Zr script subclasses ZuloReportForm and overrides PrintAsync.
Html still subclasses PrintFormBase. The Zr Field List is the layout catalog
(cells / tables); when the catalog is empty it uses the document or dictionary
schema. Html still uses GetDataTemplate, then that same schema.
Inside a print script (PrintAsync or GetDataAsync), context.GetService<T>() resolves exactly as in an event
handler: the script deny-list first, then platform services from DI, then model
service contracts (IPricingService and the like). The data script is compiled
once per source revision, not once per print.
When a Zr script fails to compile, is only a PrintFormBase, or PrintAsync
throws, print does not fall back to GetDataAsync or the raw record. Preview
shows the reason in the yellow strip, including override PrintAsync on ZuloReportForm
when the class is not a ZuloReportForm. Html still fills from GetDataAsync.
A fallback would look successful: the raw record has fields, every catalog
binding would stay empty, and the sheet would print blanks with no error.
The same cut is why a Body section's table and a child ZRTable.table are
exclusive (SectionTableXor). The section binding repeats the whole section
once per catalog row (a row template of labels, no inner grid). The control
binding expands one grid inside a section that prints once. Setting both
reprints every grid on every line.
DocumentTypeMetaId is the data source for a document script (the first selected
document type, or the parent of the first selected subtype, otherwise the stored
value). A form created from a dictionary has DocumentTypeMetaId =
Guid.Empty; such a form can only be created with a Dictionary binding.
Where the Print button shows the form is decided by the
MetaPrintFormBindings (TargetKind = DocumentType / DocumentSubtype /
Dictionary, Scope = Record / List). The right side of the form card contains the
dictionary list and the document tree: a document type is a group header
with no checkboxes, and the Record / List checkboxes belong to subtypes only. A
type list shows the form when List is selected on at least one of its
subtypes. An empty set of bindings = the form is visible on its own document
type (list and card). A dictionary with no explicit binding is not shown. A
new form created from a dictionary's tab selects Record + List on that dictionary
immediately. A new form created from a subtype's card selects Record + List on
that subtype only; an existing form can be bound or unbound, the same way a
command is.
The print dialog (a document list, a document card, a dictionary's list and
card): System printer (window.print()) plus any print-agent printer the
current user may use. Preview stays in the browser. Print on an agent printer
enqueues a PDF of the picked form (see Print agents).
Xlsx downloads. A dictionary:
GET /api/metadata/print-forms/{id}/print?kind=Dictionary&objectId=&recordId=.
Document-list batch printing#
The journal sends its selected recordIds, or the rows currently shown when
nothing is selected. For a ZuloReport or Html form, printOpen.ts requests the normal
single-record HTML for each id, then combines those pages in one browser
document. Each source document begins on a new sheet; duplicate styles and
inline renderer scripts are included once, so ZR page-number controls count the
combined pages. Copies repeats the complete batch.
The batch is browser HTML printing only. Server PDF download, Xlsx rendering, and a print-agent queue job each have a one-record contract; the dialog refuses a multi-record request for those actions instead of silently printing only the first selected row. There is no multi-record PDF/Xlsx payload and no queue job that records a set of source records.
GET /api/metadata/print-forms/available?kind=&id=&scope=&subtypeId= — the
list for the dialog.
GET /print with no formId: the first MetaPrintForm by DisplayOrder. When
there is none, or it is not ZuloReport — the Html script (a subtype outranks a type).
Xlsx with no formId redirects to render.
Dataset#
ZuloReport catalog#
One SlimTable cannot be the fill. A form needs values that print once (the
number, the customer, a total) and several tables that grow on their own (lines,
payments). Putting the header into every row, then pointing one Detail at a
single member, is why a second grid on the same sheet had nowhere to live.
PrintAsync writes the catalog. The designer binds visuals to those names. The
engine repeats after the script returns; the script does not print each line.
Report["Number"] = invoice.ID;
Report.Table("Lines").Add(new { LineNo = 1, Item = "Nail", Qty = 2m });
Report["Number"] is catalog cell Number. Table("Lines").Add appends a row
whose property names are columns. One unbound Body may hold several ZRTable
controls, each bound to its own catalog table. A grid row is Header, Data, or
Footer. Header and Footer bind to catalog cells (or a fixed caption). Data binds
to columns of that table and is cloned once per Add. An empty table prints
Header and Footer only. A ZRTable with no catalog table is a header block:
Header (and Footer) rows bound to cells, no Data row.
There is no section-editor modal. Placement, pages, and the section's catalog
table are properties in the right pane. A section is defined by where it
prints, not by a DevExpress band kind: placement (TopMargin,
PageHeader = top of the page, Body = in the flow, PageFooter = bottom of
the page, BottomMargin) and, at the top or bottom of the page, pages
(All, First, Last, FirstAndLast, NotFirst, NotLast). The canvas
header of a section shows its name and that place (Totals [Bottom of page · Last]); kind stays in the JSON only so DevExpress layouts still open.
Add section (canvas band menu and Explorer) offers the places: top of every
page, top of the first page, body, bottom of every page, bottom of the last
page, page numbers. A top or bottom section with multiPageOnly: true (Only
when several pages) is left out when the document fits on one sheet; the
Page numbers preset is that section with a ZRPageInfo (NumberOfTotal,
"1 / 3"). Its height is still reserved while pages are planned, so hiding it
never changes the page count.
A layout written by the assistant (set_print_form_layout) gets every section
exactly as tall as its controls (ZrLayoutFit): the gap under the last
control goes, a control hanging out of its section is taken in. Margins,
vertical columns and sections with no controls keep their height; only
heightMm changes. Layouts generated outside the assistant (a workspace
generator script) should do the same before they are committed.
Html and Xlsx#
The script builds the main SlimTable (its rows are SlimRow): the header
repeats in every row; nested tables (NestedTables) become parts. A document
header stored in a dictionary must be added through AddRow: a plain Add unrolls an
IDictionary into a set of pairs.
public override SlimTable GetDataTemplate()
=> new SlimTable(new { Firm = "", Qty = 0L });
public override async Task<SlimTable> GetDataAsync(PrintFormContext context)
{
var table = new SlimTable("Report");
table.Add(new { Firm = "ACME", Qty = 2L });
return table;
}
With no script, the source is the same one Xlsx FillSheet uses: the document header
- table parts. For a dictionary it is the record's fields (
header) andparts.Records(the same fields, for a list); the dictionary's lists (MetaDictionaryLists) are separateparts.
Element scripts (the Scripts tab)#
This is not the catalog fill and not GetDataAsync. One C# scriptsSource per layout,
plus a handler name on each element.
{
"scriptsSource": "private void q_BeforePrint(object sender, System.ComponentModel.CancelEventArgs e) {\n var qty = Convert.ToDecimal(GetCurrentColumnValue(\"Qty\"));\n if (qty < 0) e.Cancel = true;\n else ((ZRLabel)sender).ForeColor = \"red\";\n}\n",
"bands": [{
"id": "d", "kind": "Detail", "controls": [
{ "id": "q", "type": "ZRLabel", "binding": "Qty", "scripts": { "beforePrint": "q_BeforePrint" } }
]
}]
}
The engine wraps the source in a temporary class (one field per named control),
compiles it through RuntimeCompiler, and calls every event in the catalog
during the HTML render whenever a method name is assigned to the element. The order on
a control: EvaluateBinding (e.Value) → the summary (SummaryInit / Reset
/ RowChanged / GetResult / Calculated; e.Handled replaces the total) →
TextChanged → BeforePrint → SizeChanged / LocationChanged /
ParentChanged → Draw / HtmlItemCreated → the Preview mouse (PreviewClick
and related events fire while Preview is being drawn, not from a live hit test)
→ emit → AfterPrint → PrintOnPage (the sheet index and count on a page
header/footer; on Detail rows, after the chrome has been laid out). On the
report, before printing: ParametersRequestBeforeShow / ValueChanged /
Submit → BeforePrint → DataSourceDemanded; on every row
DataSourceRowChanged; after the body FillEmptySpace; on every section BandHeightChangedandPrintProgress. In a script ZRLabel/ZRCheckBox/ZRTableCell/ … are script-facing types backed byZrScriptControl (Text, Visible, ForeColor, BackColor, Bold). The composer emits only ZR control names. Colours are CSS (red, #c00), not System.Drawing`.
The lightning tab and the Scripts tab use one catalog: picking an event
writes private void Name_Event(...) into scriptsSource and stores the name on
scripts.beforePrint and the like. Check source compiles the wrapper
(POST …/check-scripts). That same pass is part of compiling the model
(POST /api/metadata/models/compile and …/models/{id}/compile): a broken
scriptsSource fails the model exactly as a broken event handler does.
A compilation error does not bring down the preview: the form is drawn without the scripts, with a yellow strip on top carrying the error text.
Configure sections from code#
Report scripts can change the live band collection in the report-level
BeforePrint, before detail rows are collected and pages are planned. Use
FindBand, FindControl, FindTableRow, and FindTableCell with an element id
or name. Set the returned type's properties directly: bands have Visible,
Style, Back, Fore, FontFamily, FontSizePt, Borders, HeightMm, and
WidthMm; controls have Text, Visible, WMm, HMm, and formatting
properties; table rows have HeightMm; table cells have Text, Back,
Borders, and alignment properties. A null band WidthMm keeps the default
full printable width; a positive value narrows the section. Style accepts a
named style id or name from the layout. MoveBand changes the order within the
same renderer band rank and group level; it cannot move a Detail before a
ReportHeader or a ReportFooter before Detail.
using ZuloOne.Printing;
private void Report_BeforePrint(object sender, System.EventArgs e)
{
var appendix = FindBand("Appendix");
if (appendix != null)
{
appendix.Visible = Header.TryGetValue("IncludeAppendix", out var include)
&& include is true;
appendix.Style = "Muted";
appendix.StylePriority = new ZrStylePriority
{
UseBackColor = false,
UseForeColor = false,
UseFont = false,
UseBorders = false,
};
appendix.WidthMm = 160;
}
var total = FindControl("TotalLabel");
if (total != null)
{
var showNet = Header.TryGetValue("ShowNet", out var raw) && raw is true;
total.Text = showNet ? "Net total" : "Total";
total.WMm = 48;
}
var netCell = FindTableCell("NetCell");
if (netCell != null) netCell.Back = "#e8f4ff";
MoveBand("Totals", 0);
}
The script sees Bands in its current order. The lookup methods match ids and
names case-insensitively. Use MoveBand(idOrName, index) for a stable reorder;
the index is zero-based. FindControl also searches controls nested inside
panels. The renderer still enforces its structural
phases (page/report headers, groups, details, footers), so moving
DetailReport sections relative to one another is supported, but moving one
across those phases is not.
The same report-level script can build a printout dynamically. AddBand accepts
any ZrBand; AddControl accepts any renderer-supported ZrControl and a
parent band or ZRPanel; AddTableRow / AddTableCell add table content.
Missing ids are generated. The matching RemoveBand, RemoveControl,
RemoveTableRow, and RemoveTableCell methods remove by id or name. Removing a
band also removes its sub-bands and group bindings. These changes affect only
the current render; they do not save back to LayoutJson.
var appendix = AddBand(new ZrBand
{
Kind = ZrBandKind.DetailReport,
DataMember = "WarrantyRows",
HeightMm = 10,
});
if (appendix != null)
{
AddControl(appendix.Id, new ZrControl
{
Type = "ZRLabel",
XMm = 0,
YMm = 0,
WMm = 180,
HMm = 8,
Binding = "Text",
});
}
RemoveControl("ConfidentialLabel");
RemoveTableCell("InternalNoteCell");
RemoveBand("InternalAppendix");
{
"header": { "Number": "SI-1", "Customer": "<guid>" },
"parts": { "Lines": [ { "Item": "<guid>", "Qty": 2 } ] },
"display": { "Customer": "ACME GmbH", "Lines.0.Item": "Nail" }
}
display is built by the server (IReferenceDisplay) on the Html and Xlsx auto
dataset only. That path checks display → the Detail row → the header → the
text literal. ZuloReport does not use it: bindings resolve catalog cells and
table columns written by PrintAsync. The format is {0:n2} (invariant).
For ZuloReport the Field List is the layout catalog. These endpoints supply the
document or dictionary schema when that catalog is empty, and they still feed
Html: GET /api/metadata/print-forms/{formId}/schema (GetDataTemplate) or
GET /api/metadata/print-forms/schema/{documentTypeId} (the type) /
GET /api/metadata/print-forms/schema/dictionary/{dictionaryId} (the
dictionary). The list of a dictionary's forms:
GET /api/metadata/print-forms?dictionaryMetaId=.
Layout JSON#
The units are millimetres, snap is 1 mm. Paper: A3, A4, A5, A6, B5, Letter,
Legal, Tabloid, Executive, Statement, receipt tapes 44 / 57 / 58 / 80 / 112 mm
(the roll width; height is the length of one tape page, and margins shrink to
3 mm) and Custom. A receipt has the roll flag enabled; changing the tape height keeps
the receipt type. An unknown type is an empty rectangle (forward
compatibility). The validator rejects only a broken schema (schemaVersion, no
bands, coordinates < 0).
The bands (top to bottom): TopMargin, ReportHeader, PageHeader,
GroupHeaderN…GroupHeader1, (VerticalHeader / VerticalDetail /
VerticalTotal or Detail), DetailReport, GroupFooter1…GroupFooterN,
PageFooter, ReportFooter, BottomMargin. Any section other than the sheet
margins may carry a SubBand (parentBandId) — it prints immediately after its
parent (on Detail, once per row). A detail stack is mandatory: either Detail
or VerticalDetail (the two never coexist — adding a Vertical band removes
Detail, and vice versa). TopMargin / BottomMargin are unique sections, live
in the Band menu and exist on a new report from the start; their height is
margins.top / margins.bottom (drag the band to write the margin; drag
the margin to write the band height). Left and right are still margins.left /
right (grey zones, not bands). In print TopMargin goes first in .zr-head
and BottomMargin last in .zr-foot (on every sheet). A Body section with
table set, and no child ZRTable that has its own table, is a row template:
the engine emits it once per row of that catalog table. An unbound Body prints
once; each ZRTable inside it grows from its own catalog table (Header once,
Data once per row, Footer once). Chrome never repeats from a table. An old
dataMember is read as that catalog table so an existing layout still opens.
In print a Vertical band becomes columns inside .zr-vert (the
section's heightMm is the column width).
Grouping is the groups[] array in LayoutJson (the Grouping and Sorting
panel):
{ "id": "g0", "field": "Store", "sort": "Asc", "grouped": true, "headerBandId": "gh", "footerBandId": "gf" }
grouped: false sorts the Detail rows only, with no bands (a GroupHeader /
GroupFooter left behind is not printed). grouped: true creates a
GroupHeader / GroupFooter pair. groupLevel: 0 is the level closest to Detail
(GroupHeader1). The renderer sorts the rows and repeats the header/footer
whenever the key changes. The rows are the catalog table of the row-template
Detail (a Detail with table and no bound child grid). Its group headers and
footers bind to that table's columns — the first row of the group for a header,
the last for a footer — and print only around those rows, never once on their own.
Sorting is stable, so a group field that is a sort key (0001, 0002…) keeps the
script's order. Example: a price list with Section › Block › Title groups over one
Lines table prints each section, block and title heading once, then its lines. A band's printed height is the designer's heightMm;
CanGrow / CanShrink on text expand or shrink the section to fit its content;
without them, overflowing controls are clipped (overflow:hidden). Keep
together is on by default (KeepTogether = true, and may be left out of the
JSON): a band that does not fit as a whole moves to the next sheet. Clear the checkbox
and the section is cut instead (clipTop plus a top shift), with the tail
on the next sheet. A band taller than the page is cut even with Keep together
on. PrintOnPage and Page-range summaries see the real sheet number, not
1/1. TopMargin / ReportHeader / PageHeader always go in .zr-head (the
sheet header) and PageFooter / ReportFooter / BottomMargin in .zr-foot;
the client moves them there when Core left the section in the body.
Controls that are actually drawn: ZRLabel (ZRLabel in old layouts), ZRCheckBox, ZRRichText
(simple markup such as <b> survives, script/style/iframe are stripped),
ZRPictureBox (smart tag — Image Source + a binding, Image hyperlink + a second
binding, Sizing, Image alignment), ZRTable (every row of rows[], a cell's
width = weight / Σ weights; a cell is clickable — the "Table Cell Tasks" smart
tag: Text, Data binding, Format string, the rules, Can grow / Can shrink,
Multiline, Word wrap; back = #rrggbb fill, borders = sides LRTB — a
0.2 mm outline, and an empty value draws no outline), ZRPanel (children in local coordinates),
ZRPdfContent (a PDF badge; smart tag — Source / Source URL + bindings,
Generate Own Pages, Fit Size to Content; a safe URL through <object>),
ZRPdfSignature (a squiggle + Distinguished name / Reason / Location / Date,
the Signature Options checkboxes), ZRCrossBandBox / ZRCrossBandLine (the
cross-band box and the cross-band line: an overlay in .zr-body on every sheet
the span crosses — the first sheet gets the top and the sides, the middle ones
the sides, the last one the sides and the bottom; VerticalAnchor; they do not
count towards the host band's height and are not drawn inside the section),
ZRCrossTab (the cross-tab: the control's smart tag — the catalog table,
the Data member, the Filter String, the Parameters; the cell's smart tag — the
Field Name, the Summary Type, the Format string, auto width/height, visibility;
a 2×2 grid on the canvas; in print one row/column/measure level plus the grand
totals), ZRCharacterComb, ZRLine, ZRShape (smart tag — Stretch, Fill
color, Line width, Rotation angle, the shape catalog — Rectangle / Ellipse /
arrows / Triangle / Octagon / stars / lines), ZRBarCode (SVG), ZRChart,
ZRSparkline (a line over chart.valueField), ZRGauge, ZRSubreport (a
nested catalog table through sourceBinding / binding / chart.dataMember),
ZRTableOfContents (the report bookmark plus group headers with sheet
numbers), ZRPageInfo (the number / the total / the number out of the total —
against the real .zr-page elements), ZRPageBreak (a new page after the
band).
An embedded picture (data:image/…;base64, over 48 KB of text) is printed at
the pixels its box can hold: Core re-encodes it to cover the box at 200 dpi
(SkiaSharp, cubic resampling) — JPEG 85 when the picture is opaque, PNG when it
has transparency — and keeps the original when the result is not smaller. A
camera photo of 1600 × 1200 px in a 30 × 20 mm box prints as 237 × 178 px. Sizing Clip shows a picture at its own pixel size, so a clipped
picture is never scaled. URLs (https://…, /…) go to the browser as they are.
The designer#
The form's card has three tabs: Main (the name, the engine, the bindings),
Design (the ZR canvas + Preview) and Report scripts (scriptsSource, the
element events in the same code editor as every other script: Check source /
Members / IntelliSense). The Data script button in the card header opens
that script in its own workspace tab — the same place a document's Events
button opens. For ZuloReport the script is ZuloReportForm.PrintAsync (catalog
writes). Html still opens PrintFormBase.GetDataTemplate / GetDataAsync.
When there is no script yet, the button creates one first (for a dictionary —
IDataService.GetByIdAsync, RecordId).
The Data script button on a print-form row (a subtype's or a dictionary's
Print forms list) opens the same editor tab. The lightning bolt in the
property window opens Report scripts.
The lists: /printforms, the block on a subtype's card and the Print forms
tab on a dictionary. With no document type, Preview takes the dictionary records
from the first Dictionary binding.
- On the left the Tool Box — a title, the "Standard Controls" group, an icon
plus a caption. A click picks the tool, a click on the canvas drops the control
and returns to Pointer. Dragging off the tool box does the same. Collapsed, it
is a narrow strip of icons. Sparkline / Subreport / TOC are enabled in the
tool box and print with their own rendering. The children of an
ZRPanelare separate boxes on the canvas: they can be dragged, selected and laid out outside the panel. What prints: Label / Rich Text, Check Box, Character Comb, Picture Box, Table (every row of the layout, seeded with an A/B/C header plus an empty row), a Panel with its children, PDF Content / PDF Signature, Cross-band Box / Cross-band Line, Cross-tab, Line, Shape, Bar Code, Chart / Sparkline / Gauge, Subreport, Table of Contents, Page Info, Page Break. On the canvas and in Preview a bar code is one and the same SVG (GET /api/metadata/print-forms/barcodeandZrBarCodeSvg): the linear ones (Code39/93/128, EAN/UPC, ITF, PostNet…), the matrix ones (QR / GS1 / EPC / Micro QR, Data Matrix, Aztec) with the ISO quiet zone (4 modules for QR) andmeet, PDF417 stacked, Intelligent Mail — 65 four-state bars. GS1 QR puts FNC1 in the first position, not theGSbyte. - The canvas is a physical sheet (
paper+margins). The grey on the left and the right is only the margin from the edges, not a crop: Preview andwindow.print()render the whole sheet (@pagemargin 0, width =paper), with the controls shifted bymargins.leftexactly as on the canvas. The grey zones, the dashes, the triangles on the ruler and the handles on the inner edge of the margin can all be dragged; they writemargins(top and bottom also keep the height ofTopMargin/BottomMarginin step). On the canvas the bands sit flush, with no empty gap between Detail and the footer. Preview and print are one or morepapersheets (210×297for A4): a body that does not fit, apageBreakBefore/After and anZRPageBreakall open the next.zr-page.@pagemargin 0 and controls shifted bymargins.left;.zr-pagealso carries apadding-rightfrommargins.right. The band stretches across that padding to the paper edge (background-clip: content-boxkeeps the band fill on the content box), so a control's right border on the margin is not clipped; the header (TopMargin/ReportHeaderon the first sheet only /PageHeaderon every one) goes in.zr-head, the footer (PageFooteron every one /ReportFooteron the last one only /BottomMargin) in.zr-foot(margin-top:auto) — pinned to the bottom of the sheet. A GroupHeader withrepeatEveryPagerepeats at the top of the group's continuation. Core HTML (a content width +@pagemargins) is rearranged into this geometry on the client. The gear and theReport { Paper: A4 }tag both open Page setup…. The ruler along the top is centimetres across the full width of the sheet, the one down the left side follows the bands: dragging along the scale selects the controls aligned with that range, and a click drops a guide; the grip (☰) on a section's header moves the header and resizes the section from the top. A section strip is a grey full-width header (a triangle + an icon + the name + where it prints, e.g.[Bottom of page · Last],[in the flow, once],[once per row · Lines]/(Level N)). Collapsing hides only the band body; the strip stays. A GroupHeader's icon is red, a GroupFooter's green. There is no section-editor modal: that dialog was a second copy of Explorer, and the two copies drifted. Add and delete are Explorer and the canvas band menu (page header, page footer, another body). Placement, pages, and the section's catalog table are the right pane when the section is selected. Right-clicking the sheet gives the same add tree. A group and a DetailReport can be added again. You can collapse the other bands. Over the printable area there is a square grid of light lines (not dots), eight blue handles, and a smart tag on aZRLabel. Dragging or arrow-keying across a band boundary moves the control into the other band. Right-click a control: z-order, Align to table, Add table row / Remove last table row, Text, the clipboard, Delete, Properties. Dropping a control onto anZRPanelputs it intocontrols[](local x/y). - On the right: the property window — a name|value table. On the report's
Appearance tab: Watermark (
…), a 3×3 Text Alignment, Borders, Paddings (a summary + L/R/T/B), the rules and styles, the colours in a strip, the border width, the font on one line (family + size), then B I U S, Right to left. Behavior on the report: Request parameters, Report print settings (Show print warning margins / Show print status window), Vertical splitting and Horizontal splitting (Exact / Smart), Visibility, Measure units (Millimeters), Export options, Extensions, Show page margins in preview, the scripts (…opens the lightning bolt) and the Script language. Data: Image resources / Data adapter / Calculated fields / catalog cells and tables / XML data path / Tag / Filter / bind-to / Parameters ((Collection)+…). Page: Landscape page orientation, Page height (mm), Print margins (a Top; Left; Right; Bottom summary + ← → ↑ ↓), Paper name, Use default printer settings, Printer, Roll paper, Paper kind, Page width (mm). The sizes are in millimetres. Layout:(Name), Localization, Snap grid step count, Developer options, Show grid, the snapping, Draw watermark, the schema, the Language, the Bookmark, Sections. Favorites is a flat list: Watermark, Style sheet, the catalog, Filter, bind-to, Report print settings, Measure units (Millimeters). A category header is not repeated within one tab. Search still ignores the tab. The Appearance / Behavior / Data / Page / Layout / Favorites tabs plus the lightning bolt all narrow the list down. The property rows areZrPropObjectplus a per-type descendant with its ownspec. Section placement, pages, and the catalog table are rows in this grid; there is no section modal. Paper and margins are the Page rows, not a second dialog. The Properties / Explorer / Fields tabs are along the bottom. Ctrl+click for several controls: edits in the property window and on the font/colour toolbar apply to all of the selected ones (namestays with the last one). On the canvas the same holds for the mouse: dragging a resize grip of any selected control resizes every selected control from the same edge by the same distance (pull the left edge of one, the left edges of all move, each right edge stays), and dragging one of them moves the whole selection. A plain click on a selected control, without dragging, leaves only that control selected. Typing in the smart-tag card (Text, Data binding, or a dropdown) does not move the control and does not close the card: a click on a table cell selects that cell; drag the table from its edge. Delete / Backspace on a selected cell clears the cell and leaves the grid. Escape closes the card. Arrow keys while a field or list is focused stay with the field. Ctrl+B bolds the selected cell when a cell is active. - A two-row toolbar: save / clipboard / undo, font and colour (a dropdown of Custom / Web / System plus "More colors…"), text alignment; the grid, object alignment, H/V spacing, same size, z-order, zoom. Ctrl+click for several controls; Ctrl+C/X/V. The arrow keys nudge the selected controls by 1 mm, Shift+arrow by 5 mm (inside property fields the arrows still belong to the input).
- The Grouping and Sorting panel: the field, Asc/Desc, Show header / Show footer, Move up / Move down; right-click a row to add a subsection or delete it. Its height is dragged from the top edge (a splitter), and the canvas shrinks.
- Right-click a group band (the gutter, the canvas, the explorer): Add subsection, move it, Delete group, Properties
- The Field List is the catalog: cells, then tables and their columns, then
calculated fields and parameters. Drag a cell to bind a label; drag a table
onto a Body to drop a
ZRTable(Header + Data); drag a column onto a Data cell. It is not the columns of aSlimTable. Calculated fields stay a formula layer on those names (right-click: Add / Edit / Delete all). Double-clicking a calculated field opens the Expression Editor. - The Report scripts tab: one C#
scriptsSourceper form. The editor isCodeEditorWorkbench(checking throughPOST …/check-scripts, completion throughPOST …/complete-scriptsviaZrScriptComposer). Across the top: the object (Report Report,tableRow1 Table row,tableCell1 Table Cell, …) and the eventCaption (EventName), its own set for every type: the report gets PrintProgress / DataSourceDemanded / ParametersRequest* / BeforePrint…, a running header/footer and the sheet margins (PageHeader/PageFooter/TopMargin/BottomMargin) get BeforePrint and AfterPrint only, a table and a table row get PreviewMouseMove / PreviewClick / EvaluateBinding / BeforePrint… (no summary, no TextChanged, no HtmlItemCreated), a table cell gets SummaryRowChanged / SummaryCalculated / TextChanged / EvaluateBinding / HtmlItemCreated… (no SummaryInit), a label gets EvaluateBinding / PreviewMouseUp / the summaries… Picking an event writesprivate void Name_Event(...)at once and stores the method name onscripts.beforePrintand similar properties. Check source compiles the wrapper (POST …/check-scripts). VB and JScript are not compiled. In the code:GetCurrentColumnValue("Qty"),((ZRLabel)sender).ForeColor = "red",e.Cancel = true,e.ValueinsideEvaluateBinding. The language in the property window is C# only. - The report Explorer: the sections, the panel→children and table→row→cell nesting, and search. Add and delete sections here. There is no separate section editor; the data model is the catalog.
- The report's smart tag (the gear on the sheet): the title is the report's
name (
displayName/name, otherwise "Report"), not "Tasks". The actions: a row Filter ([Qty] > 0), Measure units (Millimeters), Watermark, Page color. It does not edit sections. A Body section's gear sets the catalog table. On a section the title isGroupHeader1/Detail/ the band's name. Clicking theReport { Paper: A4 }tag selects the report (the sheet's property window). - The smart tag (the gear) on the field controls:
ZRLabel/ Check Box / Rich Text / Character Comb — Text, Data binding, Format string, Summary;ZRBarCode— the Barcode type from the catalog (1D: Code11/39/93/128, EAN/UPC, ITF-14, PostNet, GS1 DataBar…; 2D: QR / GS1 QR / EPC QR / Micro QR, Aztec, DataMatrix, PDF417), Module, Automatic module, Orientation, Text, Data binding, Format string, Show text;ZRGauge— the View type / View style / View theme, the Actual value and the Target value, the Minimum / Maximum plus a binding for each;ZRChart— the catalog table, the Data member (a part), the Value field / Argument field, the View (Line, Bar, Pie…);ZRSparkline— the same, without the argument field and the view;ZRSubreport— the Data member (a catalog table);ZRTableOfContents— a hint about where the entries come from;ZRCrossTab— the control's gear: the catalog table, the Data member, the Filter String, the Parameters; clicking a cell (Row Area / Column Area / Data Area) switches the smart tag to Cross Tab Cell: the Field Name, the Summary Type, the Format string, Column Auto Width Mode / Row Auto Height Mode, Column Visible / Row Visible; Picture Box — URL / binding / Sizing;ZRPdfContent— Source / Source URL + bindings, Generate Own Pages, Fit Size to Content;ZRPdfSignature— Image Display Mode, Display Document Signature, Show Captions / Show Certificate Name / Show Distinguished Name / Show Location / Show Signature Date / Show Signature Reason;ZRCrossBandBox/ZRCrossBandLine— Vertical binding (None / Top / Bottom / Top and bottom); Page Info — the number type. An orange dot = there is a binding - The property window: five tabs (a brush / a gear / a cylinder / a spanner / a
star) — Appearance / Behavior / Data / Layout / Favorites. The lightning bolt on
the right opens events. On the report there is a fourth tab, Page (a
sheet icon) — Landscape page orientation, Page height (mm), Print margins
(expanded ← → ↑ ↓), Paper name, Use default printer settings, Printer, Roll
paper, Paper kind, Page width (mm). The Layout tab (the spanner) on the
report:
(Name), Localization, Snap grid step count, Developer options (the units), Display name, Show grid, the snap size and Snap mode, Draw watermark, the catalog, the Language, the Bookmark, Sections. Favorites (the star) on the report: Watermark, Style sheet, the catalog, Filter, bind-to, Report print settings, Measure units. Appearance — Watermark, the alignment, Borders, Paddings, the collection of rules and styles, the Page color / Text color / Background color, the Font, Right to left. Behavior — Request parameters, printing and export, the splitting, Visibility, Measure units (Millimeters), Extensions, the Preview page margins, the scripts and the C# language. On Data: Image resources, the Data adapter(None), the Calculated fields, catalog cells and tables, the XML data path, Tag, Filter (…is the Expression Editor), Data member and Parameters. The shared fields (appearance, visibility, size) exist on the report, on a band and on a control; the type-specific ones (the bar code, the Gauge, the Chart,groupField, the paper) appear on their own tab. A band (ReportHeader, GroupHeader, …) has the same Appearance a field does: a 3×3 alignment, Borders, Paddings, the styles and rules, the dash, the colours, the B/I/U/S font, the height. On a label the Appearance tab is the alignment, Borders, Text Trimming, Paddings, the rules, Style Priority, the styles, the dash, the colours (the background is Transparent by default), the width, the Font, the Position, the Size, Right to left. Appearance — a 3×3 alignment (with no duplicate L/C/R/Justify under the grid), Borders (All borders / No borders / individual sides), Paddings All / L / R / T / B, the Border dash style, the width slider, the B/I/U/S font, the colours. Formatting Rules are a collection on the form (formattingRules[], the Condition is that same[Qty] < 0expression); on a control and on a band it isformattingRuleIds. An empty condition is always true: the rule paints on the canvas and in print immediately; a non-empty one only when the expression is true for the row. Styles are named presets plus odd/even; Style Priority decides what is taken from the object and what from the style. The f button on Text / Visible / ForeColor / BackColor opens the Expression Editor. - The status bar: the object + the paper (A4 / Letter) + the zoom. The zoom scales Preview too, on screen only — printing from Preview stays 1:1.
- Preview posts the current JSON to
POST …/preview/{documentId} - Check in the status bar is the form's "compile" (see below). The badge next
to it shows
errors/warningsof the last check; click it to reopen the list.
Checking a form#
The renderer never fails on a bad binding: an unknown field prints an empty cell
(a [Name] in Preview), a formula with a typo evaluates to empty, an event that
names a missing method is skipped. Check finds all of these before paper does.
Each finding is a row in the Form check dialog; a click selects the control,
the table cell or the band it is about.
The canvas shows the same findings in place. The designer re-checks the canvas about a second after every edit (without the trial print) and paints:
- a control or a table cell with an error in a solid red frame, with a warning in a
dashed amber one, plus a small
!in its top-right corner; - a band with a band-level finding (
DetailWithoutDataMember, an orphan band) with a!in its title bar.
Hover the frame or the ! to read the findings. Fixing the cause clears the mark on
the next re-check. Report-level findings (page geometry, scripts, the data script)
have no element to mark: they are in the status-bar badge and the dialog only.
POST /api/metadata/print-forms/{id}/check with { "layoutJson": "…", "recordId": "…" }
(both optional: no layoutJson checks the saved layout; with recordId the
check also prints that record once). The answer is always 200:
{ "ok": false, "errors": 1, "warnings": 0, "dataSource": "DataScript", "pages": 1,
"diagnostics": [ { "severity": "Error", "code": "UnknownField",
"message": "Label 'c-item': [Itme] is not in the header or the rows of 'Report'. Did you mean [Item]?",
"bandId": "detail", "controlId": "c-item", "cellId": null, "property": "binding" } ] }
Zr fields are checked against the layout catalog (cells / tables). When the
catalog is empty, the check uses the document type or the dictionary, plus the
system columns every row carries (MetaId, ID, DocumentDate,
CreatedDateTime, …). It does not call GetDataTemplate on a ZuloReportForm.
Html still checks GetDataTemplate. The designer sends the unsaved canvas and
the newest document of the form's type for the trial run.
| Code | Severity | What it means |
|---|---|---|
UnknownField |
Error | A binding, a table cell, a formula, a summary, a group, a chart or a cross-tab names a field the data does not have. The message suggests the closest name. |
DetailWithoutDataMember |
Error | Detail binds row fields but has no dataMember: it prints once, with empty cells. The message names the part to set. |
RowFieldOutsideDetail |
Error | A header or footer band binds a row field without a summary: it prints empty. |
UnknownDataMember |
Error | dataMember of the report, a band, a chart, a cross-tab or a subreport is not a part of the data. |
ExpressionSyntax |
Error | A formula does not parse, or calls an unknown function (Iff). |
MissingScriptMethod |
Error | An event names a method that scriptsSource does not define; it is skipped silently at run time. |
ReportScripts |
Error | scriptsSource does not compile. |
DataScript / DataScriptRun |
Error | The Zr script does not compile, it is not a ZuloReportForm, or (trial run) PrintAsync throws. Print does not fall back to the raw record. |
RenderFailed |
Error | The trial print threw. |
MissingBand |
Error | A group points to a header/footer band that does not exist. |
DuplicateId |
Error | Two controls share an id. |
NoRoomForRows |
Error | Margins + page header + page footer leave less than 8 mm of a page. |
OutsidePrintableArea |
Warning | A control runs past the right margin or starts at a negative position. |
OutsideBand |
Warning | A control in a fixed-height band (margins, page and report header/footer) is taller than the band; the overflow is clipped. |
BandTallerThanPage |
Warning | Detail is taller than the body of a page; every row is cut across pages. |
NoRoomOnFirstPage |
Warning | Report header and footer leave no rows on page 1. |
MissingFormattingRule / MissingStyle |
Warning | A rule id or a style name that the form does not define. |
OrphanGroupBand / OrphanSubBand |
Warning | A band nothing prints. |
ShadowedField |
Warning | A calculated field has the name of a data field and overwrites it. |
EmptyGroupField / EmptySummary / EmptyName |
Warning | A row with nothing to act on. |
GET /api/metadata/print-forms/check-all checks every saved ZuloReport form on the stand
(no trial run) and returns one result per form.
Multi-page print#
Pages are planned on the server; the browser and the PDF service lay out exactly
the same sheets (.zr-page, one per sheet, fixed height).
TopMargin,PageHeader,PageFooter,BottomMarginprint on every sheet.ReportHeaderprints on the first sheet only,ReportFooteron the last.- The body of a sheet is the paper height minus the per-page bands; the first sheet also loses the report header. The report footer is not reserved on every sheet: when the last rows leave no room for it, it gets a sheet of its own.
- A Detail row (
keepTogether: true, the default) never splits: a row that does not fit starts the next sheet. A band taller than a whole sheet is cut. GroupHeaderwithrepeatEveryPageis repeated at the top of each sheet its group continues on — including the sheet that holds only the group footer. A new group starting at a sheet break does not repeat the previous group's header.- Group sorting keeps the data order inside a group.
pageBreak: "BeforeBand"/"AfterBand"and anZRPageBreakcontrol force a new sheet.
Text that does not fit#
A label grows to fit its text (canGrow is on unless the layout sets
"canGrow": false, as in DevExpress). The page planner estimates the height of
the wrapped text — Segoe UI at the control's point size, wrapped at spaces, plus
padding and borders — and grows the band by that much. Controls that share a top
and a height form a row (a table drawn with labels): the row grows as one, so a
wrapping cell does not leave its neighbours' borders short. A control with
canGrow: false keeps its height and clips the text.
The canvas draws text exactly as print does: the font size is in points
(9 pt = 12 px at 96 DPI, scaled by the zoom), line-height: normal, and a label
without its own padding gets 0 0.5 mm on both canvases.
A gap between a table's header and its rows is empty space inside the bands, not a margin: the column headers end above the PageHeader band's bottom edge, or the Detail cells start below Detail's top edge. Fit band to controls (the band's context menu, or its smart tag) moves the band's controls up to its top edge and ends the band at their bottom. Apply it to both bands and the header touches the first row. New forms are seeded that way.
Expressions (formulas)#
A language of its own, with sugar — [Qty] * [Price],
Iif([Qty]<0,'red','black'), Sum([Amount]). Not the NCalc package, not
eval, and not compiling formulas into C#. The data script and the element
scripts stay C#.
Calculated fields are LayoutJson.calculatedFields[]. On a control they are
expressions.text | visible | fore | back. The aggregates
(Sum/Avg/Count/Min/Max) are computed over the current group
(GroupFooter) or over the whole part (ReportFooter / a calculated field).
A footer summary is a summary object on a ZRLabel in a GroupFooter /
ReportFooter / PageFooter: the Summary function, the Bound field, the
Calculation range (None/Page/Group/Report), the Format string and
ignoreNull. The canvas shows [Sum(Qty)]; Preview does the arithmetic. Page
counts the rows that landed on that sheet. RunningSum is a cumulative total down
the part's rows; Percent is the slice's share of all the rows.
The order at render time: catalog values (ZuloReport) or the Html table → calculated fields → the named style
(odd/even) → binding → expressions.text → summary → the C#
EvaluateBinding → format → visible/fore/back → the formatting rules →
BeforePrint. An error in a formula yields null (the form does not fail). The canvas evaluates no formulas — only Preview does.
The editor catalog is the engine itself: Iif, IsNull, IsNullOrEmpty,
Abs/Ceiling/Floor/Round,
Concat/Contains/StartsWith/EndsWith/Len/Lower/Upper/Trim,
AddDays, the aggregates. Min/Max with two arguments is a scalar, with a
single field it is an aggregate.
{
"calculatedFields": [
{ "id": "cf1", "name": "LineTotal", "expression": "[Qty] * [Price]", "fieldType": "Decimal" }
],
"bands": [{
"kind": "GroupFooter",
"controls": [{
"type": "ZRLabel",
"text": "[Sum(LineTotal)]",
"summary": { "function": "Sum", "field": "LineTotal", "range": "Group", "ignoreNull": true },
"expressions": {
"fore": "Iif([Qty] < 0, 'red', 'black')",
"visible": "[Qty] != 0"
}
}]
}]
}
A gauge (ZRGauge):
{
"type": "ZRGauge",
"wMm": 40,
"hMm": 22,
"gauge": {
"viewType": "Circular",
"viewStyle": "Half",
"viewTheme": "FlatLight",
"valueBinding": "Qty",
"min": "0",
"max": "100"
}
}
A chart (ZRChart):
{
"type": "ZRChart",
"wMm": 60,
"hMm": 30,
"chart": {
"view": "Line",
"dataMember": "Lines",
"valueField": "Qty",
"argumentField": "Name"
}
}
What the engine does not do#
Foreign binary layouts, QuestPDF, print agents / a printer queue, and copies
in the dialog. VB element scripts. Live evaluation of formulas on the canvas.
Cutting a single control through the middle of a glyph is not supported: Keep together keeps
the band whole; if that fails, the renderer clips at the sheet boundary
(overflow:hidden) rather than reflowing the text. A Vertical band is a stack
on the canvas; the columns exist in Preview and in print only. A Cross-tab is
one row level and one column level. PDF Content does not rasterise the pages of
a PDF — the browser draws an <object> (Generate Own Pages opens a new
sheet). A Subreport prints a nested catalog table (not another print form).
The Table of Contents is the report bookmark plus group headers with sheet
numbers. A Sparkline is a line over chart.valueField.
The assistant (not PDF, not the Cursor MCP)#
The built-in assistant edits a ZuloReport layout through the same IAssistantTool
mechanism as query_records. Not a separate MCP server, and not PDF generation.
| Tool | What it does | Right |
|---|---|---|
list_print_forms |
The forms the user may Read | Read on the bound document / dictionary |
get_print_form |
The card + layoutJson |
Read |
set_print_form_layout |
Replaces LayoutJson after ZrLayoutParser.Parse |
Read and Update. Engine=Zr only |
preview_print_form |
HTML: a url + a truncated excerpt. No file, no PDF |
Read |
Invalid JSON (no bands, schemaVersion < 1) is refused, and the database row
is left alone. Html/Xlsx cannot go through set_print_form_layout.
Same topic for other readers: User