Błędy Range Check w bibliotekach PDF dla Delphi mają reputację trudnych do zlokalizowania, ponieważ nie podążają za spójnym wzorcem wejściowym. Ten sam dokument wywołuje je na jednej maszynie, a na innej nie; ta sama ścieżka kodu zgłasza wyjątek na 3-stronicowym pliku, ale działa poprawnie na 12-stronicowym. Ta niespójność prawie zawsze sprowadza się do jednej głównej przyczyny: obiekty stron PDF nie są przechowywane w kolejności pliku. Jeśli biblioteka buduje swoją wewnętrzną tablicę stron przez sekwencyjne skanowanie obiektów, zamiast przechodzić przez drzewo stron zadeklarowane przez katalog, konstruuje indeks, którego prawidłowy zakres nie odpowiada temu, czego oczekują wywołujący, a sprawdzanie zakresu wychwytuje tę niezgodność w najgorszym możliwym momencie
Jak działa sprawdzanie zakresu w Delphi
Przy aktywnej dyrektywie kompilatora {$R+} (domyślnej w konfiguracji Debug), Delphi RTL weryfikuje każdy indeks tablicy, indeks ciągu znaków i przypisanie wyliczeniowe w czasie wykonywania. Dostęp poza zakresem zgłasza błąd ERangeError, zamiast po cichu czytać sąsiednią pamięć. Takie zachowanie jest cenne: wcześnie ujawnia ukryte błędy, zamiast pozwalać im uszkodzić strukturę danych, która psuje się dopiero sto linii później. Frustrujące jest to, że wyjątek pojawia się w miejscu dostępu, a nie w punkcie, w którym indeks został nieprawidłowo obliczony. Gdy stos wywołań pokazuje głęboko zagnieżdżoną metodę w module PDF, prawdziwy błąd znajduje się zazwyczaj o kilka ramek wcześniej
Złożone warunki logiczne pogarszają sprawę. Delphi ocenia wyrażenia and od lewej do prawej z semantyką zwarcia (short-circuit), ale zwarcie pomija ewaluację tylko wtedy, gdy lewa strona to False. Wyrażenie takie jak:
if FDocStarted and (DestIndex < Length(PageArr)) and
(PageArr[DestIndex].PageObj <> nil) then
wygląda bezpiecznie, ale chroni przed indeksem poza zakresem tylko wtedy, gdy FDocStarted ma wartość True, a DestIndex jest nieujemny. Sprawdzenie DestIndex < Length(PageArr) nic nie daje, gdy DestIndex jest ujemny, ponieważ porównanie ujemnej liczby całkowitej z nieujemną długością zwraca True w arytmetyce ze znakiem, a późniejszy dostęp do tablicy nadal powoduje błąd zakresu. Przeniesienie sprawdzenia granic na najbardziej zewnętrzną pozycję jest właściwym rozwiązaniem:
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]);
To jest mechaniczna naprawa. Zatrzymuje ona awarię. Nie wyjaśnia jednak, dlaczego DestIndex w ogóle otrzymał wartość poza prawidłowym zakresem
Prawdziwa przyczyna: kolejność obiektów a kolejność stron
ISO 32000-1 §7.7.3 definiuje drzewo stron jako drzewo węzłów Pages, których tablice Kids wymieniają obiekty stron w kolejności wyświetlania. Plik przechowuje te obiekty pod takimi przesunięciami (offset), jakie piszący przypadkowo wybrał; obiekt numer 20 może fizycznie poprzedzać obiekt numer 3 w strumieniu bajtów. Biblioteka, która buduje swoją listę stron poprzez iterację po tabeli odnośników (cross-reference) w kolejności numerów obiektów, a nie podążając za łańcuchem Kids, wygeneruje sekwencję odbiegającą od oczekiwań użytkownika. W przypadku dokumentów, w których generator zapisał strony po kolei, wszystko działa. W przypadku dokumentów, w których tak się nie stało, rozbieżność między numeracją stron biblioteki a numeracją stron wywołującego tworzy indeksy, które wykraczają poza PageArr
Właściwym podejściem jest rozpoczęcie od katalogu, rozwiązanie pośredniego odniesienia /Pages i rekurencyjne przejście przez tablicę Kids. W przypadku płaskiego dokumentu bez pośrednich węzłów Pages, przejście to jest proste:
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;
Po wykonaniu tego kodu, PageArr[0] jest pierwszą stroną, którą wyświetliłaby przeglądarka, niezależnie od tego, gdzie ten obiekt znajduje się w strumieniu bajtów. Indeksy przekazywane przez wywołujących, którzy zakładają kolejność wyświetlania, są teraz poprawnie mapowane, a błędy zakresu ustają
Zapisane na sztywno obejścia (workarounds) potęgują problem
W bazach kodu, w których nigdy nie zidentyfikowano głównej przyczyny, często można znaleźć łatki heurystyczne: zamień pierwszą i ostatnią stronę, jeśli łączna liczba wynosi 3, obróć indeks dla dokumentów z określonego generatora, zastosuj przesunięcie, gdy pierwszy numer obiektu przekroczy próg. Każda z tych łatek pasuje dokładnie do zestawu plików testowych, które były pod ręką w momencie jej pisania. Dodaj inne źródło PDF, a jedna z łatek uruchomi się w niewłaściwym czasie, generując indeks, który jest teraz podwójnie błędny: błędny, ponieważ został obliczony na podstawie tablicy w niewłaściwej kolejności, i znowu błędny, ponieważ zastosowano na nim nieodpowiednie mapowanie. Mechanizm sprawdzania zakresu wyłapuje to gdzieś w dalszej części, a zrzut stosu (stack trace) nie wskazuje na nic przydatnego
Jedyną produktywną drogą jest usunięcie każdego heurystycznego mapowania i zastąpienie konstrukcji tablicy stron prawidłowym przechodzeniem przez drzewo. Kiedy indeksy są poprawne z założenia, nie są potrzebne żadne łatki, a sprawdzanie zakresu staje się atutem, a nie przeszkodą
Jeśli utrzymujesz bibliotekę, która wykazuje ten wzorzec, włącz tymczasowo sprawdzanie zakresu w kompilacji Release i uruchom ją na zróżnicowanym korpusie plików PDF: dokumentach wygenerowanych przez Word, LaTeX, oprogramowanie układowe skanera, narzędzia do dzielenia PDF-to-PDF. Pliki, które wywołują wyjątki, to te, których kolejność obiektów stron odbiega od kolejności przejścia zakładanej przez Twój kod. Każdy z nich to punkt danych, a nie osobny błąd
W przypadku nowego kodu, który wywołuje bibliotekę PDF dla Delphi, praktyczna rada polega na traktowaniu liczby stron z biblioteki jako miarodajnej i nigdy nieprzekazywaniu indeksu pochodzącego z arytmetyki na danych zewnętrznych bez wcześniejszego upewnienia się, że mieści się on w przedziale 0..PageCount - 1. Komponent HotPDF udostępnia rozwiązaną liczbę stron za pośrednictwem THotPDF.PageCount po BeginDoc lub po załadowaniu dokumentu; wartość ta zawsze odzwierciedla przejście przez drzewo stron i można jej bezpiecznie używać jako górnej granicy dla dowolnej arytmetyki indeksów