Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.11% covered (warning)
82.11%
78 / 95
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jpeg2000Parser
82.11% covered (warning)
82.11%
78 / 95
42.86% covered (danger)
42.86%
3 / 7
40.62
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
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 parseJp2Boxes
72.50% covered (warning)
72.50%
29 / 40
0.00% covered (danger)
0.00%
0 / 1
18.08
 parseCodestream
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
8.10
 buildInfo
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 readUint32
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 readUint16
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ImageMetadata;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * Parse JPEG 2000 (.jp2, .j2k, .j2c) image headers.
11 *
12 * Supports two container formats:
13 *   - JP2 box format (starts with JP2 signature box)
14 *   - Raw codestream (.j2k/.j2c, starts with SOC marker 0xFF4F)
15 *
16 * Extracts width, height, components, and bits per component from
17 * the SIZ marker (required in every JPEG 2000 codestream).
18 */
19final class Jpeg2000Parser
20{
21    /** JP2 file signature: 12-byte box with "jP  " brand */
22    private const JP2_SIGNATURE = "\x00\x00\x00\x0C\x6A\x50\x20\x20";
23
24    /** JPEG 2000 codestream SOC (Start of Codestream) marker */
25    private const SOC_MARKER = "\xFF\x4F";
26
27    /** SIZ marker (Image and tile size) */
28    private const SIZ_MARKER = "\xFF\x51";
29
30    public static function parseFile(string $path): ImageInfo
31    {
32        $fh = LocalFilesystem::openReadable($path, "image file");
33        try {
34            // Read enough for JP2 box header + ihdr, or raw codestream SIZ
35            $data = fread($fh, min(filesize($path), 4096));
36        } finally {
37            fclose($fh);
38        }
39        return self::parse($data);
40    }
41
42    public static function parse(string $data): ImageInfo
43    {
44        $len = strlen($data);
45        if ($len < 2) {
46            throw new \RuntimeException('Data too short for JPEG 2000');
47        }
48
49        // Detect format: JP2 box container or raw codestream
50        if (str_starts_with($data, self::JP2_SIGNATURE)) {
51            return self::parseJp2Boxes($data, $len);
52        }
53
54        if (str_starts_with($data, self::SOC_MARKER)) {
55            return self::parseCodestream($data, 0, $len);
56        }
57
58        throw new \RuntimeException('Not a valid JPEG 2000 file');
59    }
60
61    /**
62     * Parse JP2 box format — walk boxes to find the codestream (jp2c)
63     * or the image header box (ihdr).
64     */
65    private static function parseJp2Boxes(string $data, int $len): ImageInfo
66    {
67        $pos = 0;
68        $width = 0;
69        $height = 0;
70        $components = 3;
71        $bitsPerComponent = 8;
72
73        while ($pos + 8 <= $len) {
74            $boxLen = self::readUint32($data, $pos);
75            $boxType = substr($data, $pos + 4, 4);
76
77            // Box length of 0 means "rest of file"; 1 means extended (8-byte) length
78            if ($boxLen === 1 && $pos + 16 <= $len) {
79                // Extended length — skip for our purposes, use upper bound
80                $boxLen = $len - $pos;
81            } elseif ($boxLen === 0) {
82                $boxLen = $len - $pos;
83            }
84
85            $contentStart = $pos + 8;
86            $contentLen = $boxLen - 8;
87
88            if ($boxType === 'ihdr' && $contentLen >= 14) {
89                // Image Header Box: height(4) width(4) nc(2) bpc(1) ...
90                $height = self::readUint32($data, $contentStart);
91                $width = self::readUint32($data, $contentStart + 4);
92                $components = self::readUint16($data, $contentStart + 8);
93                $bitsPerComponent = (ord($data[$contentStart + 10]) & 0x7F) + 1;
94
95                return self::buildInfo($width, $height, $components, $bitsPerComponent);
96            }
97
98            if ($boxType === 'jp2c' && $contentLen >= 2) {
99                // Contiguous Codestream Box — parse the embedded codestream
100                return self::parseCodestream($data, $contentStart, $len);
101            }
102
103            // jp2h (JP2 Header super-box) contains ihdr — recurse into it
104            if ($boxType === 'jp2h') {
105                $innerPos = $contentStart;
106                $innerEnd = min($pos + $boxLen, $len);
107                while ($innerPos + 8 <= $innerEnd) {
108                    $innerBoxLen = self::readUint32($data, $innerPos);
109                    $innerBoxType = substr($data, $innerPos + 4, 4);
110                    if ($innerBoxLen === 0) {
111                        $innerBoxLen = $innerEnd - $innerPos;
112                    }
113
114                    if ($innerBoxType === 'ihdr' && $innerBoxLen >= 22) {
115                        $ihdrStart = $innerPos + 8;
116                        $height = self::readUint32($data, $ihdrStart);
117                        $width = self::readUint32($data, $ihdrStart + 4);
118                        $components = self::readUint16($data, $ihdrStart + 8);
119                        $bitsPerComponent = (ord($data[$ihdrStart + 10]) & 0x7F) + 1;
120
121                        return self::buildInfo($width, $height, $components, $bitsPerComponent);
122                    }
123
124                    $innerPos += max($innerBoxLen, 8);
125                }
126            }
127
128            $pos += max($boxLen, 8);
129        }
130
131        // Fallback if no ihdr or codestream found
132        throw new \RuntimeException('JPEG 2000: unable to find image dimensions');
133    }
134
135    /**
136     * Parse a raw JPEG 2000 codestream starting at $offset.
137     * Looks for the SIZ marker to extract dimensions.
138     */
139    private static function parseCodestream(string $data, int $offset, int $len): ImageInfo
140    {
141        $pos = $offset;
142
143        // Skip SOC marker
144        if ($pos + 2 <= $len && substr($data, $pos, 2) === self::SOC_MARKER) {
145            $pos += 2;
146        }
147
148        // Next marker should be SIZ
149        if ($pos + 2 <= $len && substr($data, $pos, 2) === self::SIZ_MARKER) {
150            $pos += 2;
151
152            if ($pos + 2 > $len) {
153                throw new \RuntimeException('JPEG 2000: truncated SIZ marker');
154            }
155
156            // SIZ marker content: Lsiz(2) Rsiz(2) Xsiz(4) Ysiz(4) XOsiz(4) YOsiz(4)
157            // XTsiz(4) YTsiz(4) XTOsiz(4) YTOsiz(4) Csiz(2) ...
158            // Image dimensions = Xsiz - XOsiz, Ysiz - YOsiz
159            $segLen = self::readUint16($data, $pos);
160            $pos += 2;
161
162            if ($pos + 36 > $len) {
163                throw new \RuntimeException('JPEG 2000: truncated SIZ segment');
164            }
165
166            // Skip Rsiz (2 bytes)
167            $pos += 2;
168
169            $xSiz = self::readUint32($data, $pos);
170            $ySiz = self::readUint32($data, $pos + 4);
171            $xoSiz = self::readUint32($data, $pos + 8);
172            $yoSiz = self::readUint32($data, $pos + 12);
173
174            $width = $xSiz - $xoSiz;
175            $height = $ySiz - $yoSiz;
176
177            // Skip XTsiz(4) YTsiz(4) XTOsiz(4) YTOsiz(4)
178            $csizOffset = $pos + 32;
179            $components = self::readUint16($data, $csizOffset);
180
181            // Ssiz_i: bit depth for first component
182            $bitsPerComponent = 8;
183            if ($csizOffset + 2 + 1 <= $len) {
184                $ssiz = ord($data[$csizOffset + 2]);
185                $bitsPerComponent = ($ssiz & 0x7F) + 1;
186            }
187
188            return self::buildInfo($width, $height, $components, $bitsPerComponent);
189        }
190
191        throw new \RuntimeException('JPEG 2000: SIZ marker not found');
192    }
193
194    private static function buildInfo(int $width, int $height, int $components, int $bitsPerComponent): ImageInfo
195    {
196        $colorSpace = match ($components) {
197            1 => 'DeviceGray',
198            4 => 'DeviceCMYK',
199            default => 'DeviceRGB',
200        };
201
202        return new ImageInfo(
203            width: $width,
204            height: $height,
205            colorSpace: $colorSpace,
206            bitsPerComponent: $bitsPerComponent,
207            format: 'jpeg2000',
208            hasAlpha: $components === 2 || $components === 4,
209        );
210    }
211
212    private static function readUint32(string $data, int $offset): int
213    {
214        return (ord($data[$offset]) << 24)
215             | (ord($data[$offset + 1]) << 16)
216             | (ord($data[$offset + 2]) << 8)
217             | ord($data[$offset + 3]);
218    }
219
220    private static function readUint16(string $data, int $offset): int
221    {
222        return (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
223    }
224}