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

Сборка мусора PDF в Delphi: пометка и зачистка

Удаление страницы из PDF не удаляет её шрифты, изображения или потоки содержимого. losLab PDF Library возвращает их через mark-sweep сборщик, который проходит граф объектов вперёд от корней трейлера и удаляет каждый косвенный объект, до которого ничто больше не дотягивается. Он запускается при полном сохранении, по умолчанию выключен и возвращает число отброшенных объектов

Почему удаление страниц PDF не уменьшает файл?

Потому что удаление страницы — это правка ссылок, а не операция хранения. DeletePages(StartPage, PageCount) отвязывает объекты страниц от дерева страниц и чинит записи оглавления, которые на них указывали. Чего он не может — это решить, что программа шрифта, поток содержимого и XObject изображения, которые использовали эти страницы, теперь мертвы, потому что в момент удаления в файле ничто не фиксирует, кто ещё на них может ссылаться. Эти объекты остаются в списке объектов документа, и полное сохранение записывает каждый из них обратно. Результат — жалоба, с которой начинается большинство таких обращений в поддержку: клиент удаляет девяносто процентов страниц, сохраняет, а файл уменьшается на два процента. Хуже того, утечка накапливается. Загрузить, удалить, сохранить, снова загрузить, снова удалить, снова сохранить — и файл монотонно растёт, пока число страниц падает. Это другая проблема, нежели та, что решается субсеттингом шрифтов и понижением разрешения изображений, которые уменьшают живые объекты. Здесь объекты не слишком велики — они просто больше не часть документа

Корневой набор — это трейлер, а не дерево страниц

Граф объектов PDF не имеет поля обратной ссылки. Формат не определяет ни счётчика ссылок, ни списка обратных указателей, а ключи /Parent, которые всё же существуют, принадлежат конкретным структурам вроде дерева страниц, а не графу объектов в целом. Ничто в косвенном объекте не сообщает, кто на него указывает, поэтому вопрос «использует ли ещё кто-то объект 47» имеет ровно один ответ: пройти вперёд от известного корня и посмотреть, дойдёте ли вы до него. Именно поэтому сборщик в losLab PDF Library — это mark-sweep сборщик, а не схема подсчёта ссылок

Корни берутся из трейлера файла (ISO 32000-1 §7.5.5). Три ключа их несут: /Root, каталог документа из §7.7.2, от которого свисают дерево страниц, имена, оглавление, AcroForm и метаданные; /Info, словарь информации о документе; и /Encrypt, словарь шифрования. Два оставшихся ключа трейлера — приманки. /ID — это массив из двух байтовых строк, а /Prev — целочисленное байтовое смещение к предыдущему разделу перекрёстных ссылок. Ни то ни другое не является косвенной ссылкой, поэтому ни то ни другое не даёт корня. losLab PDF Library ставит в очередь весь словарь трейлера целиком, а не три именованных ключа, что ничего не стоит и сохраняет живым любое частное расширение трейлера

Сам обход — итеративный, а не рекурсивный. Когда обход встречает косвенную ссылку, он записывает только номер объекта и поколение, помечает соответствующий слот и помещает его в очередь FIFO вместо немедленного разыменования, что не даёт глубоким деревьям страниц и длинным цепочкам оглавления попасть в стек вызовов и предотвращает повторное декодирование одного и того же объекта. Прямые словари, массивы и словари потоков попадают во вторую очередь, охраняемую набором посещённых, потому что в реальных документах встречаются настоящие циклы: /Parent страницы указывает обратно на её узел дерева страниц, а элементы оглавления сцеплены через /Prev и /Next в обоих направлениях. Номера поколений — часть совпадения, а не украшение. Ссылка разрешается только когда номер объекта и поколение совпадают оба; ссылка на номер, существующий в другом поколении, трактуется как нулевой объект, которого требует спецификация, и никогда — как живое ребро

Как включить сборку мусора при сохранении?

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

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

Ещё две точки входа ведут к тому же сборщику. SetGarbageCollect(1) устанавливает флаг на выбранном документе, так что обычный SaveToFile его учитывает, а GarbageCollectObjects запускает проход немедленно и возвращает число удалённых бесхозных косвенных объектов. Немедленная форма — та, которую стоит использовать, когда нужно число для журнала или проверки, и её стоит проверять, потому что отрицательное значение — это не счётчик

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

Этот путь отказа важнее, чем кажется. Объекты декодируются лениво, и объект, который никогда не декодировался, не показывает вообще никаких ссылок. Если бы сборщик трактовал недекодируемый объект как пустой узел, он бы вымел всё, достижимое только через него. Поэтому обход принудительно декодирует по мере прикосновения к каждому объекту, а единственная ошибка декодирования прерывает весь проход с отрицательным результатом и оставляет документ байт-в-байт идентичным. Зачистка графа, который вы понимаете лишь частично, — это способ превратить повреждённый файл в уничтоженный

Что ломает наивный сборщик PDF?

Две детали, и обе отказывают молча, а не громко. Первая — потоки объектов. Начиная с PDF 1.5 объект-не-поток может жить сжатым внутри контейнера /ObjStm (§7.5.7), и его запись перекрёстных ссылок — это запись типа 2, называющая контейнер плюс индекс внутри него. Сжатый объект поэтому достижим только через свой контейнер. Пометьте член, зачистите контейнер, потому что ничто не ссылалось на него как на объект документа, — и вы записали файл, чей xref указывает в объект, которого больше не существует. Контейнер — это структурное хранилище, а не данные документа, поэтому он никогда не появляется как ребро в графе объектов, который вы обходите. losLab PDF Library решает это, отделяя каждого выжившего сжатого члена от исходного контейнера до того, как контейнеры исчезнут, после чего сохранение переупаковывает выживших в свежие потоки объектов. Вторая деталь — на что реально ссылается объект-поток. Байты не являются частью графа. Поток содержимого, рисующий текст с /F1 12 Tf, называет шрифт по имени ресурса, и это имя разрешается через словарь /Resources страницы, так что ребро достижимости идёт страница → /Resources/Font → объект шрифта, никогда через полезную нагрузку потока. Единственные ссылки, которые вносит поток, идут из его словаря, где /Length, /Filter и /DecodeParms — все могут быть косвенными. Сборщик, который парсит байты потока в поисках ссылок, впустую делает дорогую работу; сборщик, который пропускает словари потоков, теряет объект длины и портит файл

Что происходит с освобождёнными номерами объектов

Они становятся свободными записями и не переиспользуются в том же сохранении. Зачистка обходит список объектов в порядке убывания, чтобы удаления оставались индексно-стабильными, перестраивает индекс поиска один раз в конце вместо после каждого удаления, и для каждого удалённого объекта записывает номер в список свободных с поколением, увеличенным на единицу, — именно так, как требует §7.5.4 для записи, которая может быть позже переиспользована. Поколение, уже равное 65535, остаётся таким, отмечая этот номер как навсегда выведенный из обращения. Номера объектов намеренно не уплотняются. После сборки файл сохраняет дыры: объект 12 может быть свободен, пока 13 и 14 используются, а /Size трейлера по-прежнему сообщает наибольший номер плюс один, а не число выживших. Это законно и нормально. Перенумерация сэкономила бы горстку байт в таблице перекрёстных ссылок и потребовала бы переписать каждую ссылку в документе — а это тот вид изменения, что незаметно делает недействительным всё, что держит номера объектов извне. Размер, который вы получаете обратно, идёт от тел объектов, а не от таблицы xref

Когда нельзя запускать сборщик

Никогда при инкрементальном обновлении. Сборщик привязан к полным сохранениям, и флаг просто не читается, когда документ дополняется, и эта блокировка — не ограничение, которое нужно обходить. Инкрементальное обновление (§7.5.6) оставляет исходные байты нетронутыми и дописывает новый раздел перекрёстных ссылок, сцепленный с предыдущим через /Prev. Каждая более ранняя редакция по-прежнему указывает на объекты, на которые она всегда указывала, так что объект, недостижимый в текущей редакции, вполне достижим в более старой. Удаление его сломало бы каждую редакцию, кроме последней, и механика того, почему это так, разбирается в статье про инкрементальные обновления и сохранение в режиме дописывания. То же рассуждение исключает сборку мусора на подписанном документе, потому что полная перезапись, которая делает сборку возможной, сама же делает недействительной подпись

Стоит также ясно понимать, чем сборка не является. Это не санитайзер. Сборщик удаляет объекты, на которые никто не ссылается; у него нет мнения о том, была ли их содержимое чувствительным, а объект, на который по-прежнему ссылаются, остаётся тем, чем и был. Если цель — сделать информацию невосстановимой, а не сделать файл меньше, граф объектов — неверный уровень, и правильный — редактирование на уровне инструкций и санация документа. Эти два хорошо сочетаются именно в таком порядке: сначала редактировать и санировать, затем собирать, чтобы объекты, отсоединённые редактированием, действительно покинули файл. То же сочетание существует в API очистки ресурсов, где передача опции сборки мусора заставляет очистку запустить сборку впоследствии и сообщить об удалённых сиротах в OrphanObjectsRemoved

Последняя привычка, которую стоит выработать. Журналируйте возвращаемое значение GarbageCollectObjects в любом пакетном задании, которое выполняет удаление страниц, и наблюдайте за ним несколько недель на реальных документах. Ноль на файле, который вы только что уменьшили вдвое, означает, что что-то выше по цепочке всё ещё держит ссылку, которую вы не ожидали, — обычно запись дерева имён, назначение в оглавлении или поле AcroForm, пережившее страницу, к которой оно было прикреплено. Сборщик — это самый дешёвый отладчик достижимости, какой у вас когда-либо будет, потому что он отвечает на вопрос, на который сам формат PDF отказывается отвечать

Сборщик мусора, запись опций сохранения и API очистки ресурсов, описанные здесь, — часть losLab PDF Library для Delphi и C++Builder, чья страница продукта содержит полный справочник конвейера сохранения, включая взаимодействие сборки, упаковки потоков объектов и линеаризации