Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.33% covered (warning)
81.33%
183 / 225
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PngParser
81.33% covered (warning)
81.33%
183 / 225
12.50% covered (danger)
12.50%
1 / 8
153.70
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
 decodeAlphaPng
80.70% covered (warning)
80.70%
46 / 57
0.00% covered (danger)
0.00%
0 / 1
25.48
 peekColorType
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 paeth
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 decodeIndexedPng
77.61% covered (warning)
77.61%
52 / 67
0.00% covered (danger)
0.00%
0 / 1
33.59
 extractChunk
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 extractIdatData
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 parse
96.43% covered (success)
96.43%
54 / 56
0.00% covered (danger)
0.00%
0 / 1
24
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ImageMetadata;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * Parse PNG IHDR chunk for dimensions, bit depth, and color type.
11 *
12 * Also extracts ICC profiles from iCCP chunks. PNG alpha channels
13 * are detected since PDF handles transparency via SMask, not inline.
14 */
15final class PngParser
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    /**
29     * Decode an 8-bit alpha PNG (color type 4 = grayscale+alpha,
30     * type 6 = RGB+alpha) into separate colour and alpha streams.
31     * The PDF caller then emits the colour as the main Image
32     * XObject and the alpha as an `/SMask` reference attached to
33     * it. Returns null for non-alpha PNGs, non-8-bit depths, or
34     * any decode failure (corrupt IDAT, bad filter byte, etc.).
35     *
36     * Output streams are raw (uncompressed) per-pixel bytes —
37     * colour: 1 byte/px (grayscale) or 3 bytes/px (RGB).
38     * alpha:  1 byte/px (grayscale always).
39     *
40     * The caller is expected to FlateDecode-compress them before
41     * embedding in the PDF.
42     *
43     * @return array{colour: string, alpha: string, width: int, height: int, components: int}|null
44     */
45    public static function decodeAlphaPng(string $data): ?array
46    {
47        $info = self::parse($data);
48        $colorType = self::peekColorType($data);
49        if ($colorType === null || $info->bitsPerComponent !== 8) {
50            return null;
51        }
52        $components = match ($colorType) {
53            4 => 1, // grayscale + alpha → 2 bpp
54            6 => 3, // RGB + alpha       → 4 bpp
55            default => null,
56        };
57        if ($components === null) {
58            return null;
59        }
60        $idat = self::extractIdatData($data);
61        if ($idat === null) {
62            return null;
63        }
64        $decompressed = @gzuncompress($idat);
65        if ($decompressed === false) {
66            return null;
67        }
68        $bpp = $components + 1; // colour bytes + 1 alpha byte
69        $stride = $info->width * $bpp;
70        $expected = ($stride + 1) * $info->height;
71        if (strlen($decompressed) < $expected) {
72            return null;
73        }
74        $colour = '';
75        $alpha = '';
76        $prev = str_repeat("\x00", $stride);
77        $offset = 0;
78        for ($y = 0; $y < $info->height; $y++) {
79            $filter = ord($decompressed[$offset]);
80            $offset++;
81            $current = '';
82            for ($x = 0; $x < $stride; $x++) {
83                $px = ord($decompressed[$offset + $x]);
84                $left = $x >= $bpp ? ord($current[$x - $bpp]) : 0;
85                $up = ord($prev[$x]);
86                $upLeft = $x >= $bpp ? ord($prev[$x - $bpp]) : 0;
87                $unfiltered = match ($filter) {
88                    0 => $px,
89                    1 => ($px + $left) & 0xFF,
90                    2 => ($px + $up) & 0xFF,
91                    3 => ($px + (($left + $up) >> 1)) & 0xFF,
92                    4 => ($px + self::paeth($left, $up, $upLeft)) & 0xFF,
93                    default => -1,
94                };
95                if ($unfiltered < 0) {
96                    return null;
97                }
98                $current .= chr($unfiltered);
99            }
100            $offset += $stride;
101            // Split the unfiltered row into colour + alpha bytes.
102            for ($x = 0; $x < $info->width; $x++) {
103                $pxBase = $x * $bpp;
104                $colour .= substr($current, $pxBase, $components);
105                $alpha .= $current[$pxBase + $components];
106            }
107            $prev = $current;
108        }
109        return [
110            'colour' => $colour,
111            'alpha' => $alpha,
112            'width' => $info->width,
113            'height' => $info->height,
114            'components' => $components,
115        ];
116    }
117
118    /**
119     * Peek the PNG's color type without re-running the full parser.
120     * IHDR is always the first chunk after the 8-byte signature, so
121     * we read it directly.
122     */
123    private static function peekColorType(string $data): ?int
124    {
125        if (strlen($data) < 8 + 8 + 13 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
126            return null;
127        }
128        if (substr($data, 12, 4) !== 'IHDR') {
129            return null;
130        }
131        return ord($data[8 + 8 + 9]);
132    }
133
134    private static function paeth(int $a, int $b, int $c): int
135    {
136        $p = $a + $b - $c;
137        $pa = abs($p - $a);
138        $pb = abs($p - $b);
139        $pc = abs($p - $c);
140        if ($pa <= $pb && $pa <= $pc) {
141            return $a;
142        }
143        if ($pb <= $pc) {
144            return $b;
145        }
146        return $c;
147    }
148
149    /**
150     * Decode an 8-bit indexed-colour PNG (color type 3) into the
151     * separate colour + alpha streams a PDF Image XObject embeds.
152     * Walks the IDAT data through PNG filter reversal, indexes each
153     * pixel into the PLTE palette, and (when the optional tRNS
154     * chunk is present) looks up per-palette-index alpha.
155     *
156     * Returns null for non-indexed PNGs, unsupported bit depths
157     * (anything outside 1 / 2 / 4 / 8), missing PLTE, or any decode
158     * failure.
159     *
160     * Output: colour = 3 bytes/px (RGB). alpha = 1 byte/px when
161     * tRNS is present; null when the palette is fully opaque (the
162     * caller skips the SMask).
163     *
164     * @return array{colour: string, alpha: ?string, width: int, height: int}|null
165     */
166    public static function decodeIndexedPng(string $data): ?array
167    {
168        $info = self::parse($data);
169        $colorType = self::peekColorType($data);
170        if ($colorType !== 3 || !in_array($info->bitsPerComponent, [1, 2, 4, 8], true)) {
171            return null;
172        }
173        $palette = self::extractChunk($data, 'PLTE');
174        if ($palette === null || strlen($palette) % 3 !== 0 || $palette === '') {
175            return null;
176        }
177        $trns = self::extractChunk($data, 'tRNS');
178        $idat = self::extractIdatData($data);
179        if ($idat === null) {
180            return null;
181        }
182        $decompressed = @gzuncompress($idat);
183        if ($decompressed === false) {
184            return null;
185        }
186        // PNG packs sub-byte pixels MSB-first into the smallest whole
187        // number of bytes per scanline. Filter bytes precede the
188        // packed row, so we walk the packed stride byte-by-byte for
189        // filter reversal, then unpack into 1-byte-per-pixel indices.
190        $bitDepth = $info->bitsPerComponent;
191        $stride = (int) ceil(($info->width * $bitDepth) / 8);
192        $expected = ($stride + 1) * $info->height;
193        if (strlen($decompressed) < $expected) {
194            return null;
195        }
196        $bpp = 1; // filter neighbours operate on packed bytes for sub-8 depths
197        $colour = '';
198        $alpha = '';
199        $hasTransparent = false;
200        $prev = str_repeat("\x00", $stride);
201        $offset = 0;
202        $mask = (1 << $bitDepth) - 1;
203        for ($y = 0; $y < $info->height; $y++) {
204            $filter = ord($decompressed[$offset]);
205            $offset++;
206            $current = '';
207            for ($x = 0; $x < $stride; $x++) {
208                $px = ord($decompressed[$offset + $x]);
209                $left = $x >= $bpp ? ord($current[$x - $bpp]) : 0;
210                $up = ord($prev[$x]);
211                $upLeft = $x >= $bpp ? ord($prev[$x - $bpp]) : 0;
212                $unfiltered = match ($filter) {
213                    0 => $px,
214                    1 => ($px + $left) & 0xFF,
215                    2 => ($px + $up) & 0xFF,
216                    3 => ($px + (($left + $up) >> 1)) & 0xFF,
217                    4 => ($px + self::paeth($left, $up, $upLeft)) & 0xFF,
218                    default => -1,
219                };
220                if ($unfiltered < 0) {
221                    return null;
222                }
223                $current .= chr($unfiltered);
224            }
225            $offset += $stride;
226            // Unpack packed indices MSB-first into per-pixel indices,
227            // then walk those for palette + alpha lookup.
228            for ($x = 0; $x < $info->width; $x++) {
229                $bitIndex = $x * $bitDepth;
230                $byteIndex = intdiv($bitIndex, 8);
231                $shift = 8 - $bitDepth - ($bitIndex % 8);
232                $idx = (ord($current[$byteIndex]) >> $shift) & $mask;
233                $paletteOffset = $idx * 3;
234                if ($paletteOffset + 3 > strlen($palette)) {
235                    // Out-of-range index — paint with palette[0] as
236                    // a tolerant fallback (matches some browsers'
237                    // recovery posture).
238                    $paletteOffset = 0;
239                }
240                $colour .= substr($palette, $paletteOffset, 3);
241                if ($trns !== null) {
242                    $a = $idx < strlen($trns) ? ord($trns[$idx]) : 0xFF;
243                    $alpha .= chr($a);
244                    if ($a !== 0xFF) {
245                        $hasTransparent = true;
246                    }
247                }
248            }
249            $prev = $current;
250        }
251        return [
252            'colour' => $colour,
253            'alpha' => $hasTransparent ? $alpha : null,
254            'width' => $info->width,
255            'height' => $info->height,
256        ];
257    }
258
259    /**
260     * Find and return the first matching chunk payload. Used for
261     * one-off lookups (`PLTE`, `tRNS`) that don't appear in the
262     * critical IHDR + IDAT + IEND path the full parser walks.
263     */
264    private static function extractChunk(string $data, string $chunkType): ?string
265    {
266        $len = strlen($data);
267        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
268            return null;
269        }
270        $pos = 8;
271        while ($pos + 12 <= $len) {
272            $chunkLen = unpack('N', substr($data, $pos, 4))[1];
273            $type = substr($data, $pos + 4, 4);
274            if ($type === $chunkType) {
275                return substr($data, $pos + 8, $chunkLen);
276            }
277            if ($type === 'IEND') {
278                return null;
279            }
280            $pos += 12 + $chunkLen;
281        }
282        return null;
283    }
284
285    /**
286     * Extract the concatenated `IDAT` chunk payload from a PNG.
287     * The payload is already DEFLATE-compressed PNG-filter-coded
288     * pixel data, suitable for embedding in a PDF Image XObject
289     * with `/Filter /FlateDecode` + `/DecodeParms <<Predictor 15
290     * Columns W Colors N BitsPerComponent B>>` — PDF readers
291     * decompress + unfilter via the predictor without an
292     * intermediate raw-RGB buffer.
293     *
294     * Returns null when the PNG signature is invalid or no IDAT
295     * chunks are present.
296     */
297    public static function extractIdatData(string $data): ?string
298    {
299        $len = strlen($data);
300        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
301            return null;
302        }
303        $pos = 8;
304        $idat = '';
305        while ($pos + 12 <= $len) {
306            $chunkLen = unpack('N', substr($data, $pos, 4))[1];
307            $chunkType = substr($data, $pos + 4, 4);
308            if ($chunkType === 'IDAT' && $chunkLen > 0) {
309                $idat .= substr($data, $pos + 8, $chunkLen);
310            } elseif ($chunkType === 'IEND') {
311                break;
312            }
313            $pos += 12 + $chunkLen;
314        }
315        return $idat === '' ? null : $idat;
316    }
317
318    public static function parse(string $data): ImageInfo
319    {
320        $len = strlen($data);
321
322        // Verify PNG signature (8 bytes)
323        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
324            throw new \RuntimeException('Not a valid PNG file');
325        }
326
327        $pos = 8;
328        $width = 0;
329        $height = 0;
330        $bitDepth = 8;
331        $colorType = 2;
332        $xDpi = null;
333        $yDpi = null;
334        $iccProfile = null;
335
336        while ($pos + 12 <= $len) {
337            $chunkLen  = unpack('N', substr($data, $pos, 4))[1];
338            $chunkType = substr($data, $pos + 4, 4);
339            $chunkData = substr($data, $pos + 8, $chunkLen);
340            $pos += 12 + $chunkLen;
341
342            if ($chunkType === 'IHDR' && strlen($chunkData) >= 13) {
343                $width    = unpack('N', substr($chunkData, 0, 4))[1];
344                $height   = unpack('N', substr($chunkData, 4, 4))[1];
345                $bitDepth = ord($chunkData[8]);
346                $colorType = ord($chunkData[9]);
347            } elseif ($chunkType === 'pHYs' && strlen($chunkData) >= 9) {
348                $xPixelsPerUnit = unpack('N', substr($chunkData, 0, 4))[1];
349                $yPixelsPerUnit = unpack('N', substr($chunkData, 4, 4))[1];
350                $unit = ord($chunkData[8]);
351                if ($unit === 1 && $xPixelsPerUnit > 0 && $yPixelsPerUnit > 0) {
352                    // Unit is meters; convert to DPI
353                    $xDpi = (int) round($xPixelsPerUnit / 39.3701);
354                    $yDpi = (int) round($yPixelsPerUnit / 39.3701);
355                }
356            } elseif ($chunkType === 'iCCP' && strlen($chunkData) > 2) {
357                // iCCP chunk: null-terminated profile name, 1-byte compression method, compressed data
358                $nullPos = strpos($chunkData, "\x00");
359                if ($nullPos !== false && $nullPos + 2 <= strlen($chunkData)) {
360                    // Skip profile name + null byte + compression method byte (always 0 = deflate)
361                    $compressedData = substr($chunkData, $nullPos + 2);
362                    if ($compressedData !== '') {
363                        $decompressed = @gzuncompress($compressedData);
364                        if ($decompressed !== false) {
365                            $iccProfile = $decompressed;
366                        }
367                    }
368                }
369            } elseif ($chunkType === 'IEND') {
370                break;
371            }
372        }
373
374        [$colorSpace, $hasAlpha] = match ($colorType) {
375            0 => ['DeviceGray', false],
376            2 => ['DeviceRGB', false],
377            3 => ['DeviceRGB', false],  // indexed — treat as RGB
378            4 => ['DeviceGray', true],
379            6 => ['DeviceRGB', true],
380            default => ['DeviceRGB', false],
381        };
382
383        return new ImageInfo(
384            width: $width,
385            height: $height,
386            colorSpace: $colorSpace,
387            bitsPerComponent: $bitDepth,
388            format: 'png',
389            hasAlpha: $hasAlpha,
390            xDpi: $xDpi,
391            yDpi: $yDpi,
392            iccProfile: $iccProfile,
393        );
394    }
395}