Technical Article

Pogreške provjere raspona u Delphi PDF knjižnicama: Temeljni uzroci

Pogreške provjere raspona (range check errors) u Delphi PDF knjižnicama na glasu su kao teške za locirati jer ne prate dosljedan uzorak unosa. Isti ih dokument proizvodi na jednom računalu, a na drugom ne; isti kôd aktivira iznimku na datoteci od 3 stranice, ali radi bez problema na datoteci od 12 stranica. Ta nedosljednost gotovo uvijek upućuje na jedan temeljni uzrok: PDF objekti stranica nisu pohranjeni redoslijedom datoteke. Ako knjižnica gradi svoje interno polje stranica sekvencijalnim skeniranjem objekata umjesto obilaskom stabla stranica koje je deklarirao katalog, ona stvara indeks čiji se valjani raspon ne podudara s onim što pozivatelji očekuju, a provjera raspona hvata to neslaganje u najgorem mogućem trenutku.

Kako provjera raspona radi u Delphiju

S aktivnom kompajlerskom direktivom {$R+} (što je zadano u Debug konfiguraciji), Delphi RTL provjerava valjanost svakog indeksa polja, indeksa znakova u nizu i dodjele nabrajanja u vrijeme izvođenja. Pristup izvan granica pokreće iznimku ERangeError umjesto da tiho čita susjednu memoriju. To je ponašanje dragocjeno: rano otkriva skrivene bugove umjesto da im dopusti da oštete strukturu podataka koja zakaže tek stotinu redaka kasnije. Frustrirajući dio je to što se iznimka aktivira na mjestu pristupa, a ne na mjestu gdje je indeks pogrešno izračunat. Kada stog poziva (call stack) pokazuje duboko ugniježđenu metodu u PDF jedinici, stvarna pogreška je obično nekoliko koraka unatrag.

Složeni logički uvjeti to čine još gorim. Delphi procjenjuje and izraze slijeva nadesno s logikom kratkog spoja (short-circuit), ali kratki spoj samo preskače procjenu kada je lijeva strana False. Izraz poput:

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

izgleda sigurno, ali štiti od indeksa izvan raspona samo ako je FDocStarted jednak True i ako je DestIndex nenegativan. Provjera DestIndex < Length(PageArr) ne čini ništa kada je DestIndex negativan, jer usporedba negativnog cijelog broja s nenegativnom duljinom vraća True u označenoj aritmetici, a kasniji pristup polju i dalje pokreće pogrešku raspona. Pomicanje provjere granica na najudaljeniju poziciju je ispravan popravak:

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]);

Ovo je mehanički popravak. Zaustavlja rušenje programa. Međutim, to ne objašnjava zašto je DestIndex uopće dobio vrijednost izvan valjanog raspona.

Stvarni uzrok: Redoslijed objekata nasuprot redoslijedu stranica

Standard ISO 32000-1 §7.7.3 definira stablo stranica kao stablo čvorova Pages čija polja Kids navode objekte stranica redoslijedom prikaza. Datoteka pohranjuje te objekte na pomacima koje je pisac odlučio koristiti; objekt broj 20 može fizički prethoditi objektu broj 3 u toku bajtova. Knjižnica koja gradi svoj popis stranica prolazeći kroz tablicu unakrsnih referenci redoslijedom brojeva objekata, umjesto da prati lanac Kids, proizvest će slijed koji se razlikuje od onoga što korisnik očekuje. Na dokumentima gdje je generator slučajno zapisao stranice po redu, sve radi. Na dokumentima gdje to nije bio slučaj, neslaganje između numeriranja stranica knjižnice i numeriranja pozivatelja proizvodi indekse koji padaju izvan granica polja PageArr.

Ispravan pristup je krenuti od kataloga, razriješiti neizravnu referencu /Pages i rekurzivno proći polje Kids. Za ravan dokument bez međučvorova Pages, obilazak je jednostavan:

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;

Nakon pokretanja ovoga, PageArr[0] je prva stranica koju bi preglednik prikazao, bez obzira na to gdje se taj objekt nalazi u toku bajtova. Indeksi koje prosljeđuju pozivatelji koji pretpostavljaju redoslijed prikaza sada se ispravno mapiraju i pogreške raspona prestaju.

Hardkodirana zaobilazna rješenja pogoršavaju problem

U bazama koda gdje stvarni uzrok nikada nije identificiran, uobičajeno je pronaći heurističke zakrpe: zamijeni prvu i zadnju stranicu ako je ukupni broj jednak 3, rotiraj indeks za dokumente određenog generatora, primijeni pomak kada prvi broj objekta prijeđe prag. Svaka od tih zakrpa odgovara točno onom skupu testnih datoteka koje su bile pri ruci kada je pisana. Dodajte drugi izvor PDF-a i jedna od zakrpi će se aktivirati u krivo vrijeme, stvarajući indeks koji je sada dvostruko pogrešan: pogrešan jer je izračunat iz polja izvan reda, i ponovno pogrešan jer je na njega primijenjeno neprimjenjivo mapiranje. Provjera raspona to hvata negdje nizvodno, a stog poziva ne pokazuje ništa korisno.

Jedini produktivan put je uklanjanje svih heurističkih mapiranja i zamjena konstrukcije polja stranica ispravnim obilaskom stabla. Kada su indeksi točni po samom dizajnu, nikakve zakrpe nisu potrebne, a provjera raspona postaje prednost umjesto prepreke.

Ako održavate knjižnicu koja pokazuje ovaj obrazac, privremeno omogućite provjeru raspona u Release izdanju i pokrenite je na raznolikom korpusu PDF-ova: dokumentima koje je proizveo Word, LaTeX, firmware skenera ili alati za dijeljenje PDF-a. Datoteke koje pokreću iznimke su one kod kojih se redoslijed objekata stranica razlikuje od redoslijeda obilaska koji vaš kod pretpostavlja. Svaka od njih je podatak za analizu, a ne zaseban bug.

Za novi kod koji poziva Delphi PDF knjižnicu, praktičan je savjet tretirati broj stranica knjižnice kao mjerodavan i nikada ne prosljeđivati indeks izveden iz aritmetike na vanjskim podacima bez prethodne potvrde da se nalazi unutar raspona 0..PageCount - 1. Komponenta HotPDF component izlaže razriješeni broj stranica putem svojstva THotPDF.PageCount nakon BeginDoc ili nakon učitavanja dokumenta; ta vrijednost uvijek odražava obilazak stabla stranica i sigurna je za korištenje kao gornja granica za bilo koju aritmetiku indeksa.