Techninis straipsnis

Rėžių tikrinimo klaidos „Delphi“ PDF bibliotekose: pagrindinės priežastys

Rėžių tikrinimo klaidos (angl. range check errors) „Delphi“ PDF bibliotekose pagarsėjo kaip sunkiai susekamos, nes jos neatitinka nuoseklaus įvesties modelio. Tas pats dokumentas jas sukelia viename kompiuteryje, bet ne kitame; tas pats kodo kelias iššaukia išimtį 3 puslapių faile, bet sėkmingai veikia 12 puslapių faile. Šis nenuoseklumas beveik visada atveda prie vienos pagrindinės priežasties: PDF puslapių objektai nėra saugomi failo tvarka. Jei biblioteka formuoja savo vidinį puslapių masyvą nuskaitydama objektus nuosekliai, užuot ėjusi per puslapių medį, aprašytą kataloge, ji sukuria indeksą, kurio leistinas diapazonas neatitinka to, ko tikisi iškvietėjai, o rėžių tikrinimas sugauna šį neatitikimą pačiu netinkamiausiu momentu

Kaip rėžių tikrinimas veikia „Delphi“ programoje

Aktyvavus kompiiliatoriaus direktyvą {$R+} (kuri numatytoji yra derinimo konfigūracijoje), „Delphi RTL“ vykdymo metu patikrina kiekvieną masyvo indeksą, eilutės indeksą ir išvardintojo tipo priskyrimą. Prieiga už ribų sukelia ERangeError klaidą, užuot tyliai nuskaičius gretimą atmintį. Toks elgesys yra vertingas: jis anksti atskleidžia paslėptas klaidas, užuot leidęs joms sugadinti duomenų struktūrą, kuri sugenda tik po šimto eilučių. Labiausiai nuvilia tai, kad išimtis suveikia prieigos vietoje, o ne ten, kur indeksas buvo apskaičiuotas neteisingai. Kai iškvietimų krūva rodo giliai įdėtą metodą PDF modulyje, tikroji klaida paprastai yra keliais lygiais atgal

Sudėtinės loginės sąlygos padėtį dar pablogina. „Delphi“ apskaičiuoja and išraiškas iš kairės į dešinę su trumpojo jungimo semantika (angl. short-circuit semantics), bet trumpasis jungimas praleidžia apskaičiavimą tik tada, kai kairioji pusė yra False. Išraiška, tokia kaip:

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

atrodo saugi, tačiau ji apsaugo nuo rėžius viršijančio indekso tik tuo atveju, jei FDocStarted yra True ir DestIndex yra neneigiamas. Patikrinimas DestIndex < Length(PageArr) nieko neduoda, kai DestIndex yra neigiamas, nes lyginant neigiamą sveikąjį skaičių su neneigiamu ilgiu ženklų turinčioje aritmetikoje grąžinamas True, o vėlesnė prieiga prie masyvo vis tiek iššaukia rėžių klaidą. Ribų patikrinimo perkėlimas į pačią išorę yra teisingas sprendimas:

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 strigimą. Tai nepaaiškina, kodėl DestIndex apskritai gavo reikšmę už leistino diapazono ribų

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

ISO 32000-1 §7.7.3 apibrėžia puslapių medį kaip Pages mazgų medį, kurio Kids masyvuose puslapių objektai išvardyti rodymo tvarka. Faile tie objektai saugomi tokiais poslinkiais, kokius atsitiktinai pasirinko rašytuvas; objektas numeris 20 gali fiziškai eiti prieš objektą numerį 3 baitų sraute. Biblioteka, kuri sudaro savo puslapių sąrašą kartodama kryžminių nuorodų lentelę objektų numerių tvarka, užuot sekusi Kids grandine, sukurs seką, kuri skiriasi nuo to, ko tikisi vartotojas. Dokumentuose, kuriuose generatorius atsitiktinai įrašė puslapius iš eilės, viskas veikia. Dokumentuose, kuriuose taip neįvyko, neatitikimas tarp bibliotekos puslapių numeracijos ir iškvietėjo puslapių numeracijos sukuria indeksus, kurie patenka už PageArr ribų

Teisingas požiūris yra pradėti nuo katalogo, išspręsti netiesioginę nuorodą /Pages ir rekursyviai pereiti Kids masyvą. Plokščiam dokumentui be tarpinių Pages mazgų perėjimas yra tiesioginis:

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 šio kodo įvykdymo PageArr[0] yra pirmasis puslapis, kurį parodytų žiūryklė, nepriklausomai nuo to, kurioje baitų srauto vietoje tas objektas yra. Iškvietėjų perduoti indeksai, kurie daro prielaidą apie rodymo tvarką, dabar atvaizduojami teisingai, ir rėžių klaidos liaujasi

Kietai užkoduoti apėjimai tik pablogina problemą

Kodo bazėse, kur pagrindinė priežastis taip ir nebuvo nustatyta, dažnai galima rasti euristinių pataisymų: sukeisti pirmą ir paskutinį puslapius, jei bendras skaičius lygus 3, pasukti indeksą dokumentams iš tam tikro generatoriaus, taikyti poslinkį, kai pirmojo objekto numeris viršija ribą. Kiekvienas iš tų pataisymų tiksliai atitinka testinių failų rinkinį, kuris buvo po ranka tuo metu, kai jis buvo rašomas. Pridėkite kitą PDF šaltinį, ir vienas iš pataisymų suveiks ne laiku, sukurdamas indeksą, kuris dabar yra dvigubai neteisingas: neteisingas, nes jis buvo apskaičiuotas iš ne eilės tvarka išdėstyto masyvo, ir vėl neteisingas, nes ant viršaus buvo pritaikytas netinkamas atvaizdavimas. Rėžių tikrintuvas sugauna jį kažkur toliau kodo eigoje, o iškvietimų krūva nerodo nieko naudingo

Vienintelis produktyvus kelias yra pašalinti visus euristinius atvaizdavimus ir pakeisti puslapių masyvo konstrukciją tinkamu medžio perėjimu. Kai indeksai pagal konstrukciją yra teisingi, nereikia jokių pataisymų, o rėžių tikrintuvas tampa privalumu, o ne kliūtimi

Jei prižiūrite biblioteką, pasižyminčią tokiu elgesiu, laikinai įgalinkite rėžių tikrinimą laidos (angl. Release) versijoje ir paleiskite ją su įvairiu PDF failų rinkiniu: dokumentais, kuriuos sukūrė „Word“, „LaTeX“, skaitytuvų programinė įranga, PDF failų skaidymo įrankiai. Failai, kurie sukelia išimtis, yra tie, kurių puslapių objektų tvarka skiriasi nuo perėjimo tvarkos, kurią numato jūsų kodas. Kiekvienas iš jų yra duomenų taškas, o ne atskira klaida

Naujam kodui, iškviečiančiam „Delphi“ PDF biblioteką, praktiškas patarimas yra vertinti bibliotekos puslapių skaičių kaip autoritetingą ir niekada neperduoti indekso, gauto atliekant išorinių duomenų aritmetiką, prieš tai neįsitikinus, kad jis patenka į 0..PageCount - 1 ribas. „HotPDF“ komponentas atskleidžia apskaičiuotą puslapių skaičių per THotPDF.PageCount po BeginDoc arba įkėlus dokumentą; ta reikšmė visada atspindi puslapių medžio perėjimą ir yra saugi naudoti kaip viršutinė riba bet kokiai indeksų aritmetikai