Pull to refresh

Текст любой ценой: RTF

Reading time 14 min
Views 85K
Что ж продолжим наши изыскания на предмет получения текста из различных форматов данных. Не так давно мы с вами научились вытаскивать текст из zipped-xml-based файлов (odt и docx), а также, в начале этой недели, из pdf. Сегодня мы продолжим с обещанным rtf.

Rich Text Format (он же rtf), вы могли бы подумать, достаточно забытый, хотя и не очень сложный формат представления текстовых данных. Что ж, относительно несложный для получения текста, но за свою историю: от своей первой версии до текущей 1.9.1 — он приобрёл под 300 страниц официально документации и огромное количество надстроек, которые в большей степени нам будут мешать при получении plain text'а. Попробуем их обойти...

А что там внутри?


Как уж повелось давайте заглянем вовнутрь rtf-файла и посмотрим, что там внутри:



Что мы видим? Я вижу наше любимое стихотворение «Парус». Мы видим изначально текстовый 8-битный формат данных. Это уже радует — когда в исходных данных текст, понимать, что происходит, гораздо проще. Теперь давайте разберёмся, как эти самые данные прочитать. Для этого я расскажу немного теории по теме.

Будем считать, что rtf состоит из управляющих слов, которые могут быть сгруппированы во вложенные множества. Управляющие слово начинается на обратный слэш (\), группа обёрнута в фигурные скобки ({ и }).

Управляющие слово состоит из последовательности букв английского алфавита (от a до z) и может быть завершено численным параметром (возможно отрицательным). Как вариант, слово может содержать один не цифро-буквенный ascii-символ. Всё, что не подпадает под эти правила, не является частью управляющего слова. Таким образом, последовательность вида \rtf1\ansi\ansicpg1251 без проблем делится на три слова rtf с параметром 1 (major-версия формата), ansi (текущая кодировка) и ansicpg с параметром 1251 (текущая кодовая страница под номером 1251 — т.е. Windows-1251).

Группированные множества определяют область действия управляющих слов. Таким образом, управляющие слова описанные внутри фигурных скобок работают только внутри них и всех дочерних подмножеств. Для того, чтобы правильно отработать какие слова имеют место сейчас — требуется вести стек управляющих слов. При открытии фигурной скобки создавать новый элемент-массив в стеке, в который сразу же добавлять данные предыдущего слоя стека, при закрытии скобки — удалять самый верхний слой.

Ещё стоит отметить, что некоторые управляющие слова могут быть закрыты с помощью добавления параметра ноль, а не создания новой подгруппы. Например, следующие варианты эквивалентны: This is {\b bold} text, This is \b bold \b0 text = This is bold text.

Откуда брать текст?


С устройством нового для нас формата мы познакомились, теперь зададимся вопросом, а где брать текст. Тут всё не так сложно, как может показаться — текст надо брать там, где текущая последовательность не идентифицируется, как управляющее слово. С парой исключений, естественно.

Во-первых, стоит отметить, что исходная кодировка rtf-файла — это ANSI, поэтому без всякий изысков сохранится только, английский текст. Нас же интересует, как минимум, русский текст, а ещё лучше Unicode, не так ли? Что правда, то правда — rtf хоть и старый формат, но сгодится на сохранение и того и другого.

Итак, в rtf'е есть возможность использования второй половины таблицы ASCII, та что от 128 и выше. С учётом текущей кодировки (выше управляющее слово \ansicpg), конечно же. Для этого в RTF была введена последовательность вида \'hh, где hh — это двоичный hex-код символа из таблицы ASCII.

Ну и второй, более интересный вариант, это unicode-кодированные данные. Для них в формат включено лаконично короткое ключевое слово \uABCD с цифровым параметром ABCD. ABCD в данном случае код unicode-символа в десятичной системе счисления. Всё опять просто, как вы могли заметить.

Просто, да не очень. В rtf существует ещё одно ключевое слово \ucN, которое тесно связано с Unicode. Дело в том, что формат RTF очень рьяно поддерживает совместимость со старыми устройствами, на которых возможно придётся открывать данный файл. Как вариант, подобное устройство (ну например компьютер с Windows 3.11 :) не сможет прочитать Unicode, что ему делать? Для этого после каждого unicode-символа, шифрованного ключевым словом \u может быть указано от нуля до нескольких символов, которые должны быть отображены в случае, если rtf-viewer не способен отобразить или разобрать текущие данные (по документации, если просмотрщик не может отобразить верно данные, он должен их пропустить).

В связи с этим, большинство современных редакторов после unicode-управляющего слова ставят символ вопроса, как знак, что требуется показать вместо текущего символа. Но возможны и варианты, например: Lab\u915GValue. Зададимся вопросом — сколько символов требуется отобразить, если нет возможности показать Unicode. Всё опять же не очень сложно — указанное выше ключевое слово \ucN в качестве параметра N как раз и предоставляет это значение. Т.е. перед Unicode-данными обязательно появится что-то типа \uc1, что скажет нам пропустить один символ после unicode'а.

Давайте почитаем!


Похоже, что накопленных нами данных будет достаточно, чтобы прочитать наши первые rtf-файлы. Поехали:

  1. function rtf_isPlainText($s) {
  2.     $failAt = array("*", "fonttbl", "colortbl", "datastore", "themedata");
  3.     for ($i = 0; $i < count($failAt); $i++)
  4.         if (!empty($s[$failAt[$i]])) return false;
  5.     return true;
  6. }
  7. function rtf2text($filename) {
  8.     $text = file_get_contents($filename);
  9.     if (!strlen($text))
  10.         return "";
  11.     $document = "";
  12.     $stack = array();
  13.     $j = -1;
  14.     for ($i = 0; $i < strlen($text); $i++) {
  15.         $c = $text[$i];
  16.         switch ($c) {
  17.             case "\\":
  18.                 $nc = $text[$i + 1];
  19.                 if ($nc == '\\' && rtf_isPlainText($stack[$j])) $document .= '\\';
  20.                 elseif ($nc == '~' && rtf_isPlainText($stack[$j])) $document .= ' ';
  21.                 elseif ($nc == '_' && rtf_isPlainText($stack[$j])) $document .= '-';
  22.                 elseif ($nc == '*') $stack[$j]["*"] = true;
  23.                 elseif ($nc == "'") {
  24.                     $hex = substr($text, $i + 2, 2);
  25.                     if (rtf_isPlainText($stack[$j]))
  26.                         $document .= html_entity_decode("&#".hexdec($hex).";");
  27.                     $i += 2;
  28.                 } elseif ($nc >= 'a' && $nc <= 'z' || $nc >= 'A' && $nc <= 'Z') {
  29.                     $word = "";
  30.                     $param = null;
  31.                     for ($k = $i + 1, $m = 0; $k < strlen($text); $k++, $m++) {
  32.                         $nc = $text[$k];
  33.                         if ($nc >= 'a' && $nc <= 'z' || $nc >= 'A' && $nc <= 'Z') {
  34.                             if (empty($param))
  35.                                 $word .= $nc;
  36.                             else
  37.                                 break;
  38.                         } elseif ($nc >= '0' && $nc <= '9')
  39.                             $param .= $nc;
  40.                         elseif ($nc == '-') {
  41.                             if (empty($param))
  42.                                 $param .= $nc;
  43.                             else
  44.                                 break;
  45.                         } else
  46.                             break;
  47.                     }
  48.                     $i += $m - 1;
  49.                     $toText = "";
  50.                     switch (strtolower($word)) {
  51.                         case "u":
  52.                             $toText .= html_entity_decode("&#x".dechex($param).";");
  53.                             $ucDelta = @$stack[$j]["uc"];
  54.                             if ($ucDelta > 0)
  55.                                 $i += $ucDelta;
  56.                         break;
  57.                         case "par": case "page": case "column": case "line": case "lbr":
  58.                             $toText .= "\n"; 
  59.                         break;
  60.                         case "emspace": case "enspace": case "qmspace":
  61.                             $toText .= " "; 
  62.                         break;
  63.                         case "tab": $toText .= "\t"; break;
  64.                         case "chdate": $toText .= date("m.d.Y"); break;
  65.                         case "chdpl": $toText .= date("l, j F Y"); break;
  66.                         case "chdpa": $toText .= date("D, j M Y"); break;
  67.                         case "chtime": $toText .= date("H:i:s"); break;
  68.                         case "emdash": $toText .= html_entity_decode("&mdash;"); break;
  69.                         case "endash": $toText .= html_entity_decode("&ndash;"); break;
  70.                         case "bullet": $toText .= html_entity_decode("&#149;"); break;
  71.                         case "lquote": $toText .= html_entity_decode("&lsquo;"); break;
  72.                         case "rquote": $toText .= html_entity_decode("&rsquo;"); break;
  73.                         case "ldblquote": $toText .= html_entity_decode("&laquo;"); break;
  74.                         case "rdblquote": $toText .= html_entity_decode("&raquo;"); break;
  75.                         default:
  76.                             $stack[$j][strtolower($word)] = empty($param) ? true : $param;
  77.                         break;
  78.                     }
  79.                     if (rtf_isPlainText($stack[$j]))
  80.                         $document .= $toText;
  81.                 }
  82.                 $i++;
  83.             break;
  84.             case "{":
  85.                 array_push($stack, $stack[$j++]);
  86.             break;
  87.             case "}":
  88.                 array_pop($stack);
  89.                 $j--;
  90.             break;
  91.             case '\0': case '\r': case '\f': case '\n': break;
  92.             default:
  93.                 if (rtf_isPlainText($stack[$j]))
  94.                     $document .= $c;
  95.             break;
  96.         }
  97.     }
  98.     return $document;
  99. }
Код с комментариями вы можете получить на GitHub'е.

Заключение


Что мы имеем в итоге? Данный код справится верно с большинством rtf-файлов, но есть несколько способов его улучшить. Во-первых, стоит добавить дополнительные отсечения на нетекстовые данные — у меня отсекаются только шрифты, цветовая палитра, тема оформления, бинарные данные, а также всё, что помечено, как «не читай меня, если не можешь» (\*). Во-вторых же, стоит ещё распарсить кодировку и кодовую страницу, для того чтобы вернее отобразить ключевые слова вида \'hh.

Что дальше? Дальше я бы хотел затронуть форматы электронных книг, такие как fb2, epub и подобные им. В связи с этим, я хотел бы обратиться за помощью к читателям: во-первых, какие ещё форматы электронных книг стоит посмотреть, а во-вторых, где можно найти побольше файлов, указанных вами форматов. Заранее спасибо :)

Ссылки:
Tags:
Hubs:
+53
Comments 49
Comments Comments 49

Articles