Техническая статья

Извлечение текста из загруженного PDF в Delphi с помощью HotPDF

Компонент HotPDF Component извлекает текст в формате Unicode из любого загруженного PDF-файла в Delphi с помощью двух вызовов: ExtractLoadedPageText возвращает текст страницы в порядке чтения, а метод ExtractLoadedPageTextLayout (добавленный в версии 2.263.0) реконструирует визуальное расположение элементов на странице в виде простого текста, сохраняя колонки, отступы и выравнивание таблиц на выходе. Оба метода работают с документами, созданными не в HotPDF, что имеет решающее значение на практике: будь то счет, полученный от клиента по электронной почте, отчет от сканирующего бюро или договор, созданный в неизвестном программном обеспечении

Для реализации этой задачи потребовалось гораздо больше внутренних механизмов, чем можно предположить по двум сигнатурам методов, поскольку PDF-файлы хранят текст иначе, чем обычные текстовые файлы. В этой статье рассматриваются оба режима извлечения, а также подробно описываются три внутренние составляющие: парсер CMap, интерпретатор потока контента и цепочка резервных декодеров шрифтов. Понимание принципов их работы поможет вам легко диагностировать некорректный вывод вместо того, чтобы просто разводить руками

Почему извлечение текста сложнее, чем простое чтение строк из файла?

Поток контента PDF хранит коды символов, а не сами символы. Операторы Tj и TJ (ISO 32000-1 §9.4.3) содержат строки байтов, значение которых полностью зависит от шрифта, выбранного предшествующим оператором Tf: например, байт 0x41 может быть буквой A в кодировке WinAnsi, произвольным глифом в подмножестве шрифта или половиной двухбайтового CID в составном CJK-шрифте. Стандарт ISO 32000-1 §9.10 определяет извлечение текста именно как задачу декодирования — сопоставление каждого кода с Unicode с использованием любой информации, предоставляемой словарем шрифта, при этом стандарт прямо указывает, что соответствующий файл не обязан предоставлять достаточный объем информации для этого

Последнее утверждение объясняет суть любого отчета об ошибке вида «почему при копировании и вставке из этого PDF получается абракадабра». Программа генерации документов, внедрившая подмножество шрифта без таблицы /ToUnicode, создает файл, который отображается идеально, но извлекается в виде бессмысленного набора символов, поскольку сопоставление кодов с глифами присутствует, а сопоставление кодов с Unicode не было передано в файле. Любой надежный API извлечения текста представляет собой цепочку резервных вариантов, работающих по принципу best-effort, и главный вопрос заключается в том, насколько глубока эта цепочка

Извлечение текста в порядке чтения с помощью ExtractLoadedPageText

Для поисковой индексации, сопоставления ключевых слов или передачи текста в конвейер анализа отлично подходит метод ExtractLoadedPageText. Сигнатура метода выглядит следующим образом: function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — индексы страниц отсчитываются от нуля, результат возвращается в виде стандартного для Delphi типа UnicodeString, а функция возвращает False вместо вызова исключения, если на странице отсутствует читаемый поток контента

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // Переменная AllText теперь содержит текст документа в порядке чтения
  finally
    Pdf.Free;
  end;
end;

Переносы строк в выводе формируются на основе простой эвристики: перевод строки вставляется, когда вертикальное начало координат глифа смещается более чем на половину текущего размера шрифта — типичный признак шага Td или T* в потоке контента. Символы, которые декодер не может распознать, заменяются пробелами, а не исчезают, благодаря чему границы слов сохраняются даже при потере отдельных глифов. Этот режим не пытается группировать текст по порядку чтения или обнаруживать многоколоночную верстку: двухколоночная страница будет выведена в порядке следования объектов в потоке контента, что обычно, но не всегда, совпадает с визуальным порядком

В каких случаях лучше использовать извлечение с сохранением разметки?

Метод ExtractLoadedPageTextLayout незаменим, когда взаимное расположение текста имеет значение: в таблицах, формах, листингах кода или в любых данных, которые планируется сравнивать через diff, фильтровать через grep или разбирать по колонкам. Вместо того чтобы объединять глифы в сплошной поток, метод группирует их по базовым линиям, сортирует каждую базовую линию по оси X и воспроизводит горизонтальные и вертикальные пробелы на моноширинной символьной сетке, размеры которой определяются медианным смещением глифа и размером шрифта. Большие интервалы между текстовыми блоками на одной базовой линии заполняются пробелами, а крупные расстояния между базовыми линиями преобразуются в пустые строки. Результат выглядит в точности так же, как исходная страница

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Колонки, отступы и выравнивание таблиц сохраняются в виде
  // пробелов и пустых строк на символьной сетке
end;

Оба режима используют одни и те же механизмы декодирования и различаются лишь способом компоновки декодированных глифов, поэтому выбор режима не влияет на точность распознавания. Выбирайте ExtractLoadedPageText, когда вам важны только слова, и ExtractLoadedPageTextLayout, когда критична их структура. Автоматическое определение порядка чтения колонок не поддерживается в обоих случаях: сеточный рендеринг двухколоночной страницы отобразит обе колонки бок о бок, что отлично подходит для сравнения файлов, но неудобно для плавного чтения сплошного текста

Как HotPDF декодирует коды символов в Unicode?

HotPDF Component преобразует каждый код символа через приоритетную цепочку резервных вариантов: сначала анализируется встроенная таблица шрифта /ToUnicode CMap, затем запись /Encoding (поток или именованный CMap), далее (для составных шрифтов) — стандартные файлы CMap от Adobe для коллекций символов (таких как Adobe-GB1, Adobe-CNS1, Adobe-Japan1 и Adobe-KR) и, наконец, встроенные таблицы WinAnsi и MacRoman для простых шрифтов. Если текущая стратегия не возвращает результат, выполняется автоматический переход к следующему шагу без вызова ошибок, а если код не удается сопоставить по всей цепочке, он преобразуется в 0, позволяя вызывающей стороне вести учет пропущенных символов

Таблица /ToUnicode CMap (ISO 32000-1 §9.10.3) находится на первом месте, так как это сопоставление создатель документа записал специально для извлечения. Использование стандартных CMaps от Adobe важно для документов на CJK-языках, использующих предопределенные CMaps вида UniGB-UTF16-H вместо внедрения данных: HotPDF поставляет эти файлы коллекций в каталоге resources\CMap, находит их относительно исполняемого файла во время работы и кэширует каждую разобранную карту для каждого процесса. Это важно, так как крупнейший файл коллекции Adobe-GB1 содержит около 2 МБ исходного текста, повторный разбор которого на каждой странице нежелателен. При отсутствии этого каталога декодер просто пропускает внешние файлы CMap и работает со встроенными таблицами и кодировками. Это зеркальное отражение проблемы формирования текста на стороне чтения, описанной в статье о формировании сложного текста с помощью HotPDF, где с аналогичным различием между кодами и глифами приходится сталкиваться при записи

Две синтаксические ловушки CMap, о которых стоит знать

Файлы CMap кажутся простыми для разбора, но это не так. Две детали вызывают большинство сбоев при первой попытке написания парсера. Во-первых, количество записей указывается *перед* ключевым словом раздела: например, 2 beginbfchar, а не beginbfchar 2. Парсер, ожидающий число после ключевого слова, воспримет его как отдельный токен, в результате чего обнаружит ноль записей в каждом разделе. Надежное решение, выбранное в HotPDF, — полностью игнорировать число и выполнять чтение в цикле до встреченного закрывающего слова endbfchar / endbfrange. Это также позволяет корректно обрабатывать файлы, в которых количество записей указано с ошибкой

Вторая ловушка заключается в том, что целевые значения в bfchar и bfrange являются строками UTF-16BE, а не целыми числами. Например, значение <D83DDE00> означает U+1F600 — суррогатную пару, которую необходимо преобразовать в один кодовый пункт. Если прочитать эти четыре байта как целое число с порядком байтов big-endian, получится бессмысленное значение для любого кодового пункта за пределами базовой многоязычной плоскости (BMP). Эмодзи в PDF уже не редкость, поэтому декодер, пропускающий сборку суррогатных пар, будет давать сбой на реальных файлах ваших пользователей. HotPDF сначала преобразует шестнадцатеричный литерал в массив байтов, а затем восстанавливает кодовые единицы UTF-16BE, что также решает задачу сопоставления составных символов при обработке лигатур

Доступ к уровню глифов с помощью ExtractLoadedPageGlyphs

Оба текстовых вызова основаны на методе ExtractLoadedPageGlyphs, а базовый массив THPDFGlyphArray доступен и для вашего собственного кода. Каждая запись THPDFGlyphRecord содержит распознанный кодовый пункт Unicode наряду с исходным кодом символа, байтовой шириной кода (1, 2 или 4, определяемой диапазоном codespacerange в CMap), активным ресурсом и размером шрифта, началом координат X и Y в пользовательском пространстве и горизонтальным шагом. Этой информации достаточно для создания собственных алгоритмов определения границ слов, позиционного выделения или кастомной верстки без прямого разбора потока контента

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

Подсчет записей с Unicode = 0, как показано выше, позволяет объективно оценить качество извлечения текста в конкретном документе перед тем, как использовать этот текст далее. Кроме того, записи глифов связывают каждый символ с соответствующим операндом в исходном потоке контента, что делает возможным реализацию поиска и замены текста в загруженном документе в HotPDF на базе этого же механизма

Из каких PDF нельзя извлечь text?

Некоторые файлы не поддаются извлечению текста, и правильнее своевременно выявлять такие случаи, а не использовать некорректные результаты. Самый простой пример — отсканированные документы: страница, представляющая собой одно большое изображение, не содержит текстовых операторов, поэтому извлечение вернет пустую строку. Решением здесь является OCR, а извлечение изображений страниц из загруженного PDF будет первым шагом этого конвейера. Более сложный случай — подмножества шрифтов без таблицы /ToUnicode: если пути /Encoding и стандартные файлы CMap также не дают результата, эти глифы преобразуются в 0 и выводятся как пробелы. Зашифрованные документы извлекаются без проблем, если они загружаются с паролем через перегруженный метод LoadFromFile, благодаря чему потоки расшифровываются перед передачей интерпретатору

Стоит упомянуть одно специфическое ограничение: цепочка декодирования считывает потоки CMap и контента через собственный модуль Flate библиотеки HotPDF, поэтому шрифт, поток ToUnicode которого сжат редким фильтром, перейдет к следующему резервному варианту вместо сбоя всей страницы. На практике фильтрация FlateDecode охватывает почти все документы последних двадцати лет, а переход к резервным декодерам выполняется незаметно — вы получаете максимально качественный текст, который допускает файл, без вызова исключений. Тот же механизм разбора объектов на стороне чтения, который обрабатывает словари шрифтов, используется и для редактирования метаданных в загруженных документах, позволяя конвейеру импорта документов извлекать текст, проверять и аннотировать файлы за один проход

Извлечение текста, рендеринг с сохранением макета, доступ к уровню глифов, а также функции поиска и замены на их основе — всё это входит в состав стандартного компонента HotPDF Component для Delphi и C++Builder: никаких внешних DLL, никаких текстовых служб операционной системы, только чистый Object Pascal, код которого вы можете отлаживать по шагам