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

Извлечение текста PDF в Delphi: пробелы и переводы строк

HotPDF Delphi Component восстанавливает пробелы и переводы строк в THotPDF.ExtractLoadedPageText из геометрии глифов, а не из символов-пробелов. Пробел вставляется, когда зазор после собственной ширины глифа превышает 0.15 высоты текста, а новая строка начинается, только когда начало текста пересекает направление письма больше чем на половину высоты текста. С v2.768.3 текст страницы включает также текст, нарисованный через Form XObject, и оставляет за бортом глифы вне видимой crop-области. Дальше в статье — почему каждое из этих правил выглядит именно так: каждое заменило более простое правило, выдававшее правдоподобный, но неверный вывод на настоящих документах

Симптомы знакомы каждому, кто кормил PDF-текст поисковым индексом. Обложка извлекается как PDFReferenceManualNovember4,1998, налоговая форма распадается на 156 строк, диагональный водяной знак приезжает по символу на строку, а обрезанная проба открывается печатной slug-строкой, которую не показывает ни один вьювер. Ни один из этих файлов не сломан. Каждый использует совершенно легальный способ размещения текста, который наивный экстрактор читает неправильно

Почему извлечённый текст PDF теряет пробелы между словами?

Извлечённый текст теряет пробелы, потому что PDF никогда не обязан их содержать. Производитель может разделять слова показом символа пробела, но с тем же успехом может двигать перо числом внутри массива TJ (ISO 32000-1 §9.4.3) или свежим Td (§9.4.2), и TeX-вывод, множество файлов Distiller и большинство выключенных вёрсток делают ровно это. До v2.766.76 HPDFAssemblePageText смотрел только на вертикальное движение, поэтому разрыв слова, сделанный позиционированием, просто исчезал. Сборщик теперь мерит вдоль направления письма предыдущего глифа дистанцию от конца его собственной ширины до начала текущего глифа и вставляет пробел, когда дистанция превышает 0.15 высоты бокса текущего глифа, меренной от верхнего выноса до нижнего в user space. Пробел не добавляется, когда любая из сторон уже пустая, и не ставится между двумя CJK-символами: выключка растаскивает иероглифы, и это растяжение не значит границу слова. Записи глифов выдают ту же геометрию, так что вы сможете воспроизвести решение, когда конкретный файл вас озадачит

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // высота бокса глифа от выноса вверх до выноса вниз, в user space
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // горизонтальный текст: зазор от конца собственной ширины предыдущего глифа
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Почему мерить от собственной ширины глифа, а не от позиции пера?

HotPDF мерит словные зазоры от GlyphEndX / GlyphEndY, потому что позиция пера после глифа уже содержит интервал, который зазором не является. ISO 32000-1 §9.4.4 определяет горизонтальное смещение как ширину глифа, умноженную на размер шрифта, плюс межсимвольный интервал Tc, плюс межсловный Tw, всё помасштабированное на Tz. BaselineEndX / BaselineEndY держат полное смещение, тогда как GlyphEndX / GlyphEndY — только font advance и Tz. Разница существенна для производителей, поджимающих трекинг отрицательным Tc и возвращающих пробел подстройкой TJ после каждого глифа: от позиции пера возврат выглядит зазором, и китайский термин “95后” извлекался как “9 5 后”. Порог привязан к высоте бокса глифа, а не к размеру Tf, по схожей причине. Word-экспорты часто пишут 1 Tf и несут настоящий размер в масштабированной Tm, так что Tfs говорит 1, тогда как текст высотой в 10 пунктов, и правило, привязанное к Tfs, различало бы два написания одной и той же страницы

Правило словных пробелов HotPDF для ExtractLoadedPageText в Delphi: пробел вставляется, только когда дистанция от GlyphEndX предыдущего глифа до BaselineStartX следующего превышает 0.15 высоты бокса от выноса до выноса, потому что позиция пера в BaselineEndX уже содержит Tc, Tw и Tz и превращает возвраты выключенного трекинга в ложные зазоры вроде 9 5 后
Геометрия, а не символы-пробелы, решает, где кончаются слова — записи глифов выдают те же измерения, так что решение по любому загадочному файлу можно переиграть

У правила есть честные края. Заголовок с очень свободным трекингом, где один Tc открывает между буквами больше 0.15 высоты текста, извлечётся с пробелом между каждой парой букв — именно так страница и выглядит, но индексировать вы это, вероятно, не хотели. Куски, нарисованные не по порядку на одной базовой линии, дают отрицательный зазор и склеиваются без пробела. Ни то, ни другое в основном тексте не часты, а на тестовом корпусе правка подняла число словных совпадений с эталонным экстрактором на 28 страницах, не снизив ни одного

Когда HotPDF начинает новую строку в извлечённом тексте?

С v2.766.79 новая строка начинается, когда сдвиг от начала предыдущего глифа к текущему, спроецированный на нормаль предыдущего направления письма, превышает половину большей из высот боксов двух глифов. Прежнее правило сравнивало сырой сдвиг по Y с половиной Tfs, и проваливалось в обе стороны. При 1 Tf и масштабированной Tm порог сжимался до половины единицы, так что надстрочный знак, поднятый text rise в 0.4, или обычная дрожь базовой линии ломали строку. Правило ещё и игнорировало X целиком, поэтому текст под повёрнутой Tm шагал вниз по странице с каждым глифом и выходил по глифу на строку. Проекция на нормаль направления делает повёрнутые прогоны похожими на горизонтальные, а взятие большей из двух высот держит крупное слово-образец и его мелкую подпись на одной строке, когда они делят базовую линию. На упомянутой налоговой форме число строк упало со 156 до 97. Вертикальный текст в writing mode 1 (§9.7.4.3) идёт отдельным путём: те глифы группируются в колонки, читаются справа налево и сверху вниз, с переводом строки при каждой смене колонки

Как ExtractLoadedPageText в HotPDF решает переводы строк в Delphi: сдвиг между началами глифов проецируется на нормаль направления письма и сравнивается с половиной большей высоты боксов, поэтому надстрочный знак с малым text rise под шрифтом 1 Tf и текст, шагающий вниз по странице под повёрнутой Tm, больше не распадаются по глифу на строку
Проекция делает повёрнутые прогоны похожими на горизонтальные, а взятие большей из двух высот боксов держит крупное слово-образец и его мелкую подпись на одной строке

Какой текст ExtractLoadedPageText включает, а какой оставляет?

ExtractLoadedPageText возвращает текст, который показывает вьювер. С v2.766.80 он работает только с видимыми глифами, выбрасывая каждый глиф, чей центр бокса выпадает из GetLoadedPageVisibleBox — CropBox, обрезанного до MediaBox (§14.11.2). Это убирает slug-строки и прочие печатные метки, набранные текстом вне зоны обрезки. ExtractLoadedPageGlyphs намеренно продолжает возвращать каждый глиф потока содержимого страницы, так что этот материал вы всё ещё найдёте, когда понадобится. Фильтр — тест по боксу, а не тест видимости: текст, спрятанный clipping-путём, нарисованный белым или накрытый изображением, всё равно извлекается

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // каждый глиф потока содержимого страницы, slug-строка включительно
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // только то, что страница показывает, с вплетённым текстом Form XObject
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Текст, нарисованный через Form XObject, — часть текста страницы с v2.768.3. Заголовки, штампы и водяные знаки очень часто живут в формах, и некоторые стандартные документы теряли до перемены 30-35 процентов символов. THotPDF.InterpretContentWithForms записывает каждый Do вместе с действующим CTM, интерпретирует форму на её /Matrix, помноженной на тот CTM (§8.10.1), и вплетает глифы формы в позицию Do, рекурсируя во вложенные формы. Форма без собственной /Resources занимает чужие — того потока, что её рисует, как разрешает §7.8.3. Глифы форм несут TokenIndex = -1, а ExtractLoadedPageGlyphs по-прежнему возвращает только глифы потока страницы: поиск, замена и редактирование пишут правки обратно через TokenIndex и отредактировали бы не те байты, вкати туда глиф формы. О двух упрощениях стоит знать: текст формы не обрезается по её /BBox, а рекурсия останавливается на 12 уровнях, а не через детекцию циклов, поэтому кривая форма, рисующая саму себя, повторяет свой текст, пока не упрётся в этот потолок

Какие глифы HotPDF включает при извлечении текста страницы PDF в Delphi: ExtractLoadedPageText держит только глифы, чей центр бокса попадает в GetLoadedPageVisibleBox — CropBox, обрезанный до MediaBox, поэтому печатные slug-строки исчезают, тогда как InterpretContentWithForms вплетает глифы Form XObject в позицию каждого Do с TokenIndex = -1, а глифовый API по-прежнему возвращает всё
Тест по боксу на центре глифа — не тест видимости: белый, обрезанный и накрытый текст всё равно выходит, а текст форм считается с v2.768.3

Почему текст после оператора Q декодировался в мусор?

Текст после Q мог декодироваться неверно до v2.766.73, потому что экстрактор сохранял на q только CTM. Параметры текстового состояния — шрифт, размер, Tc, Tw, Tz, TL, режим рендеринга и rise — принадлежат графическому состоянию (§9.3.1), поэтому Q обязан восстанавливать их вместе со всем остальным на стеке (§8.4.2). Один индустриальный отчёт выбирал двухбайтовый Identity-H шрифт внутри q … Q, а затем показывал однобайтовый текст WinAnsi без собственного Tf. Экстрактор оставлял внутренний шрифт, читал отточия и слово “Adobe” в оглавлении как двухбайтовые коды и терял 15% символов страницы. Стек q/Q интерпретатора теперь держит полное текстовое состояние. Описанные здесь правила извлечения применимы к каждой странице, так что целый документ уезжает в файл одним вызовом

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // пустой диапазон = все страницы; form feed между страницами; UTF-8 BOM
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Какой текстовый API HotPDF выбрать?

ExtractLoadedPageText держится порядка потока содержимого, что правильный дефолт для поиска и индексации; цепочка декодирования под ним разобрана в статье об извлечении текста из загруженных PDF с HotPDF. Для помеченных документов, где порядок авторства важен, извлечение текста в порядке структуры идёт по дереву структуры, а не угадывает из геометрии, а для данных, запертых в таблицах, типизированное извлечение таблиц через разрывы страниц возвращает ячейки, а не строки. Полный справочник API и триальная загрузка — на странице продукта HotPDF Delphi PDF Component