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

Редагування PDF-закладок і переназначення сторінок у Delphi

Видаліть сім сторінок із 200-сторінкового довідника — і кожна закладка опиниться не там, де треба. Рішення не в тому, щоб перебудувати дерево закладок із плаского списку заголовків. PDFiumPas надає TPdfOutlineEditor, який завантажує справжнє дерево закладок, дозволяє переміщувати й перенацілювати елементи, а потім виконує ApplyPageMap, щоб провести кожне явне призначення крізь ваш план сторінок

Чому видалення сторінок ламає кожну закладку?

Бо елемент закладок не зберігає номер сторінки. Він зберігає посилання на об'єкт сторінки, і коли об'єкти сторінок змінюються, посилання вказує або на сторінку, що переїхала, або нікуди взагалі. ISO 32000-1 §12.3.2.2 визначає явне призначення як масив, чий перший елемент — непряме посилання на словник сторінки, за яким слідує fit-ім'я на кшталт /Fit чи /XYZ. Видаліть сторінку — і залишиться висяче посилання; перевпорядкуйте сторінки — і посилання досі чинне, але вже описує інший розділ. PDFiumPas під час завантаження розгортає цей масив назад у номер сторінки, тож TPdfOutlineItem.PageNumber дає вам індекс сторінки з одиниці, що збігається з публічним API TPdf, а не з номером об'єкта. У цьому вся суть абстракції: ваша логіка переназначення працює в тій самій системі координат, що й план сторінок, який ви вже побудували, коли розрізали, перевпорядкували чи виконали спуск смуг документа. Якщо ви будуєте цей план, та сама конвенція «з одиниці» проходить крізь розрізання PDF-документів на кілька файлів і крізь n-up спуск смуг і перевпорядкування сторінок

Закладки — це двозв'язне дерево, а не список

Причина, з якої не можна просто серіалізувати плаский масив заголовків, у тому, що ISO 32000-1 §12.3.3 зшиває кожен елемент закладок п'ятьма окремими посиланнями: /Parent, /Prev, /Next, /First і /Last. Тож переміщення одного піддерева переписує старого батька, нового батька, обох сусідів з кожного боку розрізу й точки вставки, а також вказівник на батька у самого перенесеного вузла. Помилитесь в одному з них — і сумлінні читачі покажуть обрізане дерево або зацикляться. PDFiumPas тримає стан редагування як масив записів TPdfOutlineItem у порядку обходу вглиб зі стабільним цілочисельним Id, тож піддерево — це неперервний зріз, а ланцюжок сусідів виводиться, а не ведеться руками. TPdfOutlineEditor.Move підіймає цей зріз, вставляє його під нового батька за вказаним індексом серед сусідів і перепризначає лише корінь блоку. Він також відмовляє у двох переміщеннях, які зіпсували б граф: перенести елемент у власне піддерево та назвати батьком неіснуючий вузол

Редагування закладок у PDFiumPas у Delphi: перенесення Розділу 3 з Частини I під корінь документа переписує вказівник /Parent перенесеного вузла плюс /First і сусідні /Prev та /Next навколо розрізу й точки вставки
Один виклик Move переписує вказівник на батька у піднятого піддерева та сусідні посилання з обох боків розрізу й точки вставки

Чому /Count знаковий?

Бо знак несе стан розгортання, а не розмір. Додатний /Count означає, що елемент розгорнутий, а число показує, скільки нащадків зараз видно; від'ємний /Count означає, що елемент згорнутий. PDFiumPas записує кількість нащадків для кожного елемента, що має дітей, і робить її від'ємною, коли IsOpen дорівнює False, а під час завантаження читає стан назад як IsOpen := HasCount and (CountValue > 0). Це найпоширеніший саморобний баг у генераторах закладок: записати беззнакове значення й мовчки розгорнути все дерево

Як PDFiumPas кодує стан розгортання закладок у Delphi: додатний /Count означає, що елемент розгорнутий і рахує видимих нащадків, від'ємний /Count означає згорнутий стан, а беззнакове значення змушує кожен читач розгорнути все дерево
Знак /Count — це стан розгортання, а модуль — кількість видимих нащадків, тож беззнакове значення мовчки розгортає все дерево
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // стає другою дитиною кореня
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // записує від'ємний /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget обробляє обидві форми, які дозволяє специфікація. Передайте DestinationInAction як False — і PDFiumPas запише прямий масив /Dest; передайте True — і він запише дію Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, згідно з ISO 32000-1 §12.6.4.2. В обох випадках спершу з елемента знімаються будь-які наявні /Dest і /A, щоб вони не могли співіснувати й суперечити одне одному. Суфікс усталено дорівнює /Fit і мусить починатися з PDF-імені — саме тому порожній або кривий суфікс одразу спричиняє помилку замість масиву призначення, який не зможе розібрати жоден читач

Як ApplyPageMap обробляє план сторінок?

ApplyPageMap приймає рівно той масив, який ваш план сторінок уже звірив: NewPageNumbers, індексований старою сторінкою мінус один, з новим номером сторінки з одиниці або нулем, якщо та сторінка не вижила. Він обходить масив елементів з кінця, щоб видалення піддерева ніколи не інвалідувало індекс, який ще належить відвідати, і звітує про зроблене через RemappedDestinationCount і RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Один елемент на кожну сторінку ПОЧАТКОВОГО документа
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == цю сторінку відкинуто

  NewPageNumbers[0] := 1;                // стара сторінка 1 -> нова сторінка 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // стара сторінка 10 -> нова сторінка 3

  // True: видалити все висяче піддерево. False: лишити елемент, зняти його ціль
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

Прапорець DeleteDangling визначає політику для призначення, що відобразилося в нуль, і обидві гілки — навмисні. З True PDFiumPas видаляє елемент і все його піддерево, бо вузол закладок, чия ціль зникла, зазвичай очолює розділ, що зник разом із нею. З False елемент виживає — заголовок і ієрархія на місці, але /Dest і /A зняті, — і саме це потрібно, коли людина збирається перенацілити його під час рецензування. Справді криві вхідні дані все одно голосно падають замість латання: від'ємний елемент або призначення за межами поданої карти повертає False з IssueKind, рівним poviInvalidPageMap

Як ApplyPageMap у PDFiumPas перенаправляє PDF-закладки в Delphi: карта сторінок, індексована старою сторінкою мінус один, надсилає виживші призначення на нові номери сторінок, а елементи, що відобразилися в нуль, або видаляються разом із піддеревом, або втрачають ціль
Карта сторінок індексується старою сторінкою мінус один, а нульовий елемент або видаляє висяче піддерево, або лишає елемент без цілі

Непрозорі елементи і чесний компроміс

Не в кожного елемента закладок є номер сторінки, який PDFiumPas здатен осмислити. Три види проходять крізь недоторканими: іменовані призначення, дії, що не є /S /GoTo, і невідомі ключі словника, додані тим, хто створив файл. Вони завантажуються з PageNumber, рівним нулю, тримають свої первинні байти в елементі й записуються назад дослівно, якщо ви явно не викличете на них Retarget

  • Іменоване призначення — це ключ у дерево імен документа, тож коректне переназначення означає розгорнути дерево і переписати цільовий запис, а не вгадувати на рівні закладок
  • Дія /URI, /Launch чи JavaScript узагалі не має семантики сторінок і не має мовчки перетворюватися на Go-To
  • Ключі постачальника та структурні призначення зберігаються, бо викидати те, чого ви не розумієте, — саме так round-trip втрачає дані

Ціна реальна, і її варто назвати прямо: ApplyPageMap повністю пропускає такі елементи, тож документ, чиї закладки всі використовують іменовані призначення, вийде з видалення сторінок зі структурно чинним, але семантично застарілим змістом. Це навмисний вибір — застаріле посилання, яке рецензент помітить, краще за впевнено хибне, якого ніхто не зауважить. Якщо ви сортуєте вхідні файли перед редагуванням, інвентарний прохід у станції огляду вхідних PDF покаже, які документи належать до цієї категорії

Збереження: інкрементна ревізія, потім незалежне перечитування

TPdfOutlineEditor.SaveIncremental додає розріджену інкрементну ревізію, а не переписує файл. Елементи, які було завантажено, зберігають первинне непряме посилання на об'єкт, аж до точного покоління, тож наявні перехресні посилання лишаються чинними; свіжий номер отримують лише додані вами елементи, які виділяються з одиниці після максимального номера об'єкта ревізії. Каталог оновлюється в тій самій ревізії, а відсутній запис /Outlines додається до нього, якщо в джерелі взагалі не було закладок

Що відбувається після запису — це частина, яку варто перейняти. PDFiumPas наново відкриває цільовий потік повністю незалежним редактором і звіряє перечитане дерево з тим, що в пам'яті: кількість елементів, заголовки, номери сторінок, суфікси призначень, форму «дія чи пряме призначення», стилі, стан розгортання та батьківські зв'язки. Будь-яка розбіжність або збій завантаження очищає цільовий потік і повертає poviVerificationFailure замість того, щоб віддати вам файл, який лише виглядає правдоподібно. Шифровані джерела відхиляються одразу з poviEncryptedInput, бо нові заголовки й призначення створюють текстовий вміст, який неможливо отримати копіюванням /Encrypt trailer наперед

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Сприймайте закладки як те, чим вони є — граф пов'язаних об'єктів із власними інваріантами, — і видалення сторінок перестане бути катастрофою закладок і стане картою сторінок, яку ви віддаєте одному виклику методу. TPdfOutlineEditor, ApplyPageMap і звірений інкрементний записувач входять до PDFiumPas починаючи з v3.98.0 для Delphi, C++Builder і Lazarus; повний API можна оглянути, а пробну версію завантажити на сторінці продукту PDFium Delphi Component