Technical Article

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

Chyby kontroly rozsahu (range check errors) v PDF knižniciach pre Delphi majú povesť ťažko vystopovateľných problémov, pretože nesledujú konzistentný vzor vstupu. Ten istý dokument ich môže spôsobiť na jednom stroji a na druhom nie; rovnaká cesta v kóde vyvolá výnimku pri 3-stranovom súbore, ale pri 12-stranovom prebehne bez chýb. Táto nekonzistentnosť má takmer vždy jedinú hlavnú príčinu: objekty stránok PDF nie sú v súbore uložené v logickom poradí. Ak knižnica vytvára svoje interné pole stránok sekvenčným skenovaním objektov namiesto prechádzania stromu stránok deklarovaného katalógom, vytvorí index, ktorého platný rozsah nezodpovedá tomu, čo volajúci očakáva, a kontrola rozsahu zachytí tento nesúlad v najmenej vhodnom okamihu.

Ako funguje kontrola rozsahu v Delphi

S aktívnou direktívou kompilátora {$R+} (predvolené v konfigurácii Debug) overuje behové prostredie Delphi (RTL) pri spustení každý index poľa, podreťazec a priradenie enumerátora. Prístup mimo rozsahu vyvolá výnimku ERangeError namiesto tichého čítania susednej pamäte. Toto správanie je veľmi užitočné: odhalí skryté chyby včas namiesto toho, aby poškodili dátovú štruktúru, ktorá zlyhá až o sto riadkov neskôr. Frustrujúce na tom je, že výnimka sa vyvolá na mieste prístupu, a nie v bode, kde bol index nesprávne vypočítaný. Keď zásobník volaní (call stack) ukazuje na hlboko vnorenú metódu v jednotke PDF, skutočná chyba je zvyčajne o niekoľko úrovní vyššie.

Zložené boolovské podmienky to ešte zhoršujú. Delphi vyhodnocuje výrazy and zľava doprava s pravidlom skráteného vyhodnocovania (short-circuit), ale to preskočí vyhodnocovanie 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, ale chráni pred indexom mimo rozsahu iba vtedy, ak je FDocStarted nastavené na True a DestIndex je nezáporný. Kontrola DestIndex < Length(PageArr) nerobí nič, keď je DestIndex záporný, pretože porovnanie záporného čísla s nezápornou dĺžkou v znamienkovej aritmetike vráti True a následný prístup k poľu stále vyvolá chybu rozsahu. Správnym riešením je presunutie kontroly hraníc na najvyššiu úroveň:

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 DestIndex na začiatku dostal hodnotu mimo platného rozsahu.

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

ISO 32000-1 §7.7.3 definuje strom stránok ako strom uzlov Pages, ktorých polia Kids uvádzajú objekty stránok v poradí zobrazenia. Súbor ukladá tieto objekty na ľubovoľné posuny (offsets), ktoré si zapisovač zvolil; objekt číslo 20 môže v bajtovom prúde fyzicky predchádzať objektu číslo 3. Knižnica, ktorá vytvára svoj zoznam stránok prechádzaním tabuľky krížových odkazov v poradí podľa čísiel objektov namiesto sledovania reťazca Kids, vytvorí sekvenciu, ktorá sa líši od očakávaní používateľa. Pri dokumentoch, kde generátor zapísal stránky v správnom poradí, všetko funguje. Pri dokumentoch, kde to tak nie je, nesúlad medzi číslovaním stránok knižnice a číslovaním volajúceho vygeneruje indexy, ktoré spadajú mimo PageArr.

Správnym prístupom je začať v katalógu, vyriešiť nepriamy odkaz na /Pages a rekurzívne prejsť pole Kids. Pre plochý dokument bez prechodných uzlov Pages je prechod 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 tomto spustení je PageArr[0] prvá stránka, ktorú by prehliadač zobrazil, bez ohľadu na to, kde sa tento objekt nachádza v bajtovom prúde. Indexy odovzdané volajúcimi, ktorí predpokladajú poradie zobrazenia, sa teraz mapujú správne a chyby kontroly rozsahu ustanú.

Natvrdo zakódované obchádzanie problému ho len zhoršuje

V kódových základniach, kde sa skutočná príčina nikdy neidentifikovala, sa často nachádzajú heuristické záplaty: výmena prvej a poslednej stránky, ak je celkový počet 3, otočenie indexu pre dokumenty z konkrétneho generátora alebo aplikovanie posunu, keď prvé číslo objektu prekročí prahovú hodnotu. Každá z týchto záplat presne vyhovuje testovacím súborom, ktoré boli k dispozícii pri jej písaní. Pridajte však iný zdroj PDF a jedna zo záplat sa spustí v nesprávnom čase, čím sa vytvorí index, ktorý je teraz dvakrát nesprávny: nesprávny, pretože bol vypočítaný z neusporiadaného poľa, a znova nesprávny, pretože sa naviac aplikovalo nepoužiteľné mapovanie. Kontrola rozsahu to zachytí niekde neskôr v toku a výpis zásobníka neukáže nič užitočné.

Jedinou efektívnou cestou je odstrániť každé heuristické mapovanie a nahradiť vytváranie poľa stránok správnym prechodom stromu. Akonáhle sú indexy zo svojej podstaty správne, žiadne záplaty nie sú potrebné a kontrola rozsahu sa stane prínosom, a nie prekážkou.

Ak udržiavate knižnicu vykazujúcu toto správanie, dočasne povoľte kontrolu rozsahu v produkčnom (Release) zostavení a spustite ju nad rôznorodým korpusom súborov PDF: dokumenty vytvorené vo Worde, LaTeXom, firmvérom skenera alebo utilitami na rozdeľovanie PDF. Súbory, ktoré spúšťajú výnimky, sú tie, ktorých poradie objektov stránok sa líši od poradia prechodu, ktoré váš kód predpokladá. Každý takýto súbor predstavuje cenný testovací prípad, nie samostatnú chybu.

Pre nový kód, ktorý volá knižnicu PDF v Delphi, je praktickou radou považovať počet stránok knižnice za autoritatívny a nikdy neodovzdávať index odvodený z aritmetiky na externých dátach bez predchádzajúceho overenia, že spadá do rozsahu 0..PageCount - 1. Komponent HotPDF component sprístupňuje vyriešený počet stránok prostredníctvom vlastnosti THotPDF.PageCount po BeginDoc alebo po načítaní dokumentu; táto hodnota vždy odráža prechod stromom stránok a je bezpečne ju použiť ako hornú hranicu pre akúkoľvek indexovú aritmetiku.