Technický článek

Ověřování komprimovaných PDF: Toky objektů a křížových odkazů (Object a XRef streamy)

Napíšete si malý validátor. Ten otevře PDF, přeskočí na konec, najde startxref, přečte offset a očekává, že přistane na klíčovém slově xref s tabulkou křížových odkazů o pevné šířce pod ním. Z této tabulky shromáždí offsety objektů, pak skenuje směrem zpět a hledá klíčové slovo trailer, aby zjistil /Root a /Size. Funguje perfektně na každém souboru, který jste vygenerovali pro testování. Pak dorazí soubor vytvořený aktuální verzí aplikace Word, nebo knihovnou cílící na PDF 1.5, a validátor jej prohlásí za rozbitý. Tam, kam ukazuje offset, není žádné klíčové slovo xref, nikde není žádný slovník trailer a tabulka objektů, kterou validátor sestavil, je téměř prázdná. Soubor je platný. Validátor jej čte objektivem starým patnáct let

To je ten vůbec nejčastější důvod, proč kontrola PDF na úrovni bytů napsaná proti klasickému rozvržení selhává u moderních dokumentů. Struktura, na které závisí, tabulka křížových odkazů v prostém textu (plaintext) a klíčové slovo trailer, se stala v PDF 1.5 volitelnou a často chybí. Nahradily ji dvě funkce: tok křížových odkazů (cross-reference stream) a komprimovaný tok objektů (compressed object stream). Obě jsou popsány v normě ISO 32000-1, a validátor, který o nich neví, vidí zdravý soubor jako hromadu chybějících objektů

Co PDF 1.5 změnilo na konci souboru

ISO 32000-1 §7.5.8 definuje tok křížových odkazů (cross-reference stream) a §7.5.7 definuje tok objektů (object stream) typu /ObjStm. Společně umožňují tvůrci (writer) vypustit dvě struktury, od kterých se odpichuje klasický parser. Soubor PDF 1.5 může končit tím, že nebude obsahovat vůbec žádnou tabulku xref. Místo toho je objekt, na který odkazuje startxref, obyčejným streamovacím objektem, jehož slovník nese /Type /XRef, a tento stream obsahuje data křížových odkazů v kompaktní binární formě. Není zde ani klíčové slovo trailer, protože patička (trailer) je nyní vlastním slovníkem tohoto streamu. Klíče, které klasický parser lovil, jako /Root, /Size a /ID, žijí uvnitř tohoto slovníku

Druhá změna se týká samotných objektů a jejich přesunu. Místo zápisu každého nepřímého (indirect) objektu na vlastním bytovém offsetu, může tvůrce (writer) sbalit mnoho malých objektů, slovníky stránek, slovníky anotací, strom struktury, do jednoho toku objektů a zkomprimovat celý kontejner pomocí Flate. Jednotlivé objekty již nemají bytový offset v souboru. Mají pozici uvnitř komprimovaného blobu. Validátor skenující raw byty hledající 1 0 obj je nikdy nenajde, protože tento text existuje teprve až po nafouknutí (inflation). Pro klasický parser polovina dokumentu zkrátka zmizela

Klíče patičky jsou plaintext, dokonce i v komprimovaném souboru

Uklidňující na tom je, že čtení patičky z cross-reference streamu nevyžaduje nafukování (inflating) vůbec ničeho. Objekt streamu je zapsán jako slovník, po kterém následuje klíčové slovo stream a poté komprimované byty. Slovník je plaintext (prostý text). Takže když startxref ukazuje na tok křížových odkazů, byty bezprostředně po čísle objektu vypadají jako obyčejný slovník a klíče /Root, /Size a /ID zde sedí v nezašifrované podobě, ještě před začátkem klíčového slova stream a Flate dat

To znamená, že validátor zjistí tři fakta, která nejvíce potřebuje: kde se nachází katalog, kolik objektů soubor deklaruje a identifikátor souboru; to vše pouhým parsováním slovníku streamu. Nemusí dekomprimovat data křížových odkazů a nemusí interpretovat binární položky, které se v nich nacházejí. Práce, která zmate naivní parser, nespočívá ve čtení patičky; spočívá v hledání objektů. Jde o dva oddělitelné problémy a řešení toho prvního je levné

Toky objektů (Object streams): hlavička, pak Flate blob

Tok objektů je kontejner. Jeho slovník nese /Type /ObjStm, položku /N udávající počet objektů sbalených uvnitř, a položku /First udávající bytový offset, v rámci nafouknutých dat, kde začíná tělo prvního objektu. Komprimované užitečné zatížení (payload), jakmile je nafouknuto, začíná malou hlavičkou s /N celočíselnými páry. Každý pár je číslo objektu a offset těla tohoto objektu vůči /First. Po hlavičce následují zřetězená těla samotných objektů

Jakmile jsou byty nafouknuty, je expanze (rozkrývání) jednoho takového streamu spíše mechanická záležitost. Přečtete slovník, získáte z něj /N a /First, nafouknete stream pomocí dekodéru Flate, projdete přední /N páry, abyste zjistili, které číslo objektu žije na kterém offsetu, a pak vyjmete (lift out) každé tělo, jako by to byl obyčejný nepřímý objekt. Jedinou skutečnou závislostí je Flate dekodér a vy už jeden máte: Delphi dodává System.ZLib a Free Pascal dodává jednotku (unit) zstream. Obě tyto knihovny zabalují (wrap) zlib a nafukují surový Flate stream, aniž by byl potřeba jakýkoli kód třetích stran. Rutina, jež přidává (appends) každý extrahovaný objekt do tabulky objektů validátoru, zapříčiní to, že se zbytek validátoru – ta část, jež prochází /Root a kontroluje strom stránek – začne chovat naprosto stejně, jak by to udělala na klasickém souboru

Co nemusíte implementovat

Je velmi snadné přecenit danou práci. Čtení klíčů patičky z komprimovaného souboru nevyžaduje dekódování binárních položek v toku křížových odkazů. Stream křížových odkazů v §7.5.8 používá tři typy položek (entry types) a položka typu 2 (type 2 entry) – ta, která říká: tento objekt žije uvnitř toku objektů N na indexu i – je to, co byste dekódovali, abyste sestavili plnou mapu offsetů. Tuto mapu potřebujete, chcete-li řešit (resolve) libovolné objekty podle čísla. Nepotřebujete ji pro čtení /Root, /Size a /ID, které jsou obsaženy v plaintext slovníku, a nepotřebujete ji ani pro expanzi toků objektů, protože každý /ObjStm sám oznamuje svůj obsah prostřednictvím klíčů /N a /First

Rovněž nemusíte řešit predikční funkce z formátů PNG a TIFF, jež může cross-reference stream použít (přes jeho /DecodeParms) pouze pro získání klíčů z patičky. Prediktory pouze filtrují binární řádky křížových odkazů, aby se daly lépe komprimovat; se slovníkem, jenž streamu předchází, nemají společného zhola nic. Minimální upgrade, který provede z klasického validátoru validátor kompatibilní (aware) s moderními PDF, je tudíž malý: jakmile se startxref zastaví raději nad streamem než nad klíčovým slovem xref, projděte zkrátka parsováním (parse) přes slovník streamu kvůli klíčům patičky a expandujte jakékoli objekty /ObjStm, na které narazíte tak, aby se jejich obsahy promítly i do tabulky objektů (object table). Dekódování položek typu 2 a prediktorů (predictors) je mnohem větší, separátní záležitost, kterou bez obav odložte do té doby, dokud doopravdy nebudete potřebovat vyhodnocovat náhodné objekty (random object resolution)

Proč kontrola souladu (compliance check) musí expandovat streamy jako první

Tohle přestává být pouze na akademické úrovni ve chvíli, kdy spustíte kontrolu profilu. Validátor zaměřený na PDF/A nebo PDF/X prohledává (inspects) specifické objekty: např. u katalogu dokumentů kontroluje pole /OutputIntents, u streamu /Metadata zjišťuje paket XMP se správným identifikátorem, u všech popisovačů písma vkládané soubory s písmy a u patičky (trailer) pak klíč /ID. V komprimovaném souboru se nicméně většina těchto objektů nachází vnitřním (inside) toků objektů. Pokud daný validátor streamy neexpandoval (neprokouknul přes obálku/dekomprimoval je), jednoduše neuvidí klíče z katalogu, nenajde potřebná metadata a nebude umět projít (enumerate) obsažená písma. Tudíž takový čistě a dokonale vyhovující dokument označí (report) štítkem ohlašující např. chybějící „output intent“, chybějící XMP nebo také chybějící půlku vlastní struktury, protože kýžený důkaz mu neustále sedí přímo před nosem ve Flate blobu, jejž ovšem zapomněl nechat zprvu nafouknout (inflate)

Na pořadí záleží. K oné expanzi (rozkrytí/rozbalení) musí zkrátka dojít dříve, než na soubor „pustíte“ jakékoliv vlastní kontroly, nikoliv až těsně podél nich, neboť veškeré kontroly jakoby implicitně očekávají (assumes), že dokáží jednoduše (a ihned) sahat na jednotlivé objekty čistě jen za pomoci číselné identifikace. Budete-li profilové kontroly roubovat (wire) přímou cestou k tzv. skenování surových bytů, zdědíte slepotu typickou spíše u dávných/klasičtějších parserů, z čehož naoplátku (and produces) vyjdou veskrze falešná narušení/výjimky – a to přesně na nejmodernějších typech souborů, od kterých zrovna naprosto po právu očekáváte maximálně dobrou formu, protože s největší pravděpodobností vůbec vznikly pomocí mnohem pokročilejších nástrojů, a tudíž mají proudy (streams) v křížových odkazech vypracovány už ve svém základu

Nechte PDFium parsovat za vás

Komponenta PDFium se chopí parsování (parses) toků křížových odkazů i objektů v podstatě ihned v rámci načítání dokumentu, čímž (which is) se naskýtá docela praktická cestička, kterak celkem hravě zabránit ručně „lepeným“ fázím nafukování/expanze (inflate-and-expand). Když totiž načítáte svůj soubor komponentou TPdf, jsou už i ty objekty napasované hluboko v /ObjStm kontejnerech de facto rozuzlené (resolved) a vstupní kontrolní body (validation entry points) krásně vidí dokument z onoho zcela expandovaného pohledu. Výsledkem z ValidatePdfA se vám pak navrátí TPdfAValidationResult coby záznam, kde pro zjištěné položky Conformance získáte hodnoty z TPdfAConformance (kupříkladu takové pac1b anebo třebas pacNone), u pole Issues hravě prohlédnete množinu detekovaných/specifických trablů (problems found), no a metoda pod štítkem IsCompliant zasvítí pravdivostí opravdu čistě jen (a pouze) tehdy, shledáte-li u dané úrovně absolutní nulový stav chyb v množině chyb (the issue set is empty). A jelikož si dané objekty prošly onou expanzí již ve stádiu počátečního načítání, nějaké pole /OutputIntents (potažmo vnitřně zakořeněná/embeddnutá písma žijící uvnitř toku objektů) to bez váhání najde, takže je v žádném případě nevyhodnotí statusem „chybí“ (missing)

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;

Totéž se ostatně vztahuje k ValidatePdfX vracející obdobně TPdfXValidationResult ve stejném hávu (shape). Smyslem tohoto naroubování/trasování napříč komponentou PDFium tkví v pouhém jednom jediném faktu: vcelku výše sepsaná/popsaná strukturální dekomprese (structural decompression) proběhne zkrátka jen jednou a navíc naprosto správně přímo ve vašem zavaděči/loaderu, a to tak, že vámi zvolený ověřovací (validační) kód dokonce v žádném okamžiku nespatří (a ani k tomu nemá příležitost) propastný rozdíl uváděný mezi běžným klasickým vs. totálně (plně) zakomprimovaným dokumentem. Oba formáty totiž ve finále doputují na stůl platného validátoru jaksi v uceleně kompletní/vyřešené kolekci prvků (resolved set of objects)

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;

Je-li pole bytů již umístěno takzvaně v samotné paměti namísto reálně „ležících“ (diskových) souborů na pevném disku, ona totožná metodika z principu (na čtení v duchu load-then-validate sekvencí) beze sporu rovněž funguje zásluhou přetížení pro LoadDocument(const Data: TBytes), které do svého podhoubí ochotně převezme (takes the) surovou výplň z onoho souboru a obě podoby obsažených streamů křížových odkazů a příslušejících objektů (zparsuje/parses) přesně tím naprosto stejným vzorcem postupu, jak by tomu došlo pod dohledem běžných souborových/adresářových umístění (the file path does). Plynoucím výstupem – co se samotného čistě/ručně programovaného validátoru týče – tedy není v první řadě zkoumat ono zmíněné APIčko; raději si k ruce vezměte toto hrubé/strukturální pravidlo (the structural rule): vždy zprvu načtěte veškeré navrácené klíče patičky hovící v onom plaintext slovníkovém streamu, veďte na popravu všechny rozkryté bloky prvků /ObjStm přes flate dekodér nejlépe ještě dřív, než celkově (dokumentově) začnete reálně „bloumat“ (walk) skrz celý PDFko samotné a dekódování obsažených binárních referencí si na úplný závěr (pro svůj samotný klid na duši) představujte už jen a pouze v barvě jakési celkem gigantické (avšak dobrovolně volitelné/optional) „přesčasovky“ pro tuze unavené ruce

Jakmile se tato (strukturní) rozbalovací expanze zdárně poddá, otevřou se vašemu validátoru naplno ona pověstná/zlatá vrátka a umožní drajvovat/kočírovat nad ním kompletní finiš se zbytkem pracovních procesů (workflow). Zajímá-li vás preflight ústrojí určené zejména pro povely/ovládání přes tzv. příkazovou řádku, a ze všeho nejvíc pro hromadné oznamování statusu napříč všemi vloženými podklady hnízdícími ve složkách, nakoukněte také na náš zevrubný návod/walkthrough ke stavbě preflightového reportingu do takzvané várkové CLI verze. A má-li validační proces sehrávat roli jakéhosi pomyslného průsečíku či ochranné zdi uprostřed odlamování masivních úseků ve velikánských/kompletních dokumentech PDF formátu, velice pravděpodobně (pro spoustu z vás) bez sebemenších zádrhelů doplňují celistvé „kombo“ postupy nastíněné v tomto průvodci nad dělením/splitting PDF dokumentů napříč početnějším zástupem vícero souborových kontejnerů (do multiple files) formou (a vzorcem/paternem z ranku) load-and-check prezentovaném v tomto sezení. Vzájemně pak v souklonné/oboustranné synergii využívají (budují / staví / build on the) ověřovací plus načítací krunýř poskytovaný povrchem PDFium komponenty jak po linii Delphi, tak posléze z C++Builder luhů a hájů