Изтриването на страница от PDF не изтрива нейните шрифтове, изображения или content streams. losLab PDF Library ги възстановява с mark-sweep колектор, който обхожда графа от обекти напред, започвайки от корените в trailer-а, и премахва всеки indirect обект, до който нищо не достига. Той се изпълнява при full save, по подразбиране е изключен и връща броя на премахнатите обекти
Защо изтриването на PDF страници не смалява файла?
Защото изтриването на страница е редакция на референции, а не операция на съхранение. DeletePages(StartPage, PageCount) откача обектите на страницата от дървото на страниците и поправя записите в outline-а, които са сочели към тях. Това, което не може да направи, е да реши, че шрифтовата програма, content stream-ът и image XObject, които тези страници са ползвали, вече са мъртви — защото в момента на изтриването нищо във файла не записва кой друг може все още да сочи към тях. Тези обекти остават в списъка с обекти на документа и full save ги записва всичките обратно. Резултатът е оплакването, с което започват повечето от тези support нишки: клиент изтрива деветдесет процента от страниците, записва, а файлът намалява с два процента. Още по-лошо, изтичането се натрупва. Зареждане, изтриване, запис, ново зареждане, ново изтриване, нов запис — и файлът расте монотонно, докато броят на страниците пада. Това е различен проблем от този, решаван от subsetting на шрифтове и downsampling на изображения, които правят живите обекти по-малки. Тук обектите не са прекалено големи. Те просто вече не са част от документа
Root set е trailer-ът, не дървото на страниците
Графът от PDF обекти няма поле за обратна референция. Форматът не дефинира брояч на референции, нито списък с обратни указатели, а ключовете /Parent, които действително съществуват, принадлежат на конкретни структури като дървото на страниците, а не на графа от обекти като цяло. Нищо в indirect обект не казва кой сочи към него, така че въпросът "използва ли все още някой обект 47" има точно един отговор: обходи напред от известен корен и виж дали ще стигнеш дотам. Затова колекторът в losLab PDF Library е mark-sweep колектор, а не refcount схема
Корените идват от trailer-а на файла (ISO 32000-1 §7.5.5). Три ключа ги носят: /Root — каталогът на документа от §7.7.2, от който висят дървото на страниците, имената, outline-ите, AcroForm и метаданните; /Info — речникът с информация за документа; и /Encrypt — речникът за криптиране. Останалите два ключа в trailer-а са примамки. /ID е масив от два байтови низа, а /Prev е цяло число — byte offset към предишната cross-reference секция. Нито един от двата не е indirect референция, така че нито един не допринася корен. losLab PDF Library поставя в опашката целия trailer речник вместо три именувани ключа, което не струва нищо и запазва живи всякакви частни trailer разширения
Самото обхождане е итеративно, не рекурсивно. Когато обходът срещне indirect референция, той записва само номера на обекта и поколението, маркира съответното гнездо и го поставя в FIFO опашка вместо да го dereference-ва веднага, което държи дълбоки дървета на страници и дълги outline вериги извън call stack-а и спира едно и също обект да бъде декодиран два пъти. Директните речници, масиви и stream речници отиват във втора опашка, охранявана от visited set, защото истинските документи съдържат истински цикли: /Parent на страница сочи обратно към нейния node в дървото на страниците, а outline елементите се верижват през /Prev и /Next в двете посоки. Числата на поколението са част от съвпадението, не украса. Референция се разрешава само когато номерът на обекта и поколението съвпадат едновременно; референция към номер, който съществува при различно поколение, се третира като null обектa, изискван от спецификацията, никога като жив ръб
Как се включва garbage collection при запис?
Garbage collection е opt-in и принадлежи на записа с опциите за save. По подразбиране е 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 изпълнява преминаването незабавно и връща броя на премахнатите изоставени indirect обекти. Незабавната форма е тази, която да се използва, когато искате число за логване или 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;
Този път за неуспех има по-голямо значение, отколкото изглежда. Обектите се декодират лениво, а обект, който никога не е бил декодиран, не разкрива никакви референции. Ако колекторът третираше недекодируем обект като празен node, той би пометнал всичко достижимо само през него. Затова обходът принуждава декодиране при всяко докосване на обект, а една-единствена грешка при декодиране прекратява цялото преминаване с отрицателен резултат и оставя документа байт-идентичен. Помитането на граф, който разбирате само частично, е начинът, по който колектор превръща повреден файл в унищожен
Какво чупи наивен PDF колектор?
Два детайла, и двата се провалят тихо, а не шумно. Първият са object streams. От PDF 1.5 насам обект, който не е stream, може да живее компресиран вътре в контейнер /ObjStm (§7.5.7), а неговият cross-reference запис е запис от тип 2, който именува контейнера плюс индекс в него. Затова компресиран обект е достижим само през своя контейнер. Маркирайте члена, пометнете контейнера, защото нищо не е реферирало към него като документен обект, и сте записали файл, чийто xref сочи в обект, който вече не съществува. Контейнерът е структурно съхранение, не документни данни, така че никога не се появява като ръб в графа от обекти, който обхождате. losLab PDF Library решава това, като откача всеки оцелял компресиран член от изходния му контейнер, преди контейнерите да изчезнат, след което запазването пренарежда оцелелите в свежи object streams. Вторият детайл е какво всъщност реферира stream обектът. Байтовете не са част от графа. Content stream, който изчертава текст с /F1 12 Tf, назовава шрифт по ресурсно име, а това име се разрешава чрез речника /Resources на страницата, така че ръбът на достижимост минава страница → /Resources → /Font → обект на шрифта, никога през payload-а на stream-а. Единствените референции, които stream допринася, идват от неговия речник, където /Length, /Filter и /DecodeParms всички е позволено да бъдат indirect. Колектор, който парсва байтовете на stream, търсейки референции, върши скъпа работа напразно; колектор, който пропуска stream речниците, губи length обекта и поврежда файла
Какво се случва с номерата на обектите, които освобождавате
Те стават свободни записи и не се използват повторно в същия save. Помитането обхожда списъка с обекти в низходящ ред, така че изтриванията остават индекс-стабилни, преизгражда индекса за търсене веднъж накрая вместо след всяко премахване и за всеки премахнат обект записва номера в списъка на свободните с поколението, увеличено с едно — точно както §7.5.4 изисква за запис, който по-късно може да бъде използван повторно. Поколение, вече достигнало 65535, остава там, маркирайки този номер като окончателно пенсиониран. Номерата на обектите умишлено не се компактират. След колекция файлът пази дупки: обект 12 може да е свободен, докато 13 и 14 се използват, а /Size в trailer-а продължава да отчита най-високия номер плюс едно, а не броя на оцелелите. Това е законно и нормално. Преномерирането би спестило шепа байтове в cross-reference таблицата и би изисквало пренаписване на всяка референция в документа, което е от типа промени, тихо анулиращи всичко, което държи номера на обекти извън файла. Размерът, който получавате обратно, идва от телата на обектите, не от xref таблицата
Кога не бива да пускате колектора
Никога при incremental update. Колекторът е ограден само за full save-ове и флагът просто не се чете, когато документът се допълва — и тази ограда не е ограничение, което трябва да заобикаляте. Incremental update (§7.5.6) оставя оригиналните байтове недокоснати и добавя нова cross-reference секция, верижена към предишната чрез /Prev. Всяка по-ранна ревизия все още сочи към обектите, към които винаги е сочила, така че обект, недостижим в текущата ревизия, е напълно достижим в по-стара. Изтриването му би счупило всяка ревизия освен последната, а механиката защо е разгледана в статията за incremental update и append-mode save-ове. Същата логика изключва garbage collection върху подписан документ, защото пълното пренаписване, което прави колекцията възможна, само по себе си анулира подписа
Струва си да е ясно и какво колекцията не е. Тя не е санитайзер. Колекторът премахва обекти, до които нищо не реферира; той няма мнение дали съдържанието им е било чувствително, а обект, до който все още се реферира, остава каквото си е бил. Ако целта е информацията да стане невъзстановима, а не файлът да стане по-малък, графът от обекти е грешният слой и редактиране на ниво инструкции и санитизация на документи е правилният. Двете се комбинират добре в този ред: първо редактирайте и санитизирайте, после колекционирайте, така че обектите, откачени от редактирането, действително да напуснат файла. Същото съчетание съществува в API-то за прочистване на ресурси, където подаването на опцията garbage-collect кара прочистването да пусне колекция след себе си и да отчете изоставените обекти, които е премахнало, в OrphanObjectsRemoved
Още един навик си струва да се възприеме. Логвайте връщаната стойност на GarbageCollectObjects в каквато и batch задача да прави изтриванията на страници, и я наблюдавайте през няколко седмици реални документи. Нула във файл, който току-що сте разполовили, означава, че нещо нагоре по веригата все още държи референция, каквато не сте очаквали — обикновено запис в name tree, дестинация в outline или AcroForm поле, преживяло страницата, към която е било прикачено. Колекторът е най-евтиният debugger за достижимост, който някога ще имате, защото отговаря на въпроса, който самият PDF формат отказва да отговори
Garbage collector-ът, записът с опциите за save и API-то за прочистване на ресурси, описани тук, са част от losLab PDF Library за Delphi и C++Builder, чиято продуктова страница носи пълния справочник за save pipeline, включително взаимодействието между колекцията, пакетирането на object streams и линеаризацията