Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.00% covered (success)
96.00%
48 / 50
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
MimeSniffer
96.00% covered (success)
96.00%
48 / 50
50.00% covered (danger)
50.00%
1 / 2
35
0.00% covered (danger)
0.00%
0 / 1
 sniff
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
23
 looksLikeSvg
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
12.42
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ResourceLoader;
6
7/**
8 * Detects the MIME type of a binary blob by looking at the first
9 * few bytes — the WHATWG mime-sniff spec equivalent for the formats
10 * phpdftk's pipeline actually consumes. The HTTP `Content-Type`
11 * response header is treated as a hint, not authoritative; servers
12 * routinely mislabel images.
13 *
14 * Coverage:
15 *
16 *   - image/png         89 50 4E 47 0D 0A 1A 0A   (8 bytes)
17 *   - image/jpeg        FF D8 FF                  (3 bytes)
18 *   - image/gif         'GIF87a' | 'GIF89a'       (6 bytes)
19 *   - image/webp        'RIFF' .... 'WEBP'        (12 bytes)
20 *   - image/tiff        'II' 2A 00 | 'MM' 00 2A   (4 bytes)
21 *   - image/bmp         'BM'                      (2 bytes)
22 *   - image/svg+xml     XML/SVG textual sniff
23 *   - font/ttf          00 01 00 00               (4 bytes)
24 *   - font/otf          'OTTO'                    (4 bytes)
25 *   - font/woff         'wOFF'                    (4 bytes)
26 *   - font/woff2        'wOF2'                    (4 bytes)
27 *   - font/collection   'ttcf'                    (4 bytes — TrueType collection)
28 *
29 * Returns `application/octet-stream` when no signature matches —
30 * safe default that downstream code can reject.
31 */
32final class MimeSniffer
33{
34    /**
35     * The default WHATWG-compatible result when nothing matches. The
36     * caller decides whether to accept opaque bytes (`<image>` href
37     * pointing at an unknown format → SVG 2 §12.6 "no image
38     * available" outcome) or surface an error.
39     */
40    public const FALLBACK = 'application/octet-stream';
41
42    /**
43     * Inspect the first few bytes of `$bytes` and return the
44     * sniffed MIME type. The caller should pass at least 16 bytes
45     * for robust detection; shorter payloads fall through to the
46     * fallback type.
47     */
48    public function sniff(string $bytes): string
49    {
50        $length = strlen($bytes);
51        if ($length < 2) {
52            return self::FALLBACK;
53        }
54
55        // Order matters — PNG / JPEG / GIF / WebP / TIFF / BMP all
56        // have unambiguous magic bytes; SVG is a textual fall-
57        // through and checked last.
58
59        if ($length >= 8 && substr($bytes, 0, 8) === "\x89PNG\r\n\x1a\n") {
60            return 'image/png';
61        }
62
63        if ($length >= 3 && substr($bytes, 0, 3) === "\xff\xd8\xff") {
64            return 'image/jpeg';
65        }
66
67        if ($length >= 6) {
68            $first6 = substr($bytes, 0, 6);
69            if ($first6 === 'GIF87a' || $first6 === 'GIF89a') {
70                return 'image/gif';
71            }
72        }
73
74        if (
75            $length >= 12
76            && substr($bytes, 0, 4) === 'RIFF'
77            && substr($bytes, 8, 4) === 'WEBP'
78        ) {
79            return 'image/webp';
80        }
81
82        if ($length >= 4) {
83            $first4 = substr($bytes, 0, 4);
84            if ($first4 === "II*\x00" || $first4 === "MM\x00*") {
85                return 'image/tiff';
86            }
87        }
88
89        if (substr($bytes, 0, 2) === 'BM') {
90            return 'image/bmp';
91        }
92
93        if ($length >= 4) {
94            $first4 = substr($bytes, 0, 4);
95            if ($first4 === "\x00\x01\x00\x00") {
96                return 'font/ttf';
97            }
98            if ($first4 === 'OTTO') {
99                return 'font/otf';
100            }
101            if ($first4 === 'wOFF') {
102                return 'font/woff';
103            }
104            if ($first4 === 'wOF2') {
105                return 'font/woff2';
106            }
107            if ($first4 === 'ttcf') {
108                return 'font/collection';
109            }
110        }
111
112        if (self::looksLikeSvg($bytes)) {
113            return 'image/svg+xml';
114        }
115
116        return self::FALLBACK;
117    }
118
119    /**
120     * Textual SVG detection — skip any UTF-8 / UTF-16 BOM and
121     * leading whitespace, then look for `<?xml` or `<svg` near the
122     * start of the document. Doesn't validate that the document is
123     * well-formed XML; that's the parser's job.
124     */
125    private static function looksLikeSvg(string $bytes): bool
126    {
127        // Strip BOMs if present.
128        if (str_starts_with($bytes, "\xEF\xBB\xBF")) {
129            $bytes = substr($bytes, 3);
130        } elseif (str_starts_with($bytes, "\xFF\xFE") || str_starts_with($bytes, "\xFE\xFF")) {
131            // UTF-16 BOM. Quick path: decode to ASCII roughly by
132            // dropping every other byte. Good enough for the magic-
133            // bytes check; full Unicode parsing is the SVG parser's
134            // job.
135            $bytes = preg_replace('/\x00/', '', substr($bytes, 2)) ?? '';
136        }
137
138        // Strip leading whitespace.
139        $trimmed = ltrim($bytes);
140        if ($trimmed === '') {
141            return false;
142        }
143
144        // Look for the XML declaration or the SVG root in the first
145        // ~256 bytes. Real SVGs may have a DOCTYPE between the XML
146        // declaration and the root; we scan ahead a bit.
147        $window = substr($trimmed, 0, 256);
148        if (str_starts_with($window, '<?xml')) {
149            // Has XML declaration — look for `<svg` somewhere in
150            // the window (likely the root or just after a doctype).
151            // Match `<svg ` (with attribute) or `<svg>` (no
152            // attributes) or `<svg xmlns…`.
153            return preg_match('/<svg(\s|>)/i', $window) === 1;
154        }
155        if (str_starts_with($window, '<svg')) {
156            // Bare SVG without XML declaration.
157            $afterSvg = substr($window, 4, 1);
158            return $afterSvg === '' || $afterSvg === ' ' || $afterSvg === '>' || $afterSvg === "\t" || $afterSvg === "\n" || $afterSvg === "\r";
159        }
160        return false;
161    }
162}