Technical Article

Rėžio tikrinimo klaidos Delphi PDF bibliotekose: pagrindinės priežastys

Rėžio (arba diapazono) tikrinimo klaidos „Delphi“ PDF bibliotekose garsėja tuo, kad jas sunku tiksliai nustatyti, nes jos neseka nuoseklaus įvesties šablono. Tas pats dokumentas viename kompiuteryje sukelia klaidą, o kitame ne; ta pati kodo dalis išmeta išimtį apdorojant 3 puslapių failą, bet veikia be klaidų su 12 puslapių failu. Toks nenuoseklumas beveik visada kyla dėl vienos pagrindinės priežasties: PDF puslapių objektai faile nėra saugomi eilės tvarka. Jei biblioteka sukuria savo vidinį puslapių masyvą nuskaitydama objektus iš eilės, užuot perėjusi katalogo deklaruotą puslapių medį, ji sukonstruoja indeksą, kurio galiojantis rėžis neatitinka to, ko tikisi iškvietėjai, o rėžio tikrinimas šį neatitikimą užfiksuoja pačiu nepalankiausiu momentu.

Kaip Delphi aplinkoje veikia rėžio tikrinimas

Esant aktyviai kompiliatoriaus direktyvai {$R+} (numatytoji derinimo / Debug konfigūracijoje), „Delphi“ RTL vykdymo metu tikrina kiekvieną masyvo indeksą, eilutės indeksą ir išvardytų tipų reikšmių priskyrimą. Prieiga už ribų sukelia ERangeError išimtį, užuot tyliai nuskaičius gretimą atmintį. Toks elgesys yra vertingas: jis anksti iškelia paslėptas klaidas, neleisdamas joms sugadinti duomenų struktūros, kuri sugestų tik po šimto eilučių. Labiausiai nuvilianti dalis yra ta, kad išimtis kyla būtent prieigos vietoje, o ne ten, kur indeksas buvo apskaičiuotas neteisingai. Kai iškvietimų dėklas rodo giliai įdėtą metodą PDF modulyje, tikroji klaida paprastai būna atlikta keliais lygiais anksčiau.

Sudėtinės loginės sąlygos šią problemą tik apsunkina. „Delphi“ vertina and išraiškas iš kairės į dešinę su trumpojo jungimo (short-circuit) semantika, tačiau trumpasis jungimas praleidžia vertinimą tik tada, kai kairioji pusė yra False. Toks pasakymas kaip:

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

atrodo saugus, tačiau jis apsaugo nuo rėžio viršijimo tik tada, jei FDocStarted yra True ir DestIndex yra neneigiamas. Patikra DestIndex < Length(PageArr) nieko neduoda, kai DestIndex yra neigiamas, nes neigiamo skaičiaus palyginimas su neneigiamu ilgiu pasirašytoje aritmetikoje grąžina True, todėl vėlesnė prieiga prie masyvo vis tiek sukelia rėžio klaidą. Teisingas sprendimas yra perkelti ribų patikrą į pačią išorę:

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

Tai yra mechaninis pataisymas. Jis sustabdo programos lūžimą, tačiau nepaaiškina, kodėl DestIndex pirmiausia gavo reikšmę už leistino diapazono ribų.

Tikroji priežastis: objektų tvarka prieš puslapių tvarką

Standarto ISO 32000-1 7.7.3 skyrius apibrėžia puslapių medį kaip Pages mazgų medį, kurio Kids masyvai išvardija puslapių objektus rodymo tvarka. Faile šie objektai saugomi bet kokiuose poslinkiuose, kuriuos pasirinko rašymo programa; pavyzdžiui, objekto numeris 20 baitų sraute gali fiziškai eiti prieš objekto numerį 3. Biblioteka, kuri kuria savo puslapių sąrašą naršydama kryžminių nuorodų lentelę pagal objektų numerius, o ne sekdama Kids grandine, sugeneruos seką, kuri skirsis nuo to, ko tikisi vartotojas. Dokumentuose, kur generatorius puslapius įrašė eilės tvarka, viskas veikia. Dokumentuose, kur puslapiai surašyti ne iš eilės, neatitikimas tarp bibliotekos puslapių numeracijos ir iškvietėjo puslapių numeracijos sukelia indeksus, kurie išeina už PageArr ribų.

Teisingas būdas yra pradėti nuo katalogo, išspręsti /Pages netiesioginę nuorodą ir rekursiškai pereiti Kids masyvą. Plokščiam dokumentui be tarpinių Pages mazgų toks perėjimas yra paprastas:

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;

Paleidus šį kodą, PageArr[0] bus pirmasis puslapis, kurį rodytų peržiūros programa, nepriklausomai nuo to, kur tas objektas yra baitų sraute. Iškvietėjų perduoti indeksai, darant prielaidą apie rodymo tvarką, dabar bus susieti teisingai ir rėžio klaidos liaosis.

Sunkiai užkoduoti apėjimai tik dar labiau apsunkina problemą

Kodo bazėse, kur pagrindinė priežastis niekada nebuvo nustatyta, dažnai galima rasti heuristinių pataisymų: sukeisti pirmą ir paskutinį puslapį, jei bendras skaičius lygus 3, pasukti indeksą dokumentams iš tam tikro generatoriaus arba pritaikyti poslinkį, kai pirmojo objekto numeris viršija slenkstį. Kiekvienas iš šių pataisymų idealiai tinka tik tam testavimo failų rinkiniui, kuris buvo po ranka kūrimo metu. Pridėjus kitą PDF šaltinį, vienas iš pataisymų suveikia netinkamu laiku, todėl indeksas tampa dvigubai klaidingas: klaidingas, nes buvo apskaičiuotas iš ne eilės tvarka sudaryto masyvo, ir vėl klaidingas, nes buvo pritaikytas netinkamas susiejimas. Rėžio tikrintuvas tai užfiksuoja kur nors toliau, o dėklo pėdsakas (stack trace) nerodo į jokią naudingą vietą.

Vienintelis efektyvus kelias yra pašalinti visus heuristinius susiejimus ir pakeisti puslapių masyvo kūrimą tinkamu medžio perėjimu. Kai indeksai yra teisingi pagal konstrukciją, pataisymų nereikia, o rėžio tikrintuvas tampa naudingu įrankiu, o ne kliūtimi.

Jei prižiūrite biblioteką, kuriai būdingas šis elgesys, laikinai įjunkite rėžio tikrinimą „Release“ versijoje ir išbandykite ją su įvairiais PDF dokumentais: sukurtais „Word“, „LaTeX“, skenerio programinės įrangos ar PDF skaidymo įrankių. Failai, kurie sukelia išimtis, yra būtent tie, kurių puslapių objektų tvarka skiriasi nuo jūsų kode numatytos perėjimo tvarkos. Kiekvienas iš jų yra atskiras duomenų taškas, o ne atskira klaida.

Rašant naują kodą, kuris kreipiasi į „Delphi“ PDF biblioteką, praktinis patarimas yra laikyti bibliotekos puslapių skaičių autoritetingu ir niekada neperduoti indekso, gauto iš išorinių duomenų skaičiavimų, prieš tai neįsitikinus, kad jis patenka į rėžį 0..PageCount - 1. HotPDF komponentas pateikia išspręstą puslapių skaičių per THotPDF.PageCount po BeginDoc arba po dokumento įkėlimo; ši reikšmė visada atspindi puslapių medžio perėjimą ir yra saugi naudoti kaip viršutinė riba atliekant bet kokius indeksų skaičiavimus.