IHDR = new \stdClass; $this->IHDR->name = 'IHDR'; $this->IHDR->length = $length; $this->IHDR->width = unpack('N', substr($data, 0, 4))[1]; $this->IHDR->height = unpack('N', substr($data, 4, 4))[1]; $this->IHDR->bitDepth = ord($data[8]); // 1, 2, 4, 8, 16 $this->IHDR->numColors = 1 << $this->IHDR->bitDepth; $this->IHDR->colorType = ord($data[9]); // 0, 2, 3, 4, 6 $this->IHDR->compressionType = ord($data[10]); $this->IHDR->compression = 'DEFLATE'; $this->IHDR->filter = ord($data[11]); $this->IHDR->interlace = ord($data[12]); // 0 = なし, 1 = Adam7 $this->IHDR->bpp = $this->IHDR->bitDepth * $this->getSamplesPerPixel($this->IHDR->colorType); $this->IHDR->hasAlpha = ($this->IHDR->colorType === 4 || $this->IHDR->colorType === 6); $crc = fread($fp, 4); $this->IHDR->crc = $crc; $this->IHDR->crc32 = unpack('N', $crc)[1]; $this->IHDR->crcHex = bin2hex($crc); $png->header = $this->IHDR; next_chunk: $lengthBytes = fread($fp, 4); if (strlen($lengthBytes) < 4) { logger(LogType::Image, 'バイト長さが悪い。ファイル:'.$file.'、バイト長さ:'.'$lengthBytes'); fclose($fp); return $png; } $length = unpack('N', $lengthBytes)[1]; $nextChunk = fread($fp, 4); $data = $length > 0 ? fread($fp, $length) : ''; $crc = fread($fp, 4); // PLTEデータ if ($nextChunk === "\x50\x4c\x54\x45") { // PLTE $palette = []; for ($i = 0; $i < $length; $i += 3) { $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2])); $palette[] = $colors->rgb; } $this->PLTE = new \stdClass; $this->PLTE->name = 'PLTE'; $this->PLTE->length = $length; $this->PLTE->palette = $palette; $this->PLTE->crc = $crc; $this->PLTE->crc32 = unpack('N', $crc)[1]; $this->PLTE->crcHex = bin2hex($crc); $png->palette = $this->PLTE; goto next_chunk; } else if ($nextChunk === "\x74\x52\x4e\x53") { // tRNS $this->tRNS = new \stdClass; $this->tRNS->name = 'tRNS'; $this->tRNS->length = $length; $vals = []; $bytes = $this->IHDR->colorType === 2 ? 3 : 1; for ($i = 0; $i < $length; $i += $bytes) { if ($this->IHDR->colorType === 2) { $colors = new \Std\Lib\RGB(ord($data[$i]), ord($data[$i+1]), ord($data[$i+2])); $vals[] = $colors->rgb; } else { $vals[] = ord($data[$i]); } } $this->tRNS->values = $vals; $this->tRNS->crc = $crc; $this->tRNS->crc32 = unpack('N', $crc)[1]; $this->tRNS->crcHex = bin2hex($crc); $png->transparency = $this->tRNS; goto next_chunk; } else if ($nextChunk === "\x63\x48\x52\x4d") { // cHRM $this->cHRM = new \stdClass; $this->cHRM->name = 'cHRM'; $this->cHRM->length = $length; $this->cHRM->whitePointX = unpack('N', substr($data, 0, 4))[1]; $this->cHRM->whitePointY = unpack('N', substr($data, 4, 4))[1]; $this->cHRM->redX = unpack('N', substr($data, 8, 4))[1]; $this->cHRM->redY = unpack('N', substr($data, 12, 4))[1]; $this->cHRM->greenX = unpack('N', substr($data, 16, 4))[1]; $this->cHRM->greenY = unpack('N', substr($data, 20, 4))[1]; $this->cHRM->blueX = unpack('N', substr($data, 24, 4))[1]; $this->cHRM->blueY = unpack('N', substr($data, 28, 4))[1]; $this->cHRM->crc = $crc; $this->cHRM->crc32 = unpack('N', $crc)[1]; $this->cHRM->crcHex = bin2hex($crc); $png->chromaticities = $this->cHRM; goto next_chunk; } else if ($nextChunk === "\x67\x41\x4d\x41") { // gAMA $this->gAMA = new \stdClass; $this->gAMA->name = 'gAMA'; $this->gAMA->length = $length; $this->gAMA->value = unpack('N', $data)[1]; $this->gAMA->crc = $crc; $this->gAMA->crc32 = unpack('N', $crc)[1]; $this->gAMA->crcHex = bin2hex($crc); $png->gamma = $this->gAMA; goto next_chunk; } else if ($nextChunk === "\x69\x43\x43\x50") { // iCCP $this->iCCP = new \stdClass; $this->iCCP->name = 'iCCP'; $this->iCCP->length = $length; $nullPos = strpos($data, "\0"); if ($nullPos === false || $nullPos < 1 || $nullPos > 79) { $this->iCCP->valid = false; $this->iCCP->error = "Missing or invalid profile name terminator"; goto iccp_crc; } $this->iCCP->profileName = substr($data, 0, $nullPos); $compressionMethod = ord($data[$nullPos + 1]); $this->iCCP->compressionMethod = $compressionMethod; if ($compressionMethod !== 0) { $this->iCCP->valid = false; $this->iCCP->error = "Unsupported compression method: {$compressionMethod}"; goto iccp_crc; } $compressedData = substr($data, $nullPos + 2); $this->iCCP->compressedData = $compressedData; $this->iCCP->compressedSize = strlen($compressedData); $this->iCCP->valid = true; iccp_crc: $this->iCCP->crc = $crc; $this->iCCP->crc32 = unpack('N', $crc)[1]; $this->iCCP->crcHex = bin2hex($crc); $png->iccProfile = $this->iCCP; goto next_chunk; } else if ($nextChunk === "\x73\x42\x49\x54") { // sBIT $this->sBIT = new \stdClass; $this->sBIT->name = 'sBIT'; $this->sBIT->length = $length; kys(['sBIT', $length, bin2hex($data)]); $this->sBIT->crc = $crc; $this->sBIT->crc32 = unpack('N', $crc)[1]; $this->sBIT->crcHex = bin2hex($crc); $png->significantBits = $this->sBIT; goto next_chunk; } else if ($nextChunk === "\x73\x52\x47\x42") { // sRGB $this->sRGB = new \stdClass; $this->sRGB->name = 'sRGB'; $this->sRGB->length = $length; $this->sRGB->intent = ord($data); $name = [ 0 => 'Perceptual', 1 => 'Relative colorimetric', 2 => 'Saturation', 3 => 'Absolute colorimetric', ]; $this->sRGB->intentName = $name[$this->sRGB->intent]; $this->sRGB->crc = $crc; $this->sRGB->crc32 = unpack('N', $crc)[1]; $this->sRGB->crcHex = bin2hex($crc); $png->rgbColorSpace = $this->sRGB; goto next_chunk; } else if ($nextChunk === "\x63\x49\x43\x50") { // cICP $this->cICP = new \stdClass; $this->cICP->name = 'cICP'; $this->cICP->length = $length; $this->cICP->colorPrimaries = ord($data[0]); $this->cICP->transferFunction = ord($data[1]); $this->cICP->matrixCoefficients = ord($data[2]); $this->cICP->videoFullRangeFlag = ord($data[3]); $this->cICP->crc = $crc; $this->cICP->crc32 = unpack('N', $crc)[1]; $this->cICP->crcHex = bin2hex($crc); $png->videoSignalTypeId = $this->cICP; goto next_chunk; } else if ($nextChunk === "\x6d\x44\x43\x56") { // mDCV $this->mDCV = new \stdClass; $this->mDCV->name = 'mDCV'; $this->mDCV->length = $length; $u16 = fn($off) => unpack('n', substr($data, $off, 2))[1]; $u32 = fn($off) => unpack('N', substr($data, $off, 4))[1]; $this->mDCV->primaries = [ 'red' => ['x' => $u16(0), 'y' => $u16(2)], 'green' => ['x' => $u16(4), 'y' => $u16(6)], 'blue' => ['x' => $u16(8), 'y' => $u16(10)], ]; $this->mDCV->whitePoint = [ 'x' => $u16(12), 'y' => $u16(14), ]; $this->mDCV->luminance = [ 'max' => $u16(16) * 0.0001, 'min' => $u16(20) * 0.0001, 'maxRaw' => $u16(16), 'minRaw' => $u16(20), ]; $this->mDCV->crc = $crc; $this->mDCV->crc32 = unpack('N', $crc)[1]; $this->mDCV->crcHex = bin2hex($crc); $png->masterDisplayColorVolume = $this->mDCV; goto next_chunk; } else if ($nextChunk === "\x63\x4c\x4c\x49") { // cLLI $this->cLLI = new \stdClass; $this->cLLI->name = 'cLLI'; $this->cLLI->length = $length; $this->cLLI->maxCCL = unpack('N', substr($data, 0, 4))[1] * 0.0001; $this->cLLI->maxCCLRaw = unpack('N', substr($data, 0, 4))[1]; $this->cLLI->maxFALL = unpack('N', substr($data, 4, 4))[1] * 0.0001; $this->cLLI->maxFALLRaw = unpack('N', substr($data, 4, 4))[1]; $this->cLLI->crc = $crc; $this->cLLI->crc32 = unpack('N', $crc)[1]; $this->cLLI->crcHex = bin2hex($crc); $png->contentLightLevelInfo = $this->cLLI; goto next_chunk; } else if ($nextChunk === "\x74\x45\x58\x74") { // tEXt if (!isset($png->textualData)) $png->textualData = []; $this->tEXt = new \stdClass; $this->tEXt->name = 'tEXt'; $this->tEXt->length = $length; $this->tEXt->text = $data; $this->tEXt->crc = $crc; $this->tEXt->crc32 = unpack('N', $crc)[1]; $this->tEXt->crcHex = bin2hex($crc); $png->textualData[] = $this->tEXt; goto next_chunk; } else if ($nextChunk === "\x69\x54\x58\x74") { // iTXt if (!isset($png->internationalTextualData)) $png->textualData = []; $this->iTXt = new \stdClass; $this->iTXt->name = 'iTXt'; $this->iTXt->length = $length; kys(['iTXt', $length, bin2hex($data)]); $this->iTXt->crc = $crc; $this->iTXt->crc32 = unpack('N', $crc)[1]; $this->iTXt->crcHex = bin2hex($crc); $png->internationalTextualData[] = $this->iTXt; kys($png); goto next_chunk; } else if ($nextChunk === "\x7a\x54\x58\x74") { // zTXt if (!isset($png->compressedTextualData)) $png->textualData = []; $this->zTXt = new \stdClass; $this->zTXt->name = 'zTXt'; $this->zTXt->length = $length; $nullPos = strpos($data, "\0"); if ($nullPos === false || $nullPos < 1 || $nullPos > 79) { $this->zTXt->valid = false; $this->zTXt->error = "Missing or invalid profile name terminator"; goto zTXt_crc; } $this->zTXt->keyword = rtrim(substr($data, 0, $nullPos)); $compressionMethod = ord($data[$nullPos + 1] ?? "\xFF"); $this->zTXt->compressionMethod = $compressionMethod; if ($compressionMethod !== 0) { $this->zTXt->valid = false; $this->zTXt->error = "Unsupported compression method"; goto zTXt_crc; } $compressedText = substr($data, $nullPos + 2); fread($fp, 13); // ???????????????????????? $this->zTXt->compressedText = $compressedText; $this->zTXt->compressedLength = strlen($compressedText); $text = zlib_decode($compressedText); if ($text === false) { $this->zTXt->valid = false; $this->zTXt->decompressionError = true; $this->zTXt->text = null; goto zTXt_crc; } else { $this->zTXt->text = $text; $this->zTXt->textUtf8 = mb_convert_encoding($text, 'UTF-8', 'ISO-8859-1'); } $this->zTXt->valid = true; zTXt_crc: $this->zTXt->crc = $crc; $this->zTXt->crc32 = unpack('N', $crc)[1]; $this->zTXt->crcHex = bin2hex($crc); $png->compressedTextualData[] = $this->zTXt; goto next_chunk; } else if ($nextChunk === "\x62\x4b\x47\x44") { // bKGD $this->bKGD = new \stdClass; $this->bKGD->name = 'bKGD'; $this->bKGD->length = $length; if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) { $r = (ord($data[0]) << 8) | ord($data[1]); $g = (ord($data[2]) << 8) | ord($data[3]); $b = (ord($data[4]) << 8) | ord($data[5]); $this->bKGD->type = 'rgb16'; $this->bKGD->r = $r; $this->bKGD->g = $g; $this->bKGD->b = $b; $this->bKGD->r8 = $r >> 8; $this->bKGD->g8 = $g >> 8; $this->bKGD->b8 = $b >> 8; $this->bKGD->webColor = sprintf("#%02x%02x%02x", $this->bKGD->r8, $this->bKGD->g8, $this->bKGD->b8); } else if ($this->IHDR->colorType === 2 || $this->IHDR->colorType === 6) { $gray = (ord($data[0]) << 8) | ord($data[1]); $this->bKGD->type = 'gray16'; $this->bKGD->gray = $gray; $this->bKGD->gray8 = $gray >> 8; } else if ($this->IHDR->colorType === 3) { $this->bKGD->type = 'palette_index'; $this->bKGD->value = ord($data); } else { $this->bKGD->type = 'unknown'; $this->bKGD->error = 'Invalid color type for bKGD'; } $this->bKGD->crc = $crc; $this->bKGD->crc32 = unpack('N', $crc)[1]; $this->bKGD->crcHex = bin2hex($crc); $png->backgroundColor = $this->bKGD; goto next_chunk; } else if ($nextChunk === "\x68\x49\x53\x54") { // hIST $this->hIST = new \stdClass; $this->hIST->name = 'hIST'; $this->hIST->length = $length; kys(['hIST', $length, bin2hex($data)]); $this->hIST->crc = $crc; $this->hIST->crc32 = unpack('N', $crc)[1]; $this->hIST->crcHex = bin2hex($crc); $png->imageHistogram = $this->hIST; kys($png); goto next_chunk; } else if ($nextChunk === "\x70\x48\x59\x73") { // pHYs $this->pHYs = new \stdClass; $this->pHYs->name = 'pHYs'; $this->pHYs->length = $length; $this->pHYs->x = unpack('N', substr($data, 0, 4))[1]; $this->pHYs->y = unpack('N', substr($data, 4, 4))[1]; $unit = [ 0 => 'unit is unknown', 1 => 'unit is the metre', ]; $this->pHYs->unitId = ord($data[8]); $this->pHYs->unit = $unit[$this->pHYs->unitId]; $this->pHYs->crc = $crc; $this->pHYs->crc32 = unpack('N', $crc)[1]; $this->pHYs->crcHex = bin2hex($crc); $png->physicalPixelDimensions = $this->pHYs; goto next_chunk; } else if ($nextChunk === "\x73\x50\x4c\x54") { // sPLT $this->sPLT = new \stdClass; $this->sPLT->name = 'sPLT'; $this->sPLT->length = $length; kys(['sPLT', $length, bin2hex($data)]); $this->sPLT->crc = $crc; $this->sPLT->crc32 = unpack('N', $crc)[1]; $this->sPLT->crcHex = bin2hex($crc); $png->suggestedPalette = $this->sPLT; kys($png); goto next_chunk; } else if ($nextChunk === "\x65\x58\x49\x66") { // eXIf $this->eXIf = new \stdClass; $this->eXIf->name = 'eXIf'; $this->eXIf->length = $length; if (strlen($data) < 8) { $this->eXIf->valid = false; $this->eXIf->error = "eXIf chunk too short"; goto exif_crc; } $byteOrder = substr($data, 0, 2); $magic = substr($data, 2, 2); $this->eXIf->rawData = $data; $this->eXIf->rawLength = $length; $this->eXIf->byteOrder = $byteOrder; // II = リトルエンディアン、MM = ビッグエンディアン $this->eXIf->isBigEndian = ($byteOrder === 'MM'); $this->eXIf->magic = unpack('n', $magic)[1]; // いつでも42ではず if (strlen($data) < 8) { $this->eXIf->valid = false; $this->eXIf->error = "Invalid TIFF magic number"; goto exif_crc; } $this->eXIf->valid = true; $ifdOffset = unpack($this->eXIf->isBigEndian ? 'N' : 'V', substr($data, 4, 4))[1]; $this->eXIf->firstIfdOffset = $ifdOffset; exif_crc: $this->eXIf->crc = $crc; $this->eXIf->crc32 = unpack('N', $crc)[1]; $this->eXIf->crcHex = bin2hex($crc); $png->exifProfile = $this->eXIf; goto next_chunk; } else if ($nextChunk === "\x74\x49\x4d\x45") { // tIME $this->tIME = new \stdClass; $this->tIME->name = 'tIME'; $this->tIME->length = $length; kys(['tIME', $length, bin2hex($data)]); $this->tIME->crc = $crc; $this->tIME->crc32 = unpack('N', $crc)[1]; $this->tIME->crcHex = bin2hex($crc); $png->lastModificationTime = $this->tIME; kys($png); goto next_chunk; } else if ($nextChunk === "\x61\x63\x54\x4c") { // acTL $this->acTL = new \stdClass; $this->acTL->name = 'acTL'; $this->acTL->length = $length; $this->acTL->numFrames = unpack('N', substr($data, 0, 4))[1]; $this->acTL->numPlays = unpack('N', substr($data, 4, 4))[1]; $this->acTL->crc = $crc; $this->acTL->crc32 = unpack('N', $crc)[1]; $this->acTL->crcHex = bin2hex($crc); $png->aniationControl = $this->acTL; goto next_chunk; } else if ($nextChunk === "\x66\x63\x54\x4c") { // fcTL if ($this->currentFrame !== null) $this->frames[] = (object)$this->currentFrame; $this->currentFrame = [ 'name' => 'fcTL', 'length' => $length, 'sequenceNumber' => unpack('N', substr($data, 0, 4))[1], 'width' => unpack('N', substr($data, 4, 4))[1], 'height' => unpack('N', substr($data, 8, 4))[1], 'xOffset' => unpack('N', substr($data, 12, 4))[1], 'yOffset' => unpack('N', substr($data, 16, 4))[1], 'delayNum' => unpack('n', substr($data, 20, 2))[1], 'delayDen' => unpack('n', substr($data, 22, 2))[1], 'disposeOp' => ord($data[24]), 'blendOp' => ord($data[25]), 'data' => null, 'crc' => $crc, 'crc32' => unpack('N', $crc)[1], 'crcHex' => bin2hex($crc), ]; goto next_chunk; } else if ($nextChunk === "\x49\x44\x41\x54" || $nextChunk === "\x66\x64\x41\x54") { // IDAT || fdAT if ($this->currentFrame === null) { $this->currentFrame = [ 'sequenceNumber' => 0, 'isFirstFrame' => true, 'width' => $this->IHDR->width, 'height' => $this->IHDR->height, 'data' => null, ]; } $compressedData = ($nextChunk === "\x66\x64\x41\x54") ? substr($data, 4) : $data; $this->currentFrame['name'] = 'fdAT'; $this->currentFrame['length'] = $length; $this->currentFrame['data'] = $compressedData; $this->currentFrame['dataBase64'] = base64_encode($compressedData); $this->currentFrame['dataHex'] = bin2hex($compressedData); $this->currentFrame['dataLength'] = strlen($compressedData); $this->currentFrame['dataChunkType'] = $nextChunk; $this->currentFrame['crc'] = $crc; $this->currentFrame['crcBase64'] = unpack('N', $crc)[1]; $this->currentFrame['crcHex'] = bin2hex($crc); if ($nextChunk === "\x49\x44\x41\x54") { $this->currentFrame['name'] = 'IDAT'; $this->frames[] = (object)$this->currentFrame; $this->currentFrame = null; } $png->data = $this->frames; goto next_chunk; } else if ($nextChunk === "\x49\x45\x4e\x44") { // IEND if ($this->currentFrame !== null) { $this->frames[] = (object)$this->currentFrame; } $png->end = (object)['crc' => $crc, 'crc32' => unpack('N', $crc)[1], 'crcHex' => bin2hex($crc)]; } else { fclose($fp); $err = '不明なチャンク:'.$nextChunk.'、HEX:'.bin2hex($nextChunk); kys($err); logger(LogType::Image, '【PNG】'.$err); return $png; } fclose($fp); return $png; } /////////////// //////// 画素の読み書き private const SIGNATURE = "\x89PNG\r\n\x1a\n"; /** Adam7の各段階:[x開始, y開始, x間隔, y間隔] */ 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]]; /** * PNG(APNGを含む)を画素にする * * 全ての色類とビット深さ(16ビットは上位8ビット)、PLTE・tRNS、Adam7、APNGのフレームに対応。 * 必須のチャンクのCRCが合わない場合は例外。 */ public function decode(string $data): Bitmap { if (!str_starts_with($data, self::SIGNATURE)) throw new \RuntimeException('PNGではありません。'); $len = strlen($data); $pos = 8; $hdr = null; $palette = null; $trns = null; $idat = ''; $actl = null; $frames = []; $seenIdat = false; $firstIsDefault = false; $ended = false; while ($pos + 12 <= $len) { $clen = unpack('N', substr($data, $pos, 4))[1]; $type = substr($data, $pos + 4, 4); if ($clen > $len - $pos - 12) throw new \RuntimeException("{$type}チャンクが途中で切れています。"); $body = substr($data, $pos + 8, $clen); $crc = unpack('N', substr($data, $pos + 8 + $clen, 4))[1]; $pos += 12 + $clen; if (crc32($type.$body) !== $crc) { // 補助チャンク(1文字目が小文字)は飛ばす if (ctype_lower($type[0])) continue; throw new \RuntimeException("{$type}チャンクのCRCが合いません。"); } switch ($type) { case 'IHDR': if ($clen !== 13) throw new \RuntimeException('IHDRが不正です。'); $h = unpack('Nw/Nh/Cdepth/Cctype/Ccomp/Cfilter/Cinterlace', $body); $ok = match ($h['ctype']) { 0 => in_array($h['depth'], [1, 2, 4, 8, 16], true), 3 => in_array($h['depth'], [1, 2, 4, 8], true), 2, 4, 6 => in_array($h['depth'], [8, 16], true), default => false, }; if (!$ok || $h['comp'] !== 0 || $h['filter'] !== 0 || $h['interlace'] > 1) throw new \RuntimeException('未対応の色類・ビット深さです。'); Bitmap::check($h['w'], $h['h']); $hdr = $h; break; case 'PLTE': if ($clen % 3 !== 0 || $clen > 768 || $clen === 0) throw new \RuntimeException('PLTEが不正です。'); $palette = $body; break; case 'tRNS': $trns = $body; break; case 'acTL': $actl = unpack('Nframes/Nplays', $body); break; case 'fcTL': if ($clen !== 26) throw new \RuntimeException('fcTLが不正です。'); $f = unpack('Nseq/Nw/Nh/Nx/Ny/ndnum/nden/Cdispose/Cblend', $body); if (!$seenIdat) $firstIsDefault = true; $frames[] = ['ctl' => $f, 'data' => '']; break; case 'IDAT': $seenIdat = true; $idat .= $body; if ($firstIsDefault && count($frames) === 1) $frames[0]['data'] .= $body; break; case 'fdAT': if ($frames) $frames[count($frames) - 1]['data'] .= substr($body, 4); break; case 'IEND': $ended = true; break 2; } } if ($hdr === null || $idat === '') throw new \RuntimeException('IHDRかIDATがありません。'); if (!$ended) throw new \RuntimeException('IENDがありません。'); if ($hdr['ctype'] === 3 && $palette === null) throw new \RuntimeException('PLTEがありません。'); $img = new Bitmap($hdr['w'], $hdr['h'], self::pixels($idat, $hdr['w'], $hdr['h'], $hdr, $palette, $trns)); if ($actl === null || !$frames) return $img; // APNG:各フレームを画面に合成する $canvas = new Bitmap($hdr['w'], $hdr['h']); $img->loops = $actl['plays']; foreach ($frames as $i => $fr) { $c = $fr['ctl']; if ($c['x'] + $c['w'] > $hdr['w'] || $c['y'] + $c['h'] > $hdr['h']) throw new \RuntimeException('APNGのフレームが画面からはみ出しています。'); $sub = new Bitmap($c['w'], $c['h'], self::pixels($fr['data'], $c['w'], $c['h'], $hdr, $palette, $trns)); // 最初のフレームの「前に戻す」は「消す」と同じ $dispose = $i === 0 && $c['dispose'] === 2 ? 1 : $c['dispose']; $before = $dispose === 2 ? $canvas->copy() : null; $canvas->draw($sub, $c['x'], $c['y'], $c['blend'] === 1); $img->frames[] = ['bitmap' => $canvas->copy(), 'delay' => (int)round($c['dnum'] * 1000 / ($c['den'] ?: 100))]; if ($dispose === 1) $canvas->clear($c['x'], $c['y'], $c['w'], $c['h']); else if ($dispose === 2) $canvas = $before; } return $img; } /** * 圧縮した画像データ(IDAT・fdAT)をRGBAにする */ private static function pixels(string $zdata, int $w, int $h, array $hdr, ?string $palette, ?string $trns): string { $channels = [0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4][$hdr['ctype']]; $bits = $channels * $hdr['depth']; $bpp = max(1, $bits >> 3); $rowBytes = fn(int $pw) => ($pw * $bits + 7) >> 3; $passes = $hdr['interlace'] ? self::ADAM7 : [[0, 0, 1, 1]]; $expected = 0; foreach ($passes as [$xs, $ys, $xd, $yd]) { $pw = intdiv($w - $xs + $xd - 1, $xd); $ph = intdiv($h - $ys + $yd - 1, $yd); if ($pw > 0 && $ph > 0) $expected += $ph * (1 + $rowBytes($pw)); } // 展開する大きさを制限する(圧縮爆弾対策) $raw = @gzuncompress($zdata, $expected + 1024); if ($raw === false) throw new \RuntimeException('画像データを展開出来ません。'); if (strlen($raw) < $expected) throw new \RuntimeException('画像データが足りません。'); $lut = self::lookup($hdr, $palette, $trns); if (!$hdr['interlace']) { $rows = self::unfilter($raw, 0, $rowBytes($w), $h, $bpp); return implode('', array_map(fn($r) => self::toRgba($r, $w, $hdr, $lut, $trns), $rows)); } $grid = array_fill(0, $h, array_fill(0, $w, "\0\0\0\0")); $off = 0; foreach (self::ADAM7 as [$xs, $ys, $xd, $yd]) { $pw = intdiv($w - $xs + $xd - 1, $xd); $ph = intdiv($h - $ys + $yd - 1, $yd); if ($pw <= 0 || $ph <= 0) continue; $rows = self::unfilter($raw, $off, $rowBytes($pw), $ph, $bpp); $off += $ph * (1 + $rowBytes($pw)); foreach ($rows as $j => $r) { $px = str_split(self::toRgba($r, $pw, $hdr, $lut, $trns), 4); $y = $ys + $j * $yd; foreach ($px as $i => $p) $grid[$y][$xs + $i * $xd] = $p; } } return implode('', array_map(fn($r) => implode('', $r), $grid)); } /** * フィルターを戻す * * @return array 行(フィルターの種類のバイトを除いた文字列) */ private static function unfilter(string $raw, int $off, int $rowBytes, int $rows, int $bpp): array { $out = []; $prev = array_fill(0, $rowBytes, 0); for ($y = 0; $y < $rows; $y++) { $ft = ord($raw[$off]); $line = substr($raw, $off + 1, $rowBytes); $off += $rowBytes + 1; if ($ft === 0) { $out[] = $line; $prev = array_values(unpack('C*', $line)); continue; } $cur = array_values(unpack('C*', $line)); switch ($ft) { case 1: for ($i = $bpp; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + $cur[$i - $bpp]) & 255; break; case 2: for ($i = 0; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + $prev[$i]) & 255; break; case 3: for ($i = 0; $i < $bpp; $i++) $cur[$i] = ($cur[$i] + ($prev[$i] >> 1)) & 255; for (; $i < $rowBytes; $i++) $cur[$i] = ($cur[$i] + (($cur[$i - $bpp] + $prev[$i]) >> 1)) & 255; break; case 4: for ($i = 0; $i < $bpp; $i++) $cur[$i] = ($cur[$i] + $prev[$i]) & 255; for (; $i < $rowBytes; $i++) { $a = $cur[$i - $bpp]; $b = $prev[$i]; $c = $prev[$i - $bpp]; $p = $a + $b - $c; $pa = abs($p - $a); $pb = abs($p - $b); $pc = abs($p - $c); $cur[$i] = ($cur[$i] + ($pa <= $pb && $pa <= $pc ? $a : ($pb <= $pc ? $b : $c))) & 255; } break; default: throw new \RuntimeException("不正なフィルターの種類です({$ft})。"); } $out[] = pack('C*', ...$cur); $prev = $cur; } return $out; } /** * パレット又は灰色(8ビット以下)の値 => RGBA(4バイト)の表 */ private static function lookup(array $hdr, ?string $palette, ?string $trns): ?array { if ($hdr['ctype'] === 3) { $lut = []; $n = intdiv(strlen($palette), 3); for ($i = 0; $i < 256; $i++) { // パレットに無い指標は黒(不正なファイルでも落ちない様に) $rgb = $i < $n ? substr($palette, $i * 3, 3) : "\0\0\0"; $a = $trns !== null && $i < strlen($trns) ? $trns[$i] : "\xff"; $lut[chr($i)] = $rgb.$a; } return $lut; } if ($hdr['ctype'] === 0 && $hdr['depth'] <= 8) { $max = (1 << $hdr['depth']) - 1; $key = $trns !== null && strlen($trns) >= 2 ? unpack('n', $trns)[1] : -1; $lut = []; for ($i = 0; $i <= $max; $i++) { $g = chr(intdiv($i * 255 + intdiv($max, 2), $max)); $lut[chr($i)] = $g.$g.$g.($i === $key ? "\0" : "\xff"); } return $lut; } return null; } /** * フィルターを戻した1行をRGBAにする */ private static function toRgba(string $row, int $w, array $hdr, ?array $lut, ?string $trns): string { $depth = $hdr['depth']; $ctype = $hdr['ctype']; if ($lut !== null) { if ($depth < 8) { // 1バイトに入っている複数の値を1バイトずつにする static $unpack = []; if (!isset($unpack[$depth])) { $n = 8 / $depth; $mask = (1 << $depth) - 1; for ($b = 0; $b < 256; $b++) { $s = ''; for ($k = $n - 1; $k >= 0; $k--) $s .= chr(($b >> ($k * $depth)) & $mask); $unpack[$depth][chr($b)] = $s; } } $row = substr(strtr($row, $unpack[$depth]), 0, $w); } return strtr($row, $lut); } // 16ビット:透明色の比較は16ビットで、表示は上位8ビット $key = null; if ($trns !== null && ($ctype === 0 || $ctype === 2)) $key = $ctype === 0 ? substr($trns, 0, 2) : substr($trns, 0, 6); if ($depth === 16) { $samples = $row; $row = preg_replace('/(.)./s', '$1', $row); } switch ($ctype) { case 0: // 16ビットの灰色(8ビット以下はlookup) $out = preg_replace('/(.)/s', "\$1\$1\$1\xff", $row); if ($key !== null) { for ($i = 0; $i < $w; $i++) if (substr($samples, $i * 2, 2) === $key) $out[$i * 4 + 3] = "\0"; } return $out; case 2: $out = implode("\xff", str_split($row, 3))."\xff"; if ($key !== null) { $sz = $depth === 16 ? 6 : 3; $src = $depth === 16 ? $samples : $row; $k = $depth === 16 ? $key : substr($key, 1, 1).substr($key, 3, 1).substr($key, 5, 1); for ($i = 0; $i < $w; $i++) if (substr($src, $i * $sz, $sz) === $k) $out[$i * 4 + 3] = "\0"; } return $out; case 4: return preg_replace('/(.)(.)/s', '$1$1$1$2', $row); default: // 6 return $row; } } /** * 画素をPNGにする * * 色の数等を見て、一番小さくなる形式を選ぶ:256色以下ならパレット(2色なら1ビット)、 * 不透明ならアルファ無し、灰色ならグレースケール。framesがあればAPNGにする。 * * @param array $opt level:圧縮レベル(0~9、既定9) */ public function encode(Bitmap $img, array $opt = []): string { $level = max(0, min(9, (int)($opt['level'] ?? 9))); $images = $img->frames ? array_column($img->frames, 'bitmap') : [$img]; foreach ($images as $f) { if ($f->width !== $img->width || $f->height !== $img->height) throw new \InvalidArgumentException('フレームの大きさが画像と違います。'); } // 色を数える(257色を超えたら止める) $colors = []; foreach ($images as $f) { for ($y = 0; $y < $f->height && count($colors) <= 256; $y++) $colors += array_flip(str_split($f->row($y), 4)); } $opaque = true; $gray = true; foreach ($images as $f) { $opaque = $opaque && $f->isOpaque(); $gray = $gray && $f->isGray(); } $plte = $trnsChunk = ''; $map = null; if (count($colors) <= 256) { // 透明な色を先にする(tRNSを短くする為) $keys = array_keys($colors); usort($keys, fn($a, $b) => ord($a[3]) <=> ord($b[3])); $map = []; $alpha = ''; foreach ($keys as $i => $k) { $map[$k] = chr($i); $plte .= substr($k, 0, 3); $alpha .= $k[3]; } $alpha = rtrim($alpha, "\xff"); $n = count($keys); $depth = $n <= 2 ? 1 : ($n <= 4 ? 2 : ($n <= 16 ? 4 : 8)); $ctype = 3; if ($alpha !== '') $trnsChunk = self::chunk('tRNS', $alpha); $plte = self::chunk('PLTE', $plte); } else { $depth = 8; $ctype = $gray ? ($opaque ? 0 : 4) : ($opaque ? 2 : 6); } $channels = [0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4][$ctype]; $bpp = max(1, ($channels * $depth) >> 3); $raw = function(Bitmap $f) use ($ctype, $depth, $map, $bpp, $level): string { $out = ''; $prev = null; for ($y = 0; $y < $f->height; $y++) { $row = $f->row($y); $line = match ($ctype) { 0 => preg_replace('/(.).../s', '$1', $row), 2 => preg_replace('/(...)./s', '$1', $row), 4 => preg_replace('/(.)..(.)/s', '$1$2', $row), 3 => strtr($row, $map), default => $row, }; if ($ctype === 3 && $depth < 8) { $per = 8 / $depth; $packed = ''; foreach (str_split($line, $per) as $group) { $b = 0; for ($k = 0; $k < $per; $k++) $b = ($b << $depth) | ($k < strlen($group) ? ord($group[$k]) : 0); $packed .= chr($b); } $line = $packed; } // パレットにはフィルターを使わない(仕様の推奨)。それ以外は行毎に一番良い物を選ぶ $out .= $ctype === 3 ? "\0".$line : self::filter($line, $prev, $bpp); $prev = $line; } return gzcompress($out, $level); }; $png = self::SIGNATURE.self::chunk('IHDR', pack('NNCCCCC', $img->width, $img->height, $depth, $ctype, 0, 0, 0)).$plte.$trnsChunk; if (!$img->frames) return $png.self::chunk('IDAT', $raw($img)).self::chunk('IEND', ''); $png .= self::chunk('acTL', pack('NN', count($images), $img->loops)); $seq = 0; foreach ($img->frames as $i => $fr) { // 遅延はミリ秒(分母1000) $png .= self::chunk('fcTL', pack('NNNNNnnCC', $seq++, $img->width, $img->height, 0, 0, min(65535, max(0, (int)$fr['delay'])), 1000, 0, 0)); $z = $raw($fr['bitmap']); if ($i === 0) { $png .= self::chunk('IDAT', $z); } else { $png .= self::chunk('fdAT', pack('N', $seq++).$z); } } return $png.self::chunk('IEND', ''); } private static function chunk(string $type, string $data): string { return pack('N', strlen($data)).$type.$data.pack('N', crc32($type.$data)); } /** * 1行にフィルターを掛ける(5種類の内、差の絶対値の合計が一番小さい物:仕様の推奨する方法) * * @return string フィルターの種類のバイト+データ */ private static function filter(string $line, ?string $prevLine, int $bpp): string { $cur = array_values(unpack('C*', $line)); $n = count($cur); $prev = $prevLine === null ? array_fill(0, $n, 0) : array_values(unpack('C*', $prevLine)); $cands = []; $cands[0] = $cur; $f = $cur; for ($i = $bpp; $i < $n; $i++) $f[$i] = ($cur[$i] - $cur[$i - $bpp]) & 255; $cands[1] = $f; if ($prevLine !== null) { $f = []; for ($i = 0; $i < $n; $i++) $f[] = ($cur[$i] - $prev[$i]) & 255; $cands[2] = $f; $f = []; for ($i = 0; $i < $n; $i++) $f[] = ($cur[$i] - ((($i >= $bpp ? $cur[$i - $bpp] : 0) + $prev[$i]) >> 1)) & 255; $cands[3] = $f; $f = []; for ($i = 0; $i < $n; $i++) { $a = $i >= $bpp ? $cur[$i - $bpp] : 0; $b = $prev[$i]; $c = $i >= $bpp ? $prev[$i - $bpp] : 0; $p = $a + $b - $c; $pa = abs($p - $a); $pb = abs($p - $b); $pc = abs($p - $c); $f[] = ($cur[$i] - ($pa <= $pb && $pa <= $pc ? $a : ($pb <= $pc ? $b : $c))) & 255; } $cands[4] = $f; } $best = 0; $bestSum = PHP_INT_MAX; foreach ($cands as $ft => $f) { $sum = 0; foreach ($f as $v) $sum += $v < 128 ? $v : 256 - $v; if ($sum < $bestSum) { $bestSum = $sum; $best = $ft; } } return chr($best).pack('C*', ...$cands[$best]); } private function getSamplesPerPixel(int $colorType): int { return match($colorType) { 0 => 1, // グレースケール 2 => 3, // RGB 3 => 1, // 指標した色(ペレット) 4 => 2, // グレースケール+アルファ 6 => 4, // RGBA default => 1, }; } }