Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.11% covered (warning)
67.11%
200 / 298
8.33% covered (danger)
8.33%
1 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PngParser
67.11% covered (warning)
67.11%
200 / 298
8.33% covered (danger)
8.33%
1 / 12
651.36
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
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
11.12
 decodeOpaquePng
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
110
 peekInterlace
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 unfilterRaster
11.54% covered (danger)
11.54%
3 / 26
0.00% covered (danger)
0.00%
0 / 1
94.76
 unfilterPass
75.00% covered (warning)
75.00%
21 / 28
0.00% covered (danger)
0.00%
0 / 1
15.64
 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        $raster = self::unfilterRaster(
70            $decompressed,
71            $info->width,
72            $info->height,
73            $bpp,
74            self::peekInterlace($data) ?? 0,
75        );
76        if ($raster === null) {
77            return null;
78        }
79        // Split the unfiltered raster into colour + alpha planes.
80        $colour = '';
81        $alpha = '';
82        $pixels = $info->width * $info->height;
83        for ($i = 0; $i < $pixels; $i++) {
84            $pxBase = $i * $bpp;
85            $colour .= substr($raster, $pxBase, $components);
86            $alpha .= $raster[$pxBase + $components];
87        }
88        return [
89            'colour' => $colour,
90            'alpha' => $alpha,
91            'width' => $info->width,
92            'height' => $info->height,
93            'components' => $components,
94        ];
95    }
96
97    /**
98     * Decode a non-alpha 8-bit PNG (colour type 0 grayscale or 2 RGB)
99     * to a flat raster.
100     *
101     * Only needed for INTERLACED images: a non-interlaced PNG's IDAT
102     * can be handed straight to PDF as FlateDecode with `/Predictor
103     * 15`, but Adam7 data is seven sub-images rather than scanlines,
104     * so the PDF predictor cannot read it and the image must be
105     * decoded here and re-emitted.
106     *
107     * @return array{colour: string, width: int, height: int, components: int}|null
108     */
109    public static function decodeOpaquePng(string $data): ?array
110    {
111        $info = self::parse($data);
112        $colorType = self::peekColorType($data);
113        if ($colorType === null || $info->bitsPerComponent !== 8) {
114            return null;
115        }
116        $components = match ($colorType) {
117            0 => 1, // grayscale
118            2 => 3, // RGB
119            default => null,
120        };
121        if ($components === null) {
122            return null;
123        }
124        $idat = self::extractIdatData($data);
125        if ($idat === null) {
126            return null;
127        }
128        $decompressed = @gzuncompress($idat);
129        if ($decompressed === false) {
130            return null;
131        }
132        $raster = self::unfilterRaster(
133            $decompressed,
134            $info->width,
135            $info->height,
136            $components,
137            self::peekInterlace($data) ?? 0,
138        );
139        if ($raster === null) {
140            return null;
141        }
142        return [
143            'colour' => $raster,
144            'width' => $info->width,
145            'height' => $info->height,
146            'components' => $components,
147        ];
148    }
149
150    /**
151     * PNG spec Â§8.2 â€” the seven Adam7 interlace passes, each as
152     * `[xStart, yStart, xStep, yStep]`. Every pass is a self-contained
153     * sub-image with its OWN filter state, so each is unfiltered
154     * independently and then scattered into the full raster.
155     */
156    private const ADAM7_PASSES = [
157        [0, 0, 8, 8],
158        [4, 0, 8, 8],
159        [0, 4, 4, 8],
160        [2, 0, 4, 4],
161        [0, 2, 2, 4],
162        [1, 0, 2, 2],
163        [0, 1, 1, 2],
164    ];
165
166    /**
167     * Peek the PNG's interlace method (IHDR byte 12): 0 = none,
168     * 1 = Adam7. Same direct-read trick as {@see peekColorType}.
169     */
170    private static function peekInterlace(string $data): ?int
171    {
172        if (strlen($data) < 8 + 8 + 13 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
173            return null;
174        }
175        if (substr($data, 12, 4) !== 'IHDR') {
176            return null;
177        }
178        return ord($data[8 + 8 + 12]);
179    }
180
181    /**
182     * Unfilter a decompressed PNG datastream into a flat, filter-byte-
183     * free raster of `$width * $height * $bpp` bytes.
184     *
185     * Handles both interlace methods. For Adam7 (`$interlace === 1`)
186     * each of the seven passes is unfiltered as its own sub-image and
187     * its pixels scattered to their final positions â€” without this,
188     * an interlaced PNG is read as though the pass data were one
189     * contiguous raster, which renders as noise.
190     *
191     * Byte-for-byte identical to the previous single-pass behaviour
192     * when `$interlace === 0`. Requires whole-byte pixels (bit depth
193     * 8), which every caller already enforces.
194     */
195    private static function unfilterRaster(
196        string $data,
197        int $width,
198        int $height,
199        int $bpp,
200        int $interlace,
201    ): ?string {
202        if ($interlace === 0) {
203            $pass = self::unfilterPass($data, 0, $width, $height, $bpp);
204            return $pass === null ? null : $pass['raster'];
205        }
206        if ($interlace !== 1) {
207            return null;
208        }
209        $raster = str_repeat("\x00", $width * $height * $bpp);
210        $offset = 0;
211        foreach (self::ADAM7_PASSES as [$xStart, $yStart, $xStep, $yStep]) {
212            // Ceiling division â€” a pass covering no pixels is skipped
213            // entirely and contributes no bytes to the stream.
214            $passWidth = intdiv($width - $xStart + $xStep - 1, $xStep);
215            $passHeight = intdiv($height - $yStart + $yStep - 1, $yStep);
216            if ($passWidth <= 0 || $passHeight <= 0) {
217                continue;
218            }
219            $pass = self::unfilterPass($data, $offset, $passWidth, $passHeight, $bpp);
220            if ($pass === null) {
221                return null;
222            }
223            $offset = $pass['offset'];
224            $passRaster = $pass['raster'];
225            for ($py = 0; $py < $passHeight; $py++) {
226                $targetY = $yStart + $py * $yStep;
227                for ($px = 0; $px < $passWidth; $px++) {
228                    $targetX = $xStart + $px * $xStep;
229                    $src = ($py * $passWidth + $px) * $bpp;
230                    $dst = ($targetY * $width + $targetX) * $bpp;
231                    for ($b = 0; $b < $bpp; $b++) {
232                        $raster[$dst + $b] = $passRaster[$src + $b];
233                    }
234                }
235            }
236        }
237        return $raster;
238    }
239
240    /**
241     * Reverse the per-row PNG filters (spec Â§9.2) for one sub-image of
242     * `$width x $height` pixels starting at `$offset`. Returns the
243     * unfiltered bytes plus the offset just past the data consumed, so
244     * the caller can walk consecutive Adam7 passes.
245     *
246     * @return array{raster: string, offset: int}|null
247     */
248    private static function unfilterPass(
249        string $data,
250        int $offset,
251        int $width,
252        int $height,
253        int $bpp,
254    ): ?array {
255        $stride = $width * $bpp;
256        if (strlen($data) < $offset + ($stride + 1) * $height) {
257            return null;
258        }
259        $raster = '';
260        $prev = str_repeat("\x00", $stride);
261        for ($y = 0; $y < $height; $y++) {
262            $filter = ord($data[$offset]);
263            $offset++;
264            $current = '';
265            for ($x = 0; $x < $stride; $x++) {
266                $px = ord($data[$offset + $x]);
267                $left = $x >= $bpp ? ord($current[$x - $bpp]) : 0;
268                $up = ord($prev[$x]);
269                $upLeft = $x >= $bpp ? ord($prev[$x - $bpp]) : 0;
270                $unfiltered = match ($filter) {
271                    0 => $px,
272                    1 => ($px + $left) & 0xFF,
273                    2 => ($px + $up) & 0xFF,
274                    3 => ($px + (($left + $up) >> 1)) & 0xFF,
275                    4 => ($px + self::paeth($left, $up, $upLeft)) & 0xFF,
276                    default => -1,
277                };
278                if ($unfiltered < 0) {
279                    return null;
280                }
281                $current .= chr($unfiltered);
282            }
283            $offset += $stride;
284            $raster .= $current;
285            $prev = $current;
286        }
287        return ['raster' => $raster, 'offset' => $offset];
288    }
289
290    /**
291     * Peek the PNG's color type without re-running the full parser.
292     * IHDR is always the first chunk after the 8-byte signature, so
293     * we read it directly.
294     */
295    private static function peekColorType(string $data): ?int
296    {
297        if (strlen($data) < 8 + 8 + 13 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
298            return null;
299        }
300        if (substr($data, 12, 4) !== 'IHDR') {
301            return null;
302        }
303        return ord($data[8 + 8 + 9]);
304    }
305
306    private static function paeth(int $a, int $b, int $c): int
307    {
308        $p = $a + $b - $c;
309        $pa = abs($p - $a);
310        $pb = abs($p - $b);
311        $pc = abs($p - $c);
312        if ($pa <= $pb && $pa <= $pc) {
313            return $a;
314        }
315        if ($pb <= $pc) {
316            return $b;
317        }
318        return $c;
319    }
320
321    /**
322     * Decode an 8-bit indexed-colour PNG (color type 3) into the
323     * separate colour + alpha streams a PDF Image XObject embeds.
324     * Walks the IDAT data through PNG filter reversal, indexes each
325     * pixel into the PLTE palette, and (when the optional tRNS
326     * chunk is present) looks up per-palette-index alpha.
327     *
328     * Returns null for non-indexed PNGs, unsupported bit depths
329     * (anything outside 1 / 2 / 4 / 8), missing PLTE, or any decode
330     * failure.
331     *
332     * Output: colour = 3 bytes/px (RGB). alpha = 1 byte/px when
333     * tRNS is present; null when the palette is fully opaque (the
334     * caller skips the SMask).
335     *
336     * @return array{colour: string, alpha: ?string, width: int, height: int}|null
337     */
338    public static function decodeIndexedPng(string $data): ?array
339    {
340        $info = self::parse($data);
341        $colorType = self::peekColorType($data);
342        if ($colorType !== 3 || !in_array($info->bitsPerComponent, [1, 2, 4, 8], true)) {
343            return null;
344        }
345        $palette = self::extractChunk($data, 'PLTE');
346        if ($palette === null || strlen($palette) % 3 !== 0 || $palette === '') {
347            return null;
348        }
349        $trns = self::extractChunk($data, 'tRNS');
350        $idat = self::extractIdatData($data);
351        if ($idat === null) {
352            return null;
353        }
354        $decompressed = @gzuncompress($idat);
355        if ($decompressed === false) {
356            return null;
357        }
358        // PNG packs sub-byte pixels MSB-first into the smallest whole
359        // number of bytes per scanline. Filter bytes precede the
360        // packed row, so we walk the packed stride byte-by-byte for
361        // filter reversal, then unpack into 1-byte-per-pixel indices.
362        $bitDepth = $info->bitsPerComponent;
363        $stride = (int) ceil(($info->width * $bitDepth) / 8);
364        $expected = ($stride + 1) * $info->height;
365        if (strlen($decompressed) < $expected) {
366            return null;
367        }
368        $bpp = 1; // filter neighbours operate on packed bytes for sub-8 depths
369        $colour = '';
370        $alpha = '';
371        $hasTransparent = false;
372        $prev = str_repeat("\x00", $stride);
373        $offset = 0;
374        $mask = (1 << $bitDepth) - 1;
375        for ($y = 0; $y < $info->height; $y++) {
376            $filter = ord($decompressed[$offset]);
377            $offset++;
378            $current = '';
379            for ($x = 0; $x < $stride; $x++) {
380                $px = ord($decompressed[$offset + $x]);
381                $left = $x >= $bpp ? ord($current[$x - $bpp]) : 0;
382                $up = ord($prev[$x]);
383                $upLeft = $x >= $bpp ? ord($prev[$x - $bpp]) : 0;
384                $unfiltered = match ($filter) {
385                    0 => $px,
386                    1 => ($px + $left) & 0xFF,
387                    2 => ($px + $up) & 0xFF,
388                    3 => ($px + (($left + $up) >> 1)) & 0xFF,
389                    4 => ($px + self::paeth($left, $up, $upLeft)) & 0xFF,
390                    default => -1,
391                };
392                if ($unfiltered < 0) {
393                    return null;
394                }
395                $current .= chr($unfiltered);
396            }
397            $offset += $stride;
398            // Unpack packed indices MSB-first into per-pixel indices,
399            // then walk those for palette + alpha lookup.
400            for ($x = 0; $x < $info->width; $x++) {
401                $bitIndex = $x * $bitDepth;
402                $byteIndex = intdiv($bitIndex, 8);
403                $shift = 8 - $bitDepth - ($bitIndex % 8);
404                $idx = (ord($current[$byteIndex]) >> $shift) & $mask;
405                $paletteOffset = $idx * 3;
406                if ($paletteOffset + 3 > strlen($palette)) {
407                    // Out-of-range index â€” paint with palette[0] as
408                    // a tolerant fallback (matches some browsers'
409                    // recovery posture).
410                    $paletteOffset = 0;
411                }
412                $colour .= substr($palette, $paletteOffset, 3);
413                if ($trns !== null) {
414                    $a = $idx < strlen($trns) ? ord($trns[$idx]) : 0xFF;
415                    $alpha .= chr($a);
416                    if ($a !== 0xFF) {
417                        $hasTransparent = true;
418                    }
419                }
420            }
421            $prev = $current;
422        }
423        return [
424            'colour' => $colour,
425            'alpha' => $hasTransparent ? $alpha : null,
426            'width' => $info->width,
427            'height' => $info->height,
428        ];
429    }
430
431    /**
432     * Find and return the first matching chunk payload. Used for
433     * one-off lookups (`PLTE`, `tRNS`) that don't appear in the
434     * critical IHDR + IDAT + IEND path the full parser walks.
435     */
436    private static function extractChunk(string $data, string $chunkType): ?string
437    {
438        $len = strlen($data);
439        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
440            return null;
441        }
442        $pos = 8;
443        while ($pos + 12 <= $len) {
444            $chunkLen = unpack('N', substr($data, $pos, 4))[1];
445            $type = substr($data, $pos + 4, 4);
446            if ($type === $chunkType) {
447                return substr($data, $pos + 8, $chunkLen);
448            }
449            if ($type === 'IEND') {
450                return null;
451            }
452            $pos += 12 + $chunkLen;
453        }
454        return null;
455    }
456
457    /**
458     * Extract the concatenated `IDAT` chunk payload from a PNG.
459     * The payload is already DEFLATE-compressed PNG-filter-coded
460     * pixel data, suitable for embedding in a PDF Image XObject
461     * with `/Filter /FlateDecode` + `/DecodeParms <<Predictor 15
462     * Columns W Colors N BitsPerComponent B>>` â€” PDF readers
463     * decompress + unfilter via the predictor without an
464     * intermediate raw-RGB buffer.
465     *
466     * Returns null when the PNG signature is invalid or no IDAT
467     * chunks are present.
468     */
469    public static function extractIdatData(string $data): ?string
470    {
471        $len = strlen($data);
472        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
473            return null;
474        }
475        $pos = 8;
476        $idat = '';
477        while ($pos + 12 <= $len) {
478            $chunkLen = unpack('N', substr($data, $pos, 4))[1];
479            $chunkType = substr($data, $pos + 4, 4);
480            if ($chunkType === 'IDAT' && $chunkLen > 0) {
481                $idat .= substr($data, $pos + 8, $chunkLen);
482            } elseif ($chunkType === 'IEND') {
483                break;
484            }
485            $pos += 12 + $chunkLen;
486        }
487        return $idat === '' ? null : $idat;
488    }
489
490    public static function parse(string $data): ImageInfo
491    {
492        $len = strlen($data);
493
494        // Verify PNG signature (8 bytes)
495        if ($len < 8 || substr($data, 0, 8) !== "\x89PNG\r\n\x1A\n") {
496            throw new \RuntimeException('Not a valid PNG file');
497        }
498
499        $pos = 8;
500        $width = 0;
501        $height = 0;
502        $bitDepth = 8;
503        $colorType = 2;
504        $xDpi = null;
505        $yDpi = null;
506        $iccProfile = null;
507
508        while ($pos + 12 <= $len) {
509            $chunkLen  = unpack('N', substr($data, $pos, 4))[1];
510            $chunkType = substr($data, $pos + 4, 4);
511            $chunkData = substr($data, $pos + 8, $chunkLen);
512            $pos += 12 + $chunkLen;
513
514            if ($chunkType === 'IHDR' && strlen($chunkData) >= 13) {
515                $width    = unpack('N', substr($chunkData, 0, 4))[1];
516                $height   = unpack('N', substr($chunkData, 4, 4))[1];
517                $bitDepth = ord($chunkData[8]);
518                $colorType = ord($chunkData[9]);
519            } elseif ($chunkType === 'pHYs' && strlen($chunkData) >= 9) {
520                $xPixelsPerUnit = unpack('N', substr($chunkData, 0, 4))[1];
521                $yPixelsPerUnit = unpack('N', substr($chunkData, 4, 4))[1];
522                $unit = ord($chunkData[8]);
523                if ($unit === 1 && $xPixelsPerUnit > 0 && $yPixelsPerUnit > 0) {
524                    // Unit is meters; convert to DPI
525                    $xDpi = (int) round($xPixelsPerUnit / 39.3701);
526                    $yDpi = (int) round($yPixelsPerUnit / 39.3701);
527                }
528            } elseif ($chunkType === 'iCCP' && strlen($chunkData) > 2) {
529                // iCCP chunk: null-terminated profile name, 1-byte compression method, compressed data
530                $nullPos = strpos($chunkData, "\x00");
531                if ($nullPos !== false && $nullPos + 2 <= strlen($chunkData)) {
532                    // Skip profile name + null byte + compression method byte (always 0 = deflate)
533                    $compressedData = substr($chunkData, $nullPos + 2);
534                    if ($compressedData !== '') {
535                        $decompressed = @gzuncompress($compressedData);
536                        if ($decompressed !== false) {
537                            $iccProfile = $decompressed;
538                        }
539                    }
540                }
541            } elseif ($chunkType === 'IEND') {
542                break;
543            }
544        }
545
546        [$colorSpace, $hasAlpha] = match ($colorType) {
547            0 => ['DeviceGray', false],
548            2 => ['DeviceRGB', false],
549            3 => ['DeviceRGB', false],  // indexed â€” treat as RGB
550            4 => ['DeviceGray', true],
551            6 => ['DeviceRGB', true],
552            default => ['DeviceRGB', false],
553        };
554
555        return new ImageInfo(
556            width: $width,
557            height: $height,
558            colorSpace: $colorSpace,
559            bitsPerComponent: $bitDepth,
560            format: 'png',
561            hasAlpha: $hasAlpha,
562            xDpi: $xDpi,
563            yDpi: $yDpi,
564            iccProfile: $iccProfile,
565        );
566    }
567}