Over the weekend, I was working on the Printing chapter of my Silverlight 4
Book. As part of that chapter, I decided to build a simple
report writer. While there's a lot of other useful stuff in the
chapter (buy my book! <g>) I felt the report writer could be
very useful to release into the wild.
Silverlight 4 Printing API
The book chapter goes into great detail on the printing API. I
won't duplicate that here. However, you can find additional information on MSDN.
Simple Report Writer Goals
The goal of the report writer is to create a simple way to print
reports in Silverlight. A non-goal was to create a report
designer or similar. I'll leave that to you all and to the
vendors.
To be a useful example, the report writer needs to support:
- Page Header and Page Footer
- Page numbering, including total page count
- Automatic pagination
- Rows of arbitrary size
- Report footer that may include totals, counts, etc.
I met all those goals. There's certainly more that can be done
(see end of this post), but it works as a proof-of-concept and
basic report writer.
Printing Mechanics
In order to support flexible layout, total page count and
(eventually) grouping, I decided to pre-build the report before
feeding it to the Silverlight printing system. The pre-build fills
a List of UI Elements, one UI Element per page. The page itself is
made up of three main areas, the PageHeader, the Items area, and
the Page Footer. Each are colored differently in the screen shot
below.
(I used the XPS Printer to create the on-screen version. Saves
paper)
Templates
There are four template properties (each of type DataTemplate)
in play in the image above:
- PageHeaderTemplate
- PageFooterTemplate
- ItemTemplate
- ReportFooterTemplate
Each of those is specified in Xaml, by the person creating the
report. Those are then used when creating each page. For example,
here are the templates shown in this page:
<local:Report x:Name="Report" Title="Current Customer List">
<local:Report.PageHeaderTemplate>
<DataTemplate>
<Grid Margin="1 1 1 20">
<Rectangle Stroke="Black" />
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="{Binding Title}"
Grid.Row="1"
FontSize="16"
FontWeight="Bold"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Top" />
<TextBlock Text="{Binding CurrentPageNumber, StringFormat='Page {0}'}"
Grid.Row="1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Top" />
</Grid>
</Grid>
</DataTemplate>
</local:Report.PageHeaderTemplate>
<local:Report.ItemTemplate>
<DataTemplate>
<Grid HorizontalAlignment="Stretch" Margin="5 0 0 20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="200" />
<ColumnDefinition Width="150" />
<ColumnDefinition Width="150" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal">
<TextBlock Text="{Binding LastName}"
FontWeight="Bold" />
<TextBlock Text=", "
FontWeight="Bold" />
<TextBlock Text="{Binding FirstName}"
FontWeight="Bold" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding LastOrderDate, StringFormat='{}{0:D}'}"
TextAlignment="Left" />
<TextBlock Grid.Column="2"
Text="{Binding TotalBusinessToDate, StringFormat='{}{0:C}'}"
TextAlignment="Right" />
<TextBlock Grid.Column="3"
Text="{Binding OutstandingBalance, StringFormat='{}{0:C}'}"
TextAlignment="Right"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Grid.ColumnSpan="4">
<TextBlock Text="{Binding StreetAddress}" />
<TextBlock Text=" " />
<TextBlock Text="{Binding City}" />
<TextBlock Text=", " />
<TextBlock Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</local:Report.ItemTemplate>
<local:Report.PageFooterTemplate>
<DataTemplate>
<Grid Margin="1 20 1 1">
<Rectangle Stroke="Black" />
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="5">
<TextBlock Text="{Binding CurrentPageNumber, StringFormat='Page {0}'}"/>
<TextBlock Text="{Binding TotalPageCount, StringFormat=' of {0}'}" />
</StackPanel>
</Grid>
</DataTemplate>
</local:Report.PageFooterTemplate>
<local:Report.ReportFooterTemplate>
<DataTemplate>
<Grid Margin="1 20 1 1">
<Rectangle Stroke="Black" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="200" />
<ColumnDefinition Width="150" />
<ColumnDefinition Width="150" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1"
Text="{Binding Count, StringFormat='{}{0} customers'}"
TextAlignment="Left" />
<TextBlock Grid.Column="2"
Text="{Binding TotalSales, StringFormat='{}{0:C}'}"
TextAlignment="Right" />
<TextBlock Grid.Column="3"
Text="{Binding TotalOutstandingBalance, StringFormat='{}{0:C}'}"
TextAlignment="Right" />
</Grid>
</Grid>
</DataTemplate>
</local:Report.ReportFooterTemplate>
</local:Report>
Template Dependency Properties
The templates are all standard Xaml. You can see that I use
regular old DataBinding just like you would if the report were
actually a listbox or a DataGrid or something. The templates are
defined on the report object as Dependency Properties. Here's an
example of one:
public DataTemplate ItemTemplate
{
get { return (DataTemplate)GetValue(ItemTemplateProperty); }
set { SetValue(ItemTemplateProperty, value); }
}
public static readonly DependencyProperty ItemTemplateProperty =
DependencyProperty.Register("ItemTemplate", typeof(DataTemplate), typeof(Report), new PropertyMetadata(null));
Building the Page
To prepare a page, I do the following:
- Create the page header from the PageHeaderTemplate
- Measure() the header to update the DesiredSize
- Create the page footer from the PageFooterTemplate
- Measure() the footer to update the DesiredSize
- Create the items panel (a StackPanel)
- Calculate the available room for items by taking the page area
(from the printing system) and subtracting the header and footer
desired sizes.
- Add the page to the internal collection of pages
Here's the code
private Grid GetNewPage(Size printableArea, out StackPanel itemsPanel, out Size itemsPanelMaxSize)
{
CurrentPageNumber++;
// pagePanel is the root element for this page
Grid pagePanel = new Grid();
RowDefinition headerRow = new RowDefinition();
headerRow.Height = GridLength.Auto;
RowDefinition itemsRow = new RowDefinition();
itemsRow.Height = new GridLength(1, GridUnitType.Star);
RowDefinition footerRow = new RowDefinition();
footerRow.Height = GridLength.Auto;
pagePanel.RowDefinitions.Add(headerRow);
pagePanel.RowDefinitions.Add(itemsRow);
pagePanel.RowDefinitions.Add(footerRow);
// Create the Page Header
FrameworkElement header = PageHeaderTemplate.LoadContent() as FrameworkElement;
header.DataContext = this;
Grid.SetRow(header, 0);
pagePanel.Children.Add(header);
header.Measure(new Size(printableArea.Width, printableArea.Height));
// Create body / itemsPanel to fit in between
itemsPanel = new StackPanel();
itemsPanel.Orientation = Orientation.Vertical;
itemsPanel.HorizontalAlignment = HorizontalAlignment.Stretch;
itemsPanel.VerticalAlignment = VerticalAlignment.Top;
Grid.SetRow(itemsPanel, 1);
pagePanel.Children.Add(itemsPanel);
// create the Page footer
FrameworkElement footer = PageFooterTemplate.LoadContent() as FrameworkElement;
footer.DataContext = this;
Grid.SetRow(footer, 2);
pagePanel.Children.Add(footer);
footer.Measure(new Size(printableArea.Width, printableArea.Height));
// Calculate how large the items panel can be
itemsPanelMaxSize = new Size(printableArea.Width, printableArea.Height - footer.DesiredSize.Height - header.DesiredSize.Height);
_pageTrees.Add(pagePanel);
return pagePanel;
}
Building the Report
That builds a page, but doesn't handle any actual data. That
function is inside the BuildReport method. BuildReport loops
through all the data, adds rows while they fit, and calls
GetNewPage() when it needs a new page.
private void BuildReport(Size printableArea)
{
_pageTrees.Clear();
CurrentPageNumber = 0;
// event letting everyone know we're building the report
OnBeginBuildReport(EventArgs.Empty);
// if no data, exit
if (ItemsSource == null)
return;
IEnumerable reportItems = ItemsSource;
IEnumerator reportItemsEnumerator = reportItems.GetEnumerator();
reportItemsEnumerator.Reset(); // required for > 1 print from same data
// ready our panel variables for creation
StackPanel itemsPanel = null;
Grid pagePanel = null;
Size itemsPanelMaxSize = new Size();
// create first page
pagePanel = GetNewPage(printableArea, out itemsPanel, out itemsPanelMaxSize);
while (reportItemsEnumerator.MoveNext())
{
// notify that we're about to build an item
PrintingEventArgs args = new PrintingEventArgs();
args.DataContext = reportItemsEnumerator.Current;
OnBeginBuildReportItem(args);
// create row. Set data context.
FrameworkElement row = ItemTemplate.LoadContent() as FrameworkElement;
row.DataContext = args.DataContext;
row.Measure(printableArea);
// create a new page if we're out of room here
if (row.DesiredSize.Height + itemsPanel.DesiredSize.Height > itemsPanelMaxSize.Height)
{
pagePanel = GetNewPage(printableArea, out itemsPanel, out itemsPanelMaxSize);
}
// add the row to the items panel, and then re-measure
itemsPanel.Children.Add(row);
itemsPanel.Measure(printableArea);
}
// create report footer
PrintingEventArgs reportFooterEventArgs = new PrintingEventArgs();
reportFooterEventArgs.DataContext = this;
OnBeginBuildReportFooter(reportFooterEventArgs);
FrameworkElement reportFooter = ReportFooterTemplate.LoadContent() as FrameworkElement;
if (reportFooter != null)
{
reportFooter.DataContext = reportFooterEventArgs.DataContext;
reportFooter.Measure(printableArea);
// fit the footer into the report
if (reportFooter.DesiredSize.Height + itemsPanel.DesiredSize.Height > itemsPanelMaxSize.Height)
{
pagePanel = GetNewPage(printableArea, out itemsPanel, out itemsPanelMaxSize);
}
itemsPanel.Children.Add(reportFooter);
}
// we're done!
OnEndBuildReport(EventArgs.Empty);
}
Printing the Report
Note all the Measure() calls in there. Those are expensive, but
necessary to figure out how much room we have left.
Finally, the integration with the Silverlight printing
engine:
public void Print()
{
CurrentPageNumber = 0;
int pageIndex = 0;
_printDocument.PrintPage += (s, e) =>
{
if (pageIndex == 0)
{
BuildReport(e.PrintableArea);
TotalPageCount = _pageTrees.Count;
CurrentPageNumber = 0;
}
if (_pageTrees.Count > 0)
{
CurrentPageNumber++;
e.PageVisual = _pageTrees[pageIndex];
}
e.HasMorePages = pageIndex < _pageTrees.Count - 1;
pageIndex++;
};
_printDocument.BeginPrint += (s, e) =>
{
OnBeginPrint(EventArgs.Empty);
};
_printDocument.EndPrint += (s, e) =>
{
OnEndPrint(EventArgs.Empty);
};
_printDocument.Print(Title);
}
In the Print method, I build the report when we start. I need to
do that here so I have the PrintableArea from the PrintPage
arguments. I then loop through the pages we built. Note that you
have to update the CurrentPage number here as well. In the original
BuildReport method, I incremented it to ensure everything sizes
correctly. Here, I increment it again as the databound values are
re-evaluated and need to be correct.
In the original code, I was not pre-building pages. I would go
through similar steps, but assign them to the PageVisual before I
added the rows. You must add the visual tree containing your page
elements only after it is fully populated. If you add it early and
simply add items to the tree after you've already assigned it,
you'll get strange results, like this:
In that example, I added the header and footer ahead of time,
but then assigned the tree to e.PageVisual, and then added
additional elements to it. I cut off the right and bottom for the
screen shot, but you can see that all the data for the report ended
up stacked in the content area. Not good.
Using the Report
The majority of the work is in the Xaml templates. Once you have
those done, the report is easy to use:
private void PrintReport_Click(object sender, RoutedEventArgs e)
{
CustomerService service = new CustomerService();
service.LoadCustomers(50);
CustomerTotals totals = new CustomerTotals();
Report.ItemsSource = service.Customers;
// total rows as they are printed
Report.BeginBuildReportItem += (s, ea) =>
{
Customer customer = ea.DataContext as Customer;
totals.Count++;
totals.TotalOutstandingBalance += customer.OutstandingBalance;
totals.TotalSales += customer.TotalBusinessToDate;
};
// use the totals in the report footer
Report.BeginBuildReportFooter += (s, ea) =>
{
ea.DataContext = totals;
};
Report.Print();
}
The only unusual bit there is how I handle totals. Since I don't
make any assumptions about the data, I need to provide some way for
you to tally items as they are printed, and then use those numbers
in the report footer. I do that by raising an event for each item
as it is added, and by then raising an event for the report footer.
The report footer event allows you to replace the DataContext for
the footer with something of your own: in this case, a class
containing calculated totals.
Conclusion
You can take this code and build on it, or snag ideas from it.
See CodePlex request below should you be interested in contributing
back. Some nice enhancements would be:
- Offer Even and Odd Item templates. If those
are provided, don't use the ItemTemplate, instead use the
appropriate even/odd template based on the current row.
- Subreports and grouping. This gets trickier
when you consider that groups can span pages, but is doable.
- Support for images. Because the pages are not
part of a real visual tree until printing time, Images do not work.
Images are resolved (downloaded/loaded) once they are added to the
true visual tree. The solution here may be to force everything
through a preview process.
- Print preview. Since we have all the trees for
each page, it should be reasonable to create a print-preview window
that allows paging through them (perhaps an items control or
similar will work here). Will require exposing the pages collection
outside of the report, or having the print-preview functionality
inside the Report class itself.
- Report Building off the UI thread. Not sure if
this is possible; I haven't investigated it yet.
- Report Progress Reporting. Raise event when
each page is actually printed, so a progress bar can be made.
I'm considering putting the full project code up on
CodePlex so anyone can browse it and add to it. Is that
interesting/useful to you? Also, if you're interested in
enhancing this project to make a real report writer, let me know.
As I mentioned, this will be included in my book as well, with more
details on the printing system.