| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Image; |
| 39 | |
| 40 | /** |
| 41 | * WebP(RIFFの入れ物) |
| 42 | * |
| 43 | * 読み込み:非可逆(VP8)、可逆(VP8L)、拡張形式(VP8X)のアルファ(ALPH、無圧縮・可逆、全てのフィルター)、 |
| 44 | * アニメーション(ANIM・ANMF、合成方法と廃棄方法、繰り返し回数)。 |
| 45 | * 書き出し:既定は非可逆(quality、アルファがあればALPHを可逆圧縮)、lossless => trueなら可逆。framesがあればアニメーション。 |
| 46 | */ |
| 47 | class Webp implements ImageInterface { |
| 48 | public function parse(string $file): \stdClass { |
| 49 | $d = (string)file_get_contents($file); |
| 50 | $chunks = self::chunks($d); |
| 51 | $info = (object)['width' => 0, 'height' => 0, 'compression' => null, 'hasAlpha' => false, 'animated' => false, 'frames' => 1, 'loops' => 0, 'icc' => false, 'exif' => false, 'xmp' => false]; |
| 52 | $image = null; |
| 53 | foreach ($chunks as [$id, $body]) { |
| 54 | if ($id === 'VP8X') { |
| 55 | if (strlen($body) < 10) throw new \RuntimeException('VP8Xチャンクが短過ぎます。'); |
| 56 | $f = ord($body[0]); |
| 57 | $info->width = 1 + self::u24($body, 4); |
| 58 | $info->height = 1 + self::u24($body, 7); |
| 59 | $info->hasAlpha = (bool)($f & 0x10); |
| 60 | $info->animated = (bool)($f & 0x02); |
| 61 | $info->icc = (bool)($f & 0x20); |
| 62 | $info->exif = (bool)($f & 0x08); |
| 63 | $info->xmp = (bool)($f & 0x04); |
| 64 | } else if ($id === 'ANIM' && strlen($body) >= 6) { |
| 65 | $info->loops = unpack('v', substr($body, 4, 2))[1]; |
| 66 | } else if ($id === 'ANMF') { |
| 67 | $image ??= self::chunks($body, 16)[0] ?? null; |
| 68 | } else if ($id === 'VP8 ' || $id === 'VP8L') { |
| 69 | $image ??= [$id, $body]; |
| 70 | } |
| 71 | } |
| 72 | if ($info->animated) $info->frames = count(array_filter($chunks, fn($c) => $c[0] === 'ANMF')); |
| 73 | if ($image !== null) { |
| 74 | [$id, $body] = $image; |
| 75 | $info->compression = $id === 'VP8L' ? 'VP8L' : 'VP8'; |
| 76 | if ($chunks[0][0] !== 'VP8X') { |
| 77 | if ($id === 'VP8L' && strlen($body) >= 5) { |
| 78 | $b = unpack('V', substr($body, 1, 4))[1]; |
| 79 | $info->width = ($b & 0x3fff) + 1; |
| 80 | $info->height = (($b >> 14) & 0x3fff) + 1; |
| 81 | $info->hasAlpha = (bool)(($b >> 28) & 1); |
| 82 | } else if ($id === 'VP8 ' && strlen($body) >= 10) { |
| 83 | $info->width = unpack('v', substr($body, 6, 2))[1] & 0x3fff; |
| 84 | $info->height = unpack('v', substr($body, 8, 2))[1] & 0x3fff; |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | return $info; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * RIFFのチャンク |
| 93 | * |
| 94 | * @param int $pos 最初のチャンクの位置(ファイルなら12、ANMFの中なら16) |
| 95 | * @return array [[ID, 中身], ...] |
| 96 | */ |
| 97 | private static function chunks(string $d, int $pos = 12): array { |
| 98 | $end = strlen($d); |
| 99 | if ($pos === 12) { |
| 100 | if (strlen($d) < 12 || !str_starts_with($d, 'RIFF') || substr($d, 8, 4) !== 'WEBP') throw new \RuntimeException('WebPではありません。'); |
| 101 | $end = min($end, 8 + unpack('V', substr($d, 4, 4))[1]); |
| 102 | } |
| 103 | $out = []; |
| 104 | while ($pos + 8 <= $end) { |
| 105 | $id = substr($d, $pos, 4); |
| 106 | $size = unpack('V', substr($d, $pos + 4, 4))[1]; |
| 107 | if ($pos + 8 + $size > $end) throw new \RuntimeException("WebPの{$id}チャンクが途中で切れています。"); |
| 108 | $out[] = [$id, substr($d, $pos + 8, $size)]; |
| 109 | $pos += 8 + $size + ($size & 1); |
| 110 | } |
| 111 | return $out; |
| 112 | } |
| 113 | |
| 114 | private static function u24(string $s, int $o): int { |
| 115 | return ord($s[$o]) | (ord($s[$o + 1]) << 8) | (ord($s[$o + 2]) << 16); |
| 116 | } |
| 117 | |
| 118 | public function decode(string $data): Bitmap { |
| 119 | $chunks = self::chunks($data); |
| 120 | $first = $chunks[0][0] ?? ''; |
| 121 | if ($first === 'VP8 ' || $first === 'VP8L') return self::still($chunks); |
| 122 | if ($first !== 'VP8X' || strlen($chunks[0][1]) < 10) throw new \RuntimeException('WebPの画像がありません。'); |
| 123 | $body = $chunks[0][1]; |
| 124 | $cw = 1 + self::u24($body, 4); |
| 125 | $ch = 1 + self::u24($body, 7); |
| 126 | Bitmap::check($cw, $ch); |
| 127 | if (!(ord($body[0]) & 0x02)) return self::still(array_slice($chunks, 1), $cw, $ch); |
| 128 | |
| 129 | // アニメーション:透明な画面にフレームを順に重ねる(背景色はブラウザと同じく使わない) |
| 130 | $loops = 0; |
| 131 | $canvas = new Bitmap($cw, $ch); |
| 132 | $img = null; |
| 133 | $dispose = null; |
| 134 | foreach ($chunks as [$id, $body]) { |
| 135 | if ($id === 'ANIM' && strlen($body) >= 6) $loops = unpack('v', substr($body, 4, 2))[1]; |
| 136 | if ($id !== 'ANMF') continue; |
| 137 | if (strlen($body) < 16) throw new \RuntimeException('ANMFチャンクが短過ぎます。'); |
| 138 | $x = 2 * self::u24($body, 0); |
| 139 | $y = 2 * self::u24($body, 3); |
| 140 | $fw = 1 + self::u24($body, 6); |
| 141 | $fh = 1 + self::u24($body, 9); |
| 142 | $flags = ord($body[15]); |
| 143 | if ($x + $fw > $cw || $y + $fh > $ch) throw new \RuntimeException('WebPのフレームが画面からはみ出しています。'); |
| 144 | if ($dispose !== null) $canvas->clear(...$dispose); |
| 145 | $canvas->draw(self::still(self::chunks($body, 16), $fw, $fh), $x, $y, !($flags & 0x02)); |
| 146 | $img ??= $canvas->copy(); |
| 147 | $img->frames[] = ['bitmap' => $canvas->copy(), 'delay' => self::u24($body, 12)]; |
| 148 | $dispose = $flags & 0x01 ? [$x, $y, $fw, $fh] : null; |
| 149 | } |
| 150 | if ($img === null) throw new \RuntimeException('WebPのアニメーションにフレームがありません。'); |
| 151 | if (count($img->frames) < 2) $img->frames = []; |
| 152 | else $img->loops = $loops; |
| 153 | return $img; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * 1枚の画像(VP8L、又はALPHとVP8) |
| 158 | * |
| 159 | * @param int|null $w 期待する幅と丈(VP8XやANMFの大きさ) |
| 160 | */ |
| 161 | private static function still(array $chunks, ?int $w = null, ?int $h = null): Bitmap { |
| 162 | $alpha = null; |
| 163 | foreach ($chunks as [$id, $body]) { |
| 164 | if ($id === 'ALPH') { |
| 165 | $alpha ??= $body; |
| 166 | } else if ($id === 'VP8L') { |
| 167 | [$iw, $ih, $argb] = (new Vp8l())->decode($body); |
| 168 | $rgba = self::rgba($argb); |
| 169 | break; |
| 170 | } else if ($id === 'VP8 ') { |
| 171 | [$iw, $ih, $rgba] = (new Vp8())->decode($body); |
| 172 | if ($alpha !== null) $rgba = self::applyAlpha($rgba, $alpha, $iw, $ih); |
| 173 | break; |
| 174 | } |
| 175 | } |
| 176 | if (!isset($rgba)) throw new \RuntimeException('WebPの画像データがありません。'); |
| 177 | if ($w !== null && ($iw !== $w || $ih !== $h)) throw new \RuntimeException('WebPの画像の大きさが合いません。'); |
| 178 | return new Bitmap($iw, $ih, $rgba); |
| 179 | } |
| 180 | |
| 181 | /** ARGB(ビッグエンディアン)の列をRGBAにする */ |
| 182 | private static function rgba(string $argb): string { |
| 183 | $out = ''; |
| 184 | for ($o = 0, $n = strlen($argb); $o < $n; $o += 16384) { |
| 185 | $px = unpack('N*', substr($argb, $o, 16384)); |
| 186 | foreach ($px as $i => $v) $px[$i] = (($v << 8) & 0xffffffff) | ($v >> 24); |
| 187 | $out .= pack('N*', ...$px); |
| 188 | } |
| 189 | return $out; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * ALPHチャンクのアルファを画素に入れる |
| 194 | */ |
| 195 | private static function applyAlpha(string $rgba, string $alph, int $w, int $h): string { |
| 196 | if ($alph === '') throw new \RuntimeException('ALPHチャンクが空です。'); |
| 197 | $hdr = ord($alph[0]); |
| 198 | $n = $w * $h; |
| 199 | if (($hdr & 3) === 0) { |
| 200 | $a = substr($alph, 1, $n); |
| 201 | if (strlen($a) < $n) throw new \RuntimeException('ALPHチャンクが途中で切れています。'); |
| 202 | } else if (($hdr & 3) === 1) { |
| 203 | // 可逆圧縮:緑がアルファ |
| 204 | $argb = (new Vp8l())->decodeImage(substr($alph, 1), $w, $h); |
| 205 | $a = ''; |
| 206 | for ($i = 2, $len = strlen($argb); $i < $len; $i += 4) $a .= $argb[$i]; |
| 207 | } else { |
| 208 | throw new \RuntimeException('未対応のALPHの圧縮方法です。'); |
| 209 | } |
| 210 | $filter = ($hdr >> 2) & 3; |
| 211 | if ($filter) $a = self::unfilter($a, $w, $h, $filter); |
| 212 | for ($i = 0; $i < $n; $i++) $rgba[$i * 4 + 3] = $a[$i]; |
| 213 | return $rgba; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * アルファのフィルターを戻す(1:左、2:上、3:勾配。1行目は左、左端は上から) |
| 218 | */ |
| 219 | private static function unfilter(string $a, int $w, int $h, int $filter): string { |
| 220 | $out = ''; |
| 221 | $prev = null; |
| 222 | for ($y = 0; $y < $h; $y++) { |
| 223 | $row = array_values(unpack('C*', substr($a, $y * $w, $w))); |
| 224 | if ($prev === null) { |
| 225 | $left = 0; |
| 226 | for ($x = 0; $x < $w; $x++) $row[$x] = $left = ($row[$x] + $left) & 255; |
| 227 | } else if ($filter === 1) { |
| 228 | $left = $prev[0]; |
| 229 | for ($x = 0; $x < $w; $x++) $row[$x] = $left = ($row[$x] + $left) & 255; |
| 230 | } else if ($filter === 2) { |
| 231 | for ($x = 0; $x < $w; $x++) $row[$x] = ($row[$x] + $prev[$x]) & 255; |
| 232 | } else { |
| 233 | $left = $topLeft = $prev[0]; |
| 234 | for ($x = 0; $x < $w; $x++) { |
| 235 | $g = $left + $prev[$x] - $topLeft; |
| 236 | $left = $row[$x] = ($row[$x] + ($g < 0 ? 0 : ($g > 255 ? 255 : $g))) & 255; |
| 237 | $topLeft = $prev[$x]; |
| 238 | } |
| 239 | } |
| 240 | $out .= pack('C*', ...$row); |
| 241 | $prev = $row; |
| 242 | } |
| 243 | return $out; |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * @param array $opt lossless(既定false)、quality(非可逆、0~100、既定80)、filter・sharpness・partitions(非可逆の細かい設定) |
| 248 | */ |
| 249 | public function encode(Bitmap $img, array $opt = []): string { |
| 250 | $lossless = (bool)($opt['lossless'] ?? false); |
| 251 | if (!$lossless && ($img->width > 16383 || $img->height > 16383)) throw new \RuntimeException('非可逆のWebPは16383x16383までです。'); |
| 252 | if ($img->width > 16384 || $img->height > 16384) throw new \RuntimeException('WebPは16384x16384までです。'); |
| 253 | if (!$img->frames) { |
| 254 | $alpha = !$img->isOpaque(); |
| 255 | $body = self::image($img, $lossless, $alpha, $opt); |
| 256 | if ($lossless || !$alpha) return self::riff($body); |
| 257 | return self::riff(self::vp8x($img, 0x10).$body); |
| 258 | } |
| 259 | $alpha = false; |
| 260 | foreach ($img->frames as $f) $alpha = $alpha || !$f['bitmap']->isOpaque(); |
| 261 | $out = self::vp8x($img, 0x02 | ($alpha ? 0x10 : 0)); |
| 262 | // 背景色(白、BGRA)と繰り返し回数 |
| 263 | $out .= self::chunk('ANIM', "\xff\xff\xff\xff".pack('v', min(65535, $img->loops))); |
| 264 | foreach ($img->frames as $f) { |
| 265 | $b = $f['bitmap']; |
| 266 | if ($b->width !== $img->width || $b->height !== $img->height) throw new \InvalidArgumentException('フレームの大きさが画像と違います。'); |
| 267 | // 毎回画面全体を置き換える(合成しない、廃棄しない) |
| 268 | $hdr = "\0\0\0\0\0\0".substr(pack('V', $b->width - 1), 0, 3).substr(pack('V', $b->height - 1), 0, 3).substr(pack('V', max(0, min(0xffffff, (int)$f['delay']))), 0, 3)."\x02"; |
| 269 | $out .= self::chunk('ANMF', $hdr.self::image($b, $lossless, !$b->isOpaque(), $opt)); |
| 270 | } |
| 271 | return self::riff($out); |
| 272 | } |
| 273 | |
| 274 | /** 1枚の画像のチャンク(VP8L、VP8、又はALPHとVP8) */ |
| 275 | private static function image(Bitmap $b, bool $lossless, bool $alpha, array $opt): string { |
| 276 | if ($lossless) { |
| 277 | $px = []; |
| 278 | foreach (unpack('N*', $b->data) as $v) $px[] = (($v & 255) << 24) | ($v >> 8); |
| 279 | return self::chunk('VP8L', (new Vp8l())->encode($px, $b->width, $b->height, $alpha)); |
| 280 | } |
| 281 | $out = ''; |
| 282 | if ($alpha) { |
| 283 | $a = []; |
| 284 | foreach (unpack('N*', $b->data) as $v) $a[] = ($v & 255) << 8; |
| 285 | // 可逆圧縮、フィルター無し |
| 286 | $out .= self::chunk('ALPH', "\x01".(new Vp8l())->encodeImage($a, $b->width, $b->height)); |
| 287 | } |
| 288 | return $out.self::chunk('VP8 ', (new Vp8())->encode($b->data, $b->width, $b->height, $opt)); |
| 289 | } |
| 290 | |
| 291 | private static function vp8x(Bitmap $img, int $flags): string { |
| 292 | return self::chunk('VP8X', chr($flags)."\0\0\0".substr(pack('V', $img->width - 1), 0, 3).substr(pack('V', $img->height - 1), 0, 3)); |
| 293 | } |
| 294 | |
| 295 | private static function chunk(string $id, string $body): string { |
| 296 | return $id.pack('V', strlen($body)).$body.(strlen($body) & 1 ? "\0" : ''); |
| 297 | } |
| 298 | |
| 299 | private static function riff(string $body): string { |
| 300 | return 'RIFF'.pack('V', 4 + strlen($body)).'WEBP'.$body; |
| 301 | } |
| 302 | } |