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 @@ -10,6 +10,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
159 changes: 93 additions & 66 deletions inc/simplepdf.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -458,75 +458,26 @@ 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.
$content = preg_replace_callback(
'/(<a\b[^>]*>\s*)?(<img\b[^>]*>)(\s*<\/a>)?/i',
function (array $match): string {
$img_tag = $this->buildPdfImageTag($match[2]);
if ($img_tag === null) {
return $match[0];
}
if ($match[1] !== '' && ($match[3] ?? '') !== '') {
// Remove the link wrapping the image
return $img_tag;
}

return $match[1] . $img_tag . ($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 @@ -552,6 +503,82 @@ public function displayText($name, $content = '', $minline = 3, $maxline = 100)
[$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
45 changes: 45 additions & 0 deletions tests/SimplePDFTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,49 @@ 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);
}

/**
* 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