Teknisk artikel

Mindre PDF'er i Delphi: orphan-objekter og ressource-dedup

PDFium Component til Delphi (PDFiumPas) gør PDF-filer mindre med tre omskrivnings-API'er på TPdf: SaveAsReachable dropper objekter, intet refererer til, SaveAsDeduplicated fusionerer byte-identiske fonte, billeder og Form XObjects, og SaveAsFlateRecompressed re-encoder eksisterende Flate-streams ved maksimal komprimering. Hver returnerer en rapport-record, så man kan se, hvad der blev fjernet, og hvor mange bytes den sparede

Den sædvanlige udløser er en support-billet, der lyder "vi slettede tyve sider, og filen blev større". Nogen åbnede en kontrakt, fjernede et tillæg, fusionerede et forsideark ind og gemte. Hvert trin var en inkrementel opdatering, så de gamle objekter er stadig i filen, og når dokumentet senere indlæses og skrives med FPDF_SaveAsCopy, kommer de bare med igen. PDFium serialiserer, hvad end der sidder i sin in-memory objekt-tabel; den har intet offentligt kald til at liste indirekte objekter, slette ét eller omskrive referencer. Alt i denne artikel kører derfor i PDFiumPas wrapper-laget oven på de bytes, PDFium frembringer

Hvorfor beholder FPDF_SaveAsCopy slettede sider i filen?

FPDF_SaveAsCopy beholder orphan-objekter, fordi PDFium indlæser hver levende post i cross-reference-tabellen ind i sit dokument, uanset om noget stadig peger på den. ISO 32000-1 §7.5.6 beskriver inkrementelle opdateringer som at tilføje nye objektversioner og en ny xref-sektion til slutningen af filen. At fjerne en side redigerer kun sidetræet; side-dictionaryen, dens content stream og enhver font eller et billede, der kun bruges af den side, er stadig listet som in-use-objekter. En fuld non-inkrementel gem ændrer ikke det, for set fra objekt-tabellens synspunkt er de objekter perfekt gyldige

Samme rester viser sig efter side-fusioner, formular-flattening udført af andre værktøjer og reparationsgem af beskadigede filer. De er usynlige i enhver viser og tilføjer intet andet end bytes. På scannede dokumenter kan et enkelt forældreløst sidebillede være flere megabytes

Mark-and-sweep med SaveAsReachable

TPdf.SaveAsReachable fjerner orphans med et klassisk mark-and-sweep-gennemløb: den lader PDFium skrive en non-inkrementel kopi af det aktuelle dokument, parser kopien ind i en objektgraf, markerer alt, der kan nås fra rødderne, og skriver én enkelt klassisk revision, der kun indeholder de markerede objekter. Objekter inde i object streams materialiseres som almindelige indirekte objekter undervejs, så outputtet altid bruger en almindelig xref-tabel

At vælge rødderne er, hvor en naiv implementering tager fejl. Det indlysende seed er Catalog'en navngivet af trailer-/Root. Derfra når gennemløbet sidetræet, side-ressourcer, fonte, billeder, Form XObjects, AcroForm, strukturtræet, outlines, navnetræer, optional content, XMP-metadata, output intents og indlejrede filer. Men dokument-information-dictionaryen refereres af trailer-/Info-posten (ISO 32000-1 §7.5.5), ikke af Catalog'en. Seed kun fra /Root, og man stille sletter Title, Author og Producer i hver fil, man behandler. PDFiumPas seeder gennemløbet med både /Root og /Info, og den genbyggede trailer bærer kun /Info videre, når objektet faktisk overlevede

Walkeren er en depth-first traversal med en eksplicit stack og et visited-sæt, så referenscykler (parent-links i sidetræet, annotation /P tilbage-pointere) kan ikke hænge den op. En reference til et objektnummer, der allerede er frit, følges simpelthen ikke i stedet for at behandles som en fejl, hvilket holder gennemløbet tolerant over for let beskadiget input

Objektnumre bevares, ikke omnummereres

TPdf.SaveAsReachable beholder hvert overlevende objekt på sit oprindelige objektnummer og generation. Fejede objekter skrives som frie poster i xref-tabellen (ISO 32000-1 §7.5.4), hullerne mellem levende numre fyldes med frie poster, og objekt 0 forbliver hovedet på free-listen. Fordi intet omnummereres, er hver reference, der overlever, stadig korrekt uden en eneste omskrevet byte, og der er ingen gammel-til-ny mapping at tage fejl af. Omkostningen er et par ubrugte xref-linjer, 20 bytes hver, hvilket er en handel værd at lave

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 bærer SourceByteCount (størrelsen af den non-inkrementelle kopi, PDFium frembragte), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded og ErrorMessage. Filnavn-overloaden skriver til en midlertidig fil ved siden af målet og erstatter kun målet, når omskrivningen lykkedes, så en fejlet kørsel aldrig efterlader en afkortet PDF

Hvilke filer nægter disse API'er?

Alle tre omskrivnings-API'er nægter krypterede dokumenter og som standard signerede. Krypteret input fejler med Succeeded = False og en fejlbesked i stedet for en exception: en enkelt-revision-omskrivning kan ikke fastholde objektnumrene, byte-områderne og per-objekt-nøglerne, som krypterings-dictionaryen afhænger af, så outputtet ville være ulæseligt. Et dokument med digitale signaturer rejser EPdfError, medmindre man giver AllowSignedDocument = True med, for en fuld omskrivning gør altid det signerede byte-område ugyldigt. At sætte det flag betyder, at man accepterer en fil, hvis signaturer vil rapporteres som brudte; det er det rigtige valg til en arkivkopi, aldrig til en kontrakt, man er ved at sende tilbage

Hvordan afgør SaveAsDeduplicated, at to billeder er ens?

TPdf.SaveAsDeduplicated behandler to ressourcer som identiske kun, når deres dictionaries, deres hele tilgængelige subgrafer og deres rå stream-bytes alle matcher. Den kører samme reachability-gennemløb først, fingerprinter derefter hver tilgængelig font, image XObject og Form XObject, fusionerer hver gruppe af matches ned på medlemmet med det laveste objektnummer og omskriver hver reference til at pege på den overlevende. Fusionerede duplikater bliver frie xref-poster ligesom orphans. Det er fixet på den klassiske "logo indlejret 400 gange"-rapport frembragt af mail-merge-værktøjer, der gen-indlejrer samme ressourcer på hver side

Fingerprintet er en 64-bit FNV-1a-hash beregnet rekursivt. For hver kandidat tager walkeren objekt-kroppen, erstatter hver N G R-reference med fingerprintet af objektet, den peger på, og hasher resultatet. Det gør hashen uafhængig af objektnummerering: to kopier af samme font, hvis deskriptorer tilfældigt er objekter 12 og 87, frembringer stadig samme værdi. Resultater memoiseres, så delte subobjekter hashes én gang, og et visiting-sæt returnerer et fast sentinel for tilbage-kanter, så cykliske grafer terminerer

Den subtile bug sidder i stream-payloaden. For sikkert at finde referencer scanner PDFiumPas en kopi af hvert objekt, i hvilken stream-data, strenge og kommentarer er blankeret ud med mellemrum; ellers ville et løb af binære pixeldata, der tilfældigt ligner 12 0 R, blive behandlet som en reference. Hasher man kun den saniterede kopi, kollapser to billeder med identiske dictionaries (samme /Width, /Height, /BitsPerComponent, /Filter, /Length) men forskellige pixels til ét fingerprint, og det andet foto på siden erstattes stille af det første. SaveAsDeduplicated tilføjer de rå bytes mellem stream og endstream til de hashede data, så pixelsne er en del af identiteten. Kandidater grupperes også efter kategori før sammenligning, så en sjælden hash-kollision mellem en font og et billede aldrig kan fusionere dem

Reference-omskrivning genbruger samme saniteret-scan-trick i omvendt retning: referencer lokaliseres i den blankerede kopi, men bytes kopieres fra den oprindelige krop, så kun dictionary- og array-struktur nogensinde modificeres, og binære payloads passerer igennem urørt

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;

Læs TPdfDedupReport omhyggeligt. Ved siden af de tre Merged*Count-felter har den KeptObjectCount og PrunedObjectCount, og i denne rapport tæller PrunedObjectCount kun fusionerede duplikater. Utilgængelige objekter droppes stadig, men de tælles ikke med i det tal. Behøver man orphan-antallet til logning, så tag det fra en separat SaveAsReachable-kørsel. Den anden grænse er, at matching er byte-eksakt på den kodede stream: samme logo komprimeret af to forskellige producere ved to forskellige Flate-niveauer bliver ikke fusioneret

Rekomprimering af Flate-streams på maksimalt niveau

TPdf.SaveAsFlateRecompressed inflater hver tilgængelig FlateDecode-stream og deflater den igen på det højeste komprimeringsniveau og beholder resultatet kun, når det er mindre og klarer ens tærskler. Mange producere skriver Flate på et hurtigt standardniveau, så content streams og indlejrede fontprogrammer skrumper ofte yderligere. Objektnumre og generationer bliver ved deres plads; kun payloaden og dens direkte /Length ændres

TPdfFlateRecompressOptions har to felter og ingen standardkonstruktør, så sæt begge. MinSavingBytes er minimum absolut besparelse pr. stream, og MinSavingRatio er en brøkdel af den oprindelige længde (0,05 betyder 5%); en ratio på 0 deaktiverer ratio-testen. Den genbyggede stream skrives med stream, CRLF, den nye payload, CRLF og endstream, så resultatet ikke afhænger af originalfilens linjeender

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignorér små gevinster
  Options.MinSavingRatio := 0.05;   // og alt under 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;

Edge cases er konservative af design. En stream, hvis /Length er en indirekte reference, springes over, for omskrivningen skal gemme et direkte heltal. En payload, der ikke kan inflateres, tæller med i FailedCount og kopieres uændret. En Flate-stream, der ikke opfylder tærsklerne, tæller med i SkippedCount. DCT-, JPX-, JBIG2- og CCITT-billeder kommer aldrig på kandidatlisten, så dette gennemløb rører aldrig JPEG-kvalitet. Til lossy billedbesparelser brug adaptiv image resampling i PDFiumPas før den endelige gem

Én portabilitetsfælde fra at skrive den tærskel-test: det første udkast sammenlignede Single(Saving)/Single(OrigLen) med Saving erklæret som Int64. Delphi accepterer det, men FPC 3.2.2 stopper med Illegal type conversion: "Int64" to "Single", for i FPC er den syntaks et typecast snarere end en numerisk konvertering. Almindelig Saving / OrigLen er ægte division i begge kompilatorer og sammenlignes direkte med en Single, så castene var simpelthen unødvendige

I hvilken rækkefølge bør de tre API'er køre?

Kør SaveAsDeduplicated først, genindlæs outputtet, og kør så SaveAsFlateRecompressed. Deduplicering inkluderer allerede reachability-sweepet, så et separat SaveAsReachable-gennemløb foran det er redundant; behold SaveAsReachable til jobs, hvor man kun vil have orphan-oprydning og et præcist fejet antal. At rekomprimere som det andet betyder, at det dyre inflate-and-deflate-arbejde kører én gang pr. overlevende stream i stedet for én gang pr. duplikat. Rekomprimeringsgennemløbet går også grafen fra /Root og /Info igen, hvilket rydder op i font-descriptors eller fontfiler, som kun en fusioneret duplikatfont stadig pegede på

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);   // prune + merge

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // re-deflater de overlevende
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Anvender man også redactions, så gør det før alt dette, for fysisk at fjerne indhold er det, der skaber nye orphans i første omgang; artiklen om operator-niveau redaction og ressource-pruning dækker redaction-siden. Og genindlæs altid den endelige fil som en sanity check i ens pipeline: et sideantal, der matcher kilden, er billig forsikring, før man overskriver noget, en kunde bryder sig om

FAQ

Omnummererer SaveAsReachable objekter eller ændrer referencer?

Nej. TPdf.SaveAsReachable beholder hvert overlevende objekt på sit oprindelige nummer og generation og markerer fjernede objekter som frie xref-poster, så ingen reference behøver omskrives

Kan SaveAsDeduplicated fusionere to forskellige billeder ved en fejl?

Ikke gennem matchende dictionaries alene. Fingerprintet inkluderer de rå stream-bytes og den fulde refererede subgraf, og kandidater sammenlignes kun inden for samme kategori (font, billede, Form XObject)

Holder disse API'er mine digitale signaturer gyldige?

Nej. Hvert API udfører en fuld omskrivning, som bryder det signerede byte-område, så signerede filer afvises, medmindre man giver AllowSignedDocument = True med og accepterer ugyldige signaturer i outputtet

Alle tre API'er følger med PDFium Component til Delphi og C++Builder sammen med redaction- og image resampling-funktionerne dækket andre steder på denne blog