/Indexed 룩업 테이블은 RGB 트리플이 아니라 베이스 컬러 스페이스의 컴포넌트를 저장합니다. DeviceGray 베이스는 항목당 1바이트, DeviceCMYK는 4바이트, Lab은 범위 스케일링이 필요한 3바이트, ICCBased는 프로필 /N이 선언하는 만큼을 담습니다. HotPDF는 파싱 시점에 그 모두를 THPDFColorSpace가 노출하는 공개 RGB 팔레트로 정규화합니다
바로 그 문장이 이 버그 부류의 전부입니다. 거의 모든 팔레트 렌더러가 3바이트 스트라이드를 가정하고 태어납니다. 실제 세상의 압도적 다수의 /Indexed 공간이 /DeviceRGB 위에 있어서 그 가정이 수년간 성립하기 때문입니다. 그런데 스캔된 인보이스 하나가 [/Indexed /DeviceCMYK 15 <...>]와 함께 도착하고, 리더가 룩업 문자열을 3바이트씩 훑으면서 0보다 큰 모든 인덱스가 다른 색의 중간에 떨어집니다. 출력이 비어 있지도 않고 크래시하지도 않습니다. 그냥 그럴듯해 보이는 쓰레기이며, 훨씬 더 나쁩니다. 그저 살짝 어색해 보이는 색에 대해 버그 리포트를 제출하는 사람은 없기 때문입니다
Indexed 팔레트는 왜 잘못 나올까요?
스트라이드가 상수가 아니기 때문에 스트라이드가 틀립니다. ISO 32000-1 §8.6.6.3은 룩업 테이블을 m × (hival + 1)바이트로 정의하는데, 여기서 m은 베이스 공간의 컴포넌트 수이며 그 베이스가 RGB라는 말은 어디에도 없습니다. 같은 섹션은 단 두 가지 베이스만 아예 금지합니다. /Indexed 자체와 /Pattern입니다. §8.6.5.2부터 §8.6.5.5의 CIE 기반 패밀리 네 가지를 포함해 나머지는 전부 허용됩니다
그래서 진짜 작업은 바이트를 읽는 것이 아니라 베이스를 먼저 해석해서 항목이 얼마나 넓은지 물어보는 것입니다. HotPDF의 ResolveIndexedPalette가 정확히 그렇게 합니다. 베이스를 THPDFColorSpace로 해석하고, 해석된 record에서 ComponentCount를 가져오며, 베이스가 csfIndexed, csfPattern, csfUnsupported로 돌아오면 배열을 아예 거부합니다. 그래야만 바이트 예산을 PaletteCount * Count로 잡는데, hival 255의 법적 최대치와 4컴포넌트 베이스에서 1024바이트가 상한입니다. 그 크기 산정의 세부 두 가지가 적대적 파일이 여러분을 시험하는 지점입니다. HotPDF는 §8.6.6.3 한계에 따라 사용 전에 hival을 0..255로 클램프하므로 hival 100000을 주장하는 룩업 배열이 기가바이트 할당으로 변하지 않으며, 예산을 룩업 데이터가 실제로 담은 만큼으로 잘라낸 뒤 끝을 넘어가는 바이트가 나오는 첫 항목에서 팔레트 채우기를 멈춥니다. 짧은 테이블은 버퍼를 넘어 읽는 것이 아니라 짧은 팔레트를 만듭니다
네 가지 베이스 공간을 하나의 RGB 테이블로 정규화하기
HotPDF는 그리기 시점이 아니라 파싱 중에 팔레트를 RGB로 변환하며, 그 결정이 나머지 설계를 좌우합니다. 공개 THPDFColorSpace.Palette 필드는 길이 3 * PaletteCount인 RGB 트리플의 TBytes이며, CIE 기반 베이스가 지원되기 훨씬 전부터 그 형태였습니다. 네이티브 베이스 컴포넌트를 담도록 record 레이아웃을 다시 쓰면 그 필드의 모든 소비자를 깨뜨렸을 것입니다. 파싱 시점 변환은 레이아웃을 온전히 유지하고, 보너스로 Gray, CMYK, Lab, CalGray, CalRGB, ICCBased 항목이 렌더러가 직접 컬러 오퍼레이터에 이미 쓰는 같은 HPDFResolveColor 경로를 타게 합니다
베이스 공간들이 서로 교환 불가능해지는 지점이 스케일링 단계입니다. 룩업 바이트는 항상 0..255지만 그 바이트가 의미하는 것은 베이스가 선언하는 컴포넌트 범위에 달려 있습니다. Device와 calibrated 컴포넌트는 0..1에 매핑됩니다. Lab은 그렇지 않습니다. L* 컴포넌트는 0..100을 돌고, a*와 b*는 공간이 선언한 /Range 위를 돕니다. HotPDF는 그 경계를 THPDFCIEParams의 AMin, AMax, BMin, BMax 필드에서 가져오는데, Lab 파서는 딕셔너리 /Range 항목에서 채우고 항목이 없으면 −108..101로 기본값을 둡니다. 기억에서 대칭 범위를 하드코딩하지 마세요. ICCBased 베이스는 세 번째 진실 소스를 더합니다. 컴포넌트 수는 ICCN으로 드러나는 프로필 스트림 /N에서 오며, ICCBased 스트림 자체가 /Range 배열을 지닐 수 있는데 HotPDF는 HPDFReadNumericArrayUpTo로 읽어 같은 컴포넌트별 경계 테이블에 담습니다. 그래서 3컴포넌트 ICC 팔레트와 3컴포넌트 Lab 팔레트는 같은 스트라이드로 룩업 문자열을 훑으면서 완전히 다른 산술을 수행합니다. 고정 RGB 스트라이드로는 도저히 만들 수 없는 결과입니다
var
CS: THPDFColorSpace;
Entry: Integer;
R, G, B: Byte;
begin
// ColorSpaceObj는 이미지 XObject나 리소스 딕셔너리의 /ColorSpace 항목이며
// 이름, 배열, 또는 간접 참조일 수 있습니다
CS := HPDFResolveColorSpace(ColorSpaceObj);
if CS.Family <> csfIndexed then
Exit;
// 베이스 공간이 무엇이었든 Palette는 이미 RGB입니다
for Entry := 0 to CS.PaletteCount - 1 do
begin
if (Entry * 3 + 2) >= Length(CS.Palette) then
Break; // 짧은 룩업 테이블, 깨끗하게 멈춤
R := CS.Palette[Entry * 3];
G := CS.Palette[Entry * 3 + 1];
B := CS.Palette[Entry * 3 + 2];
UseColor(Entry, R, G, B);
end;
end;
룩업 테이블이 스트림에 살 때
ISO 32000-1 §8.6.6.3은 룩업 테이블이 string이나 stream일 수 있게 허용하며, 물리는 쪽은 stream 형태입니다. 스트림은 /FlateDecode를 지닐 수 있고, 파일의 무관한 부분과 공유되는 간접 객체일 수 있으며, 파서가 도달하는 순간 그 TStream은 어디에 위치해 있어도 됩니다. HotPDF는 압축 케이스를 HPDFSetColorStreamReader로 등록된 디코드 바이트 콜백에 위임해서 처리합니다. Type 0 sampled-function 엔진이 쓰는 바로 그 콜백입니다
리더가 등록되지 않았을 때 폴백은 best-effort가 아니라 일부러 좁습니다. HotPDF는 스트림 딕셔너리에서 /Filter를 확인하고, 있으면 압축 바이트를 룩업에 넣어 자신만만하게 틀린 테이블을 만드는 대신 팔레트 전체를 거부합니다. 필터가 없으면 hival과 베이스 컴포넌트 수에서 계산한 바이트 예산으로 제한해 raw 바이트를 읽습니다. 스트림 분기 전체는 try..finally 안에 있어 거부를 포함한 모든 종료 경로에서 원래 Stream.Position을 복원합니다. 객체가 공유되며 다음 소비자는 옮겨진 커서를 고마워하지 않기 때문입니다. 회귀 스위트의 LookupStreamUsesDecodedReaderAndRestoresPosition이 그 동작의 두 절반을 모두 고정합니다
// 어떤 컬러 스페이스도 해석하기 전에 두 콜백을 등록하세요. 그렇지 않으면
// 간접 베이스와 압축 룩업 스트림은 그냥 거부됩니다
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
CS := HPDFResolveColorSpace(ColorSpaceObj);
if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
LogWarning('lookup table unreadable: filtered stream or no reader');
finally
HPDFSetColorStreamReader(nil);
HPDFSetColorSpaceResolver(nil);
end;
Indexed 컬러 스페이스가 무한 재귀하지 못하게 막는 것은 무엇일까요?
/Indexed를 직접 베이스로 거부하는 것은 필요하지만 결코 충분하지 않습니다. 흥미로운 공격은 옆으로 갑니다. 유효한 [/Indexed /Separation ...]를 가져와서, §8.6.6.4가 Separation이 alternate 공간을 이름 지을 수 있게 하고 §8.6.6.5가 DeviceN도 그렇게 할 수 있게 한다는 점을 떠올리세요. 그 alternate를 원래 Indexed 배열을 가리키는 간접 참조로 만드세요. 체인의 어느 단일 홉에서도 직접 베이스 규칙을 위반하는 것은 없습니다. 베이스는 Separation이고, Separation의 alternate는 배열이며, 그 배열은 우연히 시작했던 바로 그 배열일 뿐입니다. 순진하게 해석하면 파서는 스택이 바닥날 때까지 루프를 돕니다. 그 공간들이 의존하는 tint-transform 메커니즘은 그 자체로 주제이며, Separation과 DeviceN 스팟 컬러 렌더링 기사에서 다룹니다. 여기서는 순환의 한 링크로만 중요합니다
HotPDF는 모든 경로가 이미 지나가는 단일 엔트리 포인트에서 이를 막습니다. HPDFResolveColorSpace는 내부 리졸버를 HPDFColorSpaceMaxDepth, 즉 64로 제한된 깊이 카운터로 감쌉니다. 그 한계를 넘으면 가드가 플래그를 세우고, 중요한 것은 바로 그 플래그입니다. 언와인드 시 플래그가 세워진 것을 본 어느 중첩 레벨이든 자기 부분 해석 record를 버리고 csfUnsupported를 반환하므로, 그래프의 절반만 남기는 대신 전체가 거부되며, 두 카운터는 최외곽 호출이 돌아올 때 리셋됩니다. 그 카운터들은 유닛 전역이 아니라 threadvar로 선언되는데, 이는 우연한 정리정돈이 아닙니다. GResolveObj와 GReadStream도 같은 이유로 thread-local 스토리지에 삽니다. 페이지가 병렬로 렌더링되기 때문입니다. 공유 전역이면 중첩된 DeviceN 체인 깊숙이 들어간 스레드 하나가 평범하기 짝이 없는 [/Indexed /DeviceRGB 255 <...>]을 파싱하는 다른 스레드의 한계를 건드려, 로드에 따라 달라지는 비결정적 실패를 만들어낼 수 있습니다. 가장 디버깅하기 어려운 종류의 실패입니다. 64는 정당한 콘텐츠에게 넉넉한 여유입니다. 보통 4~5레벨을 넘어 중첩되지 않으므로, 구조적으로 유효해 보이는 배열에서 온 csfUnsupported는 로깅할 가치가 있는 것으로 취급하세요. 실제로는 어떤 진짜 producer도 내지 않는 순환이나 깊이를 뜻합니다. Separation과 DeviceN에 파라미터화된 IndirectAlternateCycleIsUnsupported와 ExcessivePatternDepthIsUnsupported가 두 케이스를 모두 다룹니다
function TryResolveImageSpace(Obj: THPDFObject;
out CS: THPDFColorSpace): Boolean;
begin
CS := HPDFResolveColorSpace(Obj);
// 여기서 csfUnsupported는 진짜 미지의 패밀리, 간접 alternate를 통한
// 순환, 64레벨을 넘는 깊이를 모두 포괄합니다
Result := CS.Family <> csfUnsupported;
if not Result then
CS := HPDFDefaultColorSpace; // DeviceGray, PDF 초기 공간
end;
호출자에게 문서화할 가치가 있는 API 경계 하나
HotPDF는 Indexed 팔레트를 만들 때 파싱 시점에 시행 중인 ICC 변환 구성을 적용하며, 이후의 HPDFSetColorSpaceRenderingIntent 호출은 이미 해석된 팔레트를 재생성하지 않습니다. 그 함수는 넘겨받은 record의 ICCBased와 alternate 변환을 훑어 그 파이프라인들을 재구축합니다. Palette의 평탄화된 RGB 바이트는 파싱 시점 산출물이며 그대로 남습니다. 이는 조기 정규화의 결과이지 실수가 아니며, 고객이 발견하게 두지 말고 여러분의 API 문서에 써 두는 게 좋습니다. 애플리케이션이 세션 중간에 사용자가 rendering intent를 바꾸게 한다면, 전환 후 컬러 스페이스를 다시 해석하세요. 파이프라인 설정 중에 한 번 intent를 설정한다면, 압도적으로 흔한 케이스지만, 할 일이 없습니다. 같은 순서가 HPDFSetICCColorPipeline으로 설치하는 프루핑 프로필에도 적용됩니다. 해석을 시작하기 전에 구성하고, 나중에 하지 마세요
쓰기 쪽의 거울상 버그
위의 모든 것은 읽기에 관한 것입니다. 쓰기 쪽의 대칭 결함은 출하하기 더 쉽고 놓치기도 훨씬 쉽습니다. HotPDF가 Windows 8비트 비트맵에서 /Indexed 배열을 만들 때 GetPaletteEntries를 호출하는데, 그 API는 팔레트가 실제로 담은 만큼의 항목만 채우고 그 수를 반환합니다. 원래 코드는 결과가 = 0인지만 검사한 뒤 스택 배열 위에서 고정 256 항목을 순회했습니다. 16색 비트맵의 경우 초기화되지 않은 스택 메모리 240항목이, 고객에게 건네는 PDF의 룩업 문자열에 곧장 쓰였고, hival은 255로 하드코딩되어 모든 리더가 그 찌꺼기를 진짜 컬러 데이터로 성실히 취급했습니다. 이것은 정확성 버그이자 같은 옷을 입은 정보 노출 버그입니다. 수정은 화려하지 않지만 완전합니다. 호출 전에 배열을 0으로 채우고, 루프 상한과 hival 모두에서 반환된 수를 존중하세요. 여러분의 코드 어딘가에서 /Indexed 이미지를 생성한다면, 지금 가서 hival을 확인하세요
인접한 두 조각이 그림을 완성합니다. Indexed 팔레트와 Type 0 sampled function은 디코드 스트림 리더와 같은 클램프 규율을 공유하며, 이는 Type 0 함수 컬러 룩업 테이블 글에서 다룹니다. 룩업 스트림을 지배하는 필터 규칙은 디코드 필터로 로드된 이미지 추출에서 이미지 데이터를 지배하는 규칙과 같습니다. 여기서 설명한 컬러 스페이스 리졸버는 Delphi와 C++Builder용 HotPDF Delphi Component의 일부로 출하되며, 제품 페이지에 전체 THPDFColorSpace 레퍼런스가 있습니다