2026-06-14 21:32:07 +02:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Lib\Helpers;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Converts strings from Western European single-byte encodings to UTF-8.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Supported source encodings:
|
|
|
|
|
|
* - UTF-8 (passthrough with validation)
|
|
|
|
|
|
* - Windows-1252 (superset of ISO-8859-1, covers Latin-1 in practice)
|
|
|
|
|
|
* - ISO-8859-15 (Latin-9: Latin-1 + €, œ, Œ, Š, š, Ž, ž, Ÿ)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Auto-detection is inherently unreliable for single-byte encodings.
|
|
|
|
|
|
* Always prefer toUtf8() with an explicit encoding.
|
|
|
|
|
|
*/
|
|
|
|
|
|
final class StringEncoder
|
|
|
|
|
|
{
|
|
|
|
|
|
/** UTF-8 BOM sequence (EF BB BF). */
|
|
|
|
|
|
private const string UTF8_BOM = "\xEF\xBB\xBF";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Canonical encodings supported by this class.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @var list<string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
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<string, string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
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<int, string> 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<int, string> 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<string> $strings
|
|
|
|
|
|
* @return list<string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
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<string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
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<int, string> $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<int, int> $bytes keyed from 1 (unpack behavior) */
|
|
|
|
|
|
$bytes = unpack('C*', $string);
|
|
|
|
|
|
$parts = [];
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($bytes as $byte) {
|
|
|
|
|
|
$parts[] = match (true) {
|
2026-10-28 08:53:34 +01:00
|
|
|
|
// @phpstan-ignore argument.type (ASCII range check)
|
|
|
|
|
|
$byte <= 0x7F => chr($byte),
|
|
|
|
|
|
isset($overrides[$byte]) => $overrides[$byte],
|
|
|
|
|
|
default => self::encodeLatin1ByteAsUtf8($byte),
|
2026-06-14 21:32:07 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|