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 @@ -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

Expand Down
178 changes: 111 additions & 67 deletions inc/simplepdf.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'/<a\b[^>]*>\s*(<img\b[^>]*src=["\']([^"\']*docid=(\d+)[^"\']*)["\'][^>]*>)\s*<\/a>/i',
// The sanitizer encodes some characters in attributes (e.g. "=" => "&#61;", "+" => "&#43;"), 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(
'/(<a\b[^>]*>\s*)?(<img\b[^>]*>)(\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 = '<img src="file://' . $file_path . '" width="' . $display_width . '" height="' . $display_height . '">';
$content = str_replace($full_a_tag, $display_image_tag, $content);
}
}
}
}

// Split content by tables, keeping tables in the result
$segments = preg_split('/(<table\b[^>]*>.*?<\/table>)/is', $content, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

Expand All @@ -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 <img> tag usable by TCPDF from a sanitized <img> 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 <img> tag
*
* @return string|null new <img> 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 '<img src="@' . base64_encode($data) . '" width="' . $display_width . '" height="' . $display_height . '">';
}

/**
* Display space between row
*
Expand Down
89 changes: 87 additions & 2 deletions tests/SimplePDFTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 `&#43;` and `&#61;` 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', '<p>before <img src="data:image/png;base64,' . $base64 . '" width="10"> after</p>');

$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', '<p>before <img src="data:image/png;base64,' . $base64 . '" width="10"> after</p>');

$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',
'<table><tr><td><a href="/front/document.send.php?docid=' . $document->getID() . '">'
. '<img src="/front/document.send.php?docid=' . $document->getID() . '" width="100"></a></td></tr></table>',
);

$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', '');
}
}
Loading