Odborný článok

Chyby kontroly rozsahu v PDF knižniciach pre Delphi: Hlavné príčiny

Chyby kontroly rozsahu (range check errors) v knižniciach pre prácu s PDF v prostredí Delphi si vyslúžili povesť ťažko odhaliteľných chýb, pretože nesledujú konzistentný vzor vstupu. Ten istý dokument ich vyvolá na jednom stroji, ale na inom nie; rovnaká cesta kódom spustí výnimku pri 3-stranovom súbore, ale prejde bez problémov pri 12-stranovom. Takáto nekonzistentnosť takmer vždy poukazuje na jedinú hlavnú príčinu: objekty stránok PDF nie sú uložené v poradí v súbore. Ak knižnica buduje svoje vnútorné pole stránok postupným skenovaním objektov, a nie prehľadávaním stromu stránok deklarovaného v katalógu, vytvára tak index, ktorého platný rozsah nezodpovedá očakávaniam volajúcich (callers). Následne kontrola rozsahu zachytí tento nesúlad v tom najhoršom možnom okamihu

Ako funguje kontrola rozsahu v Delphi

Pri aktívnej direktíve kompilátora {$R+} (predvolené nastavenie v konfigurácii Debug), RTL v Delphi počas behu (at runtime) validuje každý index poľa, znak v reťazci a priradenie z vymenovaného typu. Prístup mimo hraníc vyvolá výnimku ERangeError namiesto toho, aby potichu čítal susednú pamäť. Toto správanie je cenné: odhaľuje skryté chyby zavčasu, namiesto toho, aby ich nechal poškodiť dátovú štruktúru, čo by zlyhalo až o sto riadkov neskôr. Frustrujúce na tom je, že výnimka sa spúšťa na mieste prístupu, a nie v bode, kde bol index nesprávne vypočítaný. Keď call stack (zásobník volaní) ukazuje na hlboko vnorenú metódu v PDF unite, skutočná chyba sa zvyčajne nachádza o niekoľko rámcov späť

Zložité booleovské podmienky to ešte zhoršujú. Delphi vyhodnocuje výrazy and zľava doprava so sémantikou skráteného vyhodnocovania (short-circuit), ale skrátené vyhodnocovanie preskočí ďalšie podmienky iba vtedy, ak je ľavá strana False. Výraz ako:

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

vyzerá bezpečne, no chráni pred indexom mimo rozsahu iba vtedy, ak je FDocStarted na hodnote True a DestIndex je nezáporný. Kontrola DestIndex < Length(PageArr) neurobí nič v prípade, keď je DestIndex záporný, pretože porovnanie záporného celého čísla s nezápornou dĺžkou vráti v aritmetike so znamienkom True a následný prístup k poľu aj tak vyvolá chybu rozsahu. Presunutie kontroly hraníc na najvrchnejšiu pozíciu je správnou opravou:

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 aplikácie. Nevysvetľuje však, prečo hodnota DestIndex vôbec dostala hodnotu mimo platného rozsahu

Skutočná príčina: poradie objektov vs. poradie stránok

Norma ISO 32000-1 v odseku §7.7.3 definuje strom stránok ako strom uzlov typu Pages, ktorých polia Kids tvoria zoznam objektov stránok v poradí ich zobrazenia. Súbor uchováva tieto objekty na takých ofsetoch, aké si pisateľ (writer) náhodne zvolil; objekt číslo 20 môže fyzicky predchádzať objektu číslo 3 v dátovom toku. Knižnica, ktorá buduje svoj zoznam stránok iteráciou tabuľky krížových odkazov podľa čísla objektu, a nie sledovaním reťazca Kids, vytvorí sekvenciu, ktorá sa odchyľuje od toho, čo očakáva používateľ. Pri dokumentoch, kde generátor náhodou zapísal stránky poporadí, všetko funguje. Pri dokumentoch, kde to tak nie je, rozdiel medzi číslovaním stránok knižnice a číslovaním stránok volajúceho (caller) vytvára indexy, ktoré spadajú mimo poľa PageArr

Správnym prístupom je začať od katalógu, vyriešiť nepriamu referenciu na /Pages a rekurzívne prehľadať pole Kids. Pri plochom dokumente bez prostredných uzlov Pages je takýto prechod (traversal) priamočiary:

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
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Po dokončení tohto kódu predstavuje PageArr[0] prvú stranu, ktorú by prehliadač zobrazil, bez ohľadu na to, kde sa tento objekt nachádza v dátovom toku. Indexy predané volajúcimi, ktorí predpokladajú poradie zobrazenia, sa teraz správne namapujú a chyby rozsahu sa už nebudú objavovať

Natvrdo zakódované obchádzky problém len zhoršujú

V zdrojových kódoch, kde hlavná príčina nebola nikdy identifikovaná, je bežné nájsť heuristické záplaty: zameňte prvú a poslednú stranu, ak sa celkový počet rovná 3, otočte index pre dokumenty zo špecifického generátora, použite posun (offset), keď prvé číslo objektu prekročí určitú hranicu. Každá z týchto záplat presne sedí na súbor testovacích dát, ktoré boli k dispozícii v čase jej písania. Pridajte odlišný zdroj PDF a jedna zo záplat sa aktivuje v nesprávnom čase, čím vyprodukuje index, ktorý je teraz dvojnásobne nesprávny: nesprávny preto, lebo bol vypočítaný z poľa s pomiešaným poradím, a znova nesprávny preto, lebo sa na neho následne aplikovalo neplatné mapovanie. Nástroj na kontrolu rozsahu to zachytí niekde neskôr a trasovanie zásobníka (stack trace) neukáže nikam, kde by to bolo užitočné

Jedinou produktívnou cestou je odstrániť všetky heuristické mapovania a nahradiť zostavenie poľa stránok náležitým prechodom stromom (tree walk). Keď už sú indexy správne na základe svojho zostavenia, nie sú potrebné žiadne záplaty a kontrola rozsahu sa stáva výhodou, nie prekážkou

Ak spravujete knižnicu, ktorá vykazuje tento vzor, povoľte kontrolu rozsahu v konfigurácii Release dočasne a spustite ju proti rôznorodému súboru testovacích PDF dokumentov: dokumentom vytvoreným vo Worde, pomocou LaTeX-u, firmvérom skenera, utilitami na delenie PDF na PDF. Súbory spúšťajúce výnimky predstavujú tie, u ktorých sa poradie objektov na strane odlišuje od poradia vyhľadávania predpokladaného vaším kódom. Každý jeden z nich je len ďalším údajovým bodom a nie samostatnou chybou

Praktická rada v prípade nového kódu, ktorý vyvoláva funkciu z knižnice PDF Delphi, hovorí o tom, že je treba pristupovať k počítaniu stránok z pohľadu knižnice ako k autoritatívnemu úkonu a nikdy by sa nemal ďalej posúvať index odvodený z aritmetickej operácie s externými údajmi pred potvrdením toho, či zapadá medzi 0..PageCount - 1. Komponent HotPDF odkrýva počet strán po vyriešení skrz THotPDF.PageCount po vykonaní BeginDoc alebo prípadnom nahraní daného dokumentu; táto hodnota bude už vždycky vyjadrovať prehľadávanie v strome s jeho bezpečnými predpokladmi v roli hornej ohraničujúcej hranice počas akéhokoľvek kroku na indexovanie algoritmu