diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80b37f2..9d7584f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php
index 981778b..e68d757 100644
--- a/inc/simplepdf.class.php
+++ b/inc/simplepdf.class.php
@@ -574,30 +574,89 @@ private function cleanTableHtml($html)
// Remove colgroup entirely (causes fixed widths)
$html = preg_replace('/
]*>.*?<\/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(
+ '' . $html . '
',
+ 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('/]*)>/i', '', $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);
}
/**
diff --git a/tests/SimplePDFTest.php b/tests/SimplePDFTest.php
index 339a3b7..92ef792 100644
--- a/tests/SimplePDFTest.php
+++ b/tests/SimplePDFTest.php
@@ -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 = <<
+
+
+ | Content |
+
+
+
+ HTML;
+
+ $pdf = new PluginPdfSimplePDF();
+ $cleaned = $this->callPrivateMethod($pdf, 'cleanTableHtml', $html);
+
+ // no duplicated attribute on
+ $this->assertMatchesRegularExpression('/^]*>/', $cleaned);
+ preg_match('/^]*)>/', $cleaned, $matches);
+ $tableAttributes = $matches[1];
+
+ $this->assertSame(1, substr_count($tableAttributes, 'border='), 'the tag must have a single border attribute');
+ $this->assertSame(1, substr_count($tableAttributes, 'style='), 'the 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);
+ }
}