Technisch artikel

PDF Garbage Collection in Delphi: Mark and Sweep

Het verwijderen van een pagina uit een PDF verwijdert niet de bijbehorende lettertypen, afbeeldingen of content streams. losLab PDF Library ruimt ze op met een mark-sweep collector die de objectgraaf voorwaarts doorloopt vanaf de trailer roots en elk indirect object verwijdert dat nergens meer door bereikt wordt. Dit gebeurt bij een volledige save, staat standaard uit, en levert het aantal verwijderde objecten terug

Waarom maakt het verwijderen van PDF-pagina's het bestand niet kleiner?

Omdat het verwijderen van een pagina een referentie-bewerking is, geen opslagbewerking. DeletePages(StartPage, PageCount) ontkoppelt paginaobjecten uit de paginaboom en herstelt de outline-items die ernaar verwezen. Wat het niet kan doen, is bepalen dat het font-programma, de content stream en de image XObject die deze pagina's gebruikten nu dood zijn, want op het moment van verwijderen registreert niets in het bestand wie er nog meer naar zou kunnen verwijzen. Die objecten blijven in de documentobjectlijst staan, en een volledige save schrijft ze allemaal terug. Het resultaat is de klacht waarmee de meeste supportgesprekken beginnen: een klant verwijdert negentig procent van de pagina's, slaat op, en het bestand krimpt met twee procent. Erger nog, de lekkage stapelt zich op. Laden, verwijderen, opslaan, opnieuw laden, opnieuw verwijderen, opnieuw opslaan, en het bestand groeit monotoon terwijl het paginaaantal daalt. Dit is een ander probleem dan wat wordt opgelost door font subsetting en image downsampling, die levende objecten kleiner maken. Hier zijn de objecten niet te groot: ze maken simpelweg geen deel meer uit van het document

De root set is de trailer, niet de paginaboom

De PDF-objectgraaf heeft geen omgekeerd-referentieveld. Het formaat definieert geen refcount en geen back-pointer lijst, en de /Parent-sleutels die wel bestaan behoren tot specifieke structuren zoals de paginaboom, niet tot de objectgraaf als geheel. Niets in een indirect object vertelt je wie ernaar verwijst, dus de vraag "gebruikt iemand object 47 nog" heeft precies één antwoord: doorloop voorwaarts vanaf een bekende root en kijk of je aankomt. Daarom is de collector in losLab PDF Library een mark-sweep collector en geen refcount-schema

De roots komen uit de file trailer (ISO 32000-1 §7.5.5). Drie sleutels dragen ze: /Root, de document catalog uit §7.7.2 waaraan de paginaboom, namen, outlines, AcroForm en metadata allemaal hangen; /Info, het document information dictionary; en /Encrypt, het encryption dictionary. De twee overige trailer-sleutels zijn afleidingsmanoeuvres. /ID is een array van twee byte-strings, en /Prev is een integer byte offset naar de vorige cross-reference-sectie. Geen van beide is een indirecte referentie, dus geen van beide levert een root op. losLab PDF Library plaatst het volledige trailer dictionary in de wachtrij in plaats van drie benoemde sleutels, wat niets kost en elke private trailer-extensie levend houdt

De doorloop zelf is iteratief in plaats van recursief. Wanneer de traversal een indirecte referentie tegenkomt, registreert deze alleen het objectnummer en de generatie, markeert het bijbehorende slot en plaatst het op een FIFO-wachtrij in plaats van meteen te dereferencen, wat diepe paginabomen en lange outline-ketens buiten de call stack houdt en voorkomt dat hetzelfde object twee keer wordt gedecodeerd. Directe dictionaries, arrays en stream dictionaries komen op een tweede wachtrij terecht die wordt bewaakt door een bezocht-set, want echte documenten bevatten daadwerkelijke cycli: een pagina's /Parent wijst terug naar zijn paginaboomknoop, en outline-items schakelen in beide richtingen via /Prev en /Next. Generatienummers maken deel uit van de match, geen versiering. Een referentie wordt alleen opgelost wanneer objectnummer en generatie allebei overeenkomen; een referentie naar een nummer dat op een andere generatie bestaat, wordt behandeld als het null-object dat de specificatie vereist, nooit als een levende edge

Hoe schakel je garbage collection in bij een save?

Garbage collection is opt-in en hoort bij de save-options record. Standaard staat het op False, omdat de collector een destructieve doorgang over de objectgraaf is en geen bibliotheek stilzwijgend objecten mag verwijderen die een aanroeper nooit gevraagd heeft te onderzoeken

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;

Twee andere entry points bereiken dezelfde collector. SetGarbageCollect(1) zet de vlag op het geselecteerde document zodat een gewone SaveToFile hem respecteert, en GarbageCollectObjects voert de pass direct uit en retourneert het aantal verwijderde verweesde indirecte objecten. De directe vorm is de vorm om te gebruiken wanneer je een getal wilt loggen of asserten, en het loont om te controleren, want een negatieve return is geen aantal

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;

Dat foutpad is belangrijker dan het lijkt. Objecten worden lui gedecodeerd, en een object dat nooit is gedecodeerd toont helemaal geen referenties. Als de collector een niet-decodeerbaar object als een lege node zou behandelen, zou het alles wegvegen dat alleen via dat object bereikbaar was. Daarom dwingt de traversal decodering af terwijl het elk object aanraakt, en een enkele decodeerfout breekt de hele pass af met een negatief resultaat en laat het document byte-identiek achter. Vegen door een graaf die je maar gedeeltelijk begrijpt, is hoe een collector een beschadigd bestand omzet in een vernietigd bestand

Wat breekt een naïeve PDF-collector?

Twee details, en beide falen stilletjes in plaats van luidruchtig. Het eerste is object streams. Sinds PDF 1.5 kan een non-stream object gecomprimeerd leven binnen een /ObjStm-container (§7.5.7), en de cross-reference-entry ervan is een type-2-entry die de container plus een index erbinnen aanwijst. Een gecomprimeerd object is dus alleen bereikbaar via zijn container. Markeer het lid, veeg de container weg omdat niets ernaar verwees als documentobject, en je hebt een bestand geschreven waarvan de xref naar een object wijst dat niet meer bestaat. De container is structurele opslag, geen documentdata, dus verschijnt hij nooit als edge in de objectgraaf die je doorloopt. losLab PDF Library lost dit op door elk overlevend gecomprimeerd lid van zijn broncontainer los te koppelen voordat de containers verdwijnen, waarna de save de overlevenden herverpakt in nieuwe object streams. Het tweede detail is wat een stream-object daadwerkelijk refereert. De bytes maken geen deel uit van de graaf. Een content stream die tekst tekent met /F1 12 Tf noemt een font bij resource-naam, en die naam wordt opgelost via het pagina-/Resources-dictionary, dus de bereikbaarheids-edge loopt pagina → /Resources/Font → font-object, nooit via de stream-payload. De enige referenties die een stream bijdraagt komen uit zijn dictionary, waar /Length, /Filter en /DecodeParms allemaal indirect mogen zijn. Een collector die stream-bytes parseert op zoek naar referenties, doet kostbaar werk voor niets; een collector die stream-dictionaries overslaat verliest het length-object en corrumpeert het bestand

Wat gebeurt er met de objectnummers die je vrijgeeft

Ze worden vrije entries, en worden niet hergebruikt binnen dezelfde save. De sweep doorloopt de objectlijst in aflopende volgorde zodat verwijderingen index-stabiel blijven, herbouwt de lookup-index één keer aan het einde in plaats van na elke verwijdering, en registreert voor elk verwijderd object het nummer in de free list met de generatie met één verhoogd, precies zoals §7.5.4 voorschrijft voor een entry die later hergebruikt kan worden. Een generatie die al op 65535 staat, blijft daar staan, wat dat nummer permanent buiten gebruik markeert. Objectnummers worden bewust niet gecomprimeerd. Na een collectie behoudt het bestand gaten: object 12 kan vrij zijn terwijl 13 en 14 in gebruik zijn, en de trailer /Size rapporteert nog steeds het hoogste nummer plus één in plaats van het aantal overlevende objecten. Dat is legaal en normaal. Hernummeren zou een handvol bytes besparen in de cross-reference-tabel en zou vereisen dat elke referentie in het document herschreven wordt, wat het soort verandering is dat stilzwijgend alles ongeldig maakt dat objectnummers van buitenaf vasthoudt. De grootte die je terugkrijgt komt van de objectinhoud, niet van de xref-tabel

Wanneer je de collector niet mag draaien

Nooit bij een incrementele update. De collector is beperkt tot volledige saves en de vlag wordt simpelweg niet gelezen wanneer aan het document wordt toegevoegd, en die beperking is geen limitatie om omheen te werken. Een incrementele update (§7.5.6) laat de originele bytes onaangeroerd en voegt een nieuwe cross-reference-sectie toe die via /Prev aan de vorige is gekoppeld. Elke eerdere revisie wijst nog steeds naar de objecten waar hij altijd naar wees, dus een object dat onbereikbaar is in de huidige revisie is heel goed bereikbaar in een oudere. Het verwijderen ervan zou elke revisie behalve de laatste breken, en de mechaniek daarachter wordt behandeld in het artikel over incrementele updates en append-mode saves. Dezelfde redenering sluit garbage collection uit op een ondertekend document, omdat de volledige herschrijving die collectie mogelijk maakt zelf de handtekening ongeldig maakt

Het is ook goed om duidelijk te zijn over wat collectie niet is. Het is geen sanitizer. De collector verwijdert objecten waar niets naar verwijst; hij heeft geen mening over of de inhoud ervan gevoelig was, en een object dat nog steeds gerefereerd wordt blijft wat het was. Als het doel is informatie onherstelbaar te maken in plaats van het bestand kleiner te maken, is de objectgraaf de verkeerde laag en is redactie op instructieniveau en document-sanitizing de juiste. De twee combineren goed in die volgorde: eerst redigeren en saniteren, dan collecteren, zodat de objecten die redactie losmaakte daadwerkelijk het bestand verlaten. Dezelfde koppeling bestaat in de resource-purge-API, waar het meegeven van de garbage-collect-optie ervoor zorgt dat de purge daarna een collectie uitvoert en de verwijderde wezen rapporteert in OrphanObjectsRemoved

Nog één gewoonte die het waard is om aan te nemen. Log de returnwaarde van GarbageCollectObjects in welke batchjob dan ook die je paginaverwijderingen doet, en houd het een paar weken lang in de gaten bij echte documenten. Een nul bij een bestand dat je net gehalveerd hebt, betekent dat iets stroomopwaarts nog steeds een referentie vasthoudt die je niet verwachtte, meestal een name-tree-entry, een outline-destination of een AcroForm-veld dat de pagina overleefde waaraan het bevestigd was. De collector is de goedkoopste bereikbaarheids-debugger die je ooit zult hebben, want hij beantwoordt de vraag die het PDF-formaat zelf weigert te beantwoorden

De garbage collector, de save-options record en de resource-purge-API die hier beschreven zijn, maken deel uit van losLab PDF Library voor Delphi en C++Builder, waarvan de productpagina de volledige save-pipeline-referentie bevat, inclusief de interactie tussen collectie, object-stream-packing en linearisatie