Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.91% covered (success)
92.91%
131 / 141
62.50% covered (warning)
62.50%
10 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
PredictorFilter
92.91% covered (success)
92.91%
131 / 141
62.50% covered (warning)
62.50%
10 / 16
69.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decode
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 encode
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 decodeTiff
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 encodeTiff
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 decodePng
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
10.46
 encodePng
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
13
 pngDecodeSub
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 pngEncodeSub
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 pngDecodeUp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 pngEncodeUp
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 pngDecodeAverage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 pngEncodeAverage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 pngDecodePaeth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 pngEncodePaeth
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 paethPredictor
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Filters;
6
7/**
8 * PDF predictor filter — applies/removes PNG and TIFF prediction
9 * as specified in ISO 32000-2 §7.4.4.4.
10 *
11 * Predictors reduce redundancy in row-based data (e.g., image scanlines)
12 * before compression. They are used as a pre-processing step with
13 * FlateDecode and LZWDecode.
14 *
15 * Predictor values:
16 *   1  = No prediction (default)
17 *   2  = TIFF Predictor 2 (horizontal differencing)
18 *  10  = PNG None
19 *  11  = PNG Sub
20 *  12  = PNG Up
21 *  13  = PNG Average
22 *  14  = PNG Paeth
23 *  15  = PNG Optimum (per-row selector via tag byte)
24 */
25final class PredictorFilter
26{
27    public function __construct(
28        private readonly int $predictor = 1,
29        private readonly int $columns = 1,
30        private readonly int $colors = 1,
31        private readonly int $bitsPerComponent = 8,
32    ) {}
33
34    public function decode(string $data): string
35    {
36        if ($this->predictor === 1) {
37            return $data;
38        }
39
40        if ($this->predictor === 2) {
41            return $this->decodeTiff($data);
42        }
43
44        if ($this->predictor >= 10 && $this->predictor <= 15) {
45            return $this->decodePng($data);
46        }
47
48        return $data;
49    }
50
51    public function encode(string $data): string
52    {
53        if ($this->predictor === 1) {
54            return $data;
55        }
56
57        if ($this->predictor === 2) {
58            return $this->encodeTiff($data);
59        }
60
61        if ($this->predictor >= 10 && $this->predictor <= 15) {
62            return $this->encodePng($data);
63        }
64
65        return $data;
66    }
67
68    // -----------------------------------------------------------------------
69    // TIFF Predictor 2 — horizontal differencing per row
70    // -----------------------------------------------------------------------
71
72    private function decodeTiff(string $data): string
73    {
74        $bytesPerPixel = (int) ceil($this->colors * $this->bitsPerComponent / 8);
75        $rowBytes = (int) ceil($this->columns * $this->colors * $this->bitsPerComponent / 8);
76        if ($rowBytes <= 0) {
77            return $data;
78        }
79        $len = strlen($data);
80        $result = '';
81
82        for ($offset = 0; $offset < $len; $offset += $rowBytes) {
83            $row = substr($data, $offset, $rowBytes);
84            // Pad partial final row with zeros
85            if (strlen($row) < $rowBytes) {
86                $row = str_pad($row, $rowBytes, "\x00");
87            }
88            for ($i = $bytesPerPixel; $i < $rowBytes; $i++) {
89                $row[$i] = chr((ord($row[$i]) + ord($row[$i - $bytesPerPixel])) & 0xFF);
90            }
91            $result .= $row;
92        }
93
94        return $result;
95    }
96
97    private function encodeTiff(string $data): string
98    {
99        $bytesPerPixel = (int) ceil($this->colors * $this->bitsPerComponent / 8);
100        $rowBytes = (int) ceil($this->columns * $this->colors * $this->bitsPerComponent / 8);
101        $len = strlen($data);
102        $result = '';
103
104        for ($offset = 0; $offset < $len; $offset += $rowBytes) {
105            $row = substr($data, $offset, $rowBytes);
106            // Process right-to-left to avoid overwriting values we still need
107            for ($i = strlen($row) - 1; $i >= $bytesPerPixel; $i--) {
108                $row[$i] = chr((ord($row[$i]) - ord($row[$i - $bytesPerPixel])) & 0xFF);
109            }
110            $result .= $row;
111        }
112
113        return $result;
114    }
115
116    // -----------------------------------------------------------------------
117    // PNG predictors — each row has a tag byte followed by filtered scanline
118    // -----------------------------------------------------------------------
119
120    private function decodePng(string $data): string
121    {
122        $bytesPerPixel = max(1, (int) floor($this->colors * $this->bitsPerComponent / 8));
123        $rowBytes = (int) ceil($this->columns * $this->colors * $this->bitsPerComponent / 8);
124        $stride = $rowBytes + 1; // +1 for the tag byte
125        $len = strlen($data);
126        $result = '';
127        $prevRow = str_repeat("\x00", $rowBytes);
128
129        for ($offset = 0; $offset < $len; $offset += $stride) {
130            if ($offset >= $len) {
131                break;
132            }
133            $tag = ord($data[$offset]);
134            $row = substr($data, $offset + 1, $rowBytes);
135
136            if (strlen($row) < $rowBytes) {
137                // Partial last row — pass through
138                $result .= $row;
139                break;
140            }
141
142            $decoded = match ($tag) {
143                0 => $row, // None
144                1 => $this->pngDecodeSub($row, $bytesPerPixel),
145                2 => $this->pngDecodeUp($row, $prevRow),
146                3 => $this->pngDecodeAverage($row, $prevRow, $bytesPerPixel),
147                4 => $this->pngDecodePaeth($row, $prevRow, $bytesPerPixel),
148                default => $row,
149            };
150
151            $result .= $decoded;
152            $prevRow = $decoded;
153        }
154
155        return $result;
156    }
157
158    private function encodePng(string $data): string
159    {
160        $bytesPerPixel = max(1, (int) floor($this->colors * $this->bitsPerComponent / 8));
161        $rowBytes = (int) ceil($this->columns * $this->colors * $this->bitsPerComponent / 8);
162        $len = strlen($data);
163        $result = '';
164        $prevRow = str_repeat("\x00", $rowBytes);
165
166        // Use the predictor tag: Sub(1) is simple and effective
167        /** @var int<0,4> $tag */
168        $tag = match ($this->predictor) {
169            10 => 0,
170            11 => 1,
171            13 => 3,
172            14 => 4,
173            default => 2, // Optimum(15) and Up(12) — use Up as default
174        };
175
176        for ($offset = 0; $offset < $len; $offset += $rowBytes) {
177            $row = substr($data, $offset, $rowBytes);
178
179            $encoded = match ($tag) {
180                0 => $row,
181                1 => $this->pngEncodeSub($row, $bytesPerPixel),
182                2 => $this->pngEncodeUp($row, $prevRow),
183                3 => $this->pngEncodeAverage($row, $prevRow, $bytesPerPixel),
184                4 => $this->pngEncodePaeth($row, $prevRow, $bytesPerPixel),
185                default => $row,
186            };
187
188            $result .= chr($tag) . $encoded;
189            $prevRow = $row;
190        }
191
192        return $result;
193    }
194
195    // --- PNG Sub ---
196
197    private function pngDecodeSub(string $row, int $bpp): string
198    {
199        $len = strlen($row);
200        for ($i = $bpp; $i < $len; $i++) {
201            $row[$i] = chr((ord($row[$i]) + ord($row[$i - $bpp])) & 0xFF);
202        }
203        return $row;
204    }
205
206    private function pngEncodeSub(string $row, int $bpp): string
207    {
208        $len = strlen($row);
209        $result = $row;
210        for ($i = $len - 1; $i >= $bpp; $i--) {
211            $result[$i] = chr((ord($row[$i]) - ord($row[$i - $bpp])) & 0xFF);
212        }
213        return $result;
214    }
215
216    // --- PNG Up ---
217
218    private function pngDecodeUp(string $row, string $prevRow): string
219    {
220        $len = strlen($row);
221        for ($i = 0; $i < $len; $i++) {
222            $row[$i] = chr((ord($row[$i]) + ord($prevRow[$i])) & 0xFF);
223        }
224        return $row;
225    }
226
227    private function pngEncodeUp(string $row, string $prevRow): string
228    {
229        $len = strlen($row);
230        $result = $row;
231        for ($i = 0; $i < $len; $i++) {
232            $result[$i] = chr((ord($row[$i]) - ord($prevRow[$i])) & 0xFF);
233        }
234        return $result;
235    }
236
237    // --- PNG Average ---
238
239    private function pngDecodeAverage(string $row, string $prevRow, int $bpp): string
240    {
241        $len = strlen($row);
242        for ($i = 0; $i < $len; $i++) {
243            $a = $i >= $bpp ? ord($row[$i - $bpp]) : 0;
244            $b = ord($prevRow[$i]);
245            $row[$i] = chr((ord($row[$i]) + (int) floor(($a + $b) / 2)) & 0xFF);
246        }
247        return $row;
248    }
249
250    private function pngEncodeAverage(string $row, string $prevRow, int $bpp): string
251    {
252        $len = strlen($row);
253        $result = $row;
254        for ($i = 0; $i < $len; $i++) {
255            $a = $i >= $bpp ? ord($row[$i - $bpp]) : 0;
256            $b = ord($prevRow[$i]);
257            $result[$i] = chr((ord($row[$i]) - (int) floor(($a + $b) / 2)) & 0xFF);
258        }
259        return $result;
260    }
261
262    // --- PNG Paeth ---
263
264    private function pngDecodePaeth(string $row, string $prevRow, int $bpp): string
265    {
266        $len = strlen($row);
267        for ($i = 0; $i < $len; $i++) {
268            $a = $i >= $bpp ? ord($row[$i - $bpp]) : 0;
269            $b = ord($prevRow[$i]);
270            $c = $i >= $bpp ? ord($prevRow[$i - $bpp]) : 0;
271            $row[$i] = chr((ord($row[$i]) + self::paethPredictor($a, $b, $c)) & 0xFF);
272        }
273        return $row;
274    }
275
276    private function pngEncodePaeth(string $row, string $prevRow, int $bpp): string
277    {
278        $len = strlen($row);
279        $result = $row;
280        for ($i = 0; $i < $len; $i++) {
281            $a = $i >= $bpp ? ord($row[$i - $bpp]) : 0;
282            $b = ord($prevRow[$i]);
283            $c = $i >= $bpp ? ord($prevRow[$i - $bpp]) : 0;
284            $result[$i] = chr((ord($row[$i]) - self::paethPredictor($a, $b, $c)) & 0xFF);
285        }
286        return $result;
287    }
288
289    private static function paethPredictor(int $a, int $b, int $c): int
290    {
291        $p = $a + $b - $c;
292        $pa = abs($p - $a);
293        $pb = abs($p - $b);
294        $pc = abs($p - $c);
295
296        if ($pa <= $pb && $pa <= $pc) {
297            return $a;
298        }
299        if ($pb <= $pc) {
300            return $b;
301        }
302        return $c;
303    }
304}