Document Controllers
Document controllers provide code-behind functionality for PDF templates, similar to ASP.NET MVC controllers or iOS ViewControllers. Controllers can:
- Access template components via outlets (properties/fields)
- Respond to document lifecycle events via actions (methods)
- Manipulate document content programmatically
- Implement business logic separate from presentation
Controller Architecture
Controllers implement a two-phase initialization pattern:
- Parsing Phase: Controller is instantiated, outlets are assigned as components are parsed
- Lifecycle Phase: Actions are invoked during document Init, Load, DataBind, Layout, and Render
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Document Parsing β
β 1. <?scryber controller='...' ?> β Instantiate controller β
β 2. <Component id='myLabel' /> β Assign to outlet β
β 3. All required outlets assigned? β Attach controller β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Document Lifecycle β
β Init β Load β DataBind β PreLayout β Layout β PostLayout β
β β β β β β β
β Actions invoked on each event β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Outlets
Outlets are properties or fields that receive references to components in the template, identified by id attribute.
PDFOutlet Attribute
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public class PDFOutletAttribute : Attribute
{
// Optional: Component ID to bind (defaults to member name)
public string ComponentID { get; set; }
// If true, parsing fails if component not found
public bool Required { get; set; }
// If false, member is ignored (allows selective disabling)
public bool IsOutlet { get; set; }
}
Outlet Declaration Examples
public class InvoiceController
{
// Outlet named by property - looks for id='CustomerLabel'
[PDFOutlet]
public Label CustomerLabel { get; set; }
// Explicit component ID - looks for id='invoice-total'
[PDFOutlet(ComponentID = "invoice-total")]
public Label TotalLabel { get; set; }
// Required outlet - parser throws if not found
[PDFOutlet(Required = true)]
public TableGrid InvoiceItems { get; set; }
// Field outlets work too
[PDFOutlet]
public Panel HeaderPanel;
// Non-outlet property (no attribute)
public decimal TotalAmount { get; set; }
}
Template Binding
<html xmlns='http://www.w3.org/1999/xhtml'>
<body>
<main>
<!-- Binds to CustomerLabel property -->
<span id='CustomerLabel' />
<!-- Binds to TotalLabel property -->
<span id='invoice-total' />
<!-- Binds to InvoiceItems property (required) -->
<table id='InvoiceItems' />
<!-- Binds to HeaderPanel field -->
<section id='HeaderPanel' />
</main>
</body>
</html>
Actions
Actions are methods invoked during document lifecycle events. Action methods must:
- Be public instance methods
- Be marked with
[PDFAction] - Match the event handler signature:
(object sender, <ContextType> args) - Return
void
Supported Lifecycle Events
| Event | Context Type | When Invoked | Use Case |
|---|---|---|---|
Init |
InitContext |
After parsing, before data binding | Initialize state, set default values |
Load |
LoadContext |
After Init, before data binding | Load data, prepare data sources |
DataBinding |
DataBindContext |
Before databinding expressions evaluated | Modify data context |
DataBound |
DataBindContext |
After databinding complete | Post-process bound data |
PreLayout |
LayoutContext |
Before layout calculations | Modify structure before layout |
PostLayout |
LayoutContext |
After layout complete | Access calculated positions/sizes |
PreRender |
RenderContext |
Before PDF rendering | Final modifications |
PostRender |
RenderContext |
After PDF rendering | Cleanup, logging |
Action Declaration
public class ReportController
{
[PDFOutlet(Required = true)]
public Label ReportDateLabel { get; set; }
[PDFOutlet(Required = true)]
public ForEach DataRepeater { get; set; }
// Init: Set up initial state
[PDFAction]
public void Init(object sender, InitContext args)
{
args.TraceLog.Add(TraceLevel.Message, "Report Controller", "Initializing report");
ReportDateLabel.Text = DateTime.Now.ToString("MMMM dd, yyyy");
}
// Load: Prepare data sources
[PDFAction]
public void Load(object sender, LoadContext args)
{
var data = LoadReportData();
DataRepeater.DataSource = data;
args.Document.Params["RecordCount"] = data.Count;
}
// DataBinding: Modify binding context
[PDFAction]
public void HandleDataBinding(object sender, DataBindContext args)
{
if (args.DataStack.HasData)
{
var current = args.DataStack.Current;
// Augment data object
}
}
// DataBound: Post-process after binding
[PDFAction]
public void HandleDataBound(object sender, DataBindContext args)
{
args.TraceLog.Add(TraceLevel.Verbose, "Report Controller",
$"Data binding complete for {DataRepeater.ChildCount} items");
}
// PreLayout: Modify before layout
[PDFAction]
public void HandlePreLayout(object sender, LayoutContext args)
{
if (DataRepeater.ChildCount == 0)
{
// Show "no data" message
}
}
// PostLayout: Access layout information
[PDFAction]
public void HandlePostLayout(object sender, LayoutContext args)
{
args.TraceLog.Add(TraceLevel.Message, "Report Controller",
$"Layout complete: {args.DocumentLayout.AllPages.Count} pages");
}
private List<ReportData> LoadReportData()
{
// Database or API call
return new List<ReportData>();
}
}
Action Binding in Templates
Actions are bound via event handler attributes on components:
<body on-init='Init'
on-load='Load'
on-databinding='HandleDataBinding'
on-databound='HandleDataBound'
on-prelayout='HandlePreLayout'
on-postlayout='HandlePostLayout'>
<main>
<span id='ReportDateLabel' />
<data id='DataRepeater' data-bind='foreach'>
<!-- Template content -->
</data>
</main>
</body>
Complete Controller Example
Controller class:
using Scryber;
using Scryber.Components;
using Scryber.Data;
namespace MyCompany.Reports
{
public class SalesReportController
{
// Outlets - assigned during parsing
[PDFOutlet(Required = true)]
public Label CompanyNameLabel { get; set; }
[PDFOutlet(ComponentID = "report-title")]
public Label TitleLabel { get; set; }
[PDFOutlet(Required = true)]
public ForEach SalesDataRepeater { get; set; }
[PDFOutlet]
public Label TotalSalesLabel { get; set; }
[PDFOutlet]
public Panel NoDataPanel { get; set; }
// Controller state
private List<SalesRecord> _salesData;
private decimal _totalSales;
// Action: Initialize report
[PDFAction]
public void InitReport(object sender, InitContext args)
{
args.TraceLog.Add(TraceLevel.Message, "SalesReport", "Initializing sales report");
CompanyNameLabel.Text = "Acme Corporation";
TitleLabel.Text = "Monthly Sales Report";
TitleLabel.StyleClass = "report-header";
}
// Action: Load data
[PDFAction]
public void LoadReportData(object sender, LoadContext args)
{
args.TraceLog.Add(TraceLevel.Message, "SalesReport", "Loading sales data");
// Load data from database/API
_salesData = FetchSalesData();
_totalSales = _salesData.Sum(s => s.Amount);
// Bind to repeater
SalesDataRepeater.DataSource = _salesData;
SalesDataRepeater.Value = _salesData;
// Set total
TotalSalesLabel.Text = _totalSales.ToString("C");
// Show/hide no-data message
NoDataPanel.Visible = (_salesData.Count == 0);
}
// Action: Invoked for each generated row/item in the foreach template
[PDFAction]
public void HandleSalesItemDataBound(object sender, TemplateItemDataBoundArgs args)
{
if (args.Item is TableRow row && args.Context.CurrentIndex % 2 == 1)
{
row.StyleClass = "alternate-row";
}
}
// Action: Pre-layout modifications
[PDFAction]
public void BeforeLayout(object sender, LayoutContext args)
{
if (_salesData.Count > 100)
{
args.TraceLog.Add(TraceLevel.Warning, "SalesReport",
"Large dataset may cause performance issues");
}
}
// Action: Post-layout inspection
[PDFAction]
public void AfterLayout(object sender, LayoutContext args)
{
var pageCount = args.DocumentLayout.AllPages.Count;
args.TraceLog.Add(TraceLevel.Message, "SalesReport",
$"Report generated: {pageCount} pages, {_salesData.Count} records, total ${_totalSales:N2}");
}
private List<SalesRecord> FetchSalesData()
{
// Database query
return new List<SalesRecord>
{
new SalesRecord { Product = "Widget", Amount = 1250.00m },
new SalesRecord { Product = "Gadget", Amount = 2100.50m },
};
}
}
public class SalesRecord
{
public string Product { get; set; }
public decimal Amount { get; set; }
public DateTime Date { get; set; }
}
}
Template XML:
<?xml version='1.0' encoding='utf-8' ?>
<?scryber controller='MyCompany.Reports.SalesReportController, MyCompany.Reports' ?>
<html xmlns='http://www.w3.org/1999/xhtml'>
<body on-init='InitReport'
on-load='LoadReportData'
on-prelayout='BeforeLayout'
on-postlayout='AfterLayout'>
<header>
<span id='CompanyNameLabel' />
<span id='report-title' />
</header>
<main>
<table>
<thead>
<tr>
<th>Product</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<template id='SalesDataRepeater'
data-bind='foreach'
on-item-databound='HandleSalesItemDataBound'>
<tr>
<td>
<span text='{@:Product}' />
</td>
<td>
<span text='{@:Amount}' />
</td>
</tr>
</template>
</tbody>
</table>
<div>
<span>Total Sales: </span>
<span id='TotalSalesLabel' />
</div>
<section id='NoDataPanel' style='display:none'>
<span>No sales data available for this period.</span>
</section>
</main>
</body>
</html>
Usage:
using (var reader = new StreamReader("SalesReport.pdfx"))
{
var doc = Document.ParseDocument(reader, ParseSourceType.DynamicContent);
doc.ProcessDocument("SalesReport.pdf");
}
Controller Specification
Controllers are specified in three ways:
1. Processing Instruction (Document-level)
<?scryber controller='MyNamespace.MyController, MyAssembly' ?>
2. Programmatic (Code-level)
var settings = new ParserSettings(/* ... */);
settings.ControllerType = typeof(MyController);
// OR
settings.Controller = new MyController();
var doc = Document.ParseDocument(stream, ParseSourceType.DynamicContent, settings);
3. Attribute (Component-level)
[PDFController(typeof(MyController))]
public class CustomDocument : Document
{
// ...
}
Implementation Details
Controller Reflection
Definition creation via reflection in ParserDefintionFactory:
private static ParserControllerDefinition LoadControllerDefinition(string name, Type type)
{
ParserControllerDefinition defn = new ParserControllerDefinition(name, type);
// Fill outlets (properties and fields)
FillControllerOutlets(defn);
// Fill actions (methods)
FillControllerActions(defn);
return defn;
}
private static void FillControllerOutlets(ParserControllerDefinition defn)
{
// Reflect properties
PropertyInfo[] allprops = defn.ControllerType.GetProperties(
BindingFlags.Public | BindingFlags.Instance);
foreach (PropertyInfo aprop in allprops)
{
Attribute found = System.Attribute.GetCustomAttribute(aprop,
typeof(PDFOutletAttribute), true);
if (found != null)
{
PDFOutletAttribute outletAttr = (PDFOutletAttribute)found;
if (outletAttr.IsOutlet)
{
ParserControllerOutlet outlet = new ParserControllerOutlet(
aprop,
outletAttr.ComponentID,
outletAttr.Required);
defn.Outlets.Add(outlet);
}
}
}
// Similar process for fields...
}
Outlet Assignment
Parser implementation:
private void TryAssignToControllerOutlet(XmlReader reader, object instance, string outletName)
{
ParserControllerOutlet outlet;
if (this.HasController &&
this.ControllerDefinition.Outlets.TryGetOutlet(outletName, out outlet))
{
try
{
outlet.SetValue(this.Controller, instance);
LogAdd(reader, TraceLevel.Verbose,
"Assigned component with id '{0}' to outlet '{1}'",
outletName, outlet.OutletMember.Name);
}
catch (Exception ex)
{
if (outlet.Required || this.Mode == ParserConformanceMode.Strict)
throw BuildParserXMLException(ex, reader,
"Could not assign component to outlet");
}
this.UnassignedOutlets.Remove(outlet);
}
}
Outlet Validation
protected virtual void EnsureAllOutletsAreAssigned(XmlReader reader, IComponent parsed)
{
if (parsed is IControlledComponent && this.HasController)
{
foreach (ParserControllerOutlet outlet in this.UnassignedOutlets)
{
if (outlet.Required)
throw new PDFParserException(
$"Required outlet {outlet.OutletMember.Name} not assigned");
}
((IControlledComponent)parsed).Controller = this.Controller;
}
}
Best Practices
Controller Design
- Keep controllers focused on coordination, not business logic
- Use dependency injection for services
- Mark outlets as
Requiredwhen template contract demands them - Use descriptive action method names
- Test controllers independently of PDF generation
Outlet Naming
- Use descriptive names:
CustomerNameLabelnotLabel1 - Match outlet names to component IDs for clarity
- Use
ComponentIDparameter when names must differ - Document required outlets in controller comments
Action Method Guidelines
- One responsibility per action method
- Log significant operations
- Handle errors gracefully
- Avoid heavy computation in layout/render actions
- Document expected data context
Troubleshooting
βCould not assignβ¦ to the outletβ
- Check outlet type matches component type
- Verify component has correct
idattribute - Ensure outlet is public property or field
- Check for typos in ComponentID parameter
βRequired outlet not assignedβ
- Verify component with matching ID exists in template
- Check ComponentID attribute spelling
- Ensure component is in main content (not comments)
- Consider making outlet optional if conditionally present
βController type not foundβ
- Verify assembly-qualified name is correct
- Ensure controller assembly is referenced
- Check for typos in processing instruction
- Verify controller class is public
Related Documentation
- Processing Instructions - Specifying controllers
- Custom Components - Using controllers with custom components
- Integration Example - Complete working example
- Best Practices - Controller design patterns