Технічна стаття

PDF Page Labels у Delphi: виправлення number tree з /Kids

PDF Library for Delphi пише діапазони page labels через AddPageLabels, і з v3.539.10 цей виклик працює також на завантажених файлах, чиє число-дерево /PageLabels розрізане на вузли /Kids: корінь сплощується в єдиний лист /Nums ще до того, як туди піде новий діапазон, тож мітка насправді з'являється у переглядачі, а не тихо ігнорується. Типова жертва — PDF у книжковому стилі з видавничої програми, з римськими цифрами у вступі, арабською нумерацією в тілі і додатком із мітками A-1, A-2, де ви хотіли перемаркувати лише додаток — а не змінилося нічого

Що таке PDF page labels і як вони зберігаються?

Page labels — це рядки, які переглядач показує у своєму полі сторінки замість фізичного індексу сторінки, а ISO 32000-1 §12.4.2 зберігає їх як число-дерево під ключем каталогу /PageLabels. Кожен ключ — це індекс сторінки з нуля, з якого починається діапазон міток, а кожне значення — словник page label щонайбільше з трьома записами: /S для стилю нумерації (D, R, r, A чи a), /P для рядка-префікса і /St для числового значення першої сторінки діапазону, яке за замовчуванням дорівнює 1. Діапазон тягнеться до наступного ключа, і специфікація вимагає, щоб дерево містило значення для індексу сторінки 0, тож кожна сторінка вкрита якимось діапазоном

Зберігання page labels у термінах PDFlibPas: число-дерево /PageLabels ключує кожен діапазон за його стартовою сторінкою з нуля, кожне значення — словник мітки зі стилем /S, префіксом /P і першим числом /St, а книжковий приклад відображає римський вступ, арабські сторінки тіла і додаток A- на три діапазони
Діапазон тягнеться до наступного ключа, специфікація вимагає значення для індексу сторінки 0, а GetPageLabel застосовує останній діапазон, чий ключ не більший за сторінку, тож кожна сторінка отримує якусь мітку
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Сторінки 1-4: i, ii, iii, iv (малі римські)
    Lib.AddPageLabels(1, 3, 1, '');
    // Сторінки 5-120: 1, 2, 3 ... (десяткові)
    Lib.AddPageLabels(5, 1, 1, '');
    // Сторінки 121 і далі: A-1, A-2 ... (десяткові з префіксом)
    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) відображає аргументи на той словник без сюрпризів, щойно знаєш три правила. Start рахується від 1, як і будь-який інший сторінковий аргумент у бібліотеці, і записується в дерево як Start - 1. Style йде від 0 до 5, де 0 означає лише префікс, а від 1 до 5 перетворюється на значення /S D, R, r, A і a; усе поза цим діапазоном повертає 0 і нічого не торкає. Offset стає /St лише коли він більший за нуль, тож передача 0 просто пропускає ключ, і переглядач падає назад до усталеного значення 1. Оскільки page labels з'явилися в PDF 1.3, виклик також ганяє EnsureMinVersion('1.3', '/PageLabels'), який піднімає вихідну версію старішого файлу, якщо ви явно не закрили версію збереження

Чому нові page labels зникають, коли в дерева є /Kids?

Нові мітки зникають, бо ISO 32000-1 §7.9.7 (таблиця 37) велить кореневі числа-дерева нести або /Kids, або /Nums, але ніколи не обидва, а старий хелпер NumTreeSet умів шукати лише /Nums. Продюсери довгих документів часто ріжуть дерево на проміжні вузли, кожен із парою /Limits, і вішають їх на корінь, у якого є тільки /Kids. Старий код не знаходив /Nums на тому корені, створював свіжий поруч із наявним /Kids і вставляв новий діапазон туди. Результатом був корінь із двома взаємновиключними входами. Переглядачі спускаються крізь /Kids і ніколи не дивляться на випадковий масив, власний EnumNumTree бібліотеки теж спершу перевіряє /Kids, а NumTreeLookup відкидає вузол, де HasKids xor HasNums хибне. AddPageLabels усе ще повертав 1, і збережений файл усе ще відкривався чисто — а це найгірший тип збою: ніхто не скаржиться, мітки просто лишаються тими самими

Виправлення в NumTreeSet перетворює корінь на лист, перш ніж вставляти що-небудь. Коли корінь несе /Kids, EnumNumTree обходить кожен лист по порядку і збирає кожну пару ключ-значення, з того списку будується новий плаский масив /Nums, а /Kids, /Limits і будь-який застояний /Nums виганяються з кореня, перш ніж плаский масив буде причеплено. Викид /Limits — не косметика: таблиця 37 дозволяє цей запис лише на проміжних і листових вузлах, але ніколи на корені. З цієї миті вставлення — звичайне впорядковане вставляння в один масив, а наявні діапазони виживають із своїми оригінальними словниками міток. Компроміс навмисний: дерево не перебудовується назад у збалансовані вузли /Kids. Для page labels це нічого не коштує, бо навіть великий довідковий мануал рідко має більше кількох десятків діапазонів, а один лист — це те, що більшість продюсерів і так пишуть

Ремонт числа-дерева в PDFlibPas: корінь, що несе /Kids і випадковий масив /Nums, невидимий для переглядачів, бо ISO 32000-1 дозволяє лише одне з двох, тож NumTreeSet сплощує кожен лист в єдиний масив /Nums і виганяє /Kids і /Limits, які таблиця 37 ніколи не дозволяє на корені
Ніхто не скаржився, бо кожна перевірка пройшла: AddPageLabels повернув 1, збережений файл відкрився чисто, а читач, що спускається спершу крізь /Kids — так роблять і переглядачі, і сама бібліотека, — новий діапазон так і не знаходить
// Перемаркувати додаток у файлі, чий корінь /PageLabels використовує /Kids
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // напр. A-1
  // Замінити діапазон, що починається зі сторінки 121: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Наявні римські та десяткові діапазони досі в сплощеному листі
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, без змін
end;

Як масив /Nums можна неправильно прочитати як ключі?

Масив /Nums читається неправильно, коли код обходить його по одному елементу, бо масив — це пласка низка чергованих пар, [key0 value0 key1 value1 ...], і лише парні позиції — це ключі. Старий цикл NumTreeSet перевіряв кожен елемент на числовий тип, тож значення, яке випадково виявилося числом, порівнювалося так, ніби це ключ; спрацювання "менше" могло поставити точку вставляння на непарний індекс і вкинути нову пару в середину наявної, зсунувши всі пізніші пари з фази. EnumNumTree мав той самий однокроковий обхід. Тепер обидва ітерують парами з кроком два, читаючи ключ на X * 2 і значення на X * 2 + 1, а точний збіг ключа замінює значення і виходить через Break. Справедливості заради: значення page labels — словники, тож цей другий баг рідко спрацьовував на самому /PageLabels, але хелпер числа-дерева, який читає неправильний крок, — зіпсований щойно якесь значення числове, і його полагодили в тому самому проході

Виправлення кроку пар у числа-деревах PDFlibPas: масив /Nums — це пласка низка чергованих записів ключів і значень, тож обхід, що перевіряє кожен елемент, міг вставити нову пару на непарний індекс і зсунути пізніші пари з фази, тоді як виправлений обхід читає ключ на X*2 і значення на X*2+1
На /PageLabels баг рідко спрацьовував, бо значення міток — словники, але хелпер числа-дерева з неправильним кроком псується, щойно якесь значення числове, тож обидва обходи тепер крокують парами

Зворотне читання міток і текстовий round-trip

TPDFlib.GetPageLabel(Page) повертає мітку для сторінки з нумерацією від 1 і має два запасні варіанти, про які варто знати. Коли запису /PageLabels немає взагалі, він повертає десятковий номер сторінки, тож викликач може користуватися ним безумовно. Коли дерево є, але жоден діапазон не вкриває сторінку, він повертає порожній рядок — рівно те, що станеться, коли файл пропускає обов'язковий запис для індексу 0; довідкова документація каже, що для коректного показу міток має існувати діапазон, що починається зі сторінки 1, і код робить цю вимогу видимою. Літерні стилі йдуть за специфікацією, а не за стовпцями електронної таблиці: після Z іде AA, потім BB — літера повторюється, а не переноситься

var
  P: Integer;
  Data: WideString;
begin
  // Швидкий аудит того, що переглядач покаже у своєму полі сторінки
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Значення опції 4 експортує лише діапазони міток як записи PageLabelBegin
  Data := Lib.ExportDocumentData(4);
  // Імпортування промотує їх через ClearPageLabels + AddPageLabels
  Lib.ImportDocumentData(Data, 0);
end;

Для масових редагувань ExportDocumentData зі значенням опції 4 пише кожен діапазон як блок PageLabelBegin з рядками PageLabelNewIndex, PageLabelStart, PageLabelPrefix і PageLabelNumStyle, а ImportDocumentData трактує перший побачений запис мітки як повну заміну: він один раз викликає ClearPageLabels, а потім згодовує кожен запис у AddPageLabels. Це робить текстовий round trip детермінованим навіть тоді, коли оригінальний файл використовував дерево /Kids, бо очищення забирає весь запис каталогу, а перебудоване дерево — один лист від самого початку

Чого виправлення досі не гарантує?

Сплощення — одностороннє і довіряється порядку, який знаходить. EnumNumTree збирає пари у файловому порядку, а GetPageLabel застосовує останній діапазон, чий ключ менший або дорівнює індексу сторінки, тож чужий файл, чиї листи не впорядковані — §7.9.7 це забороняє, але таке в обігу, — досі може давати неправильні мітки, доки ви не перебудуєте діапазони через ClearPageLabels і свіжі виклики AddPageLabels. Мітки прив'язані до індексів сторінок, а не до об'єктів сторінок, тож будь-яка операція, що змінює кількість чи порядок сторінок, лишає діапазони там, де вони були. Заміна на місці, як-от заміна сторінок зі збереженням номерів об'єктів, тримає кількість, а отже, і мітки вирівняними, тоді як злиття на кшталт впорядкування переплетених дуплексних сканкопій дає нову послідовність сторінок, яка заслуговує на свіжий, написаний заново набір діапазонів

Виклики page labels, робота з числом-деревом і експорт-імпорт даних документа, описані тут, виходять у PDF Library for Delphi для Delphi, C++Builder і Lazarus, а довідковий запис AddPageLabels документує значення стилів і коди повернення