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); + } }