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

Сжатие PDF в Delphi: orphan-объекты и дедупликация

PDFium Component для Delphi (PDFiumPas) сжимает PDF-файлы тремя API перезаписи на TPdf: SaveAsReachable выбрасывает объекты, на которые никто не ссылается, SaveAsDeduplicated сливает побайтово идентичные шрифты, изображения и Form XObject, а SaveAsFlateRecompressed перекодирует существующие Flate-потоки на максимальном сжатии. Каждый возвращает запись-отчёт, чтобы вы видели, что удалено и сколько байтов сэкономлено

Обычный спусковой крючок — тикет поддержки в духе «удалили двадцать страниц, а файл стал больше». Кто-то открыл договор, убрал приложение, вмержил обложку и сохранил. Каждый шаг был инкрементальным обновлением, поэтому старые объекты всё ещё в файле, и когда этот документ позже загружается и пишется через FPDF_SaveAsCopy, они выходят обратно. PDFium сериализует всё, что лежит в его объектной таблице в памяти; у него нет публичного вызова, чтобы перечислить косвенные объекты, удалить один или переписать ссылки. Поэтому всё в этой статье исполняется в обёрточном слое PDFiumPas поверх байтов, которые производит PDFium

Почему FPDF_SaveAsCopy оставляет удалённые страницы в файле?

FPDF_SaveAsCopy сохраняет осиротевшие объекты, потому что PDFium грузит в документ каждую живую запись cross-reference-таблицы, указывает ли на неё ещё что-то или нет. ISO 32000-1 §7.5.6 описывает инкрементальные обновления как дописывание новых версий объектов и нового сечения xref в конец файла. Удаление страницы правит только дерево страниц; словарь страницы, её content stream и любые шрифты или изображения, использованные только ею, всё ещё числятся занятыми объектами. Полное не-инкрементальное сохранение этого не меняет: с точки зрения объектной таблицы те объекты совершенно валидны

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

Mark-and-sweep через SaveAsReachable

TPdf.SaveAsReachable убирает осиротевшие объекты классическим проходом mark-and-sweep: он даёт PDFium записать не-инкрементальную копию текущего документа, разбирает эту копию в граф объектов, помечает всё достижимое от корней и пишет одну классическую ревизию, содержащую только помеченные объекты. Объекты внутри object stream по пути материализуются как обычные косвенные объекты, так что вывод всегда использует простую таблицу xref

Выбор корней — место, где наивная реализация ломается. Очевидное зерно — Catalog, названный в трейлере записью /Root. Оттуда обход дойдёт до дерева страниц, ресурсов страниц, шрифтов, изображений, Form XObject, AcroForm, дерева структуры, закладок, деревьев имён, optional content, XMP-метаданных, output intent и вложенных файлов. Но на информационный словарь документа ссылается трейлерная запись /Info (ISO 32000-1 §7.5.5), а не Catalog. Посеять только от /Root — и вы молча удалите Title, Author и Producer каждого обработанного файла. PDFiumPas засевает обход обоими, /Root и /Info, а перестроенный трейлер тащит /Info вперёд, только когда тот объект действительно выжил

Обходчик — обход в глубину с явным стеком и множеством посещённых, поэтому циклы ссылок (родительские ссылки в дереве страниц, обратные указатели /P аннотаций) не могут его повесить. Ссылка на уже свободный номер объекта просто не отслеживается, а не трактуется как ошибка — так проход терпим к слегка повреждённому входу

Номера объектов сохраняются, а не перенумеровываются

TPdf.SaveAsReachable держит каждый выживший объект на исходном номере и поколении. Выметенные объекты пишутся свободными записями в таблице xref (ISO 32000-1 §7.5.4), промежутки между живыми номерами заполняются свободными записями, а объект 0 остаётся головой списка свободных. Поскольку ничего не перенумеровывается, каждая уцелевшая ссылка остаётся верной без единого переписанного байта, и нет отображения «старое в новое», которое можно испортить. Цена — несколько неиспользуемых строк xref по 20 байтов, и это размен, которого стоит

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport несёт SourceByteCount (размер не-инкрементальной копии, произведённой PDFium), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded и ErrorMessage. Перегрузка с именем файла пишет во временный файл рядом с целью и подменяет цель, только когда перезапись удалась, так что неудавшийся прогон никогда не оставит после себя обрезанный PDF

Какие файлы эти API откажутся брать?

Все три API перезаписи отказывают шифрованным документам и по умолчанию — подписанным. Шифрованный вход проваливается с Succeeded = False и сообщением об ошибке вместо исключения: перезапись одной ревизией не может сохранить номера объектов, диапазоны байтов и ключи на объект, от которых зависит словарь шифрования, — вывод был бы нечитаем. Документ с цифровыми подписями поднимает EPdfError, если вы не передали AllowSignedDocument = True: полная перезапись всегда обнуляет подписанный диапазон байтов. Выставить тот флаг — значит согласиться на файл, чьи подписи будут докладывать как сломанные; для архивной копии это верное решение, никогда — для договора, который вы вот-вот отправите обратно

Как SaveAsDeduplicated решает, что два изображения одинаковы?

TPdf.SaveAsDeduplicated считает два ресурса идентичными, только когда совпадают их словари, все их достижимые подграфы и сырые байты потоков. Сперва он прогоняет тот же обход достижимости, затем снимает отпечаток с каждого достижимого шрифта, image XObject и Form XObject, сливает каждую группу совпадений в член с наименьшим номером объекта и переписывает каждую ссылку на этого выжившего. Слитые дубликаты становятся свободными записями xref, как и сироты. Это фикс классического отчёта «логотип встроен 400 раз», который производят mail-merge-инструменты, перевстраивающие одни и те же ресурсы на каждой странице

Отпечаток — 64-битный хэш FNV-1a, вычисляемый рекурсивно. Для каждого кандидата обходчик берёт тело объекта, заменяет каждую ссылку N G R отпечатком объекта, на который она указывает, и хэширует результат. Это делает хэш независимым от нумерации объектов: две копии одного шрифта, чьи дескрипторы случайно оказались объектами 12 и 87, всё равно дадут одно значение. Результаты мемоизируются, так что общие подобъекты хэшируются однажды, а множество посещаемых возвращает фиксированный сентинел для обратных рёбер, чтобы циклические графы завершались

Тонкий баг сидит в полезной нагрузке потока. Чтобы находить ссылки безопасно, PDFiumPas сканирует копию каждого объекта, в которой данные потока, строки и комментарии затёрты пробелами; иначе серия бинарных пиксельных данных, случайно выглядящая как 12 0 R, была бы принята за ссылку. Хэшируйте только эту санитарную копию — и два изображения с идентичными словарями (одинаковые /Width, /Height, /BitsPerComponent, /Filter, /Length), но разными пикселями схлопнутся в один отпечаток, и второе фото на странице молча заменится первым. SaveAsDeduplicated дописывает к хэшируемым данным сырые байты между stream и endstream, так что пиксели — часть идентичности. Кандидаты к тому же группируются по категории перед сравнением, поэтому редкая коллизия хэша между шрифтом и изображением никогда их не сольёт

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

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

Читайте TPdfDedupReport внимательно. Рядом с тремя полями Merged*Count в нём есть KeptObjectCount и PrunedObjectCount, причём в этом отчёте PrunedObjectCount считает только слитые дубликаты. Недостижимые объекты всё равно выбрасываются, но в это число не входят. Если для журнала нужен счётчик осиротевших, возьмите его отдельным прогоном SaveAsReachable. Другой предел — совпадение побайтово точно на закодированном потоке: один и тот же логотип, сжатый двумя разными продюсерами на двух разных уровнях Flate, слит не будет

Пережатие Flate-потоков на максимальном уровне

TPdf.SaveAsFlateRecompressed надувает каждый достижимый поток FlateDecode и снова сдувает его на высочайшем уровне сжатия, оставляя результат, только когда тот меньше и проходит ваши пороги. Многие продюсеры пишут Flate на быстром дефолтном уровне, поэтому content stream и встроенные шрифтовые программы часто сжимаются дальше. Номера объектов и поколения остаются на месте; меняются только полезная нагрузка и её прямой /Length

У TPdfFlateRecompressOptions два поля и нет конструктора по умолчанию, так что задайте оба. MinSavingBytes — минимальная абсолютная экономия на поток, MinSavingRatio — доля исходной длины (0.05 значит 5%); соотношение 0 отключает тест пропорции. Перестроенный поток пишется как stream, CRLF, новая полезная нагрузка, CRLF и endstream, поэтому результат не зависит от переводов строк исходного файла

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // игнорировать крошечные выигрыши
  Options.MinSavingRatio := 0.05;   // и всё, что ниже 5%
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

Краевые случаи консервативны по замыслу. Поток, чей /Length — косвенная ссылка, пропускается: перезапись обязана хранить прямое целое. Полезная нагрузка, не надувшаяся, засчитывается в FailedCount и копируется без изменений. Flate-поток, не прошедший пороги, идёт в SkippedCount. Изображения DCT, JPX, JBIG2 и CCITT никогда не попадают в список кандидатов, так что этот проход никогда не трогает качество JPEG. Для сжатия изображений с потерями используйте адаптивный ресемплинг изображений в PDFiumPas до финального сохранения

Одна ловушка переносимости из написания того порогового теста: первый черновик сравнивал Single(Saving)/Single(OrigLen) при Saving, объявленном как Int64. Delphi это принимает, а FPC 3.2.2 останавливается с Illegal type conversion: "Int64" to "Single": в FPC этот синтаксис — тайпкаст, а не числовое преобразование. Простое Saving / OrigLen — настоящее деление в обоих компиляторах и напрямую сравнивается с Single, так что приведения были просто не нужны

В каком порядке прогонять три API?

Прогоните сперва SaveAsDeduplicated, перезагрузите вывод, затем прогоните SaveAsFlateRecompressed. Дедупликация уже включает выметание достижимости, так что отдельный проход SaveAsReachable перед ней избыточен; оставьте SaveAsReachable для задач, где нужна только чистка осиротевших и точный счёт выметенного. Пережатие вторым означает, что дорогая работа «надуть-сдуть» идёт один раз на выживший поток, а не один на дубликат. Проход пережатия к тому же снова обходит граф от /Root и /Info, что подчищает дескрипторы шрифтов и шрифтовые файлы, на которые указывал только слитый дубликат-шрифт

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // вымести + слить

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // пересдуть выживших
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

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

FAQ

Перенумеровывает ли SaveAsReachable объекты или меняет ссылки?

Нет. TPdf.SaveAsReachable держит каждый выживший объект на исходном номере и поколении и помечает удалённые объекты свободными записями xref, поэтому ни одну ссылку переписывать не нужно

Может ли SaveAsDeduplicated по ошибке слить два разных изображения?

Не через одно совпадение словарей. Отпечаток включает сырые байты потока и весь упомянутый подграф, а кандидаты сравниваются только внутри одной категории (шрифт, изображение, Form XObject)

Сохранят ли эти API валидность моих цифровых подписей?

Нет. Каждый API выполняет полную перезапись, ломающую подписанный диапазон байтов, поэтому подписанные файлы отвергаются, если вы не передали AllowSignedDocument = True и не согласились на неверные подписи в выводе

Все три API поставляются с PDFium Component for Delphi и C++Builder — вместе с возможностями redaction и ресемплинга изображений, разобранными в других статьях блога