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

Заміна сторінок PDF у Delphi без руйнування закладок

Заміна сторінки 3 у погодженому контракті не повинна зсувати зміст. Видали стару сторінку, встав нову — і кожна закладка, що колись вказувала туди, тепер приземляється деінде. PDFlibPas Delphi PDF library уникає цього, зберігаючи сам об'єкт цільової сторінки і переносячи лише записи, що несуть візуальний вміст

Чому закладки ламаються після заміни сторінки PDF?

Закладки ламаються, бо ціль призначення PDF називає сторінку через непряме посилання на об'єкт, а не за номером сторінки. ISO 32000-1 §12.3.2.2 визначає явну ціль призначення як масив, перший елемент якого — непряме посилання на об'єкт сторінки. Видали цей об'єкт і дороти заміну — і посилання висить у порожнечі: більшість переглядачів реагують, кидаючи читача на сторінку 1, що є рівно тим симптомом, про який люди повідомляють після заміни методом "видалити-потім-вставити". Дерево сторінок виглядає ідеально, кількість сторінок правильна, рендеринг правильний, а весь шар навігації тихо зламаний

Іменовані цілі призначення теж не рятують. §12.3.2.3 маршрутизує ім'я через дерево імен /Dests у каталозі документа, але листок, до якого це ім'я розв'язується, все одно є явним масивом цілі призначення, що тримає те саме посилання на сторінку. Іменування додає шар непрямості над посиланням на сторінку, а не навколо нього. Та сама логіка охоплює решту інтерактивного шару, описаного в §12.5: анотація-посилання несе /Dest або дію GoTo /A, чий /D — той самий масив, кожна анотація може нести запис /P, що є непрямим посиланням на її сторінку, а віджет поля форми — це анотація на рівно тих самих правах. Один наївний обмін сторінками відриває чотири підсистеми одразу, і якщо хочете побачити їх перелічені на реальному файлі, той самий граф об'єктів обходить інтроспекція структури та анотацій

Які записи сторінки несуть ідентичність, а які — вигляд

Словник сторінки змішує два типи записів, і заміна на місці вдається саме тоді, коли ти їх розділяєш. Бік вигляду скінченний і перелічуваний: /Contents, /Resources, п'ять рамок сторінки /MediaBox, /CropBox, /BleedBox, /TrimBox та /ArtBox, плюс /Rotate, /Group, /UserUnit та /BoxColorInfo. Ці одинадцять записів вирішують усе, що растеризатор видасть для сторінки, і більше ніщо у файлі не посилається на них за назвою

Бік ідентичності — це те, до чого прив'язала себе решта документа: номер об'єкта сторінки і покоління, зворотне посилання /Parent у дерево сторінок, та /Annots. PDFlibPas лишає кожен з них незайманим. ReplacePageRanges вичищає одинадцять візуальних записів з цільового словника сторінки і додає їх заново з імпортованої вихідної сторінки, тож цільовий об'єкт сторінки мутується на місці, а не замінюється. Структура дерева сторінок, вимагана §7.7.3, теж лишається побайтово ідентичною за формою: порядок /Kids, /Count, і кожен вцілілий /Parent однакові до і після, бо жоден вузол ніколи не від'єднувався

Як PDFlibPas замінює сторінку без перенумерації об'єктів?

Виклик приймає вихідний документ, цільову початкову сторінку з нумерацією від 1, вираз діапазону вихідних сторінок та прапорець опцій. Обидва документи мають бути відкриті в одному екземплярі, а цільовий документ — вибраний. Оскільки кількість сторінок цілі ніколи не змінюється, запитаний діапазон має вміщатися в документ, починаючи з TargetStartPage, і це перевіряється до того, як щось створюється

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

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

Шлях видалення, що зруйнував би те, що ти щойно переніс

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

Виправлення — режим збереження-посилюваних-об'єктів на внутрішньому шляху видалення. Коли він встановлений, видалення пропускає як очищення несуміщуваних ресурсів, так і очищення потоку вмісту, і не робить нічого, окрім від'єднання сторінок від дерева сторінок та виправлення обліку дерева. Перенесені об'єкти виживають з новим власником, і власність над об'єктами після операції — це те, що ти намалював би на дошці: один потік вмісту, одна сторінка-власник, один номер об'єкта, що ніколи не рухався. Пов'язані правила життєвого циклу для створення, видалення та зміни порядку сторінок окремо розглянуті в нотатках про операції життєвого циклу документа та сторінок

Порядок, дублікати та відмова "все або нічого"

Прапорець опцій обирає, як інтерпретувати вихідний діапазон. 0 сортує розібрані номери сторінок і видаляє дублікати, що є розумним значенням за замовчуванням, коли викликач передає щось на кшталт '4-6,2' і просто має на увазі ці чотири сторінки. 1 зберігає написаний порядок і дозволяє повторення сторінки, тож '2,1,2' справді означає три заміни, взяті з двох вихідних сторінок. Валідація виконується першою і повністю: синтаксис діапазону, кожен номер сторінки проти кількості сторінок джерела, саме значення опції та ємність цілі — усе перевіряється до того, як створюється хоч один об'єкт. Відхилений виклик встановлює LastErrorCode у 412, відновлює раніше вибрану сторінку і лишає документ рівно таким, яким він був

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

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

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

Чого заміна на місці досі не робить за тебе?

Вихідні анотації, вихідні поля форм та вихідна структура свідомо не імпортуються. Перенесення віджета без запису поля /AcroForm, або анотації, що несе marked-content, без володіння структурним деревом, дало б наполовину імпортований інтерактивний об'єкт, з яким жоден переглядач не зможе впоратися, тож операція переносить лише вигляд. Практичний наслідок у тому, що якщо сторінка заміни має нести нові поля форми чи нові посилання, ти додаєш їх до цільової сторінки після, до об'єкта цільової сторінки, який все ще сидить там і чекає на них

Ще дві межі варто перевірити на власних файлах. По-перше, /Annots зберігається, але геометрія сторінки — ні, тож заміна сторінки 220 мм на сторінку 320 мм лишає прямокутники анотацій на старих координатах усередині /MediaBox іншого розміру; якщо геометрія змінюється, перепозиціонуй анотації, що лишив. По-друге, записи поза одинадцятьма візуальними ключами лишаються з цільовою сторінкою за задумом, що правильно для /Trans чи /AA і застаріло для /Thumb, тож перегенеруй мініатюри після заміни. Позначені документи потребують ще однієї думки: структурні елементи все ще вказують на правильний об'єкт сторінки через /Pg, але їхні ідентифікатори marked-content описують вміст, якого там більше немає, тож обмін сторінки всередині робочого процесу PDF/UA — це редагування структурного дерева так само, як і редагування вмісту. Якщо твоя задача насправді компонування, а не обмін, накладання графіки на сторінки, що ти зберігаєш, підхід зшивання сторінок та шаблонів — дешевший інструмент

Усе описане тут, включно з синтаксисом виразу діапазону, значеннями опцій та навколишнім API маніпуляції сторінками, постачається в стандартній PDFlibPas Delphi PDF Library для Delphi та C++Builder, чия довідкова документація містить повний запис для виклику заміни сторінок та його кодів помилок