From e88d3abba0bca76e71c81d6d5bb92b90fca35c4c Mon Sep 17 00:00:00 2001 From: Julien Durand Date: Tue, 15 Sep 2026 15:33:12 +0200 Subject: [PATCH 1/6] fix: pdf display with tcpdf --- inc/document.class.php | 2 +- inc/simplepdf.class.php | 103 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/inc/document.class.php b/inc/document.class.php index ad67286c..0b2d7eb5 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 b2370ff0..ae7507c5 100644 --- a/inc/simplepdf.class.php +++ b/inc/simplepdf.class.php @@ -95,7 +95,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 +127,14 @@ 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'; } + + /* Pass image as inline data to TCPDF header to avoid permissions error on image's folder */ + $logo = is_file($logo_path) ? '@' . file_get_contents($logo_path) : ''; + $this->pdf->SetHeaderData($logo, 15, $msg, ''); } /** @@ -225,6 +227,64 @@ 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 === []) { + return $word; + } + $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 +296,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 +323,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: or a string containing some or all of the following characters (in any order): 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: + 2, // $ln (int) Indicates where the current position should go after the call. Possible values are: 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: 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 +376,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); + } } /** From 9a291b84e0a2f52d380d3a6a26961ae631799c00 Mon Sep 17 00:00:00 2001 From: Julien DURAND Date: Wed, 30 Sep 2026 09:05:04 +0200 Subject: [PATCH 2/6] fix: header display Co-authored-by: Romain B. <8530352+Rom1-B@users.noreply.github.com> --- inc/simplepdf.class.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php index ae7507c5..0528c866 100644 --- a/inc/simplepdf.class.php +++ b/inc/simplepdf.class.php @@ -132,8 +132,14 @@ public function setHeader($msg) $logo_path = Plugin::getPhpDir('pdf') . '/public/pics/fd_logo.png'; } - /* Pass image as inline data to TCPDF header to avoid permissions error on image's folder */ - $logo = is_file($logo_path) ? '@' . file_get_contents($logo_path) : ''; + /* TCPDF's Header() only accepts a real file path (is_file() check), not the '@data' syntax Image() supports */ + $logo = ''; + if (is_file($logo_path)) { + $tmp_logo = GLPI_TMP_DIR . '/' . uniqid('pdf_header_logo_') . '.' . pathinfo($logo_path, PATHINFO_EXTENSION); + if (copy($logo_path, $tmp_logo)) { + $logo = $tmp_logo; + } + } $this->pdf->SetHeaderData($logo, 15, $msg, ''); } From a9633662c5cbec0debe326693832e9bfd7a00c3e Mon Sep 17 00:00:00 2001 From: Julien DURAND Date: Wed, 30 Sep 2026 09:07:25 +0200 Subject: [PATCH 3/6] fix: word not spliting if it contains no delimiter If a word is too long and has no delimiter, it will overflow Co-authored-by: Romain B. <8530352+Rom1-B@users.noreply.github.com> --- inc/simplepdf.class.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php index 0528c866..9c849248 100644 --- a/inc/simplepdf.class.php +++ b/inc/simplepdf.class.php @@ -265,7 +265,18 @@ private function wrapCellContent($msg, $width) private function breakWordToFit($word, $width) { if ($this->pdf->GetStringWidth($word) <= $width) { - return $word; + // 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; } preg_match_all('/[\/\\\\_.@:-]/', $word, $matches, PREG_OFFSET_CAPTURE); From 22ee59ce2f7a3fb8ff8fcc7cc57587445c90cebb Mon Sep 17 00:00:00 2001 From: Julien Durand Date: Wed, 30 Sep 2026 10:04:15 +0200 Subject: [PATCH 4/6] fix: header logo display --- inc/simplepdf.class.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php index 9c849248..815663bf 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 @@ -132,15 +139,7 @@ public function setHeader($msg) $logo_path = Plugin::getPhpDir('pdf') . '/public/pics/fd_logo.png'; } - /* TCPDF's Header() only accepts a real file path (is_file() check), not the '@data' syntax Image() supports */ - $logo = ''; - if (is_file($logo_path)) { - $tmp_logo = GLPI_TMP_DIR . '/' . uniqid('pdf_header_logo_') . '.' . pathinfo($logo_path, PATHINFO_EXTENSION); - if (copy($logo_path, $tmp_logo)) { - $logo = $tmp_logo; - } - } - $this->pdf->SetHeaderData($logo, 15, $msg, ''); + $this->pdf->SetHeaderData($logo_path, 15, $msg, ''); } /** From 3c0ecae03c5205d50deaf83026ec5c3441af5b72 Mon Sep 17 00:00:00 2001 From: Julien Durand Date: Wed, 30 Sep 2026 11:20:04 +0200 Subject: [PATCH 5/6] chore: Add unit tests --- inc/simplepdf.class.php | 12 ++--- phpunit.xml | 18 ++++++++ tests/SimplePDFTest.php | 100 ++++++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 40 ++++++++++++++++ 4 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/SimplePDFTest.php create mode 100644 tests/bootstrap.php diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php index 815663bf..981778b2 100644 --- a/inc/simplepdf.class.php +++ b/inc/simplepdf.class.php @@ -264,6 +264,12 @@ private function wrapCellContent($msg, $width) 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 = ''; @@ -277,12 +283,6 @@ private function breakWordToFit($word, $width) return $result . $chunk; } - - preg_match_all('/[\/\\\\_.@:-]/', $word, $matches, PREG_OFFSET_CAPTURE); - $breakpoints = array_map(static fn($match) => $match[1] + 1, $matches[0]); - if ($breakpoints === []) { - return $word; - } $breakpoints[] = strlen($word); $result = ''; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..b827cdfc --- /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 00000000..339a3b71 --- /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 00000000..f87da9ae --- /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)); +} From ef276ddf0c2c6b9a9bea76c5aa04964d032233a5 Mon Sep 17 00:00:00 2001 From: Julien Durand Date: Mon, 5 Oct 2026 13:42:51 +0200 Subject: [PATCH 6/6] chore: changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c49bcd9..c3f84d39 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