Technical Article

Napake pri preverjanju obsega v knjižnicah Delphi PDF: Izvirni vzroki

Napake pri preverjanju obsega (range check errors) v knjižnicah Delphi PDF slovijo po tem, da jih je težko odkriti, saj ne sledijo doslednemu vzorcu vnosa. Isti dokument jih povzroči na enem računalniku in ne na drugem; ista pot kode sproži izjemo pri datoteki s 3 stranmi, a deluje brez težav pri datoteki z 12 stranmi. Ta nedoslednost skoraj vedno izhaja iz enega samega izvirnega vzroka: objekti strani PDF niso shranjeni v vrstnem redu datoteke. Če knjižnica zgradi svoje interno polje strani z zaporednim pregledovanjem objektov, namesto da bi prehodila drevo strani, ki ga deklarira katalog, ustvari indeks, katerega veljavni obseg se ne ujema s pričakovanji klicateljev, preverjanje obsega pa to neujemanje ujame v najslabšem možnem trenutku.

Kako deluje preverjanje obsega v Delphiju

Z aktivno prevajalno direktivo {$R+} (privzeto v konfiguraciji Debug) Delphi RTL med delovanjem preveri vsak indeks polja, indeks niza in dodelitev naštetih vrednosti. Dostop izven obsega sproži izjemo ERangeError, namesto da bi tiho prebral sosednji pomnilnik. To vedenje je dragoceno: zgodaj odkrije skrite hrošče, namesto da bi ti poškodovali podatkovno strukturo, ki odpove šele sto vrstic kasneje. Frustrirajoče pa je to, da se izjema sproži na mestu dostopa in ne na točki, kjer je bil indeks napačno izračunan. Ko klicni sklad (call stack) prikazuje globoko gnezdeno metodo v enoti PDF, je dejanska napaka običajno nekaj okvirjev nazaj.

Sestavljeni logični pogoji to še poslabšajo. Delphi ocenjuje izraze and od leve proti desni s semantiko kratkega stika (short-circuit), vendar se ocenjevanje preskoči le, ko je leva stran False. Izraz kot:

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

izgleda varen, vendar ščiti pred indeksom izven obsega le, če je FDocStarted enak True in je DestIndex nenegativen. Preverjanje DestIndex < Length(PageArr) ne naredi ničesar, ko je DestIndex negativen, saj primerjava negativnega celega števila z nenegativno dolžino vrne True v predznačeni aritmetiki, kasnejši dostop do polja pa še vedno sproži napako obsega. Premik preverjanja meja na najbolj zunanji položaj je pravilna rešitev:

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

To je mehanski popravek. Prepreči sesutje. Ne pojasni pa, zakaj je DestIndex sploh prejel vrednost zunaj veljavnega obsega.

Dejanski vzrok: vrstni red objektov proti vrstnemu redu strani

ISO 32000-1 §7.7.3 definira drevo strani kot drevo vozlišč Pages, katerih polja Kids navajajo objekte strani v vrstnem redu prikaza. Datoteka shranjuje te objekte na poljubnih odmikih, ki jih je izbral generator; objekt številka 20 lahko v bajtnem toku fizično leži pred objektom številka 3. Knjižnica, ki gradi svoj seznam strani z iteracijo navzkrižne tabele v vrstnem redu številk objektov, namesto da bi sledila verigi Kids, bo ustvarila zaporedje, ki odstopa od pričakovanj uporabnika. Pri dokumentih, kjer je generator strani zapisal po vrstnem redu, vse deluje. Pri dokumentih, kjer tega ni storil, neskladje med oštevilčenjem strani knjižnice in klicatelja povzroči indekse, ki padejo izven polja PageArr.

Pravilen pristop je, da začnete pri katalogu, razrešite posredni sklic /Pages in rekurzivno prehodite polje Kids. Za raven dokument brez vmesnih vozlišč Pages je prehod preprost:

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 zagonu te procedure je PageArr[0] prva stran, ki jo bo pregledovalnik prikazal, ne glede na to, kje se ta objekt nahaja v bajtnem toku. Indeksi, ki jih posredujejo klicatelji in predvidevajo vrstni red prikaza, se zdaj pravilno preslikajo, napake obsega pa se prenehajo pojavljati.

Trdo kodirane rešitve težavo le še poslabšajo

V kodi, kjer izvirni vzrok ni bil nikoli ugotovljen, pogosto najdemo hevristične popravke: zamenjaj prvo in zadnjo stran, če je skupno število enako 3, zasukaj indeks za dokumente določenega generatorja, uveljavi odmik, ko številka prvega objekta preseže prag. Vsak od teh popravkov ustreza natanko tistim testnim datotekam, ki so bile pri roki med pisanjem. Ko dodate drug vir PDF, se eden od popravkov sproži ob napačnem času in ustvari indeks, ki je zdaj dvakrat napačen: napačen, ker je bil izračunan iz neurejenega polja, in ponovno napačen, ker je bila nanj uveljavljena neustrezna preslikava. Preverjevalnik obsega ga ujame nekje kasneje, klicni sledilnik (stack trace) pa ne kaže na nič uporabnega.

Edina učinkovita pot je odstranitev vseh hevrističnih preslikav in zamenjava gradnje polja strani s pravilnim prehodom drevesa. Ko so indeksi pravilno zgrajeni že v osnovi, popravki niso več potrebni, preverjevalnik obsega pa postane prednost in ne več ovira.

Če vzdržujete knjižnico, ki kaže ta vzorec, začasno omogočite preverjanje obsega v izdaji Release in jo zaženite na raznolikem naboru PDF-jev: dokumentih, ustvarjenih z Wordom, z LaTeX-om, z vdelano programsko opremo skenerjev, z orodji za razdelitev PDF-jev. Datoteke, ki sprožijo izjeme, so tiste, pri katerih vrstni red objektov strani odstopa od vrstnega reda prehajanja, ki ga predvideva vaša koda. Vsaka datoteka je le podatkovna točka in ne ločen hrošč.

Za novo kodo, ki kliče knjižnico Delphi PDF, je praktičen nasvet ta, da število strani knjižnice obravnavate kot merodajno in nikoli ne posredujete indeksa, pridobljenega z aritmetiko na zunanjih podatkih, ne da bi se prej prepričali, da se nahaja znotraj obsega 0..PageCount - 1. Komponenta HotPDF izpostavi razrešeno število strani prek THotPDF.PageCount po klicu BeginDoc ali po nalaganju dokumenta; ta vrednost vedno odraža prehod drevesa strani in jo je varno uporabiti kot zgornjo mejo za vsako indeksno aritmetiko.