HotPDF는 THotPDF의 LinearizeOutput 속성을 통해 Acrobat이 Fast Web View라 부르는 레이아웃, 즉 선형화된 PDF 파일을 작성합니다. BeginDoc 이전에 이 속성을 설정하면 HotPDF는 완성된 객체 그래프를 재정렬해서, 바이트 범위를 인식하는 리더가 파일 앞부분만 가져와도 첫 페이지를 표시할 수 있게 합니다 — 문서 전체를 먼저 내려받을 필요가 없다는 뜻입니다. 이 메커니즘은 ISO 32000-1 부록 F에 정의되어 있습니다
이게 중요한 이유는 딱히 화려하지 않습니다. 일반적인 PDF는 상호 참조 테이블을 파일 끝에 두기 때문에, 뷰어는 마지막 바이트까지 도달해야 무엇이 어디에 있는지 알 수 있습니다. 200페이지짜리 스캔 보고서를 브라우저에 넘기면, 사용자가 원한 건 1페이지뿐인데도 전체 전송이 끝날 때까지 스피너만 보게 됩니다. 선형화는 쓰기 시점에 비용을 치러서 이 문제를 해결합니다. 이 글은 바로 그 쓰기 경로, 즉 분할과 측정 루프, 그리고 하드 리밋을 다룹니다. Fast Web View가 실제로 무엇을 얻게 해주는지에 대한 개념적 배경은 앞선 PDF 선형화와 Fast Web View 설명에서 다룹니다
선형화 레이아웃이 실제로 보장하는 것
선형화된 파일은 극도로 구체적인 물리적 순서를 가진 평범한 PDF이며, 여기서 나오는 모든 보장은 새로운 객체 타입이 아니라 바로 그 순서에서 나옵니다. HotPDF는 부록 F가 규정한 순서대로 각 부분을 출력합니다: 첫 1024바이트 안에 들어가는 선형화 파라미터 딕셔너리, 초기 상호 참조 테이블, 문서 수준 객체, 기본 힌트 스트림, 첫 페이지와 그 전용 객체, 그다음 나머지 페이지, 공유 객체, 그 밖의 모든 것, 마지막으로 메인 상호 참조 테이블입니다
이 분할은 선언되는 게 아니라 유도됩니다. HotPDF는 각 페이지 객체에서 참조 그래프를 순회하며, 모든 간접 객체마다 몇 개의 페이지가 그것을 참조하는지, 어느 페이지가 가장 먼저 참조했는지를 기록합니다. 정확히 한 페이지만 사용하는 객체는 그 페이지 전용이 됩니다. 두 개 이상이 참조하면 공유 객체가 됩니다. 카탈로그, 그리고 그것이 /ViewerPreferences, /OpenAction, /Threads, /AcroForm 아래에서 참조하는 것들, 그리고 보호가 활성화된 경우 암호화 딕셔너리까지 합쳐서, 다른 모든 것보다 앞에 와야 하는 문서 수준 그룹을 이룹니다. 페이지 트리 노드는 첫 페이지 섹션을 오염시키지 않도록 일부러 뒤로 미룹니다
파라미터 딕셔너리는 리더가 다른 무엇을 읽기도 전에 필요로 하는 숫자들을 담습니다: 전체 파일 길이를 위한 /L, 힌트 스트림의 오프셋과 길이를 위한 /H, 첫 페이지의 객체 번호를 위한 /O, 첫 페이지 섹션이 끝나는 바이트 위치를 위한 /E, 페이지 수를 위한 /N, 메인 상호 참조 테이블 항목의 오프셋을 위한 /T입니다. 이 값들은 전부, 그것을 써야 하는 순간에는 아직 존재하지도 않는 파일 속 바이트 오프셋입니다
힌트 테이블 오프셋은 왜 수렴해야 하는가
파라미터 딕셔너리 안의 숫자들은 그것을 담고 있는 파일 자체를 기술하며, 그중 하나라도 바뀌면 파일이 바뀌기 때문입니다. 이것이 선형화 라이터의 핵심 난제이며, HotPDF가 한 번만 쓰지 않고 반복해서 측정하는 이유입니다. /T가 6자리에서 7자리로 늘어나면 파라미터 딕셔너리가 1바이트 커지고, 헤더가 커지고, 모든 객체가 밀려나고, 메인 상호 참조 테이블이 이동하고, /T는 이제 다른 값이 필요해집니다. 실제 출력 바이트를 단 하나라도 확정하기 전에 레이아웃이 고정점에 도달해야 합니다
HotPDF는 유한한 반복으로 이를 처리합니다. 먼저 모든 객체를 바이트를 유지하지 않고 길이만 기록하는 카운팅 스트림으로 직렬화해서, 각 객체의 직렬화 크기를 알아둡니다. 그다음 문서 수준 그룹, 힌트 스트림, 첫 페이지 그룹, 이후 페이지 그룹, 공유 그룹, 나머지에 오프셋을 배정하는 레이아웃 패스를 실행하고, 메인 상호 참조 테이블이 어디에 놓일지 보고합니다. 그 결과는 다음 패스의 입력으로 다시 투입됩니다. 이 루프는 최대 8회로 제한되며, 수렴하지 못하면 그럴듯해 보이는 잘못된 오프셋을 가진 파일을 만드는 대신 예외를 발생시킵니다
CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
CalculateLayout(CandidateMainOffset, FirstXRefData,
HintOffset, EndFirstPage, NewMainOffset);
if NewMainOffset = CandidateMainOffset then
Break;
CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
raise Exception.Create('Linearization layout did not converge');
두 가지 세부 사항이 루프가 요동치지 않게 막아줍니다. 파라미터 딕셔너리는 공백으로 패딩된 고정 384바이트 슬롯에 기록되므로, 그 자체의 성장이 레이아웃을 흔드는 일은 절대 없습니다. 만약 딕셔너리 텍스트가 그 예약 공간을 초과하면 HotPDF는 조용히 모든 걸 밀어내는 대신 예외를 발생시킵니다. 그리고 수렴 이후 HotPDF는 확인용 레이아웃 패스를 한 번 더 실행하고 힌트 스트림 길이를 재검사합니다. 힌트 스트림 자체가 레이아웃이 정착된 뒤에야 알 수 있는 오프셋들을 인코딩하기 때문입니다. 이 모든 측정의 결실은 HotPDF가 문서 사본을 두 번째로 버퍼링하지 않는다는 점입니다. 오프셋이 확정되면 객체는 곧바로 대상 스트림에 직렬화되며, 각 섹션 경계마다 기록된 바이트가 약속된 오프셋과 일치하는지 확인하는 어서션이 붙습니다
Delphi에서 켜기
API 표면은 Boolean 하나뿐이고, 유일한 요구 사항은 생성이 시작되기 전에 설정하는 것입니다. LinearizeOutput은 기본값이 False이며 레이아웃 패스는 문서가 저장될 때 실행되므로, EndDoc 이후에 값을 지정해도 아무 효과가 없습니다
var
PDF: THotPDF;
begin
PDF := THotPDF.Create(nil);
try
PDF.FileName := 'fast-view.pdf';
PDF.Version := pdf17;
PDF.LinearizeOutput := True; // must precede BeginDoc
PDF.BeginDoc;
PDF.Canvas.TextOut(72, 72, 'First page');
PDF.EndDoc;
finally
PDF.Free;
end;
end;
코드 쪽의 무엇보다 우선하는 배포상의 주의점이 하나 있습니다. 선형화는 전송 계층이 HTTP 범위 요청을 지원할 때만 값어치를 합니다. 같은 파일을 전체 스트리밍하는 엔드포인트나 Range를 무시하는 CDN 설정으로 서빙하면, 사용자 눈에 보이는 이득 없이 더 느린 쓰기 경로와 더 큰 파일만 얻게 됩니다. 코드를 확인하기 전에 서버를 먼저 확인하십시오
선형화는 왜 UseXRefStream과 UseObjectStreams를 무시하는가
선형화 라이터는 모든 객체가 직접 주소 지정 가능한 자기만의 바이트 오프셋을 가져야 하는데, 이 두 기능은 바로 그것을 앗아가기 때문입니다. 그래서 HotPDF는 LinearizeOutput이 활성화되면 호출자가 UseXRefStream이나 UseObjectStreams를 설정했더라도 전통적인 텍스트 상호 참조 테이블과 압축되지 않은 간접 객체를 항상 사용합니다. 이는 여러분이 스스로 해결해야 할 충돌이 아니라 의도된 오버라이드입니다
이 근거는 힌트 테이블에서 나옵니다. 힌트 테이블은 페이지 섹션이 어디서 시작하고 길이가 얼마인지를 기술하므로, 리더는 정확히 그 범위만 요청할 수 있습니다. /ObjStm 컨테이너 안에 묶인 객체는 독립된 오프셋이 아예 없습니다. 그 객체는 통째로 가져와서 압축을 풀어야 하는 다른 압축 스트림 안의 조각으로만 존재합니다. 파일 크기를 줄이려고 객체 스트림을 쓰고 있었다면, 선형화와 압축이 서로 반대 방향으로 당기고 있다는 점을 이해하고, HotPDF의 객체 스트림과 증분 업데이트 자매편에서 그 트레이드오프를 읽어보십시오. 같은 긴장이 하이브리드 참조 파일의 형태를 결정하는데, 이는 스트림 기반 테이블과 나란히 오래된 리더도 계속 작동하게 하려고 정확히 존재하는 것으로, Office 생성 PDF의 하이브리드 상호 참조 스트림 글에서 다룹니다
버전 하한선도 있습니다. 선형화는 PDF 1.2 이상을 요구합니다. 선택된 버전이 더 오래된 것이면 HotPDF는 자동으로 올리지만, StrictVersionLock이 설정된 경우에는 여러분이 일부러 고정한 문서를 조용히 승격시키는 대신 쓰기 시점에 예외를 발생시킵니다
4GiB의 벽, 그리고 HotPDF가 잘라내는 대신 거부하는 이유
선형화 힌트 테이블은 오프셋을 32비트 값으로 저장하므로, 선형화된 파일은 4GiB 이상은 어디도 주소 지정할 수 없으며, HotPDF는 랩어라운드된 오프셋을 가진 파일을 쓰는 대신 그런 출력을 명시적 예외로 거부합니다. 이 한계는 HotPDF의 구현 선택이 아니라 부록 F가 정의한 필드 폭 그 자체입니다
이 검사는 세 곳에서 적용되며, 세 곳 모두 중요합니다. HotPDF는 각 객체의 직렬화 길이가 파악되는 즉시 검증하고, 힌트 항목을 만드는 동안 각 페이지 섹션 길이를 검증하고, 메인 상호 참조 테이블 크기가 정해진 뒤 최종 파일 길이를 검증합니다. 일찍 실패하는 것이 요점 전부입니다. 조용히 잘려나간 오프셋을 담은 힌트 테이블은 전체를 내려받는 뷰어에서는 정상적으로 열리지만, 선형화가 애초에 서비스하려던 바이트 범위 클라이언트에서만 실패하는 파일을 만들어냅니다. 이것이 최악의 실패 형태인 이유는 여러분의 테스트 뷰어에서는 절대 재현되지 않기 때문입니다. 수 기가바이트 규모의 출력을 만드는 중이라면 선형화는 맞는 도구가 아니며, 대용량 PDF 워크플로용 Direct File API 노트에서 설명하는 스트리밍 방식이 살펴봐야 할 방향입니다
불러온 파일에서 선형화 여부 감지하기
THotPDF.IsLoadedLinearized는 현재 로드된 문서가 이미 선형화된 형태로 작성되었는지를 보고하며, 실시간 스트림이 아니라 파싱 전에 찍어둔 스냅샷을 근거로 답합니다. HotPDF는 소스 스트림의 위치 0부터 첫 1024바이트를 읽어 그 안에서 첫 번째 obj 키워드를 찾고, 이어서 값이 1인 /Linearized 항목을 찾은 뒤 그 Boolean 결과를 캐시합니다
var
PDF: THotPDF;
PageCount: Integer;
begin
PDF := THotPDF.Create(nil);
try
PageCount := PDF.LoadFromFile('incoming.pdf');
if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
Writeln('Source is not Fast Web View ready');
finally
PDF.Free;
end;
end;
이 설명에는 두 가지 핵심 제약이 있습니다. 이 감지는 스트림 위치에 의존할 수 없는데, 애플리케이션 코드가 질문을 던질 즈음에는 파서가 이미 위치를 옮겨놓았기 때문입니다. 또 필요할 때 다시 읽을 수도 없는데, LoadFromFile이 로드를 마치는 즉시 내부 소스 스트림을 해제하기 때문입니다. 그래서 파싱 전 캡처 후 캐시라는 설계가 나옵니다. 이 스캔은 값에 대해서도 일부러 문자 그대로 엄격합니다. /Linearized 1이거나 분수부가 전부 0인 수치적으로 동등한 형태만 인정되는데, 파라미터 딕셔너리가 다른 값을 말하는 파일은 부록 F가 약속하는 바를 만족하지 않기 때문입니다
훔쳐갈 만한 Delphi 레코드 함정
동적 배열을 담은 로컬 레코드는 관리 필드만 초기화하고 그 외에는 아무것도 초기화하지 않습니다. 배열 옆에 평범한 Count 필드를 두었다면 여러분이 직접 그것을 초기화해야 합니다. 이 문제가 개발 도중 선형화 분할 로직을 물었고, 딱 하나의 플랫폼에서만 감춰지는 탓에 하루를 잡아먹는 종류의 버그였습니다
type
THPDFLinearIndexList = record
Values: THPDFIntegerArray; // managed field: cleared for you
Count: Integer; // plain field: whatever was on the stack
end;
// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);
동적 배열 필드는 참조 카운트 방식이라 컴파일러가 이를 0으로 만들어줍니다. 옆에 있는 Count는 그런 보장이 없는 평범한 정수이며, 초기화되지 않은 Count는 첫 번째 append를 임의의 인덱스로 보냅니다. Win32에서는 스택 슬롯이 우연히 0을 담고 있어서 append가 인덱스 0에 정확히 떨어졌고, 모든 테스트가 통과했습니다. Win64에서는 같은 코드가 배열 끝을 넘어서 썼습니다. 이 교훈은 선형화를 훨씬 넘어서 일반화됩니다. 레코드가 관리 필드와 비관리 필드를 섞어 쓴다면 Default(TRecord)를 대입하고 컴파일러가 어떤 필드를 커버하는지 따지는 걸 멈추십시오. 그리고 초록불이 켜진 Win32 실행 결과를 초기화가 올바르다는 증거로 취급하지 마십시오
여기서 설명한 LinearizeOutput과 IsLoadedLinearized 멤버는 Delphi와 C++Builder용 표준 HotPDF Component에 함께 제공됩니다. 제품 페이지에는 상호 참조 스트림, 객체 스트림, 버전 잠금과의 상호작용 규칙을 포함한 전체 속성 레퍼런스가 실려 있습니다