Műszaki cikk

PDF szemétgyűjtés Delphiben: Mark and Sweep

Egy PDF oldal törlése nem törli a hozzá tartozó betűtípusokat, képeket vagy tartalomfolyamokat. A losLab PDF Library egy mark-sweep gyűjtővel szabadítja fel ezeket, amely előre bejárja az objektumgráfot a trailer gyökerekből kiindulva, és eltávolít minden indirekt objektumot, amelyet semmi nem ér el. Teljes mentéskor fut, alapértelmezésben ki van kapcsolva, és visszaadja az eldobott objektumok számát

Miért nem csökken a fájlméret PDF oldalak törlésekor?

Mert az oldaltörlés hivatkozás-szerkesztés, nem tárolási művelet. A DeletePages(StartPage, PageCount) leválasztja az oldalobjektumokat az oldalfáról, és kijavítja a rájuk mutató vázlatbejegyzéseket (outline entries). Amit nem tud megtenni, az annak eldöntése, hogy az adott oldalak által használt betűtípus-program, tartalomfolyam és kép XObject mostantól halott-e, mert a törlés pillanatában semmi nem jegyzi fel a fájlban, hogy ki mutathat még rájuk. Ezek az objektumok a dokumentum objektumlistájában maradnak, és egy teljes mentés mindegyiket visszaírja. Az eredmény az a panasz, amivel a legtöbb ilyen support-szál kezdődik: az ügyfél törli az oldalak kilencven százalékát, ment, és a fájl mérete két százalékkal csökken. Ami rosszabb, a szivárgás halmozódik. Betöltés, törlés, mentés, újra betöltés, újra törlés, újra mentés, és a fájl monoton nő, miközben az oldalszám csökken. Ez más probléma, mint amit a betűtípus-részhalmazolás és képlemintavétel-csökkentés old meg, amely az élő objektumokat teszi kisebbé. Itt az objektumok nem túl nagyok, egyszerűen már nem részei a dokumentumnak

A gyökérhalmaz a trailer, nem az oldalfa

A PDF objektumgráfnak nincs visszahivatkozási mezője. A formátum nem definiál referenciaszámlálót és nincs vissza-mutató lista, és a meglévő /Parent kulcsok konkrét struktúrákhoz tartoznak, mint az oldalfa, nem az objektumgráf egészéhez. Egy indirekt objektumban semmi nem mondja meg, ki mutat rá, így a "használja-e még valaki a 47-es objektumot" kérdésnek pontosan egy válasza van: bejárás előre egy ismert gyökérből, és megnézni, eljutunk-e odáig. Ezért mark-sweep gyűjtő a losLab PDF Library gyűjtője, nem referenciaszámláló séma

A gyökerek a fájl trailerjéből származnak (ISO 32000-1 §7.5.5). Három kulcs hordozza őket: /Root, a §7.7.2 dokumentumkatalógusa, amiről az oldalfa, a nevek, a vázlatok, az AcroForm és a metaadatok mind lelógnak; /Info, a dokumentum-információs szótár; és /Encrypt, a titkosítási szótár. A trailer másik két kulcsa csali. Az /ID két bájtsztringből álló tömb, a /Prev pedig egy egész bájteltolás az előző kereszthivatkozási szakaszra. Egyik sem indirekt hivatkozás, így egyik sem ad gyökeret. A losLab PDF Library a teljes trailer szótárt sorba állítja három megnevezett kulcs helyett, ami semmibe nem kerül, és életben tartja bármely privát trailer-kiterjesztést

Maga a bejárás iteratív, nem rekurzív. Amikor a bejárás egy indirekt hivatkozáshoz ér, csak az objektumszámot és a generációt jegyzi fel, megjelöli a megfelelő helyet, és egy FIFO sorba teszi ahelyett, hogy azonnal feloldaná, ami kívül tartja a mély oldalfákat és a hosszú vázlatláncokat a hívási veremtől, és megakadályozza, hogy ugyanaz az objektum kétszer legyen dekódolva. A direkt szótárak, tömbök és stream-szótárak egy második, látogatott-halmazzal védett sorba kerülnek, mert a valódi dokumentumok tartalmaznak valódi ciklusokat: egy oldal /Parent-je visszamutat az oldalfa-csomópontjára, és a vázlatelemek mindkét irányban láncolódnak a /Prev és /Next révén. A generációs számok a párosítás részei, nem díszítés. Egy hivatkozás csak akkor oldódik fel, ha az objektumszám és a generáció is megegyezik; egy más generációban létező számra mutató hivatkozás úgy kezelendő, mint a specifikáció által megkövetelt null objektum, sosem élő élként

Hogyan kapcsolható be a szemétgyűjtés mentéskor?

A szemétgyűjtés opcionális, és a mentési beállítások rekordjához tartozik. Alapértelmezésben False, mert a gyűjtő az objektumgráf pusztító áthaladása, és egyetlen könyvtárnak sem szabad csendben törölnie olyan objektumokat, amelyeket a hívó soha nem kért meg, hogy vizsgáljon meg

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;

Két másik belépési pont ér el ugyanahhoz a gyűjtőhöz. A SetGarbageCollect(1) beállítja a jelzőt a kiválasztott dokumentumon, így egy hétköznapi SaveToFile is tiszteletben tartja azt, a GarbageCollectObjects pedig azonnal lefuttatja a lépést, és visszaadja az eltávolított árva indirekt objektumok számát. Az azonnali forma az, amit akkor használj, ha egy számot akarsz naplózni vagy assert-elni, és érdemes ellenőrizni, mert egy negatív visszatérési érték nem szám

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;

Ez a hibaút fontosabb, mint amilyennek látszik. Az objektumok lustán dekódolódnak, és egy sosem dekódolt objektum egyáltalán nem mutat hivatkozásokat. Ha a gyűjtő egy dekódolhatatlan objektumot üres csomópontként kezelne, elsöpörné mindazt, ami csak rajta keresztül érhető el. Ezért a bejárás dekódolást kényszerít ki minden érintett objektumon, és egyetlen dekódolási hiba is negatív eredménnyel megszakítja az egész lépést, és a dokumentumot bájtra pontosan érintetlenül hagyja. Egy csak részben megértett gráf elsöprése az a mód, ahogy egy gyűjtő egy sérült fájlt teljesen tönkretett fájllá alakít

Mi töri el egy naiv PDF gyűjtőt?

Két részlet, és mindkettő csendben, nem hangosan hibázik. Az első az objektumfolyamok. PDF 1.5 óta egy nem-stream objektum tömörítve élhet egy /ObjStm tárolón belül (§7.5.7), és a kereszthivatkozási bejegyzése egy 2-es típusú bejegyzés, amely megnevezi a tárolót plusz egy indexet azon belül. Egy tömörített objektum ezért csak a tárolóján keresztül érhető el. Jelöld meg a tagot, seperd el a tárolót, mert semmi nem hivatkozott rá dokumentumobjektumként, és olyan fájlt írtál, amelynek xref-je egy már nem létező objektumba mutat. A tároló strukturális tárolás, nem dokumentumadat, így sosem jelenik meg élként abban az objektumgráfban, amelyet bejárunk. A losLab PDF Library ezt úgy kezeli, hogy leválaszt minden túlélő tömörített tagot a forrás tárolójáról, mielőtt a tárolók eltűnnének, majd a mentés újracsomagolja a túlélőket friss objektumfolyamokba. A második részlet az, hogy egy stream-objektum valójában mire hivatkozik. A bájtok nem részei a gráfnak. Egy tartalomfolyam, amely /F1 12 Tf paranccsal rajzol szöveget, erőforrásnév alapján nevez meg egy betűtípust, és ez a név az oldal /Resources szótárán keresztül oldódik fel, így az elérhetőségi él oldal → /Resources/Font → betűtípus-objektum útvonalon fut, sosem a stream tartalmán keresztül. Az egyetlen hivatkozás, amit egy stream ad, a szótárából származik, ahol a /Length, /Filter és /DecodeParms mind lehet indirekt. Egy gyűjtő, amely a stream bájtjait elemzi hivatkozásokat keresve, feleslegesen végez drága munkát; egy gyűjtő, amely kihagyja a stream-szótárakat, elveszíti a length-objektumot és megrongálja a fájlt

Mi történik a felszabadított objektumszámokkal

Szabad bejegyzésekké válnak, és ugyanabban a mentésben nem kerülnek újrahasznosításra. A söprés csökkenő sorrendben járja be az objektumlistát, hogy a törlések indexstabilak maradjanak, egyszer építi újra a keresési indexet a végén, minden eltávolítás után helyett, és minden eltávolított objektumnál a számát a szabad listán rögzíti eggyel megnövelt generációval, pontosan ahogy a §7.5.4 megköveteli egy olyan bejegyzéshez, amely később újra felhasználható. Egy már 65535-nél tartó generáció ott is marad, jelölve, hogy az a szám véglegesen nyugdíjazva van. Az objektumszámokat szándékosan nem tömörítik. Egy gyűjtés után a fájl megtartja a lyukakat: a 12-es objektum lehet szabad, miközben a 13-as és 14-es használatban van, és a trailer /Size-ja továbbra is a legmagasabb szám plusz egyet jelenti, nem a túlélő számot. Ez jogszerű és normális. Az újraszámozás néhány bájtot spórolna meg a kereszthivatkozási táblában, és megkövetelné a dokumentum minden hivatkozásának átírását, ami olyan fajta változás, amely csendben érvényteleníti bármit, ami kívülről tartja az objektumszámokat. A visszakapott méret az objektumtörzsekből származik, nem az xref táblából

Mikor nem szabad futtatni a gyűjtőt

Soha inkrementális frissítésen. A gyűjtő teljes mentésekhez van kötve, és a jelzőt egyszerűen nem olvassa be, amikor a dokumentumhoz éppen hozzáfűznek, és ez a kapu nem egy korlátozás, amit meg kellene kerülni. Egy inkrementális frissítés (§7.5.6) érintetlenül hagyja az eredeti bájtokat, és egy új kereszthivatkozási szakaszt fűz hozzá, amely a /Prev révén az előzőhöz láncolódik. Minden korábbi revízió továbbra is azokra az objektumokra mutat, amikre mindig is mutatott, így egy objektum, amely a jelenlegi revízióban elérhetetlen, egy régebbiben nagyon is elérhető. Törlése minden revíziót tönkretenne az utolsó kivételével, és ennek mechanikáját az inkrementális frissítésekről és append-módú mentésekről szóló cikk tárgyalja. Ugyanez az érvelés zárja ki a szemétgyűjtést egy aláírt dokumentumon, mert a teljes újraírás, amely lehetővé teszi a gyűjtést, maga is érvényteleníti az aláírást

Érdemes azt is tisztázni, mi nem a gyűjtés. Nem sanitizer. A gyűjtő eltávolítja azokat az objektumokat, amelyekre semmi nem hivatkozik; nincs véleménye arról, hogy a tartalmuk érzékeny volt-e, és egy még hivatkozott objektum megmarad annak, ami volt. Ha a cél az információ helyreállíthatatlanná tétele a fájl kisebbé tétele helyett, az objektumgráf a rossz réteg, és az utasítás szintű cenzúrázás és dokumentum-fertőtlenítés a helyes eszköz. A kettő jól illeszkedik ebben a sorrendben: előbb cenzúrázz és fertőtlenítsd, aztán gyűjts, hogy azok az objektumok, amelyeket a cenzúrázás leválasztott, ténylegesen elhagyják a fájlt. Ugyanez a párosítás megvan az erőforrás-tisztító API-ban is, ahol a szemétgyűjtő opció átadása azt eredményezi, hogy a tisztítás utána egy gyűjtést futtat, és jelenti az eltávolított árvákat az OrphanObjectsRemoved-ben

Egy utolsó szokás, amit érdemes felvenni. Naplózd a GarbageCollectObjects visszatérési értékét bármely batch munkában, amely az oldaltörléseidet végzi, és figyeld néhány hétnyi valódi dokumentumon keresztül. Egy nulla egy éppen félbevágott fájlnál azt jelenti, hogy valami feljebb az adatfolyamban még mindig egy nem várt hivatkozást tart, általában egy névfa-bejegyzés, egy vázlat-célpont vagy egy AcroForm mező, amely túlélte azt az oldalt, amelyhez csatolva volt. A gyűjtő a legolcsóbb elérhetőségi debugger, amit valaha kapsz, mert megválaszolja azt a kérdést, amit maga a PDF formátum megtagad megválaszolni

Az itt leírt szemétgyűjtő, a mentési beállítások rekord és az erőforrás-tisztító API a losLab PDF Library része Delphihez és C++Builderhez, amelynek termékoldala tartalmazza a teljes mentési folyamat referenciáját, beleértve a gyűjtés, az objektumfolyam-csomagolás és a linearizáció közötti kölcsönhatást