Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.18% covered (success)
91.18%
93 / 102
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
EncodingSniffer
91.18% covered (success)
91.18%
93 / 102
57.14% covered (warning)
57.14%
4 / 7
50.65
0.00% covered (danger)
0.00%
0 / 1
 toUtf8
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 bomEncoding
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 sniffMeta
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
16
 encodingFromMetaAttributes
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 readAttributes
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
12.11
 resolveLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transcode
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
9.38
1<?php
2
3declare(strict_types=1);
4
5namespace 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 */
29final 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="&#119;indows-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}