0,0
рейтинг
14 сентября 2009 в 14:35

Разработка → Текст любой ценой: PDF

PHP*
Продолжаем разбирать текстовые форматы на предмет получения текста. Итак, обещанный ранее PDF.

С portable document format'ом не всё так просто, как DOCX или ODT, что мы рассматривали в прошлый раз, но всё же это всё ещё изначально текстовый, а не бинарный формат. Вы удивлены? Тогда давайте посмотрим на то, что там внутри. Дальше действительно много текста.



Как вы могли заметить, перед нами вполне себе «текстовый» документ, с вкраплениями бинарных данных. Конечно, как книгу pdf в блокноте не почитаешь, но понимать, что написано и что в последствии будет отображено на экране, вполне возможно. Заранее отмечу, что целью этой статьи не является описание формата данных, поэтому буду рассказывать по существу: «Где искать текст?» Более подробную информацию по формату PDF вы найдёте по ссылкам в конце этого небольшого руководства.

Типы данных PDF


PDF поддерживает несколько базовых типов данных (если быть точно восемь), часть из которых нам понадобится для работы — это строки (strings), массивы (arrays), словари (distionaries), потоки (streams) и объекты (objects). Остановимся на каждом.

Строки
Строки PDF унаследовал от PostScript, как следствие, под строкой в .pdf подразумевается последовательность 8-битных символов, окружённая круглыми скобками. String может перенесена на следующую строку с помощью обратного слэша, который не является частью строки и, помимо всего прочего, экранирует спецсимволы:

(First line \
First line \n Second line with brackets \(\))

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

First line First line
Second line with brackets ()

Из-за своей изначальной восьмибитовости в PDF есть несколько способов для вставки текстовых данных, например, в той же кодировке Unicode. Мы можем использовать вставку по восьмеричным кодам символа (\053), с помощью отдельного двухбайтового hex'а (<2B>) или даже их последовательности (<54776F20>). Например, следующие строки эквивалентны:

(Two + two = four.)
(Two \053 two \075 four.)
(Two <2B> two <3D> four.)
(<54776F202B2074776F203D20> four.)

В строках мы в будущем научимся искать текстовые данные, которые содержит в себе PDF-документ.
Массивы
Массивы в PDF заключаются в квадратные скобки и представляют собой просто последовательность группированных объектов. Например: [(Hello,)10(world!)]. Массивы подчас содержат текстовые строки.

Словари
Это обрамлённые в << и >> пары ключ-значение. Словарь часто используется для наделения объекта, который его содержит, свойствами, что описаны в dictionary. Нам же эти данные помогут определить, как, например, расшифровать поток, узнать его длину или, наоборот, отбросить текущий объект, как неинтересный (если это изображение). Перед вами пример обычного PDF-словаря:

<<
  /Length 681
  /Filter 
  /FlateDecode
>>

После чтения, мой код представит его в виде:

$dictionary = array(
    "Length" => "681",
    "Filter" => true,
    "FlateDecode" => true,
);
Потоки
Потоки представляют последовательность восьмибитных данных между ключевыми словами stream и endstream. Любые бинарные данные, будь-то сжатый текст, изображение или внедрённый шрифт, будут представлены в виде потока. Поток всегда находится внутри объекта (чуть ниже) и характеризуется, как минимум, своей длиной (опция /Length N в словаре) и очень часто методом сжатия (например, /Filter /FlateDecode). PDF поддерживает достаточное количество форматов сжатия (в том числе и формат шифрования /CryptDecode), нас же будут интересовать лишь три: наиболее часто используемый Flate (gzip-сжатие) и более редкие ASCII Hex (представление данных в виде шестнадцатеричной строки с конечным символом >) и ASCII 85-based (сжатие, когда подряд идущие 4 символа исходного текста кодируются 5 символами от ! до y в ASCII таблице).

В stream'ах мы будем искать текст, который хотим получить из PDF-документа. Пример потока вы можете найти во второй половине изображения, что вначале данного топика: да-да, те крякозябрики — это оно и есть.

Объекты
Объекты — это наибольшая структура, с которой на предстоит работать. Объект может содержать внутри себя любой другой тип данных от обычного числа до потока, обрамляется ключевыми словами obj и endobj. Объект имеет свой ID внутри документа, по которому можно на него ссылаться. Нам в первую очередь интересны объекты с потоками внутри себя (не забываем об основной подзадаче), которые почти всегда содержат ещё и набор дополнительных опций в виде словаря. Вот обычный пример объекта внутри PDF-файла (с несжатым содержимым потока):

2 0 obj
<<
/Length 9 2 R
>>
stream
BT
/F1 12 Tf
72 712 Td (A short text stream.) Tj
ET
endstream
endobj

Что ж на этом вводная часть по внутреннему представлению данных закончилась, переходим к «лакомым» штукам — получение текста из потока, а также получения словарей внутренних преобразований символов (реализацию которого я не встречал доселе).

Где искать текст?


Сформулируем задачу: «Где искать в PDF-документе текстовые объекты?» Тут всё просто и не раз и не два описано на различных форумах: будем искать объекты, в которых есть потоки. Обычно имеется ввиду, сжатые gzip, потоки, но документация говорит нам — потом может не сжат вообще или, наоборот, сжатий может быть несколько (/Filter /FlateDecode /ASCIIHexDecode). Что ж нам нужен какой-нибудь действительный пример. Пожалуйста, стихотворение Михаила Юрьевича Лермонтова «Парус» в PDF-формате (документ создан на Acrobat.com из odt-файла из прошлой статьи).

Найдём в данном документе какой-нибудь объект и начнём его разбирать. Я немного смухлюю и возьму объект, в котором заведомо есть текстовые данные, но это только для примера — скрипту всё равно с чем работать:



Давайте для начала разберёмся, что перед нами, используя полученные ранее знания о типах данных PDF. Перед нами объект со словарём свойств, которые говорят, что длина потока данных 681 байт (/Length 681), что поток сжат (/Filter) в gzip (/FlateDecode). Уже достаточно информации, чтобы разжать поток данных — подойдёт gzuncompress:

0.1 w
q 0 -0.1 612.1 792.1 re W* n
q 0 0 0 RG
0 0 0 rg
BT
2 Tr 0.59999 w
56.8 716.6 Td /F1 18 Tf[<01>17<02>10<03>10<04>17<05>]TJ
ET
Q
q 0 0 0 rg
BT
56.8 682.5 Td /F1 11 Tf[<06>9<07>11<08>6<07>11<07>11<09>13<0A>4<0B>14<0C>11<0D>11<0E>9
<0F>9<0A>4<10>11<11>10<12>23<13>6<10>11<14>10<10>11<15>]TJ
ET
... много текста ...

Теперь чуть-чуть отвлечёмся от нашего примера и узнаем ещё немного нового о представлении текста в PDF. Нам нужно запомнить всего несколько вещей:
  • Если текст есть в потоке, то он содержится между «маркером» начала текста BT (beginning of text) и конца ET (end of text).
  • PDF может отображать текст или не отображать, в зависимости наличия маркета Tj (отобразить текст) или маркера TJ (отобразить текст с учётом индивидуального символьного позиционирования). Данные маркеры стоят после строки текста или массива строк, как в данном случае ([<01>17<02>10<03>10<04>17<05>]TJ).
  • PDF поддерживает индивидуальное позиционирование символов, как я написал выше, это значит, что мы можем задать произвольный и отдельный размер расстояния между каждой парой символов. Об этом подробнее позже
Этой информации нам достаточно, чтобы выделить две строки из нашего примера:

1. <01>17<02>10<03>10<04>17<05>
2. <06>9<07>11<08>6<07>11<07>11<09>13<0A>4<0B>14<0C>11<0D>11<0E>9
   <0F>9<0A>4<10>11<11>10<12>23<13>6<10>11<14>10<10>11<15>


Внимательный читатель, посмотревший PDF примера, может предположить, что перед нами заголовок (ПАРУС) и первая строка стихотворения (Белеет парус одинокой). И он окажется прав, но! Но вы не находите, что уж очень странные hex-коды у данного текста:
  • ПАРУС кодируется, как 01 02 03 04 05
  • Белеет — как 06 07 08 07 07 09...
Очень похоже на то, что есть какая-то таблица соответствий, не так ли? Что ж вы опять правы, давайте искать…

Таблица преобразований


На предыдущем примере бы спасовало бы большинство функций получения текста из PDF, которые вы можете найти в свободном доступе в интернетах. Попробуем разобраться что к чему. Итак, нас интересуют ToUnicode CMaps, о которых рассказывается в подразделе о получении текста описания формата PDF от Adobe. Давайте поищем их в нашем файле. Я опять смухлюю и предложу читателю «заведомо правильный кусочек»:



Расшифруем его:

/CIDInit/ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo<<
/Registry (Adobe)
/Ordering (UCS)
/Supplement 0
>> def
/CMapName/Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00> 
endcodespacerange
45 beginbfchar
<01> <041F>
<02> <0410>
<03> <0420>
<04> <0423>
<05> <0421>
<06> <0411>
<07> <0435>
<08> <043B>
<09> <0442>
... много строчек преобразований ...
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end

Знакомые числа <01>, <02> и так далее? Ещё бы — мы их видели чуть раньше в текстовых строках. Предположим, что мы должны заменить 01 на 041F, взглянем, что скрывает за собой это число. Ура! #x041F = П! Мы нашли трансформацию одного символа в другой, теперь обратимся к документации и узнаем чуть больше.

bfchar
Преобразование, что находится между beginbfchar и endbfchar, самое простое. Оно ставит в соответствие первому коду другой. Например, в примере выше мы узнали, что 01 скрывает за собой код символа П. Но это лишь частный случай работы данного преобразования — есть возможность ставить в соответствие одному коду целую строку до 512 символов длины (т.е. до 128 символов в Unicode).

bfrange
Есть и другое более сложное преобразование, обрамлённое beginbfrange и endbfrange. Оно работает уже не с отдельными символами, а уже с их диапазонами. Преобразование поддерживает два вариант своей работы:
  • <0000> <005E> <0020> — мы работает с диапазоном от 0000 до 005E, каждое значение из которого преобразуется в значения из промежутка 0020 и 007E. Заметили принцип? 0000 преобразуется в 0020, 0001 в 0021, 0002 в 0022 и так далее;
  • <005F> <0061> [<00660066> <00660069> <00660066006C>] — каждое значение из промежутка между 005F и 0061 (т.е. ещё 0060) заменяется на соответствующую последовательность из массива в квадратных скобках: 005F будет заменён на 0066 00 66 (т.е. на ff), 0060 на fi, а 0061 на ffl.


Алгоритм и код


Используя полученные нами знания мы можем прочитать наш «злополучный» стих о Парусе. Что ж время представить самые интересные куски кода и ссылку на полный исходник:

  1. function pdf2text($filename) {
  2.     // Читаем данные из pdf-файла в строку, учитываем, что файл может содержать
  3.     // бинарные потоки.
  4.     $infile = @file_get_contents($filename, FILE_BINARY);
  5.     if (empty($infile))
  6.         return "";
  7.  
  8.     // Проход первый. Нам требуется получить все текстовые данные из файла.
  9.     // В 1ом проходе мы получаем лишь "грязные" данные, с позиционированием,
  10.     // с вставками hex и так далее.
  11.     $transformations = array();
  12.     $texts = array();
  13.  
  14.     // Для начала получим список всех объектов из pdf-файла.
  15.     preg_match_all("#obj(.*)endobj#ismU", $infile, $objects);
  16.     $objects = @$objects[1];
  17.  
  18.     // Начнём обходить, то что нашли - помимо текста, нам может попасться
  19.     // много всего интересного и не всегда "вкусного", например, те же шрифты.
  20.     for ($i = 0; $i < count($objects); $i++) {
  21.         $currentObject = $objects[$i];
  22.  
  23.         // Проверяем, есть ли в текущем объекте поток данных, почти всегда он
  24.         // сжат с помощью gzip.
  25.         if (preg_match("#stream(.*)endstream#ismU", $currentObject, $stream)) {
  26.             $stream = ltrim($stream[1]);
  27.  
  28.             // Читаем параметры данного объекта, нас интересует только текстовые
  29.             // данные, поэтому делаем минимальные отсечения, чтобы ускорить
  30.             // выполнения
  31.             $options = getObjectOptions($currentObject);
  32.             if (!(empty($options["Length1"]) && empty($options["Type"]) && empty($options["Subtype"])))
  33.                 continue;
  34.  
  35.             // Итак, перед нами "возможно" текст, расшифровываем его из бинарного
  36.             // представления. После этого действия мы имеем дело только с plain text.
  37.             $data = getDecodedStream($stream, $options); 
  38.             if (strlen($data)) {
  39.                 // Итак, нам нужно найти контейнер текста в текущем потоке.
  40.                 // В случае успеха найденный "грязный" текст отправится к остальным
  41.                 // найденным до этого
  42.                 if (preg_match_all("#BT(.*)ET#ismU", $data, $textContainers)) {
  43.                     $textContainers = @$textContainers[1];
  44.                     getDirtyTexts($texts, $textContainers);
  45.                 // В противном случае, пытаемся найти символьные трансформации,
  46.                 // которые будем использовать во втором шаге.
  47.                 } else
  48.                     getCharTransformations($transformations, $data);
  49.             }
  50.         }
  51.     }
  52.  
  53.     // По окончанию первичного парсинга pdf-документа, начинаем разбор полученных
  54.     // текстовых блоков с учётом символьных трансформаций. По окончанию, возвращаем
  55.     // полученный результат.
  56.     return getTextUsingTransformations($texts, $transformations);
  57. }
Код с комментариями вы можете получить на GitHub'е.

Заключение


Что ж этот код не является венцом творения, он не распарсит все предложенные ему pdf-файлы. Есть документы, в которые, к примеру, внедрены русские шрифты, осуществляющие трансформацию из символов английского алфавита в отображение русских букв.

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

Этот код не идеален в плане чтения PDF-файла по его внутренним стандартам представления информации: он не ищет страницы, он не будет работать с версиями документа (PDF поддерживает историю изменений), возможно даже, что он не идеально прочитает информацию, которую сможет обработать.

Стоит заметить, что никто не отменял $content = shell_exec('/usr/local/bin/pdftotext '.$filename.' -');. Но в данном случае стояла задача чтения PDF под любой платформой и на любой площадке.

Надеюсь вас заинтересовала эта статья, цель которой познакомить сообщество с устройством PDF, возможностью его чтения под PHP, а также найти отправные точки для получения данных в сложных случаях.

В зависимости от активности и интереса к проблеме, я либо продолжу рассказ о PDF (внутреннее устройство документа, позиционирование, шрифты, внутренние ссылки), либо вернусь к теме «Текст любой ценой» на примере RTF. Спасибо за внимание!

Ссылки:
Алексей Рембиш @Rembish
карма
168,0
рейтинг 0,0
Python

Похожие публикации

Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

  • 0
    А можно ссылки на предыдущие посты серии разместить в конце?
    • 0
      Повесил в начале, ну и для удобства в конце.
      • 0
        Спасибо. Хорошие статьи!
  • +1
    Было бы интересно узнать про то как внедряются шрифты.
    • +1
      Добавил себе в TODO.
  • 0
    Замечательно — вы действительно разобрались, как устроен PDF.

    Внесу свои пять копеек: когда мне приходилось решать эту задачу, я залез на nl3.php.net/manual/en/ref.pdf.php (выделение текста тоже только средствами PHP).

    Там был код, который иногда работал, иногда нет. Потом выяснилось, что проблема была в том, что в стандарте так и не смогли договориться, как кодировать переносы строк — \n, \r или \r\n. В общем, я эту проблему поправил, и все заработало довольно прилично. Мой код — это последний комментарий на вышеуказанной странице.
    • +2
      Да более того, после stream вообще может и не быть пробелов или переводов строк, поэтому требуется ltrim потока. Обрезать пробельные символы справа нельзя — можно нарушить целостность сжатых данных, gzuncompress просто не сработает, ругнувшись на некорректные данные.
  • –4
    классная статья, но после нее я понял что формат pdf говно
    нет ни красоты ни стиля
    • 0
      Зря Вы так о PDF. Этот формат претерпел уже 8 редакций (с 1.0 до 1.8), поддерживает возможность сохранения истории изменений и, вообще, часто является стандартом де-факто офисного документооборота. DOC разбирать сложнее. :)
  • 0
    Есть документы, в которые, к примеру, внедрены русские шрифты, осуществляющие трансформацию из символов английского алфавита в отображение русских букв.

    Примеры вы хорошие подбираете :)
    А статья супер: просто, четко и красиво изложено.
    • 0
      Спасибо за добрые слова. А про примеры — Тсс! :)
  • 0
    А можно будет в будущем сделать выковыривание не только текста, но и изображений?
    • +1
      Изображения? Что ж можно попробовать и изображения — это те же объекты с соответствующим типом сжатия. Погляжу на досуге.
      • 0
        Заранее большое спасибо! :)
  • 0
    восхитительно, спасибо, очень интересно.
    а возможно ли сделать так, чтобы текст вытаскивался с форматированием, т.е. курсив / жирный и т.д.?
    • 0
      Форматирование на страницы/абзацы — это разбор индивидуального позиционирования символов и чтение дополнительных параметров вывода текста (вспомните TJ, Tj). Я оставил решение проблемы на читателя.

      Начертание текста (полужирное, курсив или ещё воз и маленькая эффектов, что поддерживает PDF) — это к шрифтам. Выше по комментариям, я обещал поковырять PDF на предмет их чтения.

      Ну и от себя — не ищите готовых решений. Тут уже придётся сесть за редактор кода и PDF с описанием формата. :)
      • 0
        да меня просто интересует конвертация из pdf в fb2 с поддержкой форматирования, поэтому я и заинтересовался :)
  • 0
    >for ($i =; $i < count($objects); $i++) {
    поправьте
    • 0
      Спасибо. Нашёл это место в html-коде топика, там стоит $i = 0. Удивился (да, собственно, и не могло его там не быть — это же parse error). Будем считать, что НЛО утащило ноль. Ведь кому надо, уже давно скопировал код из исходника ;)
  • 0
    Огромное спасибо за статью. Хочу только внести некторые поправки
    1) В функции getCharTransformations неправильно указаны регэкспы. Вместо
    if (preg_match("#<([0-9a-f]{4})>\s+<([0-9a-f]{4})>\s+<([0-9a-f]{4})>#is", trim($current[$k]), $map)) { 

    и
    } elseif (preg_match("#<([0-9a-f]{4})>\s+<([0-9a-f]{4})>\s+\[(.*)\]#ismU", trim($current[$k]), $map)) { 

    должно быть
    if (preg_match("#<([0-9a-f]{1,4})>\s*<([0-9a-f]{1,4})>\s*<([0-9a-f]{1,4})>#is", trim($current[$k]), $map)) { 

    и
    } elseif (preg_match("#<([0-9a-f]{1,4})>\s*<([0-9a-f]{1,4})>\s*\[(.*)\]#ismU", trim($current[$k]), $map)) { 


    Далее встречается plain-текст который требует трансформации. Поэтому заменил строки в функции getTextUsingTransformations
    
    // либо в "чистую" строку, если была открыта круглая скобка. 
    if ($isPlain) 
        $plain .= $c; 
    

    на
    
    // либо в "чистую" строку, если была открыта круглая скобка. 
    if ($isPlain) {
        $key = sprintf("%04X", ord($c));
        if (isset($transformations[$key]))
            $c = html_entity_decode("&#x".$transformations[$key].";");
        $plain .= $c; 
    }
    

    насколько это правильно это отдельный вопрос.

    А теперь вопрос — если в документе несколько таблиц преобразования, то как определить какую когда нужно использовать?

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