PDFsharp and MigraDoc
In this blog post, I'll show you how to use the .NET libraries PDFsharp and MigraDoc to generate a simple PDF report (download). The entire Visual Studio solution is on GitHub: SimplePdfReport.
PDFsharp is a library for creating PDF documents by directly drawing objects or text. MigraDoc is a library for creating a document model, which can then be converted to a PDF document.
Creating a document model with MigraDoc feels like creating a document in a word processor. For example, one can add a header and footer, add paragraphs with text, set the font size, create a table and format its borders, and so on.
For generating reports, it makes sense to use MigraDoc to create a document model, and then use PDFsharp to render the report as a PDF file. That's what we'll do here.
Because the entire source code is on GitHub, I won't replicate it here. I'll just include code snippets where necessary.
The Interface
The Visual Studio solution contains three projects: the interface, the implementation, and the test. The interface project is called SimplePdfReport.Reporting. This project contains the interface for the reporting service, which will create the report and export it as a PDF file.
The interface project doesn't depend on the PDFsharp or MigraDoc libraries (notice that the project doesn't reference them). This allows the interface to be used by other projects without requiring them to also reference PDFsharp and MigraDoc. This simplifies the compilation process and lets us change implementations easily. I learned about this "Stairway Pattern" from the book Adaptive Code via C# (see my review of the book).
The interface of the report service is very simple:
namespace SimplePdfReport.Reporting
{
public interface IReport
{
void Export(string path, ReportData data);
}
}
The Export method will generate a PDF document and write it to the given path. The ReportData object holds all the information the report service needs. It is defined in the same project as the interface.
Here is the ReportData definition:
namespace SimplePdfReport.Reporting
{
public class ReportData
{
public Patient Patient { get; set; }
public StructureSet StructureSet { get; set; }
}
}
I won't show the rest of the data classes, but the format is similar—a class with several properties. Notice that we're not using classes from the Eclipse Scripting API (ESAPI) directly. The project defines its own data objects. This has two main benefits.
First, the reporting project doesn't depend on ESAPI. If ESAPI changes its API, it won't directly affect the reporting service. Second, the data objects contain only what the reporting service needs, which makes the code clearer. The trade-off is that you'll need to copy the necessary data from ESAPI to these objects, but I think it's worth the effort.
The Implementation
The implementation project is called SimplePdfReport.Reporting.MigraDoc. The ending "MigraDoc" is a hint that it uses the MigraDoc library to implement the reporting interface.
The PDFsharp and MigraDoc libraries were installed using NuGet. It's important that the version is at least 1.5 because we'll be using a feature not available in previous versions.
The ReportPdf class implements the IReport interface. Here's the Export method:
public void Export(string path, ReportData data)
{
ExportPdf(path, CreateReport(data));
}
private void ExportPdf(string path, Document report)
{
var pdfRenderer = new PdfDocumentRenderer();
pdfRenderer.Document = report;
pdfRenderer.RenderDocument();
pdfRenderer.PdfDocument.Save(path);
}
It calls the ExportPdf method after generating the report (a MigraDoc Document object). The ExportPdf method uses a PDF renderer to generate the PDF document and write it to the given path.
Document Set-Up
To generate the report, we call the CreateReport method:
private Document CreateReport(ReportData data)
{
var doc = new Document();
CustomStyles.Define(doc);
doc.Add(CreateMainSection(data));
return doc;
}
It starts by creating the Document object. It then defines various custom styles that we'll use. If you're not familiar with styles, it's just a way to standardize the look of a document by using named sets of font properties (think of Title, Heading, Caption, etc.) This definition is done in a helper class called CustomStyles in the Internal folder.
By the way, there are several helper classes I've put in the Internal folder. These classes are declared using the "internal" keyword. This makes them invisible to external projects, so they can't be used (or misused). In this project, only the ReportPdf class should be used. I like to put internal classes in their own folder so that they don't pollute the main (public) directory.
After defining the styles, we create the main "section" and add it to the document. Documents may contain multiple sections, but usually you'll just use one section. A section can have a specific orientation (landscape or portrait) and one type of header and footer. A section is automatically split into pages when rendered as a PDF (see MigraDoc: PageSetup, Headers, Footers).
Now let's look inside the CreateMainSection method:
private Section CreateMainSection(ReportData data)
{
var section = new Section();
SetUpPage(section);
AddHeaderAndFooter(section, data);
AddContents(section, data);
return section;
}
After creating a new section, we set up the page (size and margins). We then add the header and footer. Finally, we add the main contents.
Header and Footer
To add the header and footer, we use a separate class so that we don't end up with a huge ReportPdf class. Let's go through the code for adding the header:
private void AddHeader(Section section, Patient patient)
{
var header = section.Headers.Primary.AddParagraph();
header.Format.AddTabStop(Size.GetWidth(section), TabAlignment.Right);
header.AddText($"{patient.LastName}, {patient.FirstName} (ID: {patient.Id})");
header.AddTab();
header.AddText($"Generated {DateTime.Now:g}");
}
First we add a paragraph to the "primary" header of the section. It will appear on every page of the document. This paragraph will just have one line, the patient's name and ID on the left and the current date and time on the right.
There are different ways of implementing this, but a simple way is to use a right-aligned tab stop on the right margin. (Another way could be to create a table with two columns.) The AddTabStop method needs the location of the tab, so we call the GetWidth(section) in the Size helper class, which uses the page size and margins to calculate the width of the page.
We then add the name and ID of the patient, which will appear on the left. We follow it with a tab, so that any additional text appears on the right margin. Finally, we add the current date and time.
The footer is very similar, but it uses the AddPageField() and AddNumPagesField() provided by MigraDoc to insert the current page and total page number to the current paragraph.
Patient Information
After adding the header and footer to the section, we then add the main contents, which includes some patient information and a table of structures, both of which are implemented in separate classes.
For the patient information, we use a table with two columns to have a left and a right side. In the AddPatientInfoTable method, we create the table, color the table's background, set up the cell spacing, and then add two columns and one row.
To add the information on the left, we use the AddLeftInfo method (passing in the Cell object corresponding to the only row of the first column):
private void AddLeftInfo(Cell cell, Patient patient)
{
// Add patient name and sex symbol
var p1 = cell.AddParagraph();
p1.Style = CustomStyles.PatientName;
p1.AddText($"{patient.LastName}, {patient.FirstName}");
p1.AddSpace(2);
AddSexSymbol(p1, patient.Sex);
// Add patient ID
var p2 = cell.AddParagraph();
p2.AddText("ID: ");
p2.AddFormattedText(patient.Id, TextFormat.Bold);
}
private void AddSexSymbol(Paragraph p, Sex sex)
{
p.AddImage(new SexSymbol(sex).GetMigraDocFileName());
}
We've defined the patient name's style previously, so we use it when adding the patient's name. We also add the sex symbol (an image) next to the name. To do this, I used the technique described in MigraDoc: Images from Memory, which I wrapped in the SexSymbol class.
By the way, the images for the sex symbols are stored as PNG files in the Resources folder. These images must be configured as "Embedded resources," so that they're properly added to the assembly when building the project.
Below the patient name, we add the patient ID. I used the convenience method AddFormattedText() to specify the text and its format, which in this case is bold.
Adding the information on the right side is similar. The only interesting thing may be the calculation of the age of the patient based on his or her birthday.
Structures Table
For the structures content, we start by adding the heading, which is composed of the structure set ID and information about the related image.
We then add the title for the structures table. This title is just a paragraph with a custom style, where the font color is white and the background color is black (as well as adding some padding to make it look better). To make sure that the table title and the table itself are never separated, we set the format option KeepWithNext to true. (Obviously, this isn't a problem for this simple report, but it may be necessary for more complex reports).
Finally, we add the structures table itself:
private void AddStructureTable(Section section, Structure[] structures)
{
var table = section.AddTable();
FormatTable(table);
AddColumnsAndHeaders(table);
AddStructureRows(table, structures);
AddLastRowBorder(table);
AlternateRowShading(table);
}
We start by creating a new table and formatting it. In the AddColumnsAndHeaders method, we add the correct number of columns and column sizes, as well as a row for the column headers with a custom style. We also add a thin border to the bottom of the header row.
In the AddStructureRows method, we add the rows of the table, adding a paragraph with the corresponding text to each cell of the row.
Finally, we modify the table in two ways. First, we add a thick border to the last row of the table. Then, we shade alternating rows to make the rows easier to read:
private void AlternateRowShading(Table table)
{
// Start at i = 1 to skip column headers
for (var i = 1; i < table.Rows.Count; i++)
{
if (i % 2 == 0) // Even rows
{
table.Rows[i].Shading.Color = Color.FromRgb(216, 216, 216);
}
}
}
The Test
The test project is called SimplePdfReport.Test. It's a simple console application that uses the reporting service to generate a report from made up patient data and show it to you. I use it to see my progress as I implement the reporting functionality. This project needs to reference both the interface and implementation projects. Here's the Main method:
private static void Main()
{
var reportService = new ReportPdf();
var reportData = CreateReportData();
var path = GetTempPdfPath();
reportService.Export(path, reportData);
Process.Start(path);
}
It creates the implementation class of the reporting service. It then creates the made up patient data we want to report. In a real application, we would copy all the information we want to report, such as from ESAPI, to the data objects.
Then, it generates a temporary file location (with a .pdf extension), which is sent to the reporting service's Export method. The generated PDF report is saved to that location.
Finally, the Process.Start method tries to open the PDF file using the default PDF viewer, such as Adobe Acrobat. The console application ends after this call, but the PDF viewer is left open.
Final Thoughts
In a real application, your reporting interface and implementation projects would be members of a solution with many other projects. Ideally, the reporting service class would be instantiated only once, somewhere in your starting project. This project would be the only one to reference the implementation project. All other projects that may use the reporting service would just reference the interface project. These projects obtain the reporting service via dependency injection.
Comments