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

    Продолжаем разбирать текстовые форматы на предмет получения текста. Итак, обещанный ранее 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. Спасибо за внимание!

    Ссылки:
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 20
    • 0
      А можно ссылки на предыдущие посты серии разместить в конце?
      • 0
        Повесил в начале, ну и для удобства в конце.
        • 0
          Спасибо. Хорошие статьи!
      • +1
        Было бы интересно узнать про то как внедряются шрифты.
      • 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; 
                    }
                    

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

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

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