Teknisk artikel

Validera komprimerade PDF-filer: Objekt- och XRef-strömmar

Du skriver en liten validator. Den öppnar en PDF, söker sig till slutet, hittar startxref, läser förskjutningen och förväntar sig att landa på nyckelordet xref med en korsreferenstabell av fast bredd inunder den. Från den tabellen samlar den in objektförskjutningar, och skannar sedan bakåt efter nyckelordet trailer för att lära sig /Root och /Size. Det fungerar perfekt på varje fil du genererat för att testa det. Sedan anländer en fil producerad av en aktuell version av Word, eller av ett bibliotek inriktat mot PDF 1.5, och validatorn förklarar den trasig. Det finns inget xref-nyckelord där förskjutningen pekar, ingen trailer-ordlista någonstans, och objekttabellen validatorn byggde är nästan tom. Filen är giltig. Validatorn läser den genom en femton år gammal lins

Detta är den enskilt vanligaste anledningen till att en PDF-kontroll på bytenivå skriven mot den klassiska layouten misslyckas på moderna dokument. Strukturen den är beroende av, den i klartext skrivna korsreferenstabellen och nyckelordet trailer, gjordes valfria i PDF 1.5 och saknas ofta. Två funktioner ersatte den: korsreferensströmmen och den komprimerade objektströmmen. Båda beskrivs i ISO 32000-1, och en validator som inte känner till dem ser en frisk fil som en hög med saknade objekt

Vad PDF 1.5 ändrade gällande filens svans

ISO 32000-1 §7.5.8 definierar korsreferensströmmen, och §7.5.7 definierar objektströmmen av typ /ObjStm. Tillsammans låter de en författare utelämna de två strukturer en klassisk tolkare letar efter. En PDF 1.5-fil kanske slutar utan någon xref-tabell alls. I dess ställe är objektet som startxref pekar på ett vanligt strömobjekt vars ordlista bär /Type /XRef, och den strömmen håller korsreferensdata i en kompakt binär form. Det finns inget trailer-nyckelord heller, eftersom trailern nu är strömmens egen ordlista. Nycklarna en klassisk tolkare jagade efter, /Root, /Size och /ID, lever inuti den ordlistan

Den andra ändringen flyttar själva objekten. Istället för att skriva varje indirekt objekt på sin egen byte-förskjutning, kan en författare packa många små objekt, sidordlistorna, kommentarordlistorna, strukturträdet, till en enda objektström och komprimera hela behållaren med Flate. De enskilda objekten har inte längre en byte-förskjutning i filen. De har en position inuti en komprimerad blob. En validator som skannar råa byte efter 1 0 obj hittar dem aldrig, eftersom den texten endast existerar efter dekomprimering. För en klassisk tolkare har halva dokumentet helt enkelt försvunnit

Trailer-nycklarna är i klartext, även i en komprimerad fil

Det lugnande är att läsningen av trailern till en korsreferensström inte kräver dekomprimering av någonting. Ett strömobjekt skrivs som en ordlista följt av nyckelordet stream och sedan de komprimerade byten. Ordlistan är klartext. Så när startxref pekar på en korsreferensström, ser byten direkt efter objektnumret ut som en vanlig ordlista, och /Root, /Size och /ID sitter där i klartext, innan nyckelordet stream och Flate-datan börjar

Det betyder att en validator kan lära sig de tre fakta den mest behöver, var katalogen finns, hur många objekt filen gör anspråk på, och filidentifieraren, genom att bara tolka strömordlistan. Den behöver inte dekomprimera korsreferensdatan, och den behöver inte tolka de binära posterna inuti den. Arbetet som besegrar en naiv tolkare är inte att läsa trailern; det är att hitta objekten. Det är två separerbara problem, och att lösa det första är billigt

Objektströmmar: ett huvud, sedan en Flate-blob

En objektström är en behållare. Dess ordlista bär /Type /ObjStm, en /N-post som anger antalet objekt packade inuti, och en /First-post som anger byte-förskjutningen, inuti den dekomprimerade datan, där det första objektets kropp börjar. Den komprimerade lasten, väl dekomprimerad, börjar med ett litet huvud med /N heltalspar. Varje par är ett objektnummer och förskjutningen för det objektets kropp i förhållande till /First. Efter huvudet kommer själva objektkropparna, sammanfogade

Att expandera en är mekaniskt när byten väl är dekomprimerade. Du läser ordlistan för att hämta /N och /First, dekomprimerar strömmen med en Flate-avkodare, vandrar igenom de inledande /N-paren för att lära dig vilket objektnummer som bor vid vilken förskjutning, och lyfter sedan ut varje kropp som om det vore ett vanligt indirekt objekt. Det enda verkliga beroendet är Flate-avkodaren, och du har redan en: Delphi levereras med System.ZLib, och Free Pascal levereras med enheten zstream, som båda kapslar in zlib och dekomprimerar en rå Flate-ström utan någon tredjepartskod. En rutin som lägger till varje extraherat objekt i validatorns objekttabell får resten av validatorn, den del som vandrar /Root och kontrollerar sidträdet, att bete sig precis som den skulle göra på en klassisk fil

Vad du inte behöver implementera

Det är lätt att överskatta arbetet. Att läsa trailer-nycklarna från en komprimerad fil kräver inte avkodning av korsreferensströmmens binära poster. §7.5.8 korsreferensströmmen använder tre posttyper, och typ 2-posten, den som säger detta objekt bor inuti objektström N på index i, är vad du skulle avkoda för att bygga en fullständig förskjutningskarta. Du behöver den kartan för att lösa upp godtyckliga objekt via nummer. Du behöver den inte för att läsa /Root, /Size och /ID, vilka finns i klartextsordlistan, och du behöver den inte för att expandera objektströmmar, eftersom varje /ObjStm tillkännager sitt eget innehåll genom /N och /First

Du behöver inte heller hantera de PNG- och TIFF-prediktorfunktioner som en korsreferensström kan applicera genom sina /DecodeParms bara för att få tag på trailer-nycklarna. Prediktorer filtrerar de binära korsreferensraderna för att få dem att komprimeras bättre; de har ingenting att göra med ordlistan som föregår strömmen. Den minimala uppgradering som gör en klassisk validator medveten om modern PDF är därför liten: när startxref landar på en ström snarare än nyckelordet xref, tolka strömordlistan efter trailer-nycklarna, och expandera eventuella /ObjStm-objekt du stöter på så att deras innehåll går in i objekttabellen. Att avkoda typ 2-poster och prediktorer är en separat, större uppgift som du kan skjuta upp tills du genuint behöver slumpmässig objektupplösning

Varför en efterlevnadskontroll måste expandera strömmar först

Detta slutar vara akademiskt i det ögonblick du kör en profilkontroll. En PDF/A- eller PDF/X-validator inspekterar specifika objekt: dokumentkatalogen för en /OutputIntents-array, /Metadata-strömmen för ett XMP-paket med rätt identifierare, varje typsnittsbeskrivare för en inbäddad typsnittsfil, trailern för ett /ID. I en komprimerad fil ligger de flesta av dessa objekt inuti objektströmmar. En validator som inte har expanderat objektströmmarna kan inte se katalogens nycklar, kan inte hitta metadatan, och kan inte räkna upp typsnitten. Den kommer att rapportera ett perfekt överensstämmande dokument som att det saknar sin output intent, saknar sin XMP, och saknar hälften av sin struktur, eftersom bevisen den behöver fortfarande sitter i en Flate-blob den aldrig dekomprimerade

Ordningen spelar roll. Expansion måste ske innan kontrollerna körs, inte vid sidan av dem, eftersom varje kontroll förutsätter att den kan nå ett objekt via nummer. Om du kopplar en profilkontroll direkt på en rå byteskanning, ärver den den klassiska tolkarens blindhet och producerar falska överträdelser på exakt de moderna filer som är mest sannolika att vara välformade, eftersom de kom ut ur verktygskedjor som var tillräckligt nya för att skriva korsreferensströmmar till att börja med

Låta PDFium göra tolkningen åt dig

PDFium Component tolkar korsreferensströmmar och objektströmmar som en del av att ladda ett dokument, vilket är det praktiska sättet att undvika att handskriva dekomprimerings- och expansionssteget. När du laddar en fil med TPdf-komponenten, är objekten som packats in i /ObjStm-behållare redan upplösta, och valideringens ingångspunkter ser det fullt expanderade dokumentet. ValidatePdfA returnerar en TPdfAValidationResult-post vars Conformance-fält är ett TPdfAConformance-värde såsom pac1b eller pacNone, vars Issues-fält är en mängd av de specifika problem som hittades, och vars IsCompliant-metod endast är sann när en efterlevnadsnivå detekterades och problemmängden är tom. Eftersom objekten expanderades under inläsningen, hittas en /OutputIntents-array eller ett inbäddat typsnitt som levde inuti en objektström, de rapporteras inte som saknade

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

Detsamma gäller för ValidatePdfX, som returnerar ett TPdfXValidationResult med samma form. Poängen med att dirigera via PDFium är att den strukturella dekomprimeringen som beskrivs ovan sker en gång, korrekt, inuti inläsaren, så din valideringskod aldrig ser skillnaden mellan en klassisk fil och en fullt komprimerad sådan. Båda anländer till validatorn som en upplöst uppsättning objekt

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

Om byten redan finns i minnet istället för på disk, fungerar samma ladda-sedan-validera-sekvens genom LoadDocument(const Data: TBytes)-överlagringen, som tar det råa filinnehållet och tolkar dess korsreferens- och objektströmmar på samma sätt som filsökvägen gör. Lärdomen för en handskriven validator är den strukturella regeln, inte API:et: läs trailer-nycklarna från strömordlistan i klartext, expandera varje /ObjStm med en Flate-avkodare innan du vandrar igenom dokumentet, och behandla avkodningen av de binära korsreferensposterna som det större, valfria jobb det är

När strukturen väl är expanderad kan en validator driva resten av ett arbetsflöde över den. För en kommandoradssele för preflight som rapporterar efterlevnad över en mapp med indata, se vår genomgång om att bygga ett CLI för batch-preflight-rapporter. När validering är en grind inför att bryta isär ett stort dokument, paras teknikerna i vår guide till att dela PDF-dokument i flera filer naturligt med det ladda-och-kontrollera-mönster som visas här. Båda bygger på laddnings- och valideringsytan i PDFium Component för Delphi och C++Builder