Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.90% covered (warning)
85.90%
67 / 78
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
JpegParser
85.90% covered (warning)
85.90%
67 / 78
50.00% covered (danger)
50.00%
1 / 2
36.05
0.00% covered (danger)
0.00%
0 / 1
 parseFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parse
85.14% covered (warning)
85.14%
63 / 74
0.00% covered (danger)
0.00%
0 / 1
35.36
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ImageMetadata;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * Parse JPEG headers (SOF marker) for dimensions, components, and color space.
11 *
12 * Also extracts ICC profile data from APP2 markers when present, which
13 * is needed for accurate color reproduction in PDF/A output.
14 */
15final class JpegParser
16{
17    public static function parseFile(string $path): ImageInfo
18    {
19        $fh = LocalFilesystem::openReadable($path, "image file");
20        try {
21            $data = fread($fh, filesize($path));
22        } finally {
23            fclose($fh);
24        }
25        return self::parse($data);
26    }
27
28    public static function parse(string $data): ImageInfo
29    {
30        $len = strlen($data);
31        $pos = 0;
32
33        // Verify SOI marker
34        if ($pos + 2 > $len || ord($data[$pos]) !== 0xFF || ord($data[$pos + 1]) !== 0xD8) {
35            throw new \RuntimeException('Not a valid JPEG file');
36        }
37        $pos += 2;
38
39        $width = 0;
40        $height = 0;
41        $components = 3;
42        $bitsPerComponent = 8;
43        $xDpi = null;
44        $yDpi = null;
45        /** @var array<int, string> ICC profile chunks keyed by 1-based sequence number */
46        $iccChunks = [];
47
48        while ($pos + 4 <= $len) {
49            // Find next marker
50            if (ord($data[$pos]) !== 0xFF) {
51                $pos++;
52                continue;
53            }
54            $pos++;
55            // Skip padding 0xFF bytes
56            while ($pos < $len && ord($data[$pos]) === 0xFF) {
57                $pos++;
58            }
59            if ($pos >= $len) {
60                break;
61            }
62
63            $marker = ord($data[$pos]);
64            $pos++;
65
66            // EOI or standalone markers
67            if ($marker === 0xD9 || $marker === 0xD8) {
68                break;
69            }
70            // Skip standalone markers (RST0-RST7, SOI)
71            if ($marker >= 0xD0 && $marker <= 0xD7) {
72                continue;
73            }
74
75            if ($pos + 2 > $len) {
76                break;
77            }
78            $segLen = (ord($data[$pos]) << 8) | ord($data[$pos + 1]);
79            $segStart = $pos;
80            $pos += 2;
81
82            // APP0 (JFIF) for DPI
83            if ($marker === 0xE0 && $segLen >= 16) {
84                // Check JFIF identifier
85                if (substr($data, $pos, 5) === "JFIF\x00") {
86                    $units = ord($data[$pos + 7]);
87                    $xDens = (ord($data[$pos + 8]) << 8) | ord($data[$pos + 9]);
88                    $yDens = (ord($data[$pos + 10]) << 8) | ord($data[$pos + 11]);
89                    if ($units === 1 && $xDens > 0 && $yDens > 0) {
90                        $xDpi = $xDens;
91                        $yDpi = $yDens;
92                    } elseif ($units === 2 && $xDens > 0 && $yDens > 0) {
93                        // dots per cm â†’ DPI
94                        $xDpi = (int) round($xDens * 2.54);
95                        $yDpi = (int) round($yDens * 2.54);
96                    }
97                }
98            }
99
100            // APP2 (ICC_PROFILE) for embedded ICC profile
101            if ($marker === 0xE2 && $segLen >= 16) {
102                $iccId = "ICC_PROFILE\x00";
103                if (substr($data, $pos, 12) === $iccId) {
104                    $seqNum = ord($data[$pos + 12]);
105                    // byte 13 is total chunks count (not needed for keying)
106                    $iccChunks[$seqNum] = substr($data, $pos + 14, $segLen - 16);
107                }
108            }
109
110            // SOF markers: 0xC0=SOF0, 0xC1=SOF1, 0xC2=SOF2
111            if (in_array($marker, [0xC0, 0xC1, 0xC2], true)) {
112                if ($pos + 6 <= $len) {
113                    $bitsPerComponent = ord($data[$pos]);
114                    $height = (ord($data[$pos + 1]) << 8) | ord($data[$pos + 2]);
115                    $width  = (ord($data[$pos + 3]) << 8) | ord($data[$pos + 4]);
116                    $components = ord($data[$pos + 5]);
117                }
118            }
119
120            $pos = $segStart + $segLen;
121        }
122
123        $colorSpace = match ($components) {
124            1 => 'DeviceGray',
125            4 => 'DeviceCMYK',
126            default => 'DeviceRGB',
127        };
128
129        // Assemble ICC profile from collected chunks (sorted by sequence number)
130        $iccProfile = null;
131        if (!empty($iccChunks)) {
132            ksort($iccChunks);
133            $iccProfile = implode('', $iccChunks);
134        }
135
136        return new ImageInfo(
137            width: $width,
138            height: $height,
139            colorSpace: $colorSpace,
140            bitsPerComponent: $bitsPerComponent,
141            format: 'jpeg',
142            hasAlpha: false,
143            xDpi: $xDpi,
144            yDpi: $yDpi,
145            iccProfile: $iccProfile,
146        );
147    }
148}