Tehnički članak

PDF page labele u Delphi-ju: popravka /Kids number tree-ova

PDF Library for Delphi upisuje page label rangove kroz AddPageLabels, i od v3.539.10 taj poziv radi i na učitanim fajlovima čiji je /PageLabels number tree podeljen na /Kids čvorove: koren se izravna u jedan /Nums list pre nego što novi rang uđe, pa se labela zaista pojavi u pregledaču umesto da bude tiho ignorisana. Tipična žrtva je PDF nalik na knjigu iz layout alata, sa rimskim brojevima u prednjem delu, arapskim numerisanjem u glavi i prilogom označenim A-1, A-2, gde ste hteli samo da prelabelite prilog a ništa se nije promenilo

Šta su PDF page labele i kako se čuvaju?

Page labele su stringovi koje pregledač pokazuje u svom polju stranice umesto fizičkog indeksa stranice, a ISO 32000-1 §12.4.2 ih čuva kao number tree pod ključem kataloga /PageLabels. Svaki ključ je indeks stranice sa bazom nula koji započinje labeling rang, a svaka vrednost je page label rečnik sa najviše tri unosa: /S za stil numerisanja (D, R, r, A ili a), /P za prefiks string, i /St za numeričku vrednost prve stranice u rangu, koja po podrazumevanju glasi 1. Rang traje do sledećeg ključa, i specifikacija zahteva da drvo sadrži vrednost za indeks stranice 0, pa je svaka stranica pokrivena nekim rangom

Čuvanje page labela u pojmovima PDFlibPas-a: /PageLabels number tree ključuje svaki rang njegovom početnom stranicom sa bazom nula, svaka vrednost je label rečnik sa /S stilom, /P prefiksom i /St prvim brojem, a primer knjige mapira rimski prednji deo, arapske stranice glave i A- prilog na tri ranga
Rang traje do sledećeg ključa, specifikacija zahteva vrednost za indeks stranice 0, a GetPageLabel primenjuje poslednji rang čiji je ključ na stranici ili ispod nje, pa se svaka stranica razreši u nešto
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Stranice 1-4: i, ii, iii, iv (mala rimska)
    Lib.AddPageLabels(1, 3, 1, '');
    // Stranice 5-120: 1, 2, 3 ... (decimalno)
    Lib.AddPageLabels(5, 1, 1, '');
    // Stranice 121 i dalje: A-1, A-2 ... (decimalno sa prefiksom)
    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) mapira svoje argumente na taj rečnik bez iznenađenja čim znate tri pravila. Start je sa bazom 1 kao i svaki drugi page argument u biblioteci, i u drvo se upisuje kao Start - 1. Style ide od 0 do 5, gde 0 znači samo prefiks a 1 do 5 postaju /S vrednosti D, R, r, A i a; sve van toga vraća 0 i ništa ne dira. Offset postaje /St samo kad je veći od nule, pa prosleđivanje 0 jednostavno izostavlja ključ i pregledač se vraća na podrazumevanu 1. Pošto su page labele stigle sa PDF 1.3, poziv pokreće i EnsureMinVersion('1.3', '/PageLabels'), koji podigne izlaznu verziju starijeg fajla osim ako ste verziju čuvanja eksplicitno zaključali

Zašto nove page labele nestaju kad drvo ima /Kids?

Nove labele nestaju jer ISO 32000-1 §7.9.7 (Tabela 37) zahteva da koren number tree-a nosi ili /Kids ili /Nums, nikad oba, a stariji NumTreeSet helper znao je samo da traži /Nums. Proizvođači dugih dokumenata često dele drvo na međučvorove, svaki sa parom /Limits, i okače ih na koren koji ima samo /Kids. Stari kod nije našao /Nums na tom korenu, napravio je svež pored postojećih /Kids, i ubacio novi rang tamo. Rezultat je bio koren sa dva međusobno isključiva ulaza. Pregledači silaze kroz /Kids i nikad ne pogledaju odmetnuti niz, i bibliotečki EnumNumTree takođe prvo proverava /Kids, a NumTreeLookup odbija čvor gde HasKids xor HasNums nije tačno. AddPageLabels je i dalje vratio 1 i sačuvani fajl se i dalje uredno otvorio, što je najgora vrsta kvara: ništa ne prigovara, labele samo ostanu iste

Popravka u NumTreeSet-u pretvara koren u list pre nego što bilo šta ubaci. Kada koren nosi /Kids, EnumNumTree obilazi svaki list redom i skuplja svaki par ključ-vrednost, novi ravan /Nums niz se gradi iz te liste, a /Kids, /Limits i svaki zastareli /Nums se očiste sa korena pre nego što se ravan niz zakači. Bacanje /Limits nije kozmetika, jer Tabela 37 dozvoljava taj unos samo na međučvorovima i listovima, nikad na korenu. Od tog trenutka umetanje je običan sortirani umetak u jedan niz, a postojeći rangovi prežive sa svojim originalnim label rečnicima. Kompromis je namera: drvo se posle ne gradi ponovo u izbalansirane /Kids čvorove. Za page labele to ne košta ništa, jer čak i veliki priručnik retko ima više od nekoliko desetina rangova, a jedan list je ionako ono što većina proizvođača upiše

Popravka number tree-a u PDFlibPas-u: koren koji nosi /Kids i odmetnuti /Nums niz je nevidljiv pregledačima jer ISO 32000-1 dozvoljava samo jedno od ta dva, pa NumTreeSet izravna svaki list u jedan /Nums niz i očisti /Kids i /Limits, koje Tabela 37 nikad ne dozvoljava na korenu
Ništa nije prigovorilo jer je svaka provera prošla: AddPageLabels je vratio 1, sačuvani fajl se uredno otvorio, a čitač koji silazi prvo kroz /Kids, kako rade i pregledači i sama biblioteka, nikad ne nađe novi rang
// Prelabelite prilog u fajlu čiji /PageLabels koren koristi /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // npr. A-1
  // Zamenite rang koji počinje na stranici 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Postojeći rimski i decimalni rangovi i dalje su u izravnanom listu
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, nepromenjeno
end;

Kako /Nums niz može da se pogrešno pročita kao ključevi?

/Nums niz se pogrešno pročita kad ga kod obilazi jedan element po jedan, jer je niz ravna traka naizmeničnih parova, [key0 value0 key1 value1 ...], i samo parne pozicije su ključevi. Stara NumTreeSet petlja je testirala svaki element za numerički tip, pa je vrednost koja je slučajno bila broj poređena kao da je ključ; pogodak „manje od” mogao je da postavi tačku umetanja na neparan indeks i spusti novi par usred postojećeg, pomerajući svaki kasniji par iz takta. EnumNumTree je imao isti obilazak korak po korak. Obe sada iteriraju parove sa korakom od dva, čitajući ključ na X * 2 i vrednost na X * 2 + 1, i tačno poklapanje ključa zamenjuje vrednost i izlazi sa Break. Da budemo fer, page label vrednosti su rečnici, pa ovaj drugi bug retko puca na samom /PageLabels-u, ali number-tree helper koji čita pogrešan korak je pokvaren u trenutku kad je bilo koja vrednost broj, i popravljen je u istom prolazu

Popravka koraka parova u PDFlibPas number tree-ovima: /Nums niz je ravna traka naizmeničnih unosa ključeva i vrednosti, pa obilazak koji testira svaki element mogao je da umetne novi par na neparan indeks i pomeri kasnije parove iz takta, dok popravljeni obilazak čita ključ na X*2 i vrednost na X*2+1
Bug je retko pukao na /PageLabels jer su label vrednosti rečnici, ali number-tree helper koji čita pogrešan korak je pokvaren čim je bilo koja vrednost broj, pa oba obilaska sada korakaju u parovima

Čitanje labela nazad i njihov round trip

TPDFlib.GetPageLabel(Page) vraća labelu za stranicu sa bazom 1 i ima dva rezervna ishoda koja vredi znati. Bez ikakvog /PageLabels unosa vraća decimalni broj stranice, pa ga pozivalac može koristiti bezuslovno. Sa drvetom prisutnim ali bez ranga koji pokriva stranicu vraća prazan string, što je tačno ono što se dešava kad fajl preskoči obavezni unos indeksa 0; referentna dokumentacija kaže da rang koji počinje na stranici 1 mora postojati da bi se labele ispravno prikazivale, i kod tu zahtev čini vidljivim. Slovni stilovi prate specifikaciju, a ne kolone proračunskih tablica: posle Z dolazi AA, pa BB, ponavljajući slovo umesto prenošenja

var
  P: Integer;
  Data: WideString;
begin
  // Brzi pregled onoga što će pregledač pokazati u svom polju stranice
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Vrednost opcije 4 izvozi samo label rangeove kao PageLabelBegin zapise
  Data := Lib.ExportDocumentData(4);
  // Uvoz ih ponovo pokreće kroz ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Za masovne izmene, ExportDocumentData sa vrednošću opcije 4 upisuje svaki rang kao PageLabelBegin blok sa linijama PageLabelNewIndex, PageLabelStart, PageLabelPrefix i PageLabelNumStyle, a ImportDocumentData prvi label zapis koji vidi tretira kao potpunu zamenu: jednom pozove ClearPageLabels pa svaki zapis predaje AddPageLabels-u. To tekstualni round trip čini determinističnim čak i kad je originalni fajl koristio /Kids drvo, jer brisanje skida ceo katalog unos, a ponovo izgrađeno drvo je od početka jedan list

Šta popravka i dalje ne garantuje?

Izravnavanje je jednosmerno i veruje redosledu koji nađe. EnumNumTree skuplja parove po redosledu u fajlu, a GetPageLabel primenjuje poslednji rang čiji je ključ manji ili jednak indeksu stranice, pa tuđi fajl čiji su listovi van reda, što §7.9.7 zabranjuje ali što cirkuliše, i dalje može dati pogrešne labele dok rangove ne izgradite ponovo sa ClearPageLabels i svežim AddPageLabels pozivima. Labele su takođe vezane za indekse stranica, a ne za objekte stranica, pa svaka operacija koja menja broj ili redosled stranica ostavlja rangove tamo gde su bili. Zamena na mestu poput zamene stranica uz očuvanje brojeva objekata zadržava broj pa su zato i labele poravnate, dok spajanje poput sređivanja isprepletenih dupleks skenova proizvodi novi redosled stranica koji zaslužuje sveže upisane rangove

Pozivi za page labele, rukovanje number tree-om i izvoz i uvoz podataka dokumenta opisani ovde isporučuju se u PDF Library for Delphi za Delphi, C++Builder i Lazarus, sa referentnim unosom za AddPageLabels koji dokumentuje stil vrednosti i povratne kodove