Видалення сторінки з 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 запускає прохід негайно і повертає кількість видалених осиротілих непрямих об'єктів. Негайну форму варто використовувати, коли потрібне число для логування чи assert, і його варто перевіряти, бо від'ємне значення — це не кількість
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, чия сторінка продукту містить повний довідник конвеєра збереження, включно з взаємодією між збиранням сміття, пакуванням потоків об'єктів і лінеаризацією