디스크에 PDF가 하나 있고, 고객은 그 파일을 송장 더미에서 스캔해 보냈으며, 당신의 일은 OCR 단계에 넘길 수 있도록 페이지 이미지를 다시 비트맵으로 꺼내는 것이다. 파일을 로드하고 image XObject를 찾아내면, 아무도 미리 경고해 주지 않는 부분을 마주하게 된다. 그 stream 안의 바이트는 픽셀이 아니다. JPEG codestream일 수도 있고, wavelet으로 압축된 JPEG 2000 blob일 수도 있으며, Group 4 팩스 데이터일 수도 있고, Flate filter 뒤에 놓인 palette 기반 indexed raster일 수도 있다. image object는 너비와 높이를 알고 있지만, 실제 sample은 producer가 선택한 filter 안에 봉인되어 있다. 쓸 수 있는 TBitmap을 얻으려면 그 filter를 되돌려야 하고, PDF는 바이트를 봉인하는 방법을 대략 여덟 가지나 제공한다
이 공백을 메우는 것이 Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트 HotPDF의 ExtractLoadedImage다. 이 메서드는 로드한 문서 안의 image XObject를 열거하고, 각각이 무엇인지 알려 주며, 디코딩 가능한 것들은 다시 24비트 bitmap으로 되돌린다. 흥미로운 지점은 API 표면이 아니다. 메서드는 세 개뿐이다. 진짜 핵심은 왜 별도의 decode 경로가 반드시 필요했는지, 그리고 그 경로가 어떤 이미지를 픽셀로 되돌릴 수 있고 어떤 경우는 되돌릴 수 없는지다
로드된 이미지가 이미 디코딩돼 있지 않은 이유
HotPDF의 loader는 통과형 충실도에 맞춰 설계돼 있다. LoadFromFile를 호출하면 image stream은 원본 파일에 있던 그대로 유지된다. 원래 filter, 원래 압축 바이트, 원래 dictionary를 그대로 보존한다. 이것은 의도된 동작이다. 문서를 로드하는 목적은 대개 페이지를 복사하고, 파일을 병합하고, stamp를 찍고, 권한을 다시 설정하고, 다시 저장하는 데 있으며, 그 모든 작업에서 가장 저렴하고 안전한 선택은 각 image stream을 건드리지 않는 것이다. 로드 시점에 모든 이미지를 raster로 디코딩하면 대부분의 호출자가 전혀 쓰지 않을 작업에 메모리와 CPU를 낭비하게 되고, 저장 시 재인코딩하면 원문 그대로 복사돼야 할 이미지 품질까지 떨어뜨리게 된다
그 결과, 로드된 object graph는 픽셀을 들고 있지 않다. /Filter가 /DCTDecode인 image XObject는 JPEG 바이트를 담고 있을 뿐이다. HotPDF는 복사 후 재작성 경로에서 JPEG decoder가 필요하지 않았기 때문에 그 바이트를 해석하지 않았다. 그래서 실제로 픽셀이 필요해지는 순간, 추출 API가 해당 이미지가 쓰는 filter에 맞춰 처음부터 직접 디코딩을 수행해야 한다. encode 측 codec이 loader와 분리돼 있는 이유도 여기에 있다. Delphi에서 JPEG 2000 이미지를 PDF에 추가하는 글은 JPX 엔진이 생성 경로에 어떻게 연결되는지 설명하는데, 그 엔진은 extraction API가 필요해질 때까지 읽기 경로에는 연결돼 있지 않았다
세 메서드로 구성된 API
표면은 작다. GetLoadedImageCount는 로드된 문서에 image XObject가 몇 개 있는지 돌려준다. GetLoadedImageInfo는 그중 하나를 index로 지정해 descriptor record를 채운다. ExtractLoadedImage는 디코딩된 bitmap을 반환하며, 해당 이미지를 디코딩할 수 없으면 nil을 반환한다. 열거 방식은 index 기반이며, 한 번 로드한 문서 안에서는 안정적이다. 내부적으로는 indirect-object table을 순회해 /Subtype이 /Image로 해석되는 모든 stream을 수집하므로, GetLoadedImageInfo에 넘긴 index를 그대로 ExtractLoadedImage에도 넘기면 된다
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I, Count: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
Exit;
Count := Pdf.GetLoadedImageCount;
for I := 0 to Count - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if not Info.Decodable then
Continue; // filter or colour space not supported
Bmp := Pdf.ExtractLoadedImage(I);
if Bmp <> nil then
try
Bmp.SaveToFile(Format('img_%d.bmp', [I]));
finally
Bmp.Free; // caller owns the bitmap
end;
end;
finally
Pdf.Free;
end;
end;
여기에는 계약상 중요한 세부가 두 가지 있다. 첫째, 반환된 TBitmap은 호출자가 해제해야 한다. 문서가 그것을 캐시하거나 소유하지 않는다. 둘째, 호출 전에 Decodable을 확인하고, 호출 뒤에는 결과가 nil인지 다시 확인해야 한다. 이 메서드는 지원되지 않는 filter를 만났다고 예외를 던지지 않는다. 그냥 nil을 돌려준다. 배치 루프 안에서 조용히 흘러가는 nil 하나는 천 페이지짜리 작업에서 페이지 하나를 아무도 모르게 삼켜 버리는 전형적인 원인이다
디코딩 전에 descriptor 읽기
THPDFLoadedImageInfo는 전체 디코딩을 수행하기 전에 이미지의 정체를 알려 준다. 그 필드는 image dictionary에서 바로 읽어 온 값들이다. sample 단위의 Width와 Height, decode 이후 해석을 설명하는 BitsPerComponent, ColorComponents, ColorSpace(회색 1, RGB 3, CMYK 4), 명명된 압축 방식을 담는 Filter, stencil mask 여부를 나타내는 IsImageMask, 기반 indirect object를 가리키는 ObjectNumber, 그리고 Decodable이 포함된다
그중에서도 마지막 플래그가 가장 솔직하다. Decodable은 현재 실행 중인 빌드가 바로 이 filter와 colour-space 조합을 실제로 bitmap으로 바꿀 수 있을 때만 True다. 이 값은 희망 사항이 아니라 진짜 지원 행렬을 인코딩한다. 현재 빌드가 이해하지 못하는 Filter를 가진 이미지는 Decodable = False로 보고되며, 이를 기준으로 로깅할지, 건너뛸지, 아니면 raw stream을 직접 추출하는 fallback으로 갈지를 분기하면 된다. 힌트가 아니라 전제 조건으로 취급해야 한다
// Triage every image before committing to a decode.
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
I: Integer;
begin
// ... Pdf loaded ...
for I := 0 to Pdf.GetLoadedImageCount - 1 do
begin
if not Pdf.GetLoadedImageInfo(I, Info) then
Continue;
if Info.Decodable then
// ExtractLoadedImage(I) will return a TBitmap
else
// unsupported filter/colour space: log the object and skip
Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
[I, Info.ObjectNumber, Info.Width, Info.Height,
String(Info.Filter), String(Info.ColorSpace)]));
end;
end;
descriptor record를 손으로 만드는 사람이라면 한 가지 구현 세부 때문에 쉽게 발목을 잡힌다. THPDFLoadedImageInfo는 Filter와 ColorSpace라는 두 개의 AnsiString 필드를 가진다. 이 둘은 참조 카운트를 사용하는 managed type이므로, FillChar(Info, SizeOf(Info), 0)으로 record를 통째로 0으로 채우는 반사적 습관은 여기서 잘못된 선택이다. 그렇게 하면 문자열 참조를 감소시키지 않은 채 덮어써서 누수나 손상을 일으킨다. HotPDF가 이 record를 필드별로 초기화하는 이유가 바로 그것이며, 이 패턴을 자신의 코드에 복사할 때도 같은 방식을 따라야 한다
하나의 dispatcher와 여덟 개의 filter 경로
이 기능이 한 번의 릴리스가 아니라 여러 릴리스에 걸쳐 들어간 이유는 PDF에 단일한 이미지 형식이 없기 때문이다. PDF는 filter를 가진다. ISO 32000-1 §8.9.5에 따르면 image XObject는 /Filter에 어떤 필터든 지정할 수 있고, sample 해석은 별도로 /ColorSpace, /BitsPerComponent, 선택적인 /Decode array에 의해 결정된다. ExtractLoadedImage는 filter 이름을 읽고, 케이스마다 전용 decoder로 분기한다. v2.229부터 v2.231까지 차근차근 쌓인 현재 지원 집합은 이제 여덟 가지 뚜렷한 경로를 포괄한다
- Raw raster (FlateDecode, LZWDecode, 또는 filter 없음): 8비트 DeviceRGB 또는 DeviceGray에 대해 동작한다. 바이트를 inflate해 packed raster를 얻고, 여기에 적용되는 유일한 변환은 아래에서 설명할 channel swap이다
- DCTDecode (JPEG): codestream을 VCL의
TJPEGImage에 넘겨 형상과 색을 해석하게 한 뒤, 결과를 24비트 bitmap에 대입한다 - JPXDecode (JPEG 2000): OpenJPEG backend를 통해 디코딩하며, JPEG 2000 글에서 설명한 바로 그 엔진을 쓴다. 높은 비트 깊이의 component는 8비트로 다시 샘플링된다
- Indexed colour:
[/Indexed base hival lookup]array에서 palette를 읽고, 각 sample을 lookup table에 통과시켜 true colour로 확장한다 - DeviceCMYK: 네 개 채널 sample을 표준 ink-on-white 공식으로 RGB로 변환한다
- Sub-8-bit DeviceGray 및 Indexed: component당 1, 2, 4비트인 경우 sample을 하나씩 풀어서 0에서 255 범위로 스케일한다
- CCITTFaxDecode: Group 3 및 Group 4 팩스 필터를 전용 T.4/T.6 backend로 디코딩한다
- JBIG2Decode: 높은 압축률의 bilevel 필터를 등록된 JBIG2 backend를 통해 디코딩한다. encode 측 이야기는 네이티브 JBIG2 compression 글에서 다룬다
모든 경로는 결국 같은 곳에 도착한다. 24비트 BGR bitmap이다. 그것이 VCL의 TBitmap이 기본적으로 저장하는 형식이며, downstream 소비자들이 기대하는 형태이기도 하다
픽셀을 조용히 바꿔 버리는 변환들
이 경로들 가운데 두 가지는, decoder를 직접 만지지 않더라도 이해해 둘 가치가 있을 만큼 미묘하게 틀리기 쉬운 변환을 포함한다. 첫 번째는 colour-order swap이다. PDF의 DeviceRGB raster는 sample을 빨강, 초록, 파랑 순으로 저장하며, 맨 윗줄부터 내려온다. 반면 VCL의 24비트 scanline은 파랑, 초록, 빨강 순으로 저장한다. 따라서 평범한 RGB 이미지를 디코딩하는 작업은 단순한 memcpy가 아니다. 각 픽셀의 첫 번째 바이트와 세 번째 바이트를 scanline에 적재하는 동안 맞바꿔야 한다. 이를 거꾸로 하면 빨강과 파랑이 서로 뒤바뀐다. 회색조 테스트 이미지에서는 그럭저럭 넘어갈 수 있지만, 컬러 이미지에서는 즉시 치명적으로 틀어져 보인다. 참고로 행 순서는 그대로 대응한다. PDF의 top-down raster는 VCL의 ScanLine[0]이 시각적으로 맨 위 행을 가리키는 방식과 일치하므로, 수직 뒤집기는 필요 없다
두 번째는 CMYK다. PDF의 DeviceCMYK 이미지는 네 개의 잉크 채널을 가지며, RGB로의 변환은 lookup이 아니라 채널별 계산이다. 각 출력 채널은 (255 - ink) * (255 - K) / 255로 계산된다. 이것은 ICC profile을 거치는 colour-managed 변환이 아니라 장치 근사치다. 따라서 화면 표시나 재래스터라이징에는 충분히 정확하지만, 인쇄 정확도를 요구하는 경로에는 적합하지 않다. 워크플로가 높은 충실도를 요구한다면 추출한 bitmap은 preview로만 쓰고, colour-managed 파이프라인에는 원래 CMYK stream을 유지해야 한다
Indexed 경로에는 parsing 함정도 하나 숨어 있다. /Indexed color space의 palette는 literal string으로 저장될 수도 있고 hexadecimal string으로 저장될 수도 있는데, HotPDF는 hex string의 값을 디코딩된 바이트가 아니라 hex 텍스트로 보관한다. 따라서 palette가 hex string이라면 lookup table을 먼저 hex-to-bytes 변환에 통과시켜야 하고, literal string인 경우에만 이미 raw bytes라고 볼 수 있다. 이 분기를 놓치면 네 가지 색을 가진 indexed 이미지조차 엉뚱한 결과로 나온다. palette entry를 전부 잘못된 바이트 경계에서 읽게 되기 때문이다
Filter chain: 마지막 filter가 이미지 자체를 정의한다
단일 /Filter 이름은 쉬운 경우다. PDF는 여러 filter를 순서대로 거친 chain도 허용하며, 그런 경우 /Filter array에 [/ASCII85Decode /FlateDecode]나 [/ASCIIHexDecode /DCTDecode]처럼 기록된다(ISO 32000-1 §7.4). 의미는 엄밀하다. encode 시에는 왼쪽에서 오른쪽으로 필터가 적용되고, decode 시에는 오른쪽에서 왼쪽으로 되돌린다. 그리고 array의 마지막 filter가 실제 이미지 형식을 정의한다. 앞쪽 filter들은 그 위를 감싼 transport encoding일 뿐이다
Extractor는 이 문제를 껍질 벗기듯 처리한다. 어떤 image decoder가 실행되기 전에, chain 안에서 마지막을 제외한 모든 filter를 먼저 적용해 마지막 filter가 기대하는 입력을 만들어 낸다. 그 후에야 마지막 filter 기준으로 dispatch가 일어난다. 따라서 [/ASCII85Decode /DCTDecode]는 먼저 ASCII85를 풀고 나서 JPEG 경로로 분기하며, raw raster를 감싼 [/FlateDecode]는 inflate 뒤 raster 경로로 들어간다. 덕분에 여덟 개 decoder는 단순함을 유지할 수 있다. 어느 decoder도 ASCII85나 hex transport wrapper를 알 필요가 없다. decoder가 바이트를 볼 시점에는 wrapper가 이미 제거돼 있기 때문이다. 또한 마지막 filter가 지원되지 않는 chain이라면, 중간에 어정쩡하게 깨지는 대신 dispatch 단계에서 깔끔하게 실패한다
추출이 멈추는 지점과 그 다음 선택
한계를 분명하게 이해해야 한다. 마지막 filter가 지원 집합 밖에 있는 이미지는 nil을 반환한다. 현재 빌드가 colour space를 해석하지 못해도 마찬가지다. soft mask와 alpha는 bitmap에 재구성되지 않으므로, 결과는 합성된 이미지가 아니라 base image다. JPEG 2000의 8비트 초과 비트 깊이는 의도적으로 다시 샘플링되며, 이는 손실을 감수한 선택이므로 재보관이 아니라 화면 표시용일 때에만 맞다. 또 image mask는 자체 색이 없는 1비트 stencil이므로, descriptor에는 설명되지만 일반적인 사진 이미지와는 다른 존재다. 그것을 사진처럼 디코딩하려 들면 놀라게 된다
추출만으로 부족할 때에도 raw stream은 여전히 로드된 object graph 안에 filter와 함께 그대로 남아 있다. 필요하다면 그 바이트를 한 글자도 바꾸지 않고 꺼내서 자신만의 특수 codec에 넘길 수 있다. 통과형 설계가 의도적으로 보존하는 fallback이 바로 이것이다. 원본 바이트를 버리지 않기 때문에, 최악의 경우는 데이터를 잃는 것이 아니라 사용자가 직접 디코딩해야 하는 쪽으로 넘어가는 것이다. 하지만 실제 업무 대부분에서는 지원되는 여덟 가지 filter만으로도 스캐너, office suite, report engine이 뽑아내는 파일을 충분히 커버하며, GetLoadedImageCount를 돌면서 Decodable을 확인하는 몇 줄짜리 루프만으로 로드한 PDF를 비트맵 폴더로 되돌릴 수 있다
여기서 설명한 모든 decode filter와 함께, loaded-image extraction API 전체는 Delphi와 C++Builder용 HotPDF Component에 포함되어 제공된다