Delphi PDF 라이브러리인 PDFlibPas에서 MovePage로 옮겨진 페이지는 옛 Pages 노드가 들고 있던 바로 그 MediaBox, CropBox, Resources 오브젝트를 받았습니다. 그래서 옮겨진 페이지에 이어진 SetPageBox나 DrawText가 그 노드와 여전히 상속 중인 모든 형제를 조용히 다시 써 버렸죠. v3.539.36부터는 옮겨진 페이지가 자기 복사본을 받고, indirect reference는 참조로 남습니다. 같은 릴리스가 관련 경로 둘도 닫습니다. 여러 페이지가 공유하는 indirect 박스에 대한 SetPageBox, 그리고 소스 문서의 페이지를 Pages 노드에 묶어 두고 CropBox를 MediaBox에 묶어 두던 CopyPageRanges입니다
여기까지 이끈 버그 리포트들은 오브젝트 정체성을 언급하지 않습니다. "7페이지를 크롭했더니 8페이지부터 12페이지까지 같이 크롭됐다", "CropBox를 좁혔더니 MediaBox가 따라 움직였다", 가장 헷갈리는 것은 "한 페이지를 새 문서로 복사했는데 원본 파일이 바뀌었다" 같은 식이죠. 크래시도 누수도 없고 저장된 파일은 완벽하게 유효한 PDF입니다. 아무도 요청하지 않은 기하 정보가 들어 있을 뿐입니다
한 페이지의 SetPageBox는 왜 형제들의 크기를 바꿀까?
SetPageBox가 형제들의 크기를 바꾼 이유는 두 개의 페이지 트리 엔트리가 하나의 메모리 내 배열을 가리키고, SetPageBox는 타깃 배열을 제자리에서 편집하기 때문입니다. 같은 인스턴스를 들고 있는 페이지나 Pages 노드는 모두 그 편집을 봤습니다. v3.539.36 전의 PDFlibPas에서 그 공유를 만들어 낸 코드 경로는 셋입니다:
MovePage는 부모에서 떼어 내기 전에 상속 가능한 속성을 페이지에 구체화하는데, 복사본이 아니라 조상의 오브젝트 그 자체를 붙였으므로, 옮겨진 페이지와 옛 형제들이 박스 배열과 Resources 딕셔너리를 공유했습니다SetPageBox는 indirect reference를 따라가 참조된 배열을 편집했으므로, 여러 페이지가 하나의/MediaBox 11 0 R오브젝트를 가리키는 파일에서는 호출 한 번으로 그 페이지들이 모두 크기가 바뀌었습니다.MovePage가 개입했는지와 무관하게요CopyPageRanges는 소스 페이지를 타깃 문서로 클론하기 전에 상속 값을 소스 페이지에 구체화하는데, Pages 노드 인스턴스를 소스 페이지에 붙이고, 게다가 MediaBox 인스턴스 자체를 기본 CropBox로 붙였습니다
MovePage 케이스에는 짧은 역사가 있습니다. v3.539.27 전의 MovePage는 /Resources만 옮겼으므로, 다른 부모 아래로 옮겨진 페이지는 조용히 그 부모의 크기와 회전을 떠맡았습니다. v3.539.27이 빠진 MediaBox, CropBox, Rotate를 고쳤고, 이는 페이지를 재정렬할 때 CollateDocumentsEx가 의지하는 것이기도 하지만, 조상의 값을 공유 인스턴스로 붙였습니다. v3.539.36이 닫는 바로 그 틈입니다. SetPageBox와 CopyPageRanges 경로는 더 오래됐습니다. v3.539.36 전 빌드라면 어느 쪽이든 갖고 있습니다
직접 값, indirect reference, 그리고 페이지 속성 상속
상속된 페이지 속성의 올바른 복사는 직접 값을 복제하고 indirect reference는 참조로 유지합니다. ISO 32000-1이 직접 긋는 구분이기 때문입니다. 딕셔너리 안에 쓰인 [0 0 400 300] 같은 직접 오브젝트는 그 딕셔너리만의 것입니다. 11 0 obj로 한 번 정의되고 11 0 R로 인용되는 indirect 오브젝트는 설계상 공유됩니다. ISO 32000-1 §7.3.10이 파일 어디에서든 주소 가능하게 만들고, 모든 11 0 R이 같은 오브젝트를 뜻하죠
페이지 속성 상속, ISO 32000-1 §7.7.3.4는 세 번째 케이스를 더합니다. Resources, MediaBox, CropBox, Rotate는 Pages 노드에 놓여 자기 것을 정의하지 않은 모든 자손 페이지에 적용될 수 있습니다. 페이지는 값을 들고 있지 않고 /Parent를 통해 값을 찾아보죠. 페이지가 부모를 바꾸는 순간 그 탐색 체인은 끊기므로, MovePage와 BalancePageTree는 유효 값을 먼저 페이지 자신에 써야 합니다. 문제는 어떻게 쓰느냐뿐입니다
오브젝트 풀이 왜 실수를 숨기나
PDFlibPas에서 파싱되거나 생성된 모든 PDF 오브젝트는 문서의 TPDFStructure 풀이 소유하고, 딕셔너리와 배열은 자기 엔트리에 대한 순수 포인터를 저장합니다. TPDFDictionary.Add는 포인터만 기록할 뿐입니다. 그래서 하나의 인스턴스를 두 부모 컨테이너에 넣는 것은 런타임이 검사할 수 있는 모든 수준에서 합법입니다. 해체 시 이중 해제도 없고, 잘못될 참조 카운트도 없고, 예외도 없죠. 직렬화도 마찬가지로 너그럽습니다. 각 컨테이너는 공유 인스턴스의 현재 값을 inline으로 쓰고, 편집이 없는 한 출력은 올바른 복사가 내놓을 것과 바이트 단위로 같습니다
알리어싱은 누군가 공유 인스턴스를 제자리에서 변형할 때만 드러납니다. SetPageBox는 기존 배열 위의 사각형 래퍼로 정확히 그렇게 하고, 페이지에 그리기는 폰트나 이미지가 등록될 때 Resources 딕셔너리에 그렇게 합니다. 편집은 조용히 포인터를 들고 있는 다른 모든 컨테이너에 착지합니다
PDFlibPas v3.539.36은 공유 대신 어떻게 복사하나
PDFlibPas v3.539.36은 문제를 양쪽 끝에서 고칩니다. 구체화는 이제 복사본을 붙이고, 박스 쓰기는 페이지가 소유한 배열만 편집합니다. 각 수정은 다른 쪽이 못 하는 케이스를 커버합니다
구체화 헬퍼 PLInheritPageAttributes는 이제 Value 대신 Page.Owner.Decode(Value.Output)를 붙입니다. 직렬화기를 통한 왕복은 무뚝뚝하지만 정확한 방법으로, PDF 시맨틱스를 공짜로 얻게 해 줍니다. 직접 배열이나 딕셔너리는 literal 텍스트로 직렬화되어 새롭고 독립적인 인스턴스로 디코딩됩니다. indirect reference는 11 0 R로 직렬화되어 같은 오브젝트 11을 가리키는 새 참조 오브젝트로 디코딩되므로, 페이지는 inline 복사본을 받는 대신 여전히 공유 오브젝트를 참조하고, v3.539.27이 도입한 참조 동작이 보존됩니다. 복사는 직접 구조만큼만 깊습니다. 복사된 딕셔너리 안의 참조를 통해 닿는 것은 파일 포맷이 의도한 대로 공유로 남죠. BalancePageTree는 재부모화하는 모든 페이지에 같은 헬퍼를 호출하므로, 거기서 구체화된 페이지들도 별개의 인스턴스를 받습니다
복사만으로는 부족합니다. 참조 케이스는 여전히 공유 오브젝트를 가리키니까요. SetPageBox가 그 참조를 따라가 오브젝트 11을 편집했다면, 옮겨진 페이지는 다시 옛 부모와 그 다른 자식들의 크기를 바꿨을 겁니다. 그래서 박스 라이터는 이제 copy-on-write를 적용합니다. 페이지 자신의 엔트리가 직접 배열일 때만 제자리에서 편집하고, indirect이거나 없는 박스는 새 직접 배열로 대체합니다. 오브젝트 11은 그것을 인용하는 다른 모든 페이지를 위해 그대로 둡니다
| 코드 경로 | v3.539.36 이전 | v3.539.36 이후 |
|---|---|---|
MovePage 구체화 | 페이지가 조상의 직접 인스턴스를 그대로 보유 | 페이지가 디코딩된 복사본을 보유, 참조는 참조로 유지 |
SetPageBox | 참조를 따라가 공유 배열을 편집 | 페이지의 직접 배열만 편집, 아니면 새로 씀 |
CopyPageRanges 소스 페이지 | Pages 노드 박스를 공유, CropBox는 MediaBox 인스턴스 | 소스 페이지의 구체화된 값은 모두 복사본 |
| 페이지 리소스 클론 시 기본 박스 | CropBox, BleedBox, TrimBox, ArtBox가 하나의 배열 공유 | 기본 박스마다 자기 배열을 가짐 |
마지막 행은 잠복해 있던 케이스입니다. 라이브러리가 페이지 캡처나 병합을 위해 페이지의 리소스를 클론할 때 빠진 CropBox, BleedBox, TrimBox, ArtBox 엔트리를 채워 넣는데, 그것들이 같은 배열 인스턴스였습니다. 지금의 호출자 중 누구도 그 알리어스가 편집될 만큼 오래 살아남게 하지 않았지만, 다음 호출자는 그랬을 겁니다. 그 기본 박스 값이 어떻게 정해지는지는 별개의 주제로, PDFlibPas의 TrimBox, BleedBox, CropBox 기본값 가이드가 다룹니다
손으로 만든 PDF로 MovePage 알리어싱 재현하기
아무 PDFlibPas 빌드든 검증하는 가장 빠른 방법은 LoadFromString으로 읽어 들이는 작은 손작성 PDF입니다. 오브젝트 번호를 미리 모두 알 수 있죠. 아래 헬퍼는 바이트 오프셋을 정확히 계산해 고전적인 cross-reference 테이블을 쓰므로, 테스트는 파서의 손상 파일 복구 동작에 의존하지 않습니다
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // "N 0 obj"의 0 기반 바이트 오프셋
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // 각 엔트리는 정확히 20바이트
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
테스트 문서에는 중간 Pages 노드가 두 개 있습니다. 노드 3은 indirect MediaBox(오브젝트 11, 400 x 300 포인트), 직접 CropBox, 직접 Resources 딕셔너리를 실고 두 페이지를 소유합니다. 노드 4는 Letter 크기 MediaBox를 갖고 세 번째 페이지를 소유하죠. 페이지 1을 위치 3으로 옮기면 노드 4 아래로 재부모화되는데, 구체화가 필요한 바로 그 이동입니다. 없다면 그 페이지는 Letter 페이지로 변해 버립니다
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // 방금 옮긴 페이지
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBox 200 x 200
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBox 100 x 100
FontID := Lib.AddStandardFont(4); // Helvetica
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// 다른 페이지를 선택하기 전에 옛 부모를 검사(아래 참조)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // 옛 페이지 2, 여전히 노드 3 아래
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension)은 박스 타입 1을 MediaBox로, 2를 CropBox로 받고, dimension 2는 폭입니다. 기본 왼쪽 아래 원점에서 SetPageBox(1, 0, 200, 200, 200)은 왼쪽 0, 위 200, 폭 200, 높이 200을 뜻합니다. v3.539.27부터 v3.539.35 사이 빌드에서는 형제 검사가 실패합니다. CropBox 편집은 노드 3의 직접 배열에 착지하고, MediaBox 편집은 참조를 통해 오브젝트 11을 다시 씁니다
CopyPageRanges는 소스 문서를 바꿀까?
v3.539.36부터 CopyPageRanges는 여전히 소스 페이지에 쓰지만, 쓰는 모든 값이 별개의 복사본이므로 소스에 대한 이후 편집은 편집한 페이지에만 머뭅니다. 쓰기 자체는 의도된 것입니다. 소스 페이지의 딕셔너리가 타깃으로 클론되기 전에 명시적인 MediaBox, CropBox, Rotate, Resources가 필요하고, 없으면 복사본은 상속한 모든 것을 잃습니다. 페이지의 재번호 매기기와 타깃으로의 복사는 PDFlibPas의 문서 간 오브젝트 딥 카피가 다루고, 이 버그는 대부분의 사람이 복사가 읽기만 한다고 가정하는 소스 쪽에 앉아 있었습니다
출력은 이를 보여 주지 않았습니다. 공유든 복사든 구체화된 값은 똑같이 직렬화되므로, 두 문서는 수정 전이든 후든 바이트 단위로 같게 저장됐습니다. 복사 후 소스 문서를 편집해야만 알리어스가 드러났죠:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // 선택된 문서가 됨
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // CropBox만 좁힘
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // 복사본은 원래 크기를 유지
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
v3.539.36 전에는 이 문서의 두 페이지가 모두 루트 노드의 직접 MediaBox를 상속했고, 복사는 그 인스턴스를 소스 페이지 1에 붙인 다음 페이지 1의 CropBox로 또 붙였습니다. 그래서 CropBox를 좁히면 MediaBox가 좁혀지고, MediaBox 크기를 바꾸면 루트 노드를 통해 페이지 2의 크기가 바뀌었습니다. 페이지를 복사해 낸 뒤 소스를 계속 편집하는 워크플로, 원본을 다듬기 전에 양면 스캔을 하나의 PDF로 콜레이트하는 작업이 이것이 드러난 곳입니다
인스턴스 알리어싱은 왜 테스트하기 어려울까?
인스턴스 알리어싱이 테스트하기 어려운 이유는 관찰 가능한 효과가 특정 순서의 세 단계를 필요로 하기 때문입니다. 알리어스를 만들고, 한쪽을 변형하고, 다른 무엇도 건드리기 전에 다른 쪽을 검사해야 하죠. 대부분의 테스트는 첫 단계만 하고 저장된 출력을 비교하는데, 알리어스가 있든 없든 그 출력은 같습니다
PDFlibPas의 순서 함정은 SelectPage입니다. 페이지를 선택하면 SelectFont를 통해 현재 폰트를 다시 적용하는데, 그 폰트가 페이지의 리소스에 등록됩니다. 자기 /Resources가 없는 페이지는 부모의 딕셔너리로 해석되므로, 그런 페이지를 그저 선택하는 것만으로도 Pages 노드에 /Font가 합법적으로 추가됩니다. 위 MovePage 테스트에서 옛 페이지 2를 선택하면 Helvetica 엔트리가 노드 3에 추가되는데, 올바른 동작이지 누수가 아닙니다. 그래서 GetObjectToString(3) 검사가 SelectPage(1)보다 먼저 돕니다. 둘을 바꾸면 고쳐진 빌드에서 테스트가 실패합니다
그 규칙은 v3.539.36이 일부러 손대지 않은 것도 짚어 줍니다. Resources 딕셔너리를 상속하는 페이지에 리소스를 쓰면 조상의 딕셔너리에 쓰이고, 모든 형제가 새 엔트리를 봅니다. 명세대로 동작하는 상속이지 인스턴스 공유가 아니며, 공유 딕셔너리에 폰트나 이미지 이름을 추가해도 다른 페이지의 렌더링은 바뀌지 않으므로 무해합니다. 페이지가 상속을 멈춰야 한다면 먼저 자기 Resources 딕셔너리를 주세요
PDF 오브젝트 모델 코드 체크리스트
이 교훈은 풀과 포인터 컨테이너 위에 세워진 모든 PDF 오브젝트 모델에 일반화됩니다. Delphi든 다른 곳이든:
- ISO 32000-1 §7.7.3.4대로 상속 속성을 구체화할 때는 직접 값을 딥 카피하고 indirect reference는 같은 오브젝트를 가리키는 새 참조로 유지
- 공유가 의도되고 문서화된 게 아니라면 기존 인스턴스를 두 번째 컨테이너에
Add하지 말 것. 풀 소유는 런타임이 결코 불평하지 않는다는 뜻 - 현재 노드가 직접 오브젝트로 소유한 것만 제자리에서 편집. indirect이거나 상속된 값은 새 직접 오브젝트로 대체(copy-on-write)
- MediaBox에서 나온 CropBox처럼 다른 엔트리에서 파생된 기본값은 자기 인스턴스가 필요
- 알리어싱은 다른 보유자에 대한 변형-후-검사 시퀀스로 테스트하고, 그 사이 합법적으로 쓸 수 있는 호출의 순서도 검사
- 저장된 출력 비교는 여기서 아무것도 증명하지 않음. 공유 값과 복사 값은 첫 편집 전까지 똑같이 직렬화됨
- PDFlibPas에서
MovePage,CollateDocumentsEx,BalancePageTree,CopyPageRanges를 호출한 뒤 페이지 박스를 편집하거나 페이지에 그린다면 v3.539.36 이상으로 업그레이드
PDFlibPas는 페이지 트리 편집, 문서 간 복사, 페이지 박스 제어를 Delphi, C++Builder, Free Pascal용 단일 TPDFlib 클래스로 노출합니다. 에디션, 플랫폼, 전체 API 레퍼런스는 PDFlibPas Delphi PDF 라이브러리 제품 페이지를 보세요