Some users on the Xojo Forum recently asked how to render tables on a PDFDocument using the PDFTableDataSource class interface when the dat source does not comes from a RowSet. Well, the truth is that it doesn’t differ too much from when it does! Continue reading and I will show you how using a simple example.
The trick is to handle the data coming from any source with the right data structure, for example, an Array. This example uses a plain text file with several lines and columns separated by the Tab character as the source of the data.
In order to follow this tutorial, download the example project.
Getting the Data
As you can see in the example project, the text file has been added to the project using a Build > Copy Files Step (the file name is “Data.txt”), so it is copied to the Resources folder of the compiled app and available for both debugging and release builds.
Then, in the Pressed event of the Button1 control placed on the default Window1, we will access the contents of the file in order to store each of the lines in the Data Array of strings:
Var f As FolderItem = SpecialFolder.Resource("data.txt")
If f <> Nil And f.Exists Then
Var tis As TextInputStream = TextInputStream.Open(f)
Var s As String = tis.ReadAll
data = s.ToArray(EndOfLine.macOS)
End If
PDFRenderer Subclass
The next step is to create a custom class that will implement the methods provided by the PDFTableDataSource Class Interface. We will name the class “PDFRenderer”. Then, from the Inspector Panel, click on the Interfaces button, check the PDFTableDataSource interface and confirm the selection.
Once done, the methods from the class interface will be automatically added to the class:
- AddNewRow
- Completed
- HeaderHeight
- MergeCellsForRow
- PaintCell
- PaintHeaderContent
- RowHeight
Over these we will add three more methods:
The class Constructor:
Public Sub Constructor(document As PDFDocument)
// Simple constructor… just assign the received object
// to the "Document" property
Self.document = document
proxy = New Picture(document.Graphics.Width, document.Graphics.Height)
End Sub
Assign the received PDFDocument instance to the document property. It also creates a graphics instance that will be used as a proxy for calculating the height of the processed text from the data (that won’t be necessary starting with Xojo 2024r4 where some PDFDocument bugs related with that are already fixed).
DrawTable:
This method is the one that will be called to start drawing the table. It will initialize some properties from the received Data (yeah, you can see a property named as “DrawingFromRowSet”, but I left that one on purpose because this project is based on the one dealing with the data coming from a RowSet, so you can see how similar all the process is):
Public Sub DrawTable(data() As String, headers() As String)
// This is the method we call on the Class to draw
// a table on a PDFDocument based on an Array of Strings
If document <> Nil And data.LastIndex <> -1 Then
// Assign the received Array to the "Data" property
Self.data = data
// Assign the received headers to the "Headers" property
Self.Headers = Headers
// Hey! We are going to create a table from an Array
// so we use a Boolean property as a flag for that
// (Yeah, we can do it using other techniques, but this
// is easy enough for this purpose… while leave this
// helper class "open" enough for drawing tables based on other
// data sources).
Self.DrawingFromRowSet = True
// How many columns are we going to draw?
// Well… as many as columns (TABs) in any of the Array items (for example, the first one).
Var totalColumns As Integer = data(0).CountFields(Chr(9))
// This is going to be the "drawable area" on the page
// for the table = total page width less the left and right margins
Var totalWidth As Double = document.Graphics.Width - 40 // 40 = 20 points left/right margins
// Creating the PDFTable object here!
Var table As New PDFTable
// We want to repeat the headers on every page
table.HasRepeatingHeader = True
// Setting the column count for the table
table.ColumnCount = totalColumns
// …and the width for every column.
table.ColumnWidths = CalculateColumnWidths(totalWidth, totalColumns)
// The object from this class will be the responsible
// of handling all the methods associated with the
// PDFTableDataSouce Class Interface
table.DataSource = Self
// Setting the Top and Bottom margins for the drawing
// of the table on every PDF page
table.TopMargin = 20
table.BottomMargin = 20
// …and finally we instruct the PDFDocument object
// to draw the table!
document.AddTable(table, 20, 0)
// Lastly, clearing the flag
Self.DrawingFromRowSet = False
End If
End Sub
CalculateColumnWidths Method
This one will calculate the widths for each column to be rendered in the final PDF document.
Protected Function CalculateColumnWidths(TotalWidth As Double, NumberOfColumns As Double) As String
// Helper method to get a string for the column widths
ColumnWidth = TotalWidth / NumberOfColumns
Var s() As String
For n As Integer = 0 To NumberOfColumns - 1
s.Add(Str(ColumnWidth))
Next
Return String.FromArray(s, ",")
End Function
Class Properties
Because we need to track things like what the current row or column is to be rendered, the headers, etc., our class needs to add some properties:
- Protected Property ColumnWidth As Integer
- Public Property CurrentColumn As Integer
- Protected Property CurrentHeight As Integer
- Protected Property CurrentRow As Integer
- Protected Property data() As String
- Protected Property document As PDFDocument
- Protected Property DrawingFromRowSet As Boolean
- Public Property Headers() As String
- Protected Property LastRow As Integer
- Protected Property proxy As Picture
PDFTableDataSouce Methods
Time to visit the methods added by the PDFTableDataSouce class interface. These are the methods called by the PDFTable object each time it needs to retrieve a piece of information during the rendering on the PDFDocument, as for example if it needs to add a new row, the height for the Header or the current row… and the painting of the row itself!
AddNewRow Method
This one is called by the PDFTable object in order to know if it needs to render a new row in the table:
Protected Function AddNewRow(rowCount As Integer) As Boolean
// Part of the PDFTableDataSource interface.
If DrawingFromRowSet And data.LastIndex <> -1 then
// We are going to draw as many rows as rows are in the
// "data" array
Return rowCount <= data.LastIndex
End If
End Function
Completed method
This is the method invoked by the PDFTable object when it finished drawing the table, so it is your chance to take additional operations, for example, numbering every page of the PDFDocument (we can’t anticipate that before drawing the table itself):
Protected Sub Completed(x As Double, y As Double)
// Part of the PDFTableDataSource interface.
// This method is called once the table has been drawed
// so let's "print" the page number on every page
// of the PDF Document
Static pageNumber As String = "Page: "
If document <> Nil Then
Var g As Graphics = document.Graphics
For n As Integer = 1 To document.PageCount
document.CurrentPage = n
g.DrawText(pageNumber + Str(n), g.Width - g.TextWidth(pageNumber + Str(n)) - 20, g.Height - g.FontAscent)
Next
End If
End Sub
HeaderHeight Method
This is the method called by the PDFTable object the returns the height we want to use for rendering the header on the first page and, optionally, on every new page required (added to the document) by rendering the table. In this case we will return a fixed value:
Protected Function HeaderHeight() As Double
// Part of the PDFTableDataSource interface.
// Returning a fixed height value for the headers
Return 20
End Function
MergeCellsForRow Method
The PDFTableDataSouce class interface also has the ability to merge cells in the current row, so you can create more elaborated tables. We are not going to use that feature in this example project so it will be empty, no code to be executed.
PaintHeaderContent Method
This is the method in charge of rendering the header for the table, so it receives its own Graphic context with the provided height from the HeaderHeight method and the width calculated from the total of columns (headers) needed to be rendered:
Protected Sub PaintHeaderContent(g As Graphics, column As Integer)
// Part of the PDFTableDataSource interface.
// Painting the headers for the table
If column <= Self.Headers.LastIndex Then
Var s As String = headers(column)
g.DrawingColor = Color.Black
g.FillRectangle(0, 0, g.Width, g.Height)
g.DrawingColor = Color.White
g.DrawText(s, g.Width / 2 - g.TextWidth(headers(column)) / 2, g.Height / 2 + g.FontAscent / 2)
End If
End Sub
RowHeight Method
As the class interface provides a method so we can provide the height for the header, we also have a RowHeight method that the PDFTable object will call before it calls the method in charge of doing the row drawing itself. That way we can have rows of different height based on the height of the own text to be rendered!
Protected Function RowHeight() As Double
// Part of the PDFTableDataSource interface.
// We need to calculate the height for every row in the Table
// so let's calculate that based on the taller of the texts (columns)
// based on text wrapping
If CurrentRow <= data.LastIndex Then
CurrentHeight = 0
Var s() As String = data(CurrentRow).Split(Chr(9))
Var g As Graphics = proxy.Graphics
Var itemHeight As Integer
For Each item As String In s
itemHeight = g.TextHeight(item, ColumnWidth - 40)
If itemHeight > CurrentHeight Then
CurrentHeight = itemHeight
End If
Next
CurrentRow = CurrentRow + 1
Return CurrentHeight + 20
End If
End Function
PaintCell Method
Lastly, this is the method called by the PDFTable object when it is time to render a given cell for the current row in the table. It receives as parameters both a graphic context with the width and height already suited for the data to be rendered, and also the row and column values so you can get the appropriate data for a given cell from the data source:
Protected Sub PaintCell(g As Graphics, row As Integer, column As Integer)
// Part of the PDFTableDataSource interface.
// Here is where we really do the drawing
// the received "g" parameter is for a particula table cell
// based on the row / column parameters
// so the origin for X/Y coordinates is at 0,0
If DrawingFromRowSet And data.LastIndex >= row Then
// Drawing the outer rectangle for the cell
g.DrawRectangle(0, 0, g.Width, g.Height)
// retrieving the text to be drawn from the Array,
// using the row and column parameters for that.
Var s As String = data(row).NthField(Chr(9), column + 1)
// Centering vertically the text on the table cell
// while the X offset is fixed at 5 points.
g.DrawText(s, 5, g.Height / 2 + g.FontAscent / 2, ColumnWidth - 20)
// Let's keep track of the last row drawed
LastRow = row
End If
End Sub
Time to push the Start button!
Everything is setup now in our PDFRenderer class, so let’s get back to the Pressed event of the Button1 control on the default Window1 window. You will remember from the beginning of this tutorial that this is the place where we retrieved the data from the text file. So let’s add the code to create a PDFDocument instance, a new PDFRenderer instance and do the rendering of the table itself from the data:
If data.LastIndex <> -1 Then
// Creating a new PDFDocument Instance
Var d As New PDFDocument
// Creating the PDFTable renderer helper object (we pass the PDFDocument object to it in the Constructor)
Var PDFRender As New PDFRenderer(d)
// These will be the headers drawed in the Table
Var headers() As String = Array("Name", "Surname", "Address", "City", "Email")
// And let's instruct the PDFTable renderer helper to draw the table
// based in the SQLite database rowset we got in the previous step
PDFRender.DrawTable(data, headers)
// …and save the resulting PDF File to the Desktop
Var out As FolderItem = SpecialFolder.Desktop.Child("TableFromTextFile.pdf")
If out <> Nil Then
d.Save(out)
out.Open
End If
End If
Conclusion
As you see, and if this tutorial is compared with PDFTable from a RowSet, creating a PDFTable doesn’t differ too much when using a data source other than a RowSet. All you need, basically, is to keep track of the current row, know if you need to render more rows and a base data structure that can be used to retrieve the data to be rendered in a given Row/Column.
As always, you can learn more about other features provided by PDFDocument in the Xojo Documentation.
Happy Coding!
Javier Menendez is an engineer at Xojo and has been using Xojo since 1998. He lives in Castellón, Spain and hosts regular Xojo hangouts en español. Ask Javier questions on Twitter at @XojoES or on the Xojo Forum.