Technický článek

Zmenšování PDF v Delphi: sirotci a deduplikace zdrojů

PDFium Component for Delphi (PDFiumPas) zmenšuje soubory PDF třemi přepisovacími API na TPdf: SaveAsReachable odhodí objekty, na které nic neodkazuje, SaveAsDeduplicated sloučí bajtově identické fonty, obrázky a Form XObjects a SaveAsFlateRecompressed znovu zakóduje stávající Flate streamy na maximální kompresi. Každé vrátí záznam reportu, takže vidíte, co se odstranilo a kolik bajtů to ušetřilo

Obvyklou spouštěčem je support ticket hlásící „smazali jsme dvacet stránek a soubor zvětšil“. Někdo otevřel smlouvu, odstranil přílohu, přidal titulní list a uložil. Každý krok byl inkrementální aktualizace, takže staré objekty jsou pořád v souboru a když se ten dokument později načte a zapíše FPDF_SaveAsCopy, vylezou zase ven. PDFium serializuje cokoli, co sedí v jeho objektové tabulce v paměti; nemá žádné veřejné volání na výpis nepřímých objektů, smazání některého nebo přepis odkazů. Všechno v tomhle článku proto běží ve vrstvě obálky PDFiumPas nad bajty, které PDFium vyprodukuje

Proč FPDF_SaveAsCopy drží smazané stránky v souboru?

FPDF_SaveAsCopy drží sirotky, protože PDFium načte každou živou položku tabulky křížových odkazů do svého dokumentu, ať už na ni cokoli ještě ukazuje, nebo ne. ISO 32000-1 §7.5.6 popisuje inkrementální aktualizace jako přidávání nových verzí objektů a nové sekce xref na konec souboru. Odebrání stránky upraví jen strom stránek; slovník stránky, jeho content stream a jakékoli fonty či obrázky používané jen tou stránkou jsou pořád vedeny jako používané objekty. Kompletní neinkrementální uložení to nezmění, protože z pohledu objektové tabulky jsou ty objekty dokonale platné

Tatáž pozůstatky se objeví po slučování stránek, zploštění formulářů jinými nástroji a opravných uloženích poškozených souborů. Ve všech prohlížečích jsou neviditelné a nepřidávají nic kromě bajtů. U skenovaných dokumentů může jediný sirotčí obrázek stránky mít několik megabajtů

Mark-and-sweep přes SaveAsReachable

TPdf.SaveAsReachable odstraňuje sirotky klasickým průchodem mark-and-sweep: nechá PDFium zapsat neinkrementální kopii aktuálního dokumentu, parsuje tu kopii do objektového grafu, označí všechno dosažitelné z kořenů a zapíše jedinou klasickou revizi obsahující jen označené objekty. Objekty uvnitř object streamů se cestou materializují jako obyčejné nepřímé objekty, takže výstup vždy používá holou tabulku xref

Výběr kořenů je místo, kde se naivní implementace splete. Zjevné zrno je Katalog jmenovaný přívěskem /Root. Odtud procházka dosáhne stromu stránek, zdrojů stránek, fontů, obrázků, Form XObjects, AcroForm, stromu struktur, záložek, stromů názvů, volitelného obsahu, XMP metadat, výstupních záměrů a vložených souborů. Informační slovník dokumentu ale odkazuje přívěsková položka /Info (ISO 32000-1 §7.5.5), ne Katalog. Saďte jen z /Root a potichu smažete Title, Author a Producer každého souboru, který zpracujete. PDFiumPas sází procházku oběma, /Root i /Info, a přestavěný přívěsek nese /Info dál jen tehdy, když ten objekt doopravdy přežil

Procházející kód je hloubkový se zjevným zásobníkem a sadou navštívených, takže odkazové cykly (rodičovské odkazy ve stromu stránek, zpětné ukazatele anotací /P) ho nemohou zavěsit. Odkaz na číslo objektu, které už je volné, se prostě nesleduje místo brát ho za chybu, což drží průchod shovívavý k mírně poškozenému vstupu

Čísla objektů se zachovávají, nepřečíslovávají

TPdf.SaveAsReachable drží každý přeživší objekt na jeho původním čísle a generaci. Zametené objekty se zapisují jako volné položky v tabulce xref (ISO 32000-1 §7.5.4), mezery mezi živými čísly se vyplní volnými položkami a objekt 0 zůstává hlavou volného seznamu. Protože se nic nepřečíslovává, je každý odkaz, který přežije, pořád správný bez jediného přepsaného bajtu a neexistuje mapování staré-na-nové, ve kterém by se dalo splést. Cena je pár nepoužitých řádků xref, po 20 bajtech, což je obchod, který stojí za to

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport nese SourceByteCount (velikost neinkrementální kopie, kterou vyprodukoval PDFium), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded a ErrorMessage. Přetížení s názvem souboru zapisuje do dočasného souboru vedle cíle a cílem nahradí jen tehdy, když přepis uspěl, takže neúspěšný běh nikdy nezanechá useknuté PDF

Které soubory tahle API odmítnou?

Všechna tři přepisovací API odmítají zašifrované dokumenty a defaultně i podepsané. Zašifrovaný vstup selže s Succeeded = False a chybovou zprávou místo výjimky: přepis do jediné revize nedokáže držet čísla objektů, bajtové rozsahy a klíče na objekt, na kterých závisí šifrovací slovník, takže výstup by byl nečitelný. Dokument s digitálními podpisy vyhodí EPdfError, pokud nepředáte AllowSignedDocument = True, protože kompletní přepis vždycky zneplatní podepsaný bajtový rozsah. Nastavit ten příznak znamená přijmout soubor, jehož podpisy se nahlásí jako rozbité; pro archivní kopii je to správné rozhodnutí, nikdy pro smlouvu, kterou se chystáte poslat zpátky

Jak SaveAsDeduplicated rozhoduje, že dva obrázky jsou týž?

TPdf.SaveAsDeduplicated bere dva zdroje za identické jen tehdy, když se shodují jejich slovníky, celé jejich dosažitelné podgrafy i syrové bajty jejich streamů. Nejdřív pustí tutéž procházku dosažitelnosti, pak udělá fingerprint každému dosažitelnému fontu, image XObject a Form XObject, sloučí každou skupinu shod na člena s nejnižším číslem objektu a přepíše každý odkaz, aby ukazoval na toho přeživšího. Sloučené duplicity se stanou volnými položkami xref stejně jako sirotci. Tohle je oprava klasického reportu „logo vložené 400krát“ od nástrojů hromadné korespondence, které vkládají tytéž zdroje na každé stránce znovu

Fingerprint je 64bitový hash FNV-1a počítaný rekurzivně. Pro každého kandidáta vezme procházející kód tělo objektu, vymění každý odkaz N G R za fingerprint objektu, na který ukazuje, a zahashuje výsledek. To dělá hash nezávislým na číslování objektů: dvě kopie téhož fontu, jejichž deskriptory jsou náhodou objekty 12 a 87, i tak vyprodukují tutéž hodnotu. Výsledky se memoizují, takže sdílené podobjekty se hashují jednou a navštěvovací sada vrací pro zpětné hrany pevný sentinel, aby cyklické grafy skončily

Jemná chyba sedí v payloadu streamu. Aby se odkazy našly bezpečně, skenuje PDFiumPas kopii každého objektu, ve které jsou streamová data, řetězce a komentáře vybělené mezerami; jinak by se běh binárních pixelových dat, který náhodou vypadá jako 12 0 R, bral za odkaz. Zahashujete-li jen tu očistenou kopii, sberou se dva obrázky s identickými slovníky (stejné /Width, /Height, /BitsPerComponent, /Filter, /Length), ale různými pixely do jednoho fingerprintu a druhá fotka na stránce se potichu vystřídá první. SaveAsDeduplicated přidává syrové bajty mezi stream a endstream k hashovaným datům, takže pixely jsou součástí identity. Kandidáti se před srovnáním taky seskupují podle kategorie, takže vzácná kolize hashe mezi fontem a obrázkem je nikdy nemůže sloučit

Přepis odkazů používá týž trik očisteného skenu obráceně: odkazy se najdou ve vybělené kopii, ale bajty se kopírují z původního těla, takže se mění jen struktura slovníků a polí a binární payloady projdou netknuté

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

TPdfDedupReport čtěte pozorně. Vedle tří polí Merged*Count má KeptObjectCount a PrunedObjectCount a v tomto reportu počítá PrunedObjectCount jen sloučené duplicity. Nedosažitelné objekty se pořád odhazují, ale do toho čísla se nezapočítávají. Potřebujete-li počet sirotků pro logování, vezměte ho ze samostatného běhu SaveAsReachable. Druhá mez je, že párování je bajtově přesné na zakódovaném streamu: týž logo komprimované dvěma různými producenty na dvou různých úrovních Flate se nespojí

Rekompresce Flate streamů na maximální úrovni

TPdf.SaveAsFlateRecompressed nafoukne každý dosažitelný stream FlateDecode a znovu ho vyfoukne na nejvyšší úrovni komprese, přičemž výsledek drží jen tehdy, když je menší a projde vašimi prahy. Spousta producentů zapisuje Flate na rychlé výchozí úrovni, takže content streamy a vložené fontové programy se často zmenší dál. Čísla objektů a generace zůstávají; mění se jen payload a jeho přímé /Length

TPdfFlateRecompressOptions má dvě pole a žádný výchozí konstruktor, takže nastavte obojí. MinSavingBytes je minimální absolutní úspora na stream a MinSavingRatio je zlomek původní délky (0.05 znamená 5 %); poměr 0 test poměru vypíná. Přestavěný stream se zapisuje s stream, CRLF, novým payloadem, CRLF a endstream, takže výsledek nezávisí na koncích řádků původního souboru

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignoruj drobné výhry
  Options.MinSavingRatio := 0.05;   // a cokoli pod 5 %
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

Okrajové případy jsou konzervativní záměrně. Stream, jehož /Length je nepřímý odkaz, se přeskočí, protože přepis musí uložit přímé celé číslo. Payload, který se nedaří nafouknout, počítá se do FailedCount a kopíruje se beze změny. Flate stream, který prahy nesplní, počítá se do SkippedCount. Obrázky DCT, JPX, JBIG2 a CCITT nikdy nevstupují do seznamu kandidátů, takže tenhle průchod se kvality JPEG nikdy nedotkne. Pro ztrátové úspory na obrázcích použijte adaptivní převzorkování obrázků v PDFiumPas před finálním uložením

Jedna přenositelná past z psaní toho prahového testu: první návrh srovnával Single(Saving)/Single(OrigLen) s Saving deklarovaným jako Int64. Delphi to přijme, ale FPC 3.2.2 se zastaví s Illegal type conversion: "Int64" to "Single", protože ve FPC je ta syntaxe typecast, ne numerická konverze. Holé Saving / OrigLen je v obou kompilátorech skutečné dělení a srovnává se přímo proti Single, takže ta přetypování byla prostě zbytečná

V jakém pořadí mají ta tři API běžet?

Pusťte nejdřív SaveAsDeduplicated, načtěte výstup znovu a pak pusťte SaveAsFlateRecompressed. Deduplikace už zahrnuje zametání dosažitelnosti, takže samostatný průchod SaveAsReachable před ní je nadbytečný; SaveAsReachable nechávejte na zakázky, kde chcete jen úklid sirotků a přesný počet odstraněných. Rekompresovat druhý znamená, že drahá práce nafoukni-a-vyfoukni běží jednou na přeživší stream místo jednou na duplicitu. Průchod rekompresí taky znovu projde graf z /Root a /Info, což uklidí i deskriptory fontů nebo fontové soubory, na které už ukazoval jen sloučený duplicitní font

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // zamet + sloučit

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // znovu vyfoukni přeživší
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Aplikujete-li i redakce (redactions), udělejte je před čímkoli z tohohle, protože fyzické odstranění obsahu je to, co sirotky vůbec vytváří; článek o redakci na úrovni operátorů a prořezávání zdrojů pokrývá tu redakční stranu. A finální soubor vždy znovu načtěte jako kontrolu zdravého rozumu ve své pipeline: počet stránek odpovídající zdroji je levné pojištění, než přepíšete cokoli, na čem zákazníkovi záleží

FAQ

Přečíslovává SaveAsReachable objekty nebo mění odkazy?

Ne. TPdf.SaveAsReachable drží každý přeživší objekt na jeho původním čísle a generaci a označuje odebrané objekty jako volné položky xref, takže žádný odkaz se nemusí přepisovat

Může SaveAsDeduplicated omylem sloučit dva různé obrázky?

Ne jen přes shodné slovníky. Fingerprint zahrnuje syrové bajty streamu a celý odkazovaný podgraf a kandidáti se srovnávají jen v rámci téže kategorie (font, obrázek, Form XObject)

Drží ta API mé digitální podpisy platné?

Ne. Každé API provádí kompletní přepis, který rozbije podepsaný bajtový rozsah, takže podepsané soubory se odmítají, pokud nepředáte AllowSignedDocument = True a nepřijmete neplatné podpisy ve výstupu

Všechna tři API dodává PDFium Component for Delphi and C++Builder spolu s funkcemi redakce a převzorkování obrázků pokrytými jinde na tomhle blogu