Technical Article

Validace komprimovaných PDF: proudy objektů a křížových odkazů

Napíšete malý validátor. Otevře PDF, přejde na konec, najde startxref, přečte posun 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í posuny objektů a poté skenuje pozpátku klíčové slovo trailer, aby zjistil /Root a /Size. Funguje to perfektně u každého souboru, který jste vygenerovali pro testování. Poté však dorazí soubor vytvořený aktuální verzí aplikace Word nebo knihovnou cílící na PDF 1.5 a validátor jej označí za poškozený. Tam, kam ukazuje posun, není klíčové slovo xref, adresář trailer nikde a tabulka objektů, kterou validátor sestavil, je téměř prázdná. Soubor je přitom platný. Validátor jej čte patnáct let starou optikou.

Jedná se o nejčastější důvod, proč kontrola PDF na úrovni bajtů napsaná proti klasickému rozvržení selhává na moderních dokumentech. Struktura, na které závisí (textová tabulka křížových odkazů a klíčové slovo trailer), se v PDF 1.5 stala volitelnou a často chybí. Nahradily ji dvě funkce: proud křížových odkazů (cross-reference stream) a proud komprimovaných objektů (compressed object stream). Obě jsou popsány v 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ěnil na konci souboru

ISO 32000-1 §7.5.8 definuje proud křížových odkazů a §7.5.7 definuje proud objektů typu /ObjStm. Společně umožňují zapisovači vynechat obě struktury, na které se klasický parser spoléhá. Soubor PDF 1.5 nemusí končit vůbec žádnou tabulkou xref. Místo ní je objekt, na který ukazuje startxref, běžným objektem proudu, jehož adresář nese /Type /XRef, a tento proud obsahuje data křížových odkazů v kompaktní binární podobě. Neexistuje ani klíčové slovo trailer, protože trailerem je nyní samotný adresář proudu. Klíče, které klasický parser vyhledával (/Root, /Size a /ID), žijí uvnitř tohoto adresáře.

Druhá změna přesouvá samotné objekty. Namísto zápisu každého nepřímého objektu na jeho vlastním bajtovém posunu může zapisovač zabalit mnoho malých objektů (adresáře stránek, adresáře anotací, strom struktury) do jediného proudu objektů a komprimovat celý kontejner pomocí Flate. Jednotlivé objekty již nemají v souboru bajtový posun. Mají pozici uvnitř komprimovaného bloku (blob). Validátor skenující surové bajty na řetězec 1 0 obj je nikdy nenajde, protože tento text existuje až po dekompresi. Pro klasický parser polovina dokumentu jednoduše zmizela.

Klíče traileru jsou čitelné jako text, a to i v komprimovaném souboru

Uklidňující zprávou je, že čtení traileru proudu křížových odkazů nevyžaduje dekompresi vůbec ničeho. Objekt proudu se zapisuje jako adresář následovaný klíčovým slovem stream a poté komprimovanými bajty. Adresář je v textové podobě (plaintext). Takže když startxref ukazuje na proud křížových odkazů, bajty bezprostředně za číslem objektu vypadají jako běžný adresář a /Root, /Size a /ID se nacházejí jasně viditelné předtím, než začne klíčové slovo stream a data Flate.

Proudy objektů: hlavička, poté Flate blob

Proud objektů je kontejner. Jeho adresář nese /Type /ObjStm, položku /N udávající počet objektů zabalených uvnitř a položku /First udávající bajtový posun v rámci dekomprimovaných dat, kde začíná tělo prvního objektu. Komprimovaný obsah po dekompresi začíná malou hlavičkou s /N dvojicemi celých čísel. Každá dvojice představuje číslo objektu a posun těla tohoto objektu vzhledem k /First. Za hlavičkou následují samotná těla objektů spojená za sebou.

Rozbalení jednoho z nich je po dekompresi bajtů mechanickou záležitostí. Přečtete adresář, abyste získali /N a /First, dekomprimujete proud pomocí dekompresoru Flate, projdete úvodní /N dvojice, abyste zjistili, které číslo objektu žije na kterém posunu, a poté vytáhnete každé tělo, jako by to byl běžný nepřímý objekt. Jedinou skutečnou závislostí je dekompresor Flate, a ten již máte: Delphi dodává System.ZLib a Free Pascal dodává jednotku zstream, přičemž obojí balí zlib a dekomprimuje surový proud Flate bez jakéhokoli kódu třetích stran. Rutina, která připojí každý extrahovaný objekt do tabulky objektů validátoru, zajistí, že zbytek validátoru (část procházející /Root a kontrolující strom stránek) se chová přesně tak, jako u klasického souboru.

Co nemusíte implementovat

Je snadné přecenit množství práce. Čtení klíčů traileru z komprimovaného souboru nevyžaduje dekódování binárních položek proudu křížových odkazů. Proud křížových odkazů podle §7.5.8 používá tři typy položek a položka typu 2, která říká "tento objekt žije uvnitř proudu objektů N na indexu i", je tím, co byste dekódovali pro sestavení kompletní mapy posunů. Tuto mapu potřebujete k vyhledání libovolných objektů podle čísla. Nepotřebujete ji k čtení /Root, /Size a /ID, které jsou v textovém adresáři, a nepotřebujete ji ani k rozbalení proudů objektů, protože každý /ObjStm ohlašuje svůj vlastní obsah prostřednictvím /N a /First.

Nemusíte také řešit predikční funkce PNG a TIFF, které může proud křížových odkazů aplikovat přes /DecodeParms jen pro získání klíčů traileru. Prediktory filtrují binární řádky křížových odkazů, aby se lépe komprimovaly; nemají nic společného s adresářem, který předchází proudu. Minimální upgrade, který naučí klasický validátor pracovat s moderními PDF, je proto malý: když startxref přistane na proudu namísto klíčového slova xref, analyzujte adresář proudu na klíče traileru a rozbalte všechny nalezené objekty /ObjStm, aby se jejich obsah dostal do tabulky objektů. Dekódování položek typu 2 a prediktorů je samostatný, větší úkol, který můžete odložit, dokud nebudete skutečně potřebovat vyhledávání libovolných objektů.

Proč kontrola kompatibility musí nejprve rozbalit proudy

Tato otázka přestává být akademickou v okamžiku, kdy spustíte kontrolu profilu. Validátor PDF/A nebo PDF/X kontroluje konkrétní objekty: katalog dokumentu na pole /OutputIntents, proud /Metadata na balíček XMP se správným identifikátorem, každý popisovač písma na vložený soubor písma, trailer na /ID. V komprimovaném souboru je většina těchto objektů uvnitř proudů objektů. Validátor, který nerozbalil proudy objektů, nevidí klíče katalogu, nemůže najít metadata a nemůže jmenovat písma. Nahlásí zcela vyhovující dokument jako dokument bez záměru výstupu, bez XMP a bez poloviny jeho struktury, protože důkaz, který potřebuje, stále leží v bloku Flate, který nikdy nedekomprimoval.

Na pořadí záleží. Rozbalení musí proběhnout před spuštěním kontrol, nikoli souběžně s nimi, protože každá kontrola předpokládá, že se k objektu dostane podle čísla. Pokud připojíte kontrolu profilu přímo na skenování surových bajtů, zdědí slepotu klasického parseru a vygeneruje falešná porušení právě na moderních souborech, u kterých je nejpravděpodobnější, že jsou správně vytvořeny, protože pocházejí z nástrojů dostatečně nových na to, aby vůbec zapisovaly proudy křížových odkazů.

Jak nechat PDFium provést analýzu za vás

Produkt PDFium Component analyzuje proudy křížových odkazů a objektů jako součást načítání dokumentu, což je praktický způsob, jak se vyhnout ručnímu psaní kroku dekomprese a rozbalení. Když načtete soubor pomocí komponenty TPdf, objekty zabalené v kontejnerech /ObjStm jsou již vyřešeny a validační vstupní body vidí plně rozbalený dokument. ValidatePdfA vrací záznam TPdfAValidationResult, jehož pole Conformance je hodnota TPdfAConformance (například pac1b nebo pacNone), jehož pole Issues je sada konkrétních nalezených problémů a jehož metoda IsCompliant je pravdivá pouze tehdy, když byla detekována úroveň kompatibility a sada problémů je prázdná. Protože objekty byly rozbaleny během načítání, pole /OutputIntents nebo vložené písmo, které žilo uvnitř proudu objektů, se najde a není hlášeno jako chybějící.

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éž platí pro ValidatePdfX, které vrací TPdfXValidationResult se stejným tvarem. Smyslem směrování přes PDFium je, že strukturální dekomprese popsaná výše proběhne jednou a správně uvnitř zavaděče, takže váš validační kód nikdy neuvidí rozdíl mezi klasickým souborem a plně komprimovaným. Oba přicházejí k validátoru jako vyřešená sada objektů.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
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: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Pokud jsou bajty již v paměti namísto na disku, stejná sekvence načtení a následné validace funguje přes přetížení LoadDocument(const Data: TBytes), které přebírá nezpracovaný obsah souboru a analyzuje jeho proudy křížových odkazů a objektů stejně jako v případě cesty k souboru. Ponaučení pro ručně psaný validátor je strukturální pravidlo, nikoli samotné API: přečtěte si klíče traileru z adresáře proudu v textové podobě, rozbalte každý /ObjStm pomocí dekompresoru Flate před procházením dokumentu a k dekódování binárních položek křížových odkazů přistupujte jako k většímu, volitelnému úkolu.

Jakmile je struktura rozbalena, validátor nad ní může řídit zbytek pracovního postupu. Pro nástroj příkazového řádku preflight, který hlásí kompatibilitu v celé složce vstupů, viz náš průvodce vytvořením CLI pro dávkový preflight report. Když validace je krokem před rozdělením velkého dokumentu, postupy v našem průvodci rozdělením dokumentů PDF do více souborů se přirozeně párují se zde popsaným vzorem načtení a kontroly. Obojí staví na načítacím a validačním rozhraní produktu PDFium Component pro Delphi and C++Builder.