master
ブランチ master
LittleBeast / src / Std / Test / LibImage.php
31.2 KB、407行 生 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\Test;
39
40require_once __DIR__.'/../../../autoload.php';
41
42use Std\Lib\Tester;
43use Std\Lib\Image;
44use Std\Lib\Image\{Bitmap, Png, Bmp, Targa, Gif, Jpeg, Webp};
45
46$test = new Tester([
47 'colorOutput' => true,
48 'verboseOutput' => true
49]);
50
51// テスト用の小さい画像を作る(Imageのパスはルートからの相対パス)
52$rel = '/data/lbimg_'.bin2hex(random_bytes(6));
53$dir = ROOT.$rel;
54mkdir($dir, 0700, true);
55
56$chunk = fn(string $t, string $d) => pack('N', strlen($d)).$t.$d.pack('N', crc32($t.$d));
57$raw = '';
58for ($y = 0; $y < 2; $y++) $raw .= "\0".str_repeat("\xff\x00\x00", 3);
59file_put_contents("{$dir}/red.png", "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 3, 2, 8, 2, 0, 0, 0))
60 .$chunk('IDAT', gzcompress($raw)).$chunk('IEND', ''));
61file_put_contents("{$dir}/a.gif", "GIF89a".pack('vv', 4, 5)."\x80\x00\x00\x00\x00\x00\xff\xff\xff"
62 ."\x2c".pack('vvvv', 0, 0, 4, 5)."\x00\x02\x02\x44\x01\x00\x3b");
63file_put_contents("{$dir}/a.jpg", "\xff\xd8\xff\xe0".pack('n', 16)."JFIF\0\x01\x01\x00\x00\x01\x00\x01\x00\x00"
64 ."\xff\xc0".pack('n', 17)."\x08".pack('nn', 6, 7)."\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01\xff\xd9");
65file_put_contents("{$dir}/a.tga", pack('CCCvvCvvvvCC', 0, 0, 2, 0, 0, 0, 0, 0, 2, 3, 24, 0).str_repeat("\x00\x00\xff", 6));
66file_put_contents("{$dir}/a.webp", base64_decode('UklGRkwBAABXRUJQVlA4TEABAAAvE8ADAM2VIaL/MQAIwv+4hRjw/ocANSBo2zZmMP5wfxQpxEaSFEkFc0z+O3nPuDNVTiLJdq2pavRQ+Pyjh34QkBQk/z6Og1CbtgEDnpxyCOWacx4555JbuTfncuo9zT2Xcu86ykUAiVREGAJKIGa5DrM4IpgiCSIQQKkAOo4F+WEPUkgxGzs2CAjCLOA4szH4htnzjwBmMijB1IC6DjCYlLcQqIgS6APQYTKxNC+IM6HQDCkBUKcxkcWB/HAARI0phCdxHQJMdqxQxDcGiGAWXYg80NOICGjMwnwAzpDiVS8CqdMIsCCDaQFmcYKr7c4AejOmvo49kAX4XRwuTCALOY6qQQApoFD5MSYChxEMECvmnx1BhJzoYfEGKSDHZQwmJiAEFBGACEOeiOPB/+MIZmOABBFjtJpvNBac/X8cAA=='));
67file_put_contents("{$dir}/fake.png", 'これは画像ではない');
68file_put_contents("{$dir}/big.png", "\x89PNG\r\n\x1a\n".str_repeat("\0", 3000));
69
70$test->describe('画像', function($test) use ($rel): void {
71 $test->it('PNGの情報を読めるはず', function($test) use ($rel): void {
72 $img = new Image("{$rel}/red.png");
73 $test->assertEquals('image/png', $img->type);
74 $test->assertEquals('red', $img->filename);
75 $test->assertEquals('png', $img->extension);
76 $test->assertEquals(filesize(ROOT."{$rel}/red.png"), $img->bytes);
77 $test->assertEquals(3, $img->fullInfo->width);
78 $test->assertEquals(2, $img->fullInfo->height);
79 $test->assertEquals('image/png', $img->fullInfo->type);
80 });
81
82 $test->it('GIF・JPEG・TGA・WebPの大きさを読めるはず', function($test) use ($rel): void {
83 foreach (['a.gif' => ['image/gif', 4, 5], 'a.jpg' => ['image/jpeg', 7, 6], 'a.tga' => ['image/x-tga', 2, 3], 'a.webp' => ['image/webp', 20, 16]] as $f => [$type, $w, $h]) {
84 $img = new Image("{$rel}/{$f}");
85 $test->assertEquals($type, $img->type, "{$f}の種類");
86 $test->assertEquals($w, $img->fullInfo->width, "{$f}の横");
87 $test->assertEquals($h, $img->fullInfo->height, "{$f}の丈");
88 }
89 $test->assertEquals('VP8L', (new Image("{$rel}/a.webp"))->fullInfo->compression);
90 });
91
92 $test->it('大きさを読み易い形式にするはず', function($test) use ($rel): void {
93 $test->assertEquals('41 B', (new Image("{$rel}/a.jpg"))->size);
94 $test->assertEquals('2.94 KiB', (new Image("{$rel}/big.png"))->size);
95 });
96
97 $test->it('無いファイルは何もしないはず', function($test) use ($rel): void {
98 $img = new Image("{$rel}/none.png");
99 $test->assertEquals('UNSUPPORTED', $img->type);
100 $test->assertEquals(0, $img->bytes);
101 // テンプレートがfullInfoを読んでも落ちない事
102 $test->assertEquals(0, $img->fullInfo->width);
103 $test->assertEquals('UNSUPPORTED', $img->fullInfo->type);
104 });
105
106 $test->it('未対応の形式は例外のはず', function($test) use ($rel): void {
107 // 拡張子ではなく中身で判断する
108 $test->assertThrows(fn() => new Image("{$rel}/fake.png"), \RuntimeException::class);
109 });
110});
111
112$test->describe('画素の読み書き', function($test): void {
113 // テスト用の画素:塗り分け(2色、16色、灰色、半透明)
114 $make = function(string $kind, int $w = 13, int $h = 7): Bitmap {
115 $b = new Bitmap($w, $h);
116 for ($y = 0; $y < $h; $y++) for ($x = 0; $x < $w; $x++) {
117 match ($kind) {
118 'two' => ($x + $y) % 2 ? $b->set($x, $y, 0, 0, 0) : $b->set($x, $y, 255, 255, 255),
119 'sixteen' => $b->set($x, $y, ($x % 4) * 80, ($y % 4) * 80, 40),
120 'gray' => $b->set($x, $y, ($x * 19 + $y * 7) & 255, ($x * 19 + $y * 7) & 255, ($x * 19 + $y * 7) & 255),
121 'rgb' => $b->set($x, $y, ($x * 19) & 255, ($y * 37) & 255, ($x * $y * 11) & 255),
122 'rgba' => $b->set($x, $y, ($x * 19) & 255, ($y * 37) & 255, ($x * $y * 11) & 255, ($x * 23 + $y) & 255),
123 };
124 }
125 return $b;
126 };
127
128 $test->it('PNGは色に合わせて一番小さい形式で書き、そのまま読み戻せるはず', function($test) use ($make): void {
129 // 色類とビット深さ(IHDRの25・24バイト目)
130 foreach (['two' => [3, 1], 'sixteen' => [3, 4], 'rgb' => [2, 8], 'rgba' => [6, 8]] as $kind => [$ctype, $depth]) {
131 // 256色以下はパレットになるので、RGBは大きくする
132 $b = str_starts_with($kind, 'rgb') ? $make($kind, 40, 20) : $make($kind);
133 $png = (new Png())->encode($b);
134 $test->assertEquals($ctype, ord($png[25]), "{$kind}の色類");
135 $test->assertEquals($depth, ord($png[24]), "{$kind}のビット深さ");
136 $test->assertEquals($b->data, (new Png())->decode($png)->data, "{$kind}の画素");
137 }
138 // 257色以上の灰色はグレースケール
139 $g = new Bitmap(300, 1);
140 for ($x = 0; $x < 300; $x++) $g->set($x, 0, $x % 256, $x % 256, $x % 256);
141 $g->set(299, 0, 1, 1, 1, 0);
142 $test->assertEquals(4, ord((new Png())->encode($g)[25]));
143 });
144
145 $test->it('他のソフトが書いたPNG(Adam7・16ビット・複数のIDAT、2ビットの灰色とtRNS)を読めるはず', function($test): void {
146 // Pythonで書いた画像と、仕様通りに計算した画素
147 $a = (new Png())->decode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAUAAAADEAYAAAF8oSktAAAARUlEQVR42gF/AID/ATkADM6M0H1iBNbLcN3l/o5vBNgGEEUPQS/4AZYbuShicyOc4RV6jmAsa7Y+tjiOxhKWPAJySEdxNHosg2/8MHdoAAAARUlEQVTxdx8NlmXOBKUuLT5gsPxX0TRDWr8dWK4DA1NRvthgrjWNHCc8Ag1VPO0yCC+LZ9tZnaKZ3CYt/v2Q0+fcfpgdOR3QNgufp1a3AAAAAElFTkSuQmCC'));
148 $test->assertEquals('390c8c7d7247342cd8100f2f6f770d65d670e58e0351d8ae8e4f6eac342fc231b7b08716eb3fc12896b96223177494287733c28ee8ba53bdb56b8824', bin2hex($a->data));
149 $b = (new Png())->decode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAUAAAADAgAAAAA07YKFAAAAAnRSTlMAApidrBQAAAAISURBVHjaYwnPZApswiXMeQAAAAlJREFUYGo7AAALNwLgcgWJwgAAAABJRU5ErkJggg=='));
150 $test->assertEquals('555555ff555555ff555555ffffffffffffffffffaaaaaa00aaaaaa00aaaaaa00000000ff555555ff000000ffaaaaaa00ffffffffaaaaaa00000000ff', bin2hex($b->data));
151 });
152
153 $test->it('壊れたPNGは例外のはず', function($test) use ($make): void {
154 $png = (new Png())->encode($make('rgb'));
155 $bad = $png;
156 $bad[40] = chr(ord($bad[40]) ^ 1); // IDATの中(CRCが合わなくなる)
157 $test->assertThrows(fn() => (new Png())->decode($bad), \RuntimeException::class);
158 $test->assertThrows(fn() => (new Png())->decode(substr($png, 0, 60)), \RuntimeException::class);
159 $test->assertThrows(fn() => (new Png())->decode('GIF89a'), \RuntimeException::class);
160 });
161
162 $test->it('APNGのフレーム・遅延・繰り返しを読み書き出来るはず', function($test): void {
163 $a = Bitmap::fill(4, 3, 255, 0, 0);
164 $a->frames = [
165 ['bitmap' => Bitmap::fill(4, 3, 255, 0, 0), 'delay' => 100],
166 ['bitmap' => Bitmap::fill(4, 3, 0, 0, 255, 128), 'delay' => 250],
167 ];
168 $a->loops = 3;
169 $b = (new Png())->decode((new Png())->encode($a));
170 $test->assertCount(2, $b->frames);
171 $test->assertEquals([100, 250], array_column($b->frames, 'delay'));
172 $test->assertEquals(3, $b->loops);
173 $test->assertEquals([0, 0, 255, 128], $b->frames[1]['bitmap']->get(2, 1));
174 });
175
176 $test->it('アルファで重ねられるはず', function($test): void {
177 $bg = Bitmap::fill(2, 1, 255, 0, 0);
178 $bg->draw(Bitmap::fill(1, 1, 0, 0, 255, 128), 1, 0);
179 $test->assertEquals([255, 0, 0, 255], $bg->get(0, 0));
180 $test->assertEquals([127, 0, 128, 255], $bg->get(1, 0));
181 });
182
183 $test->it('BMPを読み書き出来るはず(パレット、24ビット、アルファ付き32ビット、RLE4)', function($test) use ($make): void {
184 foreach (['two' => 1, 'sixteen' => 4, 'rgb' => 24, 'rgba' => 32] as $kind => $bits) {
185 $b = str_starts_with($kind, 'rgb') ? $make($kind, 40, 20) : $make($kind);
186 $bmp = (new Bmp())->encode($b);
187 $test->assertEquals($bits, unpack('v', substr($bmp, 28, 2))[1], "{$kind}のビット深さ");
188 $test->assertEquals($b->data, (new Bmp())->decode($bmp)->data, "{$kind}の画素");
189 }
190 // RLE4(繰り返し、そのままの画素、デルタ、行と画像の終わり)。飛ばした画素は透明
191 $r = (new Bmp())->decode(base64_decode('Qk2MAAAAAAAAAHYAAAAoAAAABwAAAAQAAAABAAQAAgAAABYAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAA/wAA/wAA/wAAAEBAQABQUFAAYGBgAHBwcACAgIAAkJCQAKCgoACwsLAAwMDAANDQ0ADg4OAA8PDwAAcSAAAABTEjQAACVQAAAAIDAQT/AAE='));
192 $test->assertEquals([0, 0, 0, 0], $r->get(0, 0));
193 $test->assertEquals([240, 240, 240, 255], $r->get(3, 0));
194 $test->assertEquals([0, 0, 0, 0], $r->get(3, 1));
195 $test->assertEquals([0, 0, 255, 255], $r->get(0, 2));
196 $test->assertEquals([80, 80, 80, 255], $r->get(6, 2));
197 $test->assertEquals([0, 255, 0, 255], $r->get(1, 3));
198 });
199
200 $test->it('TGAを読み書き出来るはず(左下の原点、行を跨ぐRLE)', function($test) use ($make): void {
201 foreach (['rgb', 'rgba'] as $kind) {
202 $b = $make($kind);
203 $test->assertEquals($b->data, (new Targa())->decode((new Targa())->encode($b))->data, "{$kind}(RLE)");
204 $test->assertEquals($b->data, (new Targa())->decode((new Targa())->encode($b, ['rle' => false]))->data, "{$kind}(無圧縮)");
205 }
206 $t = (new Targa())->decode(base64_decode('AAACAAAAAAAAAAAAAwACABgA////AAAAAP//AAD/AP8A/wAA'));
207 $test->assertEquals('ff0000ff00ff00ff0000ffffffffffff000000ffffff00ff', bin2hex($t->data));
208 $t = (new Targa())->decode(base64_decode('AAAKAAAAAAAAAAAAAwACACAoAQAA//8A/wCAgv8AAAAAAP///w=='));
209 $test->assertEquals('ff0000ff00ff00800000ff000000ff000000ff00ffff00ff', bin2hex($t->data));
210 });
211
212 $test->it('GIFを読み書き出来るはず(パレット、透明、256色を超える減色)', function($test) use ($make): void {
213 foreach (['two', 'sixteen', 'gray'] as $kind) {
214 $b = $make($kind);
215 $test->assertEquals($b->data, (new Gif())->decode((new Gif())->encode($b))->data, $kind);
216 }
217 // 透明はアルファ128未満、GIFに半透明は無い
218 $t = $make('two');
219 $t->set(1, 1, 9, 9, 9, 0);
220 $t->set(2, 1, 9, 9, 9, 200);
221 $d = (new Gif())->decode((new Gif())->encode($t));
222 $test->assertEquals(0, $d->get(1, 1)[3]);
223 $test->assertEquals([9, 9, 9, 255], $d->get(2, 1));
224 // 減色:大きさと大体の色は保つ
225 $rgb = $make('rgb', 40, 20);
226 $q = (new Gif())->decode((new Gif())->encode($rgb, ['dither' => true]));
227 $diff = 0;
228 for ($i = 0; $i < strlen($rgb->data); $i++) $diff += abs(ord($rgb->data[$i]) - ord($q->data[$i]));
229 $test->assertTrue($diff / strlen($rgb->data) < 16, '平均の差');
230 });
231
232 $test->it('他のソフトが書いたGIF(インターレース、オフセットと廃棄方法のあるアニメーション)を読めるはず', function($test): void {
233 // Pillowが書いたインターレースの3x9:真ん中の列は3行ずらした色
234 $cols = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255], [255, 0, 255], [255, 255, 255], [0, 0, 0], [128, 128, 128]];
235 $i = (new Gif())->decode(base64_decode('R0lGODdhAwAJAIMAAP//////AICAgAD//wD/AP8A//8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAwAJAAAIIQANBDBAYACBAwUOBAAQYACCAQUEFABgAAACAggEHBAQEAA7'));
236 for ($y = 0; $y < 9; $y++) {
237 $test->assertEquals([...$cols[$y], 255], $i->get(0, $y), "{$y}行目");
238 $test->assertEquals([...$cols[($y + 3) % 9], 255], $i->get(1, $y), "{$y}行目の真ん中");
239 }
240 // ImageMagickが書いた6x5:紺の背景、赤(前に戻す)、黄(背景に戻す)、緑。画素はChromiumと同じ
241 $a = (new Gif())->decode(base64_decode('R0lGODlhBgAFAPAAAAAAgAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQECgAAACH/C0ltYWdlTWFnaWNrDmdhbW1hPTAuNDU0NTQ1ACwAAAAABgAFAAACBYSPqWsFACH5BAwUAAAAIf8LSW1hZ2VNYWdpY2sOZ2FtbWE9MC40NTQ1NDUALAEAAQACAAIAgP8AAAAAAAIChFEAIfkECB4AAAAh/wtJbWFnZU1hZ2ljaw5nYW1tYT0wLjQ1NDU0NQAsAwACAAIAAgCA//8AAAAAAgKEUQAh+QQEBQAAACH/C0ltYWdlTWFnaWNrDmdhbW1hPTAuNDU0NTQ1ACwAAAMAAgACAIAA/wAAAAACAoRRADs='));
242 $test->assertCount(4, $a->frames);
243 $test->assertEquals([100, 200, 300, 50], array_column($a->frames, 'delay'));
244 $test->assertEquals(0, $a->loops);
245 $f = array_column($a->frames, 'bitmap');
246 $test->assertEquals([255, 0, 0, 255], $f[1]->get(1, 1));
247 $test->assertEquals([0, 0, 128, 255], $f[2]->get(1, 1), '赤は前に戻した');
248 $test->assertEquals([255, 255, 0, 255], $f[2]->get(3, 2));
249 $test->assertEquals([0, 0, 0, 0], $f[3]->get(3, 2), '黄は背景(透明)に戻した');
250 $test->assertEquals([0, 255, 0, 255], $f[3]->get(0, 3));
251 });
252
253 $test->it('GIFのアニメーションを書けるはず', function($test): void {
254 $a = Bitmap::fill(5, 4, 255, 0, 0);
255 $a->frames = [
256 ['bitmap' => Bitmap::fill(5, 4, 255, 0, 0), 'delay' => 100],
257 ['bitmap' => Bitmap::fill(5, 4, 0, 0, 255), 'delay' => 250],
258 ];
259 $a->loops = 0;
260 $b = (new Gif())->decode((new Gif())->encode($a));
261 $test->assertEquals([100, 250], array_column($b->frames, 'delay'));
262 $test->assertEquals(0, $b->loops);
263 $test->assertEquals([0, 0, 255, 255], $b->frames[1]['bitmap']->get(4, 3));
264 });
265
266 $test->it('他のソフトが書いたJPEG(プログレッシブ+リスタート、CMYK、グレースケール、Exifの向き)を読めるはず', function($test): void {
267 // Pillow(libjpeg-turbo)で書いて、libjpeg-turboで読んだ画素(逆DCTの計算の違いで±3まで)
268 $cases = [
269 'prog' => ['/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/2wBDAQICAgICAgUDAwUKBwYHCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgr/wgARCAAHAAkDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAYH/8QAFQEBAQAAAAAAAAAAAAAAAAAAAwT/3QAEAAH/2gAMAwEAAhADEAAAAZ/WyUf/xAAYEAACAwAAAAAAAAAAAAAAAAAAAwYHF//aAAgBAQABBQKI0+g//9DH0H//xAAZEQABBQAAAAAAAAAAAAAAAAAGAAEHFSP/2gAIAQMBAT8Bj81IaNtV/8QAFxEBAAMAAAAAAAAAAAAAAAAABAATMf/aAAgBAgEBPwELk07P/8QAFxABAQEBAAAAAAAAAAAAAAAAAgAjM//aAAgBAQAGPwI5m//Q5m//xAAXEAADAQAAAAAAAAAAAAAAAAAAAdHx/9oACAEBAAE/Iaaj/9DZR//aAAwDAQACAAMAAAAQ+//EABgRAAIDAAAAAAAAAAAAAAAAAABRMUFx/9oACAEDAQE/EJPprT//xAAVEQEBAAAAAAAAAAAAAAAAAAAAEf/aAAgBAgEBPxCC/wD/xAAWEAEBAQAAAAAAAAAAAAAAAAAAAXH/2gAIAQEAAT8Qrxr/0C3/2Q==', '000100ff110708ff2f0618ff490627ff640736ff810645ff9e0758ffb60764ffcf0b71ff091e0fff1c231bff39242bff54243aff6e234aff8a235affa9236affc22478ffd92883ff0a4322ff1b472cff37493dff53494aff6e495bff89486affa8477affc14987ffd84c93ff096732ff1d6c3dff386e4cff556e5bff6e6d6bff8b6c7bffa96d89ffc36e97ffd971a4ff0a8a3fff1b8e49ff388f58ff558f67ff6f8f78ff8c8f88ffaa8f98ffc38fa5ffd893b0ff0ead50ff1fb25aff3db369ff59b278ff74b289ff90b299ffafb2a9ffc7b3b5ffddb7c2ff11cd5fff25d16bff40d37aff5dd389ff78d39aff93d1a8ffb1d1b9ffcbd3c6ffe1d6d4ff', 9, 7],
270 'cmyk' => ['/9j/7gAOQWRvYmUAZAAAAAAA/9sAQwACAQEBAQECAQEBAgICAgIEAwICAgIFBAQDBAYFBgYGBQYGBgcJCAYHCQcGBggLCAkKCgoKCgYICwwLCgwJCgoK/8AAFAgABwAJBEMRAE0RAFkRAEsRAP/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/aAA4EQwBNAFkASwAAPwDwf/ggD/zBP+2f9Kxf+IaHwh/0BdN/7+R/4143/wAE7v2AtL/0H/RLf+H+Ja/fyv34o/4hofCH/QF03/v5H/jX6H/8MBaX/wA+lv8A99LRX//Z', '000000ff1c000eff38001eff54002cff70003cff8c004affa8005affc40068ffe00078ff002410ff1c241eff38242eff54243cff70244cff8c245affa8246affc42478ffe02488ff00481eff1c482cff38483cff54484aff70485aff8c4868ffa84878ffc44886ffe04896ff006c2eff1c6c3cff386c4cff546c5aff706c6aff8c6c78ffa86c88ffc46c96ffe06ca6ff00903cff1c904aff38905aff549068ff709078ff8c9086ffa89096ffc490a4ffe090b4ff00b44bff1cb45aff38b469ff54b478ff70b487ff8cb496ffa8b4a5ffc4b4b4ffe0b4c3ff00d85bff1cd869ff38d879ff54d887ff70d897ff8cd8a5ffa8d8b5ffc4d8c3ffe0d8d3ff', 9, 7],
271 'gray' => ['/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/wAALCAAHAAkBAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/AOA/ZH/4I/aAfsv/ABLrTt/GtfW3/Dn3Qv8AoH2n/fa1/9k=', '000000ff0a0a0aff141414ff1e1e1eff282828ff323232ff3d3d3dff464646ff515151ff161616ff202020ff2b2b2bff353535ff3e3e3eff484848ff535353ff5d5d5dff676767ff2e2e2eff373737ff424242ff4c4c4cff565656ff5f5f5fff6a6a6aff747474ff7e7e7eff454545ff4f4f4fff5a5a5aff646464ff6d6d6dff777777ff828282ff8c8c8cff969696ff5b5b5bff646464ff6f6f6fff797979ff838383ff8d8d8dff989898ffa1a1a1ffabababff737373ff7c7c7cff878787ff919191ff9b9b9bffa5a5a5ffb0b0b0ffb9b9b9ffc4c4c4ff888888ff929292ff9d9d9dffa7a7a7ffb1b1b1ffbababaffc5c5c5ffcfcfcfffd9d9d9ff', 9, 7],
272 'o6' => ['/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAACAAMDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDzv9vn4I/Be3/a9+N1nb/CL4YQWeneINI0rT7SHwD4UitrDS9K8J+H9N0vTbKBNJWK1sNN061trCws4Ejt7Oyt4LW3jjghjjX/AG+/ZL8TcR5N+zP+g7RyjiDO8qo5j9G7wz4lzCjlua4/A0sdxHxfkGH4r4sz/GU8LiKUMVnfFHFGc5xxJxFmtdTx2dZ/muZZxmVfE5jjsViav+4/7LLwQ8FvFf6IFDxD8UvCHwv8SuP+KfpQfT/x/E3HPH/AHCnGPGHEWOp/T8+k3g6eNz3ibiLKcxzrN8XDCYbDYWGIzDG4itHDYehQjNUqVOMf/9k=', 'ffff00fffe0000ff00ffffff01ff00ffff00feff0000feff', 2, 3],
273 ];
274 foreach ($cases as $name => [$b64, $hex, $w, $h]) {
275 $b = (new Jpeg())->decode(base64_decode($b64));
276 $test->assertEquals([$w, $h], [$b->width, $b->height], "{$name}の大きさ");
277 $exp = hex2bin($hex);
278 $max = 0;
279 for ($i = 0; $i < strlen($exp); $i++) $max = max($max, abs(ord($exp[$i]) - ord($b->data[$i])));
280 $test->assertTrue($max <= 3, "{$name}の画素(差{$max})");
281 }
282 // 向き6(時計回りに90度):3x2が2x3になる
283 $info = (new Jpeg())->decode(base64_decode($cases['o6'][0]));
284 $test->assertEquals(2, $info->width);
285 });
286
287 $test->it('JPEGを書けるはず(品質、4:2:0・4:4:4、グレースケール、透明は白)', function($test) use ($make): void {
288 // 滑らかなグラデーション(画素毎に色が変わる画像は4:2:0で色差が失われるのが正しい)
289 $b = new Bitmap(40, 20);
290 for ($y = 0; $y < 20; $y++) for ($x = 0; $x < 40; $x++) $b->set($x, $y, $x * 6, $y * 12, ($x + $y) * 4);
291 $hi = (new Jpeg())->encode($b, ['quality' => 95]);
292 $lo = (new Jpeg())->encode($b, ['quality' => 30]);
293 $test->assertTrue(strlen($lo) < strlen($hi), '品質が低いと小さい');
294 $err = function(Bitmap $a, Bitmap $c): float {
295 $s = 0;
296 for ($i = 0; $i < strlen($a->data); $i++) if ($i % 4 !== 3) $s += abs(ord($a->data[$i]) - ord($c->data[$i]));
297 return $s / (strlen($a->data) * 3 / 4);
298 };
299 $test->assertTrue($err($b, (new Jpeg())->decode($hi)) < 2, '品質95の平均の差');
300 $test->assertTrue($err($b, (new Jpeg())->decode((new Jpeg())->encode($b, ['quality' => 95, 'subsampling' => '444']))) < 1.5, '4:4:4の平均の差');
301 // 灰色はグレースケール(SOF0の成分の数が1)
302 $g = (new Jpeg())->encode($make('gray'));
303 $test->assertEquals(1, ord($g[strpos($g, "\xff\xc0") + 9]));
304 // 透明は白と合成
305 $t = Bitmap::fill(16, 16, 0, 0, 0, 0);
306 $test->assertTrue(min((new Jpeg())->decode((new Jpeg())->encode($t))->get(8, 8)) >= 250);
307 });
308
309 $test->it('libwebpが書いたWebP(非可逆、可逆、アルファ、アニメーション)を読めるはず', function($test): void {
310 // Pillow(libwebp 1.6)で書いて、libwebpで読んだ画素のMD5(計算はlibwebpと全く同じなので一致する)
311 $cases = [
312 'ly' => ['UklGRqYAAABXRUJQVlA4IJoAAADQBACdASoYABQAPrVOoEqnJCMhsBgIAOAWiUAWHYHlTnwr36n6ZUj8wDDu9+DCyAD+opZKvMHS233rVxNfgljnxvWSmFqCXbB3yF9HmOg8rtcoGdyLn5z6+4VxeQ1ImRbIwaoZ1v7erNwZKFXCzKxRsQdQwWWn+vJ3DfH+/zPcdEROIODEUzTDqVxI6LKvZMfVtHqlKUQ+AYQA', 24, 20, ['3d2d40581e10cc86445dd4d4f2c935c4']],
313 'll' => ['UklGRkwBAABXRUJQVlA4TEABAAAvE8ADAM2VIaL/MQAIwv+4hRjw/ocANSBo2zZmMP5wfxQpxEaSFEkFc0z+O3nPuDNVTiLJdq2pavRQ+Pyjh34QkBQk/z6Og1CbtgEDnpxyCOWacx4555JbuTfncuo9zT2Xcu86ykUAiVREGAJKIGa5DrM4IpgiCSIQQKkAOo4F+WEPUkgxGzs2CAjCLOA4szH4htnzjwBmMijB1IC6DjCYlLcQqIgS6APQYTKxNC+IM6HQDCkBUKcxkcWB/HAARI0phCdxHQJMdqxQxDcGiGAWXYg80NOICGjMwnwAzpDiVS8CqdMIsCCDaQFmcYKr7c4AejOmvo49kAX4XRwuTCALOY6qQQApoFD5MSYChxEMECvmnx1BhJzoYfEGKSDHZQwmJiAEFBGACEOeiOPB/+MIZmOABBFjtJpvNBac/X8cAA==', 20, 16, ['d815286545f188d51ae2c856a6f5082c']],
314 'lya' => ['UklGRmgAAABXRUJQVlA4WAoAAAAQAAAADwAACwAAQUxQSCQAAAABR6CQbQTI3/aO4IeIiBTxMMxGUhmWZVmW5fzfBxDR/4Bb8xdWUDggHgAAAHABAJ0BKhAADAACwEwllAJ0AUAAAP72JivN6Z4AAA==', 16, 12, ['34aea5dccb0104704648d56751219bbc']],
315 'an' => ['UklGRtQBAABXRUJQVlA4WAoAAAASAAAADwAACwAAQU5JTQYAAAAAAAAAAgBBTk1GogAAAAAAAAAAAA8AAAsAACgAAAJWUDggigAAAFABAJ0BKhAADAACQDglsAAEdAAA/u1jqjpe1yd//xMetXZf+DXz/HzO0n/0HuFcl/EhnxTn0nP3xTxDvrfSTJaHOCrPsAzkZfKf/73SWFW+zS0Fi24G8cxE7+8Xr6jlWkP/8zQiYwzd5n+5wb3KX//1em9W3/J3X/v/+P+P+f+ftLXAlKa01l4AAEFOTUaSAAAAAAAAAQAACQAABQAAMgAAAkFMUEgTAAAAAQ9wrdKICCEQSDLYX3iGiP5HBgBWUDggXgAAAFABAJ0BKgoABgACQDglsAAEdAAA/vCixaMTQv+HtdHh7XR4e193///iY5/Aj+ii1+QeONk0LN/+qGF+r/Q18Gx+bdzX//6WH/fFx/Y3//8SF//pYf98XH9jf/w/gABBTk1GZAAAAAIAAAEAAAkAAAUAADwAAABWUDggTAAAAFQBAJ0BKgoABgAAADglsAAEdAAA/vCI1w8Uzl4f69KERUrb//bMkc3+Sn6WUnj11///Uaf9i2f+Jb5n//Ehf/6WH/cWz/wQ//D+AAA=', 16, 12, ['878e0516e278e5b3287d4fe34e020d74', 'cc6eb9ea67b8b238e6d1e7044cf59124', '832f71c95b62e156655925ddaaa6fb29']],
316 'anl' => ['UklGRtIDAABXRUJQVlA4WAoAAAASAAAADwAACwAAQU5JTQYAAAAAAAAAAABBTk1GSAIAAAAAAAAAAA8AAAsAACgAAAJWUDhMLwIAAC8PwAIA5wTXtq3j1XfOd37EnW3bTqo8bkZqO9e+nd3aeg03jSRJcjhy3jX3vINwOjcCABDK2fZlY7L5017QI9pba7Jtu/mP7N+VAggPiRZCnD9nY0uZiENYUe56Az+mHi568cJxoJPx50YAgP9I523pQhmiiS7eEslAh83E8MDLXwAE+4mGHg7bQIg4zW+M4zpSVOBrr6MixkcscsFC/CYgB+hi0UWRE/hDD8AFtTGQ73CSKwSbdJKn454AAj39hwKghyxkRwizV1cdp99eRQWaAC8MBFCmtRBCQQTYQX+is0FrdS7ohnz02rIL89la7yAoaKeWGrdpPS1uiDWrqSxNsPQTgoGRJ8bvqyXZBnpieLOxlOWg9PEwFGpHop9+XRYKXxQR5yXYtAZLDqlEgLQ7thIoTAjv6rs1yrCq7mnctZUJQT77expvpuH21wiPwTG61STFmDFEfp1RlarMPRNIx+UE2Y5J8KObbp9zSP+W4f66fxos8vC74/rkOX8MQAwAsIx0tm3btm2+bdu2bWbcDBHR//j4/v/pItKVwc67HSsVifj6/Xsi4YSK8tbbgUmCQQEAAAAa+9zi5c1CXCuDswOvkyNH66OtGry5++wWOfLjSxNwOpETSEO6yHQHzCGwmBun/WvdNlhtUcnPr++vnGQ8BdvX01ute8ODs9tDDDS70hy+2C81/NF2xmBO8vUnt6vvD8szMd9Ygecqclcej+fPPl/2DqdSRrEnQAMAQU5NRqgAAAAAAAABAAAJAAAFAAAyAAACVlA4TJAAAAAvCUABEMegNpKtBHeNfkZKLfTfACVYam2ojSS1eQMRKXIdqAH1X5oYBQBYaHztZZ1ZRt3VwREkEEji3G/PQB9UXzEDhfNHQXwCSI/HhxHHRG2fsq3hTalWKAblr3vV7ogag6NBxwo6J26DE8kUFLSRpOwzM/rXuiIi+h/nzwQAwN1V8hte8SrGcdM2cxCxEwBBTk1GpgAAAAIAAAEAAAkAAAUAADwAAABWUDhMjgAAAC8JQAEAx8A2tq1EOAUwZHTg0j4dEK9Gm/k24G0oaNuG6dkFNXBjzji2rTS4QxWfAui/DYdIA1klW5n5D3uQQlMwpriI0A4+cGEaoOEmB6jsfCQjs4sfmcHJGyaDCvnjpSKafeKmPv3ocuU7KGgjSXlmxvWvdUVE9D+kDgkAAOFqZzvX5vFL5nON4td8JAA=', 16, 12, ['8dba6b986075f2fe3083ea5a7feba53a', 'b2a89c12a21cceb0aefe1858efcf0b98', '61d35682a51b88e281f8aa058aa7d441']],
317 ];
318 foreach ($cases as $name => [$b64, $w, $h, $md5]) {
319 $b = (new Webp())->decode(base64_decode($b64));
320 $test->assertEquals([$w, $h], [$b->width, $b->height], "{$name}の大きさ");
321 $frames = $b->frames ? array_column($b->frames, 'bitmap') : [$b];
322 $test->assertEquals($md5, array_map(fn($f) => md5($f->data), $frames), "{$name}の画素");
323 }
324 $an = (new Webp())->decode(base64_decode($cases['an'][0]));
325 $test->assertEquals([40, 50, 60], array_column($an->frames, 'delay'));
326 $test->assertEquals(2, $an->loops);
327 });
328
329 $test->it('WebPを書けるはず(可逆はそのまま、非可逆は品質、アルファは正確、アニメーション)', function($test) use ($make): void {
330 foreach (['two', 'sixteen', 'gray', 'rgba'] as $kind) {
331 $b = $kind === 'rgba' ? $make($kind, 40, 20) : $make($kind);
332 $ll = (new Webp())->encode($b, ['lossless' => true]);
333 $test->assertEquals('VP8L', substr($ll, 12, 4), "{$kind}は可逆");
334 $test->assertEquals($b->data, (new Webp())->decode($ll)->data, "{$kind}の画素");
335 }
336 $g = new Bitmap(40, 20);
337 for ($y = 0; $y < 20; $y++) for ($x = 0; $x < 40; $x++) $g->set($x, $y, $x * 6, $y * 12, ($x + $y) * 4);
338 $hi = (new Webp())->encode($g, ['quality' => 95]);
339 $lo = (new Webp())->encode($g, ['quality' => 30]);
340 $test->assertTrue(strlen($lo) < strlen($hi), '品質が低いと小さい');
341 $test->assertEquals('VP8 ', substr($hi, 12, 4), '不透明ならVP8だけ');
342 $err = 0;
343 $back = (new Webp())->decode($hi);
344 for ($i = 0; $i < strlen($g->data); $i++) if ($i % 4 !== 3) $err += abs(ord($g->data[$i]) - ord($back->data[$i]));
345 $test->assertTrue($err / (40 * 20 * 3) < 2, '品質95の平均の差');
346 // アルファがあればVP8XとALPH(アルファは可逆)
347 $t = $make('rgba', 40, 20);
348 $ly = (new Webp())->encode($t, ['quality' => 50]);
349 $test->assertEquals('VP8X', substr($ly, 12, 4));
350 $back = (new Webp())->decode($ly);
351 $alpha = fn(string $d) => implode('', array_map(fn($i) => $d[$i], range(3, strlen($d) - 1, 4)));
352 $test->assertEquals($alpha($t->data), $alpha($back->data), 'アルファ');
353 // アニメーション
354 $a = Bitmap::fill(5, 4, 255, 0, 0);
355 $a->frames = [
356 ['bitmap' => Bitmap::fill(5, 4, 255, 0, 0), 'delay' => 100],
357 ['bitmap' => Bitmap::fill(5, 4, 0, 0, 255, 128), 'delay' => 250],
358 ];
359 $a->loops = 3;
360 foreach ([true, false] as $lossless) {
361 $b = (new Webp())->decode((new Webp())->encode($a, ['lossless' => $lossless]));
362 $test->assertEquals([100, 250], array_column($b->frames, 'delay'));
363 $test->assertEquals(3, $b->loops);
364 $test->assertEquals(128, $b->frames[1]['bitmap']->get(2, 2)[3]);
365 if ($lossless) $test->assertEquals([0, 0, 255, 128], $b->frames[1]['bitmap']->get(4, 3));
366 }
367 });
368
369 $test->it('壊れたWebPは例外のはず', function($test): void {
370 $ly = base64_decode('UklGRqYAAABXRUJQVlA4IJoAAADQBACdASoYABQAPrVOoEqnJCMhsBgIAOAWiUAWHYHlTnwr36n6ZUj8wDDu9+DCyAD+opZKvMHS233rVxNfgljnxvWSmFqCXbB3yF9HmOg8rtcoGdyLn5z6+4VxeQ1ImRbIwaoZ1v7erNwZKFXCzKxRsQdQwWWn+vJ3DfH+/zPcdEROIODEUzTDqVxI6LKvZMfVtHqlKUQ+AYQA');
371 $short = substr($ly, 0, 120);
372 $short = substr_replace(substr_replace($short, pack('V', strlen($short) - 8), 4, 4), pack('V', strlen($short) - 20), 16, 4);
373 $test->assertThrows(fn() => (new Webp())->decode($short), \RuntimeException::class);
374 $test->assertThrows(fn() => (new Webp())->decode('RIFF'.pack('V', 17).'WEBPVP8L'.pack('V', 5)."\x2e\0\0\0\0"), \RuntimeException::class);
375 $test->assertThrows(fn() => (new Webp())->decode('RIFF'.pack('V', 4).'WEBP'), \RuntimeException::class);
376 });
377
378 $test->it('未対応のJPEG(算術符号)は例外のはず', function($test): void {
379 $sof9 = "\xff\xd8\xff\xc9".pack('n', 11)."\x08".pack('nn', 8, 8)."\x01\x01\x11\x00\xff\xd9";
380 $test->assertThrows(fn() => (new Jpeg())->decode($sof9), \RuntimeException::class);
381 });
382
383 $test->it('中身で形式を判断して読めるはず', function($test) use ($make): void {
384 $b = $make('rgba');
385 foreach (['image/png', 'image/bmp', 'image/gif'] as $type) {
386 $data = Image::encode($b, $type);
387 $test->assertEquals($type, Image::detect($data));
388 if ($type !== 'image/gif') $test->assertEquals($b->data, Image::decode($data)->data);
389 }
390 $test->assertEquals('image/webp', Image::detect('RIFF'.pack('V', 100).'WEBPVP8L'));
391 $test->assertEquals($b->data, Image::decode(Image::encode($b, 'image/webp', ['lossless' => true]))->data);
392 $test->assertEquals('image/x-tga', Image::detect("\0\0\2", 'a.TGA'));
393 $test->assertNull(Image::detect('これは画像ではない'));
394 $test->assertThrows(fn() => Image::decode('これは画像ではない'), \RuntimeException::class);
395 });
396
397 $test->it('大き過ぎる画像は読まないはず', function($test): void {
398 $huge = "\x89PNG\r\n\x1a\n".pack('N', 13).'IHDR'.pack('NNCCCCC', 60000, 60000, 8, 6, 0, 0, 0);
399 $huge .= pack('N', crc32(substr($huge, 12, 17)));
400 $test->assertThrows(fn() => (new Png())->decode($huge), \RuntimeException::class);
401 });
402});
403
404foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f);
405@rmdir($dir);
406
407$test->printSummary();