기술 문서

Delphi PDF의 컬러 이모지 폰트: COLR v1, SVG, 비트맵

HotPDF는 THotPDF.DrawRegisteredColorGlyph를 통해 PDF에 컬러 이모지를 그립니다. 이 함수는 RegisterUnicodeTTF로 등록한 폰트의 컬러 데이터를 읽어 네이티브 PDF 그래픽으로 내보냅니다. COLR v0 레이어는 채워진 글리프 아웃라인으로, COLR v1 paint 그래프는 clip과 shading, 블렌드 모드로, SVG 글리프는 Form XObject로, CBDT나 sbix 비트맵은 이미지로요. 네이티브로 매핑하지 못하는 것은 조용히 검은 형태로 변하는 대신 OnColorGlyphRasterize 이벤트로 갑니다

마지막 절이 이 코드가 존재하는 전부의 이유입니다. 이모지 폰트를 평범하게 임베드하면 뷰어는 glyf나 CFF에서 아웃라인을 받아 그 시점의 채움 색이 무엇이든 그걸로 채웁니다. 웃는 얼굴은 검은 덩어리로, 깃발은 직사각형으로 도착하고, 파이프라인 어디에서도 불평하지 않습니다

컬러 이모지가 PDF에서 검은 실루엣으로 찍히는 이유는?

PDF 폰트 프로그램에는 컬러 글리프라는 개념이 없습니다. ISO 32000-1은 글리프를 현재 색으로 칠해지는 형태로 취급하고, OpenType이 나중에 추가한 컬러 테이블들, 그러니까 COLR/CPAL, SVG , CBDT/CBLC, sbix는 PDF 이미징 모델의 일부가 아니므로 어떤 뷰어도 임베드된 폰트에서 그것들을 읽을 의무가 없습니다. 색은 생산자가 아직 폰트 바이트를 쥐고 있고 어떤 글리프를 원하는지 아는 생성 시점에 페이지 콘텐츠로 번역돼야 합니다. 그 번역은 형식마다 다르고, 실제 세상의 이모지 폰트는 전부 씁니다. 레이어된 벡터, 그라디언트 paint 그래프, 임베드된 SVG 문서, PNG strike입니다. HotPDF는 결과를 THPDFOpenTypeColorFormat으로 보고하며 값은 otcfNone, otcfCOLRv0, otcfCOLRv1, otcfCBDT, otcfSVG, otcfSBIX이고, 폰트를 고정된 우선순위로 탐칩니다. COLR 먼저, 그다음 SVG, 그다음 CBDT, 그다음 sbix입니다. 벡터 데이터는 폰트가 둘 다 갖고 있을 때 비트맵을 이기는데, 확대되거나 인쇄될 수 있는 문서에서 원하는 바로 그 동작입니다

HotPDF 컬러 글리프 탐침 다이어그램: PDF 폰트 프로그램은 글리프 아웃라인을 현재 색으로 칠하므로 OpenType 컬러 테이블 COLR, SVG, CBDT, sbix는 생성 시점에 페이지 콘텐츠로 번역돼야 하고, HotPDF는 등록된 폰트를 COLR, 그다음 SVG, 그다음 CBDT, 그다음 sbix의 고정 우선순위로 탐칭해 otcfCOLRv0부터 otcfSBIX까지 THPDFOpenTypeColorFormat을 보고합니다
폰트가 둘 다 갖고 있을 때 벡터 데이터는 비트맵을 이기고, 확대되거나 인쇄될 수 있는 문서에서 원하는 바로 그 동작이며, 컬러 경로가 없는 글리프는 폴백에 맡겨집니다

호출 하나, 형식 다섯: 컬러 글리프 해석과 그리기

THotPDF.GetRegisteredColorGlyphInfo가 코드 포인트가 어떤 경로를 갈지 답하고, DrawRegisteredColorGlyph가 그 경로를 갑니다. 둘 다 RegisterUnicodeTTF에 가장 최근에 넘긴 폰트의 문자 맵에서 코드 포인트를 찾으므로, 호출 시점에 컬러 폰트가 등록된 유니코드 폰트여야 합니다. 그리기 함수는 글리프에 컬러 데이터가 없거나 어떤 경로로도 렌더링하지 못하면 False를 반환하고 폴백은 당신에게 맡깁니다

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600, CPAL 팔레트 0, 300 ppem에 가장 가까운 비트맵 strike
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // 컬러 데이터 없음: 모노크롬 아웃라인으로 폴백
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

두 파라미터가 주목할 만합니다. PaletteIndex는 CPAL 팔레트를 고르므로, 다크 배경 팔레트를 함께 배송하는 폰트를 글리프를 건드리지 않고 바꿀 수 있습니다. TargetPixelsPerEm은 비트맵 폰트에서만 의미가 있습니다. 0으로 두면 Round(FontSize * 96 / 72), 즉 화면 해상도가 기본이 되는데, 예제가 인쇄 출력을 위해 300을 요구하는 이유입니다. 정직한 한계는 시그니처에 있습니다. 호출은 코드 포인트 하나를 받아 cmap만으로 매핑합니다. ZWJ 시퀀스, 피부 톤 수식어, regional-indicator 깃발은 GSUB 리가처이므로, 그것들을 조합하는 건 OpenType GSUB 대체 글리프 기사가 다루는 종류의 셰이핑 문제지 이 진입점이 대신 해 주는 일이 아닙니다

COLR v0: 팔레트 색으로 쌓은 글리프 레이어

COLR v0은 단순한 케이스고 HotPDF는 직접 렌더링합니다. 각 베이스 글리프는 CPAL 색 엔트리와 함께 레이어 글리프들을 나열하고, 각 레이어는 자기 채움 색을 가진 평범한 text-showing 연산 하나가 되어 테이블 순서대로 쌓입니다. 알파가 255 미만인 레이어는 일치하는 /ca와 /CA를 갖춘 graphics state parameter dictionary를 받고(ISO 32000-1 §8.4.5), 모든 레이어 글리프는 사용됨으로 표시되어 어떤 코드 포인트도 직접 매핑되지 않더라도 서브셋터가 아웃라인을 유지합니다. 사람들을 놀라게 하는 디테일이 하나 있습니다. 팔레트 엔트리 인덱스 0xFFFF는 OpenType 스펙에서 "텍스트 전경색 사용"을 뜻하는데, HotPDF는 현재 페이지 채움 색이 아니라 검은색으로 해석합니다. 이모지 폰트에서는 거의 문제가 안 됩니다. 전경 엔트리로 글리프를 틴트하는 아이콘 폰트라면 텍스트 색을 따라올 거라 가정하기 전에 출력을 확인하세요

HotPDF는 COLR v1 paint 그래프를 PDF 연산자로 어떻게 바꿀까?

paint 테이블을 먼저 평평하고 유계인 그래프로 파싱한 다음에야 각 노드를 PDF 구성물로 매핑하는 방식입니다. COLR v1 글리프는 레이어 목록이 아니라 paint 레코드의 방향성 비순환 그래프이며, 노드는 PaintColrLayers와 PaintColrGlyph로 공유될 수 있습니다. 파서는 4096개 paint 노드, 64단계 깊이, 1024개 color stop으로 제한하고, 모든 노드를 active나 done으로 추적해서 활성 노드로의 역참조, 즉 악의적인 폰트가 레이어 재사용으로 만들 수 있는 사이클은 재귀에 들어가는 대신 거부됩니다. 첫 구현이 틀리는 지점은 오프셋 기준입니다. BaseGlyphPaintRecord 오프셋은 BaseGlyphList 시작 기준이고, LayerList paint 오프셋은 LayerList 기준이며, paint 테이블 안의 모든 Offset24는 그 paint 테이블 자체 기준입니다. 셋을 같은 기준으로 해석하면 완벽히 합법적인 글리프가 경계 검사에 실패하는데, 그 모양이 정확히 깨진 폰트처럼 보입니다. 그래프가 만들어지면 매핑은 직접적입니다:

  • PaintGlyph는 텍스트 렌더링 모드 7(ISO 32000-1 §9.3.6)으로 글리프 아웃라인을 clip으로 설정한 다음 그 안에서 자식을 칠합니다
  • solid paint는 클립된 직사각형을 채웁니다. linear 그라디언트는 멀티스톱 axial shading이 되고 radial 그라디언트는 두 색 radial shading이 됩니다(§8.7.4.5)
  • sweep 그라디언트에는 PDF 동등물이 없으므로 HotPDF는 색 라인에서 샘플한 단색 wedge 96개로 근사합니다
  • 변환은 cm으로 내보내지며, 글리프 베이스라인 원점을 중심으로 켤레 적용되고 이동은 FontSize / UnitsPerEm으로 스케일됩니다
  • PaintComposite 모드 13부터 27은 /Multiply, /Screen, /Luminosity 같은 separable 및 non-separable PDF 블렌드 모드(§11.3.5)로 매핑되며, ExtGState /BM 엔트리로 설정됩니다

경계는 명시적입니다. Porter-Duff 모드 5부터 12(src_in, xor, plus 등)에는 PDF 블렌드 모드 상대가 없고, linear와 radial 그라디언트의 repeat와 reflect extend 모드는 내보내지지 않으며, stop들이 서로 다른 알파 값을 갖는 그라디언트는 단일 opacity로 둔갑하지 않습니다. stop이 둘을 넘는 radial 그라디언트는 첫 색과 마지막 색만 유지합니다. HotPDF는 연산자를 하나 쓰기 전에 그래프 전체를 이 지원 서브셋과 대조하므로, 지원되지 않는 글리프는 페이지를 건드리지 않은 채 반쯤 그린 그림을 남기는 대신 래스터 폴백으로 넘어갑니다

HotPDF COLR v1 변환 다이어그램: paint 그래프는 사이클 거부와 함께 4096 노드, 64단계 깊이, 1024 color stop으로 제한된 유계 그래프로 파싱되고, 이어서 PaintGlyph는 모드 7 clip이 되고, linear와 radial 그라디언트는 axial 및 radial shading이 되며, sweep 그라디언트는 wedge 96개가 되고, PaintComposite 모드 13부터 27은 PDF 블렌드 모드가 됩니다
첫 연산자가 쓰이기 전에 그래프 전체가 지원 서브셋과 대조되므로, 지원되지 않는 글리프는 반쯤 그린 그림을 남기는 대신 페이지를 그대로 두고 래스터 폴백으로 넘어갑니다

SVG 글리프와 비트맵 strike

SVG 글리프는 HotPDF가 SVG 파일 임포트에 쓰는 것과 같은 유계 빌더를 거치고, 결과는 Form XObject(§8.10)로 등록됩니다. SVG에서 Form XObject로 기사에 설명된 것과 정확히 같습니다. SVG 테이블의 문서는 gzip 압축일 수 있습니다. 해제는 8 KB 청크로 돌고 펼쳐진 크기가 32 MB를 넘게 될 시점에 멈춥니다. 먼저 풀고 나중에 검사하는 게 아니라요. 압축 입력 자체는 8 MB로 제한됩니다. 프로파일은 의도적으로 제한적입니다. 스크립트, 임베드 이미지, 외부 URL, data: URI, 비로컬 참조는 fail closed입니다. 폼은 긴 쪽이 폰트 크기와 같아지도록 스케일되고 베이스라인에 고정되며, y가 아래로 향하는 SVG 좌표계를 y가 위로 향하는 PDF 좌표계로 매핑합니다. 빌더가 글리프용 SVG 문서 전체를 받는다는 점, glyphNNN 요소를 골라내지 않는다는 점은 유의하세요. 많은 글리프를 하나의 공유 문서에 담는 폰트는 의지하기 전에 테스트할 가치가 있습니다

비트맵 폰트는 strike 선택과 배치의 문제입니다. CBDT에서는 HotPDF가 세로 ppem이 TargetPixelsPerEm에 가장 가까운 CBLC 크기를 고르고, 이미지 형식 17, 18, 19를 받으며, 형식 19 메트릭은 자체 저장을 하나도 하지 않으므로 CBLC 인덱스 서브테이블에서 읽습니다. sbix에서는 strike 오프셋이 테이블 기준이고 글리프 오프셋은 strike 기준이며, dupe 레코드는 자기 origin 오프셋을 유지한 채 다른 글리프의 그래픽을 재사용합니다. 재귀가 바깥 origin을 덮어 쓰게 두면 이미지가 밀립니다. PNG와 JPEG 페이로드는 내부에서 디코딩되고, 폰트 크기로 늘어지는 게 아니라 FontSize / PixelsPerEmY로 스케일되며, 완전 불투명하지 않은 픽셀이 하나라도 있으면 soft mask(§11.6.5.3)와 함께 기록됩니다. sbix TIFF 페이로드는 디코딩하지 않고 이벤트로 갑니다

글리프를 네이티브로 그릴 수 없으면 무슨 일이 벌어질까?

HotPDF는 OnColorGlyphRasterize를 일으키고 핸들러가 돌려주는 RGBA 비트맵 무엇이든 배치합니다. 아무것도 할당되지 않았거나 핸들러가 Handled를 거짓으로 두면 DrawRegisteredColorGlyph는 False를 반환하고 페이지는 그대로입니다. 이벤트는 지원 서브셋 밖의 COLR v1 그래프, safe 빌더가 거부한 SVG 문서, 내부 디코더가 읽지 않는 비트맵 페이로드에서 발화합니다. 핸들러는 형식과 raw 폰트 바이트, 추출된 에셋(SVG 문서, 아직 gzip 상태일 수 있음, 또는 비트맵 바이트, COLR v1은 비어 있음), 글리프 ID, 팔레트, 대상 픽셀 크기를 받습니다

HotPDF 래스터 폴백 다이어그램: OnColorGlyphRasterize는 지원 서브셋 밖의 COLR v1 그래프, safe 빌더가 거부한 SVG 문서, 디코더가 읽지 않는 비트맵 페이로드에서 발화해 형식, 폰트 바이트, 에셋, GlyphID, PaletteIndex, PixelSize를 넘기며, 반환된 RGBA 버퍼는 길이가 정확히 Width 곱하기 Height 곱하기 4일 때만 받아들여집니다
0 크기, 잘못된 버퍼 길이, 넘치는 차원은 페이지가 건드려지기 전에 거부되고, 핸들러가 없거나 Handled가 거짓이면 호출은 False를 반환하며 페이지는 그대로입니다
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine은 당신의 래스터라이저지 HotPDF API가 아닙니다.
  // 정확히 Width * Height * 4바이트의 RGBA를 반환해야 합니다.
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// 배선
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF는 페이지를 건드리기 전에 핸들러 출력을 검증합니다. 0 크기, 길이가 정확히 Width * Height * 4가 아닌 버퍼, 오버플로할 만큼 큰 차원은 거부되고 호출은 False를 반환합니다. 래스터 폴백도 여전히 래스터이므로, 이렇게 렌더링된 이모지는 벡터 선명함을 잃습니다. 출력 해상도에 맞는 PixelSize를 요구하세요. 컬러 경로를 누락 글리프 추적 기사의 그리기 시점 커버리지 검사와 짝지으면, 임의 사용자 텍스트를 다루는 파이프라인이 누락 글리프와 색을 잃은 글리프를 모두 보고할 수 있습니다

컬러 글리프 렌더러와 OpenType 셰이핑 스택, safe SVG 빌더는 모두 Delphi와 C++Builder에서 쓸 수 있는 HotPDF Delphi PDF component에 들어 있습니다