PDFlibPas는 자체 JBIG2 halftone 리전 디코더의 독립된 결함 둘을 고쳤습니다. v3.539.37에서는 HSKIP 스킵 마스크가 ITU-T T.88 §6.6.5.1이 정의한 대로 HSKIP[ng, mg]로 인덱싱되고, v3.539.38에서는 음수 HGX나 HGY 또는 회전을 통해 음수 좌표에 닿는 그리드가 진짜 floor 시프트로 배치됩니다. 그 전 릴리스들에서는 해당 halftone 리전이 오류를 일으키지 않은 채 뭉개지거나 어긋나서 나왔습니다. 두 버그 다 우연히 대칭이거나 비음수인 테스트 데이터 뒤에 숨어 있었고, 두 번째 버그는 JBIG2를 훨씬 넘어 물어 뜯는 Delphi와 Free Pascal의 한 특성에 걸립니다. 부호 있는 정수의 shr은 논리 시프트이지 표준이 가정하는 산술 >>가 아니라는 것입니다
halftone 리전은 가장 드문 JBIG2 리전 타입이므로, 디코더는 하나로 인코딩된 스크린 인화 사진을 만나기 전에 수천 장의 스캔 문서를 처리할 수 있습니다. 막상 만나면 실패가 지독합니다. 파일은 파싱되고, 세그먼트 길이는 맞고, 페이지 크기는 정확한데 리전만 쓰레기입니다
JBIG2 halftone 리전은 실제로 무엇을 디코딩할까?
JBIG2 halftone 리전은 패턴 딕셔너리에서 뽑은 작은 비트맵들의 그리드이고, 디코더의 진짜 작업은 모든 그리드 셀의 인덱스와 그 셀이 착지하는 픽셀 위치를 계산하는 것입니다. 패턴 딕셔너리는 HPW × HPH 픽셀짜리 패턴 HNUMPATS개를 들고 있습니다. halftone 리전 세그먼트는 이어서 HGW열 × HGH행의 그리드와 같은 크기의 그레이스케일 이미지를 기술하며, Gray 부호화된 비트평면으로 코딩됩니다. 각 비트평면은 HGW × HGH 비트맵 위에서 generic region 절차로 최상위 평면부터 디코딩되고, 평면들이 모여 각 셀에 패턴 인덱스를 줍니다
셀 배치는 8비트 소수를 가진 고정소수점 산술을 씁니다. 그리드 원점 HGX, HGY는 32비트 값 쌍이고, 그리드 벡터 HRX, HRY는 이웃 셀 사이의 걸음을 기술하므로 회전된 그리드도 가능합니다. 그리드 행 mg와 그리드 열 ng에 대해 T.88 §6.6.5는 픽셀 위치를 이렇게 계산합니다:
x = (HGX + mg × HRY + ng × HRX) >> 8y = (HGY + mg × HRX − ng × HRY) >> 8
스킵 마스크는 선택적인 HENABLESKIP 플래그로 들어옵니다. 플래그가 설정되면 §6.6.5.1은 HGW × HGH 비트맵 HSKIP를 만들고, 패턴이 리전 밖에 온전히 놓인 모든 셀에 대해 HSKIP[ng, mg]를 1로 설정합니다. x + HPW <= 0, x >= HBW, y + HPH <= 0 또는 y >= HBH인 셀이죠. 그런 다음 그레이스케일 비트평면은 그 마스크를 generic region 스킵 비트맵으로 삼아 디코딩되므로, 산술 디코더는 스킵된 셀의 컨텍스트를 읽지도 갱신하지도 않습니다. 디코더와 인코더는 HSKIP의 모든 비트에 일치해야 하며, 그러지 않으면 두 산술 코더가 박자를 잃습니다
뒤집힌 HSKIP 마스크는 왜 정사각형이 아닌 그리드만 깨뜨렸을까?
스킵 마스크는 좌표가 서로 바뀐 채로 쓰였고, 정사각형이 아닌 그리드만 그것을 드러냈습니다. 정사각형 그리드는 바뀐 좌표도 모두 마스크 안에 머물게 하니까요. PDFlibPas는 비트맵을 (column, row) 픽셀 접근자로 저장하는데, 마스크를 만든 코드는 (mg, ng), 즉 행을 먼저 넘겼습니다. 그레이스케일 비트평면 디코더는 마스크를 (ng, mg)로 올바르게 읽습니다. 패턴 배치 루프는 만드는 쪽의 뒤바뀐 순서로 읽어 돌려서 둘이 서로 맞았고, 배치 로직만 검토하는 리뷰는 통과했을 겁니다. 네이밍 함정이 이를 더 악화했습니다. 배치 루프에서 col이라 불리는 변수는 그리드 행을 돌고 Row는 그리드 열을 돕니다
v3.539.37이 회귀 케이스로 쓰는 16 × 8 리전 위의 4 × 4 패턴 5 × 3 그리드를 봅시다. HRX = 1024, HRY = 0에서 그리드 열 4는 x = 16에, 그리드 행 2는 y = 8에 착지하고, 둘 다 리전 밖입니다. 올바른 마스크는 일곱 셀을 표시합니다. 열 4 전체와 행 2 전체죠. 뒤바뀐 쓰기는 세 행밖에 없는 마스크에서 행 인덱스 3과 4의 픽셀을 설정하려 했고, 비트맵 세터는 그 범위 밖 쓰기를 조용히 무시했습니다. 살아남은 것은 열 2의 행 0부터 2였습니다. 그래서 디코더는 인코더가 코딩한 두 셀을 스킵하고, 인코더가 스킵한 여섯 셀을 디코딩했습니다
산술 디코더는 그런 일이 벌어져도 실패하지 않습니다. 뒤따르는 셀의 것인 비트로 추가 픽셀을 디코딩하고, 컨텍스트는 잘못된 이웃을 읽으며, 첫 불일치 이후의 모든 패턴 인덱스는 잡음입니다. 증상이 엇갈린 셀 몇 개가 아니라 뭉개진 리전이었던 이유죠. 정사각형 그리드에서는 같은 버그가 종종 보이지 않습니다. 바뀐 좌표가 마스크를 벗어나지 않고, 리전 밖 셀들이 대각선을 기준으로 대칭이면, 오른쪽과 아래 모서리를 같은 수의 셀만큼 삐져나온 그리드가 예로, 뒤집힌 마스크가 비트 단위로 올바른 것과 같습니다. HENABLESKIP은 선택적이고, 그레이스케일 이미지가 MMR 코딩일 때는 0이어야 하며, 인코더가 설정하는 일이 드물어서 버그가 드러날 통로가 아주 적었습니다. v3.539.37부터는 만드는 쪽이 HSKIP[ng, mg]로 쓰고 배치 루프가 같은 순서로 읽습니다
음수 halftone 그리드 오프셋은 왜 세 겹으로 실패할까?
리전보다 왼쪽이나 위에서 시작하는 halftone 그리드는 PDFlibPas를 서로 다른 세 곳에서 깨뜨렸고, 각 결함은 다음 결함을 가려 주었습니다. T.88은 이 기하를 일부러 허용합니다. 스크린을 리전이 아니라 페이지에 맞추는 인코더나 회전된 그리드를 쓰는 인코더는 자연스럽게 리전이 크롭하는 음수 셀 모서리를 만들어 냅니다. v3.539.38은 세 겹을 함께 고쳤습니다. 하나만 고치면 증상만 바뀌기 때문입니다
1층: 부호 있는 필드를 부호 없이 읽음
T.88 §7.4.5.1.2는 HGX와 HGY를 부호 있는 32비트 값으로 정의하지만, 디코더는 부호 없는 필드에 쓰던 같은 32비트 헬퍼로 읽었고, 그 헬퍼는 음수 결과를 모두 0으로 클램프했습니다. HGX = -900에서 시작할 뜻이었던 그리드는 조용히 리전 원점으로 옮겨졌죠. v3.539.38 회귀 케이스에서는 그림 전체가 두 행 낮게 나왔습니다. 클램프는 다른 두 결함이 왜 그렇게 오래 살아남았는지도 설명합니다. 원점이 비음수로 강제되는 한 음수 좌표는 HRY > 0인 회전 그리드를 통해서만 나타날 수 있는데, 거기서 y = HGY + mg × HRX − ng × HRY는 뒤쪽 그리드 열에서 0 아래로 떨어집니다
2층: shr은 >> 8이 아니다
T.88은 >> 8이라 쓰고 산술 시프트, 즉 마이너스 무한대 쪽으로 반올림하는 시프트를 뜻합니다. 디코더는 그것을 shr 8로 옮겼죠. Delphi와 Free Pascal에서 부호 있는 정수의 shr은 논리 시프트입니다. 부호 비트가 0으로 밀려 들어옵니다. -512를 담은 Integer에 shr 8은 -2 대신 16777214를 냅니다. y = -2에 그려져 아래 절반으로 크롭됐어야 할 패턴은 1,600만 행 아래로 보내졌다가 리전 밖으로 버려졌습니다. 크래시는 없었고, halftone의 맨 윗행이 그저 사라졌을 뿐입니다
3층: 픽셀 대신 고정소수점을 비교함
스킵 검사는 픽셀 위치가 아니라 고정소수점 값을 비교했는데, 소수가 0이 아닌 순간 둘은 동등하지 않습니다. 원래 코드는 밀지 않은 값에 xx + HPW × 256 <= 0을 검사하는 식으로 논리 시프트를 피했고, 그것이 T.88 검사와 동등하다고 여겨졌습니다. HGX = -900과 4픽셀 패턴에서는 -900 + 1024 = 124, 양수이므로 셀은 스킵되지 않습니다. 표준은 먼저 시프트합니다. floor(-900 / 256) = -4이고 -4 + 4 = 0이 x + HPW <= 0을 만족하므로 셀은 완전히 밖에 놓여 스킵돼야 합니다. 인코더는 스킵했고 디코더는 디코딩했으며, 그레이스케일 이미지는 뒤집힌 마스크 케이스와 똑같이 흘러갔습니다
v3.539.38의 회귀 케이스는 12 × 10 리전 위에서 HGX = -900, HGY = -512, HRX = 1024에 4 × 4 패턴 4 × 3 그리드를 씁니다. 그리드 열은 x = -4, 0, 4, 8에 착지하므로 열 0은 완전히 밖에 있어 HSKIP에 들어가고, 그리드 행은 y = -2, 2, 6에 착지하므로 행 0은 버려지는 게 아니라 아래 두 픽셀 행으로 크롭돼야 합니다. 겹을 하나씩 고쳐 보면 스택이 재현됩니다:
| 고친 결함 | 디코딩된 리전 |
|---|---|
| 없음(v3.539.38 이전) | 그리드가 원점으로 끌려와 그림 전체가 두 행 낮음 |
| 부호 있는 HGX / HGY 읽기만 수정 | 첫 그리드 행이 사라지고 나머지는 스킵 검사 드리프트로 뭉개짐 |
| 부호 있는 읽기, floor 시프트, 픽셀 공간 스킵 검사 | T.88 §6.6.5에서 계산한 페이지와, 독립된 두 레퍼런스 디코더와 픽셀 단위까지 동일 |
수정은 스킵 마스크 빌더와 배치 루프가 공유하는 헬퍼 하나, HalftoneGridPixel입니다. 좌표를 Int64에 누적해 큰 mg × HRX 곱이 랩되지 않게 하고, 256으로 나누되 마이너스 무한대 쪽으로 반올림하며, ±MaxInt div 2로 클램프해 깨진 그리드가 뒤의 비트맵 산술을 오버플로하지 못게 합니다. 스킵 검사는 이제 그 픽셀 값들을 HPW, HPH, HBW, HBH와 비교하며, §6.6.5.1이 기술한 그대로입니다
Delphi에서 산술 오른쪽 시프트는 어떻게 쓸까?
Delphi에는 산술 시프트 연산자가 없으므로 올바른 부호 있는 오른쪽 시프트는 floor 나눗셈으로 써야 하는데, 평범한 div는 그 나눗셈이 아닙니다. div는 0쪽으로 절삭합니다. 비음수 값에서는 절삭과 floor가 일치하고, 제수의 정확한 배수인 음수에서도 일치하므로, -512 div 256 = -2는 간단한 테스트에서 멀쩡해 보입니다. 나머지에서는 어긋납니다. -900 div 256은 -3인데 floor는 -4이고, -1 div 256은 0인데 floor는 -1입니다. 소수가 0이 아닌 JBIG2 좌표가 바로 div가 엉뚱한 픽셀을 내놓는 케이스입니다
Delphi Win32와 Win64 컴파일러에서 -512를 담은 Integer 변수를 8만큼 오른쪽 시프트하면 16777214가 나오고, -512를 담은 Int64는 72057594037927934가 나옵니다. Free Pascal도 shr을 논리 시프트로 정의하지만 산술 버전용으로 System 유닛에 SarLongint와 SarInt64를 실어 나르는데, 그 함수들은 Delphi에는 없으므로 두 컴파일러가 공유하는 코드에는 자기 헬퍼가 필요합니다:
// Floor 나눗셈: A와 B의 부호와 무관하게 마이너스 무한대 쪽으로 반올림
// B는 0이 아니어야 하고 FloorDiv(Low(Integer), -1)는 div처럼 오버플로함
function FloorDiv(A, B: Integer): Integer;
begin
Result := A div B;
if (A mod B <> 0) and ((A < 0) <> (B < 0)) then
Dec(Result);
end;
// 산술 오른쪽 시프트(부호 있는 값에 대한 C와 T.88의 ">>")
// 음수 Value에 대해 not Value = -Value - 1은 비음수이므로 거기서는
// 논리 shr가 안전하고 바깥 not이 결과를 되돌려 매핑함
function SarInt32(Value: Integer; Shift: Integer): Integer; // Shift 0..31
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
function SarInt64(Value: Int64; Shift: Integer): Int64; // Shift 0..63
begin
if Value >= 0 then
Result := Value shr Shift
else
Result := not ((not Value) shr Shift);
end;
not 트릭은 음수를 시프트하는 일이 없으므로 컴파일러가 부호 비트를 어떻게 다루는지에 의존하지 않고, Low(Integer)를 포함해 결코 오버플로하지도 않습니다. 두 헬퍼는 수백만 개 값과 0부터 31까지 모든 시프트, 그리고 Delphi Win32, Delphi Win64, Free Pascal x86_64의 Low(Integer)와 High(Integer) 경계에서 Int64 floor 레퍼런스와 일치했습니다. 좌표를 다루는 유닛 테스트라면 어디든 간직할 만한 산 점검입니다:
var
V: Integer;
begin
V := -900;
Writeln(V shr 8); // 16777212 논리 시프트, 옛 버그
Writeln(V div 256); // -3 0쪽으로 절삭
Writeln(FloorDiv(V, 256)); // -4 T.88이 >> 8로 말하는 것
Writeln(SarInt32(V, 8)); // -4
end;
Math.Floor(V / 256)도 -4를 반환하지만 Double을 거치는 우회 때문에 253을 넘는 Int64 값에서 정밀도를 잃으므로, 정수 기하는 정수 안에 머물러야 합니다
어떤 PDFlibPas 호출이 halftone 디코더를 돌릴까?
JBIG2 halftone 디코더는 PDFlibPas가 내장 렌더러로 페이지를 그릴 때 돕니다. 렌더링에는 픽셀이 필요하니까요. RenderPageToFile과 RenderPageToStream은 둘 다 페이지의 JBIG2Decode 이미지 스트림을 통해 그곳에 닿으므로, halftone 페이지를 다시 렌더링하는 것이 v3.539.38이 출력을 바꾸는지 확인하는 직접적인 방법입니다. 같은 디코더가 다른 JBIG2 리전 타입도 처리하는데, 순수 Pascal 디코더의 JBIG2 custom Huffman 테이블과 Delphi에서 random-access JBIG2 파일 디코딩하기가 그 내용을 다루고, 렌더링된 비트맵은 PDF 페이지를 1비트 모노크롬으로 렌더링하기 같은 변환으로 흘러갑니다
uses
SysUtils, PDFlibrary;
var
Lib: TPDFlib;
Page: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('scanned-halftone.pdf', '') <> 1 then
raise Exception.CreateFmt('Load failed, error %d', [Lib.LastErrorCode]);
for Page := 1 to Lib.PageCount do
// 렌더링은 halftone을 포함해 모든 JBIG2 리전을 디코딩합니다
if Lib.RenderPageToFile(150, Page, PDF_RENDER_PNG,
Format('page-%.3d.png', [Page])) <> 1 then
Writeln('Page ', Page, ' was not rendered');
finally
Lib.Free;
end;
end.
이미지 추출은 보통 다른 경로를 탑니다. GetPageImageList는 JBIG2 이미지를 네이티브 형태로 돌려주고, SaveImageListItemDataToFile이나 GetImageListItemDataToString은 스트림 바이트로 만든 단독 JBIG2 파일을 건네줍니다. 파일 헤더, JBIG2Globals 데이터, 페이지 데이터를 감싼 end-of-file 세그먼트죠. GetImageListItemIntProperty의 속성 400은 그런 항목에 6을 보고합니다. 이 경로에서는 아무것도 디코딩되지 않으므로, 렌더링된 페이지는 잡음을 보이는데 추출된 .jb2는 다른 뷰어에서 멀쩡해 보이는 것이 이 halftone 버그 둘의 전형적인 징후였습니다:
var
ListID, I: Integer;
begin
Lib.SelectPage(1);
ListID := Lib.GetPageImageList(0);
if ListID = 0 then
Exit;
try
for I := 1 to Lib.GetImageListCount(ListID) do
if Lib.GetImageListItemIntProperty(ListID, I, 400) = 6 then // 단독 JBIG2
Lib.SaveImageListItemDataToFile(ListID, I, 0,
Format('page1-image%d.jb2', [I]));
finally
Lib.ReleaseImageList(ListID);
end;
end;
마스크나 색 변환이 렌더링 폴백을 강제하면 항목이 디코딩된 비트맵으로 돌아오고 halftone 디코더도 실제로 돕니다. 이미지 리스트에 대해서는 Delphi PDF 텍스트, 이미지, 폰트 추출에 더 있습니다
빠른 참조: JBIG2 halftone 그리드 규칙
- 스킵 마스크는
HSKIP[ng, mg], 즉 그리드 열을 먼저로 인덱싱하고 셀이 배치되는 어디에서든 같은 순서로 읽을 것(T.88 §6.6.5.1, PDFlibPas v3.539.37에서 수정) - halftone이든 그리드든 코드는 정사각형이 아닌 그리드와 비대칭인 리전 밖 셀 집합으로 테스트할 것. 정사각형 그리드는 뒤집힌 인덱스를 완전히 숨길 수 있음
HGX와HGY는 부호 있는 32비트 값으로 읽을 것(T.88 §7.4.5.1.2). 음수를 클램프하는 부호 없는 헬퍼로는 결코- 표준의
>> 8은 256으로 floor 나눗셈으로 옮길 것.shr 8도div 256도 아님 - 스킵 검사는 시프트된 픽셀 위치에서 돌릴 것.
HGX = -900과 4픽셀 패턴이 보여 주듯 소수가 0이 아닌 한 고정소수점 형태는 다름 - 그리드 좌표는
Int64에 누적하고 비트맵 코드에 넘기기 전에 클램프할 것. 깨진 그리드가 오버플로하지 못게 - 문서에
HENABLESKIP이 있는 halftone 리전, 음수 그리드 원점, 회전된 그리드가 있다면 v3.539.38 이상으로 업그레이드
PDFlibPas는 Delphi와 C++Builder에서 네이티브 Pascal JBIG2 디코더로 PDF 문서를 렌더링하고 추출하고 편집하며, 이제 T.88이 명세한 대로 halftone 스킵 마스크, 음수 그리드 원점, 회전된 그리드를 처리합니다. 기능, 에디션, 평가판 다운로드는 PDFlibPas Delphi PDF 라이브러리를 보세요