기술 문서

HotPDF로 PDF에 CFF2 가변 폰트 임베딩하기

HotPDF는 CFF2 아웃라인을 가진 OpenType 가변 폰트를, 선택한 축 값에서 PDF 리더들이 이미 이해하는 CID 키 CFF1 폰트 프로그램으로 변환해 임베딩한다. RegisterUnicodeTTF로 등록한 평범한 네이티브 텍스트는 폰트 고유의 cmap과 hmtx 테이블을 쓰고, 라이터는 변환된 프로그램을 /Subtype /CIDFontType0C로 /FontFile3 아래에 넣으면서 모든 CID가 원본 glyph ID와 같게 유지한다

Delphi에서 HotPDF가 CFF2 가변 폰트를 CID 키 CFF1로 바꿔 FontFile3 아래 임베딩하는 파이프라인, 그리고 등록된 바이트 경로가 피해 주는 조용한 GDI 대체 폰트 우회로
HotPDF는 선택한 축 값에서 variation을 고정하고 모든 CID가 원본 glyph ID와 같은 CID 키 CFF1 프로그램을 기록하므로 어느 계층도 번호 체계 사이를 통역할 필요가 없다

눈에 보이는 경로를 굳이 설명하는 이유는, 아무 말 없이 실패하기 때문이다. CFF2 테이블을 파싱해 캐시한 뒤 평범한 텍스트 경로가 폰트를 Windows GDI로 TrueType 전용 매칭으로 풀어 보면, GDI는 CFF2 아웃라인을 어떻게 다뤄야 할지 모르고 조용히 ArialUnicodeMS로 대체해 버린다. 하류에서 대체 폰트를 읽는 모든 것은 엉뚱한 아웃라인, 엉뚱한 폭, supplementary plane 문자의 엉뚱한 추출을 얻는다. PDF는 잘 열린다. 다만 여러분이 지정한 폰트로 찍혀 있지 않을 뿐이다. 그래서 텍스트 경로는 GDI 조회가 아니라 등록된 바이트에서 출발해야 한다

PDF는 왜 CFF2 폰트를 직접 임베딩하지 못하는가?

PDF에는 CFF2 프로그램이 앉을 슬롯이 없다. ISO 32000-2 §9.9는 리더가 처리해야 하는 임베디드 폰트 프로그램 유형을 나열한다. /FontFile 아래 Type 1, /FontFile2 아래 TrueType, /FontFile3 아래 순수 CFF 프로그램(Type1C 또는 CIDFontType0C) 또는 TrueType이나 CFF 아웃라인을 감싼 OpenType 래퍼다. 이 목록의 모든 CFF 플레이버는 CFF 버전 1을 뜻하는데, 리더들이 20년 동안 파싱해 온 포맷이 바로 그것이다

PDF 임베디드 폰트 슬롯 셋, 모두 CFF 버전 1로 제한되며, Delphi에서 HotPDF가 임베딩 전에 변환하게 만드는 없는 CFF2 슬롯
PDF 리더가 처리해야 하는 모든 CFF 플레이버는 CFF 버전 1이므로 CFF2 가변 폰트는 변환되기 전까지는 앉을 슬롯이 없다

CFF2는 이름만 우연히 같은 다른 포맷이다. Name과 String INDEX를 버리고 charset을 버리고, glyph 진행폭을 CharStrings에서 빼앗아 hmtx에 맡기며, 하나의 아웃라인이 설계 공간 전체를 기술하게 하는 blend 연산자를 추가한다. CFF2 테이블을 만난 CFF1 파서는 추측할 근거가 없고, PDF 명세는 시도조차 요구하지 않는다. CFF2 폰트를 OpenType 스트림으로 감싸고 운을 빌리는 것은 어느 머신에서는 렌더링되고 다음 머신에서 실패하는 그런 파일이므로, HotPDF는 그러지 않는다

변환은 또한 정적이어야 한다. 가변 폰트 정적 인스턴싱에 관한 글이 설명하듯 PDF 폰트는 고정 메트릭의 고정 아웃라인 집합이며, 어떤 리더도 축 좌표 적용을 요구받지 않는다. 그래서 HotPDF는 variation을 먼저 확정한 다음에야 CFF1을 기록한다

CFF2 폰트로 네이티브 텍스트 쓰기

애플리케이션 코드 입장에서 CFF2 폰트는 다른 등록 유니코드 폰트와 똑같이 동작한다. 축 값을 고르고, 파일을 등록하고, 패밀리 이름으로 선택하고, 그린다. RegisterUnicodeTTF는 원본 name 테이블의 별도 사본을 간직하므로 SetFont는 패밀리 이름, 전체 이름, PostScript 이름, 타이포그래픽 패밀리 이름을 대조해 얼려 둔 정적 폰트 바이트를 선택하고, Windows 설치나 GDI 조회는 전혀 개입하지 않는다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'brochure.pdf';
    Pdf.BeginDoc;
    // 축 값은 이 호출 이후 등록하는 폰트에 적용된다
    Pdf.SetVariableFontAxis('wght', 650);
    Pdf.RegisterUnicodeTTF('C:\Fonts\AcmeSerifVF.otf');
    // name 테이블의 패밀리, 전체, PostScript 또는 타이포그래픽 패밀리 이름
    Pdf.CurrentPage.SetFont('Acme Serif VF', [], 12, DEFAULT_CHARSET);
    Pdf.CurrentPage.UnicodeTextOut(72, 100, 0, 'Variable weight 650, CFF2 outlines');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

glyph ID는 등록된 cmap에서, 진행폭은 등록된 hmtx에서 온다. 진행폭이 진짜 0인 glyph는 GDI 측정으로 폴백하지 않고 0으로 유지되고, UTF-16 서로게이트 페어는 둘이 아니라 정확히 하나의 진행폭을 낳는다. BMP 문자, supplementary 문자, 합성 shaping 캐리어는 폰트별 유니코드 매핑을 유지하므로, 다른 임베디드 폰트로 갈아타거나 새 페이지를 시작해도 그 폰트가 이 폰트의 subset 상태를 갱신하거나 glyph ID를 빌려 가는 일은 없다

측정도 같은 바이트를 따른다. THPDFPage.GetWideTextWidth와 THPDFPage.GetWideCharAdvances는 선택된 바이트 기반 폰트의 메트릭을 읽으므로 줄 바꿈과 우측 정렬이 실제 그려지는 것과 일치한다. 사람들을 잡는 순서 디테일이 하나 있다. 등록 이후에 바꾼 SetVariableFontAxis는 다음에 등록하는 폰트에 적용되는 것이지 이미 얼려진 인스턴스에 적용되는 게 아니다. 두 번째 굵기를 원하면 새 값을 설정하고 다시 등록하라

HPDFConvertCFF2ToCFF는 CID 키 프로그램을 어떻게 조립하는가?

HPDFConvertCFF2ToCFF는 단일 Font DICT를 가진 원시 CID 키 CFF1 프로그램을 내놓으며, 빈 슬롯과 쓰이지 않는 슬롯까지 포함해 모든 슬롯에서 CID가 원본 glyph ID와 같다. 이 동일성이 설계의 전부다. 폰트 이름, PDF의 /W 폭, CFF 진행폭, ToUnicode CMap이 모두 같은 번호 체계를 가리키므로 glyph ID와 CID 사이를 통역할 계층이 필요 없고, 무엇도 어긋날 수 없다

PDF 쪽에서 후손은 Identity-H 또는 Identity-V 인코딩의 CIDFontType0 폰트이고, 프로그램 스트림은 /Subtype /CIDFontType0C로 /FontFile3 아래 앉는다. CID가 이미 GID와 같으므로 후손은 /CIDToGIDMap을 갖지 않고, 그 스트림은 등록된 TrueType 폰트용으로 쓰이는 이후의 스트림 치환 훅에서 제외된다. /Subtype /OpenType 래퍼와 달리 변환된 스트림은 PDF 1.6 버전 요건을 끌어올리지 않는다

폭에는 작은 기교가 필요했다. CFF1 Type 2 CharString은 진행폭을 nominalWidthX에 상대적인 선택적 첫 피연산자로 저장하는데, 정수 피연산자는 부호 있는 16비트 범위로 제한된다. OpenType 진행폭은 최대 65535의 부호 없는 16비트 값이다. HotPDF는 nominalWidthX를 32768로, defaultWidthX를 0으로 설정한 뒤 모든 진행폭을 advance - 32768로 명시적으로 기록한다. 0 진행폭은 -32768이 되고 65535 진행폭은 32767이 되어, glyph 하나도 클램프 없이 uint16 전체 범위가 표현된다

HotPDF가 부호 없는 16비트 진행폭을 nominalWidthX 32768로 부호 있는 CFF1 피연산자에 대응시키는 방식, Delphi PDF 출력의 모든 폰트 진행폭을 클램프 없이 유지
nominalWidthX 32768이 피연산자 창을 밀어 0 진행폭은 -32768로, 65535는 32767로 저장되며 어떤 glyph도 클램프되지 않는다

기하학도 같은 관심을 받는다. FontMatrix는 [1/UnitsPerEm 0 0 1/UnitsPerEm 0 0]이므로 2048 유닛 폰트는 고유 좌표를 유지한다. 하나의 피연산자로는 너무 큰 상대 이동과 선은 표현 가능한 단계로 쪼개지고, 3차 곡선은 형태를 보존하는 방식으로 분할된다. 원본 저작권·라이선스 기록, 원본 name 테이블, 임베딩 플래그는 CFF Notice 문자열에 실려 간다

문서 밖에서 컨버터 호출하기

컨버터는 HPDFOpenTypeAdvanced 유닛의 public 함수로, Pascal로 작성됐고 HarfBuzz 의존성도 시스템 폰트 설치 단계도 없다. 덕분에 그 자체로 쓸모 있다. 빌드 시점에 폰트를 미리 변환하거나 PDF 라이터가 무엇을 임베딩할지 들여다보는 식이다

uses
  System.SysUtils, System.IOUtils, HPDFOpenTypeAdvanced;

procedure ConvertWeight650;
var
  Source, Cff: TBytes;
  Axes: THPDFVariableFontAxisValueArray;
begin
  Source := TFile.ReadAllBytes('C:\Fonts\AcmeSerifVF.otf');
  SetLength(Axes, 1);
  Axes[0].Tag := 'wght';
  Axes[0].Value := 650;
  Cff := HPDFConvertCFF2ToCFF(Source, Axes);
  if Length(Cff) = 0 then
    raise Exception.Create('Not convertible: not CFF2, embedding denied or bad axes');
  TFile.WriteAllBytes('AcmeSerif-650.cff', Cff);
end;

가변 입력은 주어진 좌표에서 기하학 전용 정적 인스턴싱을 거치며 아웃라인과 진행폭을 모두 커버한다. 정적 CFF2 폰트는 빈 선택만 받고, 축 값을 넘기면 무시되는 게 아니라 오류로 처리된다. 결과는 sfnt 컨테이너가 아니라 벌거벗은 CFF1 프로그램이므로, OpenType 파일을 기대하는 코드에 그냥 넘기지 마라

예상되는 경우에는 예외가 아니라 빈 TBytes가 실패다. CFF2가 아닌 입력, 인스턴서가 받아들이지 않는 축 선택, NaN 같은 비유한 값, OS/2 fsType이 restricted-license 임베딩(0x0002)이나 bitmap-only 임베딩(0x0200)을 설정한 폰트가 그렇다. 항상 길이를 검사하라. EndDoc 안에서는 라이터가 대신 검사하고 "StoreFont: CFF2 outline conversion failed"를 던지는데, 이것이 옳은 판단이다. 출하 PDF 속의 빈 폰트 프로그램은 로그에 보이는 오류보다 훨씬 나쁘다

서버 워크로드를 위한 예산과 취소

선택적 세 번째 파라미터 THPDFVariableFontControl은 메모리를 묶어 두고 긴 변환을 취소할 수 있게 해 준다. 없으면 컨버터는 기본값을 쓴다. 폰트 입출력 각각 128 MiB 한도, 누적 작업 예산 1 GiB, CharString당 65535바이트라는 CFF1 한도다

type
  TFontJob = class
  private
    FCancelled: Boolean;
    procedure CheckCancel;
  public
    function Convert(const Source: TBytes): TBytes;
    property Cancelled: Boolean read FCancelled write FCancelled;
  end;

procedure TFontJob.CheckCancel;
begin
  if FCancelled then
    Abort;  // HPDFConvertCFF2ToCFF 바깥으로 전파된다
end;

function TFontJob.Convert(const Source: TBytes): TBytes;
var
  Control: THPDFVariableFontControl;
begin
  Control := THPDFVariableFontControl.Create(
    32 * 1024 * 1024,           // 최대 출력 바이트
    Int64(256) * 1024 * 1024,   // 누적 작업 예산
    CheckCancel);
  try
    // nil 선택: 가변 폰트의 기본 인스턴스 또는 정적 CFF2 폰트
    Result := HPDFConvertCFF2ToCFF(Source, nil, Control);
  finally
    Control.Free;
  end;
end;

예산 초과와 취소는 예외로 보고되며, 빈 결과 경로와 일부러 구별된다. 빈 배열은 폰트를 변환할 수 없다는 뜻이고, 예외는 이번 시도가 중단됐다는 뜻이다. 오류 처리에서 이 둘을 갈라 놓아라. 변환 불가능한 폰트를 더 큰 예산으로 재시도해 봤자 소용없다

임베딩된 CFF2 폰트는 얼마나 무거운가?

계획에 넣을 주된 것은 파일 크기다. HotPDF는 CFF2 폰트에 대해 EnableFontSubsetting이 켜든 꺼든 변환된 glyph 집합 전체를 임베딩한다. 수천 glyph짜리 폰트는 페이지가 헤드라인 하나만 보여도 그것을 쓰는 모든 문서에 전부 더해진다. 브로슈어라면 받아들일 만하지만, 작은 PDF 수천 장을 찍어 내는 리포트 서버라면 결정하기 전에 실측해야 할 진짜 비용이다

관련 크기 기능들도 같은 규칙을 따른다. 변환된 CFF2 폰트는 통째로 임베딩되므로 CompressDocument가 켜는 컴팩트 폰트 subset도 영구 폰트 subset 캐시도 적용되지 않는다. 같은 문서의 TrueType 폰트는 여전히 평소처럼 subset되고 캐시된다. 모든 glyph을 간직하는 한 가지 이득은 subset을 금지하는 라이선스의 폰트가 그 금지를 존중하는 형태로 임베딩된다는 것이다

자원 한도는 유한한 변환 한도이고, CFF1 자체가 단일 CharString이 담을 수 있는 양을 제한한다. 변환된 CharString이 65535바이트를 넘는 glyph이거나 작업 예산을 초과한 폰트는 빈 아웃라인으로 기록되는 대신 눈에 보이게 실패한다. 이것이 의도된 절충이다. 기이한 폰트에서 시끄럽게 실패하는 편이 조용히 빠진 glyph을 지닌 문서보다 낫다

네이티브 CFF2 텍스트와 이식 가능한 CFF1 컨버터는 Delphi와 C++Builder용 HotPDF Delphi PDF 컴포넌트에 들어 있으며, 이 블로그의 다른 글에서 다루는 가변 폰트 인스턴싱, 컬러 glyph, 폰트 subsetting 기능과 함께 제공된다