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

Копіювання PDF-обʼєктів між документами у Delphi: цикли

Обʼєднайте два PDF вручну, перенесіть один обʼєкт сторінки в цільовий документ — і копія одразу впирається в порушення доступу. PDFlibPas виправляє це в CopyForeignObject: метод глибоко копіює один непрямий обʼєкт разом із усім його замиканням посилань, а циклічні зворотні посилання на кшталт /Parent розвʼязує в null замість рекурсії

Чому копіювання однієї сторінки між документами падає?

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

Друга половина проблеми — нумерація. Непрямі обʼєкти ідентифікуються номером обʼєкта, локальним для одного файлу (ISO 32000-1 §7.3.10), тож обʼєкт, перетягнутий із документа A в документ B, треба перенумерувати, і кожне посилання на нього всередині скопійованого замикання має бути перенумероване так само, інакше два посилання, які раніше вказували на один спільний шрифт, почнуть вказувати на дві неповʼязані речі. Ця перенумерація — та сама робота, яку швидке злиття виконує на рівні байтів, і варто читати обидва підходи поруч: зсув посилань на рівні байтів для швидкого PDF-злиття розвʼязує її перекладом цілих файлів, тоді як копіювання на рівні обʼєктів мусить розвʼязувати її по одному ребру за раз

Чому міждокументна копія PDF у Delphi потребує обережності: словник сторінки та його вузол /Pages замикають цикл через /Parent і /Kids, замикання шрифту йде вниз і завершується, а PDFlibPas відображає кожен локальний для файлу номер обʼєкта заново
Дерево сторінок замикає петлю через /Parent і /Kids, тоді як замикання вмісту завершуються, і кожен скопійований номер обʼєкта треба відображати по дорозі

Що CopyForeignObject у PDFlibPas насправді копіює

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) клонує один непрямий обʼєкт і все, що з нього досяжне, — вкладені словники, масиви, рядки, імена, числа та потоки з недоторканими словниками — у поточно вибраний документ і повертає ненульовий дескриптор нового непрямого посилання. Вихідні номери обʼєктів відображаються через живу мапу, яку тримають протягом усього виклику, тож обʼєкт, досягнутий у замиканні двічі, клонується один раз і розділяється двічі. Метод повертає нуль, не підіймаючи виключень, коли ідентифікатор вихідного документа невідомий, коли джерелом є сам вибраний документ або коли ObjectNumber менший за 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile повертає 1 при успіху
    SourceDoc := Lib.SelectedDocument;   // завантаження обрало те, що завантажило
    Lib.SelectDocument(TargetDoc);       // копія пише у вибраний документ
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Дві деталі кусають людей з першого запуску. LoadFromFile відповідає 1 або 0, а не ідентифікатором документа, тож потрібний дескриптор береться з SelectedDocument одразу після завантаження; і копія завжди пише в той документ, який SelectDocument зробив поточним останнім, аніжколи в документ, з якого ви завантажили. Всередині рекурсія також несе жорстку стелю глибини 64 — це страховка від патологічної вкладеності, а не механізм обробки циклів; обробка циклів окрема і навмисна

Чому резервування Nil-відображення не розриває цикл?

Бо Nil у таблиці відображень означає водночас дві різні речі, і код не може їх розрізнити. Очевидний захист від циклу — додати запис у мапу до рекурсії в обʼєкт, щоб усе, що зациклюється назад, знаходило запис і зупинялося. Але запис ще не може тримати справжню ціль — цілі не існує, доки не записане замикання під нею, — тож він тримає Nil, і пошук, який мав би зловити зворотне ребро, читає Nil і робить висновок, що обʼєкт ніколи не відображався

// Зламано: зарезервована ціль Nil невиразна від «ще не відображено»
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // зарезервовано, досі Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // дозаповнюється лише на виході
end;

Простежте це крізь цикл сторінок. Клон сторінки доходить до /Parent, рекурсує у вузол /Pages, який доходить до /Kids, який рекурсує назад у сторінку — у чиєму зарезервованому записі все ще читається Nil, тож вона клонується вдруге, а потім втретє, і кожен рівень штовхає свіжий кадр і свіжий напівзібраний обʼєкт. Те, що ви спостерігаєте, — і не чисте переповнення стека: зовнішні кадри сидять на посиланнях, чиї цілі ніколи не були присвоєні, тож перший запис через один із таких слотів — це порушення доступу десь там, де нічого не схоже на копіювання сторінки, що його спричинило

Чому резервування Nil-цілі в мапі не зупиняє цикл у міждокументній копії PDFlibPas: пошук не може відрізнити зарезервований запис від невідображеного, тож копіювальник спускається крізь усе глибші напівзібрані кадри, аж поки запис не падає
Оскільки ціль Nil відповідає на два різні питання водночас, зворотне ребро ніколи не розпізнається, і сторінка клонується знову на кожному проході

Виправлення: явний стан «триває клонування»

Полагодження полягає в тому, щоб перестати перевантажувати Nil і поставити питання прямо. Запис мапи, чия ціль ще не присвоєна, означає цей обʼєкт зараз клонується, і предикат InProgress перевіряє саме це перед звичайним пошуком. Коли це правда, ребро — це цикл назад до предка поточного клона, і PDFlibPas видає для нього нульовий обʼєкт, а не йде за ним

// Запис мапи з ціллю Nil позначає клон, що триває
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... всередині CloneObject, для непрямого посилання:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // циклічне зворотне ребро, не рекурсуємо
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // висяче вихідне посилання
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // резервуємо, потім рекурсуємо
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // дозаповнення
end;
Exit(NewRef);

Узагальнити це безпечно лише завдяки структурному факту про PDF: цикли в графі обʼєктів зʼявляються на зворотних посиланнях, а не на ребрах вмісту. /Parent у дереві сторінок і /Prev у ланцюзі контурів вказують угору або назад на вже відвідане; замикання шрифту, зображувального XObject чи форми XObject іде вниз і завершується. Тому копія дескриптора шрифту, колірного простору чи словника затінення не страждає від нульової підміни — ніщо в цих замиканнях ніколи не влучає в InProgress. Ціна, сказана прямо, — це те, що циклічне ребро не переживає копіювання. Словник сторінки, склонований таким чином, приходить із /Parent як нульовим обʼєктом, що ISO 32000-1 §7.3.9 робить еквівалентним відсутньому запису, тож скопійована сторінка — валідний обʼєкт, який не належить жодному дереву сторінок, доки ви самі не прилаєте його до цільового вузла /Pages і не виправите /Count. Скопійований елемент контуру так само втрачає /Prev і потребує перебудови ланцюга сусідів. Це чесний компроміс: CopyForeignObject дає вам коректне замикання і лишає структурне переприкріплення викликаючому, — та сама межа, у якій працює заміна сторінок зі збереженням номерів обʼєктів

Виправлення в PDFlibPas CopyForeignObject для Delphi: явна перевірка InProgress виконується перед пошуком у мапі, циклічне зворотне ребро стає нульовим обʼєктом, а викликаючий потім прилає скопійовану сторінку до цільового дерева сторінок
Явний стан «триває» замінює перевантажений Nil, тож зворотне ребро розвʼязується в null, а викликаючому лишається одна структурна правка

Чому запис мапи треба резервувати до NewObjRef

Очевидна альтернатива оминула б увесь цей танок із «триває»: спершу виділити порожню оболонку обʼєкта, зареєструвати її справжній номер у мапі, а потім заповнити оболонку, коли діти склоновані. Тут це не працює, бо TPDFIndObj.Obj лише для читання і його вміст неможливо замінити після конструювання — оболонки, яку можна заповнити, немає. Номер і вміст вирішуються разом у NewObjRef, а отже, запис мапи мусить бути створений до рекурсивного виклику і завершений після нього, і проміжок між цими двома моментами — це саме те, що має покривати InProgress. Один наслідок варто знати до порівняння виводу: оскільки NewObjRef виконується після запису дочірнього замикання, нумерація в цілі виходить знизу вгору, і номери обʼєктів не дзеркалитимуть порядок джерела. Ніщо у форматі файлу цього не цікавить, але побайтове порівняння з вручну побудованим очікуванням — цікавить. Якщо запуск лишає обʼєкти, які ви вирішили ні до чого не причеплювати, вони не пошкоджені, а просто не мають посилань, і збирання недосяжних PDF-обʼєктів mark-and-sweep — це інструмент, який вичищає їх перед збереженням

Регресія, що покриває це, потребує однієї деталі, яка дивує людей, що пишуть тести проти TPDFlib: конструктор уже тримає документ за замовчуванням, тож DocumentCount стартує з 1, і фікстура з двома документами мусить перевіряти >= 2, а не = 2. Поруч із успішним копіюванням тест фіксує три відмови — невідомий ідентифікатор джерела, вибраний документ як власне джерело та номер обʼєкта нуль, — усі повертають 0, а не підіймають виключень, бо цикл злиття — погане місце, щоб дізнатися, що захисна гілка кидає

Де це місце в конвеєрі злиття

Копіювання на рівні обʼєктів — це примітив, до якого доходять, коли злиття цілих файлів надто грубе: витягнути одну шрифтову програму з шаблону, підтягнути один form XObject у документ штампування або перенести анотацію з її потоками вигляду між файлами, не тягнучи решту сторінки. PDFlibPas відкриває його одним викликом до завантажених документів, а як він сидить поруч з рештою низькорівневого API обʼєктів, видно в довідці PDFlibPas Delphi PDF Library