diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c49bcd..c3f84d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
+- Broken display in PDF since TCPDF 7
- Dependencies conflicts with core
- Internal server error when generating appliance asset pdf
- CI: fix Psalm cache directory, declare a unique composer autoloader suffix
diff --git a/inc/document.class.php b/inc/document.class.php
index ad67286..0b2d7eb 100644
--- a/inc/document.class.php
+++ b/inc/document.class.php
@@ -77,7 +77,7 @@ public static function pdfForItem(PluginPdfSimplePDF $pdf, CommonDBTM $item)
}
$pdf->displayTitle($title);
- $pdf->setColumnsSize(20, 15, 10, 10, 10, 8, 20, 7);
+ $pdf->setColumnsSize(18, 14, 9, 9, 9, 13, 17, 11);
$pdf->displayTitle(
__s('Name'),
__s('Entity'),
diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php
index b2370ff..981778b 100644
--- a/inc/simplepdf.class.php
+++ b/inc/simplepdf.class.php
@@ -35,6 +35,13 @@
//use TCPDF;
define('K_PATH_IMAGES', '');
+
+if (!defined('K_ALLOWED_PATHS')) {
+ define('K_ALLOWED_PATHS', [
+ GLPI_PICTURE_DIR,
+ Plugin::getPhpDir('pdf') . '/public/pics',
+ ]);
+}
class PluginPdfSimplePDF
{
// Page orientation
@@ -95,7 +102,6 @@ public function __construct($format = 'A4', $orient = '')
//set auto page breaks
$pdf->SetAutoPageBreak(true, 15);
-
// For standard language
// set font
$pdf->SetFont($font, '', 8);
@@ -128,11 +134,12 @@ public function setHeader($msg)
!empty($hook['logo_path'])
&& $config->getField('use_branding_logo')
) {
- $this->pdf->SetHeaderData($hook['logo_path'], 15, $msg, '');
+ $logo_path = $hook['logo_path'];
} else {
- $path = Plugin::getPhpDir('pdf') . '/public/pics/';
- $this->pdf->SetHeaderData($path . 'fd_logo.png', 15, $msg, '');
+ $logo_path = Plugin::getPhpDir('pdf') . '/public/pics/fd_logo.png';
}
+
+ $this->pdf->SetHeaderData($logo_path, 15, $msg, '');
}
/**
@@ -225,6 +232,75 @@ public function displayBox($gray)
Toolbox::deprecated('Calling this function is deprecated');
}
+ /**
+ * Prepare a cell's content so it wraps inside its column instead of overflowing past it.
+ *
+ * @param $msg String cell content (plain text or HTML)
+ * @param $width Float usable width of the target column (mm)
+ **/
+ private function wrapCellContent($msg, $width)
+ {
+ $msg = (string) $msg;
+ if ($width <= 0 || str_contains($msg, '<')) {
+ return $msg;
+ }
+
+ // Check width word by word
+ $words = explode(' ', $msg);
+ foreach ($words as &$word) {
+ $word = $this->breakWordToFit($word, $width);
+ }
+ unset($word);
+
+ return implode(' ', $words);
+ }
+
+ /**
+ * Insert the minimum number of breakable spaces needed for a single word to fit a width.
+ *
+ * @param $word String single word (no spaces) to fit
+ * @param $width Float usable width of the target column (mm)
+ **/
+ private function breakWordToFit($word, $width)
+ {
+ if ($this->pdf->GetStringWidth($word) <= $width) {
+ return $word;
+ }
+
+ preg_match_all('/[\/\\\\_.@:-]/', $word, $matches, PREG_OFFSET_CAPTURE);
+ $breakpoints = array_map(static fn($match) => $match[1] + 1, $matches[0]);
+ if ($breakpoints === []) {
+ // No delimiter to split on: force a character-level break so the word still fits.
+ $result = '';
+ $chunk = '';
+ foreach (preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY) as $char) {
+ if ($chunk !== '' && $this->pdf->GetStringWidth($chunk . $char) > $width) {
+ $result .= $chunk . ' ';
+ $chunk = '';
+ }
+ $chunk .= $char;
+ }
+
+ return $result . $chunk;
+ }
+ $breakpoints[] = strlen($word);
+
+ $result = '';
+ $linestart = 0;
+ $lastfit = 0;
+ foreach ($breakpoints as $point) {
+ $fits = $this->pdf->GetStringWidth(substr($word, $linestart, $point - $linestart)) <= $width;
+ if (!$fits && $lastfit > $linestart) {
+ $result .= substr($word, $linestart, $lastfit - $linestart) . ' ';
+ $linestart = $lastfit;
+ }
+ $lastfit = $point;
+ }
+ $result .= substr($word, $linestart);
+
+ return $result;
+ }
+
/**
* display a row
*
@@ -236,16 +312,26 @@ public function displayBox($gray)
**/
private function displayInternal($gray, $padd, $defalign, $miny, $msgs)
{
+ $msgs = array_map(
+ fn($msg, $i) => $this->wrapCellContent($msg, ($this->colsw[$i] ?? 0) - (2 * $padd)),
+ $msgs,
+ array_keys($msgs),
+ );
+
$this->pdf->SetFillColor($gray, $gray, $gray);
$this->pdf->SetCellPadding($padd);
$max = $miny;
+ $rowy = $this->pdf->GetY();
+ $pageatstart = $this->pdf->getPage();
+ $measurey = $this->pdf->getMargins()['top'];
- /* dry run - compute max cell height */
+ /* Dry run - compute max cell height. */
$this->pdf->startTransaction();
$i = 0;
foreach ($msgs as $msg) {
if ($i < count($this->cols)) {
+ $this->pdf->SetXY($this->colsx[$i], $measurey);
$this->pdf->writeHTMLCell(
$this->colsw[$i], // $w (float) Cell width. If 0, the cell extends up to the right margin.
$miny, // $h (float) Cell minimum height. The cell extends automatically if needed.
@@ -253,23 +339,31 @@ private function displayInternal($gray, $padd, $defalign, $miny, $msgs)
'', // $y (float) upper-left corner Y coordinate
$msg, // $html (string) html text to print. Default value: empty string.
0, // $border (mixed) Indicates if borders must be drawn around the cell. The value can be a number:
0: no border (default)
1: frame
or a string containing some or all of the following characters (in any order):
L: left
T: top
R: right
B: bottom
or an array of line styles for each border group - for example: array('LTRB' => array('width' => 2, 'cap' => 'butt', 'join' => 'miter', 'dash' => 0, 'color' => array(0, 0, 0)))
- 0, // $ln (int) Indicates where the current position should go after the call. Possible values are:
0: to the right (or left for RTL language)
1: to the beginning of the next line
2: below
+ 2, // $ln (int) Indicates where the current position should go after the call. Possible values are:
0: to the right (or left for RTL language)
1: to the beginning of the next line
2: below
1, // $fill (boolean) Indicates if the cell background must be painted (true) or transparent (false).
true, // $reseth (boolean) if true reset the last cell height (default true).
self::LEFT, // $align (string) Allows to center or align the text. Possible values are:
L : left align
C : center
R : right align
'' : empty string : left for LTR or right for RTL
true, // $autopadding (boolean) if true, uses internal padding and automatically adjust it to account for line width.
);
- if ($this->pdf->getLastH() > $max) {
- $max = $this->pdf->getLastH();
+ $height = $this->pdf->GetY() - $measurey;
+ if ($this->pdf->getPage() === $pageatstart && $height > $max) {
+ $max = $height;
}
$i++;
} else {
break;
}
}
- $this->pdf = $this->pdf->rollbackTransaction();
+ $this->pdf->rollbackTransaction(true);
+
+ // Detect if content will go over page footer and add new page
+ if ($rowy + $max > $this->pdf->getPageHeight() - $this->pdf->getBreakMargin()) {
+ $this->pdf->AddPage();
+ $rowy = $this->pdf->GetY();
+ }
/* real run */
+ $pagebefore = $this->pdf->getPage();
$i = 0;
foreach ($msgs as $msg) {
if ($i < count($this->cols)) {
@@ -298,7 +392,12 @@ private function displayInternal($gray, $padd, $defalign, $miny, $msgs)
break;
}
}
- $this->pdf->SetY($this->pdf->GetY() + 1);
+ // TCPDF 7.0 doesn't update getLastH / $lastH, so we need to track the current height ourselves.
+ if ($this->pdf->getPage() === $pagebefore) {
+ $this->pdf->SetY($rowy + $max + 1);
+ } else {
+ $this->pdf->SetY($this->pdf->GetY() + 1);
+ }
}
/**
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..b827cdf
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,18 @@
+
+
+
+ src
+
+
+
+
+
+ tests
+
+
+
diff --git a/tests/SimplePDFTest.php b/tests/SimplePDFTest.php
new file mode 100644
index 0000000..339a3b7
--- /dev/null
+++ b/tests/SimplePDFTest.php
@@ -0,0 +1,100 @@
+.
+ *
+ * @author Nelly Mahu-Lasson, Remi Collet, Teclib
+ * @author Teclib
+ * @copyright Copyright (c) 2009-2026 PDF plugin team
+ * @license AGPL License 3.0 or (at your option) any later version
+ * @link https://github.com/pluginsGLPI/pdf/
+ * @link http://www.glpi-project.org/
+ * @package pdf
+ * @since 2009
+ * http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ * --------------------------------------------------------------------------
+ */
+
+use Glpi\Tests\GLPITestCase;
+
+class SimplePDFTest extends GLPITestCase
+{
+ private function getStringWidth(PluginPdfSimplePDF $pdf, string $string): float
+ {
+ $property = new ReflectionProperty(PluginPdfSimplePDF::class, 'pdf');
+
+ return $property->getValue($pdf)->GetStringWidth($string);
+ }
+
+ public function testWrapCellContentLeavesHtmlUntouched(): void
+ {
+ $pdf = new PluginPdfSimplePDF();
+ $html = '' . str_repeat('a', 200) . '';
+
+ $this->assertSame($html, $this->callPrivateMethod($pdf, 'wrapCellContent', $html, 10));
+ }
+
+ public function testWrapCellContentLeavesContentUntouchedWhenWidthIsNotPositive(): void
+ {
+ $pdf = new PluginPdfSimplePDF();
+ $msg = str_repeat('a', 200);
+
+ $this->assertSame($msg, $this->callPrivateMethod($pdf, 'wrapCellContent', $msg, 0));
+ }
+
+ public function testBreakWordToFitLeavesWordUntouchedWhenItAlreadyFits(): void
+ {
+ $pdf = new PluginPdfSimplePDF();
+
+ $this->assertSame('short', $this->callPrivateMethod($pdf, 'breakWordToFit', 'short', 100));
+ }
+
+ public function testBreakWordToFitSplitsOnDelimiters(): void
+ {
+ $pdf = new PluginPdfSimplePDF();
+ $word = str_repeat('a', 20) . '/' . str_repeat('b', 20) . '-' . str_repeat('c', 20);
+ $width = $this->getStringWidth($pdf, str_repeat('a', 30));
+
+ $result = $this->callPrivateMethod($pdf, 'breakWordToFit', $word, $width);
+ $chunks = explode(' ', $result);
+
+ $this->assertSame($word, str_replace(' ', '', $result));
+ $this->assertGreaterThan(1, count($chunks));
+ foreach ($chunks as $chunk) {
+ $this->assertLessThanOrEqual($width, $this->getStringWidth($pdf, $chunk));
+ }
+ }
+
+ public function testBreakWordToFitFallsBackToCharacterSplitWithoutDelimiters(): void
+ {
+ $pdf = new PluginPdfSimplePDF();
+ $word = str_repeat('a', 200);
+ $width = $this->getStringWidth($pdf, str_repeat('a', 10));
+
+ $result = $this->callPrivateMethod($pdf, 'breakWordToFit', $word, $width);
+ $chunks = explode(' ', $result);
+
+ $this->assertSame($word, str_replace(' ', '', $result));
+ $this->assertGreaterThan(1, count($chunks));
+ foreach ($chunks as $chunk) {
+ $this->assertNotSame('', $chunk);
+ $this->assertLessThanOrEqual($width, $this->getStringWidth($pdf, $chunk));
+ }
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..f87da9a
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,40 @@
+.
+ *
+ * @author Nelly Mahu-Lasson, Remi Collet, Teclib
+ * @copyright Copyright (c) 2009-2022 PDF plugin team
+ * @license AGPL License 3.0 or (at your option) any later version
+ * @link https://github.com/pluginsGLPI/pdf/
+ * @link http://www.glpi-project.org/
+ * @package pdf
+ * @since 2009
+ * http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ * --------------------------------------------------------------------------
+ */
+
+$current_plugin_folder = basename(dirname(__DIR__));
+
+require __DIR__ . '/../../../tests/bootstrap.php';
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+if (!Plugin::isPluginActive($current_plugin_folder)) {
+ throw new RuntimeException(sprintf('Plugin %s is not active in the test database', $current_plugin_folder));
+}