50페이지짜리 스캔 계약서는 모든 페이지에서 같은 알파벳을 반복하지만, 이미지당 심볼 딕셔너리 하나를 만드는 JBIG2 인코더는 그 알파벳을 50번 따로따로 다시 학습한다. 네이티브 Delphi/C++Builder PDF 컴포넌트인 HotPDF는 대신 문서 전체에 걸쳐 하나의 공유 심볼 딕셔너리를 누적해 단일 문서 수준 /JBIG2Globals 스트림으로 승격시킬 수 있어서, 각 페이지 고유의 JBIG2 스트림은 알파벳 사본을 저장하는 대신 그저 심볼 ID만 참조하게 된다
이 글은 의도적으로 범위를 좁혀 HotPDF가 이 페이지 간 공유를 내부적으로 어떻게 구축하는지만 다룬다 — JBIG2 기초, CCITT와의 비교, Lossless 대 LossyLevel의 절충은 이미 Delphi의 네이티브 JBIG2 이진 압축에 관한 자매 글에서 다루었으며, 이 글은 독자가 그 글을 이미 읽었다고 가정한다
페이지별 JBIG2 압축은 왜 여전히 같은 비용을 반복하는가?
답은 호출 사이에 아무 상태도 유지되지 않기 때문이다. HotPDF의 인코더가 이미지 하나를 위한 심볼 딕셔너리를 만들 때마다, 그 딕셔너리는 그 단일 AddImage 호출에만 범위가 한정된다: 형태 매칭 패스는 매번 0부터 시작하고, 페이지의 모든 글리프는 새로운 것으로 분류되며, 그 결과 비트맵은 처음부터 산술 부호화되어 저장된다. 같은 서체로 조판된 50페이지를 같은 인코더에 넣으면 인코더는 그 전체 학습 과정을 50번 기꺼이 반복하는데, 인코더 입장에서는 각 페이지가 우연히 비슷해 보이는 서로 무관한 이미지이기 때문이다. 페이지별 UseSymbolDictionary는 이미 단일 페이지에서 단순한 제네릭 영역 인코딩을 큰 차이로 앞서지만, 실제 다중 페이지 스캔이 테이블 위에 남겨두는 진짜 상한선에는 훨씬 못 미친다
HotPDF는 단일 심볼 딕셔너리를 어떻게 페이지 간에 공유하는가?
THPDFJBIG2Options에서 AccumulateGlobalsAcrossPages를 켜면 HotPDF는 각 이미지 뒤에 폐기하는 대신 문서의 전체 수명 동안 심볼 딕셔너리 하나를 메모리에 계속 살려둔다. 이후 모든 페이지의 글리프는 무엇이든 다시 부호화되기 전에 그 계속 실행 중인 딕셔너리와 비교된다: 이미 존재하는 형태는 그 심볼 ID로 재사용되고, 누구도 본 적 없는 형태만 딕셔너리에 추가되어 부호화된다. 이 비교는 단일 페이지에 적용되는 것과 동일한 LossyLevel 허용오차 로직을 재사용한다 — 같은 글자를 약간 노이즈 있게 스캔한 것도 여전히 일치로 간주된다 — 그래서 누적기는 같은 글리프의 픽셀 단위 변형마다 딕셔너리 항목 하나씩을 만들며 조용히 부풀어 오르지 않는다. 추출이 먼저 일어나 그 비교에 데이터를 공급한다: HotPDF는 각 페이지의 비트맵을 순회하며 검은 픽셀에 대한 플러드 필로 연결된 형태를 뽑아내는데, 이는 잉크 얼룩을 손으로 추적하는 것과 같은 발상이며, 실행 중인 딕셔너리와 비교되는 것은 원시 픽셀 블록이 아니라 바로 이 추출된 형태들이다
공유 딕셔너리가 /JBIG2Globals 스트림 안에 어떻게 자리 잡는가
누적된 딕셔너리는 /JBIG2Globals 스트림 안의 심볼 딕셔너리 세그먼트 하나로 기록되며, 모든 페이지가 같은 대상을 가리킬 수 있도록 고정된 세그먼트 번호에 유지된다. ISO 32000-1 §7.4.7이 정의하는 임베디드 JBIG2 구성 안에서 텍스트 영역 세그먼트는 세그먼트 헤더의 참조 대상 세그먼트 필드를 통해 다른 세그먼트를 자신의 심볼 소스로 지정할 수 있으며, HotPDF가 의존하는 것이 바로 이 메커니즘이다: globals 스트림이 하나의 큰 심볼 딕셔너리를 담고, 각 페이지 고유의 JBIG2 스트림은 페이지 정보 세그먼트와, 참조 대상 목록이 다시 globals 세그먼트를 가리키는 텍스트 영역 세그먼트로 축소된다. 예전에는 페이지당 독립된 비트스트림이었던 것이 이제 위치와 심볼 ID의 짧은 목록이 되고, 이런 식으로 만들어진 모든 페이지는 사본이 아니라 동일한 간접 /JBIG2Globals 객체를 참조한다. HotPDF 자체의 회귀 테스트 커버리지는 정확히 이것을 확인한다: 페이지마다 글리프 레이아웃이 다른 짧은 문서를 인코딩하고, 다시 로드한 뒤, 파일 안에 서로 다른 /JBIG2Globals 객체 참조가 몇 개나 나타나는지 센다 — 몇 페이지가 심볼을 기여했든 상관없이 문서 하나에 객체 참조는 하나뿐이다
페이지 간 심볼 딕셔너리 누적 켜기
이 스위치는 자매 글에서 다룬 것과 같은 옵션 레코드에 있으며, 누적이 실제로 작동하려면 서로 일치해야 하는 네 가지 설정이 필요하다
var
Pdf: THotPDF;
Bmp: TBitmap;
PageIdx, ImgIdx: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.JBIG2Options.Lossless := True;
Pdf.JBIG2Options.UseSymbolDictionary := True;
Pdf.JBIG2Options.UseGlobalSegments := True;
Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := True; // opt-in, default False
Pdf.JBIG2Options.UseExternalEncoder := False; // accumulation needs the native path
Pdf.JBIG2Options.UseNativeArithmeticFallback := True;
Pdf.BeginDoc;
for PageIdx := 0 to ScannedPages.Count - 1 do
begin
if PageIdx > 0 then
Pdf.AddPage;
Bmp := ScannedPages[PageIdx]; // 1-bit TBitmap for this page
ImgIdx := Pdf.AddImage(Bmp, icJBIG2);
Pdf.CurrentPage.ShowImage(ImgIdx, 0, 0, Bmp.Width, Bmp.Height, 0);
end;
Pdf.EndDoc; // the shared /JBIG2Globals stream is finalized here
finally
Pdf.Free;
end;
end;
이 짝짓기는 선택적인 장식이 아니다. 이진 압축 글에서 설명한 외부 인코더 확장점 — 프로덕션급 압축률을 위해 RegisterJBIG2EncoderBackend로 등록하는 그것 — 은 이미지별 인코딩을 중심으로 만들어져 있으며, HotPDF 자체의 누적 데모와 회귀 테스트는 항상 AccumulateGlobalsAcrossPages를 UseExternalEncoder := False와 짝지어 사용한다. 이를 제안이 아니라 엄격한 요구사항으로 취급하라: 페이지 간 공유는 네이티브 인코더 기능이며, 등록된 외부 백엔드는 그저 공유 딕셔너리를 만드는 경로의 일부가 아니다
다중 페이지 스캔은 실제로 얼마나 작아지는가?
솔직한 답은 먼저 무엇이 별 효과가 없었는지부터 시작한다. 이전 릴리스는 /JBIG2Globals 스트림을 위한 콘텐츠 주소 기반 캐시를 추가했다 — 스트림 바이트의 64비트 FNV-1a 해시로 키를 삼은 조회로, 우연히 바이트 단위로 동일한 globals 데이터를 만들어낸 두 이미지가 PDF 객체 하나를 공유할 수 있게 했다. 실제 출력물에 대해 측정해 보니 이 캐시는 거의 도움이 되지 않았는데, HotPDF의 기존 전체 이미지 중복 감지가 이미 캐시가 작동할 기회를 얻기도 전에 바이트 단위로 동일한 이미지를 걸러내고 있었기 때문이다. 여기서 얻은 교훈은 스트림 수준 중복 제거는 진짜로 다른 두 페이지 이미지가 그럼에도 하나의 성장하는 딕셔너리를 공유할 수 있을 때에만 값어치를 한다는 것이며, 이것이 바로 진정한 페이지 간 누적이 제공하는 것이다
그 더 어려운 경우에 대해, HotPDF 자체의 엔지니어링 추정치는 하나의 반복되는 폰트로 만들어진 일반적인 다중 페이지 스캔에서 스트림 수준 중복 제거만으로 달성하는 것보다 대략 30~60% 더 작아진다고 본다 — 이 범위는 문서의 시각적 어휘가 실제로 얼마나 반복되는지에 따라 움직이는데, 고유한 다이어그램으로 가득한 페이지는 딕셔너리가 재사용할 것이 전혀 없기 때문이다. 이를 특정 입력에 대한 보증이 아니라 설계 목표로 취급하고, 단일 수치를 믿기보다 자신의 문서를 직접 측정하라. HotPDF와 함께 제공되는 JBIG2Benchmark 데모는 정확히 그 목적으로 존재한다: 동일한 다중 페이지 스캔을 네 가지 다른 방식으로 인코딩하고 각 설정에 대한 결과 파일 크기를 출력하므로, 이 비교는 인위적인 스캔이 아니라 여러분 자신의 스캔 조합을 대상으로 실행된다
procedure RunScenario(const Title: string; AccumulateGlobals: Boolean);
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.JBIG2Options.Lossless := True;
Pdf.JBIG2Options.UseSymbolDictionary := True;
Pdf.JBIG2Options.UseGlobalSegments := True;
Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := AccumulateGlobals;
Pdf.JBIG2Options.UseExternalEncoder := not AccumulateGlobals;
// ... encode the same three-page scan here, then compare file sizes.
finally
Pdf.Free;
end;
end;
begin
RunScenario('Per-image lossless baseline', False);
RunScenario('Cross-page accumulated globals', True);
end.
페이지 간 누적의 한계
누적된 딕셔너리는 4096개 심볼로 제한되는데, 이는 이미지별 네이티브 인코더가 단일 페이지에서 이미 강제하는 것과 같은 상한이다. 문서 중간에 이 한계를 넘으면 HotPDF는 예외를 일으키거나 실행을 중단하지 않는다: 누적기는 새 글리프를 거부하고, 그 글리프를 도입한 페이지는 자동으로 독립적인 이미지별 인코딩으로 되돌아가므로 문서는 여전히 올바르게 나온다 — 다만 상한을 넘어선 페이지에 대해서는 페이지 간 절약을 더 이상 얻지 못할 뿐이다. 두 번째 안전장치는 심볼 개수가 아니라 전체 크기를 감시한다: 누적된 딕셔너리의 결합 심볼 폭이 131071픽셀을 넘는 순간, HotPDF는 메모리 상의 구조체가 무한정 커지도록 두는 대신 현재 배치를 디스크에 흘려보내고 새 globals 그룹을 자동으로 시작한다. 두 제한 모두 자동 폴백이지 잡아야 할 예외가 아니므로 여러분 쪽 코드는 아무것도 필요하지 않다
PDF/A 적합성은 메커니즘 전체를 제한하는 대신 아예 꺼버리는 유일한 설정이다. PDFACompliance가 비어 있지 않은 순간, HotPDF는 AccumulateGlobalsAcrossPages나 JBIG2Options의 다른 어떤 설정과도 무관하게 모든 페이지에서 조용히 JBIG2를 CCITT Group 4로 대체한다 — 이는 버그가 아니라 의도적인 적합성 선택이지만, 오늘날 아카이브 프로필과 페이지 간 심볼 공유가 상호 배타적이라는 뜻이기도 하다. 어떤 설정에 정착하든, 신뢰하기 전에 작성한 것을 디코딩해 보라: LoadFromFile로 파일을 다시 로드하고 각 페이지를 ExtractLoadedImage로 뽑아내면, 이는 표준을 준수하는 어떤 리더든 그러하듯 공유된 globals를 대신 해석해 주며, 그 결과를 원본 비트맵과 비교하라
var
Loaded: THotPDF;
PageBmp: TBitmap;
PageIdx: Integer;
begin
Loaded := THotPDF.Create(nil);
try
Loaded.LoadFromFile('scanned-contract.pdf');
for PageIdx := 0 to Loaded.PagesCount - 1 do
begin
PageBmp := Loaded.ExtractLoadedImage(PageIdx); // resolves the shared globals for you
try
// Compare PageBmp against the source bitmap for this page.
finally
PageBmp.Free;
end;
end;
finally
Loaded.Free;
end;
end;
페이지 간 딕셔너리 공유는 문서의 이진 이미지 쪽만 건드린다. 같은 파이프라인이 스캔 옆에 생성된 텍스트 페이지 — 표지, 색인 페이지, OCR 텍스트 레이어 — 도 함께 내보낸다면, 객체 스트림과 xref 스트림이 그 페이지들이 추가하는 문서 구조를 압축함으로써 파일 크기 예산의 나머지 절반을 공략한다. 페이지 간 JBIG2 globals는 이미지별 JBIG2 옵션 및 나머지 압축 파이프라인과 함께 Delphi와 C++Builder용 HotPDF 컴포넌트의 일부로 제공된다