Teknisk artikel

PDF-filstørrelsesaudit i Delphi: byte-fordeling pr. kategori

For at finde ud af, hvor en PDF-filstørrelse egentlig går hen, eksponerer losLab PDF Library AuditDocumentSpace, som klassificerer hvert indirekte objekt i tolv kategorier — billeder, skrifttypeprogrammer, skrifttype-dictionaries, indholdsstrømme, formular-XObjects, objektstrømme, indlejrede filer, metadata, strukturtræ, annoteringer, sidetræ, andet — og rapporterer objektantal, lagrede bytes og procentandel for hver

Situationen, dette findes til, er velkendt. En rapport på 40 sider kommer ud af ens generator på 80 MB, kunden spørger hvorfor, og alt man kan tilbyde er et gæt. Sandsynligvis billederne. Måske skrifttyperne. Så slår man downsampling til, sender den, og filen lander på 74 MB, fordi den reelle vægt lå et helt andet sted. Vores søsterartikel om skrifttype-subsetting og billed-downsampling dækker, hvordan man mindsker en PDF; denne dækker det trin, der bør komme først, nemlig at måle det, man er ved at mindske

Hvorfor måle før man komprimerer?

Fordi de tre standardoptimeringsgennemløb har vidt forskellige gevinster på en given fil, og intet ved filen fortæller, hvilket der gælder, før man tæller efter. At subsette skrifttyper i et dokument, hvis skrifttyper allerede udgør 2 % af dets bytes, er en eftermiddag brugt på at flytte en afrundingsfejl. Downsampling af billeder i en fil, hvis masse er ukomprimerede indholdsstrømme, giver samme skuffelse. Optimeringsværktøjet er ikke den svære del — hvert bibliotek har ét. At vide, hvilket optimeringsværktøj man skal rette mod denne fil, er den svære del, og det er et regnskabsspørgsmål, ikke et komprimeringsspørgsmål. En audit fanger også de tilfælde, hvor intet optimeringsværktøj er svaret: en fil, der viser sig at være 60 % indlejrede vedhæftninger, behøver ikke bedre komprimering, den behøver en samtale om, hvorvidt de vedhæftninger hører til i dokumentet, og en fil, der er 30 % strukturtræ, betaler for tilgængelighedstagging, hvilket normalt er en bevidst omkostning, man ikke stiltiende bør fjerne. Når bytene er tilskrevet, træffer man en produktbeslutning med tal bag sig i stedet for at gribe efter den nærmeste kontakt

Hvad tolv-kategori-rapporten indeholder

AuditDocumentSpace returnerer et string list-handle frem for en record, så rapporten overlever uændret gennem den flade DLL- og COM-facade. Listen holder en Total,Objects,Bytes,100.0-opsummeringslinje efterfulgt af nøjagtig tolv Category,Objects,Bytes,Percent-linjer i en fast rækkefølge, der er del af kontrakten: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Tretten linjer, altid, selv når en kategori er tom

var
  Lib: TPDFlib;
  ListID, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') <> 1 then
      Exit;
    ListID := Lib.AuditDocumentSpace;   // 0 when no document is selected
    if ListID = 0 then
      Exit;
    try
      // GetStringListItem is 1-based: items run 1..GetStringListCount
      for I := 1 to Lib.GetStringListCount(ListID) do
        Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
    finally
      Lib.ReleaseStringList(ListID);
    end;
  finally
    Lib.Free;
  end;
end;

Én Delphi-detalje i den løkke bider dig præcis én gang. GetStringListItem bruger 1-baserede elementindekser, der matcher GetStringListCount, og et indeks uden for grænsen returnerer en tom streng frem for at rejse en undtagelse. Skriv løkken for I := 0 to Count - 1 af gammel vane, og man får en blank første linje, en stiltiende droppet sidste linje, og ingen undtagelse nogen steder til at fortælle, at indekseringen er forkert. Selve rapporten vil se næsten rigtig ud, hvilket er den værste fejltilstand, et diagnoseværktøj kan have

Hvorfor bruger audit'en lagret længde frem for afkodet størrelse?

Fordi den lagrede længde både er det tal, man vil have, og det tal, der er billigt at hente. Hvert indirekte objekt bærer TPDFIndObj.FLength, den rå bytelængde, objektet fylder i filen som den er parset. At bruge den betyder, at et 900 KB DCTDecode-billede rapporteres som 900 KB — de bytes, det koster på disken — frem for de 40 MB RGB-samples, det afkoder til. Det betyder også, at audit'en aldrig behøver afkode noget: lazy-indlæste objekter forbliver lazy, filtre forbliver ikke kørt, og at audit'e en 500 MB fil er et gennemløb over objekthoveder frem for en fuld dekomprimeringscyklus

Den anden regel er et forsvar mod dobbelttælling. Når et objekt lever inde i en komprimeret objektstrøm, angivet ved en ikke-nul FObjStrNum, registreres dets bytetal som nul. Dets lagring er allerede betalt én gang af containerstrømmen, som ISO 32000-1 §7.5.7 definerer som en /Type /ObjStm-strøm, der holder mange objekter i én Flate-komprimeret payload. At opkræve hvert medlem sin egen andel og derefter opkræve containeren igen ville oppuste totalen forbi den reelle filstørrelse. Dette har en direkte konsekvens for, hvordan man læser output, dækket nedenfor og mere dybtgående i vores artikel om object streams og cross-reference-streams

Hvorfor kan et skrifttypeprogram ikke klassificere sig selv?

Fordi en TrueType-fontfil indlejret i en PDF ikke har nogen markør, der siger det. ISO 32000-1 §9.8.1 definerer det indlejrede skrifttypeprogram som værdien af /FontFile, /FontFile2 eller /FontFile3 i en font descriptor, og stream-dictionary'et i den anden ende af den reference bærer /Length1 og filternøgler, men ikke noget /Type og intet /Subtype, der identificerer det som en skrifttype. Set isoleret er det en anonym binær strøm. Kun descriptor'en, der peger på den, ved, hvad det er. Den samme asymmetri optræder for annoteringer: §12.5.2 gør /Type /Annot valgfri i et annoteringsdictionary, så det pålidelige signal er medlemskab i en sides /Annots-array, ikke selve dictionary'et

Så klassificering kører to gange. Det første gennemløb læser hvert objekts eget /Type og /Subtype og tager de nemme sejre: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font og /Type /FontDescriptor, /Metadata, /EmbeddedFile og /Filespec, /StructTreeRoot og /StructElem, /Annot, /Page og /Pages. Alt andet lander foreløbigt i Other. Det andet gennemløb gennemgår så den refererende side og overstyrer: hvert sidedictionary omfordeler sit /Contents til indholdsstrømme, sine /Annots-poster til annoteringer og sit /Thumb til billeder, mens hvert skrifttype-dictionary gennemgår sin egen descriptor-kæde

// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
  MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
  MarkRef(Descriptor.FindValueByKeyName('FontFile'),  catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
  MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
  MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));

Aflæsning af rapporten og valg af næste træk

Læs andelene først, objektantallene dernæst, og betragt ethvert stort gab imellem dem som et signal. En moderne PDF putter de fleste af sine små dictionaries inde i objektstrømme, så Page tree og Structure tree viser rutinemæssigt snesevis af objekter mod næsten nul bytes — deres reelle omkostning er blevet foldet ind i Object streams-linjen. Er Object streams selv stor, er filen tæt med metadata-lignende struktur frem for indhold, og løftestangen er beskæring af objekter, ikke komprimering af dem. Annoteringers appearance streams opfører sig tilsvarende: de bærer /Subtype /Form, så et kraftigt stemplet dokument viser sin vægt under Form XObjects, mens Annotations-linjen forbliver lille

function CategoryShare(Lib: TPDFlib; ListID: Integer;
  const Category: string): Double;
var
  I: Integer;
  Parts: TArray<string>;
  Inv: TFormatSettings;
begin
  Result := 0;
  Inv := FormatSettings;
  Inv.DecimalSeparator := '.';   // the report is locale-independent
  for I := 2 to Lib.GetStringListCount(ListID) do   // line 1 is Total
  begin
    Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
    if (Length(Parts) = 4) and SameText(Parts[0], Category) then
      Exit(StrToFloatDef(Parts[3], 0, Inv));
  end;
end;

To formateringsfakta betyder noget, hvis man parser procentandelene frem for at vise dem. Decimalseparatoren er altid et bogstaveligt punktum uanset maskinens locale, så parsing med den omgivende FormatSettings på en tysk eller fransk arbejdsstation vil fejle eller, værre, læse forkert. Og efterfølgende nuller trimmes, så en kategori, der holder præcis 40 % af bytene, udskrives som 40, ikke 40.0 — antag aldrig et fast antal decimaler. Med andelen i hånden er ruteinddelingen mekanisk: en dominerende Images-andel peger på DownsampleImages, en dominerende Font programs-andel på SubsetEmbeddedFonts, og fyldige Content streams på CompressContent

Hvad audit'en bevidst ikke fortæller dig

Totalen er en sum over indirekte objekter, og en PDF-fil er lidt mere end sine objekter. Filhovedet, traileren, mellemobjekt-whitespace og en klassisk cross-reference-tabel er ikke indirekte objekter, så de bytes tilskrives intet, og audit-totalen lander en anelse under filstørrelsen på disken. En cross-reference-strøm er anderledes — den er et rigtigt objekt med /Type /XRef, så i en moderne fil optræder de bytes rent faktisk, i kategorien Other. Ingen af adfærdene er en fejl, men hvis man afstemmer audit'en mod et bytetal fra filsystemet, er det der, gabet kommer fra

To yderligere grænser fortjener at siges ligeud. For det første beskriver tallene en fil, der blev indlæst, ikke én under forfatning: for objekter bygget i hukommelsen, der endnu ikke har en lagret længde, falder størrelsen tilbage på det serialiserede output med en nominel tildeling for stream-dictionary'et, hvilket er et estimat af den kommende skrivning frem for en måling. Audit'er efter en gem-og-genindlæsning, hvis man ønsker eksakte tal. For det andet er en fed Other-linje et fund, ikke en fejlrapport — den betyder som regel forældreløse objekter, som intet længere refererer til, hvilket er en opgave for mark-and-sweep garbage collection frem for noget komprimeringsgennemløb

Brugt på den måde ændrer audit'en samtalens karakter. I stedet for at gætte på 80 MB-rapporten åbner man den, kører ét kald og læser, at billeder er 8 %, skrifttypeprogrammer er 61 %, og dokumentet indlejrer ni fulde skrifttypeprogrammer til en husstil, der bruger tre skrifttyper. Det er et retteligt svar med et tal hæftet på. AuditDocumentSpace, sammen med de optimeringsgennemløb den peger mod, leveres i losLab PDF Library til Delphi og C++Builder, hvor referencesiderne dokumenterer den fulde kategoriliste og string list-API'en omkring den