Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.00% |
48 / 50 |
|
50.00% |
1 / 2 |
CRAP | |
0.00% |
0 / 1 |
| MimeSniffer | |
96.00% |
48 / 50 |
|
50.00% |
1 / 2 |
35 | |
0.00% |
0 / 1 |
| sniff | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
23 | |||
| looksLikeSvg | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
12.42 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 32 | final 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 | } |