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

Убираем дубли текста от fake bold при извлечении из PDF

HotPDF Delphi Component начиная с v2.768.15 убирает дубликаты символов, которые fake bold и напечатанные поверх логотипы оставляют в извлечённом тексте PDF. Когда продюсер рисует один и тот же глиф дважды с зазором в доли пункта, ExtractLoadedPageText, ExtractLoadedPageTextLayout и ExtractLoadedPageTextColumns теперь читают этот символ один раз, и «Bold words» больше не превращается в «Bold wordsBold words»

Это один из тех багов, что выглядят как сбой декодера, но им не являются. Каждый глиф декодируется правильно; текст действительно лежит на странице дважды. Исправлению приходится решать, какой из двух совершенно валидных глифов — копия, и делать это, не съедая легитимные повторяющиеся буквы вроде двойного l в «fill» или одинаковых ячеек таблицы. Далее в статье — правило, которым пользуется HotPDF, пороги за ним, где оно нарочно расходится с другими инструментами извлечения, и второй, не связанный с этим баг извлечения из того же релизного поезда, который тоже отгрызал концы строк

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

Fake bold удваивает извлечённый текст, потому что продюсер рисует строку дважды, и обе копии — настоящие операции показа текста. Когда в семействе шрифтов нет жирного начертания или инструмент авторинга просто не утруждается, многие генераторы эмулируют жирность, показывая ту же строку через Tj или TJ, сдвинув текстовую позицию вправо на несколько десятых пункта, и показывая её снова. На экране две копии сливаются в более толстый штрих. В content stream лежат два полных прогона символьных кодов, и ISO 32000-1 §9.4 не даёт экстрактору никакого флага вида «эта копия — украшение»

Наложенные логотипы бьют в ту же проблему с другого угла. Логотип может быть нарисован один раз заливкой, а второй раз — обводкой или чуть смещённой тенью, тем же шрифтом и теми же символами. Наивный экстрактор, идущий по content stream в порядке рисования, выдаст весь первый прогон, потом весь второй — поэтому результат читается как «Bold wordsBold words», а не «BBoolldd». До v2.768.15 сборка текста в HotPDF хранила обе копии, и дубликат ломал то, о чём большинство вызывающих реально заботится: поиск «Bold words» по тексту страницы мог провалиться, потому что граница слова между двумя прогонами исчезла

Настоящий режим рендеринга текста 2 (заливка, затем обводка, ISO 32000-1 §9.3.6) этой проблемы не имеет: это одна операция показа текста, порождающая один набор глифов. Дубликаты появляются, только когда продюсер выдаёт текст дважды

Как HotPDF решает, какой глиф — дубликат?

HotPDF считает два глифа одним нарисованным символом, когда у них одинаковое значение Unicode, одинаковое направление письма, размеры различаются не более чем на 5% и они сидят почти друг на друге: не дальше 0.1 размера глифа вдоль базовой линии и не дальше 0.2 размера поперёк неё. Из каждой такой пары сохраняется глиф, нарисованный первым, а более поздний выбрасывается. Это значения реализации v2.768.15, и у каждого есть своя причина

  • Тот же Unicode, то же направление. Дубликатами могут быть только идентичные символы, и горизонтальный глиф никогда не сравнивается с вертикальным (WMode 1)
  • Размер в пределах 5%. Крупная буква заголовка и мелкая буква подписи, случайно наложившиеся, — не один и тот же текст, поэтому расхождение размеров выводит пару из игры
  • 0.1 размера вдоль базовой линии. Смещения fake bold — доли пункта, несколько процентов от обычного кегля. Две настоящие соседние буквы, даже плотно закерненные «ll», разнесены примерно на полную ширину символа — на порядок больше этого допуска
  • 0.2 размера поперёк базовой линии. Тени и обводочные проходы часто съезжают не только вправо, но и вниз, поэтому вертикальный допуск свободнее, но всё ещё далеко ниже расстояния до следующей строки
  • Побеждает нарисованный первым. Сохранение более раннего глифа бережёт порядок чтения первого прогона, так что уцелевший текст течёт так, как его написал продюсер

Расстояния меряются в собственной системе базовой линии глифа, а не в сырых X и Y страницы, поэтому повёрнутый текст и текст на наклонной базовой линии используют те же допуски, что и обычные горизонтальные строки. Размер берётся тот, с которым глиф нарисован на странице и выведен из его квадра, а не операнд размера, отданный Tf; это важно для документов, ставящих Tf в 1 и масштабирующих всё через текстовую матрицу, где сырой размер шрифта сделал бы любой допуск бесполезным

Пробелы кандидатами не бывают. Кодовые точки до U+0020, неразрывный пробел U+00A0 и идеографический пробел U+3000 пропускаются: у пустых глифов нет видимых чернил, которые можно было бы задублировать, а выбрасывание одного слило бы слова

Чтобы проверка оставалась быстрой на плотных страницах

Проверка дубликатов в HotPDF работает примерно за n log n, а не за n в квадрате: сначала сортировка, затем сравнение только внутри небольшого окна. Сравнивать каждый глиф с каждым было бы корректно и непригодно: плотная страница мелкого шрифта легко несёт десятки тысяч глифов, а проверка запускается при каждом вызове извлечения текста

Внутренняя рутина сортирует непробельные глифы по значению Unicode, затем по X начала, затем по порядку рисования — устойчивой сортировкой слиянием. После сортировки каждый потенциальный дубликат глифа сидит в порядке сразу за ним, поэтому скан идёт только вперёд, пока совпадает значение Unicode, а разница X остаётся в пределах 0.25 размера глифа. Окно 0.25 нарочно шире допуска 0.1 вдоль базовой линии, чтобы пара на чуть наклонённой базовой линии всё ещё попадала внутрь до запуска точного теста в системе базовой линии. Один тонкий случай: если скан от глифа A встречает совпадающий глиф, нарисованный раньше A, копия — A, поэтому A выбрасывается, скан останавливается, а более ранний глиф продолжает сравнение со всем, что дальше. Именно это правило держит «первый нарисованный побеждает» и для тройных ударов, а не только для пар

Сравнение идёт по значению Unicode, а не по шрифту или коду символа. Два глифа, отображённых в один Unicode через разные шрифты — скажем, проход заливкой в одном встроенном сабсете и проход обводкой в другом, — по-прежнему распознаются как один символ. Обратная сторона: глиф, чей код разрешается в Unicode 0, не имеет надёжной основы для сравнения, и это ещё одна причина следить за счётчиком неразрешённых из извлечения текста из загруженных PDF с HotPDF

Какие вызовы извлечения HotPDF убирают дубликаты глифов?

Все текстопорождающие вызовы HotPDF убирают дубликаты, а сырой вызов глифов — нет. Дедупликация живёт в THotPDF.ExtractVisibleLoadedPageGlyphs — шаге, который заодно отбрасывает глифы вне видимой коробки страницы, — и извлечение простого текста, колонок, раскладки и семантического текста читают глифы оттуда. THotPDF.ExtractLoadedPageGlyphs по-прежнему возвращает каждый глиф, который рисует content stream, включая наложения: тем, кто строит подсветку, редактирование с удалением или криминалистический инструмент, нужна полная запись нарисованного

Для обычной текстовой работы в вызывающем коде ничего не меняется. Тот же цикл, который до v2.768.15 извлекал удвоенный текст, теперь извлекает каждое слово один раз

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('brochure.pdf');
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        if Pos('Bold words', PageText) > 0 then
          Writeln('Found on page ', I + 1);
  finally
    Pdf.Free;
  end;
end;

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

var
  Grid, Columns: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1-layout.txt', Grid, TEncoding.UTF8);
  // Колонки делятся там, где зазор превышает 3x медианной ширины символа
  if Pdf.ExtractLoadedPageTextColumns(0, Columns, 3.0) then
    TFile.WriteAllText('page1-columns.txt', Columns, TEncoding.UTF8);
end;

Если хотите узнать, есть ли на странице вообще наложенный текст, сравните два вызова глифов. Учтите, что разница засчитывает и глифы за пределами crop box, так что обращайтесь с ней как с верхней оценкой дубликатов, а не точной цифрой

var
  AllGlyphs, Visible: THPDFGlyphArray;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, AllGlyphs) and
     Pdf.ExtractVisibleLoadedPageGlyphs(0, Visible) then
    Writeln(Format('%d painted, %d read as text (%d overprinted or off-page)',
      [Length(AllGlyphs), Length(Visible),
       Length(AllGlyphs) - Length(Visible)]));
end;

Правила межсловных пробелов и разрывов строк, превращающие эти глифы в текст, фильтр не трогает; подробно они разобраны в том, как HotPDF расставляет пробелы и разрывы строк в тексте страницы

Где правило нарочно расходится с другими инструментами

HotPDF выбрасывает точные дубликаты, которые некоторые другие инструменты извлечения сохраняют, и это различие намеренное. Когда изменение v2.768.15 сверяли с эталонным корпусом, один нижний колонтитул, нарисованный дважды ровно в той же позиции, извлёкся в HotPDF один раз, а в эталонном экстракторе — дважды. Глиф, нарисованный поверх идентичного глифа, невидим как отдельный символ, поэтому прочитать его один раз — это то, что страница реально показывает

Честный предел идёт и в другую сторону. Fake bold со смещением второго прохода больше чем на 0.1 размера глифа вдоль базовой линии не ловится, как и теневой проход, нарисованный заметно другим кеглем. Такие раскладки редки на практике — визуальный эффект перестаёт выглядеть жирным, как только смещение становится таким большим, — но если вы видите удвоенный текст от декоративного заголовка, сверьте значения X глифов из ExtractLoadedPageGlyphs, прежде чем считать фильтр сломанным. Фильтр также ничего не делает с дубликатами, не совпадающими геометрически, — например, колонтитул, повторённый в скрытом слое в другом месте страницы; это вопрос контента, а не наложения

Почему в тексте CID-шрифтов обрезались концы строк?

Отдельный баг, исправленный в HotPDF v2.768.12, отрезал концы строк в тексте, набранном CID-шрифтами (Type 0), чей массив ширин /W не был отсортирован по CID. ISO 32000-1 §9.7.4.3 определяет формат массива /W, но не требует, чтобы его записи шли по возрастанию CID, а некоторые генераторы пишут их в порядке первого использования глифов в документе

Декодер извлечения текста искал ширины двоичным поиском, который молча предполагает отсортированный вход. На несортированном массиве большинство поисков промахивалось и откатывалось к ширине по умолчанию /DW — 1000 единиц, полная em. Латинский текст в таком шрифте верстался так, будто каждая буква полноширинная, строки убегали далеко за правый край страницы, а глифы, попавшие вне видимой коробки, отбрасывались — концы строк исчезали из извлечённого текста и из поиска. Ничего не падало и ничего не сообщалось, потому найти это было трудно

Теперь HotPDF сортирует записи /W устойчивой сортировкой слиянием сразу после чтения, так что там, где две записи перекрываются, побеждает записанная первой — как массив и читается в файловом порядке. Рендерер страниц не затронут никогда: он держит собственную копию данных ширин и сканирует её линейно, и вертикальный массив ширин /W2 тоже сканируется линейно. Это разделение стоит помнить, когда страница выглядит идеально, а извлекается плохо: рендеринг и извлечение в PDF-библиотеке часто делят куда меньше кода, чем ожидается

FAQ

Убирает ли HotPDF повторяющиеся буквы вроде двойного l в «fill»?

Нет. Две настоящие соседние буквы разнесены примерно на ширину символа, тогда как HotPDF сливает только идентичные символы в пределах 0.1 размера глифа вдоль базовой линии и 0.2 поперёк, поэтому легитимные повторы сохраняются

Как получить все глифы, включая наложенные дубликаты?

Вызывайте THotPDF.ExtractLoadedPageGlyphs: он возвращает каждый глиф, который рисует страница, в порядке рисования. THotPDF.ExtractVisibleLoadedPageGlyphs и все вызовы извлечения текста возвращают дедуплицированный видимый набор

В какой версии HotPDF исправлен удвоенный текст от fake bold?

HotPDF v2.768.15 добавил удаление дубликатов глифов в извлечение текста, раскладки, колонок и семантического текста, а v2.768.12 починила пропадавшие концы строк в CID-шрифтах с несортированным массивом /W

Оба исправления входят в текущий HotPDF Delphi PDF component, который извлекает текст из загруженных PDF в Delphi и C++Builder впридачу к созданию, редактированию и рендерингу PDF