PDF 1.5 객체 스트림은 많은 작은 간접 객체를 하나의 Flate 압축 컨테이너로 포장하며, losLab PDF Library는 PackObjectStreams 플래그를 통해 전체 저장 시 그것들을 방출합니다. 이득은 실질적입니다: 압축되지 않은 상태로 각각 수십 바이트를 차지하던 수백 개의 페이지, 폰트, 주석 딕셔너리가 몇 개의 압축된 블롭으로 무너집니다. 대가는 포장된 모든 객체가 이제 그것을 설명할 크로스 레퍼런스 스트림을 필요로 한다는 것입니다
바로 그 두 번째 절반에서 작성기들이 깨집니다. /ObjStm 컨테이너를 만드는 것은 산술입니다. 크로스 레퍼런스 메커니즘에게 그 안을 가리키도록 가르치는 것은 재설계입니다. 완벽하게 유효한 컨테이너를 만들어놓고 그 멤버를 평범한 타입 1 오프셋으로 설명하는 작성기는 Acrobat이 손상되었다고 선언할 만큼만 딱 열리는 파일을 만들어낸 것입니다. 이 두 기능은 하나의 기능이며, 이 글은 ISO 32000-1 §7.5.7과 §7.5.8에서 정의하는 대로 그 둘의 쓰기 쪽을 다룹니다
ObjStm 컨테이너는 실제로 무엇을 담고 있는가
객체 스트림은 디코딩된 바이트가 이어붙인 두 영역인 스트림이며, ISO 32000-1 §7.5.7은 구성에서 중요한 정확히 세 개의 키를 딕셔너리에 부여합니다. /Type /ObjStm은 그것을 식별하고, /N은 멤버 개수를 주며, /First는 헤더 영역의 바이트 길이 — 다시 말해 본문이 시작되는 오프셋을 줍니다. 헤더는 객체 번호와 오프셋의 공백으로 구분된 쌍이며, 본문은 등뒤로 직렬화된 멤버들로, 각 오프셋은 디코딩된 페이로드의 시작이 아니라 본문의 시작으로부터 측정됩니다. 완전히 디코딩된 컨테이너를 읽어보면 명백해집니다: 아래에서 /First가 14인 이유는 세 개의 헤더 줄이 14바이트를 차지하기 때문이며, 객체 7이 본문 안 55바이트 지점에 있는 이유는 객체 4가 구분자를 포함해 54문자로 직렬화되었기 때문입니다
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
두 개의 멤버십 규칙은 절대적이며 둘 다 §7.5.7에서 곧바로 나옵니다. 스트림 객체는 결코 멤버가 될 수 없습니다. 스트림은 다른 스트림 안에 중첩되어야 할 원시 바이트를 담고 있기 때문입니다. 그리고 멤버는 반드시 완전한 객체 값이어야 하며, 결코 단순한 간접 참조여서는 안 됩니다 — 단순히 5 0 R인 압축된 객체는 리더가 이미 어디를 가리키는지 알지 못하면 해석할 수 없는 간접을 만들어냅니다. losLab PDF Library는 후보 수집 중에 이 두 경우 모두를 걸러내며, 암호화 딕셔너리와 객체 0도 함께 걸러낸 다음, 살아남은 것을 컨테이너당 200개씩 그룹으로 포장합니다. 그 상한은 스펙의 제한이 아니라 임의 접근에 관한 결정입니다: 멤버 하나를 원하는 리더는 컨테이너 전체를 팽창시켜야 하므로, 지나치게 큰 컨테이너는 작은 조회를 비싸게 만듭니다
ObjStm 멤버가 타입 2 크로스 레퍼런스 항목을 써야 하는 이유는 무엇인가
포장된 객체는 기록할 파일 오프셋이 없기 때문입니다. ISO 32000-1 §7.5.8은 바이너리 크로스 레퍼런스 스트림에서 세 개의 항목 타입으로 이에 답합니다: 자유 객체를 위한 타입 0, 바이트 오프셋에 저장된 일반 사용 중 객체를 위한 타입 1, 그리고 두 개의 데이터 필드가 컨테이너 객체 번호와 그 안의 멤버 인덱스를 담는 압축된 객체를 위한 타입 2입니다. 고전적인 평문 xref 테이블에는 포장된 객체를 표현할 방법이 전혀 없으며, 이것이 정확히 PDF 1.5가 두 기능을 함께 도입한 이유입니다
그 뒤를 따르는 순서는 저희 것을 포함해 거의 모든 첫 구현을 걸려 넘어뜨립니다. 일반 객체는 타입 1 항목을 받습니다. /ObjStm 컨테이너 자체도 타입 1 항목을 받습니다. 컨테이너는 실제 오프셋에 쓰인 완전히 정상적인 간접 스트림 객체이기 때문입니다. 오직 멤버만이 타입 2 항목을 받습니다. 그리고 크로스 레퍼런스 스트림 자체도 파일 안의 간접 객체이므로, 자신이 방금 쓰여진 오프셋을 가리키는 자신만의 타입 1 항목이 필요합니다 — startxref가 기록하는 것과 같은 오프셋입니다. 저희 작성기의 초기 버전은 쓰기 루프에서 멤버 대신 컨테이너 객체 번호를 제외했는데, 결과는 크로스 레퍼런스 스트림은 있지만 객체 스트림은 전혀 없는 파일이었습니다: 구조적으로는 일관되지만 의미론적으로는 비어 있고, 하류에서 거부되었습니다. /Size 값은 대응하는 하나 어긋난 오류를 숨기고 있습니다. 이는 가장 높은 객체 번호에 1을 더한 값이고 크로스 레퍼런스 스트림 자체가 가장 높은 객체 번호로 할당되므로, 그것도 세어져야 하기 때문입니다
/W 배열 크기 정하기: 네 바이트로 충분하지 않은 이유
/W 배열은 세 필드 각각의 바이트 폭을 선언하며, losLab PDF Library는 이를 /W [1 Field2 Field3]로 쓰는데, 필드 1은 타입 코드를 위해 1바이트로 고정되고 필드 3은 65535까지의 세대 번호와 멤버 인덱스를 모두 커버하는 2바이트로 고정됩니다. 필드 2는 상수일 수 없는 것인데, 서로 무관한 두 가지 양을 담기 때문입니다: 타입 1 항목에서는 파일 크기로만 제한되는 바이트 오프셋이고, 타입 2 항목에서는 컨테이너 객체 번호이며, 타입 0 항목에서는 체인의 다음 자유 객체입니다. 고정된 4바이트 필드 2는 파일이 4GB를 넘을 때까지는 잘 작동하지만, 그 경계를 넘는 모든 오프셋은 조용히 잘려나가고 테이블 전체가 쓰레기가 됩니다. 그래서 작성기는 크로스 레퍼런스 스트림 자체의 오프셋을 포함해 어떤 필드 2 슬롯이든 가질 수 있는 가장 큰 값을 조립된 테이블에서 스캔하고, 필요하면 그 필드를 8바이트까지 넓힙니다
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
폭이 알려지면 페이로드 크기도 정확히 알려지므로, 작성기는 버퍼 전체를 미리 할당하고 인덱스로 채웁니다. 항목을 AnsiString에 바이트 단위로 덧붙이면 테이블 구축이 이차식이 되는데, 이는 10페이지짜리 청구서에서는 아무도 눈치채지 못하지만 객체 20만 개짜리 문서에서는 모두가 눈치챕니다. 두 가지 세부 사항이 더 엄격한 리더를 만족시킵니다. /Index는 테이블이 커버하는 객체 번호 범위를 선언하며, 전체 재작성에서는 단순히 빈틈 없는 [0 N]입니다. 그리고 작성기가 실제로 방출하지 않은 모든 슬롯은 사용 중이 아니라 자유로 기본값을 가져야 합니다: 객체 0이 자유 체인의 머리이고, 각 자유 슬롯은 다음 것과 연결되며, 한때 삭제된 객체를 담고 있던 슬롯은 세대 번호가 하나 증가된 채로 유지됩니다. 신뢰할 수 없는 PDF를 파싱할 때의 메모리 안전성에 관한 자매 노트는 읽기 쪽에서 같은 경계 논거를 폅니다
크로스 레퍼런스 스트림이 결코 암호화되어서는 안 되는 이유는 무엇인가
리더가 무엇이든 복호화하는 방법을 알기 전에 그것을 먼저 파싱해야 하기 때문입니다. 크로스 레퍼런스 스트림은 /Encrypt 딕셔너리가 어디에 사는지 리더에게 말해주는 것입니다. 만약 그 바이트 자체가 암호화되어 있다면, 리더는 파일 키를 설명하는 객체를 찾기 위해 파일 키가 필요할 것입니다. losLab PDF Library는 이를 단일한 술어로 강제합니다: ShouldCryptStreamData는 스트림 딕셔너리가 /Type /XRef를 가질 때마다 False를 반환하므로, 어떤 경로가 직렬화기에 도달하든 그 예외는 유지됩니다
/ObjStm 컨테이너는 그 반대의 처리를 받으며, 그 비대칭은 의도적입니다. 컨테이너는 다른 모든 스트림과 똑같이 자신의 객체 번호로 키를 삼아 통째로 암호화됩니다. 그 멤버들은 개별적으로 암호화되지 않습니다 — 그것들은 복호화된 평문 형태로 포장되며, 조립된 컨테이너에 대한 단일 패스가 문자열을 포함해 그것들 모두를 커버합니다. 멤버를 이중 암호화하면 암호문으로 복호화되는 파일이 만들어지며, 바깥쪽 계층이 성공하기 때문에 실패는 인증 실패로서가 아니라 객체 그래프 깊숙한 곳의 파싱 오류로 나타납니다. 그러면 하나의 객체는 그 방식에서 완전히 벗어나 있게 됩니다: 암호화된 문서에서 카탈로그는 직접 타입 1 객체로 유지되며 결코 포장되지 않습니다. 그것을 포장하면 루트가 확립하는 데 도움을 주는 복호화 컨텍스트가 완전히 구축되기도 전에 로더가 문서 루트에 도달하기 위해 객체 스트림을 팽창시키고 복호화해야 하기 때문입니다
델파이에서 포장 켜기
공개 스위치는 TPDFlibSaveOptions의 필드로, 독립된 세터 SetPackObjectStreams로, 그리고 문서 객체의 속성으로 노출된 PackObjectStreams입니다. 기본값은 활성화되어 있으며 버전으로 자동 게이트됩니다: 작성기는 문서가 이미 PDF 1.5 이상일 때만 포장하며, 내부의 최소 버전 가드를 호출하므로 포장된 문서는 잘못 표시되는 대신 1.5로 올라갑니다. 저장 후, GetLastSaveUsedObjectStreams는 게이트가 실제로 열렸는지 보고하는데, 이는 바이트 크기 비교보다 회귀 테스트에서 원하는 단언입니다
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
포장과 가비지 컬렉션 사이의 순서가 중요합니다. 도달 가능성 분석이 먼저 실행되어야 합니다. 컨테이너로 살아남는 멤버는 그 컨테이너를 함께 끌고 가기 때문입니다 — 살아 있는 객체가 포장되면 그 컨테이너 번호는 정의상 도달 가능하며, 컨테이너를 쓸어버리면 멤버를 찾을 방법 없이 좌초시킵니다. 컬렉터를 먼저 실행하는 것은 또한 죽은 객체가 애초에 컨테이너에 전혀 들어가지 않는다는 뜻이며, 여기서 누적된 크기 이득이 나옵니다. 포장은 다른 크기 레버를 대체하는 것이 아니라 보완합니다. PDF 파일 크기 최적화와 폰트 서브셋팅 안내서는 스트림 페이로드에 작용하는 레버를 다루며, 객체 스트림은 구조에 작용합니다
활성화하기 전에 알아둘 가치가 있는 경계
증분 저장은 결코 포장하지 않습니다. 증분 업데이트는 이전 리비전을 물리적으로 온전하게 유지한 채로 새 객체와 새 크로스 레퍼런스 섹션을 덧붙이므로, 기존 객체를 새 컨테이너로 재포장하면 이전 리비전이 여전히 참조하는 타입 1 항목을 고아로 만들게 됩니다. losLab PDF Library는 append 모드가 활성화되어 있을 때마다 포장을 비활성화하며, 증분 업데이트와 append 모드 스트리밍에 관한 글이 그 경로를 완전히 다룹니다. PDF 1.5 미만의 문서는 무조건 평문 크로스 레퍼런스 테이블을 유지합니다: 1.4 소비자는 /ObjStm이 무엇인지 전혀 알지 못하며, 작성기가 더 작은 파일을 선호했다는 이유로 조용히 문서를 승급시키는 것은 호출자를 대신해 내려서는 안 될 잘못된 트레이드오프입니다. 저희가 의도적으로 방출하지 않는 선택적 키 하나는 /Extends인데, ISO 32000-1 §7.5.7은 이를 컨테이너가 선행자를 이름 붙이고 리더가 컨테이너 체인을 논리적 그룹으로 취급할 수 있게 정의합니다. 이는 진짜로 선택 사항이며, 저희가 쓰는 모든 컨테이너는 자체 완결적이고 독립적으로 디코딩 가능하며, 그것을 생략하는 것은 작성기에서 한 부류의 순환과 매달린 참조 버그를 제거합니다 — 다만 리더는 물론 다른 생산자의 파일에서 그것을 만났을 때는 여전히 /Extends를 존중해야 합니다
객체 스트림 포장과 크로스 레퍼런스 스트림 출력은 델파이와 C++Builder용 losLab PDF Library의 일부로, 그것들이 함께 구성되는 가비지 컬렉터 및 콘텐츠 스트림 최적화기와 함께 제공됩니다. 제품 페이지에는 전체 저장 옵션 레퍼런스가 있습니다