Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.18% |
93 / 102 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
| EncodingSniffer | |
91.18% |
93 / 102 |
|
57.14% |
4 / 7 |
50.65 | |
0.00% |
0 / 1 |
| toUtf8 | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| bomEncoding | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| sniffMeta | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
16 | |||
| encodingFromMetaAttributes | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| readAttributes | |
90.91% |
30 / 33 |
|
0.00% |
0 / 1 |
12.11 | |||
| resolveLabel | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| transcode | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
9.38 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Html; |
| 6 | |
| 7 | /** |
| 8 | * HTML §13.2.3 — "determining the character encoding", reduced to what a |
| 9 | * static renderer can actually observe. |
| 10 | * |
| 11 | * The parser works in UTF-8. A document served in a legacy encoding is |
| 12 | * therefore not merely mis-labelled, it is mis-READ: every non-ASCII byte |
| 13 | * becomes U+FFFD, so `<meta charset=windows-1251>` + byte 0xE6 rendered |
| 14 | * as a replacement box instead of `ж`. This class finds the declared |
| 15 | * encoding and transcodes the source before tokenization. |
| 16 | * |
| 17 | * Two signals, in the spec's priority order: |
| 18 | * |
| 19 | * 1. A byte order mark, which overrides everything including an |
| 20 | * explicit `<meta>` that contradicts it. |
| 21 | * 2. A `<meta>` charset declaration, found by {@see sniffMeta()}. |
| 22 | * |
| 23 | * With no signal the source is left alone and treated as UTF-8. That is |
| 24 | * deliberately NOT the spec's fallback (which is locale-dependent, and |
| 25 | * windows-1252 for most of the web): guessing windows-1252 for every |
| 26 | * undeclared document would re-interpret every byte of the large majority |
| 27 | * of documents that are in fact UTF-8, to fix the few that are not. |
| 28 | */ |
| 29 | final class EncodingSniffer |
| 30 | { |
| 31 | /** |
| 32 | * Elements whose content is TEXT, not markup. A `<meta charset>` |
| 33 | * inside one of these is not a tag and must not be honoured — which |
| 34 | * is the whole difference between this scan and a naive search for |
| 35 | * `<meta`, and what `html/syntax/charset/in-script`, `in-style` and |
| 36 | * `in-title` check by asserting the encoding does NOT change. |
| 37 | * |
| 38 | * `<template>` and foreign content (`<svg>`, `<math>`) are absent on |
| 39 | * purpose: their children ARE tokenized as markup, so a `<meta>` |
| 40 | * there does count. |
| 41 | */ |
| 42 | /** |
| 43 | * How far into the source a `<meta>` declaration may begin and still |
| 44 | * count (HTML §13.2.3.2, "prescan a byte stream to determine its |
| 45 | * encoding"). Past this the document is already being decoded, and |
| 46 | * the declaration is simply too late. |
| 47 | */ |
| 48 | private const int PRESCAN_LIMIT = 1024; |
| 49 | |
| 50 | private const array RAWTEXT_ELEMENTS = [ |
| 51 | 'script', 'style', 'title', 'textarea', 'xmp', |
| 52 | 'iframe', 'noembed', 'noframes', 'plaintext', |
| 53 | ]; |
| 54 | |
| 55 | /** |
| 56 | * Encoding Standard §4.2 labels → a name the transcoders understand. |
| 57 | * |
| 58 | * Only the legacy families that actually appear in documents are |
| 59 | * listed. An unknown label resolves to null and the source is left |
| 60 | * alone, which is the same outcome as before this existed. |
| 61 | * |
| 62 | * `iso-8859-1`, `ascii` and friends map to windows-1252 rather than |
| 63 | * to themselves — that is the Encoding Standard's mapping, not a |
| 64 | * shortcut, and it matters because the C1 range those labels leave |
| 65 | * undefined is where windows-1252 keeps its curly quotes. |
| 66 | */ |
| 67 | private const array LABELS = [ |
| 68 | 'unicode-1-1-utf-8' => 'UTF-8', |
| 69 | 'utf-8' => 'UTF-8', |
| 70 | 'utf8' => 'UTF-8', |
| 71 | 'ascii' => 'Windows-1252', |
| 72 | 'us-ascii' => 'Windows-1252', |
| 73 | 'iso-8859-1' => 'Windows-1252', |
| 74 | 'iso8859-1' => 'Windows-1252', |
| 75 | 'latin1' => 'Windows-1252', |
| 76 | 'l1' => 'Windows-1252', |
| 77 | 'cp1252' => 'Windows-1252', |
| 78 | 'windows-1252' => 'Windows-1252', |
| 79 | 'iso-8859-2' => 'ISO-8859-2', |
| 80 | 'latin2' => 'ISO-8859-2', |
| 81 | 'iso-8859-5' => 'ISO-8859-5', |
| 82 | 'cyrillic' => 'ISO-8859-5', |
| 83 | 'iso-8859-6' => 'ISO-8859-6', |
| 84 | 'arabic' => 'ISO-8859-6', |
| 85 | 'iso-8859-7' => 'ISO-8859-7', |
| 86 | 'greek' => 'ISO-8859-7', |
| 87 | 'iso-8859-8' => 'ISO-8859-8', |
| 88 | 'hebrew' => 'ISO-8859-8', |
| 89 | 'iso-8859-8-i' => 'ISO-8859-8', |
| 90 | 'iso-8859-15' => 'ISO-8859-15', |
| 91 | 'cp1250' => 'Windows-1250', |
| 92 | 'windows-1250' => 'Windows-1250', |
| 93 | 'cp1251' => 'Windows-1251', |
| 94 | 'windows-1251' => 'Windows-1251', |
| 95 | 'cp1253' => 'Windows-1253', |
| 96 | 'windows-1253' => 'Windows-1253', |
| 97 | 'cp1254' => 'Windows-1254', |
| 98 | 'windows-1254' => 'Windows-1254', |
| 99 | 'cp1255' => 'Windows-1255', |
| 100 | 'windows-1255' => 'Windows-1255', |
| 101 | 'cp1256' => 'Windows-1256', |
| 102 | 'windows-1256' => 'Windows-1256', |
| 103 | 'cp1257' => 'Windows-1257', |
| 104 | 'windows-1257' => 'Windows-1257', |
| 105 | 'cp1258' => 'Windows-1258', |
| 106 | 'windows-1258' => 'Windows-1258', |
| 107 | 'koi8-r' => 'KOI8-R', |
| 108 | 'koi8-u' => 'KOI8-U', |
| 109 | 'ibm866' => 'CP866', |
| 110 | 'cp866' => 'CP866', |
| 111 | 'macintosh' => 'MacRoman', |
| 112 | 'x-mac-roman' => 'MacRoman', |
| 113 | 'shift_jis' => 'SJIS', |
| 114 | 'shift-jis' => 'SJIS', |
| 115 | 'sjis' => 'SJIS', |
| 116 | 'ms_kanji' => 'SJIS', |
| 117 | 'windows-31j' => 'SJIS', |
| 118 | 'euc-jp' => 'EUC-JP', |
| 119 | 'iso-2022-jp' => 'ISO-2022-JP', |
| 120 | 'euc-kr' => 'EUC-KR', |
| 121 | 'big5' => 'BIG-5', |
| 122 | 'big5-hkscs' => 'BIG-5', |
| 123 | 'csbig5' => 'BIG-5', |
| 124 | 'gbk' => 'CP936', |
| 125 | 'gb2312' => 'CP936', |
| 126 | 'gb18030' => 'GB18030', |
| 127 | 'utf-16' => 'UTF-16', |
| 128 | 'utf-16le' => 'UTF-16LE', |
| 129 | 'utf-16be' => 'UTF-16BE', |
| 130 | ]; |
| 131 | |
| 132 | /** |
| 133 | * Transcode `$bytes` to UTF-8 using the declared encoding. |
| 134 | * |
| 135 | * `$override` takes priority over anything in the document — it is |
| 136 | * the transport-level `Content-Type` charset, which outranks `<meta>` |
| 137 | * in the spec's confidence ordering. Returns the source unchanged |
| 138 | * when no encoding is found, when it is already UTF-8, or when no |
| 139 | * transcoder on this build can handle it. |
| 140 | */ |
| 141 | public static function toUtf8(string $bytes, ?string $override = null): string |
| 142 | { |
| 143 | $bom = self::bomEncoding($bytes); |
| 144 | if ($bom !== null) { |
| 145 | // A BOM outranks both `<meta>` and the transport. It is also |
| 146 | // not content: leaving it in place puts a U+FEFF at the front |
| 147 | // of the document, which the tree builder happily turns into |
| 148 | // a stray text node. |
| 149 | $bytes = substr($bytes, $bom === 'UTF-8' ? 3 : 2); |
| 150 | return self::transcode($bytes, $bom); |
| 151 | } |
| 152 | $encoding = self::resolveLabel($override ?? '') ?? self::sniffMeta($bytes); |
| 153 | if ($encoding === null || $encoding === 'UTF-8') { |
| 154 | return $bytes; |
| 155 | } |
| 156 | return self::transcode($bytes, $encoding); |
| 157 | } |
| 158 | |
| 159 | /** The encoding named by a leading byte order mark, if there is one. */ |
| 160 | public static function bomEncoding(string $bytes): ?string |
| 161 | { |
| 162 | if (str_starts_with($bytes, "\xEF\xBB\xBF")) { |
| 163 | return 'UTF-8'; |
| 164 | } |
| 165 | if (str_starts_with($bytes, "\xFE\xFF")) { |
| 166 | return 'UTF-16BE'; |
| 167 | } |
| 168 | if (str_starts_with($bytes, "\xFF\xFE")) { |
| 169 | return 'UTF-16LE'; |
| 170 | } |
| 171 | return null; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Scan `$bytes` for a `<meta>` charset declaration and return the |
| 176 | * encoding it names, or null. |
| 177 | * |
| 178 | * This is the spec's "prescan a byte stream" with the tag awareness a |
| 179 | * real tokenizer has: comments are skipped, and so is the CONTENT of |
| 180 | * a rawtext element. Both matter — `<!--<meta charset=x>-->` and |
| 181 | * `<script><meta charset=x></script>` must not change the encoding, |
| 182 | * and a scan that only looked for `<meta` would honour both. |
| 183 | * |
| 184 | * The scan is capped at the spec's first 1024 bytes — measured at the |
| 185 | * point a tag BEGINS, so a `<meta>` starting at byte 1023 is read to |
| 186 | * completion while one starting at 1024 is not considered at all. |
| 187 | * The cap is not an optimisation, it is observable: WPT's |
| 188 | * `after-head-after-1kb` and `in-template-after-1kb` are `mismatch` |
| 189 | * tests that assert a late declaration does NOT take effect, and an |
| 190 | * uncapped scan honours them and fails. |
| 191 | */ |
| 192 | public static function sniffMeta(string $bytes): ?string |
| 193 | { |
| 194 | $length = strlen($bytes); |
| 195 | $i = 0; |
| 196 | while ($i < $length && $i < self::PRESCAN_LIMIT) { |
| 197 | if ($bytes[$i] !== '<') { |
| 198 | $i++; |
| 199 | continue; |
| 200 | } |
| 201 | if (substr($bytes, $i, 4) === '<!--') { |
| 202 | $end = strpos($bytes, '-->', $i + 4); |
| 203 | $i = $end === false ? $length : $end + 3; |
| 204 | continue; |
| 205 | } |
| 206 | $rest = substr($bytes, $i + 1, 1); |
| 207 | if ($rest === '!' || $rest === '?') { |
| 208 | $end = strpos($bytes, '>', $i); |
| 209 | $i = $end === false ? $length : $end + 1; |
| 210 | continue; |
| 211 | } |
| 212 | $isEndTag = $rest === '/'; |
| 213 | $nameStart = $i + ($isEndTag ? 2 : 1); |
| 214 | if (preg_match('/\G[a-zA-Z][^\t\n\f\r \/>]*/', $bytes, $m, 0, $nameStart) !== 1) { |
| 215 | $i++; |
| 216 | continue; |
| 217 | } |
| 218 | $tagName = strtolower($m[0]); |
| 219 | $attributes = []; |
| 220 | $i = self::readAttributes($bytes, $nameStart + strlen($m[0]), $attributes); |
| 221 | if ($isEndTag) { |
| 222 | continue; |
| 223 | } |
| 224 | if ($tagName === 'meta') { |
| 225 | $found = self::encodingFromMetaAttributes($attributes); |
| 226 | if ($found !== null) { |
| 227 | return $found; |
| 228 | } |
| 229 | continue; |
| 230 | } |
| 231 | if (in_array($tagName, self::RAWTEXT_ELEMENTS, true)) { |
| 232 | // Everything up to the matching end tag is text. |
| 233 | $close = stripos($bytes, '</' . $tagName, $i); |
| 234 | $i = $close === false ? $length : $close; |
| 235 | } |
| 236 | } |
| 237 | return null; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * The encoding a `<meta>`'s attributes declare, or null. |
| 242 | * |
| 243 | * Both spellings count: `charset="x"`, and the older |
| 244 | * `http-equiv="content-type" content="text/html; charset=x"`. |
| 245 | * |
| 246 | * @param array<string, string> $attributes |
| 247 | */ |
| 248 | private static function encodingFromMetaAttributes(array $attributes): ?string |
| 249 | { |
| 250 | if (isset($attributes['charset'])) { |
| 251 | return self::resolveLabel($attributes['charset']); |
| 252 | } |
| 253 | $httpEquiv = strtolower(trim($attributes['http-equiv'] ?? '')); |
| 254 | if ($httpEquiv !== 'content-type' || !isset($attributes['content'])) { |
| 255 | return null; |
| 256 | } |
| 257 | if (preg_match('/charset[\t\n\f\r ]*=[\t\n\f\r ]*"?\'?([^"\';\t\n\f\r ]+)/i', $attributes['content'], $m) !== 1) { |
| 258 | return null; |
| 259 | } |
| 260 | return self::resolveLabel($m[1]); |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * Read a start tag's attributes into `$out`, returning the offset |
| 265 | * just past the tag. |
| 266 | * |
| 267 | * Character references in the value ARE decoded, because a tokenizer |
| 268 | * decodes them: `<meta charset="windows-1251">` declares |
| 269 | * windows-1251, and WPT's `charset/ncr` exists to say so. |
| 270 | * |
| 271 | * @param array<string, string> $out |
| 272 | */ |
| 273 | private static function readAttributes(string $bytes, int $i, array &$out): int |
| 274 | { |
| 275 | $length = strlen($bytes); |
| 276 | while ($i < $length) { |
| 277 | $i += strspn($bytes, "\t\n\f\r /", $i); |
| 278 | if ($i >= $length) { |
| 279 | return $length; |
| 280 | } |
| 281 | if ($bytes[$i] === '>') { |
| 282 | return $i + 1; |
| 283 | } |
| 284 | $nameLength = strcspn($bytes, "\t\n\f\r =/>", $i); |
| 285 | if ($nameLength === 0) { |
| 286 | // Neither a name nor a terminator — step over it so a |
| 287 | // malformed tag cannot spin here forever. |
| 288 | $i++; |
| 289 | continue; |
| 290 | } |
| 291 | $name = strtolower(substr($bytes, $i, $nameLength)); |
| 292 | $i += $nameLength; |
| 293 | $i += strspn($bytes, "\t\n\f\r ", $i); |
| 294 | if ($i >= $length || $bytes[$i] !== '=') { |
| 295 | $out[$name] ??= ''; |
| 296 | continue; |
| 297 | } |
| 298 | $i++; |
| 299 | $i += strspn($bytes, "\t\n\f\r ", $i); |
| 300 | if ($i >= $length) { |
| 301 | return $length; |
| 302 | } |
| 303 | $quote = $bytes[$i]; |
| 304 | if ($quote === '"' || $quote === "'") { |
| 305 | $end = strpos($bytes, $quote, $i + 1); |
| 306 | $value = $end === false |
| 307 | ? substr($bytes, $i + 1) |
| 308 | : substr($bytes, $i + 1, $end - $i - 1); |
| 309 | $i = $end === false ? $length : $end + 1; |
| 310 | } else { |
| 311 | $valueLength = strcspn($bytes, "\t\n\f\r >", $i); |
| 312 | $value = substr($bytes, $i, $valueLength); |
| 313 | $i += $valueLength; |
| 314 | } |
| 315 | $out[$name] ??= html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 316 | } |
| 317 | return $length; |
| 318 | } |
| 319 | |
| 320 | /** Encoding Standard §4.2 — label → encoding name, or null. */ |
| 321 | private static function resolveLabel(string $label): ?string |
| 322 | { |
| 323 | return self::LABELS[strtolower(trim($label, "\t\n\f\r "))] ?? null; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Transcode to UTF-8, preferring mbstring and falling back to iconv |
| 328 | * for the encodings this build's mbstring lacks. When neither can |
| 329 | * do it the source is returned untouched — a mis-decoded document |
| 330 | * still renders something, where a thrown exception renders nothing. |
| 331 | */ |
| 332 | private static function transcode(string $bytes, string $encoding): string |
| 333 | { |
| 334 | if ($encoding === 'UTF-8') { |
| 335 | return $bytes; |
| 336 | } |
| 337 | if (in_array($encoding, mb_list_encodings(), true)) { |
| 338 | $converted = @mb_convert_encoding($bytes, 'UTF-8', $encoding); |
| 339 | if ($converted !== false) { |
| 340 | return $converted; |
| 341 | } |
| 342 | } |
| 343 | if (function_exists('iconv')) { |
| 344 | $converted = @iconv($encoding, 'UTF-8//IGNORE', $bytes); |
| 345 | if ($converted !== false) { |
| 346 | return $converted; |
| 347 | } |
| 348 | } |
| 349 | return $bytes; |
| 350 | } |
| 351 | } |