*/ private array $allCapsWhitelist = []; /** @var array */ private array $lowerWordSet = []; /** @var array */ private array $upperLookup = []; /** @var array */ private array $lowerLookup = []; /** @var array */ private array $dottedLookup = []; /** @var array */ private array $compoundSkip = []; /** * @var array,biCapSkip:list,biCapFamilyNameStartWith:list}> */ private array $biCapPrefixes = []; /** * @var array{ * upperExceptions: list, * lowerExceptions: list, * minorWords: list, * minorPhrases: list>, * minorRule: 'lower_anywhere'|'lower_interior', * respectSegmentStart: bool, * capitalizeLast: bool * } */ private array $rulesTyped = [ 'upperExceptions' => [], 'lowerExceptions' => [], 'minorWords' => [], 'minorPhrases' => [], 'minorRule' => 'lower_interior', 'respectSegmentStart' => false, 'capitalizeLast' => true, ]; public function __construct( private bool $isName, private string $encoding, private ?\RJT\TitleCase\Overrides $overrides = null ) { $this->hasMb = function_exists('mb_strtolower') && function_exists('mb_strtoupper') && function_exists('mb_strlen'); } public function run(string $input): string { $isName = $this->isName; // Normalize whitespace $normalized = preg_replace('/\s+/u', ' ', trim($input)); if ($normalized === null) { // preg error fallback (extremely rare) $normalized = trim($input); } if ($normalized === '') { return ''; } $parts = preg_split('/(\s+)/u', $normalized, -1, PREG_SPLIT_DELIM_CAPTURE); if ($parts === false) { // very unlikely, but safe fallback return $normalized; } $lettersOnly = preg_replace('/[^\p{L}]+/u', '', $normalized); $inputIsAllCaps = false; if (is_string($lettersOnly) && $lettersOnly !== '') { $upperLetters = $this->upper($lettersOnly); $lowerLetters = $this->lower($lettersOnly); $inputIsAllCaps = ($lettersOnly === $upperLetters) && ($lettersOnly !== $lowerLetters); } $this->allCapsWhitelist = [ 'USA' => true, 'NASA' => true, 'API' => true, 'SDK' => true, 'URL' => true, 'HTTP' => true, 'HTTPS' => true, 'HTML' => true, 'JSON' => true, 'XML' => true, 'SQL' => true, 'PO' => true, 'NW' => true, 'NE' => true, 'SW' => true, 'SE' => true, ]; // Exception lists (store comparison forms in lowercase) if ($isName) { $rules = [ 'upperExceptions' => [], // keep empty; add e.g. ['AJ','JD'] if you want 'lowerExceptions' => [], 'minorWords' => [ 'der', 'von', 'van', 'de', 'da', 'di', 'du', 'del', 'des', 'le', 'la', 'den', 'ten', 'ter', 'al', 'bin', 'ibn', ], 'minorPhrases' => [ ['de', 'la'], ['de', 'las'], ['de', 'los'], ['de', 'le'], ['van', 'de'], ['van', 'den'], ['van', 'der'], ['von', 'dem'], ['vit', 'de'], ], 'minorRule' => 'lower_anywhere', 'respectSegmentStart' => false, 'capitalizeLast' => true, ]; } else { $rules = [ 'upperExceptions' => ['PO', 'RR', 'NE', 'NW', 'SE', 'SW'], 'lowerExceptions' => ['vs'], 'minorWords' => [ 'a', 'an', 'the', 'and', 'but', 'or', 'nor', 'yet', 'so', 'as', 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'onto', 'over', 'to', 'with', 'if', 'per', 'via', 'vs', 'en', ], 'minorPhrases' => [ ['out', 'of'], ['up', 'to'], ], 'minorRule' => 'lower_interior', 'respectSegmentStart' => true, 'capitalizeLast' => true, ]; } $this->rulesTyped = $rules; $this->lowerWordSet = array_fill_keys($this->rulesTyped['minorWords'], true); $this->upperLookup = []; foreach ($this->rulesTyped['upperExceptions'] as $abbr) { $this->upperLookup[$this->lower($abbr)] = $abbr; // canonical uppercase output } $this->lowerLookup = []; foreach ($this->rulesTyped['lowerExceptions'] as $abbr) { $this->lowerLookup[$this->lower($abbr)] = $this->lower($abbr); // canonical lowercase output } $this->dottedLookup = [ 'ph.d.' => 'Ph.D.', ]; $this->compoundSkip = [ 'c/o' => true, 'and/or' => true, ]; $this->biCapPrefixes = BiCapPrefixes::get(); // Case helpers are implemented as private methods. $tokenizer = new Tokenizer(); /** * Build word records so we don’t keep re-parsing the same tokens. * * @var array $words */ $words = $tokenizer->buildWords($parts); $this->markSegmentStarts($words, $parts); $this->passBaseCasing($words, $inputIsAllCaps); $this->passUpperExceptions($words); // Prepare lowercase comparisons after base/uppercase exception passes $coresLower = array_map(fn (Word $w): string => $this->lower($w->core), $words); $lowerPositions = $this->computeLowerPositions($words, $coresLower); $this->applyLowerPositions($words, $coresLower, $lowerPositions); if (!$this->isName) { $this->applyAtEmailHeuristic($words, $coresLower, $parts); $this->passTitleLowerOverrides($words, $coresLower); $this->passAddressStateAbbreviations($words, $parts, $normalized); } if ($this->isName) { $this->passNameInitialismLikeUpper($words, $coresLower); } $this->passFinalTweaks($words); $this->applyOverrides($words); return $this->rebuildParts($parts, $words); } /** * @param array $words * @param array $parts */ private function markSegmentStarts(array &$words, array $parts): void { // Mark segment starts so we can avoid lowercasing minor words after delimiters. // Delimiters are often attached to the previous word (e.g., "War:"), so we must // inspect the previous word's *trail* as well as any standalone punctuation tokens. $segmentDelimiterRe = '/[:—–?!]/u'; $nWords = count($words); for ($i = 0; $i < $nWords; $i++) { $segmentStart = ($i === 0); if (!$segmentStart) { // Optional parenthetical restart: if a word begins a parenthetical/bracketed clause, // treat it as a segment start so minor words like "in" are capitalized: "(In Brief)". if (!$this->isName && preg_match('/^[\(\[\{]/u', $words[$i]->lead) === 1) { $segmentStart = true; } } if (!$segmentStart) { // 1) Most common case: delimiter is attached to previous word if (preg_match($segmentDelimiterRe, $words[$i - 1]->trail) === 1) { $segmentStart = true; } else { // 2) Less common: delimiter appears as its own token between words $prevIndex = $words[$i - 1]->partIndex; $currIndex = $words[$i]->partIndex; for ($p = $prevIndex + 1; $p <= $currIndex - 1; $p++) { if (preg_match($segmentDelimiterRe, $parts[$p]) === 1) { $segmentStart = true; break; } } } } $words[$i]->segmentStart = $segmentStart; } } /** * @param array $words */ private function passBaseCasing(array &$words, bool $inputIsAllCaps): void { // Pass 1: base casing (title-case each word), plus preserve mixed-case and (in title mode) acronyms $nWords = count($words); foreach ($words as $wi => $w) { if ($w->type === TokenType::OPAQUE) { $words[$wi]->core = $w->core; continue; } $core = $w->core; $coreLower = $this->lower($core); if ( !$this->isName && preg_match('/^(?[A-Z]{2,})(?[\'’])[Ss]$/u', $core, $m) === 1 ) { $contractionStems = [ 'IT', 'HE', 'SHE', 'THAT', 'WHO', 'WHAT', 'LET', 'HERE', 'THERE', 'WHERE', 'WHEN', 'WHY', 'HOW', ]; if (!in_array($m['acronym'], $contractionStems, true)) { $words[$wi]->core = $m['acronym'] . $m['apos'] . 's'; continue; } } if ($w->type === TokenType::DOTTED) { // ASCII dotted abbreviations / initialisms: // - Default: uppercase all letters (u.s.a. -> U.S.A., m.d. -> M.D.) // - Exception(s): ph.d. -> Ph.D. $key = strtolower($core); // core is ASCII letters + dots here $core = $this->dottedLookup[$key] ?? strtoupper($core); } elseif ($w->type === TokenType::DOTWORD) { // "node.js" -> "Node.js" (case left only; keep suffix lowercase) $pieces = explode('.', $core, 2); if (count($pieces) === 2) { [$left, $right] = $pieces; $preserveMixedLeft = $this->looksIntentionallyMixed($left); if (!$preserveMixedLeft) { $left = $this->titleCaseCore($left); } $core = $left . '.' . $this->lower($right); } } elseif ($w->type === TokenType::COMPOUND) { $key = $this->lower($core); if (!isset($this->compoundSkip[$key])) { $sepsRe = '/([&\/+])/u'; $chunks = preg_split($sepsRe, $core, -1, PREG_SPLIT_DELIM_CAPTURE); if ($chunks !== false) { $hasAmp = strpos($core, '&') !== false; $hasPlus = strpos($core, '+') !== false; $hasSlash = strpos($core, '/') !== false; for ($ci = 0; $ci < count($chunks); $ci++) { // Skip separators if (preg_match($sepsRe, $chunks[$ci]) === 1) { continue; } // Segment token casing rules if ($hasAmp || $hasPlus) { // Uppercase only acronym-like short segments; otherwise title-case. // 3-letter cutoff keeps: r&d -> R&D, api+sdk -> API+SDK, rock+roll -> Rock+Roll. $segLetters = preg_replace('/[^\p{L}\p{M}\p{N}]+/u', '', $chunks[$ci]); $len = (is_string($segLetters) && $segLetters !== '') ? $this->len($segLetters) : 0; if ($len > 0 && $len <= 3) { $chunks[$ci] = $this->upper($chunks[$ci]); } else { $chunks[$ci] = $this->titleCaseCore($chunks[$ci]); } } elseif ($hasSlash) { // Slash compounds: title-case each segment $chunks[$ci] = $this->titleCaseCore($chunks[$ci]); } } $core = implode('', $chunks); } } } else { $useDefaultCasing = true; if ( !$this->isName && preg_match('/[-\x{2010}\x{2011}]/u', $core) === 1 ) { $splitRe = '/([\-\\x{2010}\\x{2011}])/u'; $chunks = preg_split($splitRe, $core, -1, PREG_SPLIT_DELIM_CAPTURE); if ($chunks !== false) { $wordIndexes = []; for ($ci = 0; $ci < count($chunks); $ci += 2) { if ($chunks[$ci] !== '') { $wordIndexes[] = $ci; } } if ($wordIndexes !== []) { $useDefaultCasing = false; $firstIndex = $wordIndexes[0]; $lastIndex = $wordIndexes[count($wordIndexes) - 1]; for ($ci = 0; $ci < count($chunks); $ci++) { if ($ci % 2 === 1) { continue; // separator } if ($chunks[$ci] === '') { continue; } $segment = $chunks[$ci]; $segmentLower = $this->lower($segment); $preserveMixedSegment = $this->looksIntentionallyMixed($segment); $preserveAllCapsSegment = ( !$preserveMixedSegment && !isset($this->lowerWordSet[$segmentLower]) && preg_match('/[\'’]/u', $segment) !== 1 && $this->isAllCapsWord($segment) ); if ($preserveMixedSegment || $preserveAllCapsSegment) { continue; } if ($ci === $firstIndex || $ci === $lastIndex) { $chunks[$ci] = $this->titleCaseCore($segment); continue; } if (isset($this->lowerWordSet[$segmentLower])) { $chunks[$ci] = $this->lower($segment); } else { $chunks[$ci] = $this->titleCaseCore($segment); } } $core = implode('', $chunks); } } } if ($useDefaultCasing) { $preserveMixed = $this->looksIntentionallyMixed($core); // In title/address mode, preserve ALL-CAPS acronyms (but NOT small words like AND/OF/etc) $preserveAllCaps = ( !$this->isName && !$preserveMixed && !isset($this->lowerWordSet[$coreLower]) && preg_match('/[\'’]/u', $core) !== 1 && $this->isAllCapsWord($core) && ( !$inputIsAllCaps || isset($this->allCapsWhitelist[$this->upper($core)]) ) ); $origCore = $w->origCore; $origCoreLower = strtolower($origCore); $origIsAllLower = $this->isAllLowerAscii($origCore); $origIsAllUpper = $this->isAllUpperAscii($origCore); if ( ( !$this->isName && $this->isRomanNumeralTitle($core) && ($origIsAllLower || $origIsAllUpper) && !($wi === 0 && $origCoreLower === 'liv') ) || ( $this->isName && $wi === $nWords - 1 && $this->isRomanNumeralName($core) ) ) { $core = $this->upper($core); } elseif (!$preserveMixed && !$preserveAllCaps) { $core = $this->titleCaseCore($core); } } } if ( $this->isName && !$this->looksIntentionallyMixed($core) && !$this->isAllCapsWord($core) ) { foreach ($this->biCapPrefixes as $prefix => $config) { $next = $this->applyBiCapitalization( $core, $prefix, $config['biCapStartWith'], $config['biCapSkip'], $config['biCapFamilyNameStartWith'], $wi === 0 ); if ($next !== $core) { $core = $next; break; } } } $core = $this->fixPossessive($core); $words[$wi]->core = $core; } } /** * @param array $words */ private function passUpperExceptions(array &$words): void { // Pass 2: uppercase explicit exceptions (PO, NW, ...) if ($this->upperLookup === []) { return; } foreach ($words as $wi => $w) { $key = $this->lower($w->core); if (isset($this->upperLookup[$key])) { $words[$wi]->core = $this->upperLookup[$key]; } } } /** * @param array $words * @param array $coresLower * @return array */ private function computeLowerPositions(array $words, array $coresLower): array { // Pass 3: decide which word positions should be lowercased (rule-driven) $nWords = count($words); $lowerPositions = array_fill(0, $nWords, false); for ($i = 0; $i < $nWords; $i++) { $isFirst = ($i === 0); $isLast = ($i === $nWords - 1); if ($isFirst) { continue; } if ($this->rulesTyped['capitalizeLast'] && $isLast) { continue; } if ($this->rulesTyped['respectSegmentStart'] && $words[$i]->segmentStart) { continue; } $coreLower = $coresLower[$i]; if (!isset($this->lowerWordSet[$coreLower])) { continue; } // Apply minor rule if ($this->rulesTyped['minorRule'] === 'lower_anywhere') { $lowerPositions[$i] = true; continue; } // lower_interior (default): by the time we get here, first/last are already excluded $lowerPositions[$i] = true; } if ($this->rulesTyped['minorRule'] === 'lower_anywhere') { foreach ($this->rulesTyped['minorPhrases'] as $phrase) { $len = count($phrase); for ($i = 0; $i <= $nWords - $len; $i++) { $ok = true; for ($j = 0; $j < $len; $j++) { if ($coresLower[$i + $j] !== $phrase[$j]) { $ok = false; break; } } if ($ok) { for ($j = 0; $j < $len; $j++) { $lowerPositions[$i + $j] = true; } } } } } if ($this->rulesTyped['minorRule'] === 'lower_interior') { foreach ($this->rulesTyped['minorPhrases'] as $phrase) { $len = count($phrase); for ($i = 0; $i <= $nWords - $len; $i++) { $ok = true; for ($j = 0; $j < $len; $j++) { if ($coresLower[$i + $j] !== $phrase[$j]) { $ok = false; break; } } if (!$ok) { continue; } if ($i === 0) { continue; } if ($this->rulesTyped['respectSegmentStart'] && $words[$i]->segmentStart) { continue; } for ($j = 0; $j < $len; $j++) { $pos = $i + $j; if ($this->rulesTyped['capitalizeLast'] && $pos === $nWords - 1) { continue; } $lowerPositions[$pos] = true; } } } } return $lowerPositions; } /** * @param array $words * @param array $coresLower * @param array $lowerPositions */ private function applyLowerPositions(array &$words, array &$coresLower, array $lowerPositions): void { // Pass 4: apply lowercase positions $nWords = count($words); for ($i = 0; $i < $nWords; $i++) { if (!$lowerPositions[$i]) { continue; } // If something is intentionally mixed-case (e.g., iPhone) we avoid lowercasing it. // (Realistically these won’t be in the short-word/particle lists anyway.) if ($this->looksIntentionallyMixed($words[$i]->core)) { continue; } $words[$i]->core = $this->lower($words[$i]->core); $coresLower[$i] = $this->lower($words[$i]->core); } } /** * @param array $words * @param array $coresLower * @param array $parts */ private function applyAtEmailHeuristic(array &$words, array &$coresLower, array $parts): void { // Title-mode heuristic: keep "at" lowercase when it introduces an email address $isEmailToken = static function (string $core, string $trail): bool { // We don't parse full RFC email; this is a safe heuristic for title-casing: // core must contain '@' and at least one '.' after it. $s = $core . $trail; $atPos = strpos($s, '@'); if ($atPos === false) { return false; } return strpos($s, '.', $atPos + 1) !== false; }; $nextNonSpaceTokenAfter = static function (array $parts, int $startIndex): ?string { $n = count($parts); for ($p = $startIndex + 1; $p < $n; $p++) { if (preg_match('/^\s+$/u', $parts[$p]) === 1) { continue; } return $parts[$p]; } return null; }; $isEmailString = static function (string $token): bool { $atPos = strpos($token, '@'); if ($atPos === false) { return false; } return strpos($token, '.', $atPos + 1) !== false; }; $nWords = count($words); for ($i = 0; $i < $nWords; $i++) { if ($this->lower($words[$i]->core) !== 'at') { continue; } $isFollowedByEmail = false; // Case 1: next word exists (email was tokenized as a word, or next real word) if ($i < $nWords - 1) { $next = $words[$i + 1]; if ($isEmailToken($next->core, $next->trail)) { $isFollowedByEmail = true; } } else { // Case 2: "at" is the last word, but there may be a skipped token after it (like an email) $nextToken = $nextNonSpaceTokenAfter($parts, $words[$i]->partIndex); if (is_string($nextToken) && $isEmailString($nextToken)) { $isFollowedByEmail = true; } } if ($isFollowedByEmail) { $words[$i]->core = 'at'; $coresLower[$i] = 'at'; } } } /** * @param array $words * @param array $coresLower */ private function passTitleLowerOverrides(array &$words, array &$coresLower): void { // Pass 4b: title-only lowercase overrides (e.g., vs., v.) $nWords = count($words); for ($i = 0; $i < $nWords; $i++) { $coreLower = $this->lower($words[$i]->core); $trail = $words[$i]->trail; if ($coreLower === 'v' && strpos($trail, '.') !== false) { $words[$i]->core = 'v'; $coresLower[$i] = 'v'; continue; } if (isset($this->lowerLookup[$coreLower])) { $words[$i]->core = $this->lowerLookup[$coreLower]; $coresLower[$i] = $this->lowerLookup[$coreLower]; } } } /** * @param array $words * @param array $parts */ private function passAddressStateAbbreviations(array &$words, array $parts, string $normalized): void { // Pass 4c: title-only address heuristic for USPS state abbreviations if ($this->isName) { return; } $nWords = count($words); if ($nWords === 0) { return; } $stateSet = UspsStateCodes::set(); // Optional: state-only input (e.g., "in" -> "IN") if ($nWords === 1) { $core = $words[0]->core; if (preg_match('/^[A-Za-z]{2}$/', $core) === 1) { $upper = strtoupper($core); if (isset($stateSet[$upper])) { $words[0]->core = $upper; } } return; } if (!AddressDetector::isLikelyAddress($normalized)) { return; } $zipRe = '/^\\d{5}(?:-\\d{4})?$/'; for ($i = 0; $i < $nWords; $i++) { $core = $words[$i]->core; if (preg_match('/^[A-Za-z]{2}$/', $core) !== 1) { continue; } $upper = strtoupper($core); if (!isset($stateSet[$upper])) { continue; } $isLast = ($i === $nWords - 1); $nextIsZip = false; if (!$isLast) { $nextIsZip = preg_match($zipRe, $words[$i + 1]->core) === 1; } if ($isLast || $nextIsZip || $this->hasCommaBeforeWord($words, $parts, $i)) { $words[$i]->core = $upper; } } } /** * @param array $words * @param array $parts */ private function hasCommaBeforeWord(array $words, array $parts, int $index): bool { if ($index <= 0) { return false; } $prev = $words[$index - 1]; $curr = $words[$index]; if (strpos($prev->trail, ',') !== false) { return true; } if (strpos($curr->lead, ',') !== false) { return true; } $prevIndex = $prev->partIndex; $currIndex = $curr->partIndex; for ($p = $prevIndex + 1; $p <= $currIndex - 1; $p++) { $token = $parts[$p]; if (preg_match('/[\\p{L}\\p{M}\\p{N}]/u', $token) === 1) { continue; } if (strpos($token, ',') !== false) { return true; } } return false; } /** * @param array $words * @param array $coresLower */ private function passNameInitialismLikeUpper(array &$words, array &$coresLower): void { // Pass 5 (names only): uppercase short “initialism-like” chunks with no vowels (except last word) // This keeps the spirit of the old heuristic but reduces collateral damage. if (!$this->isName) { return; } $nWords = count($words); if ($nWords === 0) { return; } $vowelRe = '/[aeiouy]/i'; for ($i = 0; $i < $nWords - 1; $i++) { if ($words[$i]->type === TokenType::OPAQUE) { continue; } $core = $words[$i]->core; $orig = $words[$i]->origCore; // Only run the "initialism-like" uppercasing if the input already suggests an initialism. // This prevents "Ng Wei" -> "NG Wei" when input is normal name casing. $origLooksInitialism = ($this->isAllCapsWord($orig)) || (strpos($orig, '.') !== false) || (preg_match('/\d/u', $orig) === 1); if (!$origLooksInitialism) { continue; } // Avoid turning "Mc" into "MC" if (preg_match('/^mc/u', $this->lower($core)) === 1) { continue; } $lettersOnly = preg_replace('/[^\p{L}]+/u', '', $core); if (!is_string($lettersOnly) || $lettersOnly === '') { continue; } if ($this->len($lettersOnly) <= 4 && preg_match($vowelRe, $lettersOnly) !== 1) { $words[$i]->core = $this->upper($core); $coresLower[$i] = $this->lower($words[$i]->core); } } } /** * @param array $words */ private function passFinalTweaks(array &$words): void { // Pass 6: per-word tweaks (Mc…, MacD/MacV, possessive) foreach ($words as $wi => $w) { if ($w->type === TokenType::OPAQUE) { continue; } $core = $w->core; // Name-only: fix common apostrophe prefixes (O'Connor, D'Artagnan, L'Enfant, etc.) // Keep this narrow so it doesn't turn "y'all" into "Y'All". if ($this->isName) { $tmp = preg_replace_callback( '/^([odl])([\'’])(\p{L})/iu', fn (array $m): string => $this->upper($m[1]) . $m[2] . $this->upper($m[3]), $core ); $core = is_string($tmp) ? $tmp : $core; } // McX... => McX... (uppercase the letter after Mc) $tmp = preg_replace_callback( '/^mc(\p{L})/iu', fn (array $m): string => 'Mc' . $this->upper($m[1]), $core ); $core = is_string($tmp) ? $tmp : $core; // Fix possessive again in case earlier rules introduced "'S" $core = $this->fixPossessive($core); $words[$wi]->core = $core; } } /** * @param array $words */ private function applyOverrides(array &$words): void { if ($this->overrides === null) { return; } $map = $this->isName ? $this->overrides->namesMap() : $this->overrides->titleMap(); if ($map === []) { return; } $phraseOverrides = []; $wordOverrides = []; foreach ($map as $key => $value) { if (preg_match('/\s/u', $key) === 1) { $phraseOverrides[$key] = $value; } else { $wordOverrides[$key] = $value; } } $nWords = count($words); $locked = array_fill(0, $nWords, false); if ($phraseOverrides !== []) { $coresLower = array_map( fn (Word $w): string => $this->lower($w->core), $words ); foreach ($phraseOverrides as $key => $value) { $keyTokens = preg_split('/\s+/u', $key, -1, PREG_SPLIT_NO_EMPTY); $valueTokens = preg_split('/\s+/u', $value, -1, PREG_SPLIT_NO_EMPTY); if ($keyTokens === false || $valueTokens === false) { throw new \InvalidArgumentException('Invalid override phrase: ' . $key); } $keyCount = count($keyTokens); $valueCount = count($valueTokens); if ($keyCount !== $valueCount) { throw new \InvalidArgumentException( 'Override phrase "' . $key . '" must have ' . $keyCount . ' tokens; got ' . $valueCount . '.' ); } if ($keyCount === 0) { continue; } $keyTokensLower = []; foreach ($keyTokens as $token) { $keyTokensLower[] = $this->lower($token); } for ($i = 0; $i <= $nWords - $keyCount; $i++) { $blocked = false; for ($j = 0; $j < $keyCount; $j++) { if ($locked[$i + $j]) { $blocked = true; break; } } if ($blocked) { continue; } $match = true; for ($j = 0; $j < $keyCount; $j++) { if ($coresLower[$i + $j] !== $keyTokensLower[$j]) { $match = false; break; } } if (!$match) { continue; } for ($j = 0; $j < $keyCount; $j++) { $words[$i + $j]->core = $valueTokens[$j]; $locked[$i + $j] = true; } $i += $keyCount - 1; } } } if ($wordOverrides === []) { return; } $wordOverridesLower = []; foreach ($wordOverrides as $key => $value) { $wordOverridesLower[$this->lower($key)] = $value; } foreach ($words as $wi => $w) { if ($locked[$wi]) { continue; } $coreLower = $this->lower($w->core); if (isset($wordOverridesLower[$coreLower])) { $words[$wi]->core = $wordOverridesLower[$coreLower]; } } } /** * @param array $parts * @param array $words */ private function rebuildParts(array $parts, array $words): string { // Rebuild parts foreach ($words as $w) { $parts[$w->partIndex] = $w->lead . $w->core . $w->trail; } return implode('', $parts); } private function hasMb(): bool { return $this->hasMb; } private function lower(string $s): string { return $this->hasMb() ? mb_strtolower($s, $this->encoding) : strtolower($s); } private function upper(string $s): string { return $this->hasMb() ? mb_strtoupper($s, $this->encoding) : strtoupper($s); } private function len(string $s): int { return $this->hasMb() ? mb_strlen($s, $this->encoding) : strlen($s); } private function sub(string $s, int $start, ?int $length = null): string { if ($this->hasMb()) { return mb_substr($s, $start, $length, $this->encoding); } return $length === null ? substr($s, $start) : substr($s, $start, $length); } private function looksIntentionallyMixed(string $s): bool { return (bool) preg_match('/\p{Ll}\p{Lu}/u', $s); } private function isAllCapsWord(string $s): bool { // True if it contains at least one letter and equals its uppercase form return preg_match('/\p{L}/u', $s) === 1 && $s === $this->upper($s); } private function isAllLowerAscii(string $s): bool { return preg_match('/[a-z]/', $s) === 1 && preg_match('/[A-Z]/', $s) !== 1; } private function isAllUpperAscii(string $s): bool { return preg_match('/[A-Z]/', $s) === 1 && preg_match('/[a-z]/', $s) !== 1; } private function isRomanNumeralTitle(string $s): bool { return preg_match( '/^(?=[ivxlcdm])M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/iu', $s ) === 1; } private function isRomanNumeralName(string $s): bool { return preg_match( '/^(?:I|II|III|IV|V|VI|VII|VIII|IX|X|XI|XII|XIII|XIV|XV|XVI|XVII|XVIII|XIX|XX)$/iu', $s ) === 1; } private function titleCaseCore(string $core): string { // Lowercase everything, then uppercase letters at word-segment starts (Unicode-aware) $core = $this->lower($core); $out = preg_replace_callback( '/(^|[^\p{L}\p{M}\p{N}\'’])(\p{L})/u', fn (array $m): string => $m[1] . $this->upper($m[2]), $core ); return is_string($out) ? $out : $core; } private function fixPossessive(string $core): string { // Only fix *ending* "'S" / "’S" -> "'s" / "’s" $out = preg_replace('/([\'’])S$/u', '$1s', $core); return is_string($out) ? $out : $core; } /** * @param list $startsWithList * @param list $skipList * @param list $familyStartsWithList */ private function applyBiCapitalization( string $core, string $prefix, array $startsWithList, array $skipList, array $familyStartsWithList, bool $isFirstWord ): string { $lower = $this->lower($core); $prefixLower = $this->lower($prefix); $prefixLen = $this->len($prefixLower); if ($prefixLen === 0 || $this->sub($lower, 0, $prefixLen) !== $prefixLower) { return $core; } foreach ($skipList as $skip) { $needle = $this->lower($skip); $needleLen = $this->len($needle); if ($needleLen === 0 || $this->len($lower) < $needleLen) { continue; } if ($this->sub($lower, 0, $needleLen) === $needle) { return $core; } } $stemLower = $this->sub($lower, $prefixLen, null); $stemLen = $this->len($stemLower); $shouldBiCap = false; foreach ($startsWithList as $startWith) { $needle = $this->lower($startWith); $needleLen = $this->len($needle); if ($needleLen === 0 || $stemLen < $needleLen) { continue; } if ($this->sub($stemLower, 0, $needleLen) === $needle) { $shouldBiCap = true; break; } } if (!$shouldBiCap && !$isFirstWord) { foreach ($familyStartsWithList as $startWith) { $needle = $this->lower($startWith); $needleLen = $this->len($needle); if ($needleLen === 0 || $stemLen < $needleLen) { continue; } if ($this->sub($stemLower, 0, $needleLen) === $needle) { $shouldBiCap = true; break; } } } if (!$shouldBiCap) { return $core; } $nextChar = $this->sub($core, $prefixLen, 1); if ($nextChar === '') { return $core; } return $this->sub($core, 0, $prefixLen) . $this->upper($nextChar) . $this->sub($core, $prefixLen + 1, null); } }