기술 문서

Delphi에서 fontsub.dll을 이용한 TrueType 폰트 서브셋팅

Delphi와 C++Builder용 Excel 컴포넌트인 HotXLS는 TrueType 폰트 서브셋팅을 통해 임베디드 PDF 폰트 크기를 줄인다: PDF 내보내기 시점에 Windows 시스템 라이브러리 fontsub.dll의 CreateFontPackage 함수를 호출해, 전체 서체 파일을 그대로 담는 대신 워크시트가 실제로 사용한 유니코드 코드 포인트만으로 임베디드 TrueType 폰트를 다시 만든다. 중국어 제품명이 200행 있는 보고서라도 서로 다른 한자는 몇백 자 정도면 충분하지만, Windows가 제공하는 CJK 폰트는 통상 개당 5~20MB에 이른다. 이를 통째로 임베드하면 폰트 하나만으로 PDF 안의 다른 모든 객체를 합친 것보다 무거워질 수 있다

fontsub.dll은 대부분의 Delphi 개발자가 들어본 적 없는 라이브러리이며 여기에는 이유가 있다: Microsoft는 이를 주요 Win32 API가 아니라 작고 문서화가 드문 유틸리티 DLL로 제공한다. HotXLS는 이를 필수 의존성이 아니라 선택적 기능으로 취급하므로, 내보내기 코드가 이를 어떻게 로드하고 호출하고 없을 때 어떻게 대체하는지는 폰트 형식만큼이나 방어적인 Windows 프로그래밍에 대해서도 많은 것을 말해주며, 두 이야기 모두 짚어볼 가치가 있다

유니코드 텍스트는 왜 HotXLS PDF 내보내기를 부풀리는가?

HotXLS의 PDF 내보내기는 워크시트 텍스트가 WinAnsi 범위를 벗어날 때만 임베디드 TrueType 폰트를 사용하고, 그 외에는 워크시트-PDF 내보내기 안내에서 자세히 다루는 기본 경로인 내장 Helvetica 계열을 유지한다. WinAnsi는 서유럽 텍스트를 충분히 잘 커버하므로 상당수 워크북은 폰트 임베드를 전혀 유발하지 않는다: PDF는 그냥 이름으로 Helvetica를 참조하고 리더가 로컬에서 이를 공급하므로 파일은 작게 유지된다. 셀 하나가 WinAnsi로 표현할 수 없는 무언가 — 중국어 제품명, 한국어 메모, 코멘트 속 낯선 기호 — 를 담는 순간, 내보내기는 실제 폰트 프로그램을 임베드해야 하는데, PDF 리더는 표준 14개 폰트 밖의 문자에 대해 대체 글리프 소스를 갖고 있지 않기 때문이다

HotXLS는 내보내기의 UnicodeFontFile 속성이 이미 특정 파일을 가리키고 있지 않는 한, Windows Fonts 폴더를 스캔해 설치된 후보군 목록을 찾음으로써 그 폰트를 자동으로 찾아낸다. 여기에는 Windows가 중국어와 한국어 렌더링용으로 제공하는 CJK 가능 서체도 포함되며, 어떤 폰트에 안착하든 서브셋팅이 실행되기 전에 통째로 임베드된다. 이 임베드 요구사항은 PDF에만 해당한다: HotXLS의 RTF 및 HTML 내보내기 경로는 폰트 프로그램을 보내는 대신 코드 포인트를 바이트 스트림으로 이스케이프해 유니코드 텍스트를 온전히 유지하므로, 이 글이 다루는 크기 문제는 그 두 형식에는 해당되지 않는다

uses
  lxHandle, lxPDF;

var
  Book: TXLSWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('catalog-cn.xlsx');
    Exporter := TXLSPDFExport.Create;
    try
      // Optional: pin a specific CJK-capable font instead of the
      // exporter's automatic Windows\Fonts scan.
      Exporter.UnicodeFontFile := 'C:\Windows\Fonts\simhei.ttf';
      Exporter.SaveAsPDF(Book.ActiveSheet, 'catalog-cn.pdf');
    finally
      Exporter.Free;
    end;
  finally
    Book.Free;
  end;
end;

fontsub.dll이란 무엇이고, 왜 서브셋터를 처음부터 직접 작성하지 않는가?

fontsub.dll은 Windows XP 시절부터 제공되어 온 작은 Windows 시스템 라이브러리로, 여기서 관련 있는 함수 하나 CreateFontPackage를 노출한다. 원본 TrueType 폰트의 바이트와 유지할 유니코드 코드 포인트 목록을 넘기면, 폰트 형식의 모든 제약을 여전히 만족하는 최소 폰트를 돌려준다: 글리프 인덱스는 재번호되고, glyfloca는 유지된 윤곽선만으로 재구성되며, hmtxcmap은 그에 맞춰 다시 작성된다. HotXLS는 이 계약에 맞춰 함수 포인터 타입을 직접 선언한다

const
  TTFCFP_FLAGS_SUBSET = 1;
  TTFMFP_SUBSET = 0;
  TTFCFP_MS_PLATFORMID = 3;
  TTFCFP_UNICODE_CHAR_SET = 1;

type
  TCreateFontPackage = function(puchSrcBuffer: Pointer; ulSrcBufferSize: Cardinal;
    var puchFontPackageBuffer: PAnsiChar; var pulFontPackageBufferSize: Cardinal;
    var pulBytesWritten: Cardinal; usFlags, usTTCIndex, usSubsetFormat,
    usSubsetLanguage, usSubsetPlatform, usSubsetEncoding: Word;
    pusSubsetKeepList: PWordArray; usSubsetKeepListCount: Word;
    lpfnAllocate, lpfnReAllocate, lpfnFree, reserved: Pointer): Cardinal; cdecl;

CreateFontPackage가 하는 일을 호출하는 대신 손으로 구현한다는 것은 올바른 TrueType 서브셋터를 구현한다는 뜻이 된다: 유지되는 글리프가 참조하는 모든 구성요소 글리프를 끌어오기 위해 복합 글리프를 순회하고, 윤곽선이 제거된 뒤 loca 오프셋을 재구성하며, 폰트의 OS/2 테이블 안에 있는 임베딩 허용 비트를 존중하고, 고객의 컴퓨터에 우연히 설치되어 있을 온갖 특이한 폰트들에 대해서도 이 모든 것을 정확히 처리해야 한다. Microsoft는 이미 이 문제를 해결했고 그 해법을 Windows 자체의 일부로 제공하므로, Microsoft가 유지 관리하고 자사의 폰트 렌더링 스택을 상대로 테스트하며 모든 컴퓨터에 무료로 배포하는 시스템 DLL을 호출하는 데는 동적 로드 하나와 함수 포인터 하나면 충분하다. 같은 로직을 재구현한다는 것은 폰트가 우연히 크기만 할 때 문제가 되는 기능을 위해, 수십 년에 걸친 예외 사례가 쌓인 이진 형식의 파서를 직접 소유한다는 뜻이 된다

실제로 렌더링된 글리프에서 유지 목록 만들기

HotXLS는 다른 이유로 이미 유지하고 있던 맵으로부터 서브셋팅 유지 목록을 만들어서, 이 회계 작업에 추가 비용이 전혀 들지 않는다. 페이지 렌더링 코드가 임베디드 유니코드 폰트를 필요로 하는 문자를 그릴 때마다, 그 문자의 글리프 인덱스를 찾아 FUnicodeGlyphMap에 그 쌍을 기록한다. 이는 글리프-코드포인트 테이블로, 완성된 문서에서 복사·붙여넣기를 하면 원시 글리프 ID 대신 원본 텍스트가 돌아오도록 PDF ToUnicode CMap도 함께 구동한다. 페이지 콘텐츠 스트림이 완성될 무렵이면 그 맵은 이미 문서가 사용한 유니코드 코드 포인트 집합을 정확히, 더도 덜도 아니게 나열하고 있다

var
  keepList: array of Word;
  keepCount, i: Integer;
  codePoint: LongWord;
begin
  SetLength(keepList, FUnicodeGlyphMap.Count);
  keepCount := 0;
  for i := 0 to FUnicodeGlyphMap.Count - 1 do
  begin
    codePoint := LongWord(StrToIntDef('$' + FUnicodeGlyphMap.ValueFromIndex[i], 0));
    if codePoint > 0 then
    begin
      keepList[keepCount] := Word(codePoint);
      Inc(keepCount);
    end;
  end;
end;

완료 시점에 HotXLS는 그 같은 맵을 두 번째로 순회해 CreateFontPackage가 기대하는 유지 목록을 만든다. 이는 API의 유지 목록 인자가 요구하는 16비트 형식으로 된, 유지할 유니코드 코드 포인트의 단순 배열이다. 이 인자가 16비트 워드의 배열이기 때문에 기본 다국어 평면(BMP)은 깔끔하게 처리되는데, 이는 일반적인 CJK, 키릴 문자, 그리스 문자, 아랍 문자 텍스트를 아무 문제 없이 커버한다. 보조 평면 문자, 특정 이모지나 희귀한 고대 문자에 크게 의존하는 워크시트는 유지 목록 항목 하나가 직접 이름 붙일 수 있는 범위 밖에 있는데, 이는 결함이라기보다 알아둘 가치가 있는 경계다. 유니코드를 많이 쓰는 업무용 스프레드시트 대다수는 애초에 그 평면 근처에 갈 일이 없기 때문이다

fontsub.dll이 없으면 어떻게 되는가?

HotXLS는 fontsub.dll이 존재한다고 절대 가정하지 않으며, PDF 내보내기는 그것이 없다고 해서 절대 실패하지 않는다. 이 라이브러리는 서브셋이 필요한 시점에 정적 임포트가 아니라 SafeLoadLibraryGetProcAddress로 동적으로 로드되는데, 이는 fontsub.dll이 kernel32.dll처럼 문서화되고 존재가 보장된 공개 API가 아니기 때문이다: 이는 번들로 제공되는 폰트 임베딩 도구이며, Microsoft의 계약 그 어디에도 모든 SKU, 모든 서비스 브랜치, Windows를 에뮬레이트하려는 모든 호환성 레이어에서 살아남는다는 약속은 없다

var
  hFontSub: HMODULE;
  CreateFontPackage: TCreateFontPackage;
begin
  hFontSub := SafeLoadLibrary('FontSub.dll');
  if hFontSub = 0 then
    Exit; // no subsetting available - keep the full embedded font
  try
    @CreateFontPackage := GetProcAddress(hFontSub, 'CreateFontPackage');
    if not Assigned(CreateFontPackage) then
      Exit;
    // ... call CreateFontPackage, check its return code ...
  finally
    FreeLibrary(hFontSub);
  end;
end;

모든 실패 경로는 같은 결과로 되돌아간다. DLL이 없거나, export가 없거나, 반환 코드가 0이 아니거나, 폰트의 OS/2 테이블이 임베딩 허용 비트를 통해 서브셋팅을 금지하는 경우 모두, HotXLS는 그저 이미 임베드해 둔 전체 폰트를 그대로 유지하고 계속 진행한다. 아무것도 예외를 던지지 않고, 내보내기가 중단되지도 않으며, 호출 코드는 폰트 최적화를 자신만의 예외 처리로 감쌀 필요가 전혀 없다. 내보내진 PDF는 어느 쪽이든 유효하며, 유일한 변수는 결과가 작아지는지 아니면 다소 커지는지 뿐이다

PDF는 실제로 얼마나 작아지는가?

HotXLS의 TrueType 폰트 서브셋팅은 유니코드를 많이 쓰는 워크시트의 내보낸 PDF를 서브셋팅하지 않았을 때 크기의 대략 20분의 1에서 8분의 1 사이, 즉 8배에서 20배 줄이는데, 그 규모는 주어진 문서가 전체 폰트 중 실제로 얼마나 건드리는지에 따라 달라진다: 몇백 개의 서로 다른 한자로 이루어진 발주서는 CJK 서체가 제공하는 수만 개 글리프 중 그 몇백 개만 유지하는 반면, 더 폭넓은 문자 조합을 아우르는 시트는 그에 비례해 더 많이 유지한다. HotXLS는 서브셋 폰트 바이트를 PDF의 /FontFile2 스트림에 쓰기 전에 추가로 Flate 압축 패스를 한 겹 더 씌우는데, 이는 문서의 나머지 콘텐츠 스트림이 이미 거치는 것과 동일한 압축이다. 이 중 어느 것도 호출 코드에 추가 작업을 요구하지 않는다: WinAnsi를 벗어나지 않는 워크시트는 이 경로를 전혀 건드리지 않고 순수한 Helvetica로 계속 내보내지며, 유니코드 폰트 경로를 실제로 유발하는 워크시트는 설정할 속성도 별도로 호출할 것도 없이 자동으로 서브셋팅을 받는다. 여기에 관여하는 단 하나의 속성인 UnicodeFontFile은 서브셋팅이 일어나는지 여부가 아니라 어떤 폰트가 임베드되고 서브셋팅되는지만 선택한다

폰트 서브셋팅은 HotXLS Delphi Excel 컴포넌트의 더 넓은 PDF 내보내기 표면 안에 있는 한 세부사항이며, 페이지네이션, 워크시트 인쇄 메타데이터, 그리고 함께 제공되는 CSV·HTML·RTF 내보내기 경로와 나란히 자리한다