ldc-boutique-cnc-sable/app/Lib/Helpers/StringEncoder.php

321 lines
10 KiB
PHP
Raw Permalink Normal View History

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) {
// @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;
}
}