Skip to content

Commit dd8fe38

Browse files
committed
fix: repair malformed hex colors only in color attributes and CSS values so writeHTML() keeps "#1" and numeric entities such as &, link href="#N" to page N (v7.0.18)
1 parent 08da065 commit dd8fe38

4 files changed

Lines changed: 204 additions & 25 deletions

File tree

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7.0.17
1+
7.0.18

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"require": {
3030
"php": ">=8.2",
3131
"ext-curl": "*",
32-
"tecnickcom/tc-lib-pdf": "^8.80"
32+
"tecnickcom/tc-lib-pdf": "^8.81"
3333
},
3434
"require-dev": {
3535
"carthage-software/mago": "1.55.0",

‎tcpdf.php‎

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
//============================================================+
44
// File name : tcpdf.php
5-
// Version : 7.0.17
5+
// Version : 7.0.18
66
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
77
// License : GNU-LGPL v3 (https://www.gnu.org/copyleft/lesser.html)
88
// Copyright (C): 2002-2026 Nicola Asuni - Tecnick.com LTD
@@ -15,7 +15,7 @@
1515
* See: https://tcpdf.org
1616
* @package com.tecnick.tcpdf
1717
* @author Nicola Asuni
18-
* @version 7.0.17
18+
* @version 7.0.18
1919
*/
2020

2121
// TCPDF configuration
@@ -7642,8 +7642,11 @@ static function (array $match): string {
76427642
* - '@'-prefixed base64 image data is rewritten to a data URI;
76437643
* - relative local image paths are made absolute (src attributes,
76447644
* CSS url() values and the legacy img| list-bullet syntax);
7645-
* - malformed hexadecimal color tokens, silently coerced by the
7646-
* legacy parser, are repaired to valid values.
7645+
* - legacy page anchors (href="#N" or href="#N,y") become internal
7646+
* links to page N at ordinate y;
7647+
* - malformed hexadecimal colors, silently coerced by the legacy
7648+
* parser, are repaired in color attributes and CSS declaration values
7649+
* (see repairHtmlColors()).
76477650
*/
76487651
protected function normalizeHtmlMarkup(string $html): string
76497652
{
@@ -7699,37 +7702,104 @@ protected function normalizeHtmlMarkup(string $html): string
76997702
$html,
77007703
);
77017704

7705+
// Legacy page anchors: href="#N" or href="#N,y" link to page N at ordinate y.
7706+
$html = (string) preg_replace_callback(
7707+
'/(\bhref\s*=\s*["\'])#([0-9]+)(?:,([0-9]*\.?[0-9]*))?(["\'])/i',
7708+
fn(array $match): string => (
7709+
($match[1] ?? '')
7710+
. $this->engine()->addInternalLink(max(0, (int) ($match[2] ?? '1') - 1), (float) ($match[3] ?? '0'))
7711+
. ($match[4] ?? '')
7712+
),
7713+
$html,
7714+
);
7715+
7716+
return self::repairHtmlColors($html);
7717+
}
7718+
7719+
/**
7720+
* Repair malformed hexadecimal colors in the attributes whose name ends in
7721+
* "color" (color, bgcolor, bordercolor), in style attributes and in style
7722+
* blocks. Text content and other attributes are left unchanged.
7723+
*/
7724+
protected static function repairHtmlColors(string $html): string
7725+
{
77027726
$result = preg_replace_callback(
7703-
'/#([0-9a-zA-Z]+)\b/',
7727+
'/<style\b[^>]*>.*?<\/style\s*>|<[a-zA-Z][^>]*>/is',
77047728
static function (array $match): string {
7705-
$token = $match[0] ?? '';
7706-
$hex = strtolower($match[1] ?? '');
7707-
$len = strlen($hex);
7708-
if (preg_match('/^[0-9a-f]+$/', $hex) === 1 && in_array($len, [3, 4, 6, 8], true)) {
7709-
return $token;
7729+
$part = $match[0] ?? '';
7730+
if (strncasecmp($part, '<style', 6) === 0) {
7731+
return self::repairCssColors($part);
77107732
}
77117733

7712-
// Only repair tokens that could be a mistyped hex color: every
7713-
// character must be a hex digit or a digit-look-alike letter
7714-
// (l/o/i/s/g/z). This leaves CSS id selectors (#second), link
7715-
// fragments (#chapter2) and placeholders (#TOC_...) untouched
7716-
// instead of mangling them into bogus colors.
7717-
if (preg_match('/^[0-9a-fgilosz]+$/', $hex) !== 1) {
7718-
return $token;
7719-
}
7734+
$part = (string) preg_replace_callback(
7735+
'/(\bstyle\s*=\s*)(?:"([^"]*)"|\'([^\']*)\')/i',
7736+
static fn(array $style): string => isset($style[3])
7737+
? ($style[1] ?? '') . "'" . self::repairCssColors($style[3]) . "'"
7738+
: ($style[1] ?? '') . '"' . self::repairCssColors($style[2] ?? '') . '"',
7739+
$part,
7740+
);
77207741

7721-
// Map common look-alike characters, drop the rest.
7722-
$hex = strtr($hex, ['l' => '1', 'o' => '0', 'i' => '1', 's' => '5', 'g' => '6', 'z' => '2']);
7723-
$hex = (string) preg_replace('/[^0-9a-f]/', '', $hex);
7724-
$hex = substr(str_pad($hex, 6, '0'), 0, 6);
7725-
return '#' . $hex;
7742+
return (string) preg_replace_callback(
7743+
'/(\b[a-z-]*color\s*=\s*["\']?)#([0-9a-zA-Z]+)\b/i',
7744+
static fn(array $attr): string => ($attr[1] ?? '') . self::repairHexColor('#' . ($attr[2] ?? '')),
7745+
$part,
7746+
);
77267747
},
77277748
$html,
77287749
);
77297750

77307751
return $result === null ? $html : $result;
77317752
}
77327753

7754+
/**
7755+
* Repair malformed hexadecimal colors in the declaration values of a CSS
7756+
* text. Selectors, quoted strings and numeric character references are
7757+
* left unchanged.
7758+
*/
7759+
protected static function repairCssColors(string $css): string
7760+
{
7761+
return (string) preg_replace_callback(
7762+
'/:[^;{}]*+(?=[;}]|$)/',
7763+
static fn(array $decl): string => (string) preg_replace_callback(
7764+
'/"[^"]*"|\'[^\']*\'|(?<!&)#[0-9a-zA-Z]+\b/',
7765+
static fn(array $item): string => str_starts_with($item[0] ?? '', '#')
7766+
? self::repairHexColor($item[0] ?? '')
7767+
: $item[0] ?? '',
7768+
$decl[0] ?? '',
7769+
),
7770+
$css,
7771+
);
7772+
}
7773+
7774+
/**
7775+
* Repair a malformed hexadecimal color token ('#' followed by letters and
7776+
* digits). Valid 3, 4, 6 or 8 digit values and tokens that cannot be a
7777+
* mistyped color are returned unchanged; the others are padded or
7778+
* truncated to 6 digits.
7779+
*/
7780+
protected static function repairHexColor(string $color): string
7781+
{
7782+
$hex = strtolower(substr($color, 1));
7783+
if (preg_match('/^[0-9a-f]+$/', $hex) === 1 && in_array(strlen($hex), [3, 4, 6, 8], true)) {
7784+
return $color;
7785+
}
7786+
7787+
// Only repair tokens that could be a mistyped hex color: every
7788+
// character must be a hex digit or a digit-look-alike letter
7789+
// (l/o/i/s/g/z). This leaves CSS id selectors (#second), link
7790+
// fragments (#chapter2) and placeholders (#TOC_...) untouched
7791+
// instead of mangling them into bogus colors.
7792+
if (preg_match('/^[0-9a-fgilosz]+$/', $hex) !== 1) {
7793+
return $color;
7794+
}
7795+
7796+
// Map common look-alike characters, drop the rest.
7797+
$hex = strtr($hex, ['l' => '1', 'o' => '0', 'i' => '1', 's' => '5', 'g' => '6', 'z' => '2']);
7798+
$hex = (string) preg_replace('/[^0-9a-f]/', '', $hex);
7799+
7800+
return '#' . substr(str_pad($hex, 6, '0'), 0, 6);
7801+
}
7802+
77337803
/**
77347804
* Render HTML containing embedded <tcpdf data="..."/> method calls
77357805
* (K_TCPDF_CALLS_IN_HTML feature): the markup is split at the tags,

‎test/TcpdfHtmlMarkupTest.php‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,113 @@ public function testImageAlignIsDroppedOnlyInsideTables(): void
8080
$this->assertStringContainsString('</TABLE><img src="data:," align="baseline">', $out);
8181
$this->assertStringContainsString('<tablex><img src="data:," align="top"></tablex>', $out);
8282
}
83+
84+
public function testColorRepairLeavesTextAndCharacterReferencesUnchanged(): void
85+
{
86+
$html = '<p>Order #1, issue #12, ref #12345, item #123456, long #1234567, tag #abc</p>'
87+
. '<p>A&#38;B, x&#35;y, a&#39;b, &#039;q&#039;, &#8364;, &#12354;</p>'
88+
. '<p>if a < b then color=#12 > 0</p>';
89+
90+
$this->assertSame($html, $this->normalize($html));
91+
}
92+
93+
public function testColorRepairLeavesNonColorAttributesUnchanged(): void
94+
{
95+
$html = '<a href="#chapter2" title="#1 A&#38;B">x</a><img alt="#12" src="data:," /><p id="a1">y</p>';
96+
97+
$this->assertSame($html, $this->normalize($html));
98+
}
99+
100+
/**
101+
* @return array<string, array{0: string, 1: string}>
102+
*/
103+
public static function colorAttributeProvider(): array
104+
{
105+
return [
106+
'color look-alike letter' => ['<font color="#4169el">x</font>', '<font color="#4169e1">x</font>'],
107+
'bgcolor short' => ['<td bgcolor="#ab">x</td>', '<td bgcolor="#ab0000">x</td>'],
108+
'bordercolor single quotes' => ["<td BorderColor='#f0l'>x</td>", "<td BorderColor='#f01000'>x</td>"],
109+
'unquoted color' => ['<font color=#12>x</font>', '<font color=#120000>x</font>'],
110+
'valid color' => ['<font color="#ff7f50">x</font>', '<font color="#ff7f50">x</font>'],
111+
'long color' => ['<font color="#1234567">x</font>', '<font color="#123456">x</font>'],
112+
];
113+
}
114+
115+
#[\PHPUnit\Framework\Attributes\DataProvider('colorAttributeProvider')]
116+
public function testColorRepairFixesColorAttributes(string $html, string $expected): void
117+
{
118+
$this->assertSame($expected, $this->normalize($html));
119+
}
120+
121+
public function testColorRepairFixesStyleAttributeValues(): void
122+
{
123+
$out = $this->normalize(
124+
'<span style="color:#12;font-family:&#39;courier&#39;;border:1px solid #f0l">s</span>'
125+
. "<span style='background-color:#ab'>t</span>",
126+
);
127+
128+
$this->assertSame(
129+
'<span style="color:#120000;font-family:&#39;courier&#39;;border:1px solid #f01000">s</span>'
130+
. "<span style='background-color:#ab0000'>t</span>",
131+
$out,
132+
);
133+
}
134+
135+
public function testColorRepairFixesOnlyStyleBlockDeclarationValues(): void
136+
{
137+
$out = $this->normalize(
138+
'<style>#a1 { color:#4169el; } a:hover #a1 { background:#ab } p { content:"#1" }</style>'
139+
. '<p id="a1">#1</p>',
140+
);
141+
142+
$this->assertSame(
143+
'<style>#a1 { color:#4169e1; } a:hover #a1 { background:#ab0000 } p { content:"#1" }</style>'
144+
. '<p id="a1">#1</p>',
145+
$out,
146+
);
147+
}
148+
149+
public function testWriteHtmlKeepsNumbersAndCharacterReferences(): void
150+
{
151+
$pdf = $this->newPdf();
152+
$pdf->SetFont('helvetica', '', 10);
153+
$pdf->AddPage();
154+
$pdf->writeHTML(
155+
'<p>Order #1, issue #12, ref #12345, item #123456, tag #abc</p>'
156+
. '<p>A&#38;B, x&#35;y, a&#39;b, &#039;q&#039;, &#8364;</p>',
157+
);
158+
159+
$text = $this->extractText($pdf);
160+
$this->assertStringContainsString('Order #1, issue #12, ref #12345, item #123456, tag #abc', $text);
161+
$this->assertStringContainsString("A&B, x#y, a'b, 'q', \u{20AC}", $text);
162+
}
163+
164+
public function testLegacyPageAnchorsBecomeInternalLinks(): void
165+
{
166+
$out = $this->normalize('<a href="#2">p2</a><a href=\'#3,25.5\'>p3</a><a href="#chapter2">c2</a>');
167+
168+
$this->assertMatchesRegularExpression(
169+
'/^<a href="@\d+">p2<\/a><a href=\'@\d+\'>p3<\/a><a href="#chapter2">c2<\/a>$/',
170+
$out,
171+
);
172+
}
173+
174+
public function testLegacyPageAnchorLinksToThePage(): void
175+
{
176+
$pdf = $this->newPdf();
177+
$pdf->setCompression(false);
178+
$pdf->SetFont('helvetica', '', 10);
179+
$pdf->AddPage();
180+
$pdf->writeHTML('<a href="#2">link to page 2</a>');
181+
$pdf->AddPage();
182+
$pdf->writeHTML('page 2');
183+
184+
$raw = (string) $pdf->getPDFData();
185+
$pages = [];
186+
preg_match_all('/(\d+) 0 obj\s*<<\s*\/Type \/Page\b/', $raw, $pages);
187+
$this->assertCount(2, $pages[1] ?? []);
188+
$dest = [];
189+
$this->assertSame(1, preg_match('/\/Dest \[(\d+) 0 R \/XYZ /', $raw, $dest));
190+
$this->assertSame($pages[1][1] ?? '', $dest[1] ?? '');
191+
}
83192
}

0 commit comments

Comments
 (0)