내려받다가 중간에 끊긴 PDF는 거의 모든 페이지가 도착했는데도 열기를 거부할 수 있는 반면, 2,000페이지 매뉴얼은 1,500페이지로 순식간에 뛰어갑니다. 둘 다 HotPDF Delphi Component가 쓰는 PDF 파일 구조, 곧 헤더와 번호를 매긴 객체의 본문, 바이트 오프셋의 상호 참조(xref) 테이블, 트레일러에서 나옵니다. 마지막 둘은 파일 끝에 저장됩니다
ISO 32000-2 §7.5.1은 한 페이지 영수증부터 스캔 아카이브까지 모든 PDF에 같은 네 부분 해부를 제시합니다. 부분들은 항상 파일 아래 방향의 같은 순서로 앉고 고정된 체인으로 연결됩니다. 트레일러가 카탈로그를 이름 짓고 xref 테이블이 시작하는 곳을 말하고, xref 테이블이 각 객체가 시작하는 곳을 말하며, 객체들이 번호로 서로를 가리킵니다. 아래 발췌는 오프셋을 바이트마다 검증한 610바이트 Hello World 파일에서 왔습니다. 키워드마다 주석을 단 완전한 파일은 PDF 내부는 어떻게 생겼는가에, 뷰어가 이 부분들을 픽셀이 되는 길에서 어떻게 다루는지는 리더가 PDF를 열 때의 작동 방식에 있습니다
PDF 헤더는 두 줄이며 두 번째 줄이 중요합니다
%PDF-1.7 같은 첫 줄은 구문상 주석이지만, 리더는 이를 파일 서명으로 취급하고 버전을 여기서 뽑아 냅니다. 실전에서 버전 처리는 느슨하므로, 그 숫자는 어떤 기능을 기대할지에 대한 힌트로 취급하십시오. 두 번째 줄은 사람들이 실수로 지운 뒤 오후 내내 디버깅하게 되는 줄입니다. 그 페이로드는 ASCII 127을 넘는 최소 4바이트(ISO 32000-1 §7.5.2)로, 텍스트 모드로 파일을 옮기는 무엇이든 이 파일을 바이너리로 알아보고 줄 끝을 다시 쓰기를 멈추게 하려는 존재입니다. 압축 스트림은 우연히 캐리지 리턴이나 라인 피드와 일치하는 바이트를 담습니다. 그것들을 다시 써 버리면 딕셔너리에 기록된 스트림 길이가 디스크의 바이트와 더는 맞지 않습니다. 제대로 된 라이터는 지금도 그 주석을 여전히 출력합니다. 그것이 막는 실패가 조용하고도 전면적이기 때문입니다
본문: 번호를 매긴 객체와 여덟 PDF 객체 타입
본문은 간접 객체의 평평한 나열이며, 각각은 객체 번호와 세대 번호를 실은 N G obj ... endobj 껍데기로 감싸입니다. 그 평평한 목록을 PDF 내부 구조로 만드는 것은 참조 토큰 N G R입니다. 2 0 R은 "객체 2, 세대 0, 파일 안에서 우연히 사는 어디든"이라는 뜻입니다. 어떤 값이든 직접 객체로 제자리에 쓸 수도 있습니다. 발췌에서 페이지의 /Resources 딕셔너리는 직접이고 그것이 이름 짓는 폰트는 간접이므로, 백 개의 페이지가 백 벌의 사본을 들고 다니는 대신 객체 4를 공유할 수 있습니다:
1 0 obj
<< /Type /Catalog /Pages 2 0 R >> % 카탈로그가 페이지 트리를 가리킨다
endobj
...
3 0 obj
<< /Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Resources << /Font << /F1 4 0 R >> >> % 직접 딕셔너리, 간접 폰트
/Contents 5 0 R
>>
endobj
ISO 32000-1 §7.3은 어휘를 짧게 유지합니다. PDF의 모든 값은 여덟 기본 타입 중 하나입니다. 불리언과 숫자가 스칼라이고, PDF에는 지수 표기가 없어 규격을 따르는 파일에 1e6이 나타나는 일은 없습니다. 문자열은 바이트를 담고, (Hello, World!)로 또는 16진수로 <48656C6C6F>로 씁니다. /Type이나 /Helvetica 같은 이름은 바이트 단위로 비교되므로, /BaseFont가 이름을 기대하는 자리에 (Helvetica)를 쓰면 글자가 같아도 타입 오류입니다. 배열은 MediaBox처럼 무엇이든 순서대로 담습니다. 딕셔너리는 이름을 값에 대응하며 구조 대부분을 떠받칩니다. 스트림은 딕셔너리에 raw 바이트를 붙인 것으로 페이지 콘텐츠와 폰트, 이미지의 집입니다. 여덟 번째 타입은 null이고, 존재하지 않는 객체에 대한 참조는 오류를 내는 대신 null로 해석되어 손상된 파일이 완전히 실패하는 대신 열화된 채 버틸 수 있습니다
세대 번호는 재사용을 위해 존재합니다. 편집이 객체를 지우면 그 번호는 프리 리스트에 올라가고, 나중 편집이 그 번호를 재사용하면 세대가 하나 올라가므로, 낡은 12 0 R가 새 12 1 obj로 조용히 해석되는 일은 없습니다. 마주치는 객체는 거의 모두 세대 0입니다. 해석은 카탈로그, 곧 발췌에서 객체 1에서 시작하며 그 /Pages 항목이 페이지 트리로 이어집니다. 내부 노드는 /Kids로 페이지를 모으고 /Count를 유지하며 /MediaBox 같은 속성을 자손에게 물려줄 수 있습니다. 객체의 물리적 순서가 아니라 그 트리가 페이지 순서를 결정하는 이유는 PDF 페이지 트리와 페이지 순서가 다룹니다
xref 테이블은 어떻게 객체의 위치를 찾아내는가?
xref 테이블은 산술로 객체 번호를 바이트 오프셋으로 바꾸고, 2,000페이지 매뉴얼이 앞의 페이지들을 파싱하지 않고 1,500페이지로 열리는 방식이 바로 그것입니다. ISO 32000-1 §7.5.4는 모든 항목을 정확히 20바이트로 고정합니다. 파일 시작부터 센 10자리 오프셋, 빈칸, 5자리 세대, 빈칸, 타입 문자, 그리고 2바이트 줄 끝으로, LF일 때는 빈칸 더하기 라인 피드입니다. 행이 균일하므로 리더는 객체 n의 행으로 곧장 인덱싱합니다. 0 6 줄은 하위 섹션 헤더이고, 항목 0은 세대 65535의 프리로 항상 프리 객체 번호 연결 리스트의 머리입니다. 다른 모든 오프셋은 맞는 N G obj 헤더의 첫 자릿수에 착지해야 하며, 610바이트 파일이 그렇습니다:
xref
0 6 % 6개 항목, 객체 0부터 시작
0000000000 65535 f % 항목 0: 프리 리스트의 머리
0000000019 00000 n % 객체 1(카탈로그)은 19바이트에서 시작
0000000068 00000 n % 객체 2(페이지 트리)는 68바이트에서 시작
0000000125 00000 n % 객체 3(페이지)은 125바이트에서 시작
0000000263 00000 n % 객체 4(폰트)는 263바이트에서 시작
0000000333 00000 n % 객체 5(콘텐츠 스트림)은 333바이트에서 시작
절단된 PDF는 왜 열기에 실패하는가?
절단된 PDF가 실패하는 이유는 모든 것을 묶는 두 부분, 곧 xref 테이블과 트레일러가 가장 마지막에 쓰인 바이트이기 때문입니다. 트레일러 딕셔너리는 카탈로그인 /Root와 xref 항목 수인 /Size를 실는데, 슬롯 0이 프리 항목을 쥐고 있으므로 가장 높은 객체 번호보다 하나 큽니다. 그 아래 startxref가 xref 키워드의 바이트 오프셋을 주고 %%EOF가 파일을 닫습니다:
trailer
<< /Size 6 /Root 1 0 R >> % 항목 수, 문서 카탈로그
startxref
427 % xref 키워드의 바이트 오프셋
%%EOF
마지막 몇백 바이트를 잃으면 파일은 객체를 지니지만 그것들로 가는 지도가 없습니다. 엄격한 리더는 거기서 멈춥니다. 관용적인 리더는 파일 전체를 N G obj 헤더로 스캔해 테이블을 다시 짓는데, 이는 xref가 존재 이유인 피해야 할 느린 경로이고, 다시 지은 지도도 여전히 객체를 빠뜨리거나 편집된 객체의 잘못된 사본을 고를 수 있습니다. 텍스트 모드 전송은 더 조용한 피해를 입힙니다. 줄 끝을 다시 쓰면 모든 객체가 밀리기 때문입니다. 우리는 610바이트 파일을 CRLF 줄 끝으로 바꾸고 qpdf --check(qpdf 12.3.2)를 돌렸습니다. 오프셋 427에서 "file is damaged"와 "xref not found"를 보고했고, 무엇이든 검사하기 전에 스캔으로 테이블을 다시 지었습니다
클래식 xref 테이블인가, 상호 참조 스트림인가?
PDF 1.5부터 xref는 상호 참조 스트림일 수도 있습니다(ISO 32000-1 §7.5.8). /Type /XRef의 간접 객체로, 그 이진 행들이 텍스트 테이블을 대체하고 그 스트림 딕셔너리가 트레일러 항목들을 흡수하므로 trailer 키워드가 사라지고 startxref는 그 객체를 가리킵니다. 행 타입은 숫자가 됩니다(0 프리, 1 바이트 오프셋, 2 객체 스트림 안). /W가 각 열의 바이트 폭을 주고, HotPDF는 행들을 Flate와 PNG Up 예측기로 압축합니다. HotPDF는 Version이 pdf15 이상일 때 스트림 형태로 쓰므로 예제는 그것을 명시적으로 설정하고, UseObjectStreams는 같은 스위치 위에 세워집니다. xref 스트림을 전혀 파싱하지 못하는 PDF 1.4 시대 도구에서 열려야 하는 파일에는 UseXRefStream을 끄고, 컴팩트한 인덱스가 값을 내는 큰 문서에는 켭니다. 바뀌는 것은 파일 꼬리뿐입니다:
uses
System.SysUtils, HPDFDoc;
procedure SaveWithXRef(const FileName: string; AsStream: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.FileName := FileName;
PDF.Version := pdf17; // xref 스트림에는 PDF 1.5 이상이 필요하다
PDF.UseXRefStream := AsStream; // 기본 False: 텍스트 테이블 더하기 트레일러
PDF.BeginDoc;
PDF.CurrentPage.TextOut(72, 72, 0, 'Same page, different index');
PDF.EndDoc;
finally
PDF.Free;
end;
end;
// SaveWithXRef('table.pdf', False);
// 기대 꼬리: xref, 0 N, 20바이트 행,
// trailer << /Size /Root /Info /ID >>, startxref, %%EOF
// SaveWithXRef('stream.pdf', True);
// 기대 꼬리: N 0 obj << /Type /XRef /Size /Root /Info /ID /W ...
// /Filter /FlateDecode >> stream ... endstream endobj, startxref, %%EOF
증분 업데이트는 다시 쓰는 대신 두 번째 xref를 덧붙입니다
PDF는 디스크에 이미 있는 바이트를 하나도 건드리지 않고 편집될 수 있습니다. 바뀐 객체와 새 객체는 끝에 덧붙고, 그것들만 나열하는 xref 섹션과 이전 테이블의 오프셋을 /Prev 항목에 지닌 트레일러가 뒤따릅니다. 리더는 startxref를 따라 가장 최신 섹션에 가고 /Prev 체인을 거슬러 걸어 각 객체 번호의 최신 항목에 승리를 줍니다. 아래 편집은 610바이트 파일에 노트 주석을 더합니다. 페이지 객체 3은 같은 번호로 /Annots 배열을 얹어 다시 쓰이고, 객체 6은 새것입니다. 주석을 빼고 LF 줄 끝으로 두면 오프셋이 정확하고, qpdf --check는 오류 없이 그 결과를 통과시켰습니다:
% ... 원본 610바이트, 변경 없음 ...
3 0 obj % 페이지 3을 대체, 같은 객체 번호
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R] >>
endobj
6 0 obj % 새 주석
<< /Type /Annot /Subtype /Text /Rect [72 650 92 670] /Contents (Checked) >>
endobj
xref % 한 항목짜리 하위 섹션 둘
3 1
0000000610 00000 n
6 1
0000000752 00000 n
trailer
<< /Size 7 /Root 1 0 R /Prev 427 >>
startxref
843
%%EOF
덧붙이기가 서명된 PDF를 검증 가능하게 지키는 것입니다. 서명은 바이트 범위를 덮고, 증분 업데이트는 그 바이트를 결코 움직이지 않습니다. PDF가 복구 가능한 이력을 지니는 이유이기도 합니다. 낡은 페이지 3은 여전히 125바이트에, 도달할 수는 없지만 존재하며 앉아 있으니까요. 여러 번 개정된 파일은 긴 /Prev 체인을 기릅니다. qpdf revised.pdf flat.pdf는 도달 가능한 객체들을 단일 xref 아래로 다시 직렬화하고 오래된 리비전을 버리는데, 위 파일에서는 xref 테이블 하나와 /Prev 없음을 남겼고 그 대가로 원본 바이트 위의 서명을 잃었습니다. HotPDF에서 증분 리비전은 이렇게 보입니다:
procedure ReviseInPlace(const Original, Revised: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.AutoLaunch := False;
PDF.BeginIncrementalUpdate(Original); // 원본 바이트를 그대로 지킨다
PDF.SetLoadedTitle('Quarterly report, revised');
PDF.SetLoadedAuthor('Finance');
PDF.SaveIncrementalUpdate(Revised); // 바뀐 객체, 새 xref, /Prev 트레일러
finally
PDF.Free;
end;
end;
네 부분 배치는 오동작하는 PDF를 위한 빠른 분류 순서도 줍니다. 파일이 그럴듯한 startxref와 함께 %%EOF로 끝나는지 확인하고, 한두 개의 xref 오프셋이 N G obj 헤더에 착지하는지 확인한 뒤 /Root를 따라 카탈로그와 페이지 트리로 들어가십시오. Delphi와 C++Builder용 HotPDF Delphi Component는 청구에 따라 클래식 xref 테이블이나 상호 참조 스트림을 쓰고, 앞선 바이트와 그 위의 서명을 온전히 남긴 채 증분 리비전을 덧붙입니다