Teknisk artikkel

Områdekontrollfeil i Delphi PDF-biblioteker: Hovedårsaker

Områdekontrollfeil i Delphi PDF-biblioteker har et rykte på seg for å være vanskelige å finne fordi de ikke følger et konsekvent inndatamønster. Det samme dokumentet produserer dem på én maskin, men ikke på en annen; den samme kodestien utløser unntaket på en fil med 3 sider, men kjører feilfritt på en med 12 sider. Denne inkonsekvensen kan nesten alltid spores tilbake til én enkelt hovedårsak: PDF-sideobjekter lagres ikke i filrekkefølge. Hvis biblioteket bygger sin interne side-array ved å skanne objekter sekvensielt fremfor å følge sidetreet deklarert av katalogen, konstruerer det en indeks der det gyldige området ikke samsvarer med det innringeren forventer, og områdekontrollen fanger opp dette avviket på verst tenkelige tidspunkt

Hvordan områdekontroll fungerer i Delphi

Når kompilatordirektivet {$R+} er aktivt (standard i Debug-konfigurasjonen), validerer Delphi RTL hver array-indeks, streng-subskript og oppregnede tilordning ved kjøretid. En tilgang utenfor grensene utløser ERangeError i stedet for at man i stillhet leser tilstøtende minne. Den atferden er verdifull: den bringer latente feil til overflaten tidlig i stedet for å la dem korrumpere en datastruktur som bare svikter hundre linjer senere. Den frustrerende delen er at unntaket utløses ved tilgangspunktet, ikke der indeksen ble beregnet feil. Når anropsstakken viser en dypt nøstet metode i en PDF-enhet, er den virkelige feilen vanligvis flere nivåer tilbake

Sammensatte boolske betingelser gjør dette verre. Delphi evaluerer and-uttrykk fra venstre til høyre med kortslutningssemantikk, men kortslutning hopper bare over evaluering når venstre side er False. Et uttrykk som:

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

ser trygt ut, men det beskytter bare mot en indeks utenfor grensene hvis FDocStarted er True og DestIndex er ikke-negativ. Sjekken DestIndex < Length(PageArr) gjør ingenting når DestIndex er negativ, fordi å sammenligne et negativt heltall med en ikke-negativ lengde returnerer True i aritmetikk med fortegn, og den påfølgende array-tilgangen utløser fortsatt områdefeilen. Å flytte grensekontrollen til den ytterste posisjonen er den korrekte løsningen:

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

Dette er den mekaniske løsningen. Den stopper krasjet. Den forklarer ikke hvorfor DestIndex fikk en verdi utenfor det gyldige området i utgangspunktet

Den virkelige årsaken: objektrekkefølge kontra siderekkefølge

ISO 32000-1 §7.7.3 definerer sidetreet som et tre med Pages-noder der Kids-arrayene lister opp sideobjekter i visningsrekkefølge. Filen lagrer disse objektene på de forskyvningene (offsets) skribenten tilfeldigvis valgte; objektnummer 20 kan fysisk komme før objektnummer 3 i bytestrømmen. Et bibliotek som bygger sidelisten sin ved å iterere over kryssreferansetabellen i objektnummer-rekkefølge i stedet for å følge Kids-kjeden, vil produsere en sekvens som avviker fra det brukeren forventer. På dokumenter der generatoren tilfeldigvis skrev sidene i rekkefølge, fungerer alt. På dokumenter der den ikke gjorde det, produserer uoverensstemmelsen mellom bibliotekets sidenummerering og innringerens sidenummerering indekser som faller utenfor PageArr

Den riktige tilnærmingen er å starte fra katalogen, løse den indirekte /Pages-referansen og gå gjennom Kids-arrayen rekursivt. For et flatt dokument uten mellomliggende Pages-noder er gjennomgangen rett frem:

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;

Etter at dette er kjørt, er PageArr[0] den første siden en visningsapp ville vise, uavhengig av hvor det objektet sitter i bytestrømmen. Indekser sendt fra innringere som forutsetter visningsrekkefølge kartlegges nå riktig, og områdefeilene stopper

Harde kodede omgåelser forverrer problemet

I kodebaser der hovedårsaken aldri ble identifisert, er det vanlig å finne heuristiske lapper: bytt om første og siste side hvis totalantallet er 3, roter indeksen for dokumenter fra en spesifikk generator, legg til en forskyvning når det første objektnummeret overstiger en grense. Hver av disse lappene passer akkurat det settet med testfiler som var tilgjengelige da den ble skrevet. Legg til en annen PDF-kilde, og en av lappene utløses på feil tidspunkt, noe som produserer en indeks som nå er dobbelt feil: feil fordi den ble beregnet ut fra en array i feil rekkefølge, og feil igjen fordi en upassende kartlegging ble lagt på toppen. Områdekontrollen fanger den et sted lenger nedstrøms, og stakksporet peker ingen nyttige steder

Den eneste produktive veien er å fjerne hver heuristiske kartlegging og erstatte konstruksjonen av side-arrayen med en skikkelig tregjennomgang. Når indeksene er korrekte i utgangspunktet, trengs ingen lapper, og områdekontrollen blir en ressurs i stedet for en hindring

Hvis du vedlikeholder et bibliotek som utviser dette mønsteret, aktiver områdekontroll midlertidig i et Release-bygg og kjør det mot et mangfoldig utvalg av PDF-er: dokumenter produsert av Word, av LaTeX, av skanner-fastvare, av verktøy for deling fra PDF til PDF. Filene som utløser unntak er de der lagringsrekkefølgen for sideobjektene avviker fra gjennomgangsrekkefølgen koden din forutsetter. Hver og en er et datapunkt, ikke en egen feil

For ny kode som anroper et Delphi PDF-bibliotek, er det praktiske rådet å behandle bibliotekets sideantall som autoritativt og aldri sende en indeks avledet fra aritmetikk på eksterne data uten først å bekrefte at den faller innenfor 0..PageCount - 1. HotPDF-komponenten gjør det oppløste sideantallet tilgjengelig gjennom THotPDF.PageCount etter BeginDoc eller etter innlasting av et dokument; den verdien gjenspeiler alltid gjennomgangen av sidetreet og er trygg å bruke som øvre grense for all indeksaritmetikk