Слейте два PDF на ръка, преместете един-единствен обект страница в целевия документ и копирането стъпва направо в access violation. 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) клонира един непряк обект и всичко достигимо от него — вложени речници, масиви, низове, имена, числа и потоци с недокоснати речници — в текущо избрания документ и връща ненулева дръжка към новата непряка референция. Изходните номера на обекти се премапват през жива карта, държана за времето на повикването, така че обект, достигнат два пъти в затвореността, се клонира веднъж и се споделя два пъти. Връща нула, без да вдига изключение, когато ID на изходния документ е непознат, когато източникът е самият избран документ или когато 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, а не ID на документ, така че дръжката, която ви трябва, идва от 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, така че тя се клонира втори път, и трети, всяко ниво бутайки нова рамка и нов полуготов обект. Това, което наблюдавате, не е и чист stack overflow: външните рамки седят върху референции, чиито цели никога не са били зададени, така че първият запис през един от тези слотове е access violation някъде, което нищо не прилича на копирането на страницата, предизвикало го
Поправката: изрично състояние „в ход“
Поправката е да спрете да претоварвате Nil и да зададете въпроса направо. Запис в картата, чиято цел все още не е зададена, означава този обект в момента се клонира, а предикатът InProgress проверява точно това, преди да тръгне обикновеното търсене. Когато е истина, реброто е цикъл обратно към предшественик на текущия клонинг и PDFlibPas издава за него null обект вместо да го следва
// Запис в картата с 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 в верига от отметки сочат нагоре или назад към вече посетено; затвореността на шрифт, на image XObject или на form XObject върви надолу и се прекратява. Така копие на шрифтов дескриптор, на цветово пространство или на речник за засенчване не е засегнато от замяната с null — нищо в тези затворености не стъпва на InProgress. Цената, казана направо, е, че цикличното ребро не оцелява след копирането. Речник на страница, клониран по този начин, пристига с /Parent като null обект, което ISO 32000-1 §7.3.9 прави равнозначно на липсващ запис, така че копираната страница е валиден обект, който не принадлежи на никое дърво от страници, докато не я свържете в целевия /Pages възел и сами не оправите /Count. Копиран елемент от отметки губи /Prev по същия начин и се нуждае от възстановена верига на съседите. Това е честният компромис: CopyForeignObject ви дава правилна затвореност и оставя структурното повторно родителство на извикващия, което е същата граница, в която работи замяната на страници със запазени номера на обекти
Защо записът в картата трябва да се резервира преди NewObjRef
Очевидна алтернатива би заобиколила целия танц с „в ход“: първо заделете празен скелетен обект, регистрирайте истинския му номер в картата, после запълнете скелета, щом децата са клонирани. Тук това не работи, защото TPDFIndObj.Obj е само за четене и съдържанието му не може да бъде заменено след конструкцията — няма скелет за запълване. Номерът и съдържанието се решават заедно от NewObjRef, което означава, че записът в картата трябва да се създаде преди рекурсивното повикване и да се довърши след него, а интервалът между тези два момента е точно това, което InProgress трябва да покрива. Едно последствие си струва да знаете, преди да сравнявате изхода: понеже NewObjRef върви след записването на дъщерната затвореност, номерирането в целта излиза отдолу нагоре и номера на обекти няма да отразят изходния ред. Файловият формат не го интересува, но байтово сравнение срещу ръчно изградено очакване — да. Ако пробег остави обекти, които сте решили да не свържете към нищо, те са без референции, не повредени, а mark-and-sweep събирането на недостигними PDF обекти е инструментът, който ги изчиства преди запис
Регресионният тест, който покрива това, се нуждае от една подробност, която изненадва хората, пишещи тестове срещу TPDFlib: конструкторът вече държи документ по подразбиране, така че DocumentCount започва от 1 и fixture с два документа трябва да проверява >= 2, а не = 2. Редом с успешното копиране тестът фиксира трите отхвърляния — непознат изходен ID, избраният документ като собствен източник и номер на обект нула — всички връщащи 0 вместо да вдигат изключение, защото цикълът на сливане е лошо място да откриеш, че guard клауза хвърля
Кое място заема това в конвейер за сливане
Копирането на ниво обект е примитивът, до който се добирате, когато сливането на цели файлове е твърде грубо: да извадиш един шрифтов програмен файл от шаблон, да дърпаш един-единствен form XObject в документ за щамповане или да местиш анотация с нейните appearance потоци между файлове, без да дърпаш останалото от страницата. PDFlibPas го предлага като едно повикване върху заредени документи, а как се съчетава с останалата част от нисконивовия обектен API виждате в справката на PDFlibPas Delphi PDF Library