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

PDF redaction и N-up stitching в Delphi с HotPDF

К вам приходит запрос: взять пакет уже отрендеренных выписок, закрасить номера счетов и вывести по две страницы на лист, чтобы сэкономить бумагу. Обе половины этой задачи сводятся к хирургии content stream в PDF, который создали не вы, поэтому здесь нет дружелюбного page canvas для рисования и нет font manager, на который можно опереться. Вы напрямую редактируете граф объектов загруженного документа, дописывая сырые операторы рисования в страницу, макет которой уже собрал другой инструмент. HotPDF открывает для этого ровно две точки входа, и более опасная из них выглядит как раз безобидной

HotPDF является нативным VCL PDF-компонентом для Delphi и C++Builder. Его API для загруженных документов девятого раунда впервые добавил методы, которые создают совершенно новый контент на странице, открытой с диска, а не построенной вами с нуля. Здесь рассматриваются два из них: RedactLoadedRect , который рисует непрозрачный прямоугольник поверх области, и StitchLoadedPage , который масштабирует одну страницу и рисует ее на другой. Оба работают путем записи операторов content stream из ISO 32000-1 §8.5 в поток /Contents страницы. Понимание того, что делают эти операторы и, что не менее важно, чего они не делают, отделяет рабочий инструмент от утечки данных

Добавление операторов в загруженную страницу

Когда вы строите страницу обычным API HotPDF, компонент сам владеет content stream и сериализует ваши вызовы TextOut и vector для вас. Загруженная страница устроена иначе: ее /Contents является уже существующим stream object, возможно разделяемым, возможно входящим в content array, и вам нужно врезаться в него, не испортив уже имеющееся содержимое. Девятый раунд добавил три небольших помощника, которые делают это безопасным. NewIndirectStream выделяет новый косвенный THPDFStreamObject с пустым буфером и записью /Length 0 ; ResolveLoadedStream проходит по косвенной ссылке к базовому потоку, а AppendLoadedStream дописывает сырые байты в конец потока и переписывает /Length так, чтобы сохраняемый объект оставался корректно сформированным

Шаблон, которому следуют оба публичных метода, одинаков. Найти /Contents страницы, разрешить его в поток, а если пригодного потока нет, создать новый и привязать его. Затем дописать операторы. Поскольку новые байты помещаются в конец потока, модель художника гарантирует, что они будут нарисованы поверх всего, что вывел исходный макет. Вся механика прямоугольника для redaction держится именно на этом порядке, и по той же причине этот прямоугольник не является тем, чем его обычно считают

RedactLoadedRect: непрозрачная накладка, а не удаление

RedactLoadedRect принимает zero-based индекс страницы, четыре координаты в user space и три цветовых компонента в диапазоне 0-1:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statement.pdf') > 0 then
    begin
      // Cover the account-number band on page 1 with solid black.
      // Coordinates are PDF user space: origin bottom-left, points.
      Pdf.RedactLoadedRect(0, 56, 690, 320, 706, 0, 0, 0);
      Pdf.SaveLoadedDocument('statement-covered.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Под капотом метод записывает в content stream три оператора: установку цвета заливки в DeviceRGB, r g b rg , путь прямоугольника, x y w h re , и заливку, f . Ширина и высота вычисляются как X2 - X1 и Y2 - Y1 , поэтому вы передаете две противоположные точки, а метод сам вычисляет размеры. Передайте 0, 0, 0 для цвета и получите черную полосу. Передайте 1, 1, 1 для белой, совпадающей с белой страницей. Координаты принадлежат собственной user space загруженной страницы, то есть начало находится в левом нижнем углу, единицы измерения это points, и это также означает, что для точного размещения нужно знать /MediaBox страницы. GetLoadedPageBox вместе с pbMediaBox дает вам это

Прочитайте это дважды: закрашенный прямоугольник визуально закрывает контент, но не удаляет его. Текст, изображение или vector art под прямоугольником все еще присутствуют в PDF, все еще находятся в графе объектов, их по-прежнему может извлечь любой, кто скопирует страницу, запустит text extractor или просто удалит ваш прямоугольник из content stream. Это визуальная маскировка, а не redaction в юридическом или защитном смысле. Если вы скрываете действительно чувствительные данные, номера счетов, медицинские записи, личности, вообще что-либо регулируемое, закрывать это черным прямоугольником и отправлять файл означает готовить утечку данных, которая рано или поздно обнаружится. Настоящая redaction требует удаления лежащих под этим объектов контента, а не рисования поверх них

Само имя метода содержит слово "Redact", и это полезное предупреждение о том, как результат будут неверно понимать, а не обещание того, что именно будет удалено. В собственной комментарии реализация честна: она называет себя "visual redaction primitive" и отмечает, что redaction с удалением контента требует интерпретатора content stream, который проходит по существующим операторам и переписывает их. В пути загруженного документа HotPDF здесь этого не делает. Поэтому безопасное правило узкое: используйте RedactLoadedRect для нечувствительной косметической маскировки, скрытия чернового watermark, очистки области перед скриншотом, перекрытия устаревшего логотипа на внутреннем proof. Как только то, что находится под прямоугольником, стало бы важным в случае утечки, этот метод становится неправильным инструментом, а правильный ответ состоит в повторной генерации документа без этих данных или использовании настоящего pipeline удаления контента

StitchLoadedPage: масштабировать, сдвигать, рисовать

N-up impose является более дружелюбной задачей, потому что здесь ничего не скрывается, а только переставляется. StitchLoadedPage принимает индекс целевой страницы, индекс исходной страницы, смещение X/Y и коэффициент масштаба, затем рисует исходную страницу на целевой в указанной позиции и размере:

// Overlay page 2 (index 1) onto page 1 (index 0),
// scaled to 70% and nudged up-right.
Pdf.StitchLoadedPage(0, 1, 40, 380, 0.7);

// Convenience 2-up: source page on the right half of the target.
Pdf.StitchLoadedPageSideBySide(0, 1);

Строка операторов, которую он дописывает, представляет собой стандартную последовательность transform-and-paint: q для сохранения графического состояния, матрицу cm со scale на диагонали и offset в слотах трансляции, /StitchSrc Do для вызова внешнего объекта и Q для восстановления состояния. Пара q/Q важна, она изолирует transform, чтобы stitched-страница не протекала своей системой координат во все, что будет дописано позже. Метод также блокирует очевидные ошибки, индексы вне диапазона, совпадение target и source, неположительный scale, который он зажимает к 1.0 , и завершает работу тихо, без исключения, поэтому проверяйте входные данные, потому что безмолвный no-op выглядит точно так же, как успех

StitchLoadedPageSideBySide является тонкой удобной оберткой над общим методом. Он читает ширину media box целевой страницы, делит ее пополам и вызывает StitchLoadedPage с этой половиной ширины как X-offset и фиксированным scale 0.5 , помещая исходную страницу в правую половину. Жестко заданное 0.5 предполагает, что у source и target одинаковая ширина. Если это не так, source не заполнит свою половину аккуратно, и вам понадобится общий StitchLoadedPage с scale, который вы вычислите сами из обоих media box

Упрощенная стратегия XObject и ее компромисс по ISO

Именно здесь реализация делает осознанное упрощение, о котором нужно знать, прежде чем доверять результату во всех viewer. Корректный N-up impose оборачивает content исходной страницы в Form XObject, то есть самодостаточный drawable object, который ISO 32000-1 §8.10.1 требует снабдить /Type /XObject , /Subtype /Form и собственной clipping box /BBox . Stitch из девятого раунда в HotPDF такую обертку не строит. Вместо этого он регистрирует сам словарь страницы исходника непосредственно в /Resources /XObject целевой страницы под именем StitchSrc , а затем рисует его через Do . Словарь страницы и Form XObject достаточно близки по модели содержимого, оба ссылаются на content stream и словарь ресурсов, поэтому многие reader все-таки отрисуют результат

Но это не conforming Form XObject. В нем нет маркера /Subtype /Form и собственной записи /BBox , а значит, строгий consumer имеет полное право проигнорировать Do или обрезать его не так, как вы ожидаете. TechnicalNotes для этого раунда говорят об этом прямо: подход "renders under most readers", но не является "strictly ISO-compliant Form XObject", а для полной совместимости нужно отдельно синтезировать настоящий поток Form XObject. Поэтому относитесь к stitch output так же, как к любой несоответствующей конструкции: проверяйте его в тех viewer, которыми действительно пользуются ваши клиенты, а не только в том, что стоит у вас. Если вам нужны архивные PDF или файлы, чистые для строгих validator, не полагайтесь на этот путь. Та же дисциплина применима ко всему, что вы строите поверх загруженного графа объектов, и именно поэтому PDF preflight pass в Delphi заслуживает места в release pipeline всякий раз, когда вы программно мутируете документы

Где это подходит, а где нет

Оба метода являются инструментами content stream, поэтому ментальная модель здесь та же, что и для прямого рисования. Если вы уже строили страницы с нуля этим компонентом, vector и color operators за этими вызовами покажутся знакомыми по рисованию на canvas HotPDF в Delphi . Отличается лишь то, что здесь вы дописываете в поток, созданный кем-то другим, а не в поток, которым владеете сами. Держите в голове три границы:

  • Redaction является косметической. RedactLoadedRect закрашивает контент и никогда его не удаляет. Для всего чувствительного пересобирайте источник или используйте настоящее удаление контента, черный прямоугольник не является защитой
  • Stitching по замыслу не является строго совместимым. Исходная страница ссылается как псевдо-XObject без записей §8.10.1 /Subtype /Form и /BBox , поэтому подтверждайте рендеринг в целевых viewer и избегайте этого подхода там, где требуется строгая валидация
  • Координаты живут в user space страницы. Начало в левом нижнем углу, единицы points, все определяется собственным media box страницы. Считывайте box через GetLoadedPageBox , прежде чем что-либо размещать, потому что загруженная страница может оказаться не того размера, который вы предполагали

Если использовать пару этих методов в указанных пределах, они покрывают реальный workflow: переставить страницы для печати, замаскировать неконфиденциальные области и записать результат обратно через SaveLoadedDocument , и все это без полного повторного рендеринга. API для загруженных документов, включающий эти примитивы stitching и masking, поставляется вместе с HotPDF Component для Delphi и C++Builder, наряду с методами form field, annotation и FDF из того же раунда