= $len) { break; } $next = $content[$i]; // Soft return: \\ followed by newline → \n in output if ($next === "\n" || $next === "\r") { $text .= "\n"; $i++; // Skip \r\n combo if ($i < $len && $content[$i] === "\n" && $next === "\r") { $i++; } continue; } // Hex escape: \'xx (Windows-1252 byte) if ($next === '\'') { $i++; if ($i + 1 < $len) { $hex = substr($content, $i, 2); $byte = hexdec($hex); $text .= self::windows1252ToUtf8($byte); $i += 2; } continue; } // Unicode escape: \uNNNN? (decimal codepoint, ? is ANSI fallback to skip) if ($next === 'u' && $i + 1 < $len && (ctype_digit($content[$i + 1]) || $content[$i + 1] === '-')) { $i++; // past 'u' $numStr = ''; while ($i < $len && ($content[$i] === '-' || ctype_digit($content[$i]))) { $numStr .= $content[$i]; $i++; } $codepoint = (int)$numStr; // Handle negative values (RTF uses signed 16-bit) if ($codepoint < 0) { $codepoint += 65536; } $text .= self::codepointToUtf8($codepoint); // Skip the ANSI fallback character (single char after the number) if ($i < $len && $content[$i] !== '\\' && $content[$i] !== '{' && $content[$i] !== '}') { $i++; } continue; } // Control word: \word[N] followed by space or non-alpha if (ctype_alpha($next)) { $word = $next; $i++; while ($i < $len && ctype_alpha($content[$i])) { $word .= $content[$i]; $i++; } // Skip optional numeric parameter if ($i < $len && ($content[$i] === '-' || ctype_digit($content[$i]))) { $i++; while ($i < $len && ctype_digit($content[$i])) { $i++; } } // Space delimiter after control word is consumed (not part of text) if ($i < $len && $content[$i] === ' ') { $i++; } // \par = paragraph break if ($word === 'par') { $text .= "\n"; } // All other control words are formatting → skip continue; } // Escaped special characters: \{ \} \\ if ($next === '{' || $next === '}') { $text .= $next; $i++; continue; } if ($next === '\\') { // Literal backslash — but in ProPresenter context this is soft return $text .= "\n"; $i++; continue; } // Other escaped chars: skip the backslash, keep the char $i++; continue; } // Regular newlines in RTF source are just whitespace (not meaningful) if ($ch === "\n" || $ch === "\r") { $i++; continue; } // Regular character → output $text .= $ch; $i++; } return trim($text); } /** * Convert a Windows-1252 byte value to UTF-8 string. * Windows-1252 is a superset of ISO-8859-1 with extra chars in 0x80-0x9F range. */ private static function windows1252ToUtf8(int $byte): string { // The 0x80-0x9F range differs from Unicode in Windows-1252 static $cp1252 = [ 0x80 => 0x20AC, 0x82 => 0x201A, 0x83 => 0x0192, 0x84 => 0x201E, 0x85 => 0x2026, 0x86 => 0x2020, 0x87 => 0x2021, 0x88 => 0x02C6, 0x89 => 0x2030, 0x8A => 0x0160, 0x8B => 0x2039, 0x8C => 0x0152, 0x8E => 0x017D, 0x91 => 0x2018, 0x92 => 0x2019, 0x93 => 0x201C, 0x94 => 0x201D, 0x95 => 0x2022, 0x96 => 0x2013, 0x97 => 0x2014, 0x98 => 0x02DC, 0x99 => 0x2122, 0x9A => 0x0161, 0x9B => 0x203A, 0x9C => 0x0153, 0x9E => 0x017E, 0x9F => 0x0178, ]; $codepoint = $cp1252[$byte] ?? $byte; return self::codepointToUtf8($codepoint); } /** * Convert a Unicode codepoint to a UTF-8 encoded string. */ private static function codepointToUtf8(int $codepoint): string { return mb_chr($codepoint, 'UTF-8'); } }