Wrap elements in a group with a border using TCPDF

From time to time, I stumbled upon TCPDF and knew that many higher-level libraries, CMSs use it as a dependency. But until now, when I had to create a complex, dynamic document, I never worked with it.

It has some quirks. For example, the getPageWidth is with lowercase, but the GetX is not, even though both are public methods.

Another one, if you want to have a different vertical alignment than the default on a cell, you have to overwrite 14 arguments before you can specify that you want it aligned to the bottom.

// See the "B" at the end which stands for bottom
$pdf->MultiCell(200, 80, 'Lorem ipsum ...', 0, 'J', false, 1, $pdf->GetX(), $pdf->GetY(), true, 0, false, true, 0, 'B');

One more, when you call SetMargins to set the margins for the page, there's no option for the bottom. You can only set the left, top, right.

But putting all these aside, in the end, it grew on me, and I'm grateful somebody took the time to create, continuously develop, and maintain it since 2002.


In the beginning, one of the things that tripped me up was not being able to nest cells inside other cells or somehow group them into another object.

A cell in TCPDF world is a rectangular area containing some text. You can apply some basic styles to the cell, like background color and border width. The rectangular area is invisible by default, so you will see just the text with no background color or border.

Usually, you call the Cell, MultiCell, or similar methods to display a text.

In one area, I wanted to achieve a configuration of elements like this:

After going over all the 65 examples, I was baffled. I could not see how I can create a cell with a border that contains other cells.

This is not something that works:

$pdf->setCellPaddings(4);
$pdf->MultiCell(
0, // the width; 0 means as long as the page
0, // the height; 0 means as long as the content
$pdf->MultiCell(0, 0, 'Lorem ipsum ...'),
1 // 1 enables the border using the default style
);

But after I started thinking about TCPDF more like the Canvas API, I realized there's no need to group or nest elements.

It's all about the looks.

It's not a coincidence that you can at any point render, draw something anywhere on the page.

TCPDF renders elements usually from top to bottom, but you can jump from one point to another and continue from there.

There's something similar even in their API:

const ctx = canvas.getContext('2d');
 
ctx.rect(10, 20, 150, 100);
ctx.stroke();
$pdf = new TCPDF();
 
$pdf->SetLineStyle(...);
$pdf->Rect(10, 20, 150, 100);

It's more like a drawing tool than some templating language.

My conclusion was, if I want to group things, I have to draw four lines around the elements or use a rectangle or a polygon. Visually the result is the same.

In my case, this was not a one-time-needed thing, I had to "wrap elements in a group" multiple times, so I created the wrapInFauxGroupWithBorder method.

class ExtendedTCPDF extends TCPDF
{
public function wrapInFauxGroupWithBorder(callable $innerContent)
{
// ...
$innerContent();
// ...
}
}

This allows me to use it like this:

$pdf = new ExtendedTCPDF();
 
$pdf->wrapInFauxGroupWithBorder(function () use ($pdf) {
$pdf->MultiCell(...);
$pdf->WriteHTML(...);
$pdf->MultiCell(...);
});

I can put - what I consider - the inner content in the callback, and the method will take care of the necessary calculation, setting padding, and drawing the border.


If you ever need this too, you can go about it in the following way.

As the first step, just draw the border around whatever the inner content is.

Here's the code for this:

class ExtendedTCPDF extends TCPDF
{
public function wrapInFauxGroupWithBorder(callable $innerContent)
{
$groupStartX = $this->GetX();
$groupStartY = $this->GetY();
 
$pageMargins = $this->getMargins();
 
$pageWidth = $this->getCurrentPageWidth();
$pageInnerWidth = $pageWidth - $pageMargins['left'] - $pageMargins['right'];
 
$innerContent();
 
$innerContentEndY = $this->GetY();
$innerContentHeight = $innerContentEndY - $groupStartY;
 
$this->Rect(
$groupStartX,
$groupStartY,
$pageInnerWidth,
$innerContentHeight
);
}
public function getCurrentPageWidth(): int
{
return $this->getPageWidth();
}
}

The main concern here is determining the rectangle's width and height that visually wraps the content.

The Rect needs the starting positions and the dimensions. From where you start drawing is given, it's from where the "group" starts.

The width should be the width of the content area. That is the width of the page minus the margins.

The height should be the height of the inner content. You can get that by subtracting the Y-axis position from the moment it stops rendering from the place it started.

The Rect method accepts additional arguments. Those are for the border style and the fill color, but I'm okay with inheriting the defaults.

The getCurrentPageWidth is up to you; it's not that important. I prefer to make explicit that I'm getting the width of the current page, as with the getPageWidth, you can get the width of other pages.

For the next step, apply the padding to add some space between the border and content.

class ExtendedTCPDF extends TCPDF
{
+ public const GROUP_INNER_PADDING = 4;
public function wrapInFauxGroupWithBorder(callable $innerContent)
{
$groupStartX = $this->GetX();
$groupStartY = $this->GetY();
 
$pageMargins = $this->getMargins();
 
$pageWidth = $this->getCurrentPageWidth();
$pageInnerWidth = $pageWidth - $pageMargins['left'] - $pageMargins['right'];
 
+ $this->StartTransform();
+ $this->Translate(self::GROUP_INNER_PADDING, self::GROUP_INNER_PADDING);
 
$innerContent();
 
$innerContentEndY = $this->GetY();
$innerContentHeight = $innerContentEndY - $groupStartY;
 
+ $this->StopTransform();
 
$this->Rect(
$groupStartX,
$groupStartY,
$pageInnerWidth,
- $innerContentHeight
+ $innerContentHeight + self::GROUP_INNER_PADDING * 2
);
 
+ $this->SetY($innerContentEndY + self::GROUP_INNER_PADDING * 2);
}
}

Keep the starting position of the rectangle but shift the position of the inner content elements.

Instead of modifying the X and Y values, use the Translate method. This keeps the calculations way simpler. Just trust me on this.

To keep the change isolated, don't forget to wrap it in the StartTransform and StopTransform. Otherwise, all subsequent elements are going to be offset.

The height of the rectangle has to increase by the value of the top and bottom padding. If it's equal, just multiply it by two.

When you use the Translate that does not change the Y position (nor the X), you have to explicitly set the next element's starting position. If not, elements will overlap.

Here's how it all looks now:

The last piece of the puzzle deals with the elements without an explicit width. Currently, these are exceeding our "group" width.

If you don't set the width explicitly for a cell, that will be the content area's width. TCPDF assigns the width correctly here, but remember how you shifted the positions with the Translate method, and now you have to account for that.

One way to solve this is to temporarily change the page's width and then set it back once the inner contents are rendered.

class ExtendedTCPDF extends TCPDF
{
public function wrapInFauxGroupWithBorder(callable $innerContent)
{
$groupStartX = $this->GetX();
$groupStartY = $this->GetY();
 
$pageMargins = $this->getMargins();
 
- $pageWidth = $this->getCurrentPageWidth();
- $pageInnerWidth = $pageWidth - $pageMargins['left'] - $pageMargins['right'];
+ $outsideGroupPageWidth = $this->getCurrentPageWidth();
+ $outsideGroupPageInnerWidth = $outsideGroupPageWidth - $pageMargins['left'] - $pageMargins['right'];
 
$this->StartTransform();
$this->Translate(self::GROUP_INNER_PADDING, self::GROUP_INNER_PADDING);
 
+ $this->setCurrentPageWidth($outsideGroupPageWidth - self::GROUP_INNER_PADDING * 2);
 
$innerContent();
 
$innerContentEndY = $this->GetY();
$innerContentHeight = $innerContentEndY - $groupStartY;
 
$this->StopTransform();
 
$this->Rect(
$groupStartX,
$groupStartY,
$outsideGroupPageInnerWidth,
$innerContentHeight + self::GROUP_INNER_PADDING * 2
);
 
+ $this->setCurrentPageWidth($outsideGroupPageWidth);
 
$this->SetY($innerContentEndY + self::GROUP_INNER_PADDING * 2);
}
+ public function setCurrentPageWidth(int $width)
+ {
+ $this->w = $width;
+ }
}

TCPDF does not have a method for setting the page width using a method, hence the introduction of setCurrentPageWidth.

And with all this, you encapsulated all the calculations in a reusable method that you can call anytime when you need "group elements" together: