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

Поиск и замена текста в существующем PDF-файле с помощью Delphi

Компонент HotPDF Component позволяет выполнять поиск и замена текста внутри существующего PDF-файла из Delphi и C++Builder. Вызовы SearchLoadedPageText и SearchLoadedDocumentText находят каждое вхождение строки с точностью до глифа, а методы ReplaceLoadedPageText и ReplaceLoadedDocumentText перезаписывают совпавшие байты на месте. Это возможно при условии, что каждый заменяющий символ может быть закодирован заново с использованием исходного шрифта — физическое ограничение, которое в этой статье рассматривается открыто, а не прячется в сносках

Задачи, требующие этой функции, всегда обыденны. Например, компания меняет название, и три тысячи архивных счетов по-прежнему содержат старое имя. Или шаблон договора был выпущен с датой истечения срока действия за прошлый год. Либо код продукта был снят с производства, и в каждом техническом описании, где он упоминается, требуется указать код преемника. В текстовом процессоре любая из этих задач занимает тридцать секунд. В PDF же это действительно сложная проблема, и понимание причин её сложности определяет разницу между правильным использованием API и отправкой отчета об ошибке, который на самом деле является просто цитатой из спецификации

Почему заменять текст в PDF так сложно?

Замена текста в PDF сложна, потому что страница PDF содержит не редактируемый текст, а позиционированные глифы. В соответствии с моделью вывода текста стандарта ISO 32000-1 §9.4, поток контента управляет операторами вроде Tj и TJ, которые отрисовывают последовательности кодов символов по координатам, заданным текстовой матрицей. Эти коды не являются Unicode; они представляют собой индексы в той кодировке, которую объявляет шрифт страницы, а сопоставление с читаемыми символами может находиться в таблице /ToUnicode CMap, массиве различий кодировки или цепочке сопоставления CID. Здесь нет понятия абзаца, текстового потока и нет гарантии, что одно визуальное слово хранится в виде единой строки

Замена добавляет второй уровень сложности поверх декодирования: необходимо точно знать, какие именно байты исходного потока создали каждый глиф, чтобы встроить новые байты именно в этот интервал и никуда более. Модуль извлечения текста может позволить себе отбросить позиции байтов после получения Unicode. Модуль замены этого сделать не может. Именно поэтому разработчики HotPDF разделили работу на два релиза: в v2.251.0 был создан слой отслеживания смещений и поиска, а в версии v2.252.0 поверх него был реализован слой перезаписи

Поиск текста: поиск на уровне глифов с отслеживанием смещения байтов

Метод SearchLoadedDocumentText в HotPDF находит каждое вхождение искомого выражения путем сопоставления с декодированной последовательностью глифов Unicode каждой страницы, а не с необработанными байтами потока. Поэтому совпадение будет обнаружено независимо от того, как шрифт его закодировал. Базовая инфраструктура была представлена в версии v2.251.0: токенизатор потока контента фиксирует диапазон байтов StartOfs/EndOfs для каждого строкового операнда (включая его разделители ( ) или < >), а каждый декодированный глиф содержит тройку TokenIndex/ItemIndex/ByteOffset, указывающую на точный операнд, элемент массива TJ и единицу кода, которые его создали. Тот же интерпретатор глифов лежит в основе API извлечения, описанного в статье об извлечении текста из загруженного PDF в Delphi. Разница лишь в том, что поиск сохраняет информацию о происхождении данных, которую извлечение отбрасывает

Каждое совпадение возвращается в виде записи THPDFTextMatch, содержащей индекс страницы, включенный диапазон глифов, координаты X/Y начала координат в пространстве пользователя, ширину совпадения, исходный токен, индекс элемента и сам совпавший текст. Этого достаточно для создания рамки выделения, интерфейса проверки или выполнения замены. Поиск, не давший результатов, возвращает пустой массив вместо генерации ошибки, что упрощает шаблон вызова

var
  Pdf: THotPDF;
  Matches: THPDFTextMatchArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
    begin
      if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
        for I := 0 to Length(Matches) - 1 do
          WriteLn(Format('страница %d в (%.1f, %.1f): "%s"',
            [Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
             Matches[I].Text]));
    end;
  finally
    Pdf.Free;
  end;
end;

Стоит отметить одно осознанное дизайнерское решение. Когда параметр CaseSensitive равен False, сравнение выполняет преобразование регистра только для символов ASCII. Это сделано намеренно: полное преобразование регистра Unicode ведет себя по-разному в различных версиях компиляторов от Delphi 5 до XE, поддерживаемых HotPDF, а API поиска, выдающий разные результаты в зависимости от того, какой компилятор собрал ваше приложение, гораздо хуже решения с документированными и предсказуемыми ограничениями. Для латинского делового текста (имен, кодов, дат) преобразования регистра ASCII вполне достаточно

Замена текста: обратное кодирование и точечное встраивание

Метод ReplaceLoadedDocumentText, добавленный в HotPDF v2.252.0, перезаписывает каждое вхождение искомого выражения, запуская механизм декодирования в обратном направлении. Функция HPDFEncodeUnicode является обратной по отношению к декодеру кодов символов: она проходит ту же цепочку стратегий в обратном порядке — поиск bfchar и bfrange в /ToUnicode, сопоставление CID в потоке кодирования, сопоставление Type0 identity, а также предопределенные таблицы WinAnsi и MacRoman, чтобы преобразовать каждый заменяющий символ обратно в байты кодов, которые ожидает исходный шрифт. Повторно закодированные байты затем сериализуются в корректный строковый литерал или шестнадцатеричную строку, в точности повторяя правила экранирования токенизатора, чтобы гарантировать стабильность цикла разбор-сериализация

Само встраивание является точечным, а не массовым. Внутри строкового операнда заменяется только диапазон байтов кода, охватываемый совпадением. Несовпавшие байты в том же операнде, пробелы между токенами и все окружающие операторы сохраняются в исходном виде, байт за байтом. Замена bca внутри abcabc дает a + замена + bc, а не поврежденный операнд. Длина замены может быть меньше или больше исходного выражения — литерал сериализуется заново с обновлением записи /Length потока, при этом каждый поток /Contents многопоточной страницы обрабатывается изолированно, что сохраняет корректность структуры страницы

var
  Pdf: THotPDF;
  ReplaceCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
    begin
      if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
        True, ReplaceCount) then
        WriteLn(Format('выполнено перезаписей операндов: %d', [ReplaceCount]));
      Pdf.SaveLoadedDocument('contract-final.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

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

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

Вы не можете заменить текст символом, который никогда не входил во внедренное подмножество шрифта, поскольку последовательность байтов для выбора этого символа просто отсутствует в таблицах сопоставления шрифта. Когда генератор PDF внедряет подмножество шрифта, его таблица /ToUnicode CMap и структуры кодирования охватывают только глифы, которые фактически использовались в исходном документе. Метод HPDFEncodeUnicode может выполнить только существующее обратное сопоставление: если документ никогда не содержал букву E в этом шрифте, обратного кода для символа E просто нет. Это физическое свойство файла, а не ограничение конкретной библиотеки — ни один инструмент не может создать сопоставление глифов, которое никогда не было внедрено в файл

HotPDF обрабатывает сбои консервативно. Если хотя бы один символ замены не может быть закодирован заново, всё вхождение искомого выражения пропускается: без вызова исключений и без вывода частично поврежденного текста, при этом вхождение просто не учитывается в значении ReplaceCount. Практический совет: сравнивайте ReplaceCount с количеством совпадений из предварительного поиска и воспринимайте расхождение как сигнал. В примере с датой выше цифра 6 должна присутствовать где-то в тексте документа тем же шрифтом, чтобы перезапись прошла успешно — это вполне вероятно в счете, но никогда не гарантируется в общем случае. Если нужные символы недоступны, а целью является удаление конфиденциальных данных, а не перефразирование, лучше использовать инструменты полного удаления контента; этот путь описан в статье о редактировании и реструктуризации загруженных PDF в Delphi

var
  Matches: THPDFTextMatchArray;
  Expected, Replaced: Integer;
begin
  Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
  Expected := Length(Matches);
  Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
  if Replaced < Expected then
    WriteLn(Format('вхождений пропущено: символы отсутствуют ' +
      'в подмножестве шрифта или совпадение охватывает несколько операндов',
      [Expected - Replaced]));
end;

Второе условие пропуска в этом сообщении — другое задокументированное ограничение: совпадение, охватывающее несколько строковых операндов (например, слово Hello, разбитое на элементы [(He)(llo)] TJ), обнаруживается поиском, поскольку он сопоставляет декодированную последовательность глифов, но пропускается при замене. Это связано с тем, что перезапись через границы операндов потребовала бы объединения соседних диапазонов байтов. Предварительный поиск позволяет сделать оба этих ограничения видимыми, а не скрытыми

Что меняется в файле при сохранении?

Измененный поток /Contents сохраняется без сжатия. Потоки со сжатием FlateDecode распаковываются для редактирования, а при записи перестроенных байтов HotPDF удаляет запись /Filter потока и обновляет значение /Length вместо повторного сжатия. Результирующий PDF полностью валиден и корректно отображается в стандартных просмотрщиках; компромисс заключается в увеличении размера файла для каждого отредактированного потока. Для пакетных конвейеров, обрабатывающих тысячи документов, следует учитывать этот рост или выполнять отдельный проход сжатия на последующих этапах. Взаимодействие переписанных объектов со структурой перекрестных ссылок документа при сохранении — это отдельная тема, описанная в статье об объектных потоках и инкрементных обновлениях в HotPDF

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

Поиск и замена текста дополняют возможности извлечения, редактирования и рендеринга страниц в наборе инструментов HotPDF для работы с загруженными документами. Все эти функции управляются единым интерпретатором потока контента и доступны в версиях от Delphi 5 до актуальных релизов RAD Studio без внешних зависимостей. Полный справочник API и ознакомительная версия доступны на странице продукта HotPDF Component