Printing Reports in Windows Forms

 

Duncan Mackenzie
Microsoft Developer Network

(See Duncan's profile on GotDotNet.)

July 2002

Applies to:
   Microsoft® .NET Framework
   Windows Forms

Summary: How to create your own reports using the printing features of GDI+ in Microsoft .NET. (22 printed pages)

Download Printwinforms.exe.

Contents

Introduction
Printing Features in the .NET Framework
Producing Real Reports
The TabularReport Class
Conclusion

Introduction

I won't start waxing philosophical about the paperless office, but it is sufficient to note that it hasn't arrived yet. I have built many different systems that were designed to get rid of some part of a company's paperwork, turn it into data that is stored on the computer, but (regardless of how wonderful the system is) one of the major requirements is always to get that information out of the computer and back into paper form. Looking back, across systems built in Clipper, Microsoft® FoxPro®, Microsoft® Access, Microsoft® Visual Basic® and now Microsoft .NET, the one constant when developing business systems has been that creating and testing reports is one of the largest elements of the project timetable. Assuming that this is true for other people, not just me, I am going to show you how to use the drawing features of GDI+ and the GDI+ Printing classes to output a tabular report. This report type, (see Figure 1), covers a large percentage of the output you will ever have to develop.

Figure 1. Tabular reports are used to print out lists of information, such as financial accounts, order records, or other material well suited for display in a grid style.

Although I will not be covering the other very common type of report, an invoice/form (see Figure 2), many of the general reporting concepts covered in this article would apply to either style.

Figure 2. Form style reports are often used to print out invoices, tax forms, or other similar types of documents.

Note   If you have already been examining the features of Microsoft® Visual Studio® .NET, you will most likely be aware that it ships with Crystal Reports, a full-blown report development package complete with a ton of development tools, and you probably wondering why I am not discussing it in this article. Crystal Reports is a great reporting tool, but it is not free to deploy, so I want to make sure you understand what is possible using just the .NET Framework.

Printing Features in the .NET Framework

Before I dive into developing the two sample reports, I want to provide an overview of the printing functionality available in .NET. The .NET Framework has provided a set of printing features that build on the existing GDI+ classes to allow you to print, preview, and work with printers. These features are exposed for programmatic use through the System.Drawing.Printing classes and visual components (PrintDialog, PrintPreviewDialog, PrintDocument, and more...) are provided for use on Windows Forms applications. With these classes, producing some printed output can be accomplished with almost no code and using just a couple of the Windows Forms components.

Trying It Out for Yourself

As a quick introduction to printing, follow these steps to create a small test application:

  1. Create a new Visual Studio .NET Windows application, in either Microsoft® Visual C#® or Visual Basic .NET

  2. On the new Form1 that is automatically created, add one of each of the following components from the toolbox: PrintDocument, PrintPreviewDialog and Button.

  3. Now, select the PrintPreviewDialog component, and view its properties. Set the Document property, which will be currently equal to (none), to PrintDocument1 ('printDocument1' if you are using C#). This associates the two components together so that when the preview dialog needs a page drawn, to the preview window or to the printer, it will call the PrintPage event of this specific PrintDocument.

  4. Add code to the PrintPage event of PrintDocument1. In either language, you can access this event by double-clicking the PrintDocument component. Here is a sample snippet of code you can add that will print the name of the user currently logged on to the page.

    //C#
    private void printDocument1_PrintPage(object sender, 
        System.Drawing.Printing.PrintPageEventArgs e)
    {
        Graphics g = e.Graphics;
        String message = System.Environment.UserName;
        Font messageFont = new Font("Arial",
                 24,System.Drawing.GraphicsUnit.Point);
        g.DrawString(message,messageFont,Brushes.Black,100,100);
    }
    
    'Visual Basic .NET
    Private Sub PrintDocument1_PrintPage(ByVal sender As System.Object, _
            ByVal e As System.Drawing.Printing.PrintPageEventArgs) _
            Handles PrintDocument1.PrintPage
        Dim g As Graphics = e.Graphics
        Dim message As String = System.Environment.UserName
        Dim messageFont As New Font("Arial", 24, _
              System.Drawing.GraphicsUnit.Point)
        g.DrawString(message, messageFont, Brushes.Black, 100, 100)
    End Sub
    
  5. Add code to the click event of your button (double-click the button in the Form designer to get to the click event) to launch the print preview dialog. When the preview dialog needs to draw the page, the PrintPage event handler you just finished will be called.

    //C#
    private void button1_Click(object sender, System.EventArgs e)
    {
        printPreviewDialog1.ShowDialog();
    }
    
    'Visual Basic .NET
    Private Sub Button1_Click(ByVal sender As System.Object, _
            ByVal e As System.EventArgs) _
            Handles Button1.Click
        PrintPreviewDialog1.ShowDialog()
    End Sub
    

If everything worked out, running the application and clicking the button will open a dialog with your print preview, displaying a mostly empty page with just your user name written onto it.

Producing Real Reports

The quick exercise described above is actually enough to get you writing your own reports, the rest of the work is really just GDI+ drawing and some layout issues around margins and other settings; you could build complete data-driven reports simply by adding code to the PrintPage event. Of course, that might be fine for one report that never changes, but if you are going to produce reports on a regular basis, you will find putting all your code into PrintPage rather hard to maintain. Instead, what you need to do is to break the task of printing a page up into its various subcomponents and then try to write code to perform each of these bits of work. If you can make this code re-usable, then you can produce all sorts of reports without having to write anything new.

Having used a wide variety of reporting tools in the past, I have found that they all tend to divide up reports into the same common elements:

  • Page Headers/Footers
  • Report Headers/Footers
  • Group Headers/Footers
  • Detail Rows

For the sake of simplicity, I decided not to implement Report or Group sections and stick to page headers and footers, and of course the detail row. Starting off on a new Windows Form, with a PrintDocument added just like we did in the exercise, I created a few different functions, PrintPageFooter, PrintPageHeader and PrintDetailRow, each of which returns the size (the height in pixels) of the section they just created. I supplied each one of these functions with the PrintPageEventArgs object that is passed to the PrintPage event, to give each function access to information about the printer settings and to the Graphics object that they will need to do any drawing. I also passed in a rectangle, bounds, that described the area of the page on which I wanted the section to appear and a final common argument, sizeOnly, which specifies if I wanted the section to actually print or to just calculate and return its size. The PrintDetailRow function accepted several other arguments that I will discuss when we get to that code. Once I had all of these individual sections written, I would be able to create a report quickly. Regardless of the specific section I was developing, though, I found that there are a few key topics that are applicable for anyone printing out reports, including resolution and handling multiple pages.

Resolution Issues: Converting Between Inches and Pixels

When you are drawing in GDI+, you are always working in a specific unit of measure, whether that is inches, pixels, points or something else, but the relationship between these different units can be affected by the device (such as a printer) to which you are drawing. I have found it simplest to do all my measurements in inches, which are device-independent (an inch is an inch is an inch), and avoid any resolution issues at all. To do this yourself, since an inch is not the default unit of measure, you need to set the PageUnit property of your Graphics object at the start of each PrintPage event.

    Public Sub PrintPage(ByVal sender As Object, _
           ByVal e As PrintPageEventArgs)
        Dim g As Graphics = e.Graphics
        g.PageUnit = GraphicsUnit.Inch

The only little hiccup in using inches as your unit of measure is when you are dealing with the Page and Printer settings, such as margins, which are measured in 1/100ths of an inch. It is simple enough to convert between the two, but make sure you are using Single data types so that you don't lose any precision when doing the conversion. At the start of my PrintPage, I grab the margin values and convert them immediately to avoid any confusion.

Dim leftMargin As Single = e.MarginBounds.Left / 100
Dim rightMargin As Single = e.MarginBounds.Right / 100
Dim topMargin As Single = e.MarginBounds.Top / 100
Dim bottomMargin As Single = e.MarginBounds.Bottom / 100
Dim width As Single = e.MarginBounds.Width / 100
Dim height As Single = e.MarginBounds.Height / 100

Keeping Track of Multiple Pages

If your report could span more than one page, then you need to keep track of a few details, such as the current page number and the current row of data, as each page results in its own call to the PrintPage event handler. In each case, creating a variable that exists outside of the event handler code will allow you to remember your current position between calls to PrintPage.

Dim currentPage As Integer = 0
Dim currentRow As Integer = 0

Public Sub PrintPage(ByVal sender As Object, _
       ByVal e As PrintPageEventArgs)

    ...
    currentPage += 1

Always remember to reset these values before every time you print, or else your second printing won't start with a page number of 1. The best place to reset these values is in the BeginPrint event of the PrintDocument.

Private Sub PrintDocument1_BeginPrint(ByVal sender As Object, _
        ByVal e As PrintEventArgs) _
        Handles PrintDocument1.BeginPrint
    currentPage = 0
    currentRow = 0
End Sub

When I first started playing around with the printing features in .NET, I was resetting my page count in my Print button, right before printing the document. This worked fine until I decided to preview the document first before printing, the pages would go from 1 to 3 in the preview, but print with page numbers of 4 to 6, since the document was really printed twice (once to preview and once to the printer). In fact, since a user can print as many times as they want from the Print Preview dialog box, the page numbers could have kept increasing. Using the Begin Page event avoids all these worries.

It is also up to you to specify when your report is done or when you have more pages left to print, through the HasMorePages property of PrintPageEventArgs. Before the end of the PrintPage event handler, you need to set this property to false if you are done printing or true if you still have rows left to print when you reach the end of the page. Having the possibility of multiple pages requires you to know when you have reached the end of your page, which is why each of my printing procedures (PrintDetailRow, for example) has a "sizeOnly" argument. By passing True for the sizeOnly parameter, I can find out the size a detail row will be before it is printed, allowing me to decide whether I have room left on the page for that row (taking the footer into account, of course). If I don't have enough room left, I don't print the row or increment the currentRow variable; instead I set HasMorePages to True and it will be printed on the next page.

Note   If a detail row returns a size greater than the size of your page, you will never be able to print it, and you'll get stuck into an endless loop producing blank pages. After you get the size of a section, check to make sure it is reasonable (less than the available space on an otherwise empty page) before trying to work with it, and throw an exception if it is too big.

Outputting Text

Regardless of the underlying data type of your fields, you will be outputting text for every column of your report and for your headers and footers, all through the DrawString method of the Graphics class. When drawing text for use in a report, you often have to deal with size and positioning restrictions, and DrawString has provided the LayoutRectangle parameter for exactly that reason. By specifying a LayoutRectangle, you are forcing the text into a specific area, with automatic word wrapping and other features controllable through the StringFormat parameter.

Combining StringFormat Settings with a Layout Rectangle

Each of these images is the result of calling DrawString with the string, "The quick brown fox jumped high over the slow and lazy black dog," and various string format settings. The layout rectangle is not normally visible, so I drew it afterwards using DrawRectangle, to make the images easier to understand.

Figure 3. Output using the default StringFormat settings

The first image shows the default behavior of a layout rectangle, the text is wrapped to fit, partial lines are allowed to be visible, and anything outside the rectangle is clipped (not drawn).

Figure 4. Turning on LineLimit prevents partial lines

With the LineLimit option turned on, only complete lines are allowed to show, preventing the partial line that was visible in the first image; the lack of this additional line changes the word wrapping slightly.

Figure 5. NoClip allows some text to appear outside of the layout rectangle.

As shown in Figure 5, turning off the line limit option and specifying NoClip will allow parts of the string that are partially cut off by the layout rectangle to be visible.

Automatically Adding Ellipses

The StringFormat object can also be used (through the Trimming property) to add ellipses, when your string will be cut off by the layout rectangle, by putting ellipses in place of the last few characters (EllipseCharacter); after the last completely visible word (EllipseWord); or to replace the middle portion of the string so that the beginning and the end will both be visible (EllipsePath). All three options (from left to right, EllipseCharacter, EllipseWord, and EllipsePath) are shown in Figure 6, below.

Figure 6. The Trimming property can be used to provide an automatic ellipse when your string doesn't fit within the layout rectangle.

Drawing Hotkey Indicators

Another configurable property of this class is HotkeyPrefix, which can come in very handy if you are doing custom-drawn Windows Forms controls. HotkeyPrefix affects how DrawString handles ampersands, and can be set to Hide, Show, or None. If it is set to Show, an ampersand (&) before a letter in the string indicates that the letter is a hotkey, and the letter is drawn with a line under it. Hide will prevent the underline from being shown, but also skips the ampersand itself, while None will just handle the ampersand as if it was regular text. The image below shows the three possible settings (Show, Hide, and None) and the effect on the string "&Print".

Figure 7. The HotkeyPrefix property is quite useful when creating your own controls.

Aligning Text Output

Finally, the StringFormat class can be used to specify alignment, using far, near and center instead of left, right and center so that it is valid for any language, regardless of the direction of text flow. For English, flowing left to right, these values translate into right aligned (far) and left aligned (near), while center requires no translation. Whatever alignment setting you choose affects the position of your text relative to the layout rectangle you specify. Without a layout rectangle specified, it affects how DrawString interprets the x, y coordinates you provide for outputting the string. With no layout rectangle, specifying Alignment = Center causes the string output to be centered on the x coordinate you supply, Alignment = Far causes the x coordinate to be treated as the end point of the string's position, and Alignment = Near (the default) causes the string to start at the x-position. Figure 8, below, shows the three alignment settings (near, far, and center) with a layout rectangle specified on top and then with just an x, y position (the upper left corner of the line that has been drawn) specified for the bottom three examples. Note that the results would be different in a system configured to use right-to-left flowing text.

Figure 8. The Alignment property has a different effect depending on whether or not you specify a layout rectangle.

All of the DrawString examples shown in this section are included as a second project, called PlayingWithDrawString, in the code download for this article. I actually used that sample project to create all of the images for this section as well, so what you see is truly what you get!

Handling Columns

In many reports, especially tabular ones, your detail row will consist of columns of information. For each column you will need to determine a set of information including the source of the column (a field in your data source for example), the width of the column on the page, the font to use, the alignment of the column's contents, and more. For my tabular report, since columns are the main content of the report, I decided to create a special ColumnInformation class and then use a collection of this type of object to determine what columns each detail row should contain. My class includes all the information I need to correctly output each column within my data row, and even some properties (HeaderFont and HeaderText) if I decide to add a header row to my reporting code at some point in the future. Note that to simplify the code listing, I have removed the private members for each property and the actual property procedure code, the full code is included in the download.

Public Class ColumnInformation

    Public Event FormatColumn(ByVal sender As Object, _
        ByRef e As FormatColumnEventArgs)

    Public Function GetString(ByVal Value As Object)
        Dim e As New FormatColumnEventArgs()
        e.OriginalValue = Value
        e.StringValue = CStr(Value)
        RaiseEvent FormatColumn(CObj(Me), e)
        Return e.StringValue
    End Function

    Public Sub New(ByVal Field As String, _
            ByVal Width As Single, _
            ByVal Alignment As StringAlignment)
        m_Field = Field
        m_Width = Width
        m_Alignment = Alignment
    End Sub

    Public Property Field() As String
    Public Property Width() As Single
    Public Property Alignment() As StringAlignment
    Public Property HeaderFont() As Font
    Public Property HeaderText() As String
    Public Property DetailFont() As Font

End Class

Public Class FormatColumnEventArgs
    Inherits EventArgs
    Public Property OriginalValue() As Object
    Public Property StringValue() As String
End Class

In addition to the information describing my columns, I have also created a FormatColumn event, which provides a way to write custom formatting code to convert between your database value and string output. An example handler that is designed to produce currency output from a numeric database field is shown below.

Public Sub FormatCurrencyColumn(ByVal sender As Object, _
    ByRef e As FormatColumnEventArgs)
    Dim incomingValue As Decimal
    Dim outgoingValue As String
    incomingValue = CDec(e.OriginalValue)
    outgoingValue = String.Format("{0:C}", incomingValue)
    e.StringValue = outgoingValue
End Sub

Before outputting my report, I populate an ArrayList with ColumnInformation objects, attaching FormatColumn handlers as required.

Dim Columns As New ArrayList()
Public Sub PrintDoc()
    Columns.Clear()

    Dim titleInfo As _
           New ColumnInformation("title", 2, StringAlignment.Near)
    Columns.Add(titleInfo)

    Dim authorInfo As _
           New ColumnInformation("author", 2, StringAlignment.Near)
    Columns.Add(authorInfo)

    Dim bookPrice As _
           New ColumnInformation("author", 2, StringAlignment.Near)
    AddHandler bookPrice.FormatColumn, AddressOf FormatCurrencyColumn
    Columns.Add(bookPrice)

    Me.PrintPreviewDialog1.ShowDialog()
End Sub

For each row in my data source, my PrintDetailRow code loops through this ArrayList and draws the contents of each column. You can view the PrintDetailRow code in the download, and see how it uses the features described in Outputting Text to produce each column.

The TabularReport Class

Up until this point, I have been showing you all the pieces of creating a report, but the end result is to take all of those pieces and build a component that you can use to easily generate a tabular-style (see Figure 1) report. I created a new class that inherits PrintDocument, to allow it to be used with all of the existing printing tools such as the PrintDialog, PrintPreviewDialog and PrintPreviewControl. The complete code is relatively long, so I will cover the steps I followed in developing it, and then show you how you could use a class like this in your application.

Adding Properties

To allow you to setup your tabular report, I needed to add a whole bunch of properties to my class to handle configuring the Columns, providing a data source, and customizing the appearance of the overall report.

Configuring Columns

In addition to creating a ColumnInformation class, I could also have created a strongly typed collection class to hold multiple ColumnInformation instances, which would have been relatively easy using Strongly Typed Collection Generator that is up on the GotDotNet Web site. I didn't go that direction, as I wanted all column access to be through my class; instead, I decided to just use an ArrayList and create some methods on my report class to provide strongly typed access to that list.

Protected m_Columns As New ArrayList()

Public Function AddColumn(ByVal ci As ColumnInformation) As Integer
    Return m_Columns.Add(ci)
End Function

Public Sub RemoveColumn(ByVal index As Integer)
    m_Columns.RemoveAt(index)
End Sub

Public Function GetColumn(ByVal index As Integer) As ColumnInformation
    Return CType(m_Columns(index), ColumnInformation)
End Function

Public Function ColumnCount() As Integer
    Return m_Columns.Count
End Function

Public Sub ClearColumns()
    m_Columns.Clear()
End Sub

Customizing the Report Appearance

By allowing the users of my report class to configure all of the fonts and brushes used, along with the height of each report section, they have a fair degree of customization available. For all of these options, I provided public property procedures and internal variables that are initialized to some default values. I also created DefaultReportFont and DefaultReportBrush properties to allow users to set a single Font or Brush that would be used through the report, unless the more specific properties had been set. The code for the Font properties is listed below, but the Brush properties are omitted as they are essentially doing the same work (but with Brush objects).

Protected m_DefaultReportFont As Font = _
    New Font("Arial", 12, FontStyle.Bold, GraphicsUnit.Point)
Protected m_HeaderFont As Font
Protected m_FooterFont As Font
Protected m_DetailFont As Font

Public Property DefaultReportFont() As Font
    Get
        Return m_DefaultReportFont
    End Get
    Set(ByVal Value As Font)
        If Not Value Is Nothing Then
            m_DefaultReportFont = Value
        End If
    End Set
End Property

Public Property HeaderFont() As Font
    Get
        If m_HeaderFont Is Nothing Then
            Return m_DefaultReportFont
        Else
            Return m_HeaderFont
        End If
    End Get
    Set(ByVal Value As Font)
        m_HeaderFont = Value
    End Set
End Property

Public Property FooterFont() As Font
    Get
        If m_FooterFont Is Nothing Then
            Return m_DefaultReportFont
        Else
            Return m_FooterFont
        End If
    End Get
    Set(ByVal Value As Font)
        m_FooterFont = Value
    End Set
End Property

Public Property DetailFont() As Font
    Get
        If m_DetailFont Is Nothing Then
            Return m_DefaultReportFont
        Else
            Return m_DetailFont
        End If
    End Get
    Set(ByVal Value As Font)
        m_DetailFont = Value
    End Set
End Property

In addition to the Fonts and Brushes, I also implemented properties to allow the user to control the height of the various report sections (setting both a minimum and maximum for the Detail section).

Protected m_HeaderHeight As Single = 1
Protected m_FooterHeight As Single = 1
Protected m_MaxDetailRowHeight As Single = 1
Protected m_MinDetailRowHeight As Single = 0.5

Public Property HeaderHeight() As Single
    Get
        Return m_HeaderHeight
    End Get
    Set(ByVal Value As Single)
        m_HeaderHeight = Value
    End Set
End Property

Public Property FooterHeight() As Single
    Get
        Return m_FooterHeight
    End Get
    Set(ByVal Value As Single)
        m_FooterHeight = Value
    End Set
End Property

Public Property MaxDetailRowHeight() As Single
    Get
        Return m_MaxDetailRowHeight
    End Get
    Set(ByVal Value As Single)
        m_MaxDetailRowHeight = Value
    End Set
End Property

Public Property MinDetailRowHeight() As Single
    Get
        Return m_MinDetailRowHeight
    End Get
    Set(ByVal Value As Single)
        m_MinDetailRowHeight = Value
    End Set
End Property

Setting up the Data Source

To be able to produce my report, I need access to data, so I added a property that accepts a DataView instance, and then I loop through its rows in my overridden version of OnPrintPage.

Protected m_DataView As DataView
Public Property DataView() As DataView
    Get
        Return m_DataView
    End Get
    Set(ByVal Value As DataView)
        m_DataView = Value
    End Set
End Property

Protected Function GetField(ByVal row As DataRowView, ByVal fieldName As String) As Object
    Dim obj As Object = Nothing
    If Not m_DataView Is Nothing Then
        obj = row(fieldName)
    End If
    Return obj
End Function

'relevant snippet out of OnPrintPage
Dim rowCounter As Integer

e.HasMorePages = False

For rowCounter = currentRow To Me.DataView.Count - 1
    Dim currentRowHeight As Single = _
         PrintDetailRow(leftMargin, _
            currentPosition, Me.MinDetailRowHeight, _
            Me.MaxDetailRowHeight, width, _
            e, Me.DataView(rowCounter), True)

     If currentPosition + currentRowHeight < footerBounds.Y Then
        'it will fit on the page
        currentPosition += _
         PrintDetailRow(leftMargin, currentPosition, _
            MinDetailRowHeight, MaxDetailRowHeight, _
            width, e, Me.DataView(rowCounter), False)
     Else
        e.HasMorePages = True
        currentRow = rowCounter
        Exit For
     End If
Next

Printing the Individual Report Sections

Just having a lot of properties is not enough, I actually have to output the report, but I will be making extensive use of all the properties to make sure that what I print out conforms to the options the user set. Outputting the report is controlled by the code in the OnPrintPage procedure, which I override from the base class (PrintDocument), and that procedure in turn calls each of the individual section printing routines (PrintPageHeader, PrintPageFooter, and PrintDetailRow). As with some of the earlier samples, the code for these routines is quite long so I am not going to include it all inline in this article, but instead I suggest you download the code and run the sample application.

Using the TabularReport Class

Once I had finished creating the TabularReport class, building a sample report was very easy and consisted of only a few steps.

Step 1: Create an instance of TabularReport

First, create an instance of my class, which you could do directly in your code, but since I inherited from PrintDocument, you can add my component to your toolbox and then drag it onto your Windows Forms application.

Step 2: Retrieve your data

To provide the report with data, you need a DataView instance, which you can get by filling up a DataTable with the results of a stored procedure or a SQL query.

    Private Function GetData() As DataView
        Dim Conn As New OleDbConnection(connectionString)
        Conn.Open()

        'Access Version
        Dim getOrdersSQL As String = _
        "SELECT Customers.ContactName, Orders.OrderID, Orders.OrderDate, 
Orders.ShippedDate, Sum([UnitPrice]*[Quantity]) AS Total FROM (Customers 
INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID) INNER JOIN 
[Order Details] ON Orders.OrderID = [Order Details].OrderID GROUP BY 
Customers.ContactName, Orders.OrderID, Orders.OrderDate, 
Orders.ShippedDate ORDER BY Orders.OrderDate"

        Dim getOrders As New OleDbCommand(getOrdersSQL, Conn)
        getOrders.CommandType = CommandType.Text
        Dim daOrders As New OleDbDataAdapter(getOrders)
        Dim orders As New DataTable("Orders")
        daOrders.Fill(orders)
        Return orders.DefaultView
    End Function

In my sample, I am retrieving data from an Access database, so I am using the OleDB classes, but you could use any type of database you wish, since all my report needs is the resulting DataView.

Step 3: Configure the Columns of the Report

This step consists of creating the ColumnInformation objects for each of the columns in your report and adding each of those objects to the TabularReport column collection. I put this code into a routine called SetupReport that handles setting up the columns along with a variety of other appearance related details. I covered the concept of these ColumnInformation objects earlier in Handling Columns, but here is how the sample application sets them up.

Private Sub SetupReport()
    'Get Data
    Dim orders As DataView
    orders = GetData()

    'Setup Columns
    Dim contactName _
        As New ColumnInformation("ContactName", 2, _
               StringAlignment.Near)
    Dim orderID _
        As New ColumnInformation("OrderID", 1, _
               StringAlignment.Near)
    Dim orderDate _
        As New ColumnInformation("OrderDate", 1, _
               StringAlignment.Center)
    AddHandler orderDate.FormatColumn, AddressOf FormatDateColumn

    Dim shippedDate _
        As New ColumnInformation("ShippedDate", 1, _
               StringAlignment.Center)
    AddHandler shippedDate.FormatColumn, AddressOf FormatDateColumn

    Dim total _
        As New ColumnInformation("Total", 1.5, _
               StringAlignment.Far)
    AddHandler total.FormatColumn, AddressOf FormatCurrencyColumn

    With TabularReport1
        .ClearColumns()
        .AddColumn(contactName)
        .AddColumn(orderID)
        .AddColumn(orderDate)
        .AddColumn(shippedDate)
        .AddColumn(total)
        .DataView = orders
        .HeaderHeight = 0.5
        .FooterHeight = 0.3
        .DetailFont = New Font("Arial", _
                          12, FontStyle.Regular, _
                          GraphicsUnit.Point)
        .DetailBrush = Brushes.DarkKhaki
        .DocumentName = "Order Summary From Northwinds Database"
    End With
End Sub

As part of setting up the columns, there is an AddHandler call for each of the three columns, orderDate, shippedDate, and total, which associates these columns with routines to format their output as currency or date strings as appropriate.

Public Sub FormatCurrencyColumn(ByVal sender As Object, _
    ByRef e As FormatColumnEventArgs)
    Dim incomingValue As Decimal
    Dim outgoingValue As String
    If Not IsDBNull(e.OriginalValue) Then
        incomingValue = CDec(e.OriginalValue)
    Else
        incomingValue = 0
    End If
    outgoingValue = String.Format("{0:C}", incomingValue)
    e.StringValue = outgoingValue
End Sub

Public Sub FormatDateColumn(ByVal sender As Object, _
    ByRef e As FormatColumnEventArgs)
    Dim incomingValue As Date
    Dim outgoingValue As String
    If Not IsDBNull(e.OriginalValue) Then
        incomingValue = CDate(e.OriginalValue)
        outgoingValue = incomingValue.ToShortDateString()
    Else
        outgoingValue = "-"
    End If
    e.StringValue = outgoingValue
End Sub

Whatever you write in your FormatColumn event handler, make sure you make it as quick as possible, as it will be called twice per row for each column it is attached to. Even a small amount of delay will be noticeable in that situation. Implementing my column formatting using this method has some pros and cons. On the positive side, you can implement formatting as complex as you may need, making the report more flexible, while on the negative side it is relatively difficult to perform even simple formatting. As an alternative that you may wish to implement, you could build the ColumnInformation class so that simple formats such as currency could be set using a property. With that level of formatting built in, providing a FormatColumn event handler would be used only for advanced formatting, and overall performance could be improved.

Step 4: Print or Preview the Document

Using either the Print method of the TabularReport or one of the several Windows Forms controls (such as the PrintDialog, PrintPreviewDialog or PrintPreviewControl) that are capable of interacting with a PrintDocument object, you can now print or preview your configured report. Any control that has a property type of PrintDocument will happily work with an instance of TabularReport, since it inherits from PrintDocument.

Conclusion

Creating reports is a very common task, so you don't want to write all the code every single time you have to build one. Instead, I suggest you create a mini-report engine like my TabularReport class, and try to make it as flexible as you can. Take a look at my sample to see how exposing fonts, brushes and dimensions allow my single set of code to support a large amount of customization. If your report engine cannot quite handle a specific report, you could always create another report class that inherits from PageDocument again, or from TabularDocument, and either way you can build in the specific functionality you need for your report.