true, 'verboseOutput' => true ]); // テスト用の小さい画像を作る(Imageのパスはルートからの相対パス) $rel = '/data/lbimg_'.bin2hex(random_bytes(6)); $dir = ROOT.$rel; mkdir($dir, 0700, true); $chunk = fn(string $t, string $d) => pack('N', strlen($d)).$t.$d.pack('N', crc32($t.$d)); $raw = ''; for ($y = 0; $y < 2; $y++) $raw .= "\0".str_repeat("\xff\x00\x00", 3); file_put_contents("{$dir}/red.png", "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 3, 2, 8, 2, 0, 0, 0)) .$chunk('IDAT', gzcompress($raw)).$chunk('IEND', '')); file_put_contents("{$dir}/a.gif", "GIF89a".pack('vv', 4, 5)."\x80\x00\x00\x00\x00\x00\xff\xff\xff" ."\x2c".pack('vvvv', 0, 0, 4, 5)."\x00\x02\x02\x44\x01\x00\x3b"); file_put_contents("{$dir}/a.jpg", "\xff\xd8\xff\xe0".pack('n', 16)."JFIF\0\x01\x01\x00\x00\x01\x00\x01\x00\x00" ."\xff\xc0".pack('n', 17)."\x08".pack('nn', 6, 7)."\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01\xff\xd9"); file_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)); file_put_contents("{$dir}/a.webp", base64_decode('UklGRkwBAABXRUJQVlA4TEABAAAvE8ADAM2VIaL/MQAIwv+4hRjw/ocANSBo2zZmMP5wfxQpxEaSFEkFc0z+O3nPuDNVTiLJdq2pavRQ+Pyjh34QkBQk/z6Og1CbtgEDnpxyCOWacx4555JbuTfncuo9zT2Xcu86ykUAiVREGAJKIGa5DrM4IpgiCSIQQKkAOo4F+WEPUkgxGzs2CAjCLOA4szH4htnzjwBmMijB1IC6DjCYlLcQqIgS6APQYTKxNC+IM6HQDCkBUKcxkcWB/HAARI0phCdxHQJMdqxQxDcGiGAWXYg80NOICGjMwnwAzpDiVS8CqdMIsCCDaQFmcYKr7c4AejOmvo49kAX4XRwuTCALOY6qQQApoFD5MSYChxEMECvmnx1BhJzoYfEGKSDHZQwmJiAEFBGACEOeiOPB/+MIZmOABBFjtJpvNBac/X8cAA==')); file_put_contents("{$dir}/fake.png", 'これは画像ではない'); file_put_contents("{$dir}/big.png", "\x89PNG\r\n\x1a\n".str_repeat("\0", 3000)); $test->describe('画像', function($test) use ($rel): void { $test->it('PNGの情報を読めるはず', function($test) use ($rel): void { $img = new Image("{$rel}/red.png"); $test->assertEquals('image/png', $img->type); $test->assertEquals('red', $img->filename); $test->assertEquals('png', $img->extension); $test->assertEquals(filesize(ROOT."{$rel}/red.png"), $img->bytes); $test->assertEquals(3, $img->fullInfo->width); $test->assertEquals(2, $img->fullInfo->height); $test->assertEquals('image/png', $img->fullInfo->type); }); $test->it('GIF・JPEG・TGA・WebPの大きさを読めるはず', function($test) use ($rel): void { 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]) { $img = new Image("{$rel}/{$f}"); $test->assertEquals($type, $img->type, "{$f}の種類"); $test->assertEquals($w, $img->fullInfo->width, "{$f}の横"); $test->assertEquals($h, $img->fullInfo->height, "{$f}の丈"); } $test->assertEquals('VP8L', (new Image("{$rel}/a.webp"))->fullInfo->compression); }); $test->it('大きさを読み易い形式にするはず', function($test) use ($rel): void { $test->assertEquals('41 B', (new Image("{$rel}/a.jpg"))->size); $test->assertEquals('2.94 KiB', (new Image("{$rel}/big.png"))->size); }); $test->it('無いファイルは何もしないはず', function($test) use ($rel): void { $img = new Image("{$rel}/none.png"); $test->assertEquals('UNSUPPORTED', $img->type); $test->assertEquals(0, $img->bytes); // テンプレートがfullInfoを読んでも落ちない事 $test->assertEquals(0, $img->fullInfo->width); $test->assertEquals('UNSUPPORTED', $img->fullInfo->type); }); $test->it('未対応の形式は例外のはず', function($test) use ($rel): void { // 拡張子ではなく中身で判断する $test->assertThrows(fn() => new Image("{$rel}/fake.png"), \RuntimeException::class); }); }); $test->describe('画素の読み書き', function($test): void { // テスト用の画素:塗り分け(2色、16色、灰色、半透明) $make = function(string $kind, int $w = 13, int $h = 7): Bitmap { $b = new Bitmap($w, $h); for ($y = 0; $y < $h; $y++) for ($x = 0; $x < $w; $x++) { match ($kind) { 'two' => ($x + $y) % 2 ? $b->set($x, $y, 0, 0, 0) : $b->set($x, $y, 255, 255, 255), 'sixteen' => $b->set($x, $y, ($x % 4) * 80, ($y % 4) * 80, 40), 'gray' => $b->set($x, $y, ($x * 19 + $y * 7) & 255, ($x * 19 + $y * 7) & 255, ($x * 19 + $y * 7) & 255), 'rgb' => $b->set($x, $y, ($x * 19) & 255, ($y * 37) & 255, ($x * $y * 11) & 255), 'rgba' => $b->set($x, $y, ($x * 19) & 255, ($y * 37) & 255, ($x * $y * 11) & 255, ($x * 23 + $y) & 255), }; } return $b; }; $test->it('PNGは色に合わせて一番小さい形式で書き、そのまま読み戻せるはず', function($test) use ($make): void { // 色類とビット深さ(IHDRの25・24バイト目) foreach (['two' => [3, 1], 'sixteen' => [3, 4], 'rgb' => [2, 8], 'rgba' => [6, 8]] as $kind => [$ctype, $depth]) { // 256色以下はパレットになるので、RGBは大きくする $b = str_starts_with($kind, 'rgb') ? $make($kind, 40, 20) : $make($kind); $png = (new Png())->encode($b); $test->assertEquals($ctype, ord($png[25]), "{$kind}の色類"); $test->assertEquals($depth, ord($png[24]), "{$kind}のビット深さ"); $test->assertEquals($b->data, (new Png())->decode($png)->data, "{$kind}の画素"); } // 257色以上の灰色はグレースケール $g = new Bitmap(300, 1); for ($x = 0; $x < 300; $x++) $g->set($x, 0, $x % 256, $x % 256, $x % 256); $g->set(299, 0, 1, 1, 1, 0); $test->assertEquals(4, ord((new Png())->encode($g)[25])); }); $test->it('他のソフトが書いたPNG(Adam7・16ビット・複数のIDAT、2ビットの灰色とtRNS)を読めるはず', function($test): void { // Pythonで書いた画像と、仕様通りに計算した画素 $a = (new Png())->decode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAUAAAADEAYAAAF8oSktAAAARUlEQVR42gF/AID/ATkADM6M0H1iBNbLcN3l/o5vBNgGEEUPQS/4AZYbuShicyOc4RV6jmAsa7Y+tjiOxhKWPAJySEdxNHosg2/8MHdoAAAARUlEQVTxdx8NlmXOBKUuLT5gsPxX0TRDWr8dWK4DA1NRvthgrjWNHCc8Ag1VPO0yCC+LZ9tZnaKZ3CYt/v2Q0+fcfpgdOR3QNgufp1a3AAAAAElFTkSuQmCC')); $test->assertEquals('390c8c7d7247342cd8100f2f6f770d65d670e58e0351d8ae8e4f6eac342fc231b7b08716eb3fc12896b96223177494287733c28ee8ba53bdb56b8824', bin2hex($a->data)); $b = (new Png())->decode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAUAAAADAgAAAAA07YKFAAAAAnRSTlMAApidrBQAAAAISURBVHjaYwnPZApswiXMeQAAAAlJREFUYGo7AAALNwLgcgWJwgAAAABJRU5ErkJggg==')); $test->assertEquals('555555ff555555ff555555ffffffffffffffffffaaaaaa00aaaaaa00aaaaaa00000000ff555555ff000000ffaaaaaa00ffffffffaaaaaa00000000ff', bin2hex($b->data)); }); $test->it('壊れたPNGは例外のはず', function($test) use ($make): void { $png = (new Png())->encode($make('rgb')); $bad = $png; $bad[40] = chr(ord($bad[40]) ^ 1); // IDATの中(CRCが合わなくなる) $test->assertThrows(fn() => (new Png())->decode($bad), \RuntimeException::class); $test->assertThrows(fn() => (new Png())->decode(substr($png, 0, 60)), \RuntimeException::class); $test->assertThrows(fn() => (new Png())->decode('GIF89a'), \RuntimeException::class); }); $test->it('APNGのフレーム・遅延・繰り返しを読み書き出来るはず', function($test): void { $a = Bitmap::fill(4, 3, 255, 0, 0); $a->frames = [ ['bitmap' => Bitmap::fill(4, 3, 255, 0, 0), 'delay' => 100], ['bitmap' => Bitmap::fill(4, 3, 0, 0, 255, 128), 'delay' => 250], ]; $a->loops = 3; $b = (new Png())->decode((new Png())->encode($a)); $test->assertCount(2, $b->frames); $test->assertEquals([100, 250], array_column($b->frames, 'delay')); $test->assertEquals(3, $b->loops); $test->assertEquals([0, 0, 255, 128], $b->frames[1]['bitmap']->get(2, 1)); }); $test->it('アルファで重ねられるはず', function($test): void { $bg = Bitmap::fill(2, 1, 255, 0, 0); $bg->draw(Bitmap::fill(1, 1, 0, 0, 255, 128), 1, 0); $test->assertEquals([255, 0, 0, 255], $bg->get(0, 0)); $test->assertEquals([127, 0, 128, 255], $bg->get(1, 0)); }); $test->it('BMPを読み書き出来るはず(パレット、24ビット、アルファ付き32ビット、RLE4)', function($test) use ($make): void { foreach (['two' => 1, 'sixteen' => 4, 'rgb' => 24, 'rgba' => 32] as $kind => $bits) { $b = str_starts_with($kind, 'rgb') ? $make($kind, 40, 20) : $make($kind); $bmp = (new Bmp())->encode($b); $test->assertEquals($bits, unpack('v', substr($bmp, 28, 2))[1], "{$kind}のビット深さ"); $test->assertEquals($b->data, (new Bmp())->decode($bmp)->data, "{$kind}の画素"); } // RLE4(繰り返し、そのままの画素、デルタ、行と画像の終わり)。飛ばした画素は透明 $r = (new Bmp())->decode(base64_decode('Qk2MAAAAAAAAAHYAAAAoAAAABwAAAAQAAAABAAQAAgAAABYAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAA/wAA/wAA/wAAAEBAQABQUFAAYGBgAHBwcACAgIAAkJCQAKCgoACwsLAAwMDAANDQ0ADg4OAA8PDwAAcSAAAABTEjQAACVQAAAAIDAQT/AAE=')); $test->assertEquals([0, 0, 0, 0], $r->get(0, 0)); $test->assertEquals([240, 240, 240, 255], $r->get(3, 0)); $test->assertEquals([0, 0, 0, 0], $r->get(3, 1)); $test->assertEquals([0, 0, 255, 255], $r->get(0, 2)); $test->assertEquals([80, 80, 80, 255], $r->get(6, 2)); $test->assertEquals([0, 255, 0, 255], $r->get(1, 3)); }); $test->it('TGAを読み書き出来るはず(左下の原点、行を跨ぐRLE)', function($test) use ($make): void { foreach (['rgb', 'rgba'] as $kind) { $b = $make($kind); $test->assertEquals($b->data, (new Targa())->decode((new Targa())->encode($b))->data, "{$kind}(RLE)"); $test->assertEquals($b->data, (new Targa())->decode((new Targa())->encode($b, ['rle' => false]))->data, "{$kind}(無圧縮)"); } $t = (new Targa())->decode(base64_decode('AAACAAAAAAAAAAAAAwACABgA////AAAAAP//AAD/AP8A/wAA')); $test->assertEquals('ff0000ff00ff00ff0000ffffffffffff000000ffffff00ff', bin2hex($t->data)); $t = (new Targa())->decode(base64_decode('AAAKAAAAAAAAAAAAAwACACAoAQAA//8A/wCAgv8AAAAAAP///w==')); $test->assertEquals('ff0000ff00ff00800000ff000000ff000000ff00ffff00ff', bin2hex($t->data)); }); $test->it('GIFを読み書き出来るはず(パレット、透明、256色を超える減色)', function($test) use ($make): void { foreach (['two', 'sixteen', 'gray'] as $kind) { $b = $make($kind); $test->assertEquals($b->data, (new Gif())->decode((new Gif())->encode($b))->data, $kind); } // 透明はアルファ128未満、GIFに半透明は無い $t = $make('two'); $t->set(1, 1, 9, 9, 9, 0); $t->set(2, 1, 9, 9, 9, 200); $d = (new Gif())->decode((new Gif())->encode($t)); $test->assertEquals(0, $d->get(1, 1)[3]); $test->assertEquals([9, 9, 9, 255], $d->get(2, 1)); // 減色:大きさと大体の色は保つ $rgb = $make('rgb', 40, 20); $q = (new Gif())->decode((new Gif())->encode($rgb, ['dither' => true])); $diff = 0; for ($i = 0; $i < strlen($rgb->data); $i++) $diff += abs(ord($rgb->data[$i]) - ord($q->data[$i])); $test->assertTrue($diff / strlen($rgb->data) < 16, '平均の差'); }); $test->it('他のソフトが書いたGIF(インターレース、オフセットと廃棄方法のあるアニメーション)を読めるはず', function($test): void { // Pillowが書いたインターレースの3x9:真ん中の列は3行ずらした色 $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]]; $i = (new Gif())->decode(base64_decode('R0lGODdhAwAJAIMAAP//////AICAgAD//wD/AP8A//8AAAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAAwAJAAAIIQANBDBAYACBAwUOBAAQYACCAQUEFABgAAACAggEHBAQEAA7')); for ($y = 0; $y < 9; $y++) { $test->assertEquals([...$cols[$y], 255], $i->get(0, $y), "{$y}行目"); $test->assertEquals([...$cols[($y + 3) % 9], 255], $i->get(1, $y), "{$y}行目の真ん中"); } // ImageMagickが書いた6x5:紺の背景、赤(前に戻す)、黄(背景に戻す)、緑。画素はChromiumと同じ $a = (new Gif())->decode(base64_decode('R0lGODlhBgAFAPAAAAAAgAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQECgAAACH/C0ltYWdlTWFnaWNrDmdhbW1hPTAuNDU0NTQ1ACwAAAAABgAFAAACBYSPqWsFACH5BAwUAAAAIf8LSW1hZ2VNYWdpY2sOZ2FtbWE9MC40NTQ1NDUALAEAAQACAAIAgP8AAAAAAAIChFEAIfkECB4AAAAh/wtJbWFnZU1hZ2ljaw5nYW1tYT0wLjQ1NDU0NQAsAwACAAIAAgCA//8AAAAAAgKEUQAh+QQEBQAAACH/C0ltYWdlTWFnaWNrDmdhbW1hPTAuNDU0NTQ1ACwAAAMAAgACAIAA/wAAAAACAoRRADs=')); $test->assertCount(4, $a->frames); $test->assertEquals([100, 200, 300, 50], array_column($a->frames, 'delay')); $test->assertEquals(0, $a->loops); $f = array_column($a->frames, 'bitmap'); $test->assertEquals([255, 0, 0, 255], $f[1]->get(1, 1)); $test->assertEquals([0, 0, 128, 255], $f[2]->get(1, 1), '赤は前に戻した'); $test->assertEquals([255, 255, 0, 255], $f[2]->get(3, 2)); $test->assertEquals([0, 0, 0, 0], $f[3]->get(3, 2), '黄は背景(透明)に戻した'); $test->assertEquals([0, 255, 0, 255], $f[3]->get(0, 3)); }); $test->it('GIFのアニメーションを書けるはず', function($test): void { $a = Bitmap::fill(5, 4, 255, 0, 0); $a->frames = [ ['bitmap' => Bitmap::fill(5, 4, 255, 0, 0), 'delay' => 100], ['bitmap' => Bitmap::fill(5, 4, 0, 0, 255), 'delay' => 250], ]; $a->loops = 0; $b = (new Gif())->decode((new Gif())->encode($a)); $test->assertEquals([100, 250], array_column($b->frames, 'delay')); $test->assertEquals(0, $b->loops); $test->assertEquals([0, 0, 255, 255], $b->frames[1]['bitmap']->get(4, 3)); }); $test->it('他のソフトが書いたJPEG(プログレッシブ+リスタート、CMYK、グレースケール、Exifの向き)を読めるはず', function($test): void { // Pillow(libjpeg-turbo)で書いて、libjpeg-turboで読んだ画素(逆DCTの計算の違いで±3まで) $cases = [ '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], '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], 'gray' => ['/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/wAALCAAHAAkBAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/AOA/ZH/4I/aAfsv/ABLrTt/GtfW3/Dn3Qv8AoH2n/fa1/9k=', '000000ff0a0a0aff141414ff1e1e1eff282828ff323232ff3d3d3dff464646ff515151ff161616ff202020ff2b2b2bff353535ff3e3e3eff484848ff535353ff5d5d5dff676767ff2e2e2eff373737ff424242ff4c4c4cff565656ff5f5f5fff6a6a6aff747474ff7e7e7eff454545ff4f4f4fff5a5a5aff646464ff6d6d6dff777777ff828282ff8c8c8cff969696ff5b5b5bff646464ff6f6f6fff797979ff838383ff8d8d8dff989898ffa1a1a1ffabababff737373ff7c7c7cff878787ff919191ff9b9b9bffa5a5a5ffb0b0b0ffb9b9b9ffc4c4c4ff888888ff929292ff9d9d9dffa7a7a7ffb1b1b1ffbababaffc5c5c5ffcfcfcfffd9d9d9ff', 9, 7], '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], ]; foreach ($cases as $name => [$b64, $hex, $w, $h]) { $b = (new Jpeg())->decode(base64_decode($b64)); $test->assertEquals([$w, $h], [$b->width, $b->height], "{$name}の大きさ"); $exp = hex2bin($hex); $max = 0; for ($i = 0; $i < strlen($exp); $i++) $max = max($max, abs(ord($exp[$i]) - ord($b->data[$i]))); $test->assertTrue($max <= 3, "{$name}の画素(差{$max})"); } // 向き6(時計回りに90度):3x2が2x3になる $info = (new Jpeg())->decode(base64_decode($cases['o6'][0])); $test->assertEquals(2, $info->width); }); $test->it('JPEGを書けるはず(品質、4:2:0・4:4:4、グレースケール、透明は白)', function($test) use ($make): void { // 滑らかなグラデーション(画素毎に色が変わる画像は4:2:0で色差が失われるのが正しい) $b = new Bitmap(40, 20); for ($y = 0; $y < 20; $y++) for ($x = 0; $x < 40; $x++) $b->set($x, $y, $x * 6, $y * 12, ($x + $y) * 4); $hi = (new Jpeg())->encode($b, ['quality' => 95]); $lo = (new Jpeg())->encode($b, ['quality' => 30]); $test->assertTrue(strlen($lo) < strlen($hi), '品質が低いと小さい'); $err = function(Bitmap $a, Bitmap $c): float { $s = 0; for ($i = 0; $i < strlen($a->data); $i++) if ($i % 4 !== 3) $s += abs(ord($a->data[$i]) - ord($c->data[$i])); return $s / (strlen($a->data) * 3 / 4); }; $test->assertTrue($err($b, (new Jpeg())->decode($hi)) < 2, '品質95の平均の差'); $test->assertTrue($err($b, (new Jpeg())->decode((new Jpeg())->encode($b, ['quality' => 95, 'subsampling' => '444']))) < 1.5, '4:4:4の平均の差'); // 灰色はグレースケール(SOF0の成分の数が1) $g = (new Jpeg())->encode($make('gray')); $test->assertEquals(1, ord($g[strpos($g, "\xff\xc0") + 9])); // 透明は白と合成 $t = Bitmap::fill(16, 16, 0, 0, 0, 0); $test->assertTrue(min((new Jpeg())->decode((new Jpeg())->encode($t))->get(8, 8)) >= 250); }); $test->it('libwebpが書いたWebP(非可逆、可逆、アルファ、アニメーション)を読めるはず', function($test): void { // Pillow(libwebp 1.6)で書いて、libwebpで読んだ画素のMD5(計算はlibwebpと全く同じなので一致する) $cases = [ 'ly' => ['UklGRqYAAABXRUJQVlA4IJoAAADQBACdASoYABQAPrVOoEqnJCMhsBgIAOAWiUAWHYHlTnwr36n6ZUj8wDDu9+DCyAD+opZKvMHS233rVxNfgljnxvWSmFqCXbB3yF9HmOg8rtcoGdyLn5z6+4VxeQ1ImRbIwaoZ1v7erNwZKFXCzKxRsQdQwWWn+vJ3DfH+/zPcdEROIODEUzTDqVxI6LKvZMfVtHqlKUQ+AYQA', 24, 20, ['3d2d40581e10cc86445dd4d4f2c935c4']], 'll' => ['UklGRkwBAABXRUJQVlA4TEABAAAvE8ADAM2VIaL/MQAIwv+4hRjw/ocANSBo2zZmMP5wfxQpxEaSFEkFc0z+O3nPuDNVTiLJdq2pavRQ+Pyjh34QkBQk/z6Og1CbtgEDnpxyCOWacx4555JbuTfncuo9zT2Xcu86ykUAiVREGAJKIGa5DrM4IpgiCSIQQKkAOo4F+WEPUkgxGzs2CAjCLOA4szH4htnzjwBmMijB1IC6DjCYlLcQqIgS6APQYTKxNC+IM6HQDCkBUKcxkcWB/HAARI0phCdxHQJMdqxQxDcGiGAWXYg80NOICGjMwnwAzpDiVS8CqdMIsCCDaQFmcYKr7c4AejOmvo49kAX4XRwuTCALOY6qQQApoFD5MSYChxEMECvmnx1BhJzoYfEGKSDHZQwmJiAEFBGACEOeiOPB/+MIZmOABBFjtJpvNBac/X8cAA==', 20, 16, ['d815286545f188d51ae2c856a6f5082c']], 'lya' => ['UklGRmgAAABXRUJQVlA4WAoAAAAQAAAADwAACwAAQUxQSCQAAAABR6CQbQTI3/aO4IeIiBTxMMxGUhmWZVmW5fzfBxDR/4Bb8xdWUDggHgAAAHABAJ0BKhAADAACwEwllAJ0AUAAAP72JivN6Z4AAA==', 16, 12, ['34aea5dccb0104704648d56751219bbc']], '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']], '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']], ]; foreach ($cases as $name => [$b64, $w, $h, $md5]) { $b = (new Webp())->decode(base64_decode($b64)); $test->assertEquals([$w, $h], [$b->width, $b->height], "{$name}の大きさ"); $frames = $b->frames ? array_column($b->frames, 'bitmap') : [$b]; $test->assertEquals($md5, array_map(fn($f) => md5($f->data), $frames), "{$name}の画素"); } $an = (new Webp())->decode(base64_decode($cases['an'][0])); $test->assertEquals([40, 50, 60], array_column($an->frames, 'delay')); $test->assertEquals(2, $an->loops); }); $test->it('WebPを書けるはず(可逆はそのまま、非可逆は品質、アルファは正確、アニメーション)', function($test) use ($make): void { foreach (['two', 'sixteen', 'gray', 'rgba'] as $kind) { $b = $kind === 'rgba' ? $make($kind, 40, 20) : $make($kind); $ll = (new Webp())->encode($b, ['lossless' => true]); $test->assertEquals('VP8L', substr($ll, 12, 4), "{$kind}は可逆"); $test->assertEquals($b->data, (new Webp())->decode($ll)->data, "{$kind}の画素"); } $g = new Bitmap(40, 20); for ($y = 0; $y < 20; $y++) for ($x = 0; $x < 40; $x++) $g->set($x, $y, $x * 6, $y * 12, ($x + $y) * 4); $hi = (new Webp())->encode($g, ['quality' => 95]); $lo = (new Webp())->encode($g, ['quality' => 30]); $test->assertTrue(strlen($lo) < strlen($hi), '品質が低いと小さい'); $test->assertEquals('VP8 ', substr($hi, 12, 4), '不透明ならVP8だけ'); $err = 0; $back = (new Webp())->decode($hi); for ($i = 0; $i < strlen($g->data); $i++) if ($i % 4 !== 3) $err += abs(ord($g->data[$i]) - ord($back->data[$i])); $test->assertTrue($err / (40 * 20 * 3) < 2, '品質95の平均の差'); // アルファがあればVP8XとALPH(アルファは可逆) $t = $make('rgba', 40, 20); $ly = (new Webp())->encode($t, ['quality' => 50]); $test->assertEquals('VP8X', substr($ly, 12, 4)); $back = (new Webp())->decode($ly); $alpha = fn(string $d) => implode('', array_map(fn($i) => $d[$i], range(3, strlen($d) - 1, 4))); $test->assertEquals($alpha($t->data), $alpha($back->data), 'アルファ'); // アニメーション $a = Bitmap::fill(5, 4, 255, 0, 0); $a->frames = [ ['bitmap' => Bitmap::fill(5, 4, 255, 0, 0), 'delay' => 100], ['bitmap' => Bitmap::fill(5, 4, 0, 0, 255, 128), 'delay' => 250], ]; $a->loops = 3; foreach ([true, false] as $lossless) { $b = (new Webp())->decode((new Webp())->encode($a, ['lossless' => $lossless])); $test->assertEquals([100, 250], array_column($b->frames, 'delay')); $test->assertEquals(3, $b->loops); $test->assertEquals(128, $b->frames[1]['bitmap']->get(2, 2)[3]); if ($lossless) $test->assertEquals([0, 0, 255, 128], $b->frames[1]['bitmap']->get(4, 3)); } }); $test->it('壊れたWebPは例外のはず', function($test): void { $ly = base64_decode('UklGRqYAAABXRUJQVlA4IJoAAADQBACdASoYABQAPrVOoEqnJCMhsBgIAOAWiUAWHYHlTnwr36n6ZUj8wDDu9+DCyAD+opZKvMHS233rVxNfgljnxvWSmFqCXbB3yF9HmOg8rtcoGdyLn5z6+4VxeQ1ImRbIwaoZ1v7erNwZKFXCzKxRsQdQwWWn+vJ3DfH+/zPcdEROIODEUzTDqVxI6LKvZMfVtHqlKUQ+AYQA'); $short = substr($ly, 0, 120); $short = substr_replace(substr_replace($short, pack('V', strlen($short) - 8), 4, 4), pack('V', strlen($short) - 20), 16, 4); $test->assertThrows(fn() => (new Webp())->decode($short), \RuntimeException::class); $test->assertThrows(fn() => (new Webp())->decode('RIFF'.pack('V', 17).'WEBPVP8L'.pack('V', 5)."\x2e\0\0\0\0"), \RuntimeException::class); $test->assertThrows(fn() => (new Webp())->decode('RIFF'.pack('V', 4).'WEBP'), \RuntimeException::class); }); $test->it('未対応のJPEG(算術符号)は例外のはず', function($test): void { $sof9 = "\xff\xd8\xff\xc9".pack('n', 11)."\x08".pack('nn', 8, 8)."\x01\x01\x11\x00\xff\xd9"; $test->assertThrows(fn() => (new Jpeg())->decode($sof9), \RuntimeException::class); }); $test->it('中身で形式を判断して読めるはず', function($test) use ($make): void { $b = $make('rgba'); foreach (['image/png', 'image/bmp', 'image/gif'] as $type) { $data = Image::encode($b, $type); $test->assertEquals($type, Image::detect($data)); if ($type !== 'image/gif') $test->assertEquals($b->data, Image::decode($data)->data); } $test->assertEquals('image/webp', Image::detect('RIFF'.pack('V', 100).'WEBPVP8L')); $test->assertEquals($b->data, Image::decode(Image::encode($b, 'image/webp', ['lossless' => true]))->data); $test->assertEquals('image/x-tga', Image::detect("\0\0\2", 'a.TGA')); $test->assertNull(Image::detect('これは画像ではない')); $test->assertThrows(fn() => Image::decode('これは画像ではない'), \RuntimeException::class); }); $test->it('大き過ぎる画像は読まないはず', function($test): void { $huge = "\x89PNG\r\n\x1a\n".pack('N', 13).'IHDR'.pack('NNCCCCC', 60000, 60000, 8, 6, 0, 0, 0); $huge .= pack('N', crc32(substr($huge, 12, 17))); $test->assertThrows(fn() => (new Png())->decode($huge), \RuntimeException::class); }); }); foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f); @rmdir($dir); $test->printSummary();