About two weeks ago, we had a tutorial here at SOTC on the basics of
printing in WPF. It covered the standard
stuff, like popping the print dialog, and what you needed to do to print
visuals (both created in XAML and on the fly). But really, that's barely
scratching the surface - any decent printing system in pretty much any
application needs to be able to do a lot more than that. So today, we
are going to take one more baby step forward into the world of printing
- we are going to take a look at pagination.
The main class that we will need to do pagination is the
DocumentPaginator.
I mentioned this class very briefly in the previous tutorial, but only
in the context of the printing methods on
PrintDialog,
PrintVisual
(which we focused on last time) and
PrintDocument
(which we will be focusing on today). This
PrintDocument
function
takes a DocumentPaginator
to print - and this is why we need to create
one. Unfortunately, making a DocumentPaginator
is not as easy as just
creating an instance. It is an abstract class, and so you actually need
to create your own class that derives from it, filling in all the
abstract methods with the functionality that you need.
Today, we are going to be writing our own
DocumentPaginator
that spits
out random tabular data - I called it the RandomTabularPaginator
.
Every page printed will have a header, and then as many rows of data as
can be fit onto the page. Below you can see the top of an example
printed page:
So let's take a look at what we are getting into by trying to write our
own
DocumentPaginator
. The following is what Visual Studio stubs out
for you when you create a class that derives from DocumentPaginator
:public class RandomTabularPaginator : DocumentPaginator
{
/// <summary>
/// When overridden in a derived class,
/// gets the DocumentPage for the specified page number
/// </summary>
/// <param name="pageNumber">
/// The zero-based page number of the document
/// page that is needed.
/// </param>
/// <returns>
/// The DocumentPage for the specified pageNumber,
/// or DocumentPage.Missing if the page does not exist.
/// </returns>
public override DocumentPage GetPage(int pageNumber)
{
throw new NotImplementedException();
}
/// <summary>
/// When overridden in a derived class, gets a value
/// indicating whether PageCount is the total number of pages.
/// </summary>
public override bool IsPageCountValid
{
get { throw new NotImplementedException(); }
}
/// <summary>
/// When overridden in a derived class, gets a count
/// of the number of pages currently formatted.
/// </summary>
public override int PageCount
{
get { throw new NotImplementedException(); }
}
/// <summary>
/// When overridden in a derived class, gets or
/// sets the suggested width and height of each page.
/// </summary>
public override Size PageSize
{
get
{
throw new NotImplementedException();
}
set
{
throw new NotImplementedException();
}
}
/// <summary>
/// When overridden in a derived class,
/// returns the element being paginated.
/// </summary>
public override IDocumentPaginatorSource Source
{
get { throw new NotImplementedException(); }
}
}
Most of those method comments are actually pretty self-explanatory. As
you probably figured out, the really important method here is
GetPage
.
This method will get called once for every page in your document (the
number of pages in your document being defined by the PageCount
property). Each time, you have to return a
DocumentPage
instance. This might sound onerous, but it actually isn't that bad (as
we will see in a moment) - a DocumentPage
is just a shell around a
visual that you want to print.
Ok, time to get started filling out these methods. Below is the code for
everything except the
GetPage
(because that method is a bit more
complicated):public class RandomTabularPaginator : DocumentPaginator
{
private int _RowsPerPage;
private Size _PageSize;
private int _Rows;
public RandomTabularPaginator(int rows, Size pageSize)
{
_Rows = rows;
PageSize = pageSize;
}
public override DocumentPage GetPage(int pageNumber)
{
throw new NotImplementedException();
}
public override bool IsPageCountValid
{ get { return true; } }
public override int PageCount
{ get { return (int)Math.Ceiling(_Rows / (double)_RowsPerPage); } }
public override Size PageSize
{
get { return _PageSize; }
set
{
_PageSize = value;
_RowsPerPage = PageElement.RowsPerPage(PageSize.Height);
//Can't print anything if you can't fit a row on a page
Debug.Assert(_RowsPerPage > 0);
}
}
public override IDocumentPaginatorSource Source
{ get { return null; } }
}
As you can see, we first added a constructor and a couple of fields. The
two things this
RandomTabularPaginator
needs to know are the number of
rows of random data to generate, and the page size that the data will be
printed on. We pull those in through the constructor and store them.
When setting the page size, we calculate the number of rows of data that
can fit on a page, and store that as well. If your wondering what the
class PageElement
is, don't worry - we will be getting to that real
soon. For now, you will just have to believe that
PageElement.RowsPerPage
actually does return the number of rows that
can fit on a page for a given page height.
In the
PageCount
property, we just return the ceiling of the total
number of rows divided by the number of rows that can fit on a page. And
since we always know the exact number of pages in our document, we can
just flat out return true from IsPageCountValid
. In more complex
printing, you might be unable to calculate the total number of pages
right off the bat - which is why this property is there.
Finally, we return null from the
Source
because we don't have an
IDocumentPaginatorSource.
Generally, you don't have to worry about this property and can always
return null. It is used mostly by
FlowDocument
and the like.
Now for the
GetPage
method:public override DocumentPage GetPage(int pageNumber)
{
int currentRow = _RowsPerPage * pageNumber;
var page = new PageElement(currentRow,
Math.Min(_RowsPerPage, _Rows - currentRow))
{
Width = PageSize.Width,
Height = PageSize.Height,
};
page.Measure(PageSize);
page.Arrange(new Rect(new Point(0,0), PageSize));
return new DocumentPage(page);
}
First off, we calculate the current row, based off of the page number
passed in and our stored value for the number of rows that fit on a
page. Then we create a new
PageElement
for this page. This is a custom
control built just for rendering a page of RandomTabularPaginator
content - I generally find it easier to work with printing pages this
way, because otherwise the GetPage
method can get quite long with all
the rendering logic. So before we move on to the rest of this method,
let's take a look at the PageElement
class:public class PageElement : UserControl
{
private const int PageMargin = 75;
private const int HeaderHeight = 25;
private const int LineHeight = 20;
private const int ColumnWidth = 140;
private int _CurrentRow;
private int _Rows;
public PageElement(int currentRow, int rows)
{
Margin = new Thickness(PageMargin);
_CurrentRow = currentRow;
_Rows = rows;
}
public static int RowsPerPage(double height)
{
return (int)Math.Floor((height - (2 * PageMargin)
- HeaderHeight) / LineHeight);
}
private static FormattedText MakeText(string text)
{
return new FormattedText(text, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, new Typeface("Tahoma"), 14, Brushes.Black);
}
protected override void OnRender(DrawingContext dc)
{
Point curPoint = new Point(0, 0);
dc.DrawText(MakeText("Row Number"), curPoint);
curPoint.X += ColumnWidth;
for (int i = 1; i < 4; i++)
{
dc.DrawText(MakeText("Column " + i), curPoint);
curPoint.X += ColumnWidth;
}
curPoint.X = 0;
curPoint.Y += LineHeight;
dc.DrawRectangle(Brushes.Black, null,
new Rect(curPoint, new Size(Width, 2)));
curPoint.Y += HeaderHeight - LineHeight;
Random numberGen = new Random();
for (int i = _CurrentRow; i < _CurrentRow + _Rows; i++)
{
dc.DrawText(MakeText(i.ToString()), curPoint);
curPoint.X += ColumnWidth;
for (int j = 1; j < 4; j++)
{
dc.DrawText(MakeText(numberGen.Next().ToString()), curPoint);
curPoint.X += ColumnWidth;
}
curPoint.Y += LineHeight;
curPoint.X = 0;
}
}
}
This class takes in the row number to start at and the number of rows to
generate, and stores them in fields. It also has a bunch of constants
about various heights and widths. The real work in this class is in the
OnRender
method, which is where everything gets drawn. I choose to do
everything in OnRender
here, but you could have just as easily built
up a visual tree with a grid and a whole bunch of TextBlocks. In this
case, using OnRender is probably faster, but there are cases where
building a full visual tree is better to do.
The actual contents of
OnRender
for this class are kind of boring -
just a whole bunch of loops drawing
FormattedText
objects - first for the headers, and then for each of the rows. One
thing you don't want to forget (and is easy to miss) is that you still
need to deal with setting your own page margins (since printers can't
print to the edge of pages). Here, we deal with it by setting the margin
on the PageElement
in its constructor, but you can also deal with it
by just making sure you position elements appropriately when drawing or
laying things out.
Ok, back to the rest of that
GetPage
method. Once we have a
PageElement
instance, we set its width and height, and then run it
through a Measure/Arrange pass. You may remember from the previous
tutorial that you always have to Measure/Arrange brand new visuals
before passing them to the PrintVisual
method on PrintDialog
. The
same concept applies here - if you don't Measure/Arrange, you will end
up with blank sheets of paper. Finally, we take the PageElement
,
package it in a DocumentPage
, and return it. And that's it for
implementing the RandomTabularPaginator
!
But now you are probably wondering how you use a
DocumentPaginator
that you have written. Well, it is really easy - the following is the
XAML and C# code from the simple app that tested the
RandomTabularPaginator
:<Window x:Class="PaginatorExample.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="100" Width="100">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock VerticalAlignment="Center">Rows: </TextBlock>
<TextBox x:Name="NumRows">50</TextBox>
</StackPanel>
<Button Click="PrintClick">Print</Button>
</StackPanel>
</Window>
using System.Windows;
using System.Windows.Controls;
namespace PaginatorExample
{
public partial class Window1 : Window
{
public Window1()
{ InitializeComponent(); }
private void PrintClick(object sender, RoutedEventArgs e)
{
int rows = 0;
if(!int.TryParse(NumRows.Text, out rows) || rows < 0)
{
MessageBox.Show("Not a valid number of rows.");
return;
}
var printDialog = new PrintDialog();
if (printDialog.ShowDialog() == true)
{
var paginator = new RandomTabularPaginator(rows,
new Size(printDialog.PrintableAreaWidth,
printDialog.PrintableAreaHeight));
printDialog.PrintDocument(paginator, "My Random Data Table");
}
}
}
}
This code pretty much comes down to three steps (after the work of
getting the button click and grabbing the number of rows to generate).
First, pop the print dialog. If the return value is true, you move on to
step two - create a
RandomTabularPaginator
. We have the number of rows
parsed already, so all we have to do is grab the currently selected page
size off of the PrintDialog
through the PrintableAreaWidth
and
PrintableAreaHeight
properties. Finally, we pass the new
RandomTabularPaginator
to the PrintDocument
, and we are done!
Well, that is it for this installment of printing in WPF. There is
plenty more to come, though, because we still haven't event touched the
PrintQueue or the XpsDocumentWriter. If you would like the code behind
the example in this tutorial, you can grab a zip file containing the
Visual Studio solution from the download link in the source files
section below. As always, if you have any questions please leave a
comment and I'll do my best to answer it.
Source Files:
Comments
Post a Comment