Tehnički članak

Greške provjere raspona (Range Check Errors) u Delphi PDF bibliotekama: Temeljni uzroci

Greške provjere raspona u Delphi PDF bibliotekama zarađuju reputaciju po tome što ih je teško otkriti jer ne slijede dosljedan obrazac unosa. Isti ih dokument proizvodi na jednom stroju, a ne na drugom; ista staza koda pokreće iznimku na datoteci od 3 stranice, ali radi čisto na datoteci od 12 stranica. Ta nedosljednost gotovo se uvijek svodi na jedan temeljni uzrok: PDF objekti stranica nisu pohranjeni prema redoslijedu datoteka. Ako biblioteka izgradi svoj interni niz stranica skeniranjem objekata sekvencijalno (sequentially), umjesto hodanjem kroz stablo stranica (page tree) deklarirano u katalogu, ona konstruira indeks čiji važeći raspon ne odgovara onome što pozivatelji očekuju, a provjera raspona hvata tu nepodudarnost u najgorem mogućem trenutku

Kako funkcionira provjera raspona u Delphiju

Kada je direktiva kompajlera {$R+} aktivna (zadano u Debug konfiguraciji), Delphi RTL validira svaki indeks niza (array index), pretplatu za niz znakova (string subscript) i pobrojanu dodjelu (enumerated assignment) tijekom izvođenja (runtime). Pristup izvan granica pokreće (raises) ERangeError umjesto da nečujno čita susjednu memoriju. Takvo ponašanje je dragocjeno: rano izlazi na vidjelo prikrivene greške umjesto da dopusti da oštete strukturu podataka koja bi zakazala tek stotinu linija kasnije. Frustrirajući dio je taj što iznimka puca na mjestu pristupa, a ne na mjestu gdje je indeks netočno izračunat. Kada stog poziva (call stack) prikazuje duboko ugniježđenu metodu (nested method) u PDF jedinici, stvarna greška obično je nekoliko okvira (frames) unatrag

Složeni logički uvjeti pogoršavaju stvari. Delphi procjenjuje and izraze slijeva nadesno s logikom kratkog spoja (short-circuit semantics), ali kratki spoj preskače evaluaciju samo 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 jednako True i DestIndex nije negativan. Provjera DestIndex < Length(PageArr) ne čini ništa kada je DestIndex negativan, jer usporedba negativnog cijelog broja (integer) s nenegativnom duljinom vraća True u predznačnoj aritmetici (signed arithmetic), a naknadni pristup nizu i dalje pokreće pogrešku raspona. Premještanje provjere granica (bounds check) na krajnji vanjski položaj je ispravno rješ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čki popravak. To zaustavlja rušenje (crash). Ne objašnjava zašto je DestIndex prvenstveno primio vrijednost izvan važećeg raspona

Stvarni uzrok: redoslijed objekata naspram redoslijeda stranica

ISO 32000-1 §7.7.3 definira stablo stranica (page tree) kao stablo Pages čvorova (nodes) čiji nizovi Kids ispisuju objekte stranice u redoslijedu prikaza (display order). Datoteka pohranjuje te objekte u kojim god pomacima (offsets) je pisac (writer) odabrao; objekt broj 20 može fizički prethoditi objektu broj 3 u toku bajtova (byte stream). Biblioteka koja gradi svoj popis stranica prelaženjem (iterating) unakrsne referentne tablice redoslijedom brojeva objekata, a ne praćenjem Kids lanca, proizvest će slijed koji odstupa od onoga što korisnik očekuje. Kod dokumenata gdje se generator slučajno dogodio da stranice zapiše redoslijedom, sve funkcionira. U dokumentima u kojima to nije uspjelo, razlika između označavanja stranica (page numbering) u biblioteci i označavanja stranica pozivatelja stvara indekse (indices) koji ispadaju izvan PageArr

Ispravan pristup je započeti iz kataloga, razriješiti /Pages indirektnu referencu i hodati rekurzivno kroz niz Kids. Za ravan dokument (flat document) bez srednjih Pages čvorova, prelazak (traversal) 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 što se ovo pokrene, PageArr[0] je prva stranica koju bi preglednik prikazao, bez obzira na to gdje se taj objekt nalazi u toku bajtova. Indeksi proslijeđeni od strane pozivatelja (callers) koji pretpostavljaju ispravan redoslijed mapiranja zaslona (display order) sada se ispravno podudaraju, i pogreške raspona (range errors) prestaju

Fiksna (hard-coded) zaobilazna rješenja otežavaju problem

U bazama koda (codebases) u kojima temeljni uzrok nikad nije identificiran, uobičajeno je pronaći heurističke zakrpe (patches): zamijeni prvu i zadnju stranicu ako je ukupan broj jednak 3, rotiraj indeks za dokumente od specifičnog generatora, primijeni pomak (offset) kada prvi broj objekta premaši prag. Svaka od tih zakrpa odgovara točno onom skupu testnih datoteka koje su bile pri ruci kada je napisana. Dodajte drugačiji PDF izvor i jedna od zakrpa se pokreće u krivo vrijeme, proizvodeći indeks koji je sada dvostruko netočan: netočan jer je izračunat iz neporedanog niza, i ponovno netočan jer je neprimjenjivo preslikavanje primijenjeno povrh toga. Alat za provjeru raspona hvata ga negdje nizvodno, a ispis stoga (stack trace) ne ukazuje na ništa korisno

Jedini produktivni put je ukloniti sva heuristička mapiranja (heuristic mapping) i zamijeniti konstrukciju niza stranica s ispravnim hodanjem kroz stablo (tree walk). Jednom kada se indeksi isprave kroz konstrukciju, nisu potrebne nikakve zakrpe i program za provjeru raspona postaje prednost umjesto prepreke

Ako održavate biblioteku koja pokazuje ovaj obrazac, privremeno omogućite provjeru raspona (range checking) u verziji (Release build) i pokrenite ju na raznolikom korpusu PDF-ova: dokumenti kreirani pomoću Worda, LaTeX-a, na razini ugrađene programske opreme (firmware) skenera te uslužnim programima za razdvajanje iz PDF u PDF (split utilities). Datoteke koje izazivaju iznimke one su čiji redoslijed objekata na stranici odstupa od redoslijeda prolaženja koji pretpostavlja vaš kôd. Svaki od njih je podatkovna točka (data point), a ne zasebna greška (bug)

Za novi kod koji poziva Delphi PDF biblioteku, praktičan savjet je tretirati broj stranica u biblioteci kao autoritativan i nikada ne prosljeđivati indeks izveden iz aritmetike na vanjskim podacima, a da prethodno niste potvrdili da spada unutar granica 0..PageCount - 1. HotPDF komponenta izlaže (exposes) riješeni broj stranica putem THotPDF.PageCount nakon BeginDoc ili nakon učitavanja dokumenta; ta vrijednost uvijek odražava prolazak kroz stablo stranica i sigurno ju je koristiti kao gornju granicu (upper bound) za svaku aritmetiku indeksa