Teknisk artikel

Krympa PDF:er i Delphi: överblivna objekt och resursdedup

PDFium Component för Delphi (PDFiumPas) krymper PDF-filer med tre omskrivnings-API:er på TPdf: SaveAsReachable slänger objekt ingenting refererar, SaveAsDeduplicated slår ihop byte-identiska typsnitt, bilder och Form XObjects, och SaveAsFlateRecompressed kodar om befintliga Flate-strömmar med maximal komprimering. Var och en returnerar en rapportpost så du kan se vad som togs bort och hur många bytes den sparade

Den vanliga utlösaren är ett supportärende som lyder "vi raderade tjugo sidor och filen blev större". Någon öppnade ett avtal, tog bort en bilaga, slog ihop in ett försättsblad och sparade. Varje steg var en inkrementell uppdatering, så de gamla objekten finns fortfarande i filen, och när det dokumentet senare laddas och skrivs med FPDF_SaveAsCopy kommer de rakt tillbaka ut. PDFium serialiserar vad som helst som sitter i sin objekttabell i minnet; den har inget publikt anrop för att lista indirekta objekt, radera ett eller skriva om referenser. Allt i den här artikeln körs därför i PDFiumPas-omslagsskiktet ovanpå de bytes PDFium producerar

Varför behåller FPDF_SaveAsCopy raderade sidor i filen?

FPDF_SaveAsCopy behåller överblivna objekt eftersom PDFium laddar varje levande post i xref-tabellen in i sitt dokument, oavsett om något fortfarande pekar på den. ISO 32000-1 §7.5.6 beskriver inkrementella uppdateringar som att bifoga nya objektversioner och en ny xref-sektion på filens slut. Att ta bort en sida redigerar bara sidträdet; sidordlistan, dess content stream och de typsnitt eller bilder som bara den sidan använder listas fortfarande som använda objekt. En fullständig icke-inkrementell sparning ändrar inte på det, för ur objekttabellens synvinkel är de objekten helt giltiga

Samma rester dyker upp efter sidhopslagningar, formulärplattning gjord av andra verktyg och reparationsningar av skadade filer. De är osynliga i varje visare och tillför ingenting utom bytes. På inskannade dokument kan en enda överbliven sidbild vara flera megabytes

Mark-and-sweep med SaveAsReachable

TPdf.SaveAsReachable tar bort överblivna objekt med ett klassiskt mark-and-sweep-pass: den låter PDFium skriva en icke-inkrementell kopia av aktuellt dokument, parsar den kopian till en objektgraf, märker allt nåbart från rötterna och skriver en enda klassisk revision som innehåller bara de märkta objekten. Objekt inuti objektströmmar materialiseras som vanliga indirekta objekt på vägen, så utdatan använder alltid en enkel xref-tabell

Att plocka rötterna är där en naiv implementation går fel. Det uppenbara utsädet är katalogen som trailern /Root namnger. Därifrån når vandringen sidträdet, sidresurser, typsnitt, bilder, Form XObjects, AcroForm, strukturträdet, dispositionslistorna, namnträden, optional content, XMP-metadata, output intents och inbäddade filer. Men dokumentinformationsordlistan refereras av trailerposten /Info (ISO 32000-1 §7.5.5), inte av katalogen. Säddar du bara från /Root raderar du tyst Titel, Författare och Producent ur varje fil du bearbetar. PDFiumPas såddar vandringen med både /Root och /Info, och den ombyggda trailern bär bara /Info framåt när det objektet faktiskt överlevde

Vandraren är en djupförst-traversering med en explicit stack och en besökt-mängd, så referenscykler (föräldralänkar i sidträdet, anteckningens /P-bakpekare) kan inte låsa den. En referens till ett objektnummer som redan är fritt följs helt enkelt inte i stället för att behandlas som ett fel, vilket håller passet tolerant mot lätt skadad indata

Objektnummer bevaras, inte omnumreras

TPdf.SaveAsReachable behåller varje överlevande objekt på sitt ursprungliga objektnummer och generation. Svepta objekt skrivs som fria poster i xref-tabellen (ISO 32000-1 §7.5.4), gapen mellan levande nummer fylls med fria poster, och objekt 0 förblir huvudet på den fria listan. Eftersom ingenting omnumreras är varje referens som överlever fortfarande korrekt utan en enda omskriven byte, och det finns ingen gammal-till-ny-mappning att få fel. Kostnaden är några oanvända xref-rader, 20 bytes var, vilket är en affär värd att göra

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är SourceByteCount (storleken på den icke-inkrementella kopia PDFium producerade), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded och ErrorMessage. Filnamnsöverladdningen skriver till en temporärfil bredvid målet och ersätter målet först när omskrivningen lyckades, så en fallerad körning lämnar aldrig en avhuggen PDF efter sig

Vilka filer vägrar de här API:erna?

Alla tre omskrivnings-API:erna vägrar krypterade dokument och, som standard, signerade. Krypterad indata fallerar med Succeeded = False och ett felmeddelande i stället för ett undantag: en en-revisions-omskrivning kan inte behålla de objektnummer, byteintervall och per-objekt-nycklar som krypteringsordlistan beror på, så utdatan vore oläsbar. Ett dokument med digitala signaturer kastar EPdfError om du inte skickar AllowSignedDocument = True, eftersom en fullständig omskrivning alltid ogiltigförklarar det signerade byteintervallet. Att sätta den flaggan betyder att du accepterar en fil vars signaturer kommer att rapporteras som trasiga; det är rätt beslut för en arkivkopia, aldrig för ett avtal du ska skicka tillbaka

Hur avgör SaveAsDeduplicated att två bilder är samma?

TPdf.SaveAsDeduplicated behandlar två resurser som identiska bara när deras ordlistor, hela deras nåbara delgraf och deras råa strömbytes alla matchar. Den kör samma nåbarhetsvandring först, fingeravtrycker sedan varje nåbart typsnitt, bild-XObject och Form XObject, slår ihop varje grupp av träffar på medlemmen med lägst objektnummer och skriver om varje referens att peka på den överlevande. Sammanslagna dubbletter blir fria xref-poster precis som överblivna objekt. Det är fixen för den klassiska "logotyp inbäddad 400 gånger"-rapporten som mail-merge-verktyg producerar när de återinbäddar samma resurser på varje sida

Fingeravtrycket är en 64-bitars FNV-1a-hash beräknad rekursivt. För varje kandidat tar vandraren objektkroppen, ersätter varje N G R-referens med fingeravtrycket av objektet den pekar på, och hashar resultatet. Det gör hashen oberoende av objektnumrering: två kopior av samma typsnitt vars beskrivare råkar vara objekt 12 och 87 producerar fortfarande samma värde. Resultat memoiseras så delade underobjekt hashas en gång, och en besökt-mängd returnerar en fast sentinel för bakåtkanter så cykliska grafer terminerar

Den subtila buggen sitter i strömnyttlasten. För att hitta referenser säkert skannar PDFiumPas en kopia av varje objekt i vilken strömdata, strängar och kommentarer utsuddats med mellanslag; annars skulle en sekvens av binär pixeldata som råkar se ut som 12 0 R behandlas som en referens. Hashar du bara den sanerade kopian kollapsar två bilder med identiska ordlistor (samma /Width, /Height, /BitsPerComponent, /Filter, /Length) men olika pixlar till ett fingeravtryck, och det andra fotot på sidan ersätts tyst av det första. SaveAsDeduplicated bifogar de råa bytes mellan stream och endstream till det hashade datat, så pixlarna är en del av identiteten. Kandidater grupperas också efter kategori före jämförelsen, så en sällsynt hashkollision mellan ett typsnitt och en bild kan aldrig slå ihop dem

Referensomskrivningen återanvänder samma sanerade-skannings-trick baklänges: referenser lokaliseras i den utsuddade kopian men bytes kopieras från den ursprungliga kroppen, så bara ordliste- och arraystruktur modifieras någonsin och binära nyttlastar passerar orörda

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 noggrant. Bredvid de tre Merged*Count-fälten har den KeptObjectCount och PrunedObjectCount, och i den här rapporten räknar PrunedObjectCount bara sammanslagna dubbletter. Onåbara objekt slängs fortfarande, men de ingår inte i det talet. Behöver du antalet överblivna för loggning, ta det från en separat SaveAsReachable-körning. Den andra gränsen är att matchningen är byteexakt på den kodade strömmen: samma logotyp komprimerad av två olika producenter på två olika Flate-nivåer slås inte ihop

Rekomprimera Flate-strömmar på maximal nivå

TPdf.SaveAsFlateRecompressed blåser upp varje nåbar FlateDecode-ström och blåser ner den igen på den högsta komprimeringsnivån, och behåller resultatet bara när det är mindre och klarar dina trösklar. Många producenter skriver Flate på en snabb standardnivå, så content streams och inbäddade typsnittsprogram krymper ofta ytterligare. Objektnummer och generationer stannar; bara nyttlasten och dess direkta /Length ändras

TPdfFlateRecompressOptions har två fält och ingen standardkonstruktor, så sätt båda. MinSavingBytes är minimum absolut besparing per ström, och MinSavingRatio är en andel av den ursprungliga längden (0,05 betyder 5 %); en kvot på 0 avaktiverar kvotprovet. Den ombyggda strömmen skrivs med stream, CRLF, den nya nyttlasten, CRLF och endstream, så resultatet beror inte på ursprungfilens radslut

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // ignorera små vinster
  Options.MinSavingRatio := 0.05;   // och allt 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;

Kantfallen är konservativa av design. En ström vars /Length är en indirekt referens hoppas över, eftersom omskrivningen måste lagra ett direkt heltal. En nyttlast som inte kan blåsas upp räknas in i FailedCount och kopieras oförändrad. En Flate-ström som inte möter trösklarna räknas in i SkippedCount. DCT-, JPX-, JBIG2- och CCITT-bilder går aldrig in i kandidatlistan, så det här passet vidrör aldrig JPEG-kvalitet. För förlustgivande bildbesparingar, använd adaptiv bildomsampling i PDFiumPas före den slutliga sparrowingen

En portabilitetsfälla från att skriva det tröskelprovet: första utkastet jämförde Single(Saving)/Single(OrigLen) med Saving deklarerad som Int64. Delphi accepterar det, men FPC 3.2.2 stannar med Illegal type conversion: "Int64" to "Single", eftersom i FPC den syntaxen är en typgjutning snarare än en numerisk konvertering. Enkel Saving / OrigLen är riktig division i båda kompilatorerna och jämförs direkt mot en Single, så gjutningarna var helt enkelt onödiga

Vilken ordning ska de tre API:erna köras i?

Kör SaveAsDeduplicated först, ladda om utdatan, kör sedan SaveAsFlateRecompressed. Dedupliceringen inkluderar redan nåbarhetssvepet, så ett separat SaveAsReachable-pass framför det är överflödigt; behåll SaveAsReachable för jobb där du bara vill ha rensning av överblivna och ett exakt antal. Att rekomprimera som andra steg betyder att det dyra blåsa-upp-och-ner-arbetet körs en gång per överlevande ström i stället för en gång per dubblett. Rekomprimeringspasset vandrar också grafen från /Root och /Info igen, vilket städar upp typsnittsbeskrivare eller typsnittsfiler som bara ett sammanslaget dubbletttypsnitt fortfarande pekade 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);   // rensa + slå ihop

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // blås ner överlevande igen
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

Applicerar du också svärta, gör det före allt det här, eftersom fysisk borttagning av innehåll är det som skapar nya överblivna från början; artikeln om svärta på operatornivå och resursrensning täcker svärtsidan. Och ladda alltid om den slutliga filen som en sundhetskontroll i din pipeline: ett sidantal som matchar källan är billig försäkring innan du skriver över något en kund bryr sig om

FAQ

Omnumrerar SaveAsReachable objekt eller ändrar referenser?

Nej. TPdf.SaveAsReachable behåller varje överlevande objekt på sitt ursprungliga nummer och generation och märker borttagna objekt som fria xref-poster, så ingen referens behöver skrivas om

Kan SaveAsDeduplicated slå ihop två olika bilder av misstag?

Inte genom matchande ordlistor allena. Fingeravtrycket inkluderar de råa strömbytena och hela den refererade delgrafen, och kandidater jämförs bara inom samma kategori (typsnitt, bild, Form XObject)

Håller de här API:erna mina digitala signaturer giltiga?

Nej. Varje API utför en fullständig omskrivning, som bryter det signerade byteintervallet, så signerade filer avvisas om du inte skickar AllowSignedDocument = True och accepterar ogiltiga signaturer i utdatan

Alla tre API:erna medföljer PDFium Component for Delphi and C++Builder, bredvid svärt- och bildomsamplingsfunktionerna som täcks på andra ställen i den här bloggen