Technický článek

Chyby kontroly rozsahu v PDF knihovnách pro Delphi: příčiny

Chyby kontroly rozsahu v PDF knihovnách pro Delphi si vysloužily pověst obtížně dohledatelných, protože nesledují žádný konzistentní vzor vstupu. Stejný dokument je na jednom počítači vyvolá a na jiném ne; stejná cesta kódu vyhodí výjimku u třístránkového souboru, ale u dvanáctistránkového proběhne čistě. Tato nekonzistence se téměř vždy dá vystopovat k jediné kořenové příčině: objekty stránek PDF nejsou uloženy v pořadí souboru. Pokud si knihovna sestavuje interní pole stránek sekvenčním procházením objektů místo průchodu stromem stránek deklarovaným v katalogu, vytvoří index, jehož platný rozsah neodpovídá tomu, co volající očekávají, a kontrola rozsahu tento nesoulad zachytí v tu nejhorší možnou chvíli

Jak funguje kontrola rozsahu v Delphi

S aktivní direktivou kompilátoru {$R+} (výchozí v konfiguraci Debug) RTL Delphi za běhu ověřuje každý index pole, index do řetězce i přiřazení výčtového typu. Přístup mimo meze vyvolá ERangeError, místo aby tiše četl sousední paměť. Toto chování je cenné: odhaluje latentní chyby brzy, místo aby jim dovolilo poškodit datovou strukturu, která selže až o sto řádků dál. Frustrující je, že výjimka se vyhodí v místě přístupu, nikoli tam, kde byl index chybně vypočítán. Když zásobník volání ukazuje hluboko vnořenou metodu v PDF unitě, skutečná chyba je obvykle o několik rámců zpět

Složené booleovské podmínky to zhoršují. Delphi vyhodnocuje výrazy s and zleva doprava se zkrácenou sémantikou, ale zkrácení přeskočí vyhodnocení jen tehdy, když je levá strana False. Výraz jako:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

vypadá bezpečně, ale proti indexu mimo rozsah chrání jen tehdy, když FDocStarted je True a DestIndex je nezáporný. Kontrola DestIndex < Length(PageArr) nedělá nic, když je DestIndex záporný, protože porovnání záporného celého čísla s nezápornou délkou vrátí ve znaménkové aritmetice True a následný přístup do pole stejně vyvolá chybu rozsahu. Správnou opravou je přesunout kontrolu mezí na nejvzdálenější vnější pozici:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

Toto je mechanická oprava. Zastaví pád. Nevysvětluje, proč DestIndex vůbec dostal hodnotu mimo platný rozsah

Vývojový diagram chyby kontroly rozsahu v Delphi v PDF knihovně, kde záporný DestIndex obejde jednostrannou kontrolu mezí a vyvolá ERangeError při přístupu do pole, ve srovnání s opraveným pořadím ochranných podmínek, které se bezpečně zkrátí
Při $R+ RTL ověřuje každý index za běhu, takže záporný index přežije holý test horní meze a vyhodí výjimku až při samotném přístupu do pole. Otestování znaménkové dolní meze jako první umožní zkrácenému vyhodnocení index odmítnout dřív, než se dotkne jakéhokoli prvku

Skutečná příčina: pořadí objektů versus pořadí stránek

ISO 32000-1 §7.7.3 definuje strom stránek jako strom uzlů Pages, jejichž pole Kids uvádějí objekty stránek v pořadí zobrazení. Soubor tyto objekty ukládá na libovolných offsetech, které si zapisovač zrovna zvolil; objekt číslo 20 může v bajtovém proudu fyzicky předcházet objektu číslo 3. Knihovna, která sestavuje seznam stránek iterací tabulky křížových odkazů v pořadí čísel objektů místo sledování řetězce Kids, vytvoří posloupnost, která se rozchází s tím, co uživatel očekává. U dokumentů, kde generátor náhodou zapsal stránky v pořadí, všechno funguje. U dokumentů, kde ne, vytváří rozpor mezi číslováním stránek v knihovně a číslováním stránek u volajícího indexy, které padají mimo PageArr

Diagram pro Delphi porovnávající pořadí uložení objektů PDF zjištěné skenem křížových odkazů s pořadím zobrazení obnoveným průchodem polem Kids stromu stránek z katalogu
Sekvenční skeny číslují stránky v libovolném pořadí, které zvolil zapisovač, zatímco prohlížeče sledují pole Kids stromu stránek. Sestavení pole stránek z průchodu katalogem udrží indexy volajících zarovnané a v rozsahu

Správný přístup je začít od katalogu, vyhodnotit nepřímý odkaz /Pages a rekurzivně projít pole Kids. U plochého dokumentu bez mezilehlých uzlů Pages je průchod přímočarý:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // mezilehlý uzel: rekurzivně projít jeho Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Po jeho proběhnutí je PageArr[0] první stránkou, kterou by prohlížeč zobrazil, bez ohledu na to, kde daný objekt v bajtovém proudu leží. Indexy předávané volajícími, kteří předpokládají pořadí zobrazení, se nyní mapují správně a chyby rozsahu přestanou

Natvrdo zakódovaná obejití problém násobí

V kódových základnách, kde kořenová příčina nikdy nebyla identifikována, se běžně nacházejí heuristické záplaty: prohodit první a poslední stránku, když je celkový počet roven 3, otočit index u dokumentů od konkrétního generátoru, aplikovat posun, když číslo prvního objektu překročí práh. Každá z těchto záplat sedí přesně na sadu testovacích souborů, které byly po ruce v době jejího napsání. Přidejte jiný zdroj PDF a jedna ze záplat se spustí v nesprávnou chvíli a vytvoří index, který je nyní chybný dvojnásob: chybný proto, že byl vypočítán z pole v nesprávném pořadí, a znovu chybný proto, že navrch bylo aplikováno nepoužitelné mapování. Kontrola rozsahu to zachytí někde dál po proudu a trasování zásobníku neukazuje nikam užitečně

Jedinou produktivní cestou je odstranit každé heuristické mapování a nahradit sestavení pole stránek řádným průchodem stromem. Jakmile jsou indexy správné už z konstrukce, žádné záplaty nejsou potřeba a kontrola rozsahu se stává přínosem, nikoli překážkou

Pokud udržujete knihovnu, která tento vzor vykazuje, dočasně zapněte kontrolu rozsahu v sestavení Release a spusťte ji proti různorodému korpusu PDF: dokumentům vytvořeným Wordem, LaTeXem, firmwarem skenerů, utilitami pro rozdělování PDF na PDF. Soubory, které vyvolají výjimky, jsou ty, jejichž pořadí objektů stránek se rozchází s pořadím průchodu, které váš kód předpokládá. Každý z nich je datový bod, ne samostatná chyba

Pro nový kód, který volá PDF knihovnu pro Delphi, zní praktická rada: považujte počet stránek hlášený knihovnou za směrodatný a nikdy nepředávejte index odvozený aritmetikou z externích dat, aniž byste nejprve ověřili, že spadá do 0..PageCount - 1. Komponenta HotPDF pro Delphi zpřístupňuje vyhodnocený počet stránek přes THotPDF.PageCount po BeginDoc nebo po načtení dokumentu; tato hodnota vždy odráží průchod stromem stránek a lze ji bezpečně použít jako horní mez pro jakoukoli aritmetiku indexů