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

Безопасное переписывание потоков содержимого PDF в Delphi

HotPDF обходит каждый оператор потока содержимого страницы и передаёт каждый из них вашему коду через VisitLoadedPageContentOperators. Для каждого оператора вы выбираете действие: сохранить его, удалить, заменить собственными байтами, остановить обход или отклонить всю правку целиком. Если сохранены все операторы, переписанный поток побайтово идентичен исходному — именно это свойство делает механизм безопасным для запуска над документами, которые создавали не вы

Редактирование потоков содержимого — это как раз то место, где инструменты для PDF обычно ошибаются. Поток — это последовательность операторов над стековой машиной, а не дерево документа, и наивный подход с регулярным выражением по декодированным байтам ломается в тот момент, когда строковый литерал содержит что-то похожее на оператор, или встроенное изображение содержит произвольные бинарные данные. У посетителя, уже токенизировавшего поток, такой проблемы нет

Операторы потока содержимого PDF, такие как BT, Tf и TJ, поступающие в данные события посетителя вместе с фазой, именем оператора, числом операндов, байтовым диапазоном и состоянием вложенности, а затем возвращающие одно из пяти действий: сохранить, заменить, удалить, остановить или отклонить
Каждый посещённый оператор порождает ровно одно решение, а проход, сохраняющий всё подряд, записывает нетронутые диапазоны обратно для побайтово идентичного вывода

Что реально можно сделать, имея доступ на уровне операторов?

Три семейства задач. Аудит: подсчитать, сколько раз страница задаёт цвет за пределами известного набора, или найти текст, нарисованный шрифтом, который не встроен. Удаление: убрать водяной знак, нарисованный как маркированное содержимое, или вырезать конкретный вызов изображения. И нормализация: заменить одну форму оператора на эквивалентную, которую лучше обрабатывает нижестоящий потребитель

У каждой из этих задач одно общее требование: вы должны иметь возможность оставить нетронутыми остальные 99 процентов потока. Дерево синтаксиса HotPDF хранит одну копию исходника плюс компактные диапазоны, а правки — это отсортированные неперекрывающиеся оверлеи. Запись результата копирует нетронутые диапазоны напрямую и выдаёт байты замены только там, где вы что-то изменили, поэтому проход без изменений не может внести расхождения

Контракт посетителя

Реализуйте интерфейс или назначьте обработчик события, затем вызовите метод обхода с ApplyChanges, установленным в false для аудита или в true для перезаписи. Данные события несут фазу, имя оператора, число его операндов, его байтовый диапазон, текущее состояние вложенности и заимствованное представление для чтения операндов:

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // Считаем операторы отображения текста, нарисованные внутри маркированного содержимого
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // Читаем операнд только тогда, когда он действительно нужен
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // только аудит
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

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

Обратный вызов никогда не получает изменяемое дерево синтаксиса. Он получает представление, способное по запросу вернуть байты текущей операции или конкретный операнд, и это всё. Любое изменение проходит через поля действия и замены

Это разделение — не церемония. Прямая мутация дерева позволила бы посетителю обойти бюджет замены и проверку на перекрывающиеся правки, а перекрывающиеся правки в модели оверлеев по байтовым диапазонам дают вывод, который молча оказывается неверным, а не громко ломается. Маршрутизация изменений через поле действия означает, что каждая правка проверяется одинаково независимо от намерения посетителя

Чтение по запросу также удерживает типичный случай дешёвым. Аудит, который лишь считает имена операторов, вообще никогда не материализует байты операндов, а это важно для потока содержимого с сотнями тысяч операций

Поле состояния всегда описывает вход

Каждое событие несёт глубину состояния графики, глубину текстового объекта, глубину маркированного содержимого и глубину секции совместимости такими, какими они были в исходном входе непосредственно перед выполнением этого оператора. Это верно, даже если ваш посетитель удалил или заменил более ранние операторы

Привязка состояния к исходному входу, а не к отредактированному выводу, — осознанный выбор с практической выгодой: повторный запуск того же аудита над тем же документом даёт идентичные результаты, а аудит, выполняемый одновременно с правками, не смещает собственную систему отсчёта. Если вам нужно знать глубину в выводе, вы знаете, что удалили, и можете её вычислить; обратное — восстановление устойчивых входных позиций из движущегося вывода — невозможно

Удаление и замена, с правилами, которые вас защищают

Замена — это сырые байты потока содержимого, и вы отвечаете за их синтаксис. Защитные механизмы вокруг них — это бюджет на общее число байт замены, требование непересечения и опциональная проверка вывода, повторно разбирающая результат перед его принятием:

Защитные механизмы перезаписи в Delphi: посетитель объявляет намерение через Action и Replacement, которое проходит через бюджет замены, проверку неперекрывающихся байтовых диапазонов и проверку вывода перед сохранённой проверенной страницей
Каждое изменение проходит через поле действия к проверкам бюджета, перекрытия и повторного разбора, поэтому неудачная проверка прерывает операцию вместо записи повреждённых операторов
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // Убираем операторы, рисующие форму водяного знака
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // Нормализуем случайный цвет до фирменного серого
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // Отклоняем всю правку, если встретили что-то непонятное
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // повторный разбор перед принятием
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

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

Чтение статистики и где останавливается обход

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

Счётчики делают аудит самопроверяемым. VisitedOperatorCount должен сходиться с суммой KeptOperatorCount, DeletedOperatorCount и ReplacedOperatorCount, а цифры максимальной глубины подсказывают, достаточно ли глубоко вложена страница, чтобы стоило присмотреться внимательнее. SourceBytes в сравнении с OutputBytes даёт эффект вашей правки на размер одним числом

Сверка статистики аудита, где сумма счётчиков сохранённых, удалённых и заменённых операторов равна числу посещённых, рядом с семью вариантами завершения статуса от завершено до ошибки посетителя, с пометкой, что глубины состояния описывают исходный вход, поэтому аудит повторяется идентично
Счётчики, которые обязаны сходиться, плюс статус, называющий каждый возможный вариант завершения, делают аудит самопроверяемым на документах, которые создавали не вы

Для типичных задач есть маршруты более высокого уровня, вообще не требующие написания посетителя. Постоянное удаление содержимого с очисткой ресурсов, которая нужна настоящей редакции, описано в статье редактирование с удалением содержимого и сшивка страниц загруженных документов. Замена видимого текста описана в статье поиск и замена текста в существующем PDF. Обращайтесь к посетителю операторов, когда ваше правило структурное, а не текстовое, или когда вам нужен аудит, на который не отвечает ни один существующий отчёт

Обход, извлечение и перезапись потоков содержимого работают с одной и той же моделью загруженного документа в Delphi и C++Builder; полный список возможностей — на странице компонента HotPDF Delphi PDF Component