Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Fixed

- Fix PDF export crash on tickets with tables containing nested-quote styles

## [4.2.0] - 2026-10-06

### Added
Expand Down
93 changes: 76 additions & 17 deletions inc/simplepdf.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -574,30 +574,89 @@ private function cleanTableHtml($html)
// Remove colgroup entirely (causes fixed widths)
$html = preg_replace('/<colgroup\b[^>]*>.*?<\/colgroup>/is', '', $html);

// Remove table-layout:fixed style (prevents auto-sizing)
$html = preg_replace('/table-layout\s*:\s*fixed\s*;?/i', '', $html);
// Parse with DOMDocument rather than regexes: style/attribute values coming
// from pasted web content can contain nested quotes (e.g. style="...url('...')..."),
// which regex-based quote matching cannot handle reliably and ends up corrupting
// the markup fed to TCPDF (causing crashes on malformed HTML).
$dom = new DOMDocument();
$libxml_previous_state = libxml_use_internal_errors(true);
$dom->loadHTML(
'<?xml encoding="utf-8" ?><div>' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
);
libxml_use_internal_errors($libxml_previous_state);

$wrapper = $dom->getElementsByTagName('div')->item(0);
if ($wrapper === null) {
// Fallback: parsing failed unexpectedly, keep original content rather than losing it
return $html;
}

$xpath = new DOMXPath($dom);
foreach (iterator_to_array($dom->getElementsByTagName('table')) as $table) {
// Remove width/height (attributes and styles) on the table and its rows/cells
foreach ($xpath->query('.//td | .//th | .//tr | .', $table) as $node) {
if (!($node instanceof DOMElement)) {
continue;
}

$node->removeAttribute('width');
$node->removeAttribute('height');

$style = $this->removeStyleProperties($node->getAttribute('style'), ['width', 'height', 'table-layout']);
if ($style === '') {
$node->removeAttribute('style');
} else {
$node->setAttribute('style', $style);
}
}

// Add border to table if missing (for visibility)
if (!$table->hasAttribute('border') || (int) $table->getAttribute('border') < 1) {
$table->setAttribute('border', '1');
}

// Remove width/height styles only from table elements (table, td, th, tr)
$html = preg_replace('/(<(?:table|td|th|tr)\b[^>]*)\s+style\s*=\s*["\']([^"\']*)\bwidth\s*:\s*[^;"\'>]+;?([^"\']*)["\']/', '$1 style="$2$3"', $html);
$html = preg_replace('/(<(?:table|td|th|tr)\b[^>]*)\s+style\s*=\s*["\']([^"\']*)\bheight\s*:\s*[^;"\'>]+;?([^"\']*)["\']/', '$1 style="$2$3"', $html);
// Force table to 100% width for PDF (do this LAST)
$style = trim($table->getAttribute('style') . ';width:100%;', ';');
$table->setAttribute('style', $style);
}

// Remove width/height attributes only from table elements (table, td, th, tr)
$html = preg_replace('/(<(?:table|td|th|tr)\b[^>]+)\s+width\s*=\s*["\']?[^"\'\s>]+["\']?/i', '$1', $html);
$html = preg_replace('/(<(?:table|td|th|tr)\b[^>]+)\s+height\s*=\s*["\']?[^"\'\s>]+["\']?/i', '$1', $html);
$output = '';
foreach (iterator_to_array($wrapper->childNodes) as $child) {
$output .= $dom->saveHTML($child);
}

// Clean up empty style attributes and double spaces
$html = preg_replace('/\s+style\s*=\s*["\'][\s]*["\']/', '', $html);
$html = preg_replace('/\s+/', ' ', $html);
return $output;
}

// Add border to table if missing (for visibility)
if (!preg_match('/border\s*=\s*["\']?[1-9]/i', $html)) {
$html = preg_replace('/<table/i', '<table border="1"', $html, 1);
/**
* Remove the given CSS properties from an inline style declaration.
*
* @param $style string inline style attribute content
* @param $properties array lowercase property names to strip
*
* @return string
**/
private function removeStyleProperties($style, array $properties)
{
if (trim($style) === '') {
return '';
}

// Force table to 100% width for PDF (do this LAST)
$html = preg_replace('/<table([^>]*)>/i', '<table$1 style="width:100%">', $html, 1);
$kept = [];
foreach (preg_split('/;(?![^(]*\))/', $style) as $declaration) {
$declaration = trim($declaration);
if ($declaration === '') {
continue;
}
$property = strtolower(trim(explode(':', $declaration, 2)[0]));
if (in_array($property, $properties, true)) {
continue;
}
$kept[] = $declaration;
}

return $html;
return implode('; ', $kept);
}

/**
Expand Down
34 changes: 34 additions & 0 deletions tests/SimplePDFTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,38 @@ public function testBreakWordToFitFallsBackToCharacterSplitWithoutDelimiters():
$this->assertLessThanOrEqual($width, $this->getStringWidth($pdf, $chunk));
}
}

/**
* Test nested quote url in a style attribute
* used to break cleanTableHtml regex and crash TCPDF
*/
public function testCleanTableHtmlWithNestedQuoteUrlStyle(): void
{
$html = <<<HTML
<table style="width: 614px; border-collapse: collapse;" border="0" width="614">
<tbody>
<tr>
<td style="width: 51.8pt; height: 15.75pt; background-image: url('https://example.com/pics/a.jpg');">Content</td>
</tr>
</tbody>
</table>
HTML;

$pdf = new PluginPdfSimplePDF();
$cleaned = $this->callPrivateMethod($pdf, 'cleanTableHtml', $html);

// no duplicated attribute on <table>
$this->assertMatchesRegularExpression('/^<table[^>]*>/', $cleaned);
preg_match('/^<table([^>]*)>/', $cleaned, $matches);
$tableAttributes = $matches[1];

$this->assertSame(1, substr_count($tableAttributes, 'border='), 'the <table> tag must have a single border attribute');
$this->assertSame(1, substr_count($tableAttributes, 'style='), 'the <table> tag must have a single style attribute');

// width/height stripped, nested url untouched
$this->assertStringNotContainsStringIgnoringCase('width: 614px', $cleaned);
$this->assertStringNotContainsStringIgnoringCase('width: 51.8pt', $cleaned);
$this->assertStringNotContainsStringIgnoringCase('height: 15.75pt', $cleaned);
$this->assertStringContainsString("url('https://example.com/pics/a.jpg')", $cleaned);
}
}
Loading