Текст любой ценой: WCBFF и DOC

    Несколько позже, чем хотелось, но продолжаем наш разговор о получении текста из разных форматов данных. Мы с вами уже познакомились с тем, как работать с изначально XML-base файлами (docx и odt), прочитали текст из pdf, преобразовали содержимое rtf в plain-text. Теперь перейдём в вкусненькому да сладенькому — формату DOC.

    Прежде, чем внимательный читатель задастся вопросом о странной аббревиатуре в заголовке, я всё же попрошу взглянуть на содержимое какого-нибудь doc-файла:



    Я думаю, что многие из нас на заре своей компьютерной грамотности пытались открыть doc-файлы блокнотом и видели похожие крякозябрики. Но давайте зададимся вопросом, что мы можем вынести из этого месива байтов, которое есть ничто иное, как всё тот же «Парус»? Самое интересное для нас здесь, это первые восемь байт, которые будут попадаться нам от файла к файлу, а именно "D0 CF 11 E0 A1 B1 1A E1" в hex'ах, или если угодно "РПаЎ±б" в блокноте.

    Вот теперь-то и стоит расшифровать второе сокращение в заголовке. WCBFF есть ничто иное, как Windows Compound Binary File Format, что по-русски звучит как «Windows Подворье двоичных файлов формата». Оставим перевод на совести корпорации и подумаем, чем нам поможет этот формат со страшным названием.

    Так вот, CFB является прародителем, или, даже правильней сказать, скелетом для всех форматов Microsoft Office от 97ой версии до 2007 (при сохранении в формате совместимости). Этот CFB используется не только для хранения Word'овского текста, но и для сохранения листов Excel'а или презентаций PowerPoint'а. Как следствие, нам придётся прочитать костяк, что «зашифрован» в CFB, а уж потом найти в прочитанных данных текст с учётом формата DOC.

    CFB или маленькая файловая система


    Первым этапом, как я уже сказал, будет чтение CFB. CFB представляет файловую структуру в миниатюре: с секторами, корневой директориями и некоторым подобием файлов. Даже проблемы у этого файла такие же, как у обычных ФС — фрагментрованность секторов, например. Поэтому без знания структуры формата этот файл прочесть будет делом не лёгким — благо Microsoft пару лет открыл документацию как по CFB, так и по всем остальным «надстроечным» форматам.

    Давайте попробуем понять, как упакована информация в CFB-файлах. Весь файл поделён на сектора — в 512 байт каждый (в новой, четвёртой, версии размер сектора может быть 4096 байт). В первом секторе находится заголовок файла, кусочек которого мы лицезрели на скриншоте выше. В нём (заголовке) содержится вся информация о том, как, что и в какой последовательности читать из файла.

    Данные в файле хранятся сегментами (FAT) в те самые 512 байт. При нехватке места в секторе-сегменте остаток данных переносится в следующий по цепочке. Секторы цепочки могут быть разбросаны по файлу (т.е. файл может быть фрагментирован, как отмечалось выше). Для поддержания целостности цепочки секторов существуют специальные сектора, которые содержат, в какой сектор переходить из текущего, если все данные не прочитаны. Конец цепочки характеризуется специальным словом ENDOFCHAIN = 0xFFFFFFFE.

    В связи с тем, что для некоторых данных 512 байт может быть очень много, существуют «миниатюрные» сектора, называемые mini FAT. Мини FAT-сектор имеет длину 64 байта, поэтому в один FAT-сектор может влезть 8 (или 64) таких маленьких сегмента. Выбор в сторону FAT или mini FAT осуществляется, исходя из полной длины текущих данных. Если она меньше 4096 байт (один из параметров заголовка файла), тогда стоит использовать mini FAT, в противном случае — FAT.

    Данные в CFB-файле не навалены просто так — они структурированы в некоторую древовидную структуру, с корнем в специальном «файловом вхождении» Root Entry. Каждое такое entry имеет длину в 128 байт (в один сегмет FAT влезает 4 или 32 вхождения) и характеризуется названием, типом (хранилище — storage, поток — stream, корневое хранилище — root storage, пустое пространство — unused), дочерним и «братскими» элементами, цветом в красно-чёрном дереве. Помимо этого для потоков и корневого элемента имеют место такие параметры как смещение и длина содержимого.

    Таким образом, каждое вхождение в ФС может характеризоваться «прикреплённым к нему содержимым». Для потоков это будут хранимые в них данные, для корневого элемента — mini FAT файла.

    Кроме того, в файле есть структура, называемая DIFAT, которая хранит ссылки на сектора с цепочками FAT-последовательностей. Первые 109 DIFAT-ссылок лежат в конце заголовка файла и могут «обслужить» файлы длиной до 8,5 Мб, если этого недостаточно, то в заголовке могут быть ссылки на дополнительный DIFAT-сектор, который может заканчивать ссылкой на следующий DIFAT и так далее.

    Эта информация вкратце характеризует весь тот разброд и шатания, что творится в CFB-файлах. Формат, в принципе, достаточно неплохо документирован (ссылки по обыкновению в конце топика), достаточно только вдумчиво и скрупулёзно читать мануалы. Целью этой статьи я не ставил полное объяснение работы CFB-файлов, поэтому перейдём к главному — как читать doc из этого всего…

    DOC или они украли мои смещения


    Для начала скажу, что я написал парсинг doc (вместе с cfb) лишь с третьей попытки. До этого что-то где-то как-то не так читалось. А причина тому, что всё нужно было делать по документации, но… если с CFB это не составляет больших проблем (разве что английский, как язык мануала), то с DOC проблемы обеспечены.

    Начнём с того, что мы прочитали файловую систему нашего DOC'а и жаждем найти в нём текстовые данные. Что ж, Microsoft открывший спецификацию сделал нам подарок и дал возможность это сделать. Для этого мы будем работать всего с двумя вхождениями в древовидную структуру элементов CFB-файла: это поток под названием «WordDocument» и поток с названием «0Table» или «1Table» в зависимости от ситуации.

    В первом потоке находится текст документа Word, но просто так его не достать. Всё ужасно бинарно, да и ко всему прочему в Unicode кодировке с обратным порядком байтов (как и во всех CFB-файлах, стоит отметить). В связи с этим для начала прочитаем несколько полей из FIB — File Information Block — что лежит в начале потока WordDocument и наполняется от версии к версии (в 97ом Word'е этот заголовок занимал около 700 байт, в 2007ом — уже больше 2000).

    В первую очередь прочитаем слово по смещению 0x000A, в котором найдём 0x0200 бит, единичка которого скажет нам, что мы будем иметь дело с таблицей 1Table, а ноль — с 0Table. Стоит отметить, что мне попадались файлы с обеими таблицами, поэтому бит придётся читать в любом случае.

    Дальше, нам нужно найти CLX — самую жопважную часть одной из выбранных ранее табличек. В этой структуре CompLeX хранятся смещения и длины последовательностей текстовых данных в потоке WordDocument. Длина и offset к CLX находятся в 0x01A2 и 0x01A6 DWORD'ах FIB'а «документарного потока». Получив эту информацию, мы считываем CLX из табличного потока и натыкаемся на затык…

    Дело в том, что CLX содержит две абсолютно разные структуры данных переменного размера — ненужную нам RgPrc и важную PlcPcd. Дело в том, что длина PgPrc может быть как нулевой, так любой. К счастью, документация не говорит, как именно отсекать первые данные от вторых, поэтому в конечном коде пришлось писать некоторого рода костыль, который, как ни странно, работает.

    После получения PlcPcd или, если быть более адекватным в названиях, Piece Table, мы можем разбить этот массив на два: массив cp — длины текстовых кусков (lcbi = cpi+1 - cpi) и pcd (piece descriptors). В каждом из последних содержится информация о смещении в WordDocument-потоке и характеристика fCompress — является ли этот кусок сжатым в Unicode, или это ANSI (Windows-1252).

    В полученных кусочках могут встречаться некоторые управляющие символы, например, вставка объекта или изображения. В моём коде часть из них удалено, парсинг остальных спецсимволов я оставляю читателю.

    Вариант кода


    Ну и как обычно в конце, кусочек из кода и ссылки на исходники:
    1. class doc extends cfb {
    2.     public function parse() {
    3.         parent::parse();
    4.  
    5.         $wdStreamID = $this->getStreamIdByName("WordDocument");
    6.         if ($wdStreamID === false) { return false; }
    7.  
    8.         $wdStream = $this->getStreamById($wdStreamID);
    9.  
    10.         $bytes = $this->getShort(0x000A, $wdStream);
    11.         $fComplex = ($bytes & 0x0004) == 0x0004;
    12.         $fWhichTblStm = ($bytes & 0x0200) == 0x0200;
    13.         $fcClx = $this->getLong(0x01A2, $wdStream);
    14.         $lcbClx = $this->getLong(0x01A6, $wdStream);
    15.  
    16.         $ccpText = $this->getLong(0x004C, $wdStream);
    17.         $ccpFtn = $this->getLong(0x0050, $wdStream);
    18.         $ccpHdd = $this->getLong(0x0054, $wdStream);
    19.         $ccpMcr = $this->getLong(0x0058, $wdStream);
    20.         $ccpAtn = $this->getLong(0x005C, $wdStream);
    21.         $ccpEdn = $this->getLong(0x0060, $wdStream);
    22.         $ccpTxbx = $this->getLong(0x0064, $wdStream);
    23.         $ccpHdrTxbx = $this->getLong(0x0068, $wdStream);
    24.  
    25.         $lastCP = $ccpFtn + $ccpHdd + $ccpMcr + $ccpAtn + $ccpEdn + $ccpTxbx + $ccpHdrTxbx;
    26.         $lastCP += ($lastCP != 0) + $ccpText;
    27.  
    28.         $tStreamID = $this->getStreamIdByName(intval($fWhichTblStm)."Table");
    29.         if ($tStreamID === false) { return false; }
    30.  
    31.         $tStream = $this->getStreamById($tStreamID);
    32.         $clx = substr($tStream, $fcClx, $lcbClx);
    33.  
    34.         $lcbPieceTable = 0;
    35.         $pieceTable = "";
    36.         $pieceCount = 0;
    37.  
    38.         $from = 0;
    39.         while (($i = strpos($clx, chr(0x02), $from)) !== false) {
    40.             $lcbPieceTable = $this->getLong($i + 1, $clx);
    41.             $pieceTable = substr($clx, $i + 5);
    42.  
    43.             if (strlen($pieceTable) != $lcbPieceTable) {
    44.                 $from = $i + 1;
    45.                 continue;
    46.             }
    47.             break;
    48.         }
    49.  
    50.         $cp = array(); $i = 0;
    51.         while (($cp[] = $this->getLong($i, $pieceTable)) != $lastCP)
    52.             $i += 4;
    53.         $pcd = str_split(substr($pieceTable, $i + 4), 8);
    54.  
    55.         $text = "";
    56.         for ($i = 0; $i < count($pcd); $i++) {
    57.             $fcValue = $this->getLong(2, $pcd[$i]);
    58.             $isANSI = ($fcValue & 0x40000000) == 0x40000000;
    59.             $fc = $fcValue & 0x3FFFFFFF;
    60.  
    61.             $lcb = $cp[$i + 1] - $cp[$i];
    62.             if (!$isANSI)
    63.                 $lcb *= 2;
    64.             else
    65.                 $fc /= 2;
    66.  
    67.             $part = substr($wdStream, $fc, $lcb);
    68.             if (!$isANSI)
    69.                 $part = $this->unicode_to_utf8($part);
    70.  
    71.             $text .= $part;
    72.         }
    73.  
    74.         return $text;
    75.     }
    76. }
    Код с комментариями вы можете получить на GitHub'е.

    Литература


    Как не надо делать:
    Ссылки на другие статьи по теме «Текст любой ценой»:
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 25
      0
      Отличный пост! Спасибо!
        +2
        Windows Подворье двоичных файлов формата

        Смешно)
          0
          была бы библиотека которая из DOC файлов и форматирование некоторое вырывала бы… для DOCX не так уж и сложно сделать…
          0
          WCBFF — это не «Windows Подворье двоичных файлов формата», а «Смешанный Бинарный Файловый Формат».
            +1
            А в каком переводчике этот вариант? :)

            PS. Compound — скорее «составной» а не «смешаный» в данном контексте.
              0
              В том же google, но если переводить слово отдельно. Составной — не звучит. А смешанный — подходящий синоним. ИМХО.
                +5
                Ну что Вы в самом деле — чтобы получить только текст в doc и cfb нужно прочитать порядка 100 страниц документации на неродном языке. Чтобы не делать текст совсем сухим и техническим, я и разбавил его маленькой, возможно плоской шуткой.

                Я бы название формата вообще расшифровал, как «структурированный бинарный файловый формат».
              +3
              Месьё, где Ваше чувство юмора?
              +1
              А из PSD не планируется текст вытягивать?
              Мнение гуглоразработчика по поводу этого формата есть:
              code.google.com/p/xee/source/browse/trunk/XeePhotoshopLoader.m?spec=svn28&r=11#107
              интересно будет почитать комменты хабровчанина ;)
                0
                Мне кажется вот это уже лишее.
                  0
                  Скажем так (без оглядки на структуру формата, его сложность и добрые слова разработчика) этот формат мне в данный момент не интересен. Более того, я могу сказать, что я не считаю DOC или CFB плохими форматами, в то время когда они были изобретены, они здорово увеличивали скорость работы с документами на нешибко скорых компьютерах. Вполне возможно, что PSD шёл по тому же пути.
                  0
                  Оказывается не так всё просто =(
                    0
                    Ага, совсем чуть-чуть непросто. Следующая цель — текст из PPT.
                    +1
                    Эта статья то, за что я люблю Хабрахабр! Спасибо.
                      0
                      Круто. вот уж не думал, что микрософт изобретут файловую систему внутри файла )
                      а вообще вопрос — какой может быть профит от фрагментирования?
                        0
                        А смысл избавляться от фрагментирования? Можно сохранить файл под другим именем из MS Word'а, скорее всего внутренняя фрагментация уменьшится.
                        0
                        Очень познавательно, читается на одном дыхании, спасибо
                          0
                          По идее везде в тексте стоит заменить CFB на CBF. Немного бросается в глаза.
                            0
                            А почему вы функцию unpack не используете?
                              0
                              Я тоже задавался этим вопросом, уже пост фактум :) Вообще, это скорее лень пролистать лишний раз документацию, чтобы найти правильный велосипед… Скажем так, каюсь — не прав :)
                              0
                              Проблема… функция unicode_to_utf8 работает странно…

                              прогоняю через mb_strtolower($text, 'UTF-8'); и текст в нижний регистр не переводится.

                              Через вашу функцию, которая docx конвертит в текст — текст нормально в нижний регистр переводится. mb_* функции не понимают, что это UTf-8 и отказыаются работать с такими текстами.

                                0
                                Вот одну строчку заменил на Iconv. помогло.
                                if (!$isANSI) $part = iconv("UTF-16","CP1251", $part); //$part = $this->unicode_to_utf8($part);
                                  0
                                  т.е. вот правильная строка: $part = iconv('Windows-1251','UTF-8', iconv(«UTF-16»,«CP1251», $part));
                                  0
                                  + Добавил обработчик на случай зацикленности. Число можно подобрать из рассчета максимального размера файла, который придется обрабатывать.

                                  Таким образом тот файл просто не будет обработан.
                                  while (($cp[] = $this->getLong($i, $pieceTable)) != $lastCP){ $i += 4; if($i>=200000)return ''; }
                                  0
                                  спасибо!

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

                                  Самое читаемое