PDFlibPas v3.539.23은 ITU-T T.88 부속서 D.2의 random-access 조직을 쓰는 독립형 JBIG2 파일을 디코딩합니다. 이 조직에서는 모든 세그먼트 헤더가 먼저 오고 세그먼트 데이터가 같은 순서로 뒤따릅니다. PDFlibJBIG2.pas의 네이티브 Pascal 디코더는 필수 end-of-file 헤더까지 헤더 오프셋을 인덱싱하고, 세그먼트 번호가 증가하는지와 선언된 데이터 길이의 합이 남은 바이트와 정확히 일치하는지 검사한 다음, 각 본문을 헤더 순서대로 압축 데이터를 복사하거나 재배치하지 않고 디코딩합니다. 이 릴리스 이전에는 같은 파일이 헤더 플래그를 읽는 순간 "random-access organisation is not supported" 오류를 그대로 냈습니다
random-access JBIG2 파일은 드물고, 바로 그래서 실제로 나타나면 아픕니다. 이런 파일은 아카이브 파이프라인이나 문서 이미징 시스템에서 나오는데, 이들은 리더가 압축 바이트 하나 건드리기 전에 모든 세그먼트 헤더, 즉 모든 페이지와 딕셔너리 의존 관계를 먼저 보길 원합니다. 스캔 아카이브를 PDF로 일괄 변환하는 Delphi 애플리케이션은 보통 순차형 파일 수백 개가 문제없이 통과한 다음, 작업 한가운데서 이런 파일을 만납니다. 잘 만들어진 파일에서 멈춰 버리는 디코더는 쓰레기를 렌더링하는 디코더보다 조금 나을 뿐입니다. 같은 릴리스 라인이 JBIG2 커스텀 Huffman 테이블과 canonical prefix 코드를 디코더에 방금 가르쳤고, random access는 디코더가 문서화한 기능 한계에 남은 마지막 조직 격차였습니다
JBIG2 random-access 조직이란?
random-access 조직은 T.88 부속서 D가 같은 세그먼트들을 배치하는 세 가지 방식 가운데 하나입니다. sequential(D.1)은 각 헤더를 그 데이터와 엮어 놓고, random-access(D.2)는 모든 헤더를 먼저 두고 데이터를 나중에 두며, embedded(D.3)는 PDF 같은 다른 컨테이너 안에서 쓰는 헤더 없는 형태입니다. 독립형 .jb2 파일은 8바이트 식별자 97 4A 42 32 0D 0A 1A 0A로 시작하고, 그 뒤에 플래그 바이트 한 개, 페이지 수를 아는 경우에는 4바이트 페이지 수가 따라옵니다. 플래그 바이트의 비트 0이 조직을 고르는데, 1이면 sequential, 0이면 random-access입니다. 비트 1이 켜져 있으면 페이지 수를 모른다는 뜻이고 4바이트 카운트는 없습니다. PDFlibPas는 checkHeader와 setFileHeaderFlags에서 이것들을 읽고, 예약된 비트 2부터 7까지는 거부하지 않고 허용합니다
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1; // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2; // 1 = 페이지 수 생략
noOfPagesKnown := pagesKnown = 0;
// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader; // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
// PDF 스트림: 파일 헤더 없음, embedded 조직, 한 페이지
noOfPagesKnown := True;
randomAccessOrganisation := False;
noOfPages := 1;
end
else
begin
setFileHeaderFlags;
if noOfPagesKnown then
noOfPages := getNoOfPages;
end;
PDF 자체는 이 레이아웃을 담지 않습니다. ISO 32000-1 §7.4.7이 설명하는 JBIG2Decode 이미지 스트림은 embedded 조직의 페이지 세그먼트만 담고, 공유 심볼 딕셔너리는 별도의 JBIG2Globals 스트림으로 옮겨지며, 파일 헤더와 end-of-page, end-of-file 세그먼트는 없습니다. decodeJBIG2는 8바이트 식별자를 못 찾으면 정확히 이 경우라고 간주해 sequential 단일 페이지 디코딩을 강제합니다. 네이티브 JBIG2 이미지 export는 반대 방향으로 가서 PDF 세그먼트를 플래그 바이트가 $03, 즉 sequential에 페이지 수 미지수인 독립형 파일로 감싸고 끝에 end-of-file 헤더를 덧붙입니다. 그래서 random-access 작업은 한 경로만 건드립니다. TPLJBIG2Decoder에 직접 넘겨지는 독립형 파일로, 보통 PDF용으로 변환하거나 재압축하기 전 단계이며, 출력 쪽 그 일은 PDFlibPas의 JBIG2 인코더 백엔드가 맡습니다
random-access 파일을 파일 순서대로 읽을 수 없는 이유는?
random-access 파일을 파일 순서대로 읽을 수 없는 이유는, end-of-file 세그먼트 헤더 자체 외에는 바이트 스트림 어디에도 헤더 블록이 끝나고 데이터 블록이 시작되는 지점을 표시하는 게 없기 때문입니다. JBIG2 세그먼트 헤더는 가변 길이입니다. referred-to 세그먼트 카운트는 3비트 단축형이거나 retention 비트맵을 갖는 장형이고, referred-to 세그먼트 번호는 그 세그먼트 자체 번호에 따라 1, 2, 4바이트를 차지하며, 페이지 연관 필드는 1 또는 4바이트입니다. 순진한 순차 리더는 첫 헤더를 파싱하고 데이터 길이를 읽은 다음, 두 번째 헤더의 앞 바이트들을 그 세그먼트의 데이터로 취급합니다. 디코더는 훨씬 나중까지 잘못됐다는 걸 알아차리지 못하고, 그래서 옛 코드는 시도하는 대신 그 조직을 아예 거부했습니다
PDFlibPas는 random-access 세그먼트 헤더를 어떻게 인덱싱할까?
PDFlibPas는 pre-scan 하나, 즉 IndexRandomHeaders에서 random-access 헤더를 인덱싱합니다. 모든 헤더를 파싱하되 바이트 오프셋만 기록하고, 첫 번째 end-of-file 헤더(세그먼트 타입 51)에서 멈춥니다. 각 헤더는 완전히 파싱한 뒤 버려지므로 인덱스는 객체 목록이 아니라 정수 배열이고, pre-scan은 진행하면서 선언된 데이터 길이를 누적합니다. 스캔이 끝나면 리더는 첫 세그먼트 데이터의 첫 바이트 위에 있고, 그 위치가 NextBodyOffset이 됩니다
// IndexRandomHeaders, TJBIG2StreamDecoder.readSegments의 로컬
while not reader.isFinished do
begin
Offset := reader.bytePointer;
Header := TSegmentHeader.Create;
try
readSegmentHeader(Header);
if reader.BufferOverrun then
raise EJBIG2DecodeError.CreateFmt(
'JBIG2 truncated random-access header at byte %d', [Offset]);
if (HeaderCount > 0) and
(Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
PreviousNumber := Header.getSegmentNumber;
Count := Header.getSegmentDataLength;
if Count < 0 then
raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
Inc(TotalLength, Count); // Int64 누적기
HeaderOffsets[HeaderCount] := Offset; // 청크 단위로 늘어남
Inc(HeaderCount);
if Header.getSegmentType = JBIG2_END_OF_FILE then
begin
if Count <> 0 then
raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
FoundEnd := True;
Break;
end;
finally
Header.Free;
end;
end;
if not FoundEnd then
raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;
그 루프의 모든 검사는 random-access 파일이 sequential 파일보다 중복이 적다는 사실에서 나옵니다. 세그먼트 번호는 부호 없는 값으로 비교해 엄격히 증가해야 합니다. 두 헤더가 같은 번호를 주장하면 나중 region의 referred-to 목록이 어느 본문을 뜻하는지 모호해지기 때문입니다. 데이터 길이 필드는 handleSegmentDataLength가 읽는데, 이 함수는 최상위 비트가 켜진 값, 그중에서도 0xFFFFFFFF "unknown length" 마커를 전부 -1로 매핑합니다. random-access 레이아웃에서는 다음 본문이 어디서 시작하는지 찾을 다른 방법이 없으므로, PDFlibPas는 end 마커를 찾아 헤매는 대신 그 길이를 즉시 거부합니다. 합계는 남은 바이트와 양방향으로 정확히 일치해야 하고, 마지막 본문 뒤에 바이트 하나만 더 있어도 "trailing random-access data"로 실패합니다. 이 엄격함은 의도된 것입니다. 이 레이아웃에서 길이 불일치는 오류 지점 뒤의 모든 본문이 밀렸다는 뜻이고, 길 잃은 바이트 하나를 그냥 넘겨 버리는 디코더는 그것이 무해한 패딩인지 어긋난 데이터의 첫 증상인지 알 방법이 없습니다
마지막 end-of-page 세그먼트는 왜 사라졌을까?
마지막 end-of-page 세그먼트가 사라진 이유는 디코딩 루프의 첫 버전이 sequential 종료 조건인 while not reader.isFinished를 그대로 유지했는데, random-access 레이아웃에서는 헤더 인덱스가 끝나기 전에 데이터 스트림이 먼저 바닥나기 때문입니다. end-of-page(타입 49)와 end-of-file 세그먼트는 데이터를 0바이트 가지며, 보통 파일의 마지막 헤더들입니다. 마지막 region 본문을 소진하고 나면 리더는 버퍼 끝에 딱 걸려 있으므로 루프가 종료되고, 그 길이 0 세그먼트들은 한 번도 디스패치되지 않은 채 페이지가 미완성으로 남습니다. 수정은 random-access 루프가 바이트 대신 헤더를 세게 만듭니다. 각 반복은 리더를 다음 인덱스된 헤더로 점프시키고, 이전 본문이 바이트 중간에 끝났을 수 있으므로 bitPointer를 7로 되돌리며, 그 헤더를 다시 파싱한 다음 bytePointer를 NextBodyOffset로 옮기고 본문을 지나 진행합니다. 기존 세그먼트 핸들러와 referred-to 세그먼트 검사, Context 진단은 그대로 돌고, 오류 메시지는 본문 위치가 아니라 헤더의 원래 바이트 오프셋을 보고합니다
// TJBIG2StreamDecoder.readSegments, main loop
if randomAccessOrganisation then
IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
if randomAccessOrganisation then
begin
reader.bytePointer := HeaderOffsets[HeaderIndex];
reader.bitPointer := 7; // 부분 바이트 뒤 재정렬
Inc(HeaderIndex);
end;
SegmentOffset := reader.bytePointer; // 오류 컨텍스트에 사용
readSegmentHeader(segmentHeader);
if randomAccessOrganisation then
reader.bytePointer := NextBodyOffset; // 이 세그먼트의 데이터로 점프
DataLength := segmentHeader.getSegmentDataLength;
DataEnd := reader.bytePointer + DataLength;
NextBodyOffset := DataEnd;
// ... 기존 세그먼트 핸들러로 디스패치한 뒤 DataEnd로 이동
end;
random-access 검증이 실제로 증명하는 것은?
검증은 재배치된 바이트가 sequential 원본과 같은 픽셀로 디코딩된다는 것과, 잘못된 random-access 입력이 깨끗하게 실패한다는 것을 증명합니다. 임의의 인코더가 만든 random-access 파일을 커버한다는 증명은 아닙니다. 공유 Pascal 회귀 테스트는 7x1 검은 픽셀 행으로 디코딩되어야 하는 커스텀 테이블 픽스처 기반의 235바이트 합성 파일을 씁니다. 페이지 수를 아는 경우와 카운트 필드를 제거한 경우 양쪽 모두에 대해 검사하고, 이어서 그 파일의 잘린 접두어 전부, 중복 세그먼트 번호, 꼬리 바이트 하나, unknown data length를 디코더에 먹이면서 매번 LoadFromByteArray가 False를 반환하고 Width와 Height를 0으로 남겨두는지 단언합니다. 실제 이미지 케이스는 500x473 커스텀 테이블 refinement 이미지로, 세그먼트를 원본 헤더와 압축 바이트를 모두 보존한 채 random-access 레이아웃으로 재배치했습니다. SHA-256은 검토된 sequential 베이스라인과 정확히 일치합니다. 이 파일은 조직 변환으로 만든 파생물이지 실제 세상에서 발견된 자연 random-access 문서가 아니며, 그런 자연 샘플은 구할 수 없었습니다. 스위트는 기존 sequential 픽셀 케이스 세 개와 함께 Delphi Win32 1,598개, Delphi Win64 이미지 스위트 42개, FPC Win32 48개, FPC Win64 46개 테스트를 통과했습니다
random-access .jb2 파일 로딩과 그 한계
애플리케이션 코드는 바뀌지 않습니다. TPLJBIG2Decoder.LoadFromByteArray가 파일 헤더와 조직을 스스로 감지하고, 거부된 입력에는 그 이유를 LastError에 담아 False를 반환하며, 디코딩된 페이지는 픽셀당 1바이트를 돌려주는 Width, Height, GetScanline으로 노출합니다
uses
SysUtils, Classes, PDFlibJBIG2;
function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
FS: TFileStream;
begin
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, FS.Size);
if Length(Result) > 0 then
FS.ReadBuffer(Result[0], Length(Result));
finally
FS.Free;
end;
end;
function CountBlackPixels(const FileName: string): Integer;
var
Decoder: TPLJBIG2Decoder;
Row: TJBIG2ByteArray;
X, Y: Integer;
begin
Result := 0;
Decoder := TPLJBIG2Decoder.Create;
try
// sequential과 random-access 독립형 파일은 같은 호출로 처리됩니다
if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
for Y := 0 to Decoder.Height - 1 do
if Decoder.GetScanline(Y, Row) then
for X := 0 to Decoder.Width - 1 do
if Row[X] = 1 then
Inc(Result);
finally
Decoder.Free;
end;
end;
경계는 분명하게 말해 둘 가치가 있습니다. random-access 지원은 파일 조직 기능이지 random 페이지 API가 아닙니다. TPLJBIG2Decoder는 여전히 첫 페이지의 비트맵을 돌려주고, 40페이지 파일에서 7페이지를 골라내거나 페이지를 게으르게 디코딩하는 호출은 없습니다. unknown data length 세그먼트는 random-access 파일에서 거부되고, 커스텀 Huffman prefix 길이와 테이블 엔트리 수에 대한 기존 한계는 그대로입니다. 이 한계들은 좁아서 Delphi 애플리케이션이 LastError로 거부된 케이스를 다른 곳으로 돌릴 수 있고, PDF 이미지 추출부터 JBIG2 인코딩까지 이미지 파이프라인의 나머지는 PDFlibPas Delphi PDF 라이브러리 제품 페이지에서 다룹니다