Обʼєднайте два 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-злиття розвʼязує її перекладом цілих файлів, тоді як копіювання на рівні обʼєктів мусить розвʼязувати її по одному ребру за раз
Що 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 і поставити питання прямо. Запис мапи, чия ціль ще не присвоєна, означає цей обʼєкт зараз клонується, і предикат 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 дає вам коректне замикання і лишає структурне переприкріплення викликаючому, — та сама межа, у якій працює заміна сторінок зі збереженням номерів обʼєктів
Чому запис мапи треба резервувати до NewObjRef
Очевидна альтернатива оминула б увесь цей танок із «триває»: спершу виділити порожню оболонку обʼєкта, зареєструвати її справжній номер у мапі, а потім заповнити оболонку, коли діти склоновані. Тут це не працює, бо TPDFIndObj.Obj лише для читання і його вміст неможливо замінити після конструювання — оболонки, яку можна заповнити, немає. Номер і вміст вирішуються разом у NewObjRef, а отже, запис мапи мусить бути створений до рекурсивного виклику і завершений після нього, і проміжок між цими двома моментами — це саме те, що має покривати InProgress. Один наслідок варто знати до порівняння виводу: оскільки NewObjRef виконується після запису дочірнього замикання, нумерація в цілі виходить знизу вгору, і номери обʼєктів не дзеркалитимуть порядок джерела. Ніщо у форматі файлу цього не цікавить, але побайтове порівняння з вручну побудованим очікуванням — цікавить. Якщо запуск лишає обʼєкти, які ви вирішили ні до чого не причеплювати, вони не пошкоджені, а просто не мають посилань, і збирання недосяжних PDF-обʼєктів mark-and-sweep — це інструмент, який вичищає їх перед збереженням
Регресія, що покриває це, потребує однієї деталі, яка дивує людей, що пишуть тести проти TPDFlib: конструктор уже тримає документ за замовчуванням, тож DocumentCount стартує з 1, і фікстура з двома документами мусить перевіряти >= 2, а не = 2. Поруч із успішним копіюванням тест фіксує три відмови — невідомий ідентифікатор джерела, вибраний документ як власне джерело та номер обʼєкта нуль, — усі повертають 0, а не підіймають виключень, бо цикл злиття — погане місце, щоб дізнатися, що захисна гілка кидає
Де це місце в конвеєрі злиття
Копіювання на рівні обʼєктів — це примітив, до якого доходять, коли злиття цілих файлів надто грубе: витягнути одну шрифтову програму з шаблону, підтягнути один form XObject у документ штампування або перенести анотацію з її потоками вигляду між файлами, не тягнучи решту сторінки. PDFlibPas відкриває його одним викликом до завантажених документів, а як він сидить поруч з рештою низькорівневого API обʼєктів, видно в довідці PDFlibPas Delphi PDF Library