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 병합을 위한 바이트 수준 참조 이동은 파일 전체를 번역해 풀고, 객체 수준 복사는 한 번에 한 에지씩 풀어야 합니다
PDFlibPas CopyForeignObject가 실제로 복사하는 것
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber)는 간접 객체 하나와 그것에서 도달 가능한 모든 것 — 중첩 사전, 배열, 문자열, 이름, 숫자, 사전이 온전히 딸린 스트림 — 을 현재 선택된 문서로 클론하고, 새 간접 참조에 대한 0이 아닌 핸들을 반환합니다. 소스 객체 번호는 호출 동안 유지되는 라이브 맵으로 재매핑되므로, 클로저 안에서 두 번 도달한 객체는 한 번 클론되고 두 번 공유됩니다. 소스 문서 ID를 알 수 없거나, 소스가 선택된 문서 자신이거나, ObjectNumber가 1 미만일 때는 예외를 일으키지 않고 0을 반환합니다
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은 문서 ID가 아니라 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는 따라가는 대신 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는 이미 방문한 무언가를 위나 뒤로 가리킵니다. 폰트, 이미지 XObject, 폼 XObject의 클로저는 아래로 내려가며 종료됩니다. 따라서 폰트 디스크립터, 색 공간, 셰이딩 사전의 복사본은 null 치환의 영향을 받지 않습니다. 그 클로저 어디도 InProgress에 닿지 않습니다. 비용을 분명히 말하자면, 순환 에지는 복사를 살아남지 못합니다. 이렇게 클론된 페이지 사전은 /Parent가 null 객체로 도착하는데, ISO 32000-1 §7.3.9는 이를 없는 항목과 동등하게 만들므로, 복사된 페이지는 대상 /Pages 노드에 직접 연결하고 /Count를 고치기 전까지는 어느 페이지 트리에도 속하지 않는 유효한 객체입니다. 복사된 아웃라인 항목도 같은 방식으로 /Prev를 잃고 형제 체인을 다시 만들어야 합니다. 그것이 정직한 트레이드입니다. CopyForeignObject는 올바른 클로저를 주고 구조적 재부모 연결은 호출자에게 남기는데, 이는 객체 번호를 보존하며 페이지 교체가 일하는 것과 같은 경계입니다
맵 항목을 NewObjRef 전에 예약해야 하는 이유
자명한 대안은 진행 중 절차 전체를 우회할 것입니다. 빈 껍데기 객체를 먼저 할당하고 실제 번호를 맵에 등록한 뒤, 자식들이 클론되면 껍데기를 채우는 방식입니다. 그러나 여기서는 통하지 않습니다. TPDFIndObj.Obj는 읽기 전용이고 내용을 구성 후 교체할 수 없습니다. 채울 껍데기가 없습니다. 번호와 내용은 NewObjRef가 함께 결정하므로, 맵 항목은 재귀 호출 전에 만들어지고 호출 후에 완성되어야 하며, 그 두 순간 사이의 구간이야말로 InProgress가 덮어야 할 구간입니다. 출력을 diff하기 전에 알아둘 한 가지 결과: NewObjRef는 자식 클로저가 쓰인 뒤에 실행되므로, 대상의 번호 매기기는 아래에서 위로 나오고 객체 번호는 소스 순서를 반영하지 않습니다. 파일 형식은 아무것도 신경 쓰지 않지만, 손으로 만든 기대치와의 바이트 비교는 신경 씁니다. 실행 후 링크하지 않기로 결정한 객체들이 남는다면 그것들은 손상이 아니라 미참조이며, 도달 불가능한 PDF 객체의 마크 앤 스윕 수집이 저장 전에 이를 치우는 도구입니다
이것을 덮는 회귀 테스트는 TPDFlib을 상대로 테스트를 쓰는 사람을 놀라게 하는 세부 하나를 요구합니다. 생성자가 이미 기본 문서를 들고 있으므로 DocumentCount는 1에서 시작하고, 두 문서 픽스처는 = 2가 아니라 >= 2를 단언해야 합니다. 성공 복사와 함께 테스트는 세 가지 거부 — 알려지지 않은 소스 ID, 자기 자신을 소스로 하는 선택 문서, 0인 객체 번호 — 가 예외를 던지는 대신 모두 0을 반환함을 고정합니다. 병합 루프는 가드 절이 던지는 걸 발견하기에 나쁜 장소입니다
병합 파이프라인에서 이것이 놓이는 자리
객체 수준 복사는 파일 전체 병합이 너무 거칠 때 꺼내는 원시 기능입니다. 템플릿에서 폰트 프로그램 하나를 들어올리거나, 스탬핑 문서로 폼 XObject 하나를 끌어오거나, 페이지의 나머지를 끌고 가지 않고 어노테이션을 외형 스트림과 함께 파일 간에 옮길 때입니다. PDFlibPas는 로드된 문서를 향한 단일 호출로 이를 노출하며, 저수준 객체 API의 나머지와 어떻게 어울리는지는 PDFlibPas Delphi PDF Library 참조에서 볼 수 있습니다