선형화된 PDF의 첫 번째 객체는 자신이 속한 파일의 정확한 길이를 바이트 단위까지 기술해야 하고, 파일의 첫 1024바이트 안에 나타나야 합니다. ISO 32000-1 부록 F는 이 항목을 /L이라 부르며, 실제 파일 크기와 어긋나는 것을 발견한 리더에게는 그 문서를 평범한 PDF로 취급하고 안에 든 모든 선형화 힌트를 무시하라고 지시합니다. 그러니까 라이터는 아직 쓰기가 끝나지 않은 파일의 길이를, 힌트 스트림의 오프셋과 첫 페이지의 끝, 파일 뒤쪽 근처에 놓일 상호 참조 테이블의 위치 곁에 인쇄해야 합니다. 앞쪽의 무엇 하나만 크기가 바뀌어도 이 네 숫자는 전부 움직입니다
이 배치의 결실이 Fast Web View입니다. 평범한 PDF는 상호 참조 테이블을 파일 끝에 두기 때문에, 200페이지짜리 스캔 보고서를 넘겨받은 브라우저는 마지막 바이트가 도착할 때까지 1페이지의 위치조차 알 수 없습니다. 선형화된 파일은 1페이지와 작은 상호 참조 섹션을 앞쪽으로 옮겨서, HTTP 범위 요청을 쓰는 클라이언트가 첫 페이지를 일찍 그릴 수 있게 합니다. 이 글에서 다루는 것은 쓰기 쪽입니다. 읽기 쪽 그림은 PDF 선형화와 Fast Web View 설명에 있습니다
HotPDF는 선형화된 파일을 어떻게 배치하는가
HotPDF는 THotPDF.LinearizeOutput이 True일 때 선형화된 출력을 쓰며, 파일은 부록 F가 규정한 물리적 순서를 부분마다 그대로 따릅니다. 헤더 다음에는 선형화 파라미터 딕셔너리가 오고, 이어서 첫 페이지 상호 참조 섹션과 그 트레일러, 다른 문서 수준 객체와 함께 있는 카탈로그, 기본 힌트 스트림, 필요로 하는 모든 객체를 곁에 둔 첫 페이지, 순서대로 나오는 나머지 페이지, 이후 페이지들 사이에서 공유되는 객체, 그 밖의 모든 것, 마지막으로 메인 상호 참조 테이블입니다
문서 모델 어디에도 어떤 객체가 어느 부분에 속하는지 알려주는 것은 없습니다. 라이터는 EndDoc이 저장하는 시점에 참조 그래프에서 그 답을 도출하는데, 각 페이지 객체에서 출발해 모든 간접 객체마다 몇 개의 페이지가 도달하는지를 셉니다. 한 페이지만 도달하는 객체는 그 페이지 섹션에 들어갑니다. 여러 페이지가 도달하는 객체는 공유 객체가 되는데, 사양을 대충 읽으면 놓치는 하나의 비틀림이 있습니다. 1페이지가 그 객체를 쓰는 페이지 가운데 있다면 객체는 어차피 첫 페이지 섹션으로 들어간다는 것입니다. 1페이지는 선행 바이트만으로 렌더링되어야 하기 때문입니다. 순수하게 이후 페이지들 사이에서만 공유되는 객체만이 공유 부분에서 대기합니다. 카탈로그(그 /ViewerPreferences, /OpenAction, /Threads, /AcroForm 항목이 무엇을 가리키든)와 보호 문서의 암호화 딕셔너리가 문서 수준 그룹을 이룹니다. 페이지 트리 노드는 첫 페이지를 부풀리는 일이 없도록 뒤쪽 부분으로 밀어넣습니다
출력을 헥스 에디터로 열어보면 객체 번호가 거꾸로 보입니다. 선형화 딕셔너리부터 힌트 스트림까지의 앞쪽 블록이 파일에서 가장 큰 객체 번호를 달고 있고, 그 뒤의 모든 것은 1부터 다시 매겨집니다. 앞쪽의 작은 상호 참조 섹션이 바로 그 큰 번호 블록을 커버하고, 메인 테이블이 1부터 N까지를 커버합니다
파티션 목록의 레코드 초기화 함정
이런 목록은 자연스럽게 동적 배열과 별도의 카운트를 담은 레코드로 작성하는데, 바로 그 모양에 델파이 함정이 숨어 있습니다. 컴파일러는 로컬 레코드의 관리 필드를 초기화합니다. 그 옆에 앉아 있는 평범한 Integer는 아무것도 못 받습니다. Win32에서는 스택 슬롯이 우연히 0을 담고 있는 경우가 잦아 첫 append가 인덱스 0에 떨어지고 모든 테스트가 통과합니다. Win64에서는 같은 슬롯에 남은 찌꺼기가 들어 있을 수 있고, 첫 append는 임의의 인덱스에 씁니다
type
TPartList = record
Values: TArray<Integer>; // 관리형 필드: 컴파일러가 비웁니다
Count: Integer; // 비관리형 필드: 스택에 있던 값이 그대로
end;
procedure Append(var List: TPartList; Value: Integer);
begin
if List.Count = Length(List.Values) then
SetLength(List.Values, List.Count * 2 + 16);
List.Values[List.Count] := Value;
Inc(List.Count);
end;
procedure BuildParts;
var
FirstPage, Shared: TPartList;
begin
FirstPage := Default(TPartList); // 장식이 아니라 필수
Shared := Default(TPartList);
Append(FirstPage, 12);
Append(Shared, 40);
end;
HotPDF의 라이터는 모든 파티션 목록을 만지기 전에 Default를 대입합니다. Win32에서 초록불로 끝난 실행 하나는 그런 레코드가 초기화되었는지 아무것도 말해주지 않습니다
첫 바이트를 쓰기 전에 왜 레이아웃이 수렴해야 하는가
파일 앞쪽이 뒤쪽을 가리키는 오프셋을 담고 있고, 그 오프셋의 자릿수가 뒤에 오는 모든 것을 밀어내기 때문입니다. 첫 페이지 트레일러는 메인 상호 참조 테이블의 바이트 오프셋을 담은 /Prev 항목을 운반합니다. 그 오프셋을 99998로 쓴 뒤 테이블이 실제로는 100003에 떨어진 것을 발견하면, 트레일러는 자릿수 하나만큼 자랍니다. 첫 페이지 상호 참조 섹션도 같이 자라고, 뒤에 오는 모든 객체가 1바이트씩 밀리고, 메인 테이블은 이제 100004에 놓입니다. 한 번의 패스로 이걸 맞힐 수 있는 건 운빨뿐입니다
HotPDF는 두 가지 조치로 이 순환을 끊습니다. 파라미터 딕셔너리는 공백으로 패딩된 고정 384바이트 슬롯으로 들어가서, /L, /H, /E, /T가 필요한 만큼의 자릿수를 객체 하나도 움직이지 않고 차지할 수 있습니다. 딕셔너리 텍스트가 384바이트를 넘어야 하는 일이 생기면 파일을 밀어내는 대신 쓰기가 예외를 일으킵니다. 그러면 /Prev만이 유일한 자유 변수로 남고, 이것이 고정점 루프를 돕니다. 모든 객체는 우선 길이만 기록하고 바이트는 보관하지 않는 카운팅 스트림으로 직렬화됩니다. 이어서 레이아웃 패스가 섹션별로 오프셋을 배정하고 메인 테이블이 어디에 떨어질지 보고하며, 그 답이 다음 패스의 입력이 됩니다. 제안된 오프셋이 움직임을 멈추면 루프가 끝나고, 여덟 번의 시도 후에도 수렴하지 않으면 예외로 포기합니다
자릿수가 바뀌어야만 무언가가 움직이므로, 세 번째 패스가 필요한 것은 99998 사례처럼 10의 거듭제곱 경계 근처뿐입니다. 수렴 후 라이터는 확인용 패스를 한 번 돌리고, 실제 오프셋으로 힌트 스트림을 다시 만든 뒤 길이가 변하지 않았는지 검사합니다. 오프셋은 고정 폭 필드에 앉아 있으므로 자리표 버전과 최종 버전이 반드시 일치해야 합니다. 그다음 출력은 버퍼에 두 번째 사본을 만들지 않고 대상 스트림으로 곧바로 들어가며, 각 섹션 경계마다 쓰인 바이트가 약속된 오프셋과 맞는지 비교됩니다. 어긋나면 EndDoc에서 예외가 터집니다
힌트 테이블, 그리고 부록 F를 두 갈래로 읽을 수 있는 부분
기본 힌트 스트림은 두 개의 테이블을 운반합니다. 페이지 오프셋 힌트 테이블은 모든 페이지마다 그 섹션이 몇 개의 객체를 담는지, 그 섹션이 바이트로 얼마나 긴지, 어떤 공유 객체에 의존하는지를 기록합니다. 공유 객체 힌트 테이블은 공유 객체 그룹 하나하나의 길이를 나열합니다. 둘이 있으면 리더는 37페이지를 곧바로 요청할 수 있고, 스트림의 /S 항목이 공유 테이블이 시작되는 스트림 안쪽 위치를 알려줍니다
부록 F F.4에는 틀리기 쉬운 세부 사항이 두 가지 있습니다. 첫째, 힌트 테이블에 저장되는 오프셋은 기본 힌트 스트림이 존재하지 않는 것처럼 계산한 논리적 오프셋입니다. 첫 페이지 객체와 첫 공유 객체는 물리적으로는 둘 다 힌트 스트림 뒤에 앉아 있으므로, 라이터는 그 오프셋을 기록하기 전에 힌트 객체의 길이를 뺍니다. 대신 물리적 오프셋을 기록한 라이터는 결과로 나오는 모든 범위 요청을 정확히 힌트 객체 크기만큼 빗나가게 만듭니다
둘째는 진짜 모호성입니다. F.4의 본문은 각 테이블을 최상위 비트부터 시작하는 하나의 연속 비트 스트림으로 기술하고, 첫 페이지 공유 그룹과 이후 공유 그룹을 두 개의 별도 나열로 적습니다. 그런데 실제로 돌고 있는 힌트 스트림들은 모든 열을 바이트 경계로 패딩하고 공유 그룹을 하나로 합친 목록으로 취급하며, 힌트를 소비하는 리더들이 받아들인다고 알려진 형식도 바로 이것입니다. HotPDF는 바이트 정렬된 합침 레이아웃을 씁니다. 이것이 옳은 선택입니다. F.4의 문자 그대로는 만족하지만 읽는 클라이언트에서 어긋나는 테이블은 선형화가 빛나야 할 바로 그 지점에서 실패하고, 파일 전체를 내려받는 경우에는 문제가 드러날 일조차 없습니다
Delphi에서 선형화 출력 켜기
API 쪽에서 스위치는 Boolean 하나입니다. LinearizeOutput의 기본값은 False이고 라이터는 EndDoc이 문서를 직렬화할 때 이 값을 참조하므로, 다른 출력 옵션들과 함께 BeginDoc 전에 설정해 두고 손대지 않으면 됩니다. 위에서 말한 모든 실패(비수렴, 과대해진 파라미터 딕셔너리, 경계 불일치, 아래에서 다루는 4 GiB 상한)는 EndDoc에서 터지는 예외로 표면화되며, 덕분에 폴백 처리가 단순해집니다
procedure WriteReport(const FileName: string; Linearize: Boolean);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.FileName := FileName;
PDF.Version := pdf17;
PDF.LinearizeOutput := Linearize; // BeginDoc 전에 설정
PDF.BeginDoc; // 1페이지 생성
PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
PDF.AddPage;
PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
PDF.EndDoc; // 레이아웃과 쓰기는 여기서
finally
PDF.Free;
end;
end;
procedure PublishReport(const FileName: string);
begin
try
WriteReport(FileName, True);
except
on E: Exception do
begin
LogWarning('Linearization failed: ' + E.Message); // 여러분의 로거
WriteReport(FileName, False); // 대신 평범한 PDF
end;
end;
end;
폴백은 어떤 뷰어로든 열리는 평범한 PDF를 만들어 냅니다. 잃는 것은 빠른 첫 페이지뿐입니다. 보고서 파이프라인이라면 이 트레이드는 쉽게 이해됩니다. 느린 첫 그리기는 성가심에 그치지만, 사라진 보고서는 장애입니다
배포 쪽이 코드보다 위입니다. 선형화는 서버가 HTTP Range 요청을 존중할 때만 값어치를 합니다. 파일을 통째로 스트리밍하는 엔드포인트는 더 느린 쓰기와 더 큰 파일을 대가로 아무것도 사주지 않습니다. 이메일로 발송되거나 첨부되거나, 누군가 보기 전에 내려받아지는 파일에는 LinearizeOutput을 끄세요. 브라우저가 URL에서 곧바로 여는 문서에는, 응답이 Accept-Ranges: bytes를 실어 나르는지 확인한 뒤 켜세요
LinearizeOutput은 왜 객체 스트림을 끄는가
모든 힌트 테이블 항목은 바이트 범위를 기술하는데, /ObjStm 컨테이너에 포장된 객체는 자기만의 바이트 범위가 없습니다. 그 객체는 통째로 가져와 통째로 풀어야 하는 다른 압축 스트림의 조각으로만 존재합니다. LinearizeOutput이 켜져 있으면 HotPDF는 UseXRefStream이나 UseObjectStreams도 함께 설정되어 있어도 전통적인 텍스트 상호 참조 테이블과 포장 풀린 간접 객체를 씁니다. 저장 시점에는 선형화 경로가 우선하므로, 여러분의 코드가 해결할 충돌은 없습니다
대가는 파일 크기입니다. 객체 스트림이 함께 눌렀을 작은 딕셔너리들이 이제 느슨한 채로 남기 때문입니다. 첫 페이지 지연보다 압축 출력이 더 중요하다면, HotPDF의 객체 스트림과 증분 업데이트에 트레이드오프가 정리되어 있습니다. Office 익스포터는 같은 긴장을 반대쪽에서 겪는데, 그래서 일부는 Office 생성 PDF의 하이브리드 상호 참조 스트림에서 기술한 레이아웃을 내놓습니다
선형화에는 PDF 1.2 이상도 필요합니다. Version이 더 낮으면 HotPDF는 저장 시점에 문서를 1.2로 올립니다. StrictVersionLock이 설정되어 있으면 대신 예외를 일으키는데, 여러분이 일부러 고정해 둔 버전이 등 뒤에서 바뀌어서는 안 되기 때문입니다
선형화 출력의 4 GiB 상한
힌트 테이블은 오프셋과 길이를 32비트 필드에 저장하므로, 선형화된 파일은 4 GiB 너머의 어떤 것도 주소 지정할 수 없고 HotPDF는 랩어라운드되는 오프셋을 쓰는 대신 그런 파일을 만들기를 거부합니다. 이 상한은 부록 F가 정의하는 필드 폭에서 나옵니다
이 검사는 세 지점에서 돌며, 셋 모두 첫 바이트가 목적지에 닿기 전입니다. 직렬화 길이가 판명되는 대로 각 객체마다, 힌트 항목을 만드는 동안 각 페이지 섹션 길이마다, 메인 상호 참조 테이블 크기가 정해진 뒤 최종 파일 길이에 한 번입니다. 랩어라운드된 오프셋은 최악의 실패 형태입니다. 파일을 통째로 내려받는 뷰어는 힌트를 무시할 수 있지만 바이트 범위 클라이언트는 엉뚱한 바이트로 점프하므로, 데스크톱 테스트로는 결코 재현되지 않습니다. 수 기가바이트 출력이라면 대용량 PDF 워크플로용 Direct File API의 스트리밍 경로가 살펴볼 곳입니다
들어오는 파일의 선형화 여부 검사하기
THotPDF.IsLoadedLinearized는 로드된 문서가 스스로 선형화되었다고 선언하는지를 보고합니다. 검사는 파싱 전에 일어납니다. HotPDF는 소스의 위치 0부터 최대 1024바이트를 읽고, 첫 번째 객체를 찾아, 그 객체 안에서 값 1인 /Linearized를 살핍니다. 주석과 리터럴 문자열, 헥스 문자열은 건너뛰므로 문자열 안에 그 이름이 나타나는 것은 해당하지 않고, 스캔은 stream이나 endobj에서 멈춥니다. 받아들여지는 것은 1이나 1.0 같은 동등한 값뿐입니다
답은 로드 시점에 캐시됩니다. 애플리케이션 코드가 질문할 즈음에는 파서가 이미 스트림 위치를 옮겼고 LoadFromFile은 자기 소스 스트림을 이미 해제했기 때문입니다
이 플래그는 파일이 주장하는 바를 보고합니다. 부록 F는 두 번째 조건을 얹습니다. /L이 실제 파일 길이와 맞아야 하고, 선형화 뒤에 덧붙은 증분 업데이트는 그 일치를 깨므로 리더들은 파일을 평범한 것으로 취급하는 쪽으로 돌아섭니다. 저장을 덧붙이는 방식의 편집기에서 파일을 받는 파이프라인이라면 이 두 숫자를 스스로 비교할 수 있습니다
// HotPDF 일부가 아닌 여러분의 헬퍼: /L을 실제 크기와 비교합니다
// 단순화한 파싱: /L 뒤에 공백 하나가 있다고 가정합니다
function DeclaredLengthMatches(const FileName: string): Boolean;
var
FS: TFileStream;
Head: AnsiString;
ActualSize: Int64;
P, Q: Integer;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
ActualSize := FS.Size;
SetLength(Head, 1024);
SetLength(Head, FS.Read(Head[1], 1024));
finally
FS.Free;
end;
P := Pos(AnsiString('/L '), Head);
Result := P > 0;
if not Result then
Exit;
Inc(P, 3);
Q := P;
while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
Inc(Q);
Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;
procedure InspectUpload(const FileName: string);
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
if PDF.LoadFromFile(FileName) <= 0 then
Exit;
if not PDF.IsLoadedLinearized then
Writeln('Not linearized')
else if not DeclaredLengthMatches(FileName) then
Writeln('Linearized, but modified after linearization')
else
Writeln('Ready for Fast Web View');
finally
PDF.Free;
end;
end;
여기서 설명한 LinearizeOutput과 IsLoadedLinearized 멤버는 Delphi와 C++Builder용 HotPDF Delphi Component에 함께 제공됩니다. 제품 페이지에는 상호 참조 스트림, 객체 스트림, 버전 잠금과의 상호작용 규칙이 문서화되어 있습니다