diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bf37c7..2e79850 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Fix PDF export of tabs renamed in GLPI 11
+- Fix PDF export crash on tickets with tables containing nested-quote styles
## [4.1.4] - 2026-07-30
diff --git a/inc/simplepdf.class.php b/inc/simplepdf.class.php
index d505647..01d7c6f 100644
--- a/inc/simplepdf.class.php
+++ b/inc/simplepdf.class.php
@@ -468,30 +468,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_use_internal_errors(true);
+ $dom->loadHTML(
+ '' . $html . '
',
+ LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
+ );
+ libxml_clear_errors();
+
+ $wrapper = $dom->getElementsByTagName('div')->item(0);
+ if ($wrapper === null) {
+ // Fallback: parsing failed unexpectedly, keep original content rather than losing it
+ return $html;
+ }
+
+ foreach (iterator_to_array($dom->getElementsByTagName('table')) as $table) {
+ // Remove width/height (attributes and styles) on the table and its rows/cells
+ $xpath = new DOMXPath($dom);
+ 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 (explode(';', $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/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..3023d8c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,7 @@
+
+
+
+ tests
+
+
+
diff --git a/tests/Units/SimplePdfTest.php b/tests/Units/SimplePdfTest.php
new file mode 100644
index 0000000..b3de60f
--- /dev/null
+++ b/tests/Units/SimplePdfTest.php
@@ -0,0 +1,78 @@
+.
+ *
+ * @author Nelly Mahu-Lasson, Remi Collet, Teclib
+ * @copyright Copyright (c) 2009-2022 PDF plugin team
+ * @license AGPL License 3.0 or (at your option) any later version
+ * @link https://github.com/pluginsGLPI/pdf/
+ * @link http://www.glpi-project.org/
+ * @package pdf
+ * @since 2009
+ * http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ * --------------------------------------------------------------------------
+ */
+
+declare(strict_types=1);
+
+namespace GlpiPlugin\Pdf\Tests\Units;
+
+use Glpi\Tests\GLPITestCase;
+use PluginPdfSimplePDF;
+use ReflectionMethod;
+
+class SimplePdfTest extends GLPITestCase
+{
+ /**
+ * 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();
+
+ $method = new ReflectionMethod(PluginPdfSimplePDF::class, 'cleanTableHtml');
+ $cleaned = $method->invoke($pdf, $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);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..7c4ce0e
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,42 @@
+.
+ *
+ * @author Nelly Mahu-Lasson, Remi Collet, Teclib
+ * @copyright Copyright (c) 2009-2022 PDF plugin team
+ * @license AGPL License 3.0 or (at your option) any later version
+ * @link https://github.com/pluginsGLPI/pdf/
+ * @link http://www.glpi-project.org/
+ * @package pdf
+ * @since 2009
+ * http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ * --------------------------------------------------------------------------
+ */
+
+use function Safe\realpath;
+
+$current_plugin_folder = basename(realpath(__DIR__ . '/../'));
+
+require __DIR__ . '/../../../tests/bootstrap.php';
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+if (!Plugin::isPluginActive($current_plugin_folder)) {
+ throw new RuntimeException(sprintf('Plugin %s is not active in the test database', $current_plugin_folder));
+}