'UNCOMPRESSED COLOR-MAPPED IMAGE', 2 => 'UNCOMPRESSED TRUE COLOR IMAGE', 3 => 'UNCOMPRESSED GRAYSCALE IMAGE', 9 => 'RUN-LENGTH ENCODED COLOR-MAPPED IMAGE', 10 => 'RUN-LENGTH ENCODED TRUE-COLOR IMAGE', 11 => 'RUN-LENGTH ENCODED GRAYSCALE IMAGE', ]; private const FOOTER = "TRUEVISION-XFILE.\0"; public function parse(string $file): \stdClass { $d = (string)file_get_contents($file); $h = $this->header($d); return (object)[ 'width' => $h['width'], 'height' => $h['height'], 'imageTypeByte' => $h['type'], 'imageType' => self::TYPES[$h['type']], 'bitDepth' => $h['depth'], 'colorMapType' => $h['cmType'], 'colorMapLength' => $h['cmLength'], 'colorMapBits' => $h['cmBits'], 'x' => $h['x'], 'y' => $h['y'], 'isTopToBottom' => $h['top'], 'isRightToLeft' => $h['right'], 'alphaBits' => $h['alphaBits'], 'hasAlpha' => $h['alpha'], 'imageId' => $h['id'], 'version' => $h['v2'] ? 2 : 1, ]; } /** * ヘッダー(とTGA 2.0のフッター・拡張領域)を読む */ private function header(string $d): array { if (strlen($d) < 18) throw new \RuntimeException('TGAではありません。'); $h = unpack('CidLen/CcmType/Ctype/vcmFirst/vcmLength/CcmBits/vx/vy/vwidth/vheight/Cdepth/Cdesc', $d); if (!isset(self::TYPES[$h['type']])) throw new \RuntimeException("未対応のTGAの種類です({$h['type']})。"); $okDepth = match ($h['type'] & 7) { 1 => in_array($h['depth'], [8, 16], true) && $h['cmType'] === 1, 2 => in_array($h['depth'], [15, 16, 24, 32], true), 3 => in_array($h['depth'], [8, 16], true), }; if (!$okDepth || ($h['cmType'] === 1 && !in_array($h['cmBits'], [15, 16, 24, 32], true))) { throw new \RuntimeException('TGAのビット深さが不正です。'); } Bitmap::check($h['width'], $h['height']); $alphaBits = $h['desc'] & 0x0f; // TGA 2.0:拡張領域の「属性の種類」(0 = アルファ無し、1・2 = 未定義、3 = アルファ、4 = 事前乗算したアルファ) $v2 = strlen($d) >= 44 && substr($d, -18) === self::FOOTER; $attr = null; if ($v2) { $ext = unpack('V', substr($d, -26, 4))[1]; if ($ext > 0 && $ext + 495 <= strlen($d) - 26 && unpack('v', substr($d, $ext, 2))[1] >= 495) $attr = ord($d[$ext + 494]); } $alpha = $alphaBits > 0 && ($attr === null || $attr >= 3); // パレットの画像は、パレットの項目のアルファを使う if (($h['type'] & 7) === 1) $alpha = $h['cmBits'] === 32 || ($h['cmBits'] === 16 && $alphaBits > 0); if (($h['type'] & 7) === 3 && $h['depth'] === 16) $alpha = true; return $h + [ 'id' => substr($d, 18, $h['idLen']), 'top' => ($h['desc'] & 0x20) !== 0, 'right' => ($h['desc'] & 0x10) !== 0, 'alphaBits' => $alphaBits, 'alpha' => $alpha, 'premultiplied' => $attr === 4, 'v2' => $v2, ]; } public function decode(string $data): Bitmap { $h = $this->header($data); $w = $h['width']; $ht = $h['height']; $kind = $h['type'] & 7; $pos = 18 + $h['idLen']; // パレット $lut = null; if ($h['cmType'] === 1) { $eb = ($h['cmBits'] + 7) >> 3; $cm = substr($data, $pos, $h['cmLength'] * $eb); $pos += $h['cmLength'] * $eb; if ($kind === 1) { $lut = []; $n = $h['depth'] === 16 ? 65536 : 256; $entries = str_split($cm, $eb); foreach ($entries as $i => $e) $lut[$h['cmFirst'] + $i] = $this->pixel($e, $h['cmBits'], $h['alpha']); } } $bpp = ($h['depth'] + 7) >> 3; $count = $w * $ht; if ($h['type'] >= 9) { $raw = $this->unrle($data, $pos, $count, $bpp); } else { $raw = substr($data, $pos, $count * $bpp); if (strlen($raw) < $count * $bpp) throw new \RuntimeException('TGAの画素データが足りません。'); } // RGBAにする if ($kind === 1) { $idx = $h['depth'] === 16 ? array_values(unpack('v*', $raw)) : array_values(unpack('C*', $raw)); $black = "\0\0\0".($h['alpha'] ? "\0" : "\xff"); $px = implode('', array_map(fn($i) => $lut[$i] ?? $black, $idx)); } else if ($kind === 3) { $px = $h['depth'] === 8 ? preg_replace('/(.)/s', '$1$1$1'."\xff", $raw) : preg_replace('/(.)(.)/s', '$1$1$1$2', $raw); } else if ($h['depth'] === 24) { $px = preg_replace('/(.)(.)(.)/s', '$3$2$1'."\xff", $raw); } else if ($h['depth'] === 32) { $px = $h['alpha'] ? preg_replace('/(.)(.)(.)(.)/s', '$3$2$1$4', $raw) : preg_replace('/(.)(.)(.)./s', '$3$2$1'."\xff", $raw); } else { $alpha = $h['alpha'] && $h['depth'] === 16; $px = implode('', array_map(fn($p) => $this->pixel($p, 16, $alpha), str_split($raw, 2))); } if ($h['premultiplied']) $px = $this->unpremultiply($px); // 向き(既定は左下が原点) $rows = str_split($px, $w * 4); if (!$h['top']) $rows = array_reverse($rows); if ($h['right']) $rows = array_map(fn($r) => implode('', array_reverse(str_split($r, 4))), $rows); return new Bitmap($w, $ht, implode('', $rows)); } /** * パレットの項目又は15・16ビットの画素をRGBAにする */ private function pixel(string $p, int $bits, bool $alpha): string { if ($bits === 24) return $p[2].$p[1].$p[0]."\xff"; if ($bits === 32) return $p[2].$p[1].$p[0].($alpha ? $p[3] : "\xff"); // A1R5G5B5(リトルエンディアン) $v = ord($p[0]) | (ord($p[1] ?? "\0") << 8); $c = fn($x) => chr(($x << 3) | ($x >> 2)); $a = $bits === 16 && $alpha ? ($v & 0x8000 ? "\xff" : "\0") : "\xff"; return $c(($v >> 10) & 31).$c(($v >> 5) & 31).$c($v & 31).$a; } /** * RLEを展開する(パケットが行を跨いでも良い) */ private function unrle(string $d, int $pos, int $count, int $bpp): string { $out = ''; $n = 0; $len = strlen($d); while ($n < $count) { if ($pos >= $len) throw new \RuntimeException('TGAのRLEデータが足りません。'); $hdr = ord($d[$pos++]); $k = ($hdr & 0x7f) + 1; if ($hdr & 0x80) { $out .= str_repeat(substr($d, $pos, $bpp), $k); $pos += $bpp; } else { $out .= substr($d, $pos, $k * $bpp); $pos += $k * $bpp; } $n += $k; } return substr($out, 0, $count * $bpp); } private function unpremultiply(string $px): string { $len = strlen($px); for ($i = 0; $i < $len; $i += 4) { $a = ord($px[$i + 3]); if ($a === 0 || $a === 255) continue; for ($c = 0; $c < 3; $c++) $px[$i + $c] = chr(min(255, intdiv(ord($px[$i + $c]) * 255 + ($a >> 1), $a))); } return $px; } /** * 画素をTGAにする(RLEのトゥルーカラー、不透明なら24ビット、それ以外は32ビット、左上が原点、TGA 2.0のフッター付き) * * @param array $opt rle:false で圧縮しない */ public function encode(Bitmap $img, array $opt = []): string { $alpha = !$img->isOpaque(); $bpp = $alpha ? 4 : 3; $rle = $opt['rle'] ?? true; $raw = $alpha ? preg_replace('/(.)(.)(.)(.)/s', '$3$2$1$4', $img->data) : preg_replace('/(.)(.)(.)./s', '$3$2$1', $img->data); $header = pack('CCCvvCvvvvCC', 0, 0, $rle ? 10 : 2, 0, 0, 0, 0, 0, $img->width, $img->height, $bpp * 8, 0x20 | ($alpha ? 8 : 0)); $body = $rle ? $this->rle($raw, $img->width, $bpp) : $raw; return $header.$body.pack('VV', 0, 0).self::FOOTER; } /** * RLEにする(TGA 2.0の推奨通り、パケットは行を跨がない) */ private function rle(string $raw, int $w, int $bpp): string { $out = ''; foreach (str_split($raw, $w * $bpp) as $row) { $px = str_split($row, $bpp); $n = count($px); $i = 0; while ($i < $n) { // 同じ画素の繰り返し $j = $i + 1; while ($j < $n && $j - $i < 128 && $px[$j] === $px[$i]) $j++; if ($j - $i >= 2) { $out .= chr(0x80 | ($j - $i - 1)).$px[$i]; $i = $j; continue; } // 繰り返さない画素(次の繰り返しの前まで) $j = $i + 1; while ($j < $n && $j - $i < 128 && !($j + 1 < $n && $px[$j] === $px[$j + 1])) $j++; $out .= chr($j - $i - 1).implode('', array_slice($px, $i, $j - $i)); $i = $j; } } return $out; } }