Technický článek

Načítání hybridně-referenčních PDF z aplikací Word a Excel v Delphi

Otevřete PDF, které vyprodukoval Microsoft Word nebo Excel, prolistujte si ho a neuvidíte nic neobvyklého. Načtěte ho do programu v Delphi, přečtěte počet stránek a číslo bude správné. Potom jej znovu uložte se zapnutým šifrováním a úloha selže s chybou EListError, nebo se výstup otevře s varováním o poškozených křížových odkazech (cross-reference). Soubor přitom nikdy nebyl poškozený. Jde o hybridně referenční soubor (hybrid-reference) a stejná struktura, která umožňuje patnáct let starému prohlížeči jeho otevření, je přesně ta struktura, která porazí zavaděč (loader), který přestane číst příliš brzy

Toto je jeden z nejčastějších způsobů, jak se PDF pipeline, jež prošla všemi interními testy, setká se souborem, který nedokáže znovu zpracovat a uložit (round-trip). Všechny vstupní soubory byly do té doby generovány interně (in-house), takže nikdy nebyly hybridní. První hybridní soubor dorazí v den, kdy zákazník přepošle fakturu exportovanou z tabulkového procesoru

Co Word a Excel vlastně zapisují

Norma ISO 32000-1 popisuje hybridně-referenční uspořádání v §7.5.8.4. Aplikace, která vyžaduje funkce PDF 1.5, jako jsou objektové proudy (object streams), a zároveň chce umožnit čtečce PDF 1.4 otevřít soubor, zapisuje křížové odkazy dvakrát. Existuje klasická tabulka křížových odkazů – řádky ASCII o pevné šířce, které ukončovaly každé PDF až do verze 1.4 – a křížový odkazovací proud (cross-reference stream), který indexuje zbytek. Informace na konci dokumentu (trailer) u klasické sekce nese položku /XRefStm, jejíž hodnotou je bajtový ofset daného proudu

Toto rozdělení práce je záměrné. Objekty, ke kterým se musí starší čtečka dostat (jako například katalog nebo strom stránek), jsou adresovatelné z klasické tabulky. Objekty, které byly sbaleny do komprimovaných objektových proudů, jsou v klasické tabulce označeny jako volné položkou s typem f, takže je čtečka 1.4 prostě přeskočí a nikdy neklopýtne o strukturu, kterou nedokáže rozparsovat. Jejich skutečná umístění žijí pouze v křížovém odkazovacím proudu. Poznávacím znamením takového souboru je jeho samotný konec (tail): krátká klasická sekce, často jen slovo xref následované hlavičkou podsekce 0 0, jejíž trailer odkazuje na /XRefStm, kde sedí skutečná data pro obnovu

Diagram HotPDF ocasu PDF s hybridní referencí z Wordu nebo Excelu, kde klasický xref trailer nese /XRefStm 87325 ukazující zpět na stream křížových odkazů indexující formulářová pole a tagovanou strukturu neviditelné pro loader zastavující se u klasické tabulky
Hybridní ocas drží jen symbolickou klasickou tabulku, jejíž jediný payload je offset /XRefStm, zatímco streamová strana drží skutečný index objektů

Proč správný počet stránek nic nedokazuje

Protože katalog a strom stránek jsou schválně dostupné z klasické tabulky, zavaděč, který čte pouze tuto tabulku, najde /Root, projde strom stránek a ohlásí správný počet stránek. Vše, co stará čtečka potřebuje, je na místě, takže se soubor zdá být v pořádku. Chybějící objekty jsou ty, které byly zabaleny do objektových proudů: slovníky polí AcroForm, strukturní prvky pro tagged-PDF, nekonečná spousta malých slovníků, které legacy prohlížeč nikdy nemusel vidět

Tuto mezeru nezaznamenáte, dokud na tyto objekty něco nesáhne a kompletní opětovné uložení (resave) sáhne na všechny. Procházení dokumentu za účelem opětovného zašifrování nebo přepsání je přesně ta operace, která si vyžádá postupně každé číslo objektu, a to je ten důvod, proč se symptom objeví až při uložení a nikoli při načítání, tedy daleko od své příčiny

Past spočívá v detektoru, který uvidí xref a skončí

Laciným způsobem, jak rozhodnout, jak je soubor indexován, je sledovat startxref a zkontrolovat první bajty, na které odkazuje. Klíčové slovo xref znamená klasickou tabulku; stream objekt znamená křížový odkazovací proud. Tento test je správný pro každý soubor, který se zavazuje k jednomu ze schémat. U hybridního souboru je ale chybný, protože jeho startxref míří na klasickou sekci z jediného důvodu: uspokojit starší čtečky, přičemž ale /XRefStm v traileru této sekce je místem, kde je fakticky indexována většina dokumentu. Detektor, který na prvním zjištěném slově xref vrátí odpověď "klasický", už /XRefStm nikdy nečte a všechny objekty, které žijí pouze v proudu, se tak stávají neviditelnými

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // počet je správný
    // zde prohlédněte nebo upravte načtený dokument
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // prochází každý objekt
  finally
    Pdf.Free;
  end;
end;

Když je ve hře detektor, který svou práci brzy vzdá (early-exit detector), načítání vypadá v pořádku a opětovné uložení (resave) je místem, kde se chybějící objekty začnou hlásit o slovo. Řešením však není číst na začátku více bajtů; řešením je rozpoznat hybridní trailer a sledovat cestu za /XRefStm předtím, než se rozhodnete, že zpracování souboru je hotové

Pořadí sloučení nelze obejít (Merge order is not negotiable)

Jakmile jsou načteny oba indexy, mohou být sloučeny (merged) pouze jedním směrem. Křížový odkazovací proud musí být sloučen jako první a klasické položky se kolem něj musí vyplnit. Důvodem je drobný klam v samotném srdci tohoto formátu. Hybridní soubor označí v klasické tabulce své komprimované objekty jako volné, aby je staré čtečky ignorovaly. Zavaděč (loader), který ctí pravidlo „první vyhrává“ (first-seen-wins policy) a přečte klasickou tabulku jako první, zaznamená tato čísla objektů jako volná, a potom vyhodí položky z proudu, které je ve skutečnosti lokalizují, protože daná místa (slots) už jsou zkrátka obsazená. Otočte pořadí a z proudu získané záznamy typu 2 (což je vždy číslo objektového proudu plus příslušný index) si vybojují místa, která jim měla patřit, a teprve kolem nich se poskládají klasické záznamy

Stejná disciplína je ochranou před staršími revizemi křísícími už dříve smazané objekty. Inkrementální updaty se totiž řetězí pozpátku skrze parametr /Prev a prázdný záznam typu 0 je onou zmíněnou ochranou (sentinel), že některá novější sekce odeslala číslo objektu do penze. Nelze dopustit, aby pozdější starší sekce v řetězci tento hlídací prvek přepsala zastaralým (stale) umístěním. Když k principu "první vyhrává" přistoupíte autoritativně u všech zjištěných značek pro uvolněné bloky, tak smazané prostě zůstanou smazané; pokud k němu ale přistoupíte lehkovážně, tak i pouhá vlastní historie souboru dokáže oživit obsah smazaný v nejnovější revizi

Diagram HotPDF kontrastující dvě pořadí sloučení hybridních xref dat: čtení klasické tabulky nejdřív označí objekt 12 jako volný a zahodí jeho pozdní položku type 2, takže resave selže s EListError, zatímco sloučení streamu křížových odkazů nejdřív umožní každé položce obsadit svůj slot a klasická tabulka se usadí kolem ní
Čtení streamu jako prvního umožní položkám typu 2 přihlásit své sloty, takže se klasická tabulka usadí kolem nich místo aby je vymazala

Co to znamená u HotPDF

Tento engine za vás řeší hybridně-referenční soubory a dělá to na každé cestě, která musí zpracovávat data křížových odkazů. Načtěte dokument pomocí LoadFromFile nebo LoadFromStream, proveďte příslušné změny a zavolejte SaveLoadedDocument; nebo jednoduše spusťte jednorázovou operaci jako EncryptFile, která sama přečte vstup a rovnou zapíše výstup. V obou případech obnova automaticky přečte atribut /XRefStm, zkombinuje proudovou sekci (stream section) před klasickými údaji, ještě před spuštěním zápisu vyřeší i objekty vložené v proudech a teprve s tím vším je postupně očísluje. Problém se poprvé ukázal na cestě šifrování AES-256, protože šifrování dokumentu přepisuje každý objekt, a proto vyžaduje, aby každý objekt už byl lokalizován

// Jednorázově: přečte hybridní vstup a zapíše kopii zašifrovanou AES-256
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

Detail, který stojí za to si odsud odnést, leží nad samotným rozhraním API. Soubory přicházející z Wordu, Excelu, PowerPointu a dlouhého seznamu pipeline typu „Uložit jako PDF“ bývají hybridní běžně, takže zavaděč testovaný jen proti výstupu vlastního generátoru se v testování s hybridním souborem možná nikdy nesetká. Naplňte své testovací sady dokumenty exportovanými z reálných aplikací Office, ne jen soubory, které vytvořil váš vlastní kód

Zkoumání podezřelého souboru

Dvě prohlídky dokáží otázku vyřešit rychle. Otevřete soubor v hexovém zobrazení a přečtěte bajty za posledním startxref; hybridní soubor ukáže krátkou klasickou sekci, jejíž slovník traileru obsahuje /XRefStm. Nebo porovnejte počet objektů, který nahlásí plný parse, s nejvyšším číslem objektu, které v traileru deklaruje /Size. Velká mezera znamená, že se objekty skrývají v proudech, které zavaděč neotevřel, což je tentýž schodek, který se později promění v selhání při ukládání

Konec typického exportu z Excelu dělá z první kontroly konkrétní záležitost. Vše za posledním klíčovým slovem xref je čisté ASCII, takže podpis přečtete rovnou z hexového zobrazení (ofsety ilustrativní, poznámky přidány)

xref
0 0                          % prázdná klasická podsekce: žádné řádky
trailer
<< /Size 216                 % o jedničku za nejvyšším použitým číslem objektu
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % bajtový offset cross-reference streamu
>>
startxref
88710                        % ukazuje na výše uvedenou klasickou sekci
%%EOF

Podsekce 0 0 je poznávacím znamením: klasická tabulka s nulou položek existuje jen proto, aby nesla trailer, a trailer existuje hlavně proto, aby řekl /XRefStm 87325. Detektor, který se zastaví u klíčového slova xref, v tomto okamžiku vidí index ničeho. Když raději napíšete kontrolu jako skript, než abyste ji hodnotili okem, značka vždy sedí v posledních několika kilobajtech souboru, takže stačí omezené zpětné čtení

// Vrací ofset /XRefStm z konce souboru, nebo -1, pokud
// značka chybí (soubor není hybridní, nebo není PDF vůbec)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // trailer sídlí v konci souboru
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // omezené zpětné čtení: maximálně 2 KB
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // v konci souboru není hybridní značka
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // přeskočte bílé znaky za klíčem
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Použití: nezáporný výsledek udává bajt, kde proud začíná
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Tento průzkum (probe) berte jako třídění (triage), nikoli jako parsování: řekne vám jen to, které soubory v dávce vyžadují pozornost předtím, než se spustí proces opětovného uložení, a nic víc. Co potom musí zavaděč udělat s ofsetem, který najde, sledovat řetězec sekcí, sloučit položky proudu před těmi klasickými a respektovat hlídací prvky volných položek, to krok za krokem prochází náš doprovodný článek o manipulaci s hybridně referenčními PDF z aplikací sady Office v Delphi

Zapisovací strana tohoto příběhu, tedy jak se objektové proudy a komprimované křížové odkazy vůbec vytvářejí, je popsána v našem článku o objektových proudech a inkrementálních aktualizacích. Když je dotyčný hybridní soubor zároveň velmi velký, techniky načítání z průvodce rozhraním Direct File API pro rozsáhlá PDF workflow vám umožní jej prozkoumat, aniž byste ho museli celý načítat do paměti. Obojí se přirozeně doplňuje s procesem obnovy popsaným v tomto článku, který se dodává jako součást komponenty HotPDF pro Delphi a C++Builder, bok po boku s API pro načítání, editaci, šifrování a podepisování, o kterých se na tomto blogu píše jinde