*/ private const array CANONICAL_ENCODINGS = [ 'UTF-8', 'Windows-1252', 'ISO-8859-15', ]; /** * Lowercase alias -> canonical name. * * Windows-1252 absorbs ISO-8859-1/Latin-1 because it is a strict superset: * bytes 0xA0-0xFF are identical, and 0x80-0x9F are defined in Win-1252 * but undefined (and useless) in ISO-8859-1. * * @var array */ private const array ENCODING_ALIASES = [ // UTF-8 'utf8' => 'UTF-8', 'utf-8' => 'UTF-8', // Windows-1252 (absorbs Latin-1 / ISO-8859-1) 'win1252' => 'Windows-1252', 'windows1252' => 'Windows-1252', 'cp1252' => 'Windows-1252', 'latin1' => 'Windows-1252', 'latin-1' => 'Windows-1252', 'iso-8859-1' => 'Windows-1252', // ISO-8859-15 (Latin-9) 'latin9' => 'ISO-8859-15', 'latin-9' => 'ISO-8859-15', 'iso-8859-15' => 'ISO-8859-15', ]; /** * Windows-1252 specific codepoints (0x80-0x9F) absent from ISO-8859-1. * * Bytes 0x81, 0x8D, 0x8F, 0x90, 0x9D are officially undefined in Windows-1252. * They are intentionally absent - encountering them triggers the * Latin-1 fallback (Unicode codepoint = byte value). * * @var array Byte value -> UTF-8 character. */ private const array WIN1252_SPECIAL_BYTES = [ 0x80 => "\u{20AC}", // € 0x82 => "\u{201A}", // ‚ 0x83 => "\u{0192}", // ƒ 0x84 => "\u{201E}", // „ 0x85 => "\u{2026}", // … 0x86 => "\u{2020}", // † 0x87 => "\u{2021}", // ‡ 0x88 => "\u{02C6}", // ˆ 0x89 => "\u{2030}", // ‰ 0x8A => "\u{0160}", // Š 0x8B => "\u{2039}", // ‹ 0x8C => "\u{0152}", // Œ 0x8E => "\u{017D}", // Ž 0x91 => "\u{2018}", // ' 0x92 => "\u{2019}", // ' 0x93 => "\u{201C}", // " 0x94 => "\u{201D}", // " 0x95 => "\u{2022}", // • 0x96 => "\u{2013}", // – 0x97 => "\u{2014}", // — 0x98 => "\u{02DC}", // ˜ 0x99 => "\u{2122}", // ™ 0x9A => "\u{0161}", // š 0x9B => "\u{203A}", // › 0x9C => "\u{0153}", // œ 0x9E => "\u{017E}", // ž 0x9F => "\u{0178}", // Ÿ ]; /** * ISO-8859-15 bytes that differ from ISO-8859-1. * Only 8 codepoints differ between Latin-1 and Latin-9. * * @var array Byte value -> UTF-8 character. */ private const array LATIN9_OVERRIDES = [ 0xA4 => "\u{20AC}", // € (replaces ¤) 0xA6 => "\u{0160}", // Š (replaces ¦) 0xA8 => "\u{0161}", // š (replaces ¨) 0xB4 => "\u{017D}", // Ž (replaces ´) 0xB8 => "\u{017E}", // ž (replaces ¸) 0xBC => "\u{0152}", // Œ (replaces ¼) 0xBD => "\u{0153}", // œ (replaces ½) 0xBE => "\u{0178}", // Ÿ (replaces ¾) ]; /** * Converts a string from a known encoding to UTF-8. * * @param bool $stripBom Remove UTF-8 BOM from the result if present. * * @throws \ValueError If encoding is unsupported or the string is invalid. */ public static function toUtf8( string $string, string $sourceEncoding, bool $stripBom = true, ): string { if ($string === '') { return ''; } $canonical = self::resolveEncoding($sourceEncoding); $result = match ($canonical) { 'UTF-8' => self::ensureValidUtf8($string), 'Windows-1252' => self::fromSingleByte($string, self::WIN1252_SPECIAL_BYTES), 'ISO-8859-15' => self::fromSingleByte($string, self::LATIN9_OVERRIDES), default => throw new \ValueError(sprintf( 'Unsupported encoding "%s". Supported: %s', $canonical, implode(', ', self::supportedEncodings()), )) }; return $stripBom ? self::stripUtf8Bom($result) : $result; } /** * Attempts to detect the encoding and convert to UTF-8. * * Detection strategy: * 1. If the string is valid UTF-8, it is treated as UTF-8. * 2. Otherwise, the configured fallback encoding is used. * * This two-step approach is more reliable than mb_detect_encoding * for single-byte encodings (which accept any byte sequence). * * @param string $fallbackEncoding Encoding to assume when the string is not valid UTF-8. * Defaults to 'Windows-1252' (covers most Western European legacy data). * @param bool $stripBom Remove UTF-8 BOM from the result if present. * * @throws \ValueError If fallbackEncoding is unsupported or is 'UTF-8'. */ public static function detectAndConvertToUtf8( string $string, string $fallbackEncoding = 'Windows-1252', bool $stripBom = true, ): string { if ($string === '') { return ''; } $canonical = self::resolveEncoding($fallbackEncoding); throw_if($canonical === 'UTF-8', \ValueError::class, 'Fallback encoding cannot be UTF-8. Use toUtf8() for explicit UTF-8 validation.'); // UTF-8 is the only encoding we can reliably detect: // it has strict byte sequence rules that reject invalid sequences. if (mb_check_encoding($string, 'UTF-8')) { $result = $string; } else { $result = self::toUtf8($string, $canonical, stripBom: false); } return $stripBom ? self::stripUtf8Bom($result) : $result; } /** * Converts an array of strings to UTF-8 from a known encoding. * * @param list $strings * @return list */ public static function arrayToUtf8( array $strings, string $sourceEncoding, bool $stripBom = true, ): array { return array_map( static fn (string $s): string => self::toUtf8($s, $sourceEncoding, $stripBom), $strings, ); } /** * Returns all supported encoding names (canonical + aliases). * * @return list */ public static function supportedEncodings(): array { return array_values(array_unique([ ...self::CANONICAL_ENCODINGS, ...array_keys(self::ENCODING_ALIASES), ])); } /** * Resolves a user-supplied encoding name to its canonical form. * * @throws \ValueError If encoding is unknown. */ private static function resolveEncoding(string $encoding): string { $normalized = strtolower(trim($encoding)); if (isset(self::ENCODING_ALIASES[$normalized])) { return self::ENCODING_ALIASES[$normalized]; } $match = array_find( self::CANONICAL_ENCODINGS, static fn (string $canonical): bool => strtolower($canonical) === $normalized, ); if ($match !== null) { return $match; } throw new \ValueError(sprintf( 'Unsupported encoding "%s". Supported: %s', $encoding, implode(', ', self::supportedEncodings()), )); } /** * Validates that a string is well-formed UTF-8. * * @throws \ValueError If the string contains invalid UTF-8 sequences. */ private static function ensureValidUtf8(string $string): string { throw_unless(mb_check_encoding($string, 'UTF-8'), \ValueError::class, 'String is not valid UTF-8.'); return $string; } /** * Converts a single-byte encoded string to UTF-8. * * All supported single-byte encodings share the same base: * - 0x00-0x7F: ASCII (identity) * - 0x80-0xFF: Encoding-specific - use overrides map first, then Latin-1 identity. * * @param array $overrides Byte -> UTF-8 mapping for encoding-specific codepoints. * For Windows-1252: the 0x80-0x9F special range. * For ISO-8859-15: the 8 bytes that differ from Latin-1. */ private static function fromSingleByte(string $string, array $overrides): string { /** @var array $bytes keyed from 1 (unpack behavior) */ $bytes = unpack('C*', $string); $parts = []; foreach ($bytes as $byte) { $parts[] = match (true) { // @phpstan-ignore argument.type (ASCII range check) $byte <= 0x7F => chr($byte), isset($overrides[$byte]) => $overrides[$byte], default => self::encodeLatin1ByteAsUtf8($byte), }; } return implode('', $parts); } /** * Encodes a single byte (0x80-0xFF) as a two-byte UTF-8 sequence. * * For Latin-1 / ISO-8859-1, the Unicode codepoint equals the byte value, * so the UTF-8 encoding is: 110xxxxx 10xxxxxx. * * @throws \ValueError If the byte is outside the expected range. */ private static function encodeLatin1ByteAsUtf8(int $byte): string { if ($byte < 0x80 || $byte > 0xFF) { throw new \ValueError( sprintf('Byte must be in range 0x80-0xFF, got 0x%02X.', $byte), ); } return chr(0xC0 | ($byte >> 6)) . chr(0x80 | ($byte & 0x3F)); } /** * Strips the UTF-8 BOM (EF BB BF) if present at the start of the string. */ private static function stripUtf8Bom(string $string): string { if (str_starts_with($string, self::UTF8_BOM)) { return substr($string, 3); } return $string; } }