Teknisk artikel

Intervallkontrollsfel i Delphi PDF-bibliotek: Grundorsaker

Intervallkontrollsfel (range check errors) i Delphi PDF-bibliotek har ett rykte om sig att vara svåra att spåra eftersom de inte följer ett konsekvent inmatningsmönster. Samma dokument producerar dem på en dator men inte på en annan; samma kodsökväg utlöser undantaget på en fil med 3 sidor men körs utan problem på en med 12 sidor. Den inkonsekvensen kan nästan alltid spåras till en enda grundorsak: PDF-sidobjekt lagras inte i filordning. Om biblioteket bygger sin interna sid-array genom att skanna objekt sekventiellt istället för att gå igenom sidträdet som deklareras av katalogen, konstruerar det ett index vars giltiga intervall inte stämmer överens med vad anropare förväntar sig, och intervallkontrollen upptäcker den avvikelsen vid sämsta möjliga tillfälle

Hur intervallkontroll fungerar i Delphi

Med kompilatordirektivet {$R+} aktivt (standard i Debug-konfigurationen), validerar Delphis RTL varje array-index, sträng-subskript och uppräknad tilldelning vid körning. En åtkomst utanför gränserna kastar ERangeError istället för att tyst läsa angränsande minne. Det beteendet är värdefullt: det lyfter fram latenta buggar tidigt istället för att låta dem korrumpera en datastruktur som sedan misslyckas hundra rader senare. Den frustrerande delen är att undantaget utlöses vid åtkomstpunkten, inte vid den punkt där indexet beräknades felaktigt. När anropsstacken visar en djupt nästlad metod i en PDF-enhet, är det verkliga misstaget vanligtvis flera ramar bakåt

Sammansatta booleska villkor gör detta värre. Delphi utvärderar and-uttryck från vänster till höger med kortslutningssemantik, men kortslutning hoppar endast över utvärdering när den vänstra sidan är False. Ett uttryck som:

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

ser säkert ut, men det skyddar bara mot ett index utanför intervallet om FDocStarted är True och DestIndex är icke-negativt. Kontrollen DestIndex < Length(PageArr) gör ingenting när DestIndex är negativt, eftersom jämförelsen av ett negativt heltal med en icke-negativ längd returnerar True i teckenberoende aritmetik, och den efterföljande array-åtkomsten utlöser fortfarande intervallfelet. Att flytta gränskontrollen till den yttersta positionen är den korrekta åtgärden:

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

Detta är den mekaniska åtgärden. Det stoppar kraschen. Det förklarar dock inte varför DestIndex fick ett värde utanför det giltiga intervallet från första början

Den verkliga orsaken: objektordning kontra sidordning

ISO 32000-1 §7.7.3 definierar sidträdet som ett träd av Pages-noder vars Kids-arrayer listar sidobjekt i visningsordning. Filen lagrar dessa objekt på de förskjutningar (offsets) som skaparen råkade välja; objektnummer 20 kan fysiskt föregå objektnummer 3 i byteströmmen. Ett bibliotek som bygger sin sidlista genom att iterera korsreferenstabellen i objektnummerordning istället för att följa Kids-kedjan kommer att producera en sekvens som avviker från vad användaren förväntar sig. För dokument där generatorn råkade skriva sidorna i ordning fungerar allt. För dokument där den inte gjorde det, producerar avvikelsen mellan bibliotekets sidnumrering och anroparens sidnumrering index som faller utanför PageArr

Det korrekta tillvägagångssättet är att börja från katalogen, lösa upp /Pages indirekta referensen och gå igenom Kids-arrayen rekursivt. För ett platt dokument utan mellanliggande Pages-noder är genomgången okomplicerad:

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;

När detta körts är PageArr[0] den första sidan som en visare skulle visa, oavsett var det objektet befinner sig i byteströmmen. Index som skickas av anropare som förutsätter visningsordning mappas nu korrekt, och intervallfelen upphör

Hårdkodade lösningar förvärrar problemet

I kodbaser där grundorsaken aldrig identifierades är det vanligt att hitta heuristiska patchar: byt första och sista sidan om det totala antalet är 3, rotera indexet för dokument från en specifik generator, applicera en förskjutning när det första objektnumret överstiger ett tröskelvärde. Var och en av dessa patchar passar exakt den uppsättning testfiler som fanns till hands när den skrevs. Lägg till en annan PDF-källa och en av patcharna utlöses vid fel tidpunkt, vilket producerar ett index som nu är dubbelt fel: fel eftersom det beräknades från en array i fel ordning, och fel igen eftersom en otillämplig mappning applicerades ovanpå det. Intervallkontrollen fångar det någonstans längre fram och stackspårningen pekar inte på något användbart

Den enda produktiva vägen är att ta bort varje heuristisk mappning och ersätta konstruktionen av sid-arrayen med en ordentlig trädgenomgång. När indexen är korrekta genom sin konstruktion behövs inga patchar, och intervallkontrollen blir en tillgång istället för ett hinder

Om du underhåller ett bibliotek som uppvisar detta mönster, aktivera intervallkontroll i en Release-bygge temporärt och kör det mot en mångsidig korpus av PDF-filer: dokument producerade av Word, av LaTeX, av skanner-firmware, av PDF-till-PDF-delningsverktyg. De filer som utlöser undantag är de vars ordning av sidobjekt avviker från den genomgångsordning din kod förutsätter. Var och en är en datapunkt, inte en separat bugg

För ny kod som anropar ett Delphi PDF-bibliotek är det praktiska rådet att behandla bibliotekets sidantal som auktoritativt och aldrig skicka ett index som härletts från aritmetik på extern data utan att först bekräfta att det faller inom 0..PageCount - 1. Komponenten HotPDF exponerar det lösta sidantalet genom THotPDF.PageCount efter BeginDoc eller efter laddning av ett dokument; det värdet reflekterar alltid genomgången av sidträdet och är säkert att använda som övre gräns för all indexaritmetik