Technical Article

Дебъгване на грешки при проверка на диапазона в Delphi PDF библиотеки

Грешките при проверка на диапазона (range check errors) в Delphi PDF библиотеките имат репутацията на трудни за откриване, тъй като не следват последователен модел на възникване. Един и същ документ ги предизвиква на една машина, но не и на друга; същият път в кода задейства изключение при файл от 3 страници, но работи без проблем при файл от 12 страници. Това несъответствие почти винаги се дължи на една основна причина: обектите на страниците в PDF файла не се съхраняват в реда на тяхното показване. Ако библиотеката изгражда своя вътрешен масив от страници чрез последователно сканиране на обектите, вместо да обхожда дървото от страници, декларирано в каталога, тя конструира индекс, чийто валиден диапазон не съответства на очакванията, а проверката на диапазона улавя това несъответствие в най-неподходящия момент.

Как работи проверката на диапазона в Delphi

При активна директива на компилатора {$R+} (която е по подразбиране в конфигурация Debug), Delphi RTL валидира всеки индекс на масив, подниз на низ и присвояване на изброим тип по време на изпълнение. Достъпът извън границите предизвиква изключение ERangeError, вместо тихомълком да чете съседна памет. Това поведение е изключително полезно: то извежда наяве скрити грешки рано, вместо да ги остави да повредят структура от данни, която би се сринала сто реда по-надолу. Неприятното е, че изключението се задейства на мястото на достъпа, а не там, където индексът е бил изчислен неправилно. Когато стекът на повикванията (call stack) сочи към дълбоко вложен метод в PDF модул, реалната грешка обикновено се намира няколко нива по-нагоре.

Сложните логически условия допълнително усложняват това. Delphi оценява изразите с and от ляво на дясно чрез съкратено оценяване (short-circuit), но то пропуска изпълнението само когато лявата страна е False. Израз като:

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

изглежда безопасен, но той предпазва от индекс извън границите само ако FDocStarted е True и DestIndex е неотрицателно число. Проверката DestIndex < Length(PageArr) не върши нищо, когато DestIndex е отрицателно число, тъй като сравняването на отрицателно цяло число с неотрицателна дължина връща True в подписаната аритметика (signed arithmetic) и последващият достъп до масива все пак задейства грешка в диапазона. Извеждането на проверката на границите в най-външната позиция е правилното решение:

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

Това е механичното решение. То спира срива на приложението. Но то не обяснява защо първоначално DestIndex е получил стойност извън валидния диапазон.

Реалната причина: подредба на обектите срещу подредба на страниците

ISO 32000-1 §7.7.3 дефинира дървото от страници как дърво от възли Pages, чиито масиви Kids описват обектите на страниците в реда на тяхното показване. Файлът съхранява тези обекти на произволни отмествания, избрани от записващата програма; обект номер 20 може физически да предхожда обект номер 3 в байтовия поток. Библиотека, която изгражда своя списък със страници чрез обхождане на таблицата с препратки по номера на обектите, вместо да следва веригата Kids, ще генерира последователност, различна от очакваната от потребителя. При документи, при които генераторът е записал страниците последователно, всичко работи. Но при тези, при които това не е така, разминаването между номерацията на страниците в библиотеката и тази на извикващия код води до индекси, които попадат извън PageArr.

Правилният подход изисква да се започне от каталога, да се намери косвената препратка /Pages и масивът Kids да се обходи рекурсивно. За плосък документ без междинни възли Pages обхождането е съвсем просто:

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;

След изпълнението на тази процедура PageArr[0] ще бъде първата страница, която визуализаторът ще покаже, независимо къде се намира този обект в байтовия поток. Индексите, подадени от извикващия софтуер, които приемат реда на показване, вече се съпоставят правилно и грешките в диапазона изчезват.

Твърдо кодираните заобиколни решения задълбочават проблема

В кодови бази, където основната причина никога не е била идентифицирана, често се срещат евристични пачове: размяна на първата и последната страница, ако общият брой е 3, ротация на индекса за документи от специфичен генератор или прилагане на отместване, когато първият номер на обект надвиши даден праг. Всеки от тези пачове съответства точно на набора от тестови файлове, с които разполага разработчикът по време на писането му. Добавянето на различен PDF източник обаче води до задействане на някой от пачовете в грешен момент, правейки индекса двойно по-грешен: веднъж защото е изчислен от разбъркан масив, и втори път заради приложеното грешно преобразуване. Проверката на диапазона го улавя някъде по веригата надолу, а стекът с грешки не показва нищо полезно.

Единственият ефективен път е да се премахне всяко евристично преобразуване и да се замени изграждането на масива със страници с коректно обхождане на дървото. Когато индексите са правилни по рождение, не са необходими никакви пачове и проверката на диапазона се превръща в предимство, а не в пречка.

Ако поддържате библиотека, която показва такова поведение, включете временно проверката на диапазона в Release конфигурация и я тествайте с разнообразна база от PDF файлове: документи, създадени от Word, LaTeX, фърмуер на скенери или инструменти за разделяне на PDF. Файловете, които предизвикват изключения, са именно тези, при които редът на обектите се разминава с реда на обхождане, който вашият код предполага. Всеки такъв файл е полезна отправна точка за анализ, а не отделен бъг.

За нов код, който извиква Delphi PDF библиотека, практическият съвет е да разглеждате броя страници в библиотеката като меродавен и никога да не подавате индекс, изчислен външно, без първо да потвърдите, че той попада в границите 0..PageCount - 1. Компонентът HotPDF предоставя изчисления брой страници чрез THotPDF.PageCount след BeginDoc или след зареждане на документ; тази стойност винаги отразява обхождането на дървото и е безопасна за използване като горна граница при изчисляване на индекси.