Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion inc/document.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
119 changes: 109 additions & 10 deletions inc/simplepdf.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, '');
}

/**
Expand Down Expand Up @@ -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
*
Expand All @@ -236,40 +312,58 @@ 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.
'', // $x (float) upper-left corner X coordinate
'', // $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:<ul><li>0: no border (default)</li><li>1: frame</li></ul> or a string containing some or all of the following characters (in any order):<ul><li>L: left</li><li>T: top</li><li>R: right</li><li>B: bottom</li></ul> 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:<ul><li>0: to the right (or left for RTL language)</li><li>1: to the beginning of the next line</li><li>2: below</li></ul>
2, // $ln (int) Indicates where the current position should go after the call. Possible values are:<ul><li>0: to the right (or left for RTL language)</li><li>1: to the beginning of the next line</li><li>2: below</li></ul>
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:<ul><li>L : left align</li><li>C : center</li><li>R : right align</li><li>'' : empty string : left for LTR or right for RTL</li></ul>
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)) {
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
18 changes: 18 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
testdox="true"
cacheDirectory="var/phpunit"
>
<source>
<include>
<directory>src</directory>
</include>
</source>

<testsuites>
<testsuite name="Tests">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
100 changes: 100 additions & 0 deletions tests/SimplePDFTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/**
* -------------------------------------------------------------------------
* LICENSE
*
* This file is part of PDF plugin for GLPI.
*
* PDF is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PDF is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Reports. If not, see <http://www.gnu.org/licenses/>.
*
* @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 = '<b>' . str_repeat('a', 200) . '</b>';

$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));
}
}
}
40 changes: 40 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/**
* -------------------------------------------------------------------------
* LICENSE
*
* This file is part of PDF plugin for GLPI.
*
* PDF is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PDF is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Reports. If not, see <http://www.gnu.org/licenses/>.
*
* @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));
}
Loading