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

Видалення сторінок PDF у Delphi без висячих посилань

HotPDF Delphi Component видаляє сторінку із завантаженого PDF через THotPDF.DeletePage, і від версії 2.751.0 цей виклик також вичищає кожне посилання рівня документа, яке досі вказує на ту сторінку: названі пункти призначення в дереві /Names /Dests, застарілий словник /Dests у каталозі, дії /GoTo закладок, елементи структури під /StructTreeRoot, ParentTree, записи OBJR для анотацій і анотації-посилання на сторінках, що лишилися. Дерево сторінок перебудовується останнім, після того, як ніщо інше вже не може дістатися видаленого об'єкта

Відмова, якій це запобігає, легко відтворюється й важко діагностується. Видаліть титульну сторінку тегованого звіту, збережіть і відкрийте результат: Acrobat показує правильну кількість сторінок, але закладка «Contents» тепер веде в нікуди, перевірка доступності повідомляє про елемент структури без сторінки, а строгий валідатор перелічує посилання на звільнений об'єкт. У дереві сторінок усе правильно. Проблема в тому, що сторінка PDF — це не лише листок /Pages; це ціль, на яку вказує половина каталогу, і видалення листка лишає кожен із тих вказівників висячим

Чому видалення сторінки з /Kids недостатньо?

Бо ISO 32000-1 дозволяє щонайменше семи незалежним структурам тримати посилання на об'єкт сторінки, і лише одна з них — це дерево сторінок. Видалення сторінки з /Kids і зменшення /Count задовольняє §7.7.3, а кожне інше посилання стає вказівником на об'єкт, який або звільнено в xref, або просто відсутній у перезаписаному файлі. Переглядач, який піде за одним із тих вказівників, отримає null, а що він зробить із цим null — справа самого переглядача

  • Дерево імен під /Names /Dests (§7.7.4, §12.3.2.3) зіставляє імена з масивами пунктів призначення, чий перший елемент — це сторінка
  • Словник /Dests до версії 1.2 прямо в каталозі тримає такі самі масиви з ключами-іменами
  • Елементи закладок (§12.3.3) доходять до сторінки або через inline /Dest, або через дію /A з /S /GoTo і масивом /D
  • Елементи структури (§14.7.2) несуть ключ /Pg, що називає сторінку, на якій живе їхній marked content, а їхні нащадки /K можуть бути посиланнями на marked content і посиланнями на об'єкти (§14.7.4.3), прив'язаними до тієї сторінки
  • ParentTree (§14.7.4.4) зіставляє номери /StructParents сторінок і анотацій назад з елементами структури, і елемент може жити там, зовсім не з'являючись у ланцюгу /K від кореня
  • Анотації-посилання на інших сторінках (§12.5.6.5) несуть /Dest або дію /GoTo, націлену на цю сторінку, і /OpenAction каталогу може робити те саме
Чому видалення сторінки HotPDF з /Kids недостатньо: ISO 32000-1 дозволяє дереву імен /Names /Dests, застарілому словнику /Dests у каталозі, елементам закладок, елементам структури з /Pg, ParentTree, анотаціям-посиланням і /OpenAction тримати посилання на той самий об'єкт сторінки, а перебудовується лише дерево сторінок
Сторінка PDF — це ціль, на яку вказує половина каталогу: видалення листка задовольняє дерево сторінок, поки кожен інший вказівник розв'язується в null, тож обрізаний звіт втрачає закладку Contents і не проходить перевірку доступності

Що THotPDF.DeletePage вичищає до того, як торкнутися дерева сторінок?

THotPDF.DeletePage(PageIndex) на завантаженому документі спершу виконує весь обхід посилань, потім позначає об'єкт сторінки видаленим через DeleteObj, від'єднує будь-які віджети від дерева полів AcroForm, зсуває внутрішній масив сторінок і нарешті викликає RebuildLoadedPageTree, щоб перезаписати /Kids, /Count і /Parent кожної вцілілої сторінки. Обхід відвідує каталог у фіксованому порядку: дерево імен /Names /Dests, словник /Dests старого стилю, /OpenAction, дерево закладок, /StructTreeRoot разом із його ParentTree і, нарешті, масиви /Annots кожної сторінки, що лишається. Кожен крок вирішує, чи посилання видаляється, перецілюється або лишається недоторканим, виходячи з того, що специфікація дозволяє цій структурі робити без сторінки. Перед усім цим діють дві перевірки: DeletePage піднімає Invalid page number для індексу поза діапазоном і відмовляється видаляти останню сторінку, бо вузол /Pages із нульовою кількістю нащадків не є валідним PDF, а DeletePages приймає ту саму 1-based нотацію "1,3-5,7-", що й інші операції зі сторінками завантаженого документа, і перебирає від найбільшого вибраного індексу вниз, щоб написані вами індекси лишалися чинними під час роботи

Фіксований обхід посилань, який THotPDF.DeletePage виконує до торкання дерева сторінок: перевірки відкидають індекс поза діапазоном або останню сторінку, далі вичищаються /Names /Dests і застарілий /Dests, /OpenAction відкидається, закладки перецілюються на NearestRetainedPage, StructTreeRoot і ParentTree вичищаються, посилання вцілілих сторінок вилучаються, і останнім виконується RebuildLoadedPageTree
Кожна структура отримує те, що дозволяє специфікація: імена зникають, закладки приземляються на найближчу вцілілу сторінку, елементи структури втрачають /Pg або зникають, а перезапис /Kids відбувається лише після того, як ніщо інше не може дістатися видаленого об'єкта
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Нульовий індекс: прибрати титульну сторінку. Названі
      // пункти призначення, закладки, дерево структури, ParentTree
      // і анотації-посилання, що вказували на неї, вичищаються
      // до перебудови дерева /Pages.
      Pdf.DeletePage(0);
      // 1-based синтаксис діапазонів для пакетів, внутрішньо
      // від найбільшого індексу, щоб попередні лишалися чинними.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Чим відрізняється обробка названих пунктів призначення і закладок?

Названі пункти призначення видаляються, а закладки перецілюються, бо ім'я, якого більше не існує, — прийнятний результат, тоді як закладка без пункту призначення — видимий дефект. У дереві /Names /Dests HotPDF обходить кожен вузол, перевіряє кожен пункт призначення — і в голій формі масиву, і у формі словника з ключем /D — проти видаленої сторінки й вилучає пару ім'я/значення, коли перший елемент масиву — це та сторінка. Вузол, у якого і /Names, і /Kids виявляються порожніми, позначається видаленим і від'єднується від батька, тож дерево ніколи не тримає порожнистих листків. Той самий тест виконується над словником /Dests старого стилю в каталозі, а /OpenAction каталогу просто відкидається, якщо воно відкривало видалену сторінку. Одна межа тут: коли вузол дерева імен втрачає записи, HotPDF видаляє пару /Limits цього вузла замість того, щоб перерахувати новий найнижчий і найвищий ключ, і хоча переглядачі чудово розв'язують імена й без неї, строга перевірка відповідності, читаючи ISO 32000-1 §7.9.6, може позначити не-кореневий вузол без /Limits

Елементи закладок ідуть протилежним шляхом. RetargetOutlineDestinations обходить /First і /Next від кореня закладок зі списком відвіданих і обмеженням глибини 128, щоб зіпсоване циклічне дерево не могло підвісити виклик, і для кожного масиву /Dest або масиву /D дії /GoTo, націленого на цю сторінку, замінює перший елемент на NearestRetainedPage: сторінку, що йшла за видаленою, або сторінку перед нею, коли видалена була останньою. Параметри вигляду після посилання на сторінку лишаються як були. Тож закладка, яка вказувала на видалений відкривач розділу, приземляється на першу сторінку того, що лишилося, а не зникає з бічної панелі — і саме такої поведінки чекають рецензенти від обрізаного документа. Утім, тест пункту призначення зіставляє лише явні масиви: елемент закладки, чий /Dest є рядком-іменем, який раніше резолвився у видалену сторінку, не перецілюється, бо запис у дереві імен зник і посилання тепер не резолвиться нікуди, а не в звільнений об'єкт, тож переглядач трактує його як мертву закладку. Механіка самого дерева закладок — /First, /Next і неочевидна семантика /Count — розібрана в посібнику зі додавання закладок і названих пунктів призначення до завантаженого PDF

// Перевіряйте обхід, а не довіряйте йому.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Закладка, що цілилася в титульну сторінку, тепер резолвиться
// в сторінку, що йшла за нею (нульовий індекс 0 після видалення).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

Що стається з деревом структури та ParentTree?

Елементи структури, які існують лише завдяки видаленій сторінці, вилучаються, а елементи, що охоплюють кілька сторінок, втрачають ключ /Pg, але зберігають своїх нащадків. PruneStructureElement спускається ланцюгом /K від /StructTreeRoot на глибину 128, обробляючи і форму масиву, і форму єдиного словника для /K, які дозволяє §14.7.2. Для кожного елемента він спершу вичищає нащадків, а потім оцінює сам елемент: якщо вичищення спорожнило його /K, елемент позначається видаленим і батько його відкидає. Якщо власний /Pg елемента називає видалену сторінку, а в елемента ще є нащадки й батько /P, то вилучається лише /Pg, бо /Pg на елементі — це типова сторінка для його нащадків marked content, а ті нащадки можуть явно посилатися на інші сторінки. Лише елемент, чий /Pg є видаленою сторінкою і під яким не лишилося нічого, вилучається остаточно

ParentTree отримує ту саму обробку, і причина — та, що вкусила під час розробки: елемент структури може бути досяжним із ParentTree і більше нізвідки. Дерево чисел зіставляє цілі /StructParents або з єдиним елементом, або з масивом елементів, і PruneParentTreeNode виконує PruneStructureElement над кожним значенням, яке знаходить, вилучає значення, які було вичищено, видаляє пару /Nums, коли її масив значень порожній, і від'єднує вузол, у якого і /Nums, і /Kids зникли. Вичищення лише нащадків /K лишило б ці осиротілі елементи такими, що вказують на звільнену сторінку через /Pg і на звільнені посилання на marked content через своїх нащадків /MCR. Якщо ви витягуєте текст у порядку структури, це має пряме значення: витягування тексту в порядку структури обходить саме ці дерева, і елемент із null у /Pg — це абзац, який тихо випадає з порядку читання

Які анотації-посилання на вцілілих сторінках вилучаються?

Будь-яка анотація-посилання на вцілілій сторінці, чий масив /Dest або дія /GoTo вказує на видалену сторінку, вилучається разом зі своєю приналежністю в дереві структури. RemoveRetainedPageDestinationAnnotations обходить масив /Annots кожної сторінки, крім цільової, застосовує той самий тест пункту призначення, що й для закладок, позначає відповідну анотацію видаленою, відкидає її з масиву й далі викликає PruneAnnotationReferencesInStructureTree, щоб словник OBJR, чий /Obj називав цю анотацію, було вилучено з його елемента структури, а сам елемент вилучено, якщо OBJR був його єдиним нащадком. Залишити OBJR на місці означало б порушити §14.7.4.3, який вимагає, щоб /Obj посилався на наявний об'єкт, і це виринуло б у перевірці PDF/UA як теговане посилання без жодної анотації за ним. Зауважте асиметрію із закладками: посилання вилучаються, а не перецілюються. Перехресне посилання в тексті, яке казало «див. сторінку 3», стає неправильним, коли сторінки 3 більше немає, і націлити його на сторінку 4 було б брехнею в тому сенсі, у якому закладка, що приземляється на найближчий розділ, нею не є, тож якщо вашому робочому процесу потрібно зберегти ці посилання, перецільте їх самі до виклику DeletePage

Чому вилучений /MCR або /OBJR ніколи не можна реєструвати як вільний?

Бо посилання на marked content і посилання на об'єкти зазвичай є прямими словниками всередині масиву /K їхнього батьківського елемента, а реєстр інкрементальних змін розв'язує прямий об'єкт у найближчий непрямий, що його містить. Коли RemoveArrayItem відкидає нащадка з масиву /K, він звільняє об'єкт у пам'яті лише тоді, коли це був THPDFLink або значення без непрямості, а MarkRemovedObject реєструє об'єкт для списку вільних лише тоді, коли його номер об'єкта більший за нуль. Перша версія цього обходу такого розрізнення не робила, і ефект при інкрементальному збереженні був рівно тим, для чого реєстр і призначений: RegisterIncrementalChange ішов від прямого /MCR вгору до свого кореня транзакції графа — а ним був вцілілий елемент структури, який ним володів, — і записував той елемент як null. Документ, який втратив одну сторінку, повертався з тим, що тегований вміст на інших сторінках тихо переставав бути тегованим. Єдиний правильний хід для прямого нащадка — позначити його контейнер брудним через TouchContainer, щоб контейнер було перезаписано, і не чіпати список вільних

Чому вилученого нащадка /MCR або OBJR ніколи не можна реєструвати як вільний у HotPDF: реєстр інкрементальних змін розв'язує прямий словник у найближчий непрямий контейнер, тож перша версія записала вцілілий елемент структури як null і тихо зняла тегування з уцілілих сторінок, а тепер TouchContainer перезаписує контейнер і не чіпає список вільних
Звільнення нащадка в пам'яті зарезервоване для THPDFLink або непремих значень і для номерів об'єктів, більших за нуль, тож інкрементальне збереження дописує лише торкнуті контейнери й звільнений об'єкт сторінки
// Інкрементальне оновлення: у дописану секцію потрапляють
// лише торкнуті контейнери й звільнений об'єкт сторінки.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Вцілілі елементи структури, чий /K втратив прямий /MCR,
  // перезаписуються на місці, а не записуються як null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

Та сама обережність визначає й те, що DeletePage навмисно не звільняє в завантаженому документі. Content streams, XObjects і анотації видаленої сторінки, які не є віджетами, лишаються об'єктами, бо завантажений файл може ділити будь-який із них зі сторінкою, що лишається, і дешевого способу довести протилежне на момент видалення немає. Вилучення посилання з дерева сторінок достатньо для коректності; байти, які ці об'єкти все ще займають, — окреме питання, і граф залежностей об'єктів та аналіз утримуваних байтів — це інструмент для вимірювання того, що обрізаний документ усе ще несе

DeletePage чи DeleteLoadedPage: що викликати?

Викликайте DeletePage для будь-якого видалення сторінки, орієнтованого на користувача, а DeleteLoadedPage прибережіть для випадку, коли весь документ переверстується заново і жодне посилання рівня документа не варте збереження. THotPDF.DeleteLoadedPage(PageIndex), доданий у версії 2.508.0, — це полегшений варіант: він зсуває внутрішній масив сторінок, викликає RebuildLoadedKidsArray, щоб перезаписати /Kids і /Count, інвалідує кеш відрендерених сторінок і запускає OnLoadedDocumentModified. Він не обходить дерево імен, закладки, дерево структури чи анотації інших сторінок і не позначає об'єкт сторінки видаленим. Це правильний інструмент усередині N-up imposition, де HotPDF додає щойно скомпоновані аркуші, а потім відкидає кожну оригінальну сторінку через DeleteLoadedPage(0): вихідні сторінки замінюються цілком, а вміст аркуша посилається на їхні ресурси, а не на об'єкти сторінок. Для звичайної задачі «прибрати сторінку 7 із цього контракту» DeletePage — єдиний виклик, який лишає тегований документ із закладками й перехресними посиланнями достатньо узгодженим, щоб пройти валідатор, — і при повному перезаписі через SaveLoadedDocument, і при інкрементальному оновленні через SaveIncrementalUpdate. Обидва методи постачаються в HotPDF Delphi Component для Delphi і C++Builder, без жодної зовнішньої рантайм-залежності чи потреби в переглядачі