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