diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0f2e3d..1f97d6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Fix PDF export crash on tickets with tables containing nested-quote styles
+- Fix base64 images (e.g. in ticket tasks) corrupted or missing in PDF export
## [4.2.0] - 2026-10-06
diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php
index e68d757..4f0ab4e 100644
--- a/inc/simplepdf.class.php
+++ b/inc/simplepdf.class.php
@@ -458,75 +458,32 @@ public function displayText($name, $content = '', $minline = 3, $maxline = 100)
$content = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
$content = RichText::getSafeHtml($content);
- preg_match_all(
- '/]*>\s*(
]*src=["\']([^"\']*docid=(\d+)[^"\']*)["\'][^>]*>)\s*<\/a>/i',
+ // 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.
+ // 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(
+ '/(]*>\s*)?(
]*>)(\s*<\/a>)?/i',
+ 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 $placeholder;
+ }
+
+ return $match[1] . $placeholder . ($match[3] ?? '');
+ },
$content,
- $matches,
- PREG_SET_ORDER,
);
- if ($matches !== []) {
- $document = new Document();
- foreach ($matches as $match) {
- $full_a_tag = $match[0];
- $full_img_tag = $match[1];
- $docid = (int) $match[3];
-
- if (
- $document->getFromDB($docid)
- && $document->canViewFile()
- && isset($document->fields['filepath'])
- ) {
- $file_path = GLPI_DOC_DIR . '/' . $document->fields['filepath'];
- if (file_exists($file_path)) {
- // Get actual image dimensions from file
- $img_info_actual = @getimagesize($file_path);
- $actual_width = 450; // Default
- $actual_height = 300; // Default
-
- if ($img_info_actual !== false) {
- $actual_width = $img_info_actual[0];
- $actual_height = $img_info_actual[1];
- }
-
- // Extract width and height from the HTML img tag
- $html_width = null;
- $html_height = null;
-
- if (preg_match('/width=["\']?(\d+)["\']?/i', $full_img_tag, $w_match)) {
- $html_width = (int) $w_match[1];
- }
- if (preg_match('/height=["\']?(\d+)["\']?/i', $full_img_tag, $h_match)) {
- $html_height = (int) $h_match[1];
- }
-
- // Calculate display dimensions preserving aspect ratio
- $display_width = $html_width ?? $actual_width;
- $display_height = $html_height ?? $actual_height;
-
- // If only one dimension is specified, calculate the other to preserve ratio
- if ($html_width && !$html_height) {
- $display_height = (int) ($html_width * $actual_height / $actual_width);
- } elseif ($html_height && !$html_width) {
- $display_width = (int) ($html_height * $actual_width / $actual_height);
- }
-
- // Apply maximum width constraint (200 pixels max)
- $max_width = 200;
- if ($display_width > $max_width) {
- $ratio = $max_width / $display_width;
- $display_width = $max_width;
- $display_height = (int) ($display_height * $ratio);
- }
-
- // Replace the original image by a properly resized one + remove the a tag
- $display_image_tag = '
';
- $content = str_replace($full_a_tag, $display_image_tag, $content);
- }
- }
- }
- }
-
// Split content by tables, keeping tables in the result
$segments = preg_split('/(
]*>.*?<\/table>)/is', $content, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
@@ -546,12 +503,99 @@ public function displayText($name, $content = '', $minline = 3, $maxline = 100)
}
}
- $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;
}
+ /**
+ * Build an
tag usable by TCPDF from a sanitized
tag.
+ *
+ * Supported sources are GLPI documents (document.send.php?docid=X) and base64 data URIs.
+ * Image data is validated with GD before being embedded, to prevent TCPDF from failing on corrupted images.
+ *
+ * @param string $img_tag sanitized
tag
+ *
+ * @return string|null new
tag, empty string if the image must be dropped, null if the tag must be kept as is
+ */
+ private function buildPdfImageTag(string $img_tag): ?string
+ {
+ if (!preg_match('/\bsrc\s*=\s*(["\'])(.*?)\1/is', $img_tag, $src_match)) {
+ return null;
+ }
+ $src = html_entity_decode($src_match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ $data = null;
+ if (preg_match('/^data:image\/[a-z0-9.+-]+;base64,(.*)$/is', $src, $data_match)) {
+ $data = base64_decode(preg_replace('/\s+/', '', $data_match[1]), true);
+ } elseif (str_contains(parse_url($src, PHP_URL_PATH) ?? '', 'document.send.php')) {
+ parse_str(parse_url($src, PHP_URL_QUERY) ?? '', $query);
+ $document = new Document();
+ if (
+ isset($query['docid'])
+ && $document->getFromDB((int) $query['docid'])
+ && $document->canViewFile()
+ && !empty($document->fields['filepath'])
+ ) {
+ $file_path = GLPI_DOC_DIR . '/' . $document->fields['filepath'];
+ $data = is_readable($file_path) ? file_get_contents($file_path) : false;
+ } else {
+ $data = false;
+ }
+ } else {
+ return null;
+ }
+
+ // Drop unreadable or corrupted images instead of letting TCPDF fail
+ if (
+ !is_string($data)
+ || ($img_info = @getimagesizefromstring($data)) === false
+ || @imagecreatefromstring($data) === false
+ ) {
+ return '';
+ }
+ $actual_width = $img_info[0];
+ $actual_height = $img_info[1];
+
+ // Extract width and height from the HTML img tag
+ $html_width = preg_match('/\bwidth=["\']?(\d+)/i', $img_tag, $w_match) ? (int) $w_match[1] : null;
+ $html_height = preg_match('/\bheight=["\']?(\d+)/i', $img_tag, $h_match) ? (int) $h_match[1] : null;
+
+ // Calculate display dimensions preserving aspect ratio
+ $display_width = $html_width ?: $actual_width;
+ $display_height = $html_height ?: $actual_height;
+
+ // If only one dimension is specified, calculate the other to preserve ratio
+ if ($html_width && !$html_height) {
+ $display_height = (int) ($html_width * $actual_height / $actual_width);
+ } elseif ($html_height && !$html_width) {
+ $display_width = (int) ($html_height * $actual_width / $actual_height);
+ }
+
+ // Apply maximum width constraint (200 pixels max)
+ $max_width = 200;
+ if ($display_width > $max_width) {
+ $ratio = $max_width / $display_width;
+ $display_width = $max_width;
+ $display_height = (int) ($display_height * $ratio);
+ }
+
+ // "@" prefix: TCPDF data stream, no local file or remote URL access needed
+ return '
';
+ }
+
/**
* Display space between row
*
diff --git a/tests/SimplePDFTest.php b/tests/SimplePDFTest.php
index 92ef792..e5c3bd0 100644
--- a/tests/SimplePDFTest.php
+++ b/tests/SimplePDFTest.php
@@ -31,9 +31,9 @@
* --------------------------------------------------------------------------
*/
-use Glpi\Tests\GLPITestCase;
+use Glpi\Tests\DbTestCase;
-class SimplePDFTest extends GLPITestCase
+class SimplePDFTest extends DbTestCase
{
private function getStringWidth(PluginPdfSimplePDF $pdf, string $string): float
{
@@ -131,4 +131,89 @@ public function testCleanTableHtmlWithNestedQuoteUrlStyle(): void
$this->assertStringNotContainsStringIgnoringCase('height: 15.75pt', $cleaned);
$this->assertStringContainsString("url('https://example.com/pics/a.jpg')", $cleaned);
}
+
+ /**
+ * Test a base64 image in a rich text (e.g. a ticket task)
+ * `+` and `=` were encoded as `+` and `=` by the sanitizer, so the image data was corrupted and TCPDF crashed
+ */
+ public function testDisplayTextEmbedsBase64Image(): void
+ {
+ // 1x1 semi-transparent PNG, its base64 contains both `+` and `=`
+ $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAADUlEQVQImWP4z8BQBwAEfwF+fk6erwAAAABJRU5ErkJggg==';
+
+ $pdf = new PluginPdfSimplePDF();
+ $pdf->newPage();
+ $pdf->displayText('Description', 'before
after
');
+
+ $this->assertStringContainsString('/Subtype /Image', $pdf->output());
+ }
+
+ /**
+ * Test a corrupted base64 image in a rich text
+ */
+ public function testDisplayTextDropsCorruptedBase64Image(): void
+ {
+ $base64 = base64_encode($this->getCorruptedPngContent());
+
+ $pdf = new PluginPdfSimplePDF();
+ $pdf->newPage();
+ $pdf->displayText('Description', 'before
after
');
+
+ $output = $pdf->output();
+ $this->assertStringStartsWith('%PDF-', $output);
+ $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
+ {
+ $this->login();
+
+ // ~1 MB PNG: random pixels are not compressible
+ $image = imagecreatetruecolor(600, 600);
+ for ($x = 0; $x < 600; $x++) {
+ for ($y = 0; $y < 600; $y++) {
+ imagesetpixel($image, $x, $y, random_int(0, 0xFFFFFF));
+ }
+ }
+ $filename = uniqid('glpitest_', true) . '.png';
+ $this->assertTrue(imagepng($image, GLPI_TMP_DIR . '/' . $filename, 0));
+ $this->assertGreaterThan(1_000_000, filesize(GLPI_TMP_DIR . '/' . $filename));
+
+ $document = $this->createItem(
+ Document::class,
+ [
+ 'filename' => $filename,
+ 'entities_id' => getItemByTypeName('Entity', '_test_root_entity', true),
+ '_filename' => [$filename],
+ ],
+ ['filename', '_filename'],
+ );
+
+ $pdf = new PluginPdfSimplePDF();
+ $pdf->newPage();
+ $pdf->displayText(
+ 'Description',
+ '',
+ );
+
+ $this->assertStringContainsString('/Subtype /Image', $pdf->output());
+ }
+
+ /**
+ * Build a PNG file with a valid RGBA header but undecodable image data
+ */
+ private function getCorruptedPngContent(): string
+ {
+ $chunk = (static fn(string $type, string $data): string => pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)));
+
+ return "\x89PNG\r\n\x1a\n"
+ . $chunk('IHDR', pack('NNCCCCC', 2, 2, 8, 6, 0, 0, 0))
+ . $chunk('IDAT', 'not zlib compressed data')
+ . $chunk('IEND', '');
+ }
}