master
ブランチ master
LittleBeast / src / Std / Lib / Image / Png.php
44.7 KB、1090行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Image;
39
40use Std\Lib\Image\ImageInterface;
41use LogType;
42
43class Png implements ImageInterface {
44 public \stdClass $IHDR; // 画像ヘッダー
45 // 横(4バイト)
46 // 丈(4バイト)
47 // ビット深さ(1バイト)
48 // 色類(1バイト)
49 //// | PNG画像タイプ | 色類 | 許可したビット深さ | 解釈 |
50 //// |--------------------------|------|--------------------|----------------------------------------------- |
51 //// | グレースケール | 0 | 1,2,4,8,16 | 各ピクセルはグレースケールのサンプル |
52 //// | トゥルーカラー | 2 | 8,16 | 各ピクセルはR,G,Bトリプル |
53 //// | 指標した色 | 3 | 1,2,4,8 | 各ピクセルはパレット指標(PLTEチャンクあり) |
54 //// | グレースケール+アルファ | 4 | 8,16 | 各ピクセルはグレースケール+アルファのサンプル |
55 //// | トゥルーカラー+アルファ | 6 | 8,16 | 各ピクセルはR,G,B,Aトリプル |
56 // 圧縮関数(1バイト)
57 //// 0 (DEFLATE)しかない
58 // フィルター関数(1バイト)
59 //// 0(アダプティブ・フィルタリング)しかない
60 // インタレース関数(1バイト)
61 //// 0 = なし
62 //// 1 = Adam7
63 public \stdClass $PLTE; // パレット(赤、緑、青)
64 // 色類3 = 必須
65 // 色類2と6 = 任意
66 public \stdClass $IDAT; // 画像データ
67 public \stdClass $IEND; // 画像終了
68
69 public \stdClass $tRNS; // 透明情報
70 // 色類0 = グレーサンプル値(2バイト)
71 // 色類2 = RGBサンプル値(2x2x2バイト)
72 // 色類3 = パレット指標のアルファ(各1バイト)
73 public \stdClass $cHRM; // 主な色度とホワイトポイント
74 // 白X(4バイト)
75 // 白Y(4バイト)
76 // 赤X(4バイト)
77 // 赤Y(4バイト)
78 // 緑X(4バイト)
79 // 緑Y(4バイト)
80 // 青X(4バイト)
81 // 青Y(4バイト)
82 public \stdClass $gAMA; // ガンマ(4バイト)
83 public \stdClass $iCCP; // 組み込みしたICCプロファイル
84 // プロファイル名(1~79バイト)
85 // NULL分離子(1バイト)
86 // 圧縮関数(1バイト)
87 // 圧縮プロファイル(nバイト)
88 public \stdClass $sBIT; // 上位ビット(4バイト)
89 // 色類0 = グレーサンプルビット(1バイト)
90 // 色類2, 3 = RGBサンプルビット(1x1x1バイト)
91 // 色類4 = グレーサンプルビット(1x1バイト)
92 // 色類6 = RGBサンプルビット(1x1x1x1バイト)
93 public \stdClass $sRGB; // レンダリングインテント(1バイト)
94 // 0 = 知覚
95 // 1 = 相対カラー・メトリック
96 // 2 = 彩度
97 // 3 = 絶対カラー・メトリック
98 public \stdClass $cICP; // (1バイト)
99 public \stdClass $mDCV; // (1バイト)
100 public \stdClass $cLLI; // (1バイト)
101 public \stdClass $tEXt; // 文字データ
102 public \stdClass $iTXt; // 圧縮した文字データ
103 public \stdClass $zTXt; // 国際文字データ
104 public \stdClass $bKGD; // 拝啓色
105 public \stdClass $hIST; // 画像ヒストグラム
106 public \stdClass $pHYs; // 物理的なぷくセルのサイズ
107 // 単位当たりのピクセル数(PPU)のX軸(4バイト)
108 // 単位当たりのピクセル数(PPU)のY軸(4バイト)
109 // ユニット指定子(1バイト)
110 //// 0 = 不明
111 //// 1 = メートル
112 public \stdClass $sPLT; // 提案されている配色パターン
113 // パレット名(1~79バイト)
114 // NULL分離子(1バイト)
115 // 赤(1か2バイト)
116 // 緑(1か2バイト)
117 // 青(1か2バイト)
118 // アルファ(1か2バイト)
119 // 周波数(2バイト)
120 // 等
121 public \stdClass $eXIf; // Exifプロファイル
122 public \stdClass $tIME; // 画像の最後変更日
123 // 年(2バイト)
124 // 月(1バイト:1-12)
125 // 日(1バイト:1-31)
126 // 時(1バイト:0-23)
127 // 分(1バイト:0-59)
128 // 秒(1バイト:0-60)
129 public \stdClass $acTL; // アニメーション・コントロール
130 // フレーム数(4バイト)
131 // 再生数(4バイト、0 = 無限)
132
133 public array $frames = [];
134 private ?array $currentFrame = null;
135
136 // TODO: 残り: sBIT, iTXt, hIST, sPLT, tIME
137 public function parse(string $file): \stdClass {
138 $png = new \stdClass();
139 $fp = fopen($file, 'rb');
140
141 $magic = bin2hex(fread($fp, 8)); // マジックのスキップ
142 $length = unpack('N', fread($fp, 4))[1];
143 $header = fread($fp, 4);
144 if ($header !== 'IHDR') {
145 fclose($fp);
146 return $png;
147 }
148
149 // IHDRデータ
150 $data = fread($fp, $length);
151
152 // N = ビッグエンディアン(4ビット)
153 $this->IHDR = new \stdClass;
154 $this->IHDR->name = 'IHDR';
155 $this->IHDR->length = $length;
156 $this->IHDR->width = unpack('N', substr($data, 0, 4))[1];
157 $this->IHDR->height = unpack('N', substr($data, 4, 4))[1];
158 $this->IHDR->bitDepth = ord($data[8]); // 1, 2, 4, 8, 16
159 $this->IHDR->numColors = 1 << $this->IHDR->bitDepth;
160 $this->IHDR->colorType = ord($data[9]); // 0, 2, 3, 4, 6
161 $this->IHDR->compressionType = ord($data[10]);
162 $this->IHDR->compression = 'DEFLATE';
163 $this->IHDR->filter = ord($data[11]);
164 $this->IHDR->interlace = ord($data[12]); // 0 = なし, 1 = Adam7
165 $this->IHDR->bpp = $this->IHDR->bitDepth * $this->getSamplesPerPixel($this->IHDR->colorType);
166 $this->IHDR->hasAlpha = ($this->IHDR->colorType === 4 || $this->IHDR->colorType === 6);
167 $crc = fread($fp, 4);
168 $this->IHDR->crc = $crc;
169 $this->IHDR->crc32 = unpack('N', $crc)[1];
170 $this->IHDR->crcHex = bin2hex($crc);
171
172 $png->header = $this->IHDR;
173
174next_chunk:
175 $lengthBytes = fread($fp, 4);
176 if (strlen($lengthBytes) < 4) {
177 logger(LogType::Image, 'バイト長さが悪い。ファイル:'.$file.'、バイト長さ:'.'$lengthBytes');
178 fclose($fp);
179 return $png;
180 }
181
182 $length = unpack('N', $lengthBytes)[1];
183 $nextChunk = fread($fp, 4);
184 $data = $length > 0 ? fread($fp, $length) : '';
185 $crc = fread($fp, 4);
186
187 // PLTEデータ
188 if ($nextChunk === "\x50\x4c\x54\x45") { // PLTE
189 $palette = [];
190 for ($i = 0; $i < $length; $i += 3) {
191 $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
192 $palette[] = $colors->rgb;
193 }
194
195 $this->PLTE = new \stdClass;
196 $this->PLTE->name = 'PLTE';
197 $this->PLTE->length = $length;
198 $this->PLTE->palette = $palette;
199 $this->PLTE->crc = $crc;
200 $this->PLTE->crc32 = unpack('N', $crc)[1];
201 $this->PLTE->crcHex = bin2hex($crc);
202
203 $png->palette = $this->PLTE;
204 goto next_chunk;
205 } else if ($nextChunk === "\x74\x52\x4e\x53") { // tRNS
206 $this->tRNS = new \stdClass;
207 $this->tRNS->name = 'tRNS';
208 $this->tRNS->length = $length;
209 $vals = [];
210 $bytes = $this->IHDR->colorType === 2 ? 3 : 1;
211 for ($i = 0; $i < $length; $i += $bytes) {
212 if ($this->IHDR->colorType === 2) {
213 $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2]));
214 $vals[] = $colors->rgb;
215 } else {
216 $vals[] = ord($data[$i]);
217 }
218 }
219 $this->tRNS->values = $vals;
220 $this->tRNS->crc = $crc;
221 $this->tRNS->crc32 = unpack('N', $crc)[1];
222 $this->tRNS->crcHex = bin2hex($crc);
223 $png->transparency = $this->tRNS;
224 goto next_chunk;
225 } else if ($nextChunk === "\x63\x48\x52\x4d") { // cHRM
226 $this->cHRM = new \stdClass;
227 $this->cHRM->name = 'cHRM';
228 $this->cHRM->length = $length;
229 $this->cHRM->whitePointX = unpack('N', substr($data, 0, 4))[1];
230 $this->cHRM->whitePointY = unpack('N', substr($data, 4, 4))[1];
231 $this->cHRM->redX = unpack('N', substr($data, 8, 4))[1];
232 $this->cHRM->redY = unpack('N', substr($data, 12, 4))[1];
233 $this->cHRM->greenX = unpack('N', substr($data, 16, 4))[1];
234 $this->cHRM->greenY = unpack('N', substr($data, 20, 4))[1];
235 $this->cHRM->blueX = unpack('N', substr($data, 24, 4))[1];
236 $this->cHRM->blueY = unpack('N', substr($data, 28, 4))[1];
237 $this->cHRM->crc = $crc;
238 $this->cHRM->crc32 = unpack('N', $crc)[1];
239 $this->cHRM->crcHex = bin2hex($crc);
240 $png->chromaticities = $this->cHRM;
241 goto next_chunk;
242 } else if ($nextChunk === "\x67\x41\x4d\x41") { // gAMA
243 $this->gAMA = new \stdClass;
244 $this->gAMA->name = 'gAMA';
245 $this->gAMA->length = $length;
246 $this->gAMA->value = unpack('N', $data)[1];
247 $this->gAMA->crc = $crc;
248 $this->gAMA->crc32 = unpack('N', $crc)[1];
249 $this->gAMA->crcHex = bin2hex($crc);
250 $png->gamma = $this->gAMA;
251 goto next_chunk;
252 } else if ($nextChunk === "\x69\x43\x43\x50") { // iCCP
253 $this->iCCP = new \stdClass;
254 $this->iCCP->name = 'iCCP';
255 $this->iCCP->length = $length;
256
257 $nullPos = strpos($data, "\0");
258 if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
259 $this->iCCP->valid = false;
260 $this->iCCP->error = "Missing or invalid profile name terminator";
261 goto iccp_crc;
262 }
263
264 $this->iCCP->profileName = substr($data, 0, $nullPos);
265 $compressionMethod = ord($data[$nullPos + 1]);
266 $this->iCCP->compressionMethod = $compressionMethod;
267
268 if ($compressionMethod !== 0) {
269 $this->iCCP->valid = false;
270 $this->iCCP->error = "Unsupported compression method: {$compressionMethod}";
271 goto iccp_crc;
272 }
273
274 $compressedData = substr($data, $nullPos + 2);
275 $this->iCCP->compressedData = $compressedData;
276 $this->iCCP->compressedSize = strlen($compressedData);
277 $this->iCCP->valid = true;
278
279iccp_crc:
280 $this->iCCP->crc = $crc;
281 $this->iCCP->crc32 = unpack('N', $crc)[1];
282 $this->iCCP->crcHex = bin2hex($crc);
283 $png->iccProfile = $this->iCCP;
284 goto next_chunk;
285 } else if ($nextChunk === "\x73\x42\x49\x54") { // sBIT
286 $this->sBIT = new \stdClass;
287 $this->sBIT->name = 'sBIT';
288 $this->sBIT->length = $length;
289 kys(['sBIT', $length, bin2hex($data)]);
290 $this->sBIT->crc = $crc;
291 $this->sBIT->crc32 = unpack('N', $crc)[1];
292 $this->sBIT->crcHex = bin2hex($crc);
293 $png->significantBits = $this->sBIT;
294 goto next_chunk;
295 } else if ($nextChunk === "\x73\x52\x47\x42") { // sRGB
296 $this->sRGB = new \stdClass;
297 $this->sRGB->name = 'sRGB';
298 $this->sRGB->length = $length;
299 $this->sRGB->intent = ord($data);
300 $name = [
301 0 => 'Perceptual',
302 1 => 'Relative colorimetric',
303 2 => 'Saturation',
304 3 => 'Absolute colorimetric',
305 ];
306 $this->sRGB->intentName = $name[$this->sRGB->intent];
307 $this->sRGB->crc = $crc;
308 $this->sRGB->crc32 = unpack('N', $crc)[1];
309 $this->sRGB->crcHex = bin2hex($crc);
310 $png->rgbColorSpace = $this->sRGB;
311 goto next_chunk;
312 } else if ($nextChunk === "\x63\x49\x43\x50") { // cICP
313 $this->cICP = new \stdClass;
314 $this->cICP->name = 'cICP';
315 $this->cICP->length = $length;
316 $this->cICP->colorPrimaries = ord($data[0]);
317 $this->cICP->transferFunction = ord($data[1]);
318 $this->cICP->matrixCoefficients = ord($data[2]);
319 $this->cICP->videoFullRangeFlag = ord($data[3]);
320 $this->cICP->crc = $crc;
321 $this->cICP->crc32 = unpack('N', $crc)[1];
322 $this->cICP->crcHex = bin2hex($crc);
323 $png->videoSignalTypeId = $this->cICP;
324 goto next_chunk;
325 } else if ($nextChunk === "\x6d\x44\x43\x56") { // mDCV
326 $this->mDCV = new \stdClass;
327 $this->mDCV->name = 'mDCV';
328 $this->mDCV->length = $length;
329
330 $u16 = fn($off) => unpack('n', substr($data, $off, 2))[1];
331 $u32 = fn($off) => unpack('N', substr($data, $off, 4))[1];
332 $this->mDCV->primaries = [
333 'red' => ['x' => $u16(0), 'y' => $u16(2)],
334 'green' => ['x' => $u16(4), 'y' => $u16(6)],
335 'blue' => ['x' => $u16(8), 'y' => $u16(10)],
336 ];
337 $this->mDCV->whitePoint = [
338 'x' => $u16(12),
339 'y' => $u16(14),
340 ];
341 $this->mDCV->luminance = [
342 'max' => $u16(16) * 0.0001,
343 'min' => $u16(20) * 0.0001,
344 'maxRaw' => $u16(16),
345 'minRaw' => $u16(20),
346 ];
347 $this->mDCV->crc = $crc;
348 $this->mDCV->crc32 = unpack('N', $crc)[1];
349 $this->mDCV->crcHex = bin2hex($crc);
350 $png->masterDisplayColorVolume = $this->mDCV;
351 goto next_chunk;
352 } else if ($nextChunk === "\x63\x4c\x4c\x49") { // cLLI
353 $this->cLLI = new \stdClass;
354 $this->cLLI->name = 'cLLI';
355 $this->cLLI->length = $length;
356 $this->cLLI->maxCCL = unpack('N', substr($data, 0, 4))[1] * 0.0001;
357 $this->cLLI->maxCCLRaw = unpack('N', substr($data, 0, 4))[1];
358 $this->cLLI->maxFALL = unpack('N', substr($data, 4, 4))[1] * 0.0001;
359 $this->cLLI->maxFALLRaw = unpack('N', substr($data, 4, 4))[1];
360 $this->cLLI->crc = $crc;
361 $this->cLLI->crc32 = unpack('N', $crc)[1];
362 $this->cLLI->crcHex = bin2hex($crc);
363 $png->contentLightLevelInfo = $this->cLLI;
364 goto next_chunk;
365 } else if ($nextChunk === "\x74\x45\x58\x74") { // tEXt
366 if (!isset($png->textualData)) $png->textualData = [];
367 $this->tEXt = new \stdClass;
368 $this->tEXt->name = 'tEXt';
369 $this->tEXt->length = $length;
370 $this->tEXt->text = $data;
371 $this->tEXt->crc = $crc;
372 $this->tEXt->crc32 = unpack('N', $crc)[1];
373 $this->tEXt->crcHex = bin2hex($crc);
374 $png->textualData[] = $this->tEXt;
375 goto next_chunk;
376 } else if ($nextChunk === "\x69\x54\x58\x74") { // iTXt
377 if (!isset($png->internationalTextualData)) $png->textualData = [];
378 $this->iTXt = new \stdClass;
379 $this->iTXt->name = 'iTXt';
380 $this->iTXt->length = $length;
381 kys(['iTXt', $length, bin2hex($data)]);
382 $this->iTXt->crc = $crc;
383 $this->iTXt->crc32 = unpack('N', $crc)[1];
384 $this->iTXt->crcHex = bin2hex($crc);
385 $png->internationalTextualData[] = $this->iTXt;
386 kys($png);
387 goto next_chunk;
388 } else if ($nextChunk === "\x7a\x54\x58\x74") { // zTXt
389 if (!isset($png->compressedTextualData)) $png->textualData = [];
390 $this->zTXt = new \stdClass;
391 $this->zTXt->name = 'zTXt';
392 $this->zTXt->length = $length;
393
394 $nullPos = strpos($data, "\0");
395 if ($nullPos === false || $nullPos < 1 || $nullPos > 79) {
396 $this->zTXt->valid = false;
397 $this->zTXt->error = "Missing or invalid profile name terminator";
398 goto zTXt_crc;
399 }
400
401 $this->zTXt->keyword = rtrim(substr($data, 0, $nullPos));
402 $compressionMethod = ord($data[$nullPos + 1] ?? "\xFF");
403
404 $this->zTXt->compressionMethod = $compressionMethod;
405
406 if ($compressionMethod !== 0) {
407 $this->zTXt->valid = false;
408 $this->zTXt->error = "Unsupported compression method";
409 goto zTXt_crc;
410 }
411
412 $compressedText = substr($data, $nullPos + 2);
413 fread($fp, 13); // ????????????????????????
414 $this->zTXt->compressedText = $compressedText;
415 $this->zTXt->compressedLength = strlen($compressedText);
416
417 $text = zlib_decode($compressedText);
418
419 if ($text === false) {
420 $this->zTXt->valid = false;
421 $this->zTXt->decompressionError = true;
422 $this->zTXt->text = null;
423 goto zTXt_crc;
424 } else {
425 $this->zTXt->text = $text;
426 $this->zTXt->textUtf8 = mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1');
427 }
428
429 $this->zTXt->valid = true;
430
431zTXt_crc:
432 $this->zTXt->crc = $crc;
433 $this->zTXt->crc32 = unpack('N', $crc)[1];
434 $this->zTXt->crcHex = bin2hex($crc);
435 $png->compressedTextualData[] = $this->zTXt;
436 goto next_chunk;
437 } else if ($nextChunk === "\x62\x4b\x47\x44") { // bKGD
438 $this->bKGD = new \stdClass;
439 $this->bKGD->name = 'bKGD';
440 $this->bKGD->length = $length;
441 if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
442 $r = (ord($data[0]) << 8) | ord($data[1]);
443 $g = (ord($data[2]) << 8) | ord($data[3]);
444 $b = (ord($data[4]) << 8) | ord($data[5]);
445
446 $this->bKGD->type = 'rgb16';
447 $this->bKGD->r = $r;
448 $this->bKGD->g = $g;
449 $this->bKGD->b = $b;
450
451 $this->bKGD->r8 = $r >> 8;
452 $this->bKGD->g8 = $g >> 8;
453 $this->bKGD->b8 = $b >> 8;
454 $this->bKGD->webColor = sprintf("#%02x%02x%02x", $this->bKGD->r8, $this->bKGD->g8, $this->bKGD->b8);
455 } else if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) {
456 $gray = (ord($data[0]) << 8) | ord($data[1]);
457 $this->bKGD->type = 'gray16';
458 $this->bKGD->gray = $gray;
459 $this->bKGD->gray8 = $gray >> 8;
460 } else if ($this->IHDR->colorType === 3) {
461 $this->bKGD->type = 'palette_index';
462 $this->bKGD->value = ord($data);
463 } else {
464 $this->bKGD->type = 'unknown';
465 $this->bKGD->error = 'Invalid color type for bKGD';
466 }
467
468 $this->bKGD->crc = $crc;
469 $this->bKGD->crc32 = unpack('N', $crc)[1];
470 $this->bKGD->crcHex = bin2hex($crc);
471 $png->backgroundColor = $this->bKGD;
472 goto next_chunk;
473 } else if ($nextChunk === "\x68\x49\x53\x54") { // hIST
474 $this->hIST = new \stdClass;
475 $this->hIST->name = 'hIST';
476 $this->hIST->length = $length;
477 kys(['hIST', $length, bin2hex($data)]);
478 $this->hIST->crc = $crc;
479 $this->hIST->crc32 = unpack('N', $crc)[1];
480 $this->hIST->crcHex = bin2hex($crc);
481 $png->imageHistogram = $this->hIST;
482 kys($png);
483 goto next_chunk;
484 } else if ($nextChunk === "\x70\x48\x59\x73") { // pHYs
485 $this->pHYs = new \stdClass;
486 $this->pHYs->name = 'pHYs';
487 $this->pHYs->length = $length;
488 $this->pHYs->x = unpack('N', substr($data, 0, 4))[1];
489 $this->pHYs->y = unpack('N', substr($data, 4, 4))[1];
490 $unit = [
491 0 => 'unit is unknown',
492 1 => 'unit is the metre',
493 ];
494 $this->pHYs->unitId = ord($data[8]);
495 $this->pHYs->unit = $unit[$this->pHYs->unitId];
496 $this->pHYs->crc = $crc;
497 $this->pHYs->crc32 = unpack('N', $crc)[1];
498 $this->pHYs->crcHex = bin2hex($crc);
499 $png->physicalPixelDimensions = $this->pHYs;
500 goto next_chunk;
501 } else if ($nextChunk === "\x73\x50\x4c\x54") { // sPLT
502 $this->sPLT = new \stdClass;
503 $this->sPLT->name = 'sPLT';
504 $this->sPLT->length = $length;
505 kys(['sPLT', $length, bin2hex($data)]);
506 $this->sPLT->crc = $crc;
507 $this->sPLT->crc32 = unpack('N', $crc)[1];
508 $this->sPLT->crcHex = bin2hex($crc);
509 $png->suggestedPalette = $this->sPLT;
510 kys($png);
511 goto next_chunk;
512 } else if ($nextChunk === "\x65\x58\x49\x66") { // eXIf
513 $this->eXIf = new \stdClass;
514 $this->eXIf->name = 'eXIf';
515 $this->eXIf->length = $length;
516
517 if (strlen($data) < 8) {
518 $this->eXIf->valid = false;
519 $this->eXIf->error = "eXIf chunk too short";
520 goto exif_crc;
521 }
522
523 $byteOrder = substr($data, 0, 2);
524 $magic = substr($data, 2, 2);
525
526 $this->eXIf->rawData = $data;
527 $this->eXIf->rawLength = $length;
528 $this->eXIf->byteOrder = $byteOrder; // II = リトルエンディアン、MM = ビッグエンディアン
529 $this->eXIf->isBigEndian = ($byteOrder === 'MM');
530 $this->eXIf->magic = unpack('n', $magic)[1]; // いつでも42ではず
531
532 if (strlen($data) < 8) {
533 $this->eXIf->valid = false;
534 $this->eXIf->error = "Invalid TIFF magic number";
535 goto exif_crc;
536 }
537
538 $this->eXIf->valid = true;
539
540 $ifdOffset = unpack($this->eXIf->isBigEndian ? 'N' : 'V', substr($data, 4, 4))[1];
541 $this->eXIf->firstIfdOffset = $ifdOffset;
542
543exif_crc:
544 $this->eXIf->crc = $crc;
545 $this->eXIf->crc32 = unpack('N', $crc)[1];
546 $this->eXIf->crcHex = bin2hex($crc);
547 $png->exifProfile = $this->eXIf;
548 goto next_chunk;
549 } else if ($nextChunk === "\x74\x49\x4d\x45") { // tIME
550 $this->tIME = new \stdClass;
551 $this->tIME->name = 'tIME';
552 $this->tIME->length = $length;
553 kys(['tIME', $length, bin2hex($data)]);
554 $this->tIME->crc = $crc;
555 $this->tIME->crc32 = unpack('N', $crc)[1];
556 $this->tIME->crcHex = bin2hex($crc);
557 $png->lastModificationTime = $this->tIME;
558 kys($png);
559 goto next_chunk;
560 } else if ($nextChunk === "\x61\x63\x54\x4c") { // acTL
561 $this->acTL = new \stdClass;
562 $this->acTL->name = 'acTL';
563 $this->acTL->length = $length;
564 $this->acTL->numFrames = unpack('N', substr($data, 0, 4))[1];
565 $this->acTL->numPlays = unpack('N', substr($data, 4, 4))[1];
566 $this->acTL->crc = $crc;
567 $this->acTL->crc32 = unpack('N', $crc)[1];
568 $this->acTL->crcHex = bin2hex($crc);
569 $png->aniationControl = $this->acTL;
570 goto next_chunk;
571 } else if ($nextChunk === "\x66\x63\x54\x4c") { // fcTL
572 if ($this->currentFrame !== null) $this->frames[] = (object)$this->currentFrame;
573
574 $this->currentFrame = [
575 'name' => 'fcTL',
576 'length' => $length,
577 'sequenceNumber' => unpack('N', substr($data, 0, 4))[1],
578 'width' => unpack('N', substr($data, 4, 4))[1],
579 'height' => unpack('N', substr($data, 8, 4))[1],
580 'xOffset' => unpack('N', substr($data, 12, 4))[1],
581 'yOffset' => unpack('N', substr($data, 16, 4))[1],
582 'delayNum' => unpack('n', substr($data, 20, 2))[1],
583 'delayDen' => unpack('n', substr($data, 22, 2))[1],
584 'disposeOp' => ord($data[24]),
585 'blendOp' => ord($data[25]),
586 'data' => null,
587 'crc' => $crc,
588 'crc32' => unpack('N', $crc)[1],
589 'crcHex' => bin2hex($crc),
590 ];
591
592 goto next_chunk;
593 } else if ($nextChunk === "\x49\x44\x41\x54" || $nextChunk === "\x66\x64\x41\x54") { // IDAT || fdAT
594 if ($this->currentFrame === null) {
595 $this->currentFrame = [
596 'sequenceNumber' => 0,
597 'isFirstFrame' => true,
598 'width' => $this->IHDR->width,
599 'height' => $this->IHDR->height,
600 'data' => null,
601 ];
602 }
603
604 $compressedData = ($nextChunk === "\x66\x64\x41\x54") ? substr($data, 4) : $data;
605 $this->currentFrame['name'] = 'fdAT';
606 $this->currentFrame['length'] = $length;
607 $this->currentFrame['data'] = $compressedData;
608 $this->currentFrame['dataBase64'] = base64_encode($compressedData);
609 $this->currentFrame['dataHex'] = bin2hex($compressedData);
610 $this->currentFrame['dataLength'] = strlen($compressedData);
611 $this->currentFrame['dataChunkType'] = $nextChunk;
612 $this->currentFrame['crc'] = $crc;
613 $this->currentFrame['crcBase64'] = unpack('N', $crc)[1];
614 $this->currentFrame['crcHex'] = bin2hex($crc);
615
616 if ($nextChunk === "\x49\x44\x41\x54") {
617 $this->currentFrame['name'] = 'IDAT';
618 $this->frames[] = (object)$this->currentFrame;
619 $this->currentFrame = null;
620 }
621
622 $png->data = $this->frames;
623 goto next_chunk;
624 } else if ($nextChunk === "\x49\x45\x4e\x44") { // IEND
625 if ($this->currentFrame !== null) {
626 $this->frames[] = (object)$this->currentFrame;
627 }
628 $png->end = (object)['crc' => $crc, 'crc32' => unpack('N', $crc)[1], 'crcHex' => bin2hex($crc)];
629 } else {
630 fclose($fp);
631 $err = '不明なチャンク:'.$nextChunk.'、HEX:'.bin2hex($nextChunk);
632 kys($err);
633 logger(LogType::Image, '【PNG】'.$err);
634 return $png;
635 }
636
637 fclose($fp);
638 return $png;
639 }
640
641 ///////////////
642
643 //////// 画素の読み書き
644
645 private const SIGNATURE = "\x89PNG\r\n\x1a\n";
646 /** Adam7の各段階:[x開始, y開始, x間隔, y間隔] */
647 private const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
648
649 /**
650 * PNG(APNGを含む)を画素にする
651 *
652 * 全ての色類とビット深さ(16ビットは上位8ビット)、PLTE・tRNS、Adam7、APNGのフレームに対応。
653 * 必須のチャンクのCRCが合わない場合は例外。
654 */
655 public function decode(string $data): Bitmap {
656 if (!str_starts_with($data, self::SIGNATURE)) throw new \RuntimeException('PNGではありません。');
657 $len = strlen($data);
658 $pos = 8;
659 $hdr = null;
660 $palette = null;
661 $trns = null;
662 $idat = '';
663 $actl = null;
664 $frames = [];
665 $seenIdat = false;
666 $firstIsDefault = false;
667 $ended = false;
668
669 while ($pos + 12 <= $len) {
670 $clen = unpack('N', substr($data, $pos, 4))[1];
671 $type = substr($data, $pos + 4, 4);
672 if ($clen > $len - $pos - 12) throw new \RuntimeException("{$type}チャンクが途中で切れています。");
673 $body = substr($data, $pos + 8, $clen);
674 $crc = unpack('N', substr($data, $pos + 8 + $clen, 4))[1];
675 $pos += 12 + $clen;
676 if (crc32($type.$body) !== $crc) {
677 // 補助チャンク(1文字目が小文字)は飛ばす
678 if (ctype_lower($type[0])) continue;
679 throw new \RuntimeException("{$type}チャンクのCRCが合いません。");
680 }
681
682 switch ($type) {
683 case 'IHDR':
684 if ($clen !== 13) throw new \RuntimeException('IHDRが不正です。');
685 $h = unpack('Nw/Nh/Cdepth/Cctype/Ccomp/Cfilter/Cinterlace', $body);
686 $ok = match ($h['ctype']) {
687 0 => in_array($h['depth'], [1, 2, 4, 8, 16], true),
688 3 => in_array($h['depth'], [1, 2, 4, 8], true),
689 2, 4, 6 => in_array($h['depth'], [8, 16], true),
690 default => false,
691 };
692 if (!$ok || $h['comp'] !== 0 || $h['filter'] !== 0 || $h['interlace'] > 1) throw new \RuntimeException('未対応の色類・ビット深さです。');
693 Bitmap::check($h['w'], $h['h']);
694 $hdr = $h;
695 break;
696 case 'PLTE':
697 if ($clen % 3 !== 0 || $clen > 768 || $clen === 0) throw new \RuntimeException('PLTEが不正です。');
698 $palette = $body;
699 break;
700 case 'tRNS':
701 $trns = $body;
702 break;
703 case 'acTL':
704 $actl = unpack('Nframes/Nplays', $body);
705 break;
706 case 'fcTL':
707 if ($clen !== 26) throw new \RuntimeException('fcTLが不正です。');
708 $f = unpack('Nseq/Nw/Nh/Nx/Ny/ndnum/nden/Cdispose/Cblend', $body);
709 if (!$seenIdat) $firstIsDefault = true;
710 $frames[] = ['ctl' => $f, 'data' => ''];
711 break;
712 case 'IDAT':
713 $seenIdat = true;
714 $idat .= $body;
715 if ($firstIsDefault && count($frames) === 1) $frames[0]['data'] .= $body;
716 break;
717 case 'fdAT':
718 if ($frames) $frames[count($frames) - 1]['data'] .= substr($body, 4);
719 break;
720 case 'IEND':
721 $ended = true;
722 break 2;
723 }
724 }
725 if ($hdr === null || $idat === '') throw new \RuntimeException('IHDRかIDATがありません。');
726 if (!$ended) throw new \RuntimeException('IENDがありません。');
727 if ($hdr['ctype'] === 3 && $palette === null) throw new \RuntimeException('PLTEがありません。');
728
729 $img = new Bitmap($hdr['w'], $hdr['h'], self::pixels($idat, $hdr['w'], $hdr['h'], $hdr, $palette, $trns));
730 if ($actl === null || !$frames) return $img;
731
732 // APNG:各フレームを画面に合成する
733 $canvas = new Bitmap($hdr['w'], $hdr['h']);
734 $img->loops = $actl['plays'];
735 foreach ($frames as $i => $fr) {
736 $c = $fr['ctl'];
737 if ($c['x'] + $c['w'] > $hdr['w'] || $c['y'] + $c['h'] > $hdr['h']) throw new \RuntimeException('APNGのフレームが画面からはみ出しています。');
738 $sub = new Bitmap($c['w'], $c['h'], self::pixels($fr['data'], $c['w'], $c['h'], $hdr, $palette, $trns));
739 // 最初のフレームの「前に戻す」は「消す」と同じ
740 $dispose = $i === 0 && $c['dispose'] === 2 ? 1 : $c['dispose'];
741 $before = $dispose === 2 ? $canvas->copy() : null;
742 $canvas->draw($sub, $c['x'], $c['y'], $c['blend'] === 1);
743 $img->frames[] = ['bitmap' => $canvas->copy(), 'delay' => (int)round($c['dnum'] * 1000 / ($c['den'] ?: 100))];
744 if ($dispose === 1) $canvas->clear($c['x'], $c['y'], $c['w'], $c['h']);
745 else if ($dispose === 2) $canvas = $before;
746 }
747 return $img;
748 }
749
750 /**
751 * 圧縮した画像データ(IDAT・fdAT)をRGBAにする
752 */
753 private static function pixels(string $zdata, int $w, int $h, array $hdr, ?string $palette, ?string $trns): string {
754 $channels = [0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4][$hdr['ctype']];
755 $bits = $channels * $hdr['depth'];
756 $bpp = max(1, $bits >> 3);
757 $rowBytes = fn(int $pw) => ($pw * $bits + 7) >> 3;
758
759 $passes = $hdr['interlace'] ? self::ADAM7 : [[0, 0, 1, 1]];
760 $expected = 0;
761 foreach ($passes as [$xs, $ys, $xd, $yd]) {
762 $pw = intdiv($w - $xs + $xd - 1, $xd);
763 $ph = intdiv($h - $ys + $yd - 1, $yd);
764 if ($pw > 0 && $ph > 0) $expected += $ph * (1 + $rowBytes($pw));
765 }
766 // 展開する大きさを制限する(圧縮爆弾対策)
767 $raw = @gzuncompress($zdata, $expected + 1024);
768 if ($raw === false) throw new \RuntimeException('画像データを展開出来ません。');
769 if (strlen($raw) < $expected) throw new \RuntimeException('画像データが足りません。');
770
771 $lut = self::lookup($hdr, $palette, $trns);
772 if (!$hdr['interlace']) {
773 $rows = self::unfilter($raw, 0, $rowBytes($w), $h, $bpp);
774 return implode('', array_map(fn($r) => self::toRgba($r, $w, $hdr, $lut, $trns), $rows));
775 }
776
777 $grid = array_fill(0, $h, array_fill(0, $w, "\0\0\0\0"));
778 $off = 0;
779 foreach (self::ADAM7 as [$xs, $ys, $xd, $yd]) {
780 $pw = intdiv($w - $xs + $xd - 1, $xd);
781 $ph = intdiv($h - $ys + $yd - 1, $yd);
782 if ($pw <= 0 || $ph <= 0) continue;
783 $rows = self::unfilter($raw, $off, $rowBytes($pw), $ph, $bpp);
784 $off += $ph * (1 + $rowBytes($pw));
785 foreach ($rows as $j => $r) {
786 $px = str_split(self::toRgba($r, $pw, $hdr, $lut, $trns), 4);
787 $y = $ys + $j * $yd;
788 foreach ($px as $i => $p) $grid[$y][$xs + $i * $xd] = $p;
789 }
790 }
791 return implode('', array_map(fn($r) => implode('', $r), $grid));
792 }
793
794 /**
795 * フィルターを戻す
796 *
797 * @return array 行(フィルターの種類のバイトを除いた文字列)
798 */
799 private static function unfilter(string $raw, int $off, int $rowBytes, int $rows, int $bpp): array {
800 $out = [];
801 $prev = array_fill(0, $rowBytes, 0);
802 for ($y = 0; $y < $rows; $y++) {
803 $ft = ord($raw[$off]);
804 $line = substr($raw, $off + 1, $rowBytes);
805 $off += $rowBytes + 1;
806 if ($ft === 0) {
807 $out[] = $line;
808 $prev = array_values(unpack('C*', $line));
809 continue;
810 }
811 $cur = array_values(unpack('C*', $line));
812 switch ($ft) {
813 case 1:
814 for ($i = $bpp; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + $cur[$i - $bpp]) & 255;
815 break;
816 case 2:
817 for ($i = 0; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + $prev[$i]) & 255;
818 break;
819 case 3:
820 for ($i = 0; $i < $bpp; $i++) $cur[$i] = ($cur[$i] + ($prev[$i] >> 1)) & 255;
821 for (; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + (($cur[$i - $bpp] + $prev[$i]) >> 1)) & 255;
822 break;
823 case 4:
824 for ($i = 0; $i < $bpp; $i++) $cur[$i] = ($cur[$i] + $prev[$i]) & 255;
825 for (; $i < $rowBytes; $i++) {
826 $a = $cur[$i - $bpp];
827 $b = $prev[$i];
828 $c = $prev[$i - $bpp];
829 $p = $a + $b - $c;
830 $pa = abs($p - $a);
831 $pb = abs($p - $b);
832 $pc = abs($p - $c);
833 $cur[$i] = ($cur[$i] + ($pa <= $pb && $pa <= $pc ? $a : ($pb <= $pc ? $b : $c))) & 255;
834 }
835 break;
836 default:
837 throw new \RuntimeException("不正なフィルターの種類です({$ft})。");
838 }
839 $out[] = pack('C*', ...$cur);
840 $prev = $cur;
841 }
842 return $out;
843 }
844
845 /**
846 * パレット又は灰色(8ビット以下)の値 => RGBA(4バイト)の表
847 */
848 private static function lookup(array $hdr, ?string $palette, ?string $trns): ?array {
849 if ($hdr['ctype'] === 3) {
850 $lut = [];
851 $n = intdiv(strlen($palette), 3);
852 for ($i = 0; $i < 256; $i++) {
853 // パレットに無い指標は黒(不正なファイルでも落ちない様に)
854 $rgb = $i < $n ? substr($palette, $i * 3, 3) : "\0\0\0";
855 $a = $trns !== null && $i < strlen($trns) ? $trns[$i] : "\xff";
856 $lut[chr($i)] = $rgb.$a;
857 }
858 return $lut;
859 }
860 if ($hdr['ctype'] === 0 && $hdr['depth'] <= 8) {
861 $max = (1 << $hdr['depth']) - 1;
862 $key = $trns !== null && strlen($trns) >= 2 ? unpack('n', $trns)[1] : -1;
863 $lut = [];
864 for ($i = 0; $i <= $max; $i++) {
865 $g = chr(intdiv($i * 255 + intdiv($max, 2), $max));
866 $lut[chr($i)] = $g.$g.$g.($i === $key ? "\0" : "\xff");
867 }
868 return $lut;
869 }
870 return null;
871 }
872
873 /**
874 * フィルターを戻した1行をRGBAにする
875 */
876 private static function toRgba(string $row, int $w, array $hdr, ?array $lut, ?string $trns): string {
877 $depth = $hdr['depth'];
878 $ctype = $hdr['ctype'];
879
880 if ($lut !== null) {
881 if ($depth < 8) {
882 // 1バイトに入っている複数の値を1バイトずつにする
883 static $unpack = [];
884 if (!isset($unpack[$depth])) {
885 $n = 8 / $depth;
886 $mask = (1 << $depth) - 1;
887 for ($b = 0; $b < 256; $b++) {
888 $s = '';
889 for ($k = $n - 1; $k >= 0; $k--) $s .= chr(($b >> ($k * $depth)) & $mask);
890 $unpack[$depth][chr($b)] = $s;
891 }
892 }
893 $row = substr(strtr($row, $unpack[$depth]), 0, $w);
894 }
895 return strtr($row, $lut);
896 }
897
898 // 16ビット:透明色の比較は16ビットで、表示は上位8ビット
899 $key = null;
900 if ($trns !== null && ($ctype === 0 || $ctype === 2)) $key = $ctype === 0 ? substr($trns, 0, 2) : substr($trns, 0, 6);
901 if ($depth === 16) {
902 $samples = $row;
903 $row = preg_replace('/(.)./s', '$1', $row);
904 }
905
906 switch ($ctype) {
907 case 0: // 16ビットの灰色(8ビット以下はlookup)
908 $out = preg_replace('/(.)/s', "\$1\$1\$1\xff", $row);
909 if ($key !== null) {
910 for ($i = 0; $i < $w; $i++) if (substr($samples, $i * 2, 2) === $key) $out[$i * 4 + 3] = "\0";
911 }
912 return $out;
913 case 2:
914 $out = implode("\xff", str_split($row, 3))."\xff";
915 if ($key !== null) {
916 $sz = $depth === 16 ? 6 : 3;
917 $src = $depth === 16 ? $samples : $row;
918 $k = $depth === 16 ? $key : substr($key, 1, 1).substr($key, 3, 1).substr($key, 5, 1);
919 for ($i = 0; $i < $w; $i++) if (substr($src, $i * $sz, $sz) === $k) $out[$i * 4 + 3] = "\0";
920 }
921 return $out;
922 case 4:
923 return preg_replace('/(.)(.)/s', '$1$1$1$2', $row);
924 default: // 6
925 return $row;
926 }
927 }
928
929 /**
930 * 画素をPNGにする
931 *
932 * 色の数等を見て、一番小さくなる形式を選ぶ:256色以下ならパレット(2色なら1ビット)、
933 * 不透明ならアルファ無し、灰色ならグレースケール。framesがあればAPNGにする。
934 *
935 * @param array $opt level:圧縮レベル(0~9、既定9)
936 */
937 public function encode(Bitmap $img, array $opt = []): string {
938 $level = max(0, min(9, (int)($opt['level'] ?? 9)));
939 $images = $img->frames ? array_column($img->frames, 'bitmap') : [$img];
940 foreach ($images as $f) {
941 if ($f->width !== $img->width || $f->height !== $img->height) throw new \InvalidArgumentException('フレームの大きさが画像と違います。');
942 }
943
944 // 色を数える(257色を超えたら止める)
945 $colors = [];
946 foreach ($images as $f) {
947 for ($y = 0; $y < $f->height && count($colors) <= 256; $y++) $colors += array_flip(str_split($f->row($y), 4));
948 }
949 $opaque = true;
950 $gray = true;
951 foreach ($images as $f) {
952 $opaque = $opaque && $f->isOpaque();
953 $gray = $gray && $f->isGray();
954 }
955
956 $plte = $trnsChunk = '';
957 $map = null;
958 if (count($colors) <= 256) {
959 // 透明な色を先にする(tRNSを短くする為)
960 $keys = array_keys($colors);
961 usort($keys, fn($a, $b) => ord($a[3]) <=> ord($b[3]));
962 $map = [];
963 $alpha = '';
964 foreach ($keys as $i => $k) {
965 $map[$k] = chr($i);
966 $plte .= substr($k, 0, 3);
967 $alpha .= $k[3];
968 }
969 $alpha = rtrim($alpha, "\xff");
970 $n = count($keys);
971 $depth = $n <= 2 ? 1 : ($n <= 4 ? 2 : ($n <= 16 ? 4 : 8));
972 $ctype = 3;
973 if ($alpha !== '') $trnsChunk = self::chunk('tRNS', $alpha);
974 $plte = self::chunk('PLTE', $plte);
975 } else {
976 $depth = 8;
977 $ctype = $gray ? ($opaque ? 0 : 4) : ($opaque ? 2 : 6);
978 }
979 $channels = [0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4][$ctype];
980 $bpp = max(1, ($channels * $depth) >> 3);
981
982 $raw = function(Bitmap $f) use ($ctype, $depth, $map, $bpp, $level): string {
983 $out = '';
984 $prev = null;
985 for ($y = 0; $y < $f->height; $y++) {
986 $row = $f->row($y);
987 $line = match ($ctype) {
988 0 => preg_replace('/(.).../s', '$1', $row),
989 2 => preg_replace('/(...)./s', '$1', $row),
990 4 => preg_replace('/(.)..(.)/s', '$1$2', $row),
991 3 => strtr($row, $map),
992 default => $row,
993 };
994 if ($ctype === 3 && $depth < 8) {
995 $per = 8 / $depth;
996 $packed = '';
997 foreach (str_split($line, $per) as $group) {
998 $b = 0;
999 for ($k = 0; $k < $per; $k++) $b = ($b << $depth) | ($k < strlen($group) ? ord($group[$k]) : 0);
1000 $packed .= chr($b);
1001 }
1002 $line = $packed;
1003 }
1004 // パレットにはフィルターを使わない(仕様の推奨)。それ以外は行毎に一番良い物を選ぶ
1005 $out .= $ctype === 3 ? "\0".$line : self::filter($line, $prev, $bpp);
1006 $prev = $line;
1007 }
1008 return gzcompress($out, $level);
1009 };
1010
1011 $png = self::SIGNATURE.self::chunk('IHDR', pack('NNCCCCC', $img->width, $img->height, $depth, $ctype, 0, 0, 0)).$plte.$trnsChunk;
1012 if (!$img->frames) return $png.self::chunk('IDAT', $raw($img)).self::chunk('IEND', '');
1013
1014 $png .= self::chunk('acTL', pack('NN', count($images), $img->loops));
1015 $seq = 0;
1016 foreach ($img->frames as $i => $fr) {
1017 // 遅延はミリ秒(分母1000)
1018 $png .= self::chunk('fcTL', pack('NNNNNnnCC', $seq++, $img->width, $img->height, 0, 0, min(65535, max(0, (int)$fr['delay'])), 1000, 0, 0));
1019 $z = $raw($fr['bitmap']);
1020 if ($i === 0) {
1021 $png .= self::chunk('IDAT', $z);
1022 } else {
1023 $png .= self::chunk('fdAT', pack('N', $seq++).$z);
1024 }
1025 }
1026 return $png.self::chunk('IEND', '');
1027 }
1028
1029 private static function chunk(string $type, string $data): string {
1030 return pack('N', strlen($data)).$type.$data.pack('N', crc32($type.$data));
1031 }
1032
1033 /**
1034 * 1行にフィルターを掛ける(5種類の内、差の絶対値の合計が一番小さい物:仕様の推奨する方法)
1035 *
1036 * @return string フィルターの種類のバイト+データ
1037 */
1038 private static function filter(string $line, ?string $prevLine, int $bpp): string {
1039 $cur = array_values(unpack('C*', $line));
1040 $n = count($cur);
1041 $prev = $prevLine === null ? array_fill(0, $n, 0) : array_values(unpack('C*', $prevLine));
1042 $cands = [];
1043 $cands[0] = $cur;
1044 $f = $cur;
1045 for ($i = $bpp; $i < $n; $i++) $f[$i] = ($cur[$i] - $cur[$i - $bpp]) & 255;
1046 $cands[1] = $f;
1047 if ($prevLine !== null) {
1048 $f = [];
1049 for ($i = 0; $i < $n; $i++) $f[] = ($cur[$i] - $prev[$i]) & 255;
1050 $cands[2] = $f;
1051 $f = [];
1052 for ($i = 0; $i < $n; $i++) $f[] = ($cur[$i] - ((($i >= $bpp ? $cur[$i - $bpp] : 0) + $prev[$i]) >> 1)) & 255;
1053 $cands[3] = $f;
1054 $f = [];
1055 for ($i = 0; $i < $n; $i++) {
1056 $a = $i >= $bpp ? $cur[$i - $bpp] : 0;
1057 $b = $prev[$i];
1058 $c = $i >= $bpp ? $prev[$i - $bpp] : 0;
1059 $p = $a + $b - $c;
1060 $pa = abs($p - $a);
1061 $pb = abs($p - $b);
1062 $pc = abs($p - $c);
1063 $f[] = ($cur[$i] - ($pa <= $pb && $pa <= $pc ? $a : ($pb <= $pc ? $b : $c))) & 255;
1064 }
1065 $cands[4] = $f;
1066 }
1067 $best = 0;
1068 $bestSum = PHP_INT_MAX;
1069 foreach ($cands as $ft => $f) {
1070 $sum = 0;
1071 foreach ($f as $v) $sum += $v < 128 ? $v : 256 - $v;
1072 if ($sum < $bestSum) {
1073 $bestSum = $sum;
1074 $best = $ft;
1075 }
1076 }
1077 return chr($best).pack('C*', ...$cands[$best]);
1078 }
1079
1080 private function getSamplesPerPixel(int $colorType): int {
1081 return match($colorType) {
1082 0 => 1, // グレースケール
1083 2 => 3, // RGB
1084 3 => 1, // 指標した色(ペレット)
1085 4 => 2, // グレースケール+アルファ
1086 6 => 4, // RGBA
1087 default => 1,
1088 };
1089 }
1090}