19 сентября 2009 в 16:24

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

PHP*
Что ж продолжим наши изыскания на предмет получения текста из различных форматов данных. Не так давно мы с вами научились вытаскивать текст из 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 <b>bold</b> 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 и подобные им. В связи с этим, я хотел бы обратиться за помощью к читателям: во-первых, какие ещё форматы электронных книг стоит посмотреть, а во-вторых, где можно найти побольше файлов, указанных вами форматов. Заранее спасибо :)

Ссылки:
+53
16952
145
Rembish 81,5

Комментарии (49)

0
CAJAX, #
Хотелось бы увидеть код для DJVU, есди это возможно.
–3
init0, #
Зачем? Там ведь только картинки.
+2
overPlumbum, #
там может быть текстовый слой. но это скорее редкость
0
CAJAX, #
Да, «текстовое» происхождение у них очень редкое, чаще скан.
Хотя пару раз натыкался на документы, которые были сканом с текстовым слоем из FineReader.

Тут больше любопытство и спортивный интерес :)
0
Rembish, #
Если бы «спортивный интерес» решал бы реальную задачу поискового индексирования, то я бы взялся — а так…
0
overPlumbum, #
надо только не забывать что чаще всего в DJVU нет текстового слоя, а есть только картинка с текстом — её можно разве что только распознать каким-нибудь FineReader-ом, но это уже совсем другая тема.
0
Rembish, #
Да, Вы правы, я глядел в сторону .djvu, но, «потрогав» несколько файлов, отказался от идеи.
0
semenovs, #
Только хотел написать что RTF напомнил мне по структуре LaTex, а у вас и ссылка в конце стоит :)
0
ZxAnderson, #
Ошибка:
#14 for ($i =; $i < strlen($text); $i++) {
0
ZxAnderson, #
И еще тут:
#54 if ($ucDelta > )
0
Rembish, #
Спасибо, конечно, но это хайлайтер highlight.hohli.com/ как-то странно формирует выходной поток, что хабр обрезает единичку — в комментированном исходнике по ссылке, всё нормально.
0
ZxAnderson, #
Угу. Заметил, что по ссылке все ок.
Вопрос может немного не в тему, подскажите — как объединить два rtf файла в один?
Т.е. есть договор и приложение к нему двумя файлами. Как сделать один единый документ?
0
Rembish, #
Ну так навскидку — сделать merge стилей (добавить недостающие шрифты/палитры в \fonttbl и \colortbl), оставшееся приложение с учётом изменений ссылок на стили дописать перед последней }. Из коробки у меня решения нет :)
+1
overPlumbum, #
поидее если просто склеить тела документов, то всё должно быть хорошо.
только посередине стоит вставить перевод страницы (\page ) или строки (\par или \line — по ситуации) и хотя бы сброс стилей параграфа ( \pard )

пример: есть два документа {\rtf1… hello… } и {\rtf1… bye… }
если склеить должно получится:
{\rtf1… hello… \pard \page… bye… }

возможно, переопределять стили, шрифты и цвета второй раз не очень хорошо. но на первый взгляд работает.
0
ZxAnderson, #
Спасибо конечно, это вроде и так понятно. Но проблема как раз-таки и состояла в том, как отделить тело от хедера и футера. Попробуйте создать два документа (желательно побольше, может даже вставить текст какого-то договора) в MS Word в формате rtf и склеить их через блокнот. Наверное тогда вы лучше сможете понять мою проблему.
0
evra, #
forum.script-coding.info/viewtopic.php?id=4832
Использовала, проблема — поддержка только одноколоночного формата.
www.sobolsoft.com/howtouse/combine-rtf-files.htm — здесь проблема поменьше — 30 долларов :)
Если кто знает как без проблем — просьба отписать.
+1
Rhaps107, #
выскажусь в защиту rtf — формат читается как на винде, так и на линуксе (убунту) без установки доп. софта (ворда или openoffice), в отличие, например от doc. В тоже время в rtf можно оформлять текст, делать списки. Поэтому резюме в нем храню.
0
Rembish, #
Вообще, его никто не ругает — хороший формат. При отсутствии носителя можно открыть небольшой файл в блокноте и выписать содержимое на листочек :)
0
Rhaps107, #
Не ругаете, но у вас он
> достаточно забытый

А у меня вовсю используется. Компромиссный вариант между просто текстом и громоздким и требовательным к ПО doc.
0
Rembish, #
Что ж согласен, для мультиплатформенности и нересурсоёмкости — самое оно. Да и работает, как правильно подметили, из коробки.
+1
mayhem, #
очень даже используется. это один из самых безболезненных способов делать на сайтах автоматически генерируемые документы, которые можно потом открыть в ворде.
0
Rembish, #
Уговорили, я совсем забыл, что многие ставят метки в rtf, а потом на их место — вставляют данные. Каюсь :)
0
bolk, #
Ошибка:

$text = file_get_contents($filename);
if (empty($infile))
return "";

вероятно, тут $text на пустоту должен проверяться? Кстати, проверка empty не очень хороша, потому что даст true на тексте '0'.

Кроме того, выносите вычисление длины и прочего за условие цикла:

for ($i = 0; $i < strlen($text); $i++)

замените на

for ($i = 0, $len = strlen($text); $i < $len; $i++)

иначе у вас strlen будет вычислять значение на каждом шаге цикла
0
Rembish, #
Что ж с первой «ошибкой» согласен — свой копипейст нужно проверять ;)
А про strlen — соглашусь, что каждый раз. Но я не писал самый оптимальный алгоритм (если глянуть на чтение мной pdf'а, так там вообще оптимизировать и оптимизировать). Но всё равно, Евгений, спасибо — добавил в исходном коде по ссылке.
+1
Jean_Valjean, #
Интересно.

Было бы здорово, если бы вы написали, как средствами PHP «собрать» Excel файл, причем — прежней Эксели (1997-2003), а не новой — 2007 года.
+4
intellinside, #
возможно Вам будет полезно вот это
+1
Rembish, #
Да, например, это. На мой взгляд писать Excel никогда особой проблемы не представляло.
–1
NULL_byte, #
CSV рулит.
+1
Rembish, #
Вы мешаете мух с котлетами — cvs только даёт возможность предоставить данные в виде таблицы — никакого форматирования и тому прочих рюшек. Всё зависит от задачи, так что CVS просто есть — рулить он не может.
0
volinrok, #
Можно просто вывести данные в HTML табличку и

В любой версии Excel откроется. Можно любые стили применять, только не подключать внешние стайлшиты. То же самое с вордом:
0
volinrok, #
Можно просто вывести данные в HTML табличку и добавить соответствующий хидер:
header(«Content-type: application/vnd.ms-excel»);
header(«Content-Disposition: attachment;Filename=document_name.xls»);

В любой версии Excel откроется. Можно любые стили применять, только не подключать внешние стайлшиты.

То же самое с вордом:
header(«Content-type: application/vnd.ms-word»);
header(«Content-Disposition: attachment;Filename=document_name.doc»);
0
Rembish, #
Я понимаю, что откроется. Но все эти ухищрения не от хорошей жизни, так ведь? Тётенька из бухгалтерии вполне вероятно вашу табличку откроет в браузере и завопит, что у ней Excel сломался. Давайте мыслить масштабней.
0
volinrok, #
Тетенька не сможет открыть этот файл так просто в браузере — это будет файл с расширением XLS. Понятно что это не полноценный Excel файл, но задачу решает более чем в 90% случаев. Опять же, там можно сделать любое форматирование.
0
Rembish, #
Никто не говорит, что Ваш вариант неверный, неправильный. Просто Вы идёте простым путём, а я напролом, но идеологически правильней. К тому же Вы по большей части рассуждаете о записи данных — меня интересует чтение.

Почему именно чтение, да потому что в интернетах по сервакам разбросаны документы именно в doc'ах, rtf'ках и odt'шках, а не всё подряд в переименованном html'е. Индексирование — вот первоцель статей.
0
Kasheftin, #
Пытаюсь приспособить Ваш скрипт для своих нужд, но не очень получается (с rtf первый раз). Поможете?
У меня куча файлов типа такого route2.ru/obzor/data/as1075.rtf, в wordpad вполне прилично выглядят. В них нет картинок, но встречается текст разной жирности, которую нужно вытаскивать. Из-за этого я подумал, что хорошо бы конвертировать rtf не просто в plain text, но в html. Возникает проблема — по-видимому, разные версии wordpad генерят разный rtf. В моих исходниках когда начинается жирный текст, ставится /b, но в конце тега /b0 нет. Если же я открою файл и пересохраню, то он становится гораздо красивее, в полтора раза меньше, и появляются /b0. Но работать нужно с исходниками. Есть идеи, как отлавливать, что тег /b закончился?
–2
ifrond, #
Это адаптированный перевод?
Вроде бы вот оригинал
www.webcheatsheet.com/php/reading_the_clean_text_from_rtf.php
0
Rembish, #
Вау! Меня переводят и перепечатывают (скрежещет зубами из-за позорного плагиата)! Уверяю Вас писал всё сам, это легко приметить, в тексте на англоязычном сайте упоминается кодировка cp1251 — странно, что английский/американский программист по умолчанию разбирает текст в кирилице ;)

Надо бы написать им письмо с просьбой поставить метку о том, что это перевод статьи.
0
Rembish, #
Более того, на скриншоте на вышеупомянутом сайте совсем другие данные, нежели обсуждаются в статье. Да и посмотрев мои остальные статьи, Вы вполне можете сделать вывод, что изыскания я делал сам — копаться надо было глубоко, а скрипты-поделки из интернетов мне не подходили.
0
deiww, #
Хорошая, а главное простая статья как генерировать RTF на основе шаблонов:
www.freeweb.hu/php5unleashed/ch28lev1sec1.html
рекомендую. Сам долго искал.
0
alekztrz, #
Может кому пригодится: PHPRtfLite. Библиотека для работы с RTF, поддерживает работу с таблицами, картинками, колонтитулами. В свое время помогла.
0
maxG, #
Для меня было важно дополнить массив $failAt метода rtf_isPlainText() значением «pict». В моем случае это была картинка.
0
Fadeev, #
Не работает скрипт. Выдает не в той кодировке. Пример — Ïðàâèëà
0
Rembish, #
Милый мой Fadeev, перед тем как размахивать шашкой и кричать, что скрипт не работает, наверное всё же стоит заглянуть вовнутрь и понять, что же происходит. Советую внимательно рассматривать строчку №232 до просветления. Если последнее не появится, увы и ах — похоже программирование не для Вас. Удачи Вам.
0
Fadeev, #
Без этого комментария я сразу с этой строчкой и начал работать, пока, правда, без результата.
0
Fadeev, #
И даже если делать вот так HTML-Entities → CP1252 → CP1251
то все равно появляются пробелы в тех местах, где не нужно.
0
Rembish, #
В скрипте — windows-1250 — это среднеевропейская кодировка (я нахожусь в Чехии, халтура предполагала работу с чешским языком). Вы пытаетесь сконвертировать latin-1 (или если угодно windows-1252) в windows-1251.

Пробелы вполне могут появляться из-за того, что скрипт неверно работает с мультибайтовыми кодировками с пустым верхним байтом. Исправлять это не буду, не пишу в PHP больше четырёх лет. Если у Вас есть возможность, желание и знания разобраться, как и почему это не работает, и как это исправить — сделайте pull request.

Ещё раз удачи.
0
Fadeev, #
Не нашел функцию to_windows1250() строка 90

Полагаю стоит сделать to_windows1251(), на нее проверки нет совсем, а на windows-1250 — есть.
0
Rembish, #
Да, похоже код to_windows1250 куда-то убежал (считайте, потерялся в вечности). Повторюсь, что это была чешская халтура, получение кирилицы в тот момент мне было не интересно.
0
Fadeev, #
del *не в ту ветку*

Только зарегистрированные пользователи могут оставлять комментарии.
Войдите, пожалуйста.