Tehnički članak

Greške provere opsega u Delphi PDF bibliotekama: Osnovni uzroci

Greške provere opsega u Delphi PDF bibliotekama imaju reputaciju teških za otkrivanje jer ne prate dosledan obrazac unosa. Isti dokument ih proizvodi na jednoj mašini, a ne na drugoj; ista putanja koda pokreće izuzetak na datoteci od 3 stranice, ali radi čisto na onoj od 12 stranica. Ta nedoslednost se gotovo uvek svodi na jedan osnovni uzrok: objekti PDF stranica se ne čuvaju prema redosledu u datoteci. Ako biblioteka gradi svoj unutrašnji niz stranica uzastopnim skeniranjem objekata umesto da prolazi kroz stablo stranica deklarisano u katalogu, ona konstruiše indeks čiji se važeći opseg ne podudara sa onim što pozivaoci očekuju, a provera opsega hvata to neslaganje u najgorem mogućem trenutku

Kako funkcioniše provera opsega u Delphiju

Sa aktivnom {$R+} direktivom prevodioca (podrazumevano u Debug konfiguraciji), Delphi RTL proverava svaki indeks niza, indeks niza znakova (string) i nabrojanu (enumerated) dodelu tokom izvršavanja. Pristup van granica pokreće ERangeError umesto da tiho čita susednu memoriju. Takvo ponašanje je dragoceno: rano otkriva skrivene greške umesto da im dozvoli da oštete strukturu podataka koja bi otkazala tek stotinu linija kasnije. Frustrirajući deo je to što se izuzetak pokreće na mestu pristupa, a ne na mestu gde je indeks pogrešno izračunat. Kada pozivni stek (call stack) prikaže duboko ugneždenu metodu u PDF jedinici, stvarna greška je obično nekoliko okvira unazad

Složeni logički uslovi (compound boolean conditions) ovo čine još gorim. Delphi izračunava and izraze s leva na desno sa semantikom kratkog spoja (short-circuit), ali kratki spoj preskače izračunavanje samo kada je leva strana False. Izraz kao što je:

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

izgleda bezbedno, ali štiti od indeksa van opsega samo ako je FDocStarted jednako True i DestIndex nije negativan. Provera DestIndex < Length(PageArr) ne radi ništa kada je DestIndex negativan, jer poređenje negativnog celog broja sa nenegativnom dužinom vraća True u aritmetici sa predznakom, a naknadni pristup nizu i dalje pokreće grešku provere opsega. Premeštanje provere granica na krajnju spoljašnju poziciju je ispravno rešenje:

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čko rešenje. Ono zaustavlja pad programa. Međutim, ne objašnjava zašto je DestIndex uopšte dobio vrednost izvan važećeg opsega

Pravi uzrok: redosled objekata naspram redosleda stranica

Standard ISO 32000-1 §7.7.3 definiše stablo stranica kao stablo Pages čvorova čiji Kids nizovi navode objekte stranica u redosledu prikaza. Datoteka čuva te objekte na onim ofsetima koje je kreator izabrao; objekat broj 20 može fizički prethoditi objektu broj 3 u toku bajtova (byte stream). Biblioteka koja gradi svoju listu stranica iteracijom tabele unakrsnih referenci (cross-reference table) prema redosledu brojeva objekata umesto praćenja lanca Kids proizvešće niz koji se razlikuje od onoga što korisnik očekuje. Kod dokumenata gde je generator slučajno zapisao stranice po redu, sve funkcioniše. Kod dokumenata gde to nije slučaj, neslaganje između numeracije stranica biblioteke i numeracije pozivaoca proizvodi indekse koji padaju izvan niza PageArr

Ispravan pristup je započeti od kataloga, razrešiti /Pages indirektnu referencu i rekurzivno proći kroz niz Kids. Za ravan dokument bez posrednih Pages čvorova, pretraga je pravolinijska:

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 što se ovo izvrši, PageArr[0] je prva stranica koju bi pregledač prikazao, bez obzira na to gde se taj objekat nalazi u toku bajtova. Indeksi prosleđeni od strane pozivaoca koji pretpostavljaju redosled prikaza sada se ispravno preslikavaju, a greške provere opsega prestaju

Hardkodovana rešenja (workarounds) pogoršavaju problem

U bazama koda gde pravi uzrok nikada nije identifikovan, uobičajeno je pronaći heurističke zakrpe: zameniti prvu i poslednju stranicu ako je ukupan broj jednak 3, rotirati indeks za dokumente iz specifičnog generatora, primeniti ofset kada broj prvog objekta premaši prag. Svaka od tih zakrpa tačno odgovara skupu testnih datoteka koje su bile dostupne kada je napisana. Dodajte drugačiji PDF izvor i jedna od zakrpa se pokreće u pogrešno vreme, proizvodeći indeks koji je sada dvostruko pogrešan: pogrešan jer je izračunat iz niza koji nije po redu i ponovo pogrešan jer je na vrhu primenjeno neodgovarajuće preslikavanje. Proveravač opsega ga hvata negde nizvodno (downstream) i trag steka ne ukazuje ni na šta korisno

Jedini produktivan put je ukloniti sva heuristička preslikavanja i zameniti konstrukciju niza stranica pravilnim prolaskom kroz stablo. Kada su indeksi ispravni po konstrukciji, zakrpe nisu potrebne, a proveravač opsega postaje prednost umesto prepreke

Ako održavate biblioteku koja ispoljava ovaj obrazac, privremeno omogućite proveru opsega u Release verziji i pokrenite je protiv raznovrsnog korpusa PDF-ova: dokumenata koje proizvode Word, LaTeX, firmver skenera, uslužni programi za deljenje (split) PDF-a u PDF. Datoteke koje pokreću izuzetke su one čiji redosled objekata stranica odstupa od redosleda pretrage koji vaš kod pretpostavlja. Svaka od njih je tačka podataka, a ne zasebna greška

Za novi kod koji poziva Delphi PDF biblioteku, praktičan savet je da se broj stranica biblioteke tretira kao merodavan i nikada se ne prosleđuje indeks izveden iz aritmetike na spoljnim podacima, a da se prethodno ne potvrdi da spada u opseg 0..PageCount - 1. Komponenta HotPDF izlaže rešen broj stranica kroz svojstvo THotPDF.PageCount nakon poziva BeginDoc ili nakon učitavanja dokumenta; ta vrednost uvek odražava pretragu stabla stranica i bezbedna je za korišćenje kao gornja granica za bilo kakvu indeksnu aritmetiku