Pull to refresh

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

Reading time 9 min
Views 28K
Несколько позже, чем хотелось, но продолжаем наш разговор о получении текста из разных форматов данных. Мы с вами уже познакомились с тем, как работать с изначально 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'е.

Литература


Как не надо делать:
Ссылки на другие статьи по теме «Текст любой ценой»:
Tags:
Hubs:
+62
Comments 25
Comments Comments 25

Articles