ldc-boutique-cnc-sable/app/Lib/Helpers/StringEncoder.php
Jérémy Munsch 4ff3daecab
refactor(Auth): improve reset URL handling and validation
refactor(Helpers): update parameter types and remove ignores

refactor(Models): streamline order assignment logic

refactor(Enums): clarify unused constant warnings

refactor(Services): encapsulate cutoff week offset logic
2026-10-28 08:53:34 +01:00

320 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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),
};
}
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;
}
}