Artykuł techniczny

Etykiety stron PDF w Delphi: naprawa drzew /Kids

PDF Library for Delphi zapisuje zakresy etykiet stron przez AddPageLabels, a od v3.539.10 to wywołanie działa też na wczytanych plikach, których drzewo numeryczne /PageLabels jest rozdzielone na węzły /Kids: korzeń jest spłaszczany do pojedynczego liścia /Nums, zanim wejdzie nowy zakres, więc etykieta faktycznie pokazuje się w przeglądarce, zamiast być po cichu ignorowana. Typowa ofiara to książkowy PDF z programu składu, z cyframi rzymskimi w przedmowie, numeracją arabską w korpusie i aneksem oznaczonym A-1, A-2, gdzie chciałeś przemianować tylko aneks, a nie zmieniło się nic

Czym są etykiety stron PDF i jak są przechowywane?

Etykiety stron to łańcuchy, które przeglądarka pokazuje w swoim polu strony zamiast fizycznego indeksu strony, a ISO 32000-1 §12.4.2 przechowuje je jako drzewo numeryczne pod kluczem katalogu /PageLabels. Każdy klucz to liczony od zera indeks strony rozpoczynający zakres etykietowania, a każda wartość to słownik etykiety strony z co najwyżej trzema wpisami: /S dla stylu numeracji (D, R, r, A albo a), /P dla łańcucha prefiksu i /St dla wartości liczbowej pierwszej strony w zakresie, domyślnie 1. Zakres biegnie do następnego klucza, a specyfikacja wymaga, by drzewo zawierało wartość dla indeksu strony 0, więc każda strona jest pokryta jakimś zakresem

Przechowywanie etykiet stron w terminach PDFlibPas: drzewo numeryczne /PageLabels kluczuje każdy zakres liczoną od zera stroną startową, każda wartość to słownik etykiety ze stylem /S, prefiksem /P i pierwszą liczbą /St, a przykład książkowy mapuje rzymską przedmowę, arabskie strony korpusu i aneks A- na trzy zakresy
Zakres biegnie do następnego klucza, specyfikacja wymaga wartości dla indeksu strony 0, a GetPageLabel stosuje ostatni zakres, którego klucz jest na stronie lub poniżej niej, więc każda strona rozwiązuje się do czegoś
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Strony 1-4: i, ii, iii, iv (rzymskie małe)
    Lib.AddPageLabels(1, 3, 1, '');
    // Strony 5-120: 1, 2, 3 ... (dziesiętne)
    Lib.AddPageLabels(5, 1, 1, '');
    // Strony 121 wzwyż: A-1, A-2 ... (dziesiętne z prefiksem)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) mapuje swoje argumenty na ten słownik bez niespodzianek, gdy znasz trzy reguły. Start jest liczony od 1 jak każdy inny argument strony w tej bibliotece i trafia do drzewa jako Start - 1. Style biegnie od 0 do 5, gdzie 0 znaczy sam prefiks, a 1 do 5 stają się wartościami /S: D, R, r, A i a; cokolwiek poza tym zakresem zwraca 0 i niczego nie dotyka. Offset staje się /St tylko, gdy jest większy od zera, więc podanie 0 po prostu pomija klucz, a przeglądarka cofa się do domyślnej 1. Ponieważ etykiety stron przyszły z PDF 1.3, wywołanie puszcza też EnsureMinVersion('1.3', '/PageLabels'), które podnosi wersję wyjściową starszego pliku, chyba że jawnie zablokowałeś wersję zapisu

Dlaczego nowe etykiety stron znikają, gdy drzewo ma /Kids?

Nowe etykiety znikają, bo ISO 32000-1 §7.9.7 (Tabela 37) każe korzeniowi drzewa numerycznego nieść /Kids albo /Nums, nigdy oba, a wcześniejszy helper NumTreeSet umiał szukać tylko /Nums. Producenci długich dokumentów często rozdzielają drzewo na węzły pośrednie, każdy z parą /Limits, i wieszają je na korzeniu, który ma tylko /Kids. Stary kod nie znajdował /Nums na tym korzeniu, tworzył świeżą obok istniejących /Kids i wstawiał tam nowy zakres. Wynikiem był korzeń z dwoma wzajemnie wykluczającymi się punktami wejścia. Przeglądarki schodzą przez /Kids i nigdy nie patrzą na obcą tablicę, własny EnumNumTree biblioteki też sprawdza najpierw /Kids, a NumTreeLookup odrzuca węzeł, w którym HasKids xor HasNums daje false. AddPageLabels nadal zwracało 1, a zapisany plik otwierał się bez zarzutu, co jest najgorszym rodzajem awarii: nic nie narzeka, etykiety po prostu zostają takie same

Poprawka w NumTreeSet zamienia korzeń w liść, zanim cokolwiek zostanie wstawione. Gdy korzeń niesie /Kids, EnumNumTree obchodzi każdy liść po kolei i zbiera każdą parę klucz–wartość, z tej listy budowana jest nowa płaska tablica /Nums, a /Kids, /Limits i ewentualne nieaktualne /Nums są czyszczone z korzenia, zanim płaska tablica zostanie przyczepiona. Zrzucenie /Limits to nie kosmetyka, bo Tabela 37 dopuszcza ten wpis tylko na węzłach pośrednich i liściach, nigdy na korzeniu. Od tego momentu wstawienie to zwykły sortowany insert do jednej tablicy, a istniejące zakresy przetrwają ze swoimi oryginalnymi słownikami etykiet. Kompromis jest celowy: drzewo nie jest potem przebudowywane na zbalansowane węzły /Kids. Przy etykietach stron to nic nie kosztuje, bo nawet duży podręcznik referencyjny rzadko ma więcej niż kilkadziesiąt zakresów, a pojedynczy liść i tak pisze większość producentów

Naprawa drzewa numerycznego w PDFlibPas: korzeń niosący /Kids i obcą tablicę /Nums jest niewidoczny dla przeglądarek, bo ISO 32000-1 dopuszcza tylko jedno z dwóch, więc NumTreeSet spłaszcza każdy liść do pojedynczej tablicy /Nums i czyści /Kids oraz /Limits, których Tabela 37 nigdy nie dopuszcza na korzeniu
Nic nie narzekało, bo każde sprawdzenie przechodziło: AddPageLabels zwracało 1, zapisany plik otwierał się bez zarzutu, a czytnik schodzący najpierw po /Kids — tak jak robią to przeglądarki i sama biblioteka — nigdy nie znajduje nowego zakresu
// Przemianowanie aneksu w pliku, którego korzeń /PageLabels używa /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // np. A-1
  // Zastąp zakres zaczynający się na stronie 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Istniejące zakresy rzymskie i dziesiętne są nadal w spłaszczonym liściu
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, bez zmian
end;

Jak tablica /Nums bywa błędnie czytana jako klucze?

Tablica /Nums bywa błędnie czytana, gdy kod obchodzi ją element po elemencie, bo ta tablica to płaski bieg naprzemiennych par, [key0 value0 key1 value1 ...], a kluczami są tylko pozycje parzyste. Stara pętla NumTreeSet testowała każdy element pod kątem typu liczbowego, więc wartość, która akurat była liczbą, była porównywana jak klucz; trafienie mniej niż mogło ustawić punkt wstawienia na nieparzysty indeks i wrzucić nową parę w środek istniejącej, wysuwając każdą późniejszą parę z fazy. EnumNumTree miał ten sam jedno-krokowy spacer. Oba idą teraz parami z krokiem dwa, czytając klucz na X * 2 i wartość na X * 2 + 1, a dokładne trafienie klucza zastępuje wartość i wychodzi przez Break. Uczciwie mówiąc, wartości etykiet stron to słowniki, więc ten drugi błąd rzadko odpalał na samym /PageLabels, ale helper drzewa numerycznego czytający zły krok jest zepsuty od chwili, gdy jakakolwiek wartość jest liczbą, i został naprawiony w tym samym przebiegu

Poprawka kroku par w drzewach numerycznych PDFlibPas: tablica /Nums to płaski bieg naprzemiennych wpisów klucz i wartość, więc spacer testujący każdy element mógł wstawić nową parę na nieparzysty indeks i wysunąć późniejsze pary z fazy, podczas gdy poprawiony spacer czyta klucz na X*2 i wartość na X*2+1
Błąd rzadko odpalał na /PageLabels, bo wartości etykiet to słowniki, ale helper drzewa numerycznego czytający zły krok psuje się od chwili, gdy jakakolwiek wartość jest liczbą, więc oba spacery teraz stępują parami

Odczyt etykiet i podróż w obie strony

TPDFlib.GetPageLabel(Page) zwraca etykietę strony liczonej od 1 i ma dwa fallbacki warte poznania. Bez jakiegokolwiek wpisu /PageLabels zwraca dziesiętny numer strony, więc wołający może używać go bezwarunkowo. Z drzewem obecnym, ale bez zakresu pokrywającego stronę, zwraca pusty łańcuch, co dzieje się dokładnie wtedy, gdy plik pomija obowiązkowy wpis indeksu 0; dokumentacja referencyjna mówi, że zakres zaczynający się na stronie 1 musi istnieć, żeby etykiety wyświetlały się poprawnie, i kod czyni to wymaganie widocznym. Style literowe idą za specyfikacją, a nie za kolumnami arkusza: po Z przychodzi AA, potem BB, z powtórzeniem litery zamiast przenoszenia

var
  P: Integer;
  Data: WideString;
begin
  // Szybki audyt tego, co przeglądarka pokaże w swoim polu strony
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Wartość opcji 4 eksportuje tylko zakresy etykiet jako rekordy PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // Import odtwarza je przez ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Przy edycjach hurtowych ExportDocumentData z wartością opcji 4 zapisuje każdy zakres jako blok PageLabelBegin z wierszami PageLabelNewIndex, PageLabelStart, PageLabelPrefix i PageLabelNumStyle, a ImportDocumentData traktuje pierwszy napotkany rekord etykiety jako pełne zastąpienie: woła raz ClearPageLabels, a potem podaje każdy rekord do AddPageLabels. To czyni podróż tekstową w obie strony deterministyczną nawet wtedy, gdy oryginalny plik używał drzewa /Kids, bo czyszczenie usuwa cały wpis katalogu, a przebudowane drzewo od początku jest pojedynczym liściem

Czego poprawka nadal nie gwarantuje?

Spłaszczanie jest jednokierunkowe i ufa znalezionej kolejności. EnumNumTree zbiera pary w kolejności pliku, a GetPageLabel stosuje ostatni zakres, którego klucz jest mniejszy lub równy indeksowi strony, więc obcy plik z liśćmi w złej kolejności, którego §7.9.7 zabrania, ale który krąży, nadal może dawać złe etykiety, dopóki nie przebudujesz zakresów przez ClearPageLabels i świeże wywołania AddPageLabels. Etykiety są też związane z indeksami stron, a nie obiektami stron, więc każda operacja zmieniająca liczbę albo kolejność stron zostawia zakresy tam, gdzie były. Podmiana w miejscu, jak zastępowanie stron z zachowaniem numerów obiektów, trzyma liczbę, a więc i etykiety w ryzach, natomiast scalanie, jak kolacjonowanie przeplatanych skanów dwustronnych, produkuje nową sekwencję stron, która zasługuje na świeżo wypisany zestaw zakresów

Wywołania etykiet stron, obsługa drzew numerycznych i opisany tu eksport oraz import danych dokumentu trafiają razem do PDF Library for Delphi dla Delphi, C++Buildera i Lazarusa, a wpis referencyjny AddPageLabels dokumentuje wartości stylów i kody zwrotne