Technisch artikel

PDF-bestandsgrootte-audit in Delphi: byte-uitsplitsing per categorie

Om uit te vinden waar een PDF-bestandsgrootte daadwerkelijk aan opgaat, biedt losLab PDF Library AuditDocumentSpace, dat elk indirect object classificeert in twaalf categorieën — afbeeldingen, fontprogramma's, fontdictionaries, contentstreams, form-XObjects, objectstreams, ingebedde bestanden, metadata, structuurboom, annotaties, paginaboom, overig — en het aantal objecten, opgeslagen bytes en percentageaandeel van elk rapporteert

De situatie waarvoor dit bestaat is bekend. Een rapport van 40 pagina's komt uit je generator op 80 MB, de klant vraagt waarom, en het enige wat je kunt bieden is een gok. Waarschijnlijk de afbeeldingen. Misschien de fonts. Dus schakel je downsampling in, verstuurt het, en het bestand landt op 74 MB omdat het echte gewicht ergens heel anders zat. Ons bijbehorende artikel over font subsetting en afbeeldingsdownsampling behandelt hoe je een PDF verkleint; dit artikel behandelt de stap die daaraan vooraf zou moeten gaan, namelijk meten wat je op het punt staat te verkleinen

Waarom meten vóór je comprimeert?

Omdat de drie standaard optimalisatiepassen wildly verschillende opbrengsten hebben op elk gegeven bestand, en niets aan het bestand je vertelt welke van toepassing is totdat je telt. Fonts subsetten in een document waarvan de fonts al 2% van de bytes uitmaken is een middag besteed aan het verplaatsen van een afrondingsfout. Afbeeldingen downsamplen in een bestand waarvan de bulk uit ongecomprimeerde contentstreams bestaat levert dezelfde teleurstelling op. De optimizer is niet het moeilijke deel — elke bibliotheek heeft er een. Weten welke optimizer op dit bestand te richten is het moeilijke deel, en dat is een boekhoudkundige vraag, geen compressievraag. Een audit vangt ook de gevallen op waarin geen enkele optimizer het antwoord is: een bestand dat 60% ingebedde bijlagen blijkt te zijn heeft geen betere compressie nodig, het heeft een gesprek nodig over of die bijlagen wel in het document thuishoren, en een bestand dat 30% structuurboom is betaalt voor toegankelijkheidstagging, wat gewoonlijk een bewuste kost is die je niet stilzwijgend moet strippen. Zodra de bytes zijn toegewezen, neem je een productbeslissing met cijfers erachter in plaats van naar de dichtstbijzijnde schakelaar te grijpen

Wat het rapport met twaalf categorieën bevat

AuditDocumentSpace retourneert een string-list-handle in plaats van een record, zodat het rapport de platte DLL- en COM-facades ongewijzigd overleeft. De lijst bevat een samenvattingsregel Total,Objects,Bytes,100.0 gevolgd door precies twaalf regels Category,Objects,Bytes,Percent in een vaste volgorde die deel uitmaakt van het contract: Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other. Dertien regels, altijd, ook wanneer een categorie leeg is

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;

Eén Delphi-detail in die lus zal je precies één keer bijten. GetStringListItem gebruikt one-based itemindices, overeenkomstig GetStringListCount, en een index buiten bereik retourneert een lege string in plaats van een exception te gooien. Schrijf de lus uit gewoonte als for I := 0 to Count - 1 en je krijgt een lege eerste regel, een stilzwijgend weggevallen laatste regel, en nergens een exception die je vertelt dat de indexering fout is. Het rapport zelf zal er bijna correct uitzien, wat de ergste faalvorm is die een diagnostisch hulpmiddel kan hebben

Waarom gebruikt de audit opgeslagen lengte in plaats van gedecodeerde grootte?

Omdat opgeslagen lengte zowel het gewenste getal is als het getal dat goedkoop te verkrijgen is. Elk indirect object draagt TPDFIndObj.FLength, de ruwe bytelengte die het object inneemt in het bestand zoals geparseerd. Het gebruik ervan betekent dat een DCTDecode-afbeelding van 900 KB gerapporteerd wordt als 900 KB — de bytes die het je op schijf kost — in plaats van de 40 MB aan RGB-samples waartoe het decodeert. Het betekent ook dat de audit nooit iets hoeft te decoderen: lui geladen objecten blijven lui, filters blijven ongedraaid, en het auditen van een bestand van 500 MB is een pas over objectheaders in plaats van een volledige decompressiecyclus

De tweede regel is een verdediging tegen dubbeltelling. Wanneer een object zich binnen een gecomprimeerde objectstream bevindt, aangegeven door een niet-nul FObjStrNum, wordt zijn byteaantal geregistreerd als nul. De opslag ervan is al één keer betaald door de containerstream, die ISO 32000-1 §7.5.7 definieert als een /Type /ObjStm-stream met vele objecten in één Flate-gecomprimeerde payload. Elk lid zijn eigen aandeel toerekenen en dan de container nogmaals aanrekenen zou het totaal opblazen tot voorbij de echte bestandsgrootte. Dit heeft een directe consequentie voor hoe je de output leest, hieronder behandeld en dieper uitgewerkt in ons artikel over object streams en cross-reference streams

Waarom kan een fontprogramma zichzelf niet classificeren?

Omdat een TrueType-fontbestand ingebed in een PDF geen markering heeft die dat aangeeft. ISO 32000-1 §9.8.1 definieert het ingebedde fontprogramma als de waarde van /FontFile, /FontFile2 of /FontFile3 in een fontdescriptor, en het streamdictionary aan het andere eind van die verwijzing draagt /Length1 en filtersleutels maar geen /Type en geen /Subtype die het als font identificeert. Op zichzelf beschouwd is het een anonieme binaire stream. Alleen de descriptor die ernaar verwijst weet wat het is. Dezelfde asymmetrie doet zich voor bij annotaties: §12.5.2 maakt /Type /Annot optioneel in een annotatiedictionary, dus het betrouwbare signaal is lidmaatschap van een pagina-/Annots-array, niet het dictionary zelf

Classificatie draait daarom twee keer. De eerste pas leest het eigen /Type en /Subtype van elk object en pakt de makkelijke winsten: /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font en /Type /FontDescriptor, /Metadata, /EmbeddedFile en /Filespec, /StructTreeRoot en /StructElem, /Annot, /Page en /Pages. Al het andere landt voorlopig in Overig. De tweede pas loopt vervolgens langs de verwijzende kant en overschrijft: elk paginadictionary wijst zijn /Contents opnieuw toe aan contentstreams, zijn /Annots-items aan annotaties, en zijn /Thumb aan afbeeldingen, terwijl elk fontdictionary zijn eigen descriptorketen doorloopt

// 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]));

Het rapport lezen en de volgende stap kiezen

Lees eerst de aandelen, dan de objectaantallen, en behandel elk groot gat daartussen als een signaal. Een moderne PDF stopt het merendeel van zijn kleine dictionaries in objectstreams, dus Paginaboom en Structuurboom tonen routinematig tientallen objecten tegenover bijna nul bytes — hun echte kosten zijn opgevouwen in de regel Objectstreams. Als Objectstreams zelf groot is, zit het bestand dicht met metadata-achtige structuur in plaats van inhoud, en is de hefboom het snoeien van objecten, niet het comprimeren ervan. Verschijningsstreams van annotaties gedragen zich vergelijkbaar: ze dragen /Subtype /Form, dus een zwaar gestempeld document toont zijn gewicht onder Form-XObjects terwijl de regel Annotaties klein blijft

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;

Twee opmaakfeiten zijn van belang als je de percentages parseert in plaats van ze weer te geven. Het decimaalscheidingsteken is altijd letterlijk een punt, ongeacht de locale van de machine, dus parseren met de omringende FormatSettings op een Duits of Frans werkstation zal falen of, erger, verkeerd interpreteren. En achterliggende nullen worden weggelaten, dus een categorie die precies 40% van de bytes bevat, drukt af als 40, niet als 40.0 — ga nooit uit van een vast aantal decimalen. Met het aandeel in handen is de routing mechanisch: een dominant aandeel Afbeeldingen wijst naar DownsampleImages, een dominant aandeel Fontprogramma's naar SubsetEmbeddedFonts, en omvangrijke Contentstreams naar CompressContent

Wat de audit bewust niet vertelt

Het totaal is een som over indirecte objecten, en een PDF-bestand is iets meer dan zijn objecten. De bestandsheader, de trailer, tussenobject-witruimte en een klassieke cross-reference-tabel zijn geen indirecte objecten, dus die bytes worden aan niets toegewezen en het audittotaal komt een klein beetje onder de grootte op schijf uit. Een cross-reference-stream is anders — het is een echt object met /Type /XRef, dus in een modern bestand verschijnen die bytes wél, in de categorie Overig. Geen van beide gedragingen is een gebrek, maar als je de audit reconcilieert met een bytetelling van het bestandssysteem, is dat waar het verschil vandaan komt

Twee grenzen zijn het waard om nadrukkelijk te noemen. Ten eerste beschrijven de cijfers een bestand dat geladen is, niet een bestand dat wordt opgesteld: voor objecten die in het geheugen zijn opgebouwd en nog geen opgeslagen lengte hebben, valt de grootte terug op de geserialiseerde output met een nominale marge voor het streamdictionary, wat een schatting is van de uiteindelijke schrijfactie in plaats van een meting. Audit na een save-and-reload als je exacte cijfers wilt. Ten tweede is een dikke regel Overig een bevinding, geen bugrapport — het betekent gewoonlijk verweesde objecten waar niets meer naar verwijst, wat een taak is voor mark-and-sweep garbage collection in plaats van voor enige compressiepas

Zo gebruikt verandert de audit de vorm van het gesprek. In plaats van te gokken naar het rapport van 80 MB open je het, doe je één aanroep, en lees je dat afbeeldingen 8% zijn, fontprogramma's 61%, en dat het document negen volledige fontprogramma's inbedt voor een huisstijl die drie lettertypen gebruikt. Dat is een oplosbaar antwoord met een getal eraan gekoppeld. AuditDocumentSpace, samen met de optimalisatiepassen waar het je naartoe wijst, wordt geleverd in de losLab PDF Library voor Delphi en C++Builder, waar de referentiepagina's de volledige categorielijst en de string-list-API eromheen documenteren