HotPDF Delphi Component가 LoadFromFile로 PDF 1.5 파일을 로드할 때, /Type /ObjStm 컨테이너 안에 압축되어 들어 있는 객체들을 파싱하지는 않습니다. 각 압축 멤버가 어디 있는지만 기록해 두고, 무언가 요청할 때만 파싱합니다. 이 lazy 불변 조건이 로드 시간을 실제로 건드리는 양에 비례하게 유지해 주고, 또한 전체 재작성이 바이트를 내보내기 전에 한 가지 일을 더 해야 하는 이유이기도 합니다. 아직 파싱되지 않은 멤버를 전부 확장해야 합니다. 재작성이 그 멤버들이 사는 컨테이너를 곧 버릴 예정이기 때문입니다
이 노트를 쓰게 만든 증상은 설명하기는 쉽고 디버깅하기는 불쾌합니다. 폰트와 색 공간, 구조 트리가 객체 스트림에 들어 있는 파일을 로드해 BeginDoc과 EndDoc 생성 쌍을 거치면 출력은 아무 불평 없이 열립니다. 페이지 수도 맞고, 대충 확인한 페이지에는 텍스트도 보입니다. 그러다 동료가 40페이지를 열면 본문 텍스트가 대체 폰트로 렌더링되거나, ActualText 대체가 있던 자리에서 Extract Text 명령이 쓰레기를 돌려줍니다. 아무것도 크래시하지 않았습니다. 라이터가 한 번도 로드되지 않은 객체를 그냥 직렬화했고, 로드되지 않은 객체는 아무것도 아닌 것으로 직렬화됩니다
LoadFromFile은 압축된 객체에 대해 실제로 무엇을 보관할까요?
모든 type-2 상호 참조 엔트리에 대해 LoadFromFile은 FCompactObjects에 작은 레코드를 남깁니다. 객체 번호, 컨테이너 테이블에서 담고 있는 스트림의 인덱스, 그 스트림 안에서 멤버의 위치, 그리고 nil로 시작하는 ParsedObject 포인터입니다. 컨테이너 자체는 찾아내고, 문서가 암호화되어 있으면 복호화하고, 압축을 풀지만, 멤버 본문은 바이트로 남겨 둡니다. ISO 32000-1 §7.5.7이 이를 가능하게 하는 컨테이너 레이아웃을 정의합니다. 객체 번호와 오프셋 쌍으로 이루어진 헤더가 있고, /First 뒤에 멤버 본문이 이어 붙는 구조이므로, 어떤 멤버든 이웃을 건드리지 않고 잘라낼 수 있습니다
레코드를 객체로 바꾸는 유일한 경로가 EnsureCompressedObjectLoaded입니다. 객체 번호로 레코드를 찾고, ParsedObject가 이미 설정되어 있으면 그 캐시된 객체를 반환하며 캐시 적중으로 셉니다. 그렇지 않으면 컨테이너가 축출되었을 경우 다시 로드하고, 오프셋 테이블에서 멤버의 바이트 범위를 계산하고, 파서에 그 슬라이스의 제로 카피 뷰를 넘긴 다음, 결과를 레코드에 되저장합니다. 이후로 그 객체는 간접 객체이고 실제 객체 번호를 들고 있으며, 파일 본문에서 파싱된 다른 객체와 마찬가지로 문서의 객체 인덱스에 등록됩니다. 카탈로그, info 딕셔너리, 페이지 트리 루트, 페이지 객체들은 탐색에 필요하기 때문에 로드 시점에 이 경로를 거칩니다. 폰트, 색 공간, ExtGState 딕셔너리, 구조 요소는 그렇지 않고, 페이지 렌더나 재작성이 건드릴 때까지 레코드로 남습니다
이것을 밖에서 지켜볼 수 있습니다. GetLoadedObjectStreamCacheInfo는 컨테이너가 몇 개 있는지, 멤버가 몇 개 인덱싱되었는지, 그중 몇 개가 지금까지 파싱되었는지 보고합니다
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
구조가 많은 파일에서는 로드 직후 세 번째 숫자가 두 번째 숫자의 작은 일부에 불과합니다. 그 차이가 lazy 로딩의 존재 이유이고, 동시에 전체 재작성이 되돌아가서 챙겨야 하는 객체 집합이기도 합니다
전체 재작성은 증분 저장이 유지하는 폰트를 왜 떨어뜨릴까요?
전체 재작성은 원본 파일의 /ObjStm과 /XRef 컨테이너를 버리고 객체 그래프를 처음부터 다시 직렬화하므로, ParsedObject가 아직 nil인 멤버는 출력에 남길 표현이 없습니다. 증분 업데이트는 이 문제가 전혀 없습니다. 원본 바이트 뒤에 새 객체를 덧붙이고, 옛 컨테이너는 이전 상호 참조 섹션이 주소를 잡을 수 있도록 그대로 두기 때문입니다. 차이는 두 모드가 폰트를 다루는 방식에 있지 않습니다. 원본 컨테이너가 살아남아 다음 뷰어에게 읽히느냐에 있습니다
수정은 FileName을 설정하든 OutputStream을 설정하든 EndDoc이 구동하는 직렬화기인 SaveToStream에 있습니다. 어떤 라이터 분기로 보내기 전에 FCompactObjects를 순회하며 모든 항목에 EnsureCompressedObjectLoaded를 호출합니다. 멤버를 로드할 수 없으면 저장은 계속 진행하지 않고 예외를 냅니다. 폰트 딕셔너리를 조용히 떨어뜨리는 재작성은 멈추는 재작성보다 나쁘기 때문입니다. 확장은 그 계층, 즉 classic, packed, linearized 분기 위에, 그리고 linearized 경로의 재로드된 구조 스트림 정리보다도 위에 있어야 합니다. 이전 버전은 SaveLoadedDocument 안에서만 멤버를 확장했는데, 로드된 문서 쪽 어휘는 커버했지만 생성 쪽 어휘는 완전히 놓쳤습니다. LoadFromFile에 이어 BeginDoc, 페이지 편집, EndDoc으로 가는 흐름은 건드리지 않은 멤버가 전부 미파싱 상태인 채로 라이터에 곧장 들어갔습니다
// 이제 두 재작성 어휘 모두 어떤 라이터가 돌기 전에 compact 멤버를 확장합니다.
// 로드된 문서 경로:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// 로드된 파일에 대한 생성 경로:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream이 먼저 모든 FCompactObjects 항목을 구체화합니다
캐시된 멤버는 그동안 여러분이 한 일을 그대로 유지합니다. 저장 전에 파싱되고 편집되고 dirty로 표시된 객체는 편집 내용과 함께 캐시에서 반환되고, 삭제한 멤버는 반복 저장에도 삭제 상태를 유지합니다. 확장 패스는 구조적으로 멱등입니다. 언제나 nil 슬롯만 채우기 때문입니다
세 페이지 픽셀 검사가 ActualText 사례를 놓치는 이유
이 버그가 가장 오래 숨어 있는 곳이 구조 요소입니다. ISO 32000-1 §14.9.4에 정의된 marked content 시퀀스의 ActualText 항목은 추출과 접근성을 위해 글리프를 대체하지만 렌더링에는 영향을 주지 않습니다. 구조 요소가 객체 스트림에 살고 있고 재작성이 그것을 잃으면, 페이지는 여전히 제대로 그려지고 첫 페이지와 중간 페이지, 마지막 페이지가 원본과 픽셀 단위로 일치하며, 누군가 텍스트 추출이나 스크린 리더를 돌릴 때에야 회귀가 드러납니다. 페이지만 렌더링하는 재작성 테스트는 태그가 있는 PDF를 위한 재작성 테스트가 아닙니다. 추출한 텍스트와 구조 트리도 비교해야 합니다
빈 사용자 비밀번호는 로드를 어떻게 바꿀까요?
빈 사용자 비밀번호도 파일이 암호화되어 있다는 뜻이고, 그런 파일의 객체 스트림은 파일 키를 복구하기 전까지 암호문입니다. ISO 32000-1 §7.6.3.4 Algorithm 2가 비밀번호와 /O 항목, /P, 첫 문서 식별자로 그 키를 유도하며, HotPDF는 type-2 패스가 컨테이너 하나라도 압축을 풀기 전에 빈 문자열에 대해 이 알고리즘을 돌려야 합니다. 그래서 로드된 암호화 문서에서 BeginDoc이 무엇보다 먼저 빈 비밀번호로 DecryptLoadedDocument를 호출합니다. 재작성을 시작하려면 객체 그래프가 인증되고 복호화되어 있어야 하며, 호출자가 출력을 보호할 생각인지와는 무관합니다. 출력 암호화는 별개의 결정이고 호출자의 보호 설정이 좌우하며, BeginDoc은 복호화 패스를 마친 뒤 그 설정을 복원합니다. 암호화된 입력이 조용히 암호화된 출력이 되지 않도록 하기 위해서입니다
컨테이너 정책은 비밀번호를 시도하기 전에 /Encrypt 딕셔너리에서 읽습니다. /V가 1과 2인 경우 모든 스트림이 파일 키로 암호화됩니다. crypt filter의 경우 HotPDF는 /CF를 통해 /StmF를 해석합니다. Identity 필터이거나 /CFM이 None이면 평문 컨테이너이고, V2와 AESV2는 암호화된 컨테이너입니다. 그 답은 FReloadObjectStreamsEncrypted에 들어가고, 한 가지 특정한 경우에 중요합니다. 컨테이너는 평문인데 문자열은 아닐 때 멤버들이 개별적으로 복호화해야 하는 암호화된 문자열을 들고 있으므로, MaterializeMembersOfPlaintextObjectStreams가 객체별 복호화 패스에 앞서 모든 compact 멤버를 확장합니다. 정책이 아직 알려지지 않았을 때는 아무것도 하지 않고, 컨테이너 자체가 암호화되었을 때도 아무것도 하지 않습니다. 암호화된 컨테이너의 멤버는 그 컨테이너와 함께 이미 복호화되었고, 두 번 복호화되어서는 절대 안 되기 때문입니다
컨테이너를 복호화할 수 없으면 어떻게 될까요?
복호화에 실패한 컨테이너는 격리되고, 치명적이지는 않습니다. type-2 패스는 FObjStmQuarantine에 THPDFObjStmQuarantineInfo 항목을 기록합니다. 컨테이너의 객체 번호, THPDFObjStmQuarantineReason, 진단 문자열, 그리고 상호 참조가 그 컨테이너로 라우팅한 멤버 객체 번호 목록입니다. osqrDecryptFailed는 네 가지 상황에서 나옵니다. crypt filter를 하나도 해석할 수 없거나, AES-256 또는 AES-GCM 복호화가 예외를 내거나, 예전 방식의 RC4 또는 AES-128 복호화가 예외를 내거나, 쓸 만한 파일 키가 아예 없을 때입니다. 독립적인 컨테이너는 계속 로드되므로, 컨테이너 하나가 손상된 문서도 여전히 열리고 그 컨테이너에 의존하지 않는 모든 페이지를 여전히 렌더링합니다
격리 목록은 파서 폴백을 거쳐도 살아남습니다. 기본 상호 참조 로드가 실패해서 HotPDF가 파일을 스캔해 객체 테이블을 재구성하면, 첫 시도의 암호화 플래그는 그 재구성을 넘기지 못할 수 있지만 격리 기록은 남습니다. 그래서 BeginDoc이 암호화 플래그가 아니라 격리 목록을 검사합니다. 로드된 문서에서 FObjStmQuarantine을 순회하며 첫 osqrDecryptFailed 항목에서 예외를 내고, 컨테이너를 지목하며 유효한 비밀번호로 다시 로드하라고 요구합니다. 그 지점을 넘어서 진행한 재작성은 컨테이너가 담고 있어야 할 멤버들을 빈 객체로 쓰고 성공을 보고했을 것입니다. 같은 검사를 더 이르게, 여러분 정책대로 공개 접근자를 통해 직접 돌릴 수도 있습니다
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // 빈 사용자 비밀번호
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// 여기서부터는 재작성해도 안전합니다
end;
다른 격리 사유들은 암호화와 무관한 실패를 다룹니다. 스트림이 아닌 컨테이너, 딕셔너리 누락, 잘못된 /N이나 /First, 허용 범위를 벗어난 스트림 크기, 압축 해제 실패, 데이터를 넘어가는 /First, 또는 디코딩은 되었지만 파싱되지 않은 멤버 본문입니다. 이런 것들은 수집 단계에서 로깅할 가치가 있습니다. 각각이 하류에서 빠지게 될 정확한 멤버를 알려 주기 때문입니다
재작성에 원본 숫자 토큰이 필요한 이유는?
HotPDF는 모든 숫자 객체를 Single로 저장하고, Single은 실수의 원본 텍스트를 재현할 수 없습니다. ISO 32000-1 §7.3.3은 같은 값에 대해 라이터가 0.750000, .75, 0.75 중 무엇이든 내보낼 수 있게 하고, 그중 어느 것도 24비트 이진수와 일반 포매터를 거치는 왕복을 그대로 통과하지 못합니다. 더 나쁜 것은 0.7 같은 값이 Single로는 아예 표현되지 않는다는 점입니다. 가장 가까운 부동소수점으로 파싱되고, 그 부동소수점을 다시 포맷하면 숫자 루프에 따라 0.69999999나 반올림된 이웃 값이 나올 수 있습니다. 채우기 색이나 /CA 투명도 상수에서는 8비트 채널에서 한 카운트 차이이고, 이는 원본과의 픽셀 비교를 실패시키기에 충분하며, 그라디언트 경계에서는 눈에 보이기에도 충분합니다
THPDFNumericObject.RememberSourceToken이 수정되지 않은 경우를 해결합니다. 파서는 Value를 대입한 직후 원시 토큰을 넘겨 이 메서드를 호출합니다. 이 메서드는 숫자와 소수점 최대 하나, 선택적 선행 부호로만 이루어진 토큰만 받아들이고, 토큰을 그것이 대응하던 값과 함께 FSourceValue에 저장합니다. SourceToken 속성은 Value가 아직 FSourceValue와 같을 때만 저장된 텍스트를 반환합니다. 숫자를 바꾸면 토큰은 증발하므로, 수정된 값은 언제나 기존 포맷 경로를 거치고 낡은 텍스트를 내보내지 않습니다. SaveNumericObject는 SourceToken을 먼저 확인해 있으면 그대로 쓰고, 메모리에서 생성되거나 편집된 숫자에 대해서만 정수, 색 공간 참조, 소수 분기로 내려갑니다
이 불변 조건은 작고 분명히 말할 가치가 있습니다. 건드리지 않은 숫자는 읽어 들인 바이트 그대로 기록되고, 건드린 숫자는 HotPDF 자체 포매터가 기록합니다. EnsureCompressedObjectLoaded가 멤버 슬라이스에 같은 파서를 돌리므로, compact 멤버도 본문 객체와 같은 혜택을 받습니다. 숫자 포맷 자체와 그것이 프로세스 로케일과 무관하다는 점은 HotPDF의 로케일 무관 PDF 숫자 포맷 글에서 다룹니다
객체 스트림에 대한 재작성 경로 테스트
위에서 설명한 모든 실패를 세 가지 검사가 잡아내고, 어느 것도 Acrobat을 필요로 하지 않습니다. 첫째, 저장 후 IndexedObjectCount와 MaterializedObjectCount를 비교하십시오. 전체 재작성에서는 둘이 같아야 하고, 차이가 있으면 떨어뜨린 멤버입니다. 둘째, 두 파일을 렌더링만 하지 말고 텍스트를 추출하고 구조 트리를 열거해서, 사라진 ActualText나 사라진 구조 요소가 차이로 드러나게 하십시오. 셋째, 새 인스턴스로 출력을 로드하고 GetLoadedQuarantinedObjStmCount가 0인지 단언하십시오. 라이터가 리더가 열 수 없는 컨테이너를 만들지 않았다는 것도 함께 증명됩니다. FReloadObjectStreamsEncrypted를 결정하는 crypt filter 조합은 StmF, StrF, EFF 정책 글에 정리되어 있습니다. 이 이야기의 라이터 쪽, 즉 객체 스트림을 어떻게 내보내고 언제 재작성보다 증분 업데이트를 선호할지는 객체 스트림과 증분 업데이트 가이드에 있습니다
lazy 멤버 로딩, 라이터 이전 확장 패스, 복호화 격리, 원본 토큰 보존은 모두 Delphi와 C++Builder용 HotPDF Delphi Component에 들어 있습니다. GetLoadedObjectStreamCacheInfo와 격리 접근자를 여러분의 수집 파이프라인에 대고 추적해 보고 싶다면 제품 페이지에서 API 레퍼런스를 링크하고 있습니다