Ошибки проверки диапазона в библиотеках PDF для Delphi заслужили репутацию трудноуловимых, поскольку они не следуют последовательному шаблону ввода. Один и тот же документ вызывает их на одной машине и не вызывает на другой; один и тот же путь в коде вызывает исключение на 3-страничном файле, но работает без ошибок на 12-страничном. Эта непоследовательность почти всегда сводится к одной основной причине: объекты страниц PDF не хранятся в порядке следования в файле. Если библиотека строит свой внутренний массив страниц, сканируя объекты последовательно, а не проходя по дереву страниц, объявленному в каталоге, она создает индекс, чей допустимый диапазон не соответствует тому, что ожидают вызывающие функции, и проверка диапазона ловит это несоответствие в самый неподходящий момент
Как работает проверка диапазона в Delphi
При активной директиве компилятора {$R+} (по умолчанию в конфигурации Debug), RTL Delphi проверяет каждый индекс массива, индекс строки и перечисляемое присваивание во время выполнения. Выход за границы вызывает ERangeError, а не тихое чтение соседней памяти. Это поведение ценно: оно выявляет скрытые ошибки на ранней стадии, вместо того чтобы позволить им повредить структуру данных, которая дает сбой лишь сотней строк ниже. Разочаровывающая часть заключается в том, что исключение срабатывает на месте доступа, а не в точке, где индекс был вычислен неверно. Когда стек вызовов показывает глубоко вложенный метод в модуле PDF, настоящая ошибка обычно находится на несколько фреймов выше
Сложные логические условия усугубляют эту ситуацию. Delphi вычисляет выражения and слева направо с семантикой короткого замыкания, но короткое замыкание пропускает вычисление только тогда, когда левая часть равна 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 в потоке байтов. Библиотека, которая строит свой список страниц, перебирая таблицу перекрестных ссылок в порядке номеров объектов, а не следуя цепочке 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-в-PDF. Файлы, вызывающие исключения — это те, чей порядок объектов страниц отклоняется от порядка обхода, который предполагает ваш код. Каждый из них — это точка данных, а не отдельная ошибка
Для нового кода, вызывающего библиотеку PDF для Delphi, практический совет — относиться к количеству страниц библиотеки как к авторитетному и никогда не передавать индекс, полученный в результате арифметических операций с внешними данными, не убедившись предварительно, что он попадает в диапазон 0..PageCount - 1. Компонент HotPDF предоставляет разрешенное количество страниц через THotPDF.PageCount после BeginDoc или после загрузки документа; это значение всегда отражает обход дерева страниц и безопасно для использования в качестве верхней границы для любой арифметики индексов