기술 문서

PDF의 컴포짓 글리프와 CFF seac 악센트 렌더링

HotPDF는 하나의 글리프를 다른 글리프들로 조립하는 두 폰트 메커니즘을 다뤄서 임베디드 폰트의 악센트 문자를 올바르게 렌더링합니다: 컴포넌트 오프셋이 글리프-디바이스 행렬보다 먼저 글리프 유닛으로 적용되어야 하는 TrueType 컴포짓 글리프, 그리고 StandardEncoding 코드로 베이스 글자와 악센트를 이어 붙이는 CFF seac 형식의 endchar가 그 대상입니다. 하나라도 틀리면 é, Å, ü, 컬리 더블 쿼트가 부품 하나를 잃고 나오는데, 주변의 평범한 글자들은 완벽해 보입니다

마지막 디테일이야말로 이 버그들을 비싸게 만드는 것입니다. 리포트는 렌더링되고 본문 텍스트는 멀쩡한데 "München"에만 구멍이 뚫려 있거나, 인용이 작은따옴표 하나와 빈틈으로 열립니다. 아무도 렌더러를 의심하지 않습니다. 첫 가설은 언제나 손상된 폰트나 엉터리 생산자고, 티켓은 일주일을 떠돕니다. 이 글은 각 메커니즘에서 실제로 무엇이 잘못되는지, HotPDF Delphi 컴포넌트가 v2.768.10과 v2.768.11부터 이를 어떻게 다루는지, 그리고 HotPDF가 폰트를 읽는 게 아니라 쓸 때 컴포짓 글리프를 온전하게 지키는 v2.770.83의 관련 서브셋팅 수정을 짚어 갑니다

PDF 글리프에서 하필 악센트만 사라지는 이유

악센트가 사라지는 이유는 글리프에서 0이 아닌 오프셋을 가진 부분이 그것뿐이기 때문입니다. TrueType과 CFF 폰트 모두 아웃라인을 재사용해서 공간을 아낍니다: é는 새 모양으로 그려지지 않고 "e 글리프에 어큐트 글리프를 오른쪽 위로 밀어 얹은 것"으로 그려집니다. 베이스 글자는 거의 언제나 오프셋 (0, 0)에 앉아 있으므로, 오프셋 적용에 어떤 오류가 있어도 그것은 건드리지 않습니다. 움직이는 컴포넌트는 악센트이고, 그래서 잘못된 자리에 착지하거나 아예 그려지지 않는 것도 악센트입니다

증상 패턴 자체가 하나의 진단 도구입니다. 악센트 문자의 베이스 글자는 그려지는데 발음 구별 부호가 그려지지 않거나, “와 ” 같은 짝을 이루는 구두점이 두 개가 아니라 하나의 기호로 보인다면 폰트 파일은 그만 보세요. 깨진 폰트는 보통 글리프를 통째로 깨뜨리고, 모든 뷰어에서 깨뜨립니다. Acrobat은 페이지를 잘 보여 주는데 한 렌더러에서만 조립 글리프의 부품이 빠진다면, 그것은 컴포넌트를 조립하는 코드를 가리킵니다

TrueType 컴포짓 글리프는 컴포넌트를 어떻게 배치하나요?

TrueType 컴포짓 글리프는 각 컴포넌트를 자체적인 작은 아핀 변환으로 배치합니다. 폰트 유닛으로 표현되며, 이 변환은 글리프-디바이스 행렬보다 먼저 컴포넌트 아웃라인에 작용해야 합니다. OpenType glyf 테이블에서 컴포짓 글리프는 음수 컨투어 개수로 시작하고, 이어서 컴포넌트 레코드의 목록이 옵니다. 각 레코드는 플래그 워드, 컴포넌트 글리프 인덱스, 바이트 또는 워드인 두 인수(ARG_1_AND_2_ARE_WORDS, 0x0001), 그리고 선택적 스케일을 담습니다: WE_HAVE_A_SCALE(0x0008)은 F2Dot14 값 하나, WE_HAVE_AN_X_AND_Y_SCALE(0x0040)은 둘, WE_HAVE_A_TWO_BY_TWO(0x0080)은 완전한 2x2 행렬입니다. ARGS_ARE_XY_VALUES(0x0002)가 설정되면 두 인수는 폰트 유닛의 dx/dy 오프셋입니다. MORE_COMPONENTS(0x0020)는 레코드가 또 따라온다는 뜻입니다

컴포넌트마다 스케일 부분과 오프셋으로 컴포넌트 행렬 CM을 만들고, 폰트 유닛을 디바이스 픽셀로 사상하는 행렬 M과 결합합니다. 그 결합의 순서야말로 버그 그 자체입니다. HotPDF의 내부 행렬 곱은 열벡터 규약을 쓰므로, A와 B의 곱은 B를 먼저 적용한 다음 A를 적용합니다. 올바른 합성은 CM을 글리프 공간에서 먼저, M을 나중에 적용합니다. 반대로 쓰면 M이 먼저 돌고 CM의 오프셋이 그 뒤에 더해지는데, 이미 디바이스 픽셀이고 스케일은 한 번도 적용되지 않은 채로요

// 개념 설명용: 열벡터 규약의 2D 아핀 레코드,
// Mul(A, B)는 점을 먼저 B로, 그다음 A로 사상
type
  TAffine = record
    a, b, c, d, e, f: Double;
  end;

function Mul(const A, B: TAffine): TAffine;
begin
  Result.a := A.a * B.a + A.c * B.b;
  Result.b := A.b * B.a + A.d * B.b;
  Result.c := A.a * B.c + A.c * B.d;
  Result.d := A.b * B.c + A.d * B.d;
  Result.e := A.a * B.e + A.c * B.f + A.e;
  Result.f := A.b * B.e + A.d * B.f + A.f;
end;

// M  : 폰트 유닛 -> 디바이스 픽셀 (작은 스케일, y 뒤집힘)
// CM : 컴포넌트 변환; e/f는 폰트 유닛의 dx/dy
Sub := Mul(M, CM);   // 올바름: 오프셋도 아웃라인과 함께 축소
// Sub := Mul(CM, M); // 틀림: dx/dy가 날 디바이스 픽셀로 착지

숫자는 일단 보면 실패를 뻔히 드러냅니다. Arial은 2048 유닛 em을 쓰고, é의 악센트 컴포넌트는 그 폰트 유닛으로 오프셋을 담습니다. 올바른 순서에서는 그 오프셋이 아웃라인과 함께 줄어 보통 줌에서 몇 픽셀이 됩니다. 뒤집힌 순서에서는 날 폰트 유닛 값이 디바이스 픽셀로 더해지고, HotPDF 테스트 렌더에서 Arial é의 악센트는 글자에서 243픽셀 떨어진 곳에, 글리프는 물론 보이는 것이라곤 아무것도 없는 바깥에 착지했습니다. Arial의 왼쪽 더블 쿼트는 작은따옴표 모양 두 개, 그중 두 번째가 밀린 형태이므로, 홀로 남은 ‘ 하나와 빈 공간으로 렌더링되었습니다

선형 부분은 버그를 좀처럼 드러내지 않습니다. 악센트 컴포넌트는 보통 스케일을 전혀 갖지 않고, 균등 스케일은 어차피 M과 교환 법칙이 성립하므로, 뒤집힌 곱을 드러내는 것은 이동뿐입니다. 평범한 라틴 텍스트로 하는 빠른 테스트가 통과하는 것도 같은 이유입니다: 뒤집힘은 v2.768.10이 고치기 전까지 여러 릴리스에 걸쳐 코드에 앉아 있었고, 오프셋을 가진 컴포넌트만 그것을 보여 줬습니다

재귀와 순환

컴포짓 글리프는 다른 컴포짓 글리프를 참조할 수 있으므로, 렌더러는 재귀적으로 훑으면서 자기 방어를 해야 합니다. HotPDF는 중첩 깊이를 5단계, 글리프당 총 컴포넌트 수를 1024로 제한하고, 자기 글리프 인덱스를 부르는 컴포넌트는 건너뛰며, 거의 안 쓰이는 포인트 매칭 모드(오프셋이 아니라 포인트 번호인 인수)는 0 오프셋으로 취급합니다. 적대적이거나 깨진 폰트가 컴포넌트 하나를 잃게 만들 수는 있어도, 페이지 렌더를 멈춰 세울 수는 없습니다

CFF endchar의 seac 형식이란 무엇인가요?

endchar의 seac 형식은 CFF Type 2 charstring이 "이 글리프는 베이스 글자에 악센트를 얹은 것"이라고 말하는 폐기 예정 방식입니다: endchar에 인수 네 개가 있으면 그것들은 adx ady bchar achar입니다. Adobe Technical Note #5177이 Type 1과의 호환성 부록에서 이를 기술합니다. 이름은 Type 1의 seac 연산자(standard encoding accented character)에서 왔고, Type 1 폰트에서 변환된 CFF 서브셋은 악센트 라틴 글자에 여전히 이것을 매우 많이 씁니다

구현자를 넘어뜨리는 디테일이 셋 있습니다. 첫째, bchar와 achar는 글리프 ID가 아닙니다. Adobe StandardEncoding의 코드이므로, 렌더러는 각 코드를 글리프 이름으로 사상한 다음 폰트의 charset에서 그 이름을 찾아 GID를 얻습니다. 둘째, Type 2에는 asb 인수가 없습니다. asb adx ady bchar achar을 받는 Type 1 seac와 다릅니다. Type 2에서는 악센트의 원점이 사이드베어링 보정 없이 베이스 글리프 원점 기준 (adx, ady)으로 곧장 갑니다. 셋째, CID 키드 CFF 폰트는 seac를 아예 쓰면 안 됩니다. StandardEncoding 코드를 풀 글리프 이름 charset이 없기 때문입니다

그다음은 폭입니다. 모든 Type 2 charstring은 첫 스택 비우기 연산자를 선택적 어드밴스 폭 인수로 시작할 수 있고, endchar도 그런 연산자 중 하나입니다. 인수 하나인 보통 endchar는 폭을 담고; 인수 다섯 개인 seac endchar는 폭 뒤에 네 개의 seac 값을 담습니다. 규칙은 인수를 기대되는 고정 개수, seac의 경우 4와 비교해 세는 것입니다. v2.768.11 이전 HotPDF는 모든 endchar를 "폭을 가져가고 멈춘다"로 취급했으므로, seac 글리프는 빈 아웃라인을 만들어 냈습니다: 베이스도 악센트도 없이, 줄 안에 크기만 정확한 빈틈이 남았습니다

// Type 2 인터프리터에서 endchar 판정의 개념도.
// 스택에는 endchar 앞의 오퍼랜드가 있고, Count는 그 개수.
if (Count >= 4) and not IsCIDFont then
begin
  // 5개 오퍼랜드: 첫 번째는 어드밴스 폭, 나머지는 seac
  if Count = 5 then
    TakeWidthAndShift;                 // 폭을 버리고 4개 오퍼랜드 유지
  if StackDepth = 4 then
  begin
    BaseGID   := GIDForName(StdEncName(Round(Stack[2])));  // bchar
    AccentGID := GIDForName(StdEncName(Round(Stack[3])));  // achar
    DrawGlyph(BaseGID, Identity);
    DrawGlyph(AccentGID, Translate(Stack[0], Stack[1]));   // adx, ady
    Exit;
  end;
end;
// 그 외: 보통 endchar, 선택적 폭, 패스 닫기

실제 인터프리터에서는 두 컴포넌트 글리프가 같은 charstring 엔진으로 실행되고, 악센트의 행렬은 현재 글리프 행렬에 (adx, ady)로의 이동을 합성한 것이며, 행렬은 그 뒤 복원되어 줄의 이후 글리프들은 영향받지 않습니다. 어느 쪽 이름이든 GID로 해석되지 않으면 글리프 전체를 실패시키는 대신 그 컴포넌트를 건너뜁니다. 서브셋이 조금 너무 공격적으로 잘려 있을 때 원하는 관대한 동작이죠

자신의 Delphi 코드에서 수정 확인하기

컴포짓 글리프와 seac 처리를 검증하는 가장 빠른 길은 임베디드 폰트의 악센트 텍스트가 있는 페이지를 공개 출력 경로 양쪽으로 렌더링하는 것입니다: 래스터 출력용 THotPDF.RenderLoadedPageToBitmap과 EmbedFonts를 True로 둔 벡터 출력용 THotPDF.BuildLoadedPageSVG입니다. 둘은 같은 글리프 아웃라인 코드를 공유하므로, v2.768.10과 v2.768.11 수정은 둘 다에 들어갔습니다. 파이프라인 전체는 HotPDF가 임베디드 TrueType, CFF, Type 3 글리프를 렌더링하는 방식에서, 비트맵 옵션은 Delphi에서 PDF 페이지를 비트맵으로 렌더링하기에서 다룹니다

uses
  System.Classes, Vcl.Graphics, HPDFDoc;

procedure RenderAccentProbe(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  Svg: AnsiString;
  Lines: TStringList;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;

    // 래스터 경로: 144 DPI면 어긋난 악센트를 찾기 쉽습니다
    Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
    if Bmp <> nil then
    try
      Bmp.SaveToFile('accent-probe.bmp');
    finally
      Bmp.Free;
    end;

    // 벡터 경로: EmbedFonts = True는 글리프 아웃라인을 기록하므로
    // 컴포짓과 seac 조립이 SVG 패스 자체에서 보입니다
    Svg := Pdf.BuildLoadedPageSVG(0, True);
    if Svg <> '' then
    begin
      Lines := TStringList.Create;
      try
        Lines.Text := string(Svg);
        Lines.SaveToFile('accent-probe.svg');
      finally
        Lines.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

테스트 문서는 신중하게 고르세요. é, Å, 컬리 더블 쿼트를 담은 임베디드 TrueType 서브셋 하나, 그리고 Type 1 폰트에서 만들어진 임베디드 CFF(FontFile3, Type1C) 서브셋 하나가 필요합니다. seac가 아직 사는 곳은 바로 후자로, 보통 구식 조판 출력과 일부 인쇄 워크플로에 있습니다. 생산자가 임베드하지 않은 폰트로 짜인 텍스트는 여기서 아무것도 알려 주지 않습니다. 그 글리프들은 ISO 32000-1 §9.9가 기술하는 임베디드 폰트 프로그램이 아니라 설치된 Windows 폰트에서 나오기 때문입니다

폰트 서브셋팅 중에 컴포짓 글리프는 왜 깨졌나요?

서브셋팅 중에 컴포짓 글리프가 깨진 이유는 컴포넌트가 스케일이나 2x2 변환을 가질 때마다 서브셋터가 잘못된 스트라이드로 컴포넌트 레코드를 건너뛰고, 그다음 컴포넌트의 글리프 인덱스를 잘못 읽었기 때문입니다. HotPDF는 v2.770.83에서 이를 고쳤습니다. 서브셋팅은 렌더링 버그의 쓰기 쪽 쌍둥이입니다: HotPDF가 설치된 TrueType 폰트의 서브셋을 임베드할 때는 컴포짓이 참조하는 모든 글리프를 남기고 그 참조들을 새로운 압축된 글리프 ID로 다시 번호 매겨야 합니다

옛 워커는 인수 크기 플래그와 more-components 플래그만 봤으므로 레코드당 6 또는 8바이트를 전진했습니다. 스케일 플래그가 더하는 2, 4, 8바이트짜리 변환 데이터는 무시했죠. 스케일된 컴포넌트 다음에 읽은 "글리프 인덱스"는 실제로는 변환 바이트였으므로, 진짜 컴포넌트가 서브셋에서 빠질 수 있고, 다시 번호 매기기 단계가 새 글리프 ID를 변환 데이터 안에 곧장 쓸 수도 있었습니다. 결과는 일부 뷰어에서 틀린 악센트, 일그러진 악센트, 또는 아예 없는 악센트로 그려지는 악센트 문자였고, 컴포넌트에 스케일을 거는 폰트에서만 그랬으니 대부분의 테스트가 끝내 못 잡은 것입니다

스트라이드 버그 옆에 문제가 둘 더 있었습니다. 글리프 인덱스 재번호 매기기는 멱등이 아니고, 중첩된 컴포짓이 두 번 재번호 매겨지고 있었습니다: 표시된 모든 컴포짓을 도는 패스로 한 번, 그 안으로의 재귀 하강으로 또 한 번. HotPDF는 이제 각 컴포짓을 정확히 한 번만 재번호 매기고 "이 컴포짓은 어떤 글리프가 필요한가" 단계를 작업 큐에서 구동합니다. 서로를 참조하는 컴포짓의 순환은 종료되고, 폰트의 maxp 글리프 수를 넘는 글리프 인덱스는 거부되며, 빈 글리프는 컴포짓으로 파싱되지 않습니다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'accents-subset.pdf';
    Pdf.FontEmbedding := True;          // 쓰이는 글리프를 임베드
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 14);
    // é, Å, ü와 컬리 쿼트는 Arial에서 모두 컴포짓 글리프
    Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
      'Café, Ångström, München, “quoted”');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

그 파일을 위의 프로브와 두 번째 뷰어로 왕복시켜 보세요. v2.770.83부터는 스케일이나 2x2 변환 뒤의 컴포넌트를 포함해 모든 컴포짓 글리프의 모든 컴포넌트가 서브셋팅을 살아남으므로, 악센트는 HotPDF, Acrobat, 브라우저 뷰어에서 똑같이 그려집니다

컴포짓 글리프 처리가 다루지 않는 것들

컴포짓 글리프 수정은 오프셋 기반 배치, 그러니까 거의 모든 실제 폰트가 쓰는 방식을 다루고, 나머지는 보수적으로 근사합니다. 두 인수가 오프셋이 아니라 맞출 포인트를 지정하는 TrueType 포인트 매칭 컴포짓은 0 오프셋으로 그려집니다. CID 키드 CFF 폰트의 seac는 사양이 거기서 금지하므로 설계상 무시됩니다. 잘린 서브셋에 베이스나 악센트 코드에 해당하는 글리프가 없는 seac는 찾을 수 있는 컴포넌트를 그립니다. 이 경우들 중 어느 것도 렌더러를 크래시시키지 않지만, 아카이빙이나 PDF/A 용도의 PDF를 만든다면 더 안전한 선택은 오프셋 컴포짓과 seac 없는 Type 2 charstring을 쓰는 폰트를 임베드하는 것입니다. 그래야 한 렌더러가 아니라 모든 소비자가 같은 글자를 보니까요

FAQ

내 PDF 렌더러에서 악센트 문자가 악센트 없이 그려지는 이유는 무엇인가요?

악센트 문자는 보통 컴포짓 글리프나 CFF seac 글리프이며, 악센트가 오프셋을 가진 유일한 컴포넌트입니다. 컴포넌트 오프셋을 디바이스 행렬 뒤에 적용하거나 endchar의 seac를 무시하는 렌더러는 베이스 글자가 정상적으로 그려지는 동안 악센트를 떨어뜨리거나 엉뚱한 곳에 놓습니다

HotPDF는 CFF endchar의 seac 형식을 지원하나요?

예. v2.768.11부터 HotPDF는 인수 네 개(폭을 포함하면 다섯 개)인 endchar를 베이스 글리프에 (adx, ady)로 밀린 악센트를 얹은 것으로 그리며, 둘 다 StandardEncoding 이름과 폰트 charset으로 해석합니다. 비트맵 렌더링과 폰트 임베드 SVG 익스포트 양쪽에서요

HotPDF가 TrueType 폰트를 서브셋할 때 컴포짓 글리프는 보존되나요?

예. v2.770.83부터 HotPDF는 2, 4, 8바이트 컴포넌트 변환을 올바르게 건너뛰고, 참조된 모든 컴포넌트를 서브셋에 남기고, 중첩 컴포짓을 한 번만 재번호 매기고, 순환하거나 범위 밖의 글리프 참조에서 안전하게 멈춥니다

컴포짓 글리프 렌더링, CFF seac 지원, 안전한 TrueType 서브셋팅은 HotPDF Delphi PDF Component의 일부로 제공됩니다. 외부 DLL 없이 PDF 문서를 생성, 편집, 추출, 렌더링하는 Delphi와 C++Builder용 네이티브 VCL 라이브러리죠