Last updated: August 25, 2026.
Rich Text Format stores visible text together with control words, nested groups, font tables, color tables, embedded objects, and encoded characters. A full RTF reader is the safest choice for arbitrary documents. The function below is a compact extractor for common text-focused RTF files.
Common RTF elements
- Braces create groups whose formatting state is inherited by nested groups.
- Control words begin with a backslash and may have a numeric parameter.
\'hhrepresents a byte in the active Windows code page.\uNrepresents a Unicode UTF-16 code unit.- Destinations such as font tables, pictures, and metadata are not visible body text.
PHP extractor for common documents
<?php
declare(strict_types=1);
function rtfToText(string $rtf): string
{
$skipWords = array_fill_keys([
'fonttbl', 'colortbl', 'stylesheet', 'info', 'pict', 'object',
'header', 'footer', 'fldinst', 'datastore', 'themedata'
], true);
$stack = [];
$state = ['skip' => false, 'uc' => 1, 'codepage' => 1252];
$output = '';
$fallback = 0;
$length = strlen($rtf);
for ($i = 0; $i < $length; $i++) {
$char = $rtf[$i];
if ($char === '{') { $stack[] = $state; continue; }
if ($char === '}') { $state = array_pop($stack) ?? $state; continue; }
if ($char !== '\\') {
if ($fallback > 0) { $fallback--; continue; }
if (!$state['skip'] && $char !== "
" && $char !== "
") $output .= $char;
continue;
}
$next = $rtf[$i + 1] ?? '';
if (str_contains('\\{}', $next)) {
if (!$state['skip']) $output .= $next;
$i++; continue;
}
if ($next === "'") {
$hex = substr($rtf, $i + 2, 2);
if (!$state['skip'] && ctype_xdigit($hex)) {
$output .= iconv('Windows-' . $state['codepage'], 'UTF-8//IGNORE', chr(hexdec($hex)));
}
$i += 3; continue;
}
if ($next === '*') { $state['skip'] = true; $i++; continue; }
if ($next === '~' && !$state['skip']) { $output .= ' '; $i++; continue; }
if (!preg_match('/\G\\([a-z]+)(-?\d+)? ?/Ai', $rtf, $match, 0, $i)) continue;
$word = strtolower($match[1]);
$number = isset($match[2]) ? (int) $match[2] : null;
$i += strlen($match[0]) - 1;
if (isset($skipWords[$word])) $state['skip'] = true;
elseif ($word === 'ansicpg' && $number) $state['codepage'] = $number;
elseif ($word === 'uc' && $number !== null) $state['uc'] = max(0, $number);
elseif ($word === 'u' && $number !== null && !$state['skip']) {
$unit = $number < 0 ? $number + 65536 : $number;
$output .= mb_convert_encoding(pack('n', $unit), 'UTF-8', 'UTF-16BE');
$fallback = $state['uc'];
} elseif (($word === 'par' || $word === 'line') && !$state['skip']) $output .= "
";
elseif ($word === 'tab' && !$state['skip']) $output .= " ";
}
$output = preg_replace('/[ \t]+\n/', "
", $output) ?? $output;
$output = preg_replace('/\n{3,}/', "
", $output) ?? $output;
return trim($output);
}
$rtf = file_get_contents(__DIR__ . '/document.rtf');
echo rtfToText($rtf === false ? '' : $rtf);Limitations
This compact parser does not reproduce tables, list numbering, fields, bidirectional text, surrogate pairs, or every RTF destination. Treat the extracted result as untrusted plain text and HTML-escape it before displaying it on a web page. For document conversion where fidelity matters, use a maintained RTF library or an isolated document-conversion service and test it with files from your users.
Nice compact code! Works perfectly if you replace the apostrophes with quotation marks in the “Skip trash” line:
case “\0”: case “\r”: case “\f”: case “\n”:
Also regards tables if you extend the “Select line feeds, spaces and tabs” lines:
case “par”: case “page”: case “column”: case “line”: case “lbr”: case “row”:
$toText .= “\n”;
break;
case “emspace”: case “enspace”: case “qmspace”: case “cell”:
$toText .= ” “;
break;
Doesn’t work, getting multiple errors