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
320 lines
10 KiB
PHP
320 lines
10 KiB
PHP
<?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;
|
||
}
|
||
}
|