Помилки перевірки діапазону (range check errors) у бібліотеках Delphi PDF мають репутацію складних для виявлення, оскільки вони не слідують послідовному шаблону вхідних даних. Той самий документ викликає їх на одній машині й не викликає на іншій; той самий шлях коду генерує виняток на 3-сторінковому файлі, але працює без помилок на 12-сторінковому. Ця непослідовність майже завжди зводиться до однієї першопричини: об'єкти сторінок PDF не зберігаються в порядку файлу. Якщо бібліотека будує свій внутрішній масив сторінок шляхом послідовного сканування об'єктів замість обходу дерева сторінок, оголошеного каталогом, вона створює індекс, дійсний діапазон якого не збігається з тим, що очікують викликаючі функції, і перевірка діапазону ловить цю невідповідність у найгірший можливий момент
Як працює перевірка діапазону в Delphi
За активної директиви компілятора {$R+} (типово для конфігурації Debug), Delphi RTL перевіряє кожен індекс масиву, індекс рядка та перелічене присвоєння під час виконання. Доступ за межами масиву викликає ERangeError замість тихого читання сусідньої пам'яті. Така поведінка є цінною: вона виявляє приховані помилки на ранній стадії замість того, щоб дозволити їм пошкодити структуру даних, яка дасть збій лише на сотню рядків пізніше. Неприємна частина полягає в тому, що виняток спрацьовує в місці доступу, а не в точці, де індекс було обчислено неправильно. Коли стек викликів показує глибоко вкладений метод у модулі 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 в арифметиці зі знаком, і наступний доступ до масиву все одно викликає помилку діапазону. Переміщення перевірки меж на найвищу позицію є правильним виправленням:
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 у потоці байтів. Бібліотека, яка будує свій список сторінок шляхом ітерації таблиці перехресних посилань (cross-reference table) у порядку номерів об'єктів замість того, щоб слідувати ланцюжком 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 або після завантаження документа; це значення завжди відображає обхід дерева сторінок, і його безпечно використовувати як верхню межу для будь-якої арифметики індексів