Technical Article

순수 Delphi에서 OpenType GSUB 스타일 대체문자(Stylistic Alternates) 구현하기

디자이너가 제목용으로 단층 구조의 a를 가지거나, 표용으로 슬래시가 들어간 0을 가지거나, 표지용으로 스와시(swash) 대문자 세트를 가지는 글꼴을 선택할 수 있습니다. 이 글리프들은 이미 글꼴에 포함되어 있습니다. 단지 기본값이 아닐 뿐입니다. 기본값인 a는 문자가 cmap 테이블을 통해 하나의 글리프에 매핑되고, 대체 글리프는 몇 개의 글리프 ID만큼 떨어져 있어 대체 규칙(substitution rule)을 통해서만 액세스할 수 있습니다. PDF에서 이러한 대체 글리프를 표시하려면 규칙을 읽어 콘텐츠 스트림에 대체 글리프를 출력해야 합니다. 이 글에서는 기본 셰이핑(shaping) 라이브러리를 사용하지 않고 Object Pascal에서 단일 대체(single-substitution) 종류의 규칙을 읽는 방법을 설명합니다.

논의의 범위를 의도적으로 좁게 잡았습니다. 스타일 세트(stylistic set)와 스타일 대체문자(stylistic alternate)는 1:1 글리프 입력 및 출력 대체입니다. 이들은 규모가 작고 결정론적인 테이블 스캔만으로 해석할 수 있는 OpenType 레이아웃 영역이므로, C 라이브러리 종속성 없이 자체적으로 실행되려는 Pascal 엔진에 적합합니다.

HarfBuzz 대신 순수 Delphi를 사용하는 이유

HarfBuzz는 '텍스트 셰이핑' 요구 사항에 대한 확실한 대안이며, 완전한 양방향(bidirectional) 텍스트, 인도계 문자 또는 아랍어 셰이핑에 적합한 솔루션입니다. 그러나 이는 C 라이브러리입니다. 이를 Delphi나 C++Builder 제품에 결합하려면 모든 대상 플랫폼과 아키텍처용으로 네이티브 객체를 제공해야 하고, 호출 규약을 맞춰야 하며, 릴리스 주기를 관리해야 하고, 라이선스 조건을 검토해야 합니다. 개별 작업이 어렵지는 않지만 지속적으로 오버헤드가 발생하며, 실제 필요로 하는 기능이 단지 '이 문자의 ss01 형식을 제공하는 것'인 경우에는 메리트가 없습니다.

Single substitution에는 셰이핑 엔진이 필요하지 않습니다. 단지 몇 가지 GSUB 하위 테이블 형식을 분석할 파서와 한두 번의 이진 탐색이 필요할 뿐입니다. 이를 Pascal로 구현하면 전체 툴체인을 단일 컴파일러 내부로 유지할 수 있습니다. 분명히 해둘 점은 이 방식이 글리프 대체 룩업만을 처리한다는 것입니다. 양방향 분석, 인도계 문자 재정렬, 자동 맥락별(contextual) 셰이핑 등은 수행하지 않습니다. 이러한 복잡한 기능이 필수적인 경우 단일 대체 쿼리만으로는 이를 대체할 수 없습니다.

GSUB 계층 구조, 최상위부터 최하위까지

Glyph Substitution(GSUB) 테이블은 간접 참조 체인으로 구성되어 있으며, 대체 쿼리는 이 체인을 최상단부터 순차적으로 탐색합니다. 최상단에는 ScriptList가 있습니다. latn과 같은 스크립트 태그가 항목을 선택하고, 특별 태그인 DFLT는 일치하는 특정 스크립트가 없을 때 적용되는 기본 스크립트입니다. 스크립트 항목은 언어 시스템인 LangSys를 가리키며, 일반적인 경우를 위한 기본 LangSys와 특별한 동작이 필요한 언어를 위한 옵션 LangSys가 있습니다. 대표적으로 터키어의 경우 점이 있는 i와 점이 없는 i를 각각 개별적으로 처리해야 합니다.

LangSys는 일련의 피처 인덱스(feature index)를 지정합니다. 각 인덱스는 FeatureList를 가리키며, 피처 레코드에는 ss01을 포함한 4바이트 태그와 룩업 인덱스 목록이 포함됩니다. 이 인덱스들은 최종적으로 실제 대체 하위 테이블이 있는 LookupList를 가리킵니다. 따라서 ss01을 해석하는 과정은 다음과 같습니다: 스크립트를 찾고, 해당 LangSys를 찾고, 태그가 ss01인 피처를 찾고, 해당 피처가 지정하는 룩업들을 수집한 다음 적용합니다. HotPDF는 라틴어 서체의 대다수가 채택하고 있는 DFLT 스크립트와 기본 LangSys를 기본값으로 사용하며, 글꼴이 특정 스크립트 아래에 기능을 포함하고 있는 경우 스크립트 태그를 재정의할 수 있는 방법도 제공합니다.

커버리지 테이블에 의한 적용 대상 결정

모든 대체 하위 테이블은 동일한 질문으로 시작합니다: 입력 글리프가 이 규칙에 참여하는지, 참여한다면 규칙의 자체 인덱스에서 어느 위치에 해당하는지 확인하는 것입니다. 이 질문은 Coverage 테이블을 통해 해결되며, 반환되는 커버리지 인덱스(coverage index)는 하위 테이블의 나머지 영역에서 글리프가 어떻게 변경되는지 찾는 데 사용되는 서수 값입니다.

Coverage는 두 가지 형식으로 제공됩니다. Format 1은 오름차순으로 정렬된 글리프 ID 목록입니다. 이진 탐색으로 글리프를 찾으며, 목록 내 위치가 곧 커버리지 인덱스가 됩니다. Format 2는 범위 레코드 목록으로, 각각 시작 글리프, 끝 글리프 및 시작 글리프가 매핑되는 커버리지 인덱스를 포함합니다. 범위 내의 글리프는 범위의 시작점 오프셋을 통해 커버리지 인덱스를 얻습니다. Format 1은 참여 글리프들이 흩어져 있을 때 유리하고, Format 2는 연속된 구간에 모여 있을 때 적합합니다. 두 형식 모두 정렬되어 있어 로그 시간(logarithmic time) 내에 탐색되며, 커버리지 인덱스를 반환하거나 일치 항목이 없음을 나타내는 'not covered'를 반환하여 엔진이 해당 글리프를 변경 없이 그대로 두도록 제어합니다.

단일 대체, 두 가지 포맷

Single Substitution은 LookupType 1이며, 하나의 글리프를 정확히 하나의 대체 글리프에 매핑합니다. 이 역시 두 가지 형식을 제공하는데, 이는 공간 최적화를 위한 구분입니다. Format 1은 단일 부호 있는 델타(delta) 값을 저장합니다. 출력 글리프 ID는 입력 글리프 ID에 델타를 더하고 65536으로 모듈러 연산한 결과입니다. 이는 모든 대상 글리프가 대체 글리프와 고정된 오프셋 관계에 있을 때 유용합니다. 예를 들어 올드스타일 숫자와 일치하는 라이닝 숫자들이 일정한 거리만큼 유지되어 정렬되는 방식입니다. Coverage 테이블Says which glyphs qualify, and the one delta serves all of them.

Format 2는 대체 글리프 ID의 명시적 배열을 저장합니다. Coverage 테이블에서 얻은 커버리지 인덱스가 배열의 인덱스로 기능하므로, 커버리지 인덱스가 0인 글리프는 배열의 첫 번째 항목으로 대체되고 인덱스 1은 두 번째 항목으로 대체됩니다. Format 2는 대체 글리프들이 균일한 오프셋을 갖지 않는 경우에 사용되며, 수작업으로 구성된 스타일 세트에서 흔히 볼 수 있습니다. 호출 방식은 어느 쪽이든 동일합니다. 입력 글리프를 가져와 Coverage 테이블을 통해 일치하는지 확인하고, 해당하면 델타를 적용하거나 배열 값을 읽습니다.

var
  Pdf: THotPDF;
  BaseGID, AltGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\MyStylisticFace.ttf');
    Pdf.SetFont('My Stylistic Face', 12, []);

    // Default glyph for 'a' through the font's cmap.
    BaseGID := Pdf.GetUnicodeGlyphForCodepoint(Ord('a'));

    // Stylistic Set 1: resolve the alternate via GSUB LookupType 1.
    AltGID := Pdf.GetSingleSubstituteGlyph(BaseGID, 'ss01');

    // AltGID = BaseGID means the feature did not touch this glyph.
    if AltGID <> BaseGID then
      { emit AltGID in the content stream };
  finally
    Pdf.Free;
  end;
end;

주목할 만한 동작 규칙은 패스스루(pass-through) 처리입니다. GetSingleSubstituteGlyph는 글꼴이 없거나, GSUB 테이블이 없거나, 일치하는 피처가 없거나, 커버리지 매칭에 실패하는 등의 모든 경우에 입력 글리프 ID를 변경 없이 그대로 반환합니다. That means the call is safe to make unconditionally. 대체를 요청했을 때 제공되는 대체 글리프가 없다면 원래 글리프가 그대로 반환되므로, 호출 코드에서 해당 피처가 없는 글꼴에 대해 별도의 예외 처리를 구현할 필요가 없습니다.

스타일 기능 태그의 의미

피처 태그는 요청하고자 하는 대체 형식의 명칭이며, 스타일 작업에 사용되는 주요 태그 목록은 비교적 간단합니다. 핵심이 되는 쌍은 글리프의 대체 형식을 전체적으로 제어하는 salt(stylistic alternates)와 글꼴이 정의할 수 있는 20개의 스타일 세트인 ss01~ss20입니다. 각 세트는 디자이너가 연관된 대체 동작을 하나로 묶어 명명한 패키지입니다. 예를 들어 글꼴 디자이너가 단층 구조의 a와 직선 획을 가진 Rss03 아래에 둘 수 있으며, 이 세트를 활성화하면 두 글자가 모두 스타일 대체 처리됩니다.

이와 더불어 여러 단일 대체 태그들이 존재합니다. aalt는 글리프가 가지는 모든 대체문자의 합집합인 access-all-alternates로, 일반적으로 글리프 팔레트 기능으로 노출됩니다. titl은 큰 자막 크기용으로 가공된 타이틀용 대문자를 선택합니다. subssups는 단순히 크기를 축소한 기본값이 아닌 실제 아래첨자 및 위첨자 숫자로 대체합니다. ordn은 1st, 2nd에 사용되는 서수(ordinal) 형태의 문자들을 생성합니다. frac은 분수(fraction)를 구성하지만, 대각선 분수의 온전한 구현에는 단일 대체를 넘어선 합자(ligature) 및 맥락별 제어 로직도 활용됩니다. 단일 글리프의 경우 작동 방식은 ss01과 동일합니다: 대입 쿼리에 태그를 전달하고 대체 글리프를 받아오면 됩니다.

// Try a stylistic-set feature, then fall back to plain alternates.
function ResolveAlternate(Pdf: THotPDF; BaseGID: Word;
  const PreferredTag: AnsiString): Word;
begin
  Result := Pdf.GetSingleSubstituteGlyph(BaseGID, PreferredTag);
  if Result = BaseGID then
    Result := Pdf.GetSingleSubstituteGlyph(BaseGID, 'salt');
  // Still BaseGID if neither feature covers this glyph.
end;

cmap 포맷 12 및 보조 평면

대체 쿼리가 실행되기 전에 문자가 먼저 글리프로 변환되어야 하며, 이는 cmap 테이블이 처리합니다. 대체 쿼리는 글리프 ID에서 시작하므로 문자에서 cmap을 통해 글리프로 전환되고, 다시 GSUB를 통해 글리프에서 대체 글리프로 이어집니다. cmap의 핵심 기능은 커버리지 영역입니다. Format 4 하위 테이블은 기본 다국어 평면(Basic Multilingual Plane, BMP)인 첫 65536개의 코드 포인트를 처리하며, 이는 일반적인 라틴어 텍스트에 충분합니다. 그러나 U+10000 이상의 코드 포인트인 보조 평면 영역을 처리하기에는 부족합니다. 보조 평면에는 수학용 영숫자 기호, 여러 특수 기호 및 다양한 문자들이 배치되어 있습니다.

Format 12는 U+0000부터 U+10FFFF 전체 범위를 처리하는 하위 테이블입니다. 이는 정렬된 그룹 목록으로 구성되며, 각 그룹은 시작 코드 포인트, 끝 코드 포인트, 시작 글리프 ID를 가져 코드 포인트의 연속된 범위가 연속된 글리프에 매핑됩니다. HotPDF는 데이터 레이아웃에 최적화된 하이브리드 전략을 사용하여 코드 포인트를 해석합니다. BMP 영역의 코드 포인트는 코드 포인트를 인덱스로 사용하는 다이렉트 배열에서 직접 반환하여 검색 없이 1회에 조회가 완료됩니다. 보조 평면의 코드 포인트는 코드 포인트순으로 정렬된 희소 테이블(sparse table)에서 제공되며 이진 탐색으로 수행됩니다. 그 결과 GetUnicodeGlyphForCodepoint는 전체 Cardinal을 파라미터로 받아 전체 범위에서 올바르게 작동하며, 글꼴이 매핑하지 않는 코드 포인트에 대해서는 0인 .notdef 글리프를 반환합니다.

var
  Pdf: THotPDF;
  Cp: Cardinal;
  GID, StyledGID: Word;
begin
  // A supplementary-plane code point: U+1D49C MATHEMATICAL SCRIPT CAPITAL A.
  Cp := $1D49C;
  GID := Pdf.GetUnicodeGlyphForCodepoint(Cp);  // format 12 lookup
  if GID <> 0 then
    StyledGID := Pdf.GetSingleSubstituteGlyph(GID, 'ss01')
  else
    StyledGID := 0;  // font has no glyph for this code point
end;

이러한 쿼리가 중단되는 지점

단일 대체 API는 한 가지 형태의 작업만 해결하므로, 처리하지 못하는 작업 영역을 명확히 알아둘 필요가 있습니다. LookupType 1은 8가지 대체 유형 중 하나입니다. 이 쿼리는 하나의 글리프가 여러 글리프가 되는 LookupType 2 다중 대체(multiple substitution)나, 여러 글리프가 합쳐지는 LookupType 4 합자 대체(ligature substitution)를 처리하지 못합니다. 또한 특정 영역 주변에 나타날 때만 활성화되는 LookupType 5, 6인 맥락별 및 체이닝 맥락별 유형을 처리하지 못하며, 확장(extension) 및 역체이닝(reverse-chaining) 유형도 지원하지 않습니다. 대각선 분수, 데바나가리 합자, 아랍어 초성/중성/종성 연결 등은 선후 관계를 다루는 시퀀스 문제이므로 글리프 단위의 단일 대체 룩업으로는 표현할 수 없습니다.

또한 자동 셰이핑을 제공하지 않습니다. 텍스트 흐름을 스캔하여 활성화할 피처를 알아서 식별하고 스크립트가 요구하는 순서대로 적용하는 지능형 기능은 포함되어 있지 않습니다. 호출자가 직접 피처 태그를 지정하여 글리프 단위로 적용해야 합니다. 이는 선택형 개별 편집인 스타일 세트와 대체문자에는 가장 적합한 구조이지만, 문자 재배치 등이 필요한 복잡한 스크립트에는 맞지 않습니다. 이러한 한계를 분명히 함으로써 글리프 대체 처리 로직을 가볍고 예측 가능하게 유지할 수 있습니다.

글리프 순서 처리가 필요한 복잡한 언어 셰이핑은 Delphi의 복잡한 스크립트 텍스트 셰이핑에 대한 문서에서 다룹니다. 글리프 대체가 페이지에 이미지와 다른 글꼴을 배치하는 보고서 출력 작업의 일부인 경우, 글꼴 및 이미지를 포함한 보고서 출력 가이드에서 이들 요소가 서로 어떻게 결합되는지 설명합니다. 이 모든 기능은 이 블로그에서 다루는 글꼴 임베딩, 서브셋 생성, 텍스트 API와 함께 GSUB 대체 쿼리를 제공하는 Delphi 및 C++Builder용 HotPDF 컴포넌트와 동일한 엔진에서 실행됩니다.