|null */ private static ?array $colorMap = null; public static function extract(string $encodedImage): string { if (!extension_loaded('gd')) { throw new ContractlessException('The GD PHP extension is required to read wallet images.'); } $png = base64_decode($encodedImage, true); if ($png === false) { throw new ContractlessException('The wallet private-key image is not valid Base64.'); } foreach (['h', 'h2', 'v', 'v2'] as $style) { $image = @imagecreatefromstring($png); if ($image === false) { throw new ContractlessException('The wallet private-key image is not a valid PNG.'); } $oriented = null; try { $oriented = self::orient($image, $style); if ($oriented !== $image) { imagedestroy($image); $image = null; } $text = self::extractFromOrientedImage($oriented); if ($text !== null) { return $text; } } finally { if ($oriented !== null) { imagedestroy($oriented); } elseif ($image !== null) { imagedestroy($image); } } } throw new ContractlessException('The wallet private-key image could not be decoded.'); } /** @param \GdImage $image */ private static function orient(object $image, string $style): object { if ($style === 'h') { return $image; } if ($style === 'h2') { imageflip($image, IMG_FLIP_VERTICAL); return $image; } // PHP rotates counter-clockwise. These transformations mirror the // inverse rotations used by encrypted_images during decoding. $rotated = imagerotate($image, $style === 'v' ? 90 : -90, 0); if ($rotated === false) { throw new ContractlessException('The wallet image could not be rotated.'); } if ($style === 'v2') { imageflip($rotated, IMG_FLIP_VERTICAL); } return $rotated; } /** @param \GdImage $image */ private static function extractFromOrientedImage(object $image): ?string { if (imagesx($image) !== self::IMAGE_WIDTH) { return null; } $characters = ''; $map = self::colorMap(); foreach (self::ANCHOR_ROWS as $row) { for ($x = 0; $x < self::IMAGE_WIDTH; $x++) { $index = imagecolorat($image, $x, $row); if ($index === false) { return null; } $rgba = imagecolorsforindex($image, $index); $key = "{$rgba['red']},{$rgba['green']},{$rgba['blue']}"; if (!isset($map[$key])) { return null; } $characters .= $map[$key]; } } $prefix = substr($characters, 0, self::LENGTH_PREFIX_BYTES); if (strlen($prefix) !== self::LENGTH_PREFIX_BYTES || !ctype_digit($prefix)) { return null; } $length = (int) $prefix; $payload = substr($characters, self::LENGTH_PREFIX_BYTES, $length); return strlen($payload) === $length ? $payload : null; } /** @return array */ private static function colorMap(): array { if (self::$colorMap !== null) { return self::$colorMap; } $base = [ 'a' => [204, 180, 194], 'A' => [255, 255, 255], 'b' => [197, 186, 201], 'B' => [221, 206, 212], 'c' => [181, 185, 193], 'C' => [184, 201, 223], 'd' => [224, 218, 192], 'D' => [185, 191, 195], 'e' => [181, 197, 198], 'E' => [193, 206, 255], 'f' => [252, 193, 211], 'F' => [183, 192, 229], 'g' => [180, 191, 192], 'G' => [187, 219, 189], 'h' => [195, 187, 234], 'H' => [182, 216, 189], 'i' => [197, 183, 248], 'I' => [200, 182, 204], 'j' => [255, 235, 196], 'J' => [194, 186, 228], 'k' => [199, 238, 239], 'K' => [208, 247, 234], 'l' => [244, 214, 189], 'L' => [187, 243, 239], 'm' => [188, 231, 238], 'M' => [187, 197, 227], 'n' => [186, 240, 191], 'N' => [187, 198, 206], 'o' => [205, 193, 184], 'O' => [191, 187, 197], 'p' => [194, 200, 206], 'P' => [195, 183, 229], 'q' => [182, 219, 196], 'Q' => [238, 216, 184], 'r' => [199, 181, 208], 'R' => [239, 231, 198], 's' => [189, 188, 230], 'S' => [242, 192, 230], 't' => [199, 199, 199], 'T' => [188, 190, 230], 'u' => [230, 180, 253], 'U' => [241, 247, 247], 'v' => [242, 190, 199], 'V' => [230, 247, 234], 'w' => [197, 186, 249], 'W' => [194, 247, 249], 'x' => [242, 182, 246], 'X' => [188, 222, 193], 'y' => [188, 194, 183], 'Y' => [197, 195, 197], 'z' => [187, 249, 240], 'Z' => [233, 231, 242], '0' => [195, 184, 218], '1' => [232, 180, 196], '2' => [191, 193, 196], '3' => [185, 186, 186], '4' => [191, 247, 180], '5' => [187, 199, 248], '6' => [248, 198, 184], '7' => [243, 195, 184], '8' => [232, 192, 208], '9' => [239, 197, 183], '/' => [199, 187, 241], '+' => [195, 216, 223], '=' => [193, 211, 184], ]; $map = []; foreach ($base as $character => [$red, $green, $blue]) { $average = ($red + $green + $blue) / 3; $vivid = static function (int $value) use ($average): int { $saturated = $average + (($value - $average) * 2.35); $contrasted = (($saturated - 128) * 1.12) + 128 - 18; return (int) round(max(0, min(255, $contrasted))); }; $map[$vivid($red) . ',' . $vivid($green) . ',' . $vivid($blue)] = $character; } self::$colorMap = $map; return $map; } }