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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fix warning on the Domains tab of PDF exports
- Fix errors when generating a pdf with software license/installation tabs
- Incomplete columns when exporting Users or Groups
- Fix content missing from PDF export when a table contains a large image

## [4.1.6] - 2026-10-06

Expand Down
31 changes: 24 additions & 7 deletions inc/simplepdf.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -393,19 +393,25 @@ public function displayText($name, $content = '', $minline = 3, $maxline = 100)
// The sanitizer encodes some characters in attributes (e.g. "=" => "=", "+" => "+"), which TCPDF
// does not decode: document URLs are no longer recognized and base64 data URIs get corrupted.
// Images are therefore resolved here from their decoded "src" and passed to TCPDF as validated data streams.
$content = preg_replace_callback(
// Resolved images are replaced by placeholders until the end of the processing: their (large) base64 data
// would otherwise make the table regex hit "pcre.backtrack_limit" and the whole content would be lost.
$images = [];
$placeholder_token = 'pdfimg' . bin2hex(random_bytes(8)) . '_';
$content = preg_replace_callback(
'/(<a\b[^>]*>\s*)?(<img\b[^>]*>)(\s*<\/a>)?/i',
function (array $match): string {
function (array $match) use (&$images, $placeholder_token): string {
$img_tag = $this->buildPdfImageTag($match[2]);
if ($img_tag === null) {
return $match[0];
}
$placeholder = $placeholder_token . count($images);
$images[$placeholder] = $img_tag;
if ($match[1] !== '' && ($match[3] ?? '') !== '') {
// Remove the link wrapping the image
return $img_tag;
return $placeholder;
}

return $match[1] . $img_tag . ($match[3] ?? '');
return $match[1] . $placeholder . ($match[3] ?? '');
},
$content,
);
Expand All @@ -429,7 +435,18 @@ function (array $match): string {
}
}

$this->displayInternal(240, 0.5, self::LEFT, $minline * 5, [$formatted_content]);
// Put back the resolved images
$formatted_content = strtr($formatted_content, $images);

// TCPDF also matches tables with a lazy regex and silently drops the whole content when it fails:
// raise "pcre.backtrack_limit" to fit the content size (large embedded images) while rendering it.
$backtrack_limit = ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', (string) max((int) $backtrack_limit, 2 * strlen($formatted_content)));
try {
$this->displayInternal(240, 0.5, self::LEFT, $minline * 5, [$formatted_content]);
} finally {
ini_set('pcre.backtrack_limit', $backtrack_limit);
}

/* Restore */
[$this->cols, $this->colsx, $this->colsw, $this->align, ] = $save;
Expand All @@ -447,10 +464,10 @@ function (array $match): string {
*/
private function buildPdfImageTag(string $img_tag): ?string
{
if (!preg_match('/\bsrc\s*=\s*(["\'])(.*?)\1/is', $img_tag, $src_match)) {
if (!preg_match('/\bsrc\s*=\s*(?|"([^"]*)"|\'([^\']*)\')/i', $img_tag, $src_match)) {
return null;
}
$src = html_entity_decode($src_match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$src = html_entity_decode($src_match[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');

$data = null;
if (preg_match('/^data:image\/[a-z0-9.+-]+;base64,(.*)$/is', $src, $data_match)) {
Expand Down
65 changes: 65 additions & 0 deletions tests/Units/SimplePdfTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

use Document;
use Glpi\Tests\DbTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PluginPdfSimplePDF;
use Psr\Log\LogLevel;
use ReflectionMethod;
Expand Down Expand Up @@ -181,6 +182,53 @@ public function testDisplayTextDropsCorruptedBase64Image(): void
$this->assertStringNotContainsString('/Subtype /Image', $output);
}

/**
* Test a large document image inside a table
* Its base64 data made the table regex hit `pcre.backtrack_limit`, and the whole content vanished from the PDF
*/
public function testDisplayTextEmbedsLargeDocumentImageInTable(): void
{
$content = $this->getLargePngContent();
$this->assertGreaterThan(1_000_000, strlen($content));

$docid = $this->createDocumentFile('PNG', 'png', $content);
$url = '/front/document.send.php?docid=' . $docid;

$pdf = new PluginPdfSimplePDF();
$pdf->newPage();
$pdf->displayText(
'Description',
'<table><tr><td><a href="' . $url . '"><img src="' . $url . '" width="100"></a></td></tr></table>',
);

$this->assertStringContainsString('/Subtype /Image', $pdf->output());
}

public static function largeBase64ImageProvider(): iterable
{
yield 'outside a table' => ['<p>before %s after</p>'];
yield 'inside a table' => ['<table><tr><td>before %s after</td></tr></table>'];
}

/**
* Test a large pasted base64 image, inside and outside a table
* Its data made the `src` and table regexes hit `pcre.backtrack_limit`, and the image or the whole content was lost
*/
#[DataProvider('largeBase64ImageProvider')]
public function testDisplayTextEmbedsLargeBase64Image(string $html): void
{
// ~1.5 MB base64 data
$base64 = base64_encode($this->getLargePngContent());
$this->assertGreaterThan(1_500_000, strlen($base64));

$pdf = new PluginPdfSimplePDF();
$pdf->newPage();
$pdf->displayText('Description', sprintf($html, '<img src="data:image/png;base64,' . $base64 . '" width="100">'));

// The whole content (including the image) was lost when a regex failed
$this->assertStringContainsString('/Subtype /Image', $pdf->output());
}

/**
* Create a document whose file is stored in GLPI_DOC_DIR
*/
Expand Down Expand Up @@ -231,6 +279,23 @@ private function getValidPngContent(): string
return ob_get_clean();
}

/**
* Build a ~1.15 MB uncompressed PNG: random pixels are not compressible
*/
private function getLargePngContent(): string
{
$image = imagecreatetruecolor(620, 620);
for ($x = 0; $x < 620; $x++) {
for ($y = 0; $y < 620; $y++) {
imagesetpixel($image, $x, $y, random_int(0, 0xFFFFFF));
}
}
ob_start();
imagepng($image, null, 0);

return ob_get_clean();
}

/**
* Build a PNG file with a valid RGBA header but undecodable image data
*/
Expand Down
Loading