기술 문서

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

폰트를 Segoe UI Emoji로 하고 평범한 텍스트 호출로 PDF에 쓴 그리는 얼굴 이모지는 평평한 검은 실루엣으로 나옵니다. 모양은 맞지만 노란색도, 음영도 없습니다. CBDT 이모지 폰트로 바꾸면 같은 호출이 아예 아무것도 그리지 않을 수 있습니다. 그 폰트는 그림을 PNG 파일로 보관하고 윤곽선 데이터는 거의 또는 전혀 실지 않기 때문입니다. 뷰어에서 검은 모양을 복사해도 클립보드는 여전히 U+1F600을 담고 있습니다. 문자는 맞고, 색 테이블만 읽히지 않았을 뿐입니다

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

PDF 폰트 프로그램에는 색이 입혀진 글리프라는 개념이 없기 때문입니다. ISO 32000-1은 모든 글리프를 현재 채움 색으로 칠하는 모양으로 취급하고, OpenType이 나중에 추가한 색 테이블들(COLR/CPAL, SVG , CBDT/CBLC, sbix)은 PDF 이미징 모델의 일부가 아니어서, 어떤 뷰어도 임베디드 폰트에서 그것들을 읽을 이유가 없습니다. 이모지가 지닌 색은 PDF가 쓰이는 동안, 생산자가 아직 폰트 바이트를 쥐고 있고 원하는 글리프가 정확히 무엇인지 아는 지점에서 페이지 콘텐츠로 바뀌어야 합니다. 그 번역은 형식마다 다른데, 실제 이모지 폰트는 넷 모두를 씁니다. 쌓인 벡터 레이어, 그라디언트 페인트 그래프, 통째의 SVG 문서, PNG strike입니다

HotPDF는 그 번역을 THotPDF.DrawRegisteredColorGlyph에서 합니다. 글리프가 어느 형식으로 해석되었는지 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';
    // 팔레트 항목 0xFFFF("텍스트 전경색")는 이 색으로 해석됩니다
    Pdf.ColorGlyphForegroundColor := clNavy;
    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 팔레트를 고르므로, 어두운 배경 팔레트를 실은 폰트도 글리프를 건드리지 않고 갈아끼울 수 있습니다. 팔레트 항목 0xFFFF는 OpenType 명세에서 특별합니다. "텍스트 색이 무엇이든 그것"을 뜻하며, HotPDF는 그것을 ColorGlyphForegroundColor와 ColorGlyphForegroundAlpha(기본 검정과 255)에서 채웁니다. 이모지 폰트는 그 항목을 거의 쓰지 않습니다. 아이콘 폰트가 쓰고, 여러분 폰트가 그것으로 글리프를 물들이면 그리기 전에 속성을 텍스트 색으로 두세요

TargetPixelsPerEm은 비트맵 폰트에서만 의미가 있고, 그 기본값이 재정의할 가장 가치 있는 매개변수입니다. 0으로 두면 Round(FontSize * 96 / 72), 즉 화면 해상도가 됩니다. 36포인트 이모지의 48픽셀. 20, 32, 40, 48, 64, 96, 160 ppem의 sbix strike를 선언하는 폰트에서 그 기본값은 48픽셀 strike를 고르고, 600 dpi 프린터는 그것을 약 여섯 배로 늘려야 합니다. 600 dpi에서 36포인트가 필요한 300을 요구하면 같은 호출이 폰트가 가진 가장 큰 160 strike를 가져갑니다

시그니처는 범위도 고정합니다. 코드 포인트 하나가 들어가 cmap을 통과합니다. ZWJ 가족 시퀀스, 피부톤 수식어, 지역 표시기 플래그는 GSUB 합자이므로 먼저 글리프로 셰이핑해야 합니다. OpenType GSUB 대체형 글이 다루는 종류의 작업입니다

PDF 벡터로서의 COLR 레이어와 페인트 그래프

COLR v0은 단순한 형식입니다. 각 베이스 글리프는 레이어 글리프 스택을 나열하고 각 레이어는 CPAL 색을 갖으며, HotPDF는 테이블 순서대로 각 레이어의 윤곽선을 채웁니다. CPAL 색의 알파가 255 미만인 레이어는 자체 /ca를 받아 반투명 레이어가 아래 레이어들과 폰트 디자이너가 의도한 그대로 블렌드됩니다. 평범한 문서에서는 스택 전체가 하나의 고립 투명도 그룹(ISO 32000-1 §11.4)으로 캡처됩니다. 그룹은 글리프 밖에서 일어나는 일에 관한 것입니다. 이모지 주위에 설정한 불투명도, 블렌드 모드, 소프트 마스크는 끝난 글리프에 한 번 적용됩니다. 모든 레이어마다 반복되어 레이어가 겹치는 곳마다 두 배로 쌓이는 대신

COLR v1은 다른 동물입니다. 그라디언트, 변환, 중첩 글리프 클립, 합성 모드를 지닌 페인트 레코드의 방향성 비순환 그래프이고, 노드는 PaintColrLayers와 PaintColrGlyph로 공유될 수 있습니다. HotPDF는 그것을 먼저 한도가 정해진 그래프로 파싱합니다. 페인트 노드 4096, 깊이 96단계, 색 정지점 1024개로 제한하고, 방문 중인 각 노드를 추적해 현재 경로로 되돌아오는 참조(적대적 폰트가 레이어 재사용으로 만들 수 있는 사이클)는 재귀 대신 거부합니다

오프셋이 손작성 COLR v1 파서가 보통 무너지는 자리입니다. BaseGlyphPaintRecord 오프셋은 BaseGlyphList 시작 기준, LayerList 페인트 오프셋은 LayerList 기준, 페인트 테이블 안의 모든 Offset24는 그 페인트 테이블 자체 기준입니다. 셋을 하나의 기준에 대해 해석하면 완전히 합법적인 글리프가 경계 검사에 실패하는데, 꼭 손상된 폰트처럼 보입니다. 그래프가 만들어지면 PDF로의 사상은 대체로 직접적입니다:

  • PaintGlyph는 글리프 윤곽선을 클리핑 경로로 바꾸고, 자식 페인트가 잘려진 영역을 채웁니다
  • 선형 그라디언트는 다중 정지점 축성 셰이딩이 되고 방사형 그라디언트는 방사형 셰이딩이 됩니다(§8.7.4.5)
  • 스윕 그라디언트에는 PDF 셰이딩 타입이 없어서, 중심 주위로 색 선에서 샘플한 96개의 단색 부채꼴로 그려집니다
  • 변환은 cm 연산자가 되고, 병진은 FontSize / UnitsPerEm으로 스케일됩니다
  • PaintComposite 모드 13에서 27은 같은 이름의 PDF 블렌드 모드, /Multiply, /Screen, /Luminosity 등(§11.3.5)으로 ExtGState /BM 항목을 통해 사상됩니다

반투명 정지점을 지닌 그라디언트에는 함정이 하나 더 숨어 있습니다. COLR v1은 알파로 프리멀티플라이된 색을 보간하는 반면 PDF는 직선 색을 기대하고 알파를 스스로 적용하므로, 프리멀티플라이된 값을 PDF에 넘기는 변환기는 모든 반투명 밴드를 두 번 어둡게 만듭니다. 색은 보간 후, 기록 전에 프리멀티플라이를 풀어야 합니다

HotPDF COLR v1 변환 다이어그램: 페인트 그래프는 사이클 거절과 함께 4096 노드, 96 깊이 단계, 1024 색 정지점으로 제한된 유계 그래프로 파싱되고, PaintGlyph는 윤곽선 클리핑 경로가 되며, 선형 및 방사형 그라디언트는 축성 및 방사형 셰이딩이 되고, 스윕 그라디언트는 96개 부채꼴이 되고, PaintComposite 모드 13~27은 PDF 블렌드 모드가 됨
그래프 전체가 첫 연산자가 기록되기 전에 지원 서브셋에 대해 검사되므로, 지원되지 않는 글리프는 페이지를 그대로 두고 반쯤 그린 그림을 남기는 대신 래스터 폴백으로 넘어갑니다

PDF/A-1이나 PDF/X-1a 아래에서 COLR v1 합성 모드는 어떻게 되나

PaintComposite의 Porter-Duff 모드들(5에서 12: src_in, dest_out, src_atop, xor, plus와 형제들)에는 같은 이름의 PDF 블렌드 모드가 없고, PDF 명세의 뻔한 독법은 표현할 수 없다고 말합니다. 그 독법은 너무 강합니다. in/out 모드는 한 피연산자 위의 알파 소프트 마스크이고, atop, xor, plus는 고립 형식, 그룹 알파, 휘도 마스크, 블렌드 산술로 조립될 수 있습니다. HotPDF는 그렇게 만들어서 이 글리프들이 벡터로 남게 합니다

비용은 그 구성들 대부분이 PDF 투명도를 필요로 하고, PDF/A-1, PDF/X-1a, PDF/X-3은 그것을 금한다는 것입니다. PDFACompliance가 PDF/A-1 값이거나 PDFXCompliance가 'X-1a'나 'X-3'이면 HotPDF는 그리기 전에 글리프를 검사해 투명도 없이는 그릴 수 없는 경우를 래스터 이벤트로 보냅니다. atop, xor, plus 합성, 마스크된 피연산자가 단순 불투명 페인트 이상인 in/out 합성, 부분 알파의 단색 페인트, 반투명 정지점의 그라디언트, 알파가 255 미만인 COLR v0 레이어입니다. in과 out 모드는 마스크된 쪽이 평범한 단색이나 그라디언트일 때, 선택적으로 글리프 클립이나 변환으로 감싸인 채, 벡터로 남습니다. 그 경우 불투명한 회색조 마스크만 필요하기 때문입니다. StrictVersionLock이 파일을 PDF 1.4 미만으로 고정할 때 COLR v0에도 같은 것이 적용됩니다

Pdf.PDFACompliance := 'B';   // PDF/A-1b: 투명도가 허용되지 않습니다
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // OnColorGlyphRasterize 핸들러가 없으면 반투명하거나
  // 합성된 글리프는 PDF/A-1 아래에서 여기서 끝납니다
  LogMissingColorGlyph($1F308);   // 여러분의 자체 로깅 루틴

아카이브가 이모지를 색으로 보여줘야 한다면 PDF/A-1b 대신 PDF/A-2b를 겨냥하세요. Part 2는 투명도를 허용하고 모든 COLR 구성이 벡터로 남으며, 폴백 핸들러가 반투명 이모지마다의 주 렌더러가 아니라 안전망이 됩니다. PDF/A-1은 이름으로 요구하는 워크플로에 남겨 두세요

리더 쪽 설정 하나도 여기 속합니다. HotPDF는 COLR v1 그룹을 선형광 블렌드 공간에서 합성합니다. 어떤 렌더러는 그런 그룹에서 아주 어두운 채널을 잃고 어떤 렌더러는 그룹 블렌드 공간을 완전히 무시하므로, COLRv1DeviceSpaceGroupBlend(기본 False)가 그룹을 평범한 장치 공간에서 합성하게 합니다. 트레이드오프는 COLR v1 반복 및 반사 그라디언트 글에서 설명합니다. 사용자가 실제로 쓰는 뷰어에서 어두운 반투명 틴트가 사라지면 켜세요

SVG 글리프와 비트맵 strike

SVG 글리프는 HotPDF가 임포트된 SVG 파일에 쓰는 것과 같은 safe 빌더를 통과하고, 그 결과는 SVG를 Form XObject로 글에서 기술한 대로 Form XObject(§8.10)로 등록됩니다. SVG 테이블의 문서는 gzip 압축일 수 있습니다. 압축 입력은 8MB로 제한되고, 압축 해제는 8KB 청크로 돌며, 팽창 크기가 32MB를 넘게 되는 즉시 멈춥니다. 스크립트, foreignObject, javascript: URL, @import 스타일시트는 빌더가 문서를 완전히 거부하게 만듭니다. 폼은 긴 쪽이 폰트 크기와 같아지도록 스케일됩니다. 프로덕션 전에 시험할 것이 하나 있습니다. 일부 폰트는 많은 글리프를 하나의 공유 SVG 문서에 포장하는데, 빌더는 글리프마다 그 문서 전체를 받습니다

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

래스터 폴백 핸들러 쓰기

네이티브 경로가 모두 통하지 않으면 HotPDF는 OnColorGlyphRasterize를 일으키고 핸들러가 반환한 RGBA 비트맵을 배치합니다. 핸들러가 없거나 Handled가 False로 남으면 DrawRegisteredColorGlyph는 False를 반환하고 아무것도 그려지지 않습니다. 핸들러는 형식, 폰트 바이트, 추출된 에셋(SVG 문서, 아직 gzip 상태일 수 있음, 또는 비트맵 바이트, COLR은 빈 값), 글리프 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를 반환합니다. 가변 컬러 폰트라면 핸들러는 등록된 축 값의 정적 인스턴스를 받습니다. 원본 가변 폰트를 받게 되면 Sender(THotPDF로 캐스트)에 대한 GetRegisteredVariableFontAxes가 렌더링할 좌표를 반환합니다. PixelSize는 같은 TargetPixelsPerEm 값이므로 위의 인쇄 해상도 조언이 여기도 적용됩니다

임의 사용자 텍스트를 인쇄하는 파이프라인은 두 종류의 손실을 알아야 합니다. 폰트가 애초에 갖지 않은 글리프와, 색을 잃은 글리프입니다. 빠진 글리프 추적 글의 그리기 시점 커버리지 추적이 첫째를 보고하고, DrawRegisteredColorGlyph의 False가 둘째를 보고합니다

COLR, SVG, 비트맵 이모지 렌더링과 OpenType 셰이핑, safe SVG 빌더는 모두 HotPDF component for Delphi and C++Builder의 일부입니다