기술 문서

PDF의 CMYK JPEG과 DCTDecode ColorTransform

PDF에서 나온 CMYK JPEG가 흐릿하거나 색이 밀리거나 음화처럼 보인다면 거의 항상 디코더가 두 사실 중 하나를 틀린 것이다. 샘플이 YCCK로 저장돼 변환이 필요한지, 아니면 Adobe 관례대로 반전돼 저장됐는지. HotPDF의 디코더는 3채널과 4채널 이미지에 대해 DCTDecode /ColorTransform 파라미터를 존중하고, 스펙이 정의하지 않은 값은 거부하며, ISO 32000-1 Table 13이 요구하듯 JPEG 데이터 안의 Adobe APP14 마커가 사전 항목보다 우선하도록 한다. 같은 부류의 함정이 이웃한 규칙 둘에도 있다. 멀티 필터 스트림은 단계 정렬 /DecodeParms 배열을 실어야 하고, JPEG 2000 이미지는 명시적 /Decode 매핑을 유지해야 한다

셋 모두 같은 주제다. PDF는 이미 자기 뜻을 말하고 있고, 함정은 살짝 다른 질문에 답하는 렌더러다

PDF 속 CMYK JPEG는 왜 반전되거나 색이 빠져 보이는가?

CMYK JPEG가 PDF에서 틀어지는 원인은 둘 중 하나이고, 화면에서 나타나는 모습이 다르다. 흰 종이가 검게 되고 옅은 색조가 어두워지는 음화(negative)라면 반전 저장을 가리킨다. 피부가 초록으로 가고 K 디테일은 날카로운 채 검정이 회색이 되는 색 밀림이라면, 필요한 곳에서 색 변환이 빠졌거나 필요 없는 곳에서 변환이 적용됐다는 뜻이다

Delphi PDF에서 깨진 CMYK JPEG의 증상 지도: 음화는 반전된 Adobe 저장을 가리키고, 색이 빠진 색조는 어긋난 ColorTransform을 가리킨다
음화는 /Decode 배열을 요구하는 반전 저장이고, 색조 밀림은 어긋난 transform이며, 날카로운 K 평면은 transform이 범인임을 먼저 가리킨다

transform 문제는 JPEG가 색을 압축하는 방식에서 온다. 베이스라인 JPEG는 휘도와 색도가 분리될 때 가장 잘 압축되므로 인코더는 보통 압축 전에 RGB를 YCbCr로 바꾸고, 4채널 인코더는 CMY를 YCC로 바꾸면서 K는 그대로 통과시킨다. 이것이 YCCK라 불리는 형식이다. 디코더는 인코더가 적용한 변환을 정확히 되돌려야 한다. 일어나지 않은 변환을 되돌리거나 일어난 변환을 건너뛰면 모든 픽셀이 어긋난다. 검정 채널은 어느 쪽이든 살아남기 때문에, 깨진 CMYK 이미지가 날카로운 검정 텍스트는 지키면서 나머지는 전부 흐트러지는 일이 흔하다

반전 문제는 역사적 사안이다. Photoshop을 비롯한 Adobe 도구는 CMYK JPEG의 모든 샘플을 255 - value로 기록하고 파일에 Adobe APP14 마커를 찍는다. JPEG 표준에는 그런 말이 없다. 하나의 관례이며, 모든 소비자가 이를 알아야 한다. PDF에서는 이 반전을 추측에 맡기지 말고 이미지 사전으로 표현하는 것이 생산자의 몫이다

/ColorTransform은 디코더에게 무엇을 말해 주는가?

/ColorTransform은 디코더가 되돌려야 할 변환이 무엇인지 PDF 수준에서 선언하는 것이다. ISO 32000-1 §7.4.8 Table 13은 두 값을 정의한다. 0은 변환 없음, 즉 디코더가 압축된 그대로의 샘플을 돌려준다는 뜻이다. 1은 인코더가 표준 변환을 적용했다는 뜻으로, 3채널 이미지는 YCbCr에서 RGB로, 4채널 이미지는 YCCK에서 CMYK로 되돌아간다. 이 파라미터는 1·2채널 이미지에서는 무시된다. Table 13은 놓치기 쉬운 우선 순위 규칙도 정한다. 인코딩된 데이터가 자체 transform 코드를 지닌 Adobe 마커를 실어 있으면 그 코드가 통치하고 사전 항목은 무시된다. 그 마커가 없는 JPEG만 사전으로 폴백하며, 항목마저 없으면 3채널 이미지는 변환 적용이, 4채널 이미지는 변환 없음이 기본값이다

Delphi용 DCTDecode ColorTransform 결정 매트릭스: Adobe APP14 마커가 사전보다 우선하고, 정의되지 않은 값은 HotPDF가 거부한다
JPEG 데이터 안의 Adobe 마커가 사전 항목보다 위이며, 마커 없는 JPEG만 /ColorTransform이나 컴포넌트 수 기본값으로 폴백한다

쉬운 실수는 파라미터 사전을 받아 놓고 JPEG 디코더에는 넘기지 않는 이미지 경로다. (128, 63, 31) 색을 담은 1픽셀 RGB JPEG가 이를 즉시 드러낸다. 그런 디코더는 /ColorTransform 0과 /ColorTransform 1 양쪽에서 [128, 63, 31]을 돌려주지만, 0의 올바른 답은 날 YCbCr 트리플렛 [79, 101, 163]이다

HotPDF는 이 파라미터를 바이트 수준에서 적용한다. HPDFDecodeHeadlessTerminalImage는 JPEG 세그먼트 구조를 start-of-scan 마커까지 훑으며 Adobe APP14 세그먼트를 찾는데, comment 세그먼트 안에서 비슷하게 생긴 바이트들은 인정되지 않는다. 데이터가 마커를 실어 있으면 그 transform 코드가 결정하고 사전 값은 무시된다. 없으면 디코더는 JPEG의 사본을 만들어 start-of-scan 마커 직전에 transform 코드 0, 1, 또는 값 1인 4채널 이미지라면 YCCK 코드 2를 지닌 APP14 세그먼트를 끼워 넣는다. 3채널 이미지의 유효 transform이 0일 때는 언제나 JFIF 헤더도 무장 해제한다. JFIF 헤더만으로 libjpeg-turbo가 YCbCr을 가정하기 때문이다. 호출자의 버퍼는 건드리지 않으며, 세그먼트를 끼워 넣을 때는 그 16바이트 추가분을 디코더가 무엇을 할당하기 전에 작업 바이트 예산에 청구한다

마커가 없을 때만 끼워 넣는 것이 중요한 이유는 libjpeg의 디테일 때문이다. libjpeg는 자신이 본 마지막 APP14 세그먼트를 쓴다. 기존 마커 뒤에 세그먼트를 끼워 넣으면 명시적 사전 값이 데이터 안의 상충하는 마커를 조용히 이겨 버리는데, Table 13과는 정확히 반대다. HotPDF는 Table 13 순서를 전체 이미지 XObject 경로와 인라인 이미지 경로 양쪽에 똑같이 적용한다

한 이미지가 이상하게 보일 때 페이지를 렌더링하지 않고 샘플을 들여다보려면 같은 터미널 디코더를 직접 호출할 수 있다. 함수는 HPDFHeadlessImageCodecs 유닛에 있으며 필터 이름, 인코딩된 바이트, 선택적 파라미터 사전, 그리고 JPEG 프레임 헤더와 일치해야 하는 이미지 사전의 기하 정보를 받는다

uses
  SysUtils, HPDFObjs, HPDFHeadlessImageCodecs;

// ColorTransform < 0은 "항목 없음"을 뜻한다: JPEG 마커에게 맡긴다
function DecodeDCT(const Encoded: TBytes; Width, Height, Channels,
  ColorTransform: Integer): TBytes;
var
  Parms: THPDFDictionaryObject;
begin
  Parms := nil;
  if ColorTransform >= 0 then
  begin
    Parms := THPDFDictionaryObject.Create(nil);
    Parms.AddNumericValue('ColorTransform', ColorTransform);
  end;
  try
    Result := HPDFDecodeHeadlessTerminalImage('DCTDecode', Encoded, nil, Parms,
      Width, Height, Channels, 8, 64 * 1024 * 1024, nil);
  finally
    Parms.Free;
  end;
end;

디코더는 무엇이 합법적 값인지에 대해 엄격하다. 명시적 -1, 2, 또는 0.5 같은 비정수는 기본값으로 폴백하는 대신 EHPDFHeadlessImageError를 던진다. 스펙이 정의하지 않은 값은 디코더가 믿고 행동할 만한 근거를 주지 않기 때문이다. 이 검사는 데이터 안의 Adobe 마커가 항목을 무의미하게 만들 때도 유지된다. 정의되지 않은 값은 문서 자체의 구조적 오류이기 때문이다. 변환된 사본에 모자란 예산은 같은 방식으로 실패하며, 메시지가 작업 바이트 예산을 이름으로 밝힌다

명시적 null은 다른 사안이다. 값 자체가 아니기 때문이다. ISO 32000-1 §7.3.9에 따르면 값이 null인 사전 항목은 없는 항목과 동등하므로, /ColorTransform null은 키가 아예 없는 것처럼 마커나 컴포넌트 수 기본값으로 넘어간다. HotPDF는 이를 모든 경로에서 그렇게 처리한다. 헤드리스와 VCL, 스트림과 인라인 모두다

한 가지 기본값이 더 이야기할 가치가 있다. HotPDF가 일부러 Table 13의 문자 그대로를 넘어서는 지점이기 때문이다. JFIF 헤더도 Adobe 마커도 없고 프레임 헤더가 자기 컴포넌트를 R, G, B라고 부르는 3컴포넌트 JPEG는 자기 바이트로 RGB를 담고 있다고 말하는 셈이다. Table 13은 여전히 변환 적용이 기본값이라고 말할 것이지만, libjpeg과 그 위에 세워진 리더들은 이것을 변환 없이 디코딩하고 HotPDF도 그렇게 한다. 헤드리스와 VCL 디코더 모두 그렇게 취급하므로 빨강과 파랑은 데이터가 놓아 준 자리에 머문다. 명시적 사전 0이나 1은 여전히 이 추론을 덮어쓴다

uses
  System.IOUtils;

var
  Jpeg, Stored, Converted, ByMarkers: TBytes;
begin
  // PDF 스트림에서 저장한 640 x 480 CMYK 이미지의 날 DCTDecode 바이트
  Jpeg := TFile.ReadAllBytes('suspect-image.jpg');
  try
    // 데이터에 Adobe APP14 마커가 있으면 세 호출이 모두 일치한다:
    // 마커가 결정하고 사전 값은 무시된다
    Stored    := DecodeDCT(Jpeg, 640, 480, 4, 0);   // 마커 없음: 압축된 그대로의 샘플
    Converted := DecodeDCT(Jpeg, 640, 480, 4, 1);   // 마커 없음: YCCK를 CMYK로 되돌림
    ByMarkers := DecodeDCT(Jpeg, 640, 480, 4, -1);  // 마커 없음: 4채널 기본값, 변환 없음
    Writeln(Format('pixel 0: stored %d,%d,%d,%d  converted %d,%d,%d,%d',
      [Stored[0], Stored[1], Stored[2], Stored[3],
       Converted[0], Converted[1], Converted[2], Converted[3]]));
  except
    on E: EHPDFHeadlessImageError do
      Writeln('Decoder refused the image: ', E.Message);
  end;
end;

4채널 참조 픽셀이 무엇을 봐야 하는지 보여 준다. YCCK로 저장된 (10, 20, 30, 40) CMYK 픽셀은 YCCK 변환이 되돌려지지 않으면 [245, 235, 225, 215]로, 되돌려지면 [0, 116, 0, 215]로 돌아온다. K 바이트 215는 양쪽에서 같다. K 평면이 멀쩡한데 나머지 셋이 틀리다면 어긋난 transform이 첫 번째 용의자다

반전된 CMYK 관례의 자리는 /Decode다

HotPDF 터미널 디코더는 CMYK 샘플을 libjpeg-turbo가 내놓은 그대로 돌려주고 Adobe 마커만으로 뒤집지 않는다. 위의 숫자들이 이 관례를 보여 준다. 245, 235, 225, 215는 그저 255에서 10, 20, 30, 40을 뺀 값, 즉 Adobe류 인코더가 저장하는 반전 형태다. 이것을 다시 잉크량으로 되돌리는 것은 이미지 사전의 몫이며, 그런 JPEG를 임베딩하는 생산자는 /Decode [1 0 1 0 1 0 1 0]을 기록해야 한다. 렌더러는 /Decode 배열을 모든 컴포넌트에 적용하므로 올바르게 기록된 파일은 올바르게 인쇄된다

반대 면도 분명히 말해 둔다. Adobe식으로 반전된 CMYK JPEG를 맞는 /Decode 배열 없이 임베딩한 PDF는 HotPDF에서 음화로 렌더링된다. 파일 자체가 그렇게 그림을 묘사하기 때문이다. 어떤 뷰어들은 APP14 마커를 탐지해 조용히 보정한다. HotPDF는 그런 휴리스틱을 더하지 않으므로, 그런 파일의 수정은 그것을 쓴 소프트웨어 쪽에 있다

두 필터에 하나의 DecodeParms 사전은 왜 오류인가?

/Filter가 여러 단계의 배열인 스트림은 같은 길이의 /DecodeParms 배열, 즉 단계마다 하나의 파라미터 사전이나 null을 실어야 한다. ISO 32000-1 §7.3.8.2 Table 5가 이 규칙을 말하고, HotPDF가 이를 강제한다. 하나의 공유 사전을 실어 보낸 멀티 필터 스트림은 디코딩되는 대신 잘못된 문서로 보고된다

관대한 대안은 보이는 것보다 나쁘다. /DecodeParms가 이미 배열일 때만 길이를 검사하는 디코더는, 단일 사전과 두 필터를 받으면 그 사전을 모든 단계에서 조용히 재사용한다. 사전이 비어 있으면 무해하지만 그렇지 않으면 파괴적이다. 안쪽 Flate 단계를 위한 /Predictor 12가 바깥 단계에도 적용되고, 출력은 깨끗한 오류 대신 그럴듯해 보이는 쓰레기가 된다. 그런 파일의 의도는 복구할 수 없으므로 HotPDF는 비어 있는 경우조차 거부한다. /Filter [/FlateDecode /FlateDecode]와 /DecodeParms << >>를 지닌 페이지 콘텐츠 스트림이 그 예다

Delphi에서 HotPDF가 멀티 필터 PDF 스트림에 대해 받아들이는 DecodeParms 형태: 정렬된 배열은 통과하고, 공유 사전이나 어긋난 배열 길이는 거부된다
멀티 필터 스트림에는 단계마다 하나의 사전이나 null이 필요하며, 공유 사전은 비어 있어도 거부된다

받아들이는 형태와 거부하는 형태는 나열하기 쉽다

  • 받아들임: 필터 수와 무관하게 /DecodeParms 생략 또는 null
  • 받아들임: 필터 수와 길이가 같은 배열, 각 항목은 사전 또는 null
  • 받아들임: /Filter가 정확히 하나의 필터를 지명할 때의 단일 사전
  • 거부: 둘 이상의 필터가 하나의 사전을 공유, 진단 메시지 "Stream filter chain requires array DecodeParms"
  • 거부: 필터 수와 길이가 다른 배열, 진단 메시지 "DecodeParms array must match the filter chain"

같은 검사는 평범한 콘텐츠 스트림과 마지막 필터가 DCTDecode 같은 터미널 코덱인 이미지 스트림에도 돌아가므로, 스트림이 무엇을 실든 잘못된 체인은 같은 자리에서 실패한다. 압축 해제 한도와의 상호작용은 필터 체인 예산과 PDF 디코드 폭탄에 관한 글이 다룬다. 애플리케이션 코드에서는 페이지가 렌더링될 때 이 오류가 표면으로 나온다

uses
  SysUtils, HPDFHeadlessDocument, HPDFHeadlessCairoDevice;

var
  Document: THPDFHeadlessDocument;
  Options: THPDFHeadlessRenderOptions;
  Info: THPDFHeadlessRenderResult;
begin
  Document := THPDFHeadlessDocument.Create;
  try
    try
      Document.LoadFromFile('catalog-cmyk.pdf');
      Options := THPDFHeadlessRenderOptions.Default;
      Info := HPDFRenderHeadlessPagePNGFile(Document.GetRenderAccess(0),
        'page1.png', Options);
      Writeln('images=', Info.Images, ' decoded bytes=', Info.DecodedBytes);
    except
      on E: Exception do
        Writeln('Rejected: ', E.ClassName, ': ', E.Message);
    end;
  finally
    Document.Free;
  end;
end;

JPEG 2000 이미지는 명시적 /Decode를 유지한다

JPEG 2000 이미지가 /Decode를 무시하는 것은 사전에 /ColorSpace가 없고 이미지 마스크도 아닐 때뿐이다. ISO 32000-1 §8.9.5가 이를 조건으로 묶어 두고 HotPDF가 이를 따른다. stencil이 아닌 모든 JPX 이미지의 /Decode를 버리는 것이 쉬운 지름길이지만, 명시적 반전이 그냥 증발한다. /Decode [1 0] 아래의 64인 DeviceGray JPX 샘플은 64가 아니라 191로 렌더링되어야 한다

이 한 조건만 맞춘다고 충분하지 않다. 이미지가 JPX일 때마다 기본 컴포넌트 범위를 설정하는 Lab이나 ICCBased 같은 색 공간 분기는 정당하게 적용된 배열을 한 번 더 덮어쓴다. 그래서 렌더러는 유효한 /Decode 배열이 실제로 적용됐는지 기록해 두고, 아무것도 적용되지 않았을 때만 기본 범위를 채워 넣는다. 명시적 Lab과 ICCBased 범위는 이렇게 살아남고, 사전이 침묵할 때는 코드스트림 색 선언이 여전히 통치한다. 포맷의 디코더 측면은 JPEG 2000과 JPXDecode 글에 기술되어 있다

어떤 디코드 경로가 이 규칙들을 따르는가?

양쪽 파이프라인 모두 이를 따른다. 헤드리스 쪽이라면 THPDFHeadlessDocument 로딩, 공유 스트림 디코더, HPDFRenderHeadlessPagePNGFile 뒤의 Cairo 기반 페이지 렌더러다. VCL 쪽이라면 TBitmap으로의 loaded-image 추출과 VCL 페이지 렌더링이며, 전체 이미지 디코딩이든 축소 영역 디코딩이든 같은 Table 13 순서를 적용한다. 그래서 JFIF 헤더가 Adobe transform 0 위에 YCbCr을 강요할 수 없고, 불법 값은 그쪽에서도 이미지를 디코딩 불가로 만든다. 인라인 DCT 이미지는 이미지 XObject와 같은 마커·null·기본값·거부 규칙을 따르며, 축약 키 이름과 전체 키 이름 모두를 다룬다. /DP << … >> 사전과 /F [/AHx /DCT] 같은 축약 필터 배열도 포함이고, 불법 파라미터를 지닌 인라인 이미지는 페이지 나머지가 렌더링되는 동안 건너뛰어진다. 로드된 PDF에서 이미지 추출하기 가이드가 그 API의 필터 처리 나머지를 다룬다

이 동작을 믿기 전에 알아 둘 실용적 노트 몇 가지다

  • JPEG 디코딩에는 실행 시 호환되는 TurboJPEG 라이브러리가 필요하다. HOTPDF_TURBOJPEG_LIBRARY가 기본값인 turbojpeg.dll 또는 libturbojpeg.so.0을 덮어쓴다
  • 엄격한 구조 검사는 관대한 리더가 열어 주는 파일을 거부한다. 특히 공유되는 빈 /DecodeParms 사전이 그렇고, 그런 파일의 수정은 생산자 쪽에 있다

색 관리 인쇄 워크플로라면 실용적 결론은 짧다. 데이터 안의 Adobe 마커와 일치하는 명시적 /ColorTransform과 저장 방식에 맞는 /Decode 배열을 붙여 CMYK JPEG를 기록하고, 모든 멀티 필터 스트림에 단계 정렬 /DecodeParms 배열을 실어라. 그리고 HotPDF가 파일이 실제로 말하는 것을 그대로 요구할 것으로 기대하라

여기서 보여 준 헤드리스 로더, 스트림 디코더, Cairo 페이지 렌더러는 클래식 VCL 문서 엔진과 함께 Delphi, C++Builder, Lazarus용 HotPDF Delphi PDF 컴포넌트로 제공된다