서브셋터가 방출된 코드 포인트에서 도달 가능한 글리프만 남길 때, 셰이핑된 글리프는 .notdef 박스로 렌더링됩니다. 델파이와 C++Builder용 네이티브 VCL PDF 컴포넌트인 HotPDF는 버전 2.435.0까지 정확히 이 결함을 가지고 있었습니다: OpenType GSUB 출력은 내부 사용 비트맵에 기록되었고, 서브셋터는 그 값을 존중하겠다고 선언해 놓고 실제로는 한 번도 읽지 않았습니다
이는 폰트 서브셋팅을 조용히 비활성화시켰던 EndDoc 버그와는 다른 종류의 실패입니다. 그 버그는 직렬화 대비 서브셋팅이 언제 실행되는가에 관한 문제였고, 서브셋팅 자체를 통째로 비활성화시켰습니다. 이번 버그는 서브셋팅이 일정대로 완벽하게 실행될 때 그 서브셋에 무엇이 담기는가에 관한 문제입니다. 파이프라인은 정확한 시점에 발동하고, ISO 32000-1 §9.6.4가 요구하는 대로 여섯 글자 서브셋 접두사가 /BaseFont에 정확히 나타나며, 파일은 작아지고, 모든 라틴 문자 페이지는 깨끗하게 교정되지만, 아랍어 페이지는 빈 사각형이 나란히 늘어선 모습으로 나옵니다. 순서 버그는 일단 들여다보면 요란하게 드러납니다. 클로저 버그는 영원히 조용합니다. 서브셋 자체는 구조적으로 유효하며, 오직 자신의 멤버십 목록만 틀렸기 때문입니다
셰이핑된 글리프가 .notdef로 렌더링되는 이유는 무엇인가
문서가 방출하는 코드 포인트 집합은 문서가 그리는 글리프 집합과 다르며, 이 둘을 혼동하는 서브셋터는 셰이핑이 만들어낸 모든 글리프를 버리기 때문입니다. 텍스트 셰이핑은 논리적 문자 시퀀스를 위치가 지정된 글리프 시퀀스로 바꾸며, 그 목적 전체가 어떤 단일 입력 문자도 매핑되지 않는 글리프를 만들어내는 데 있습니다: 아랍어 중간형 heh, fi 리가처, 데바나가리 접합자, rclt 기능으로 선택되는 문맥 대체 글리프 등이 그것입니다. 이들 각각은 GSUB 룩업이 제조한 글리프 ID이지, 문자열의 어떤 문자에 대해서도 cmap 테이블이 건네주는 값이 아닙니다. 따라서 순전히 cmap에 의해 구동되는 서브셋터는 잘못된 인덱스를 순회하고 있는 셈입니다. 셰이핑 이전에 텍스트가 사용할 수 있었던 모든 글리프는 충실히 보존하면서, 셰이핑 이후 텍스트가 실제로 사용하는 글리프는 정확히 버립니다. 그러면 렌더러는 임베드된 폰트에게 GID 1847을 요청하고, 서브셋은 loca의 해당 항목을 0으로 만들어 놓았으므로 대신 글리프 인덱스 0이 돌아옵니다. 글리프 인덱스 0은 OpenType 정의상 .notdef이며, 그래서 실패 신호가 잘못된 글자나 크래시가 아니라 빈 박스로 나타나는 것입니다. PDF의 그 무엇도 형식이 잘못되지 않았습니다. 단지 폰트가 콘텐츠 스트림이 요청한 글리프를 담고 있지 않을 뿐입니다
코드 포인트는 글리프가 아니다: 서브셋의 세 가지 소스
올바른 서브셋 클로저는 각각 자체 누산기를 가진 세 가지 독립적인 소스를 합집합으로 묶어야 합니다. 첫째는 코드 포인트에서 파생된 집합입니다. HotPDF는 BMP 문자가 방출될 때 FUnicodeUsedCps를, 서로게이트 쌍을 통해 도달하는 보조 평면 문자에 대해서는 FUnicodeSmpUsed를 누적한 다음, 각각을 FUnicodeCpToGid를 통해 글리프 ID로 매핑합니다. 둘째는 셰이핑에서 파생된 집합, 즉 GSUB 치환이 만들어낸 글리프 ID로, MarkUnicodeGlyphUsed와 EnableShapingFeatureForSubset를 통해 FUnicodeExtraUsedGlyphs에 기록됩니다. 셋째는 합성 클로저입니다. glyf에서 numberOfContours가 -1인 글리프는 구성 요소 글리프 ID들로부터 조립되며, 합성 글리프는 남기면서 그 구성 요소는 버리면 .notdef이 아니라 빈 윤곽선이 되어, 어쩌면 간격 버그처럼 읽히기 때문에 더 나쁠 수 있습니다
HotPDF는 첫 번째와 세 번째는 항상 처리해 왔습니다. 직렬화 이전에 EndDoc이 호출하는 서브셋팅 진입점인 BuildAndApplyUnicodeFontSubset은 사용된 글리프 배열을 GID 0으로 시드하고, BMP 코드 포인트를 순회하며, SMP 사용 목록을 순회한 다음, 내부적으로 합성 구성 요소를 해석하는 서브셋 빌더에 그 배열을 넘깁니다. 두 번째 소스는 작성만 되었을 뿐 한 번도 소비되지 않았고, 세 소스가 서로 다른 콘텐츠에서 실패하기 때문에, 회귀 테스트 코퍼스가 대부분 라틴 문자인 코드베이스에서 이 간극은 몇 년이고 숨어 있을 수 있습니다
작성되었지만 한 번도 읽히지 않은 배열
이 계약은 세 곳에 문서화되어 있었지만 그 어느 곳에서도 지켜지지 않았습니다. FUnicodeExtraUsedGlyphs의 선언부에는 EndDoc 서브셋터가 이를 코드 포인트에서 파생된 사용량과 합집합으로 묶는다고 명시되어 있었습니다. ApplyArabicGSUBRefinement의 헤더 주석은 방출되는 모든 대체 GID가 MarkUnicodeGlyphUsed를 거쳐 전달되어 서브셋터가 그 글리프를 임베드 폰트로 끌어온다고 약속했습니다. 같은 약속이 rclt 경로에 대해 ApplyArabicGSUBContextualRefinement에도 그대로 등장합니다. 두 호출자 모두 자신의 절반은 지켰습니다. 이 필드에 대한 모든 참조를 grep으로 훑어보니 나머지 절반은 약 90초 만에 결론이 났습니다: 선언 하나, RegisterUnicodeTTF 내부의 SetLength 할당 하나, 그리고 두 마킹 루틴에서의 쓰기. 읽기는 단 한 건도 없었습니다. 이것이 새겨둘 만한 진단 요령입니다. 폰트를 훨씬 넘어서까지 일반화되기 때문입니다. 어떤 필드가 여러 호출 지점에서 쓰이는데 어디서도 읽히지 않는다면, 그것이 아무리 꼼꼼하게 주석 달려 있어도 그 필드가 대표하는 기능은 존재하지 않는 것입니다. 서브셋터의 1단계는 한 화면에 다 읽힐 만큼 작으며, 무엇을 찾아야 하는지 알고 나면 그 간극은 뻔히 보입니다
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
루프 한 줄로 고치는 수정, 그리고 직접 글리프를 마킹하는 법
수정은 합집합이며, 그 안전성 논거는 연산의 방향에서 나옵니다: 비트를 세팅만 할 뿐 절대 지우지 않으므로, 예전에 서브셋에서 살아남던 글리프가 새로 버려지는 일은 일어날 수 없습니다
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
이 변경이 폰트 엔진 재작성이 아니라 저위험 수정인 이유는 세 가지 속성 때문입니다. 위에서 본 대로 단조적입니다. 셰이핑을 한 번도 하지 않은 폰트에서는 아무 동작도 하지 않습니다. FUnicodeExtraUsedGlyphs가 계속 모두 False로 남기 때문에 라틴 전용 문서의 바이트 출력은 변하지 않습니다. 그리고 이것이 2단계보다 먼저 적용되므로, 두 서브셋 빌더 모두 이를 이어받습니다: 원래의 GID 번호를 보존하는 희소 빌더와, PDF/A 하에서 HotPDF가 남겨진 글리프를 조밀한 범위로 재번호 매기고 maxp.numGlyphs를 줄이며 ISO 32000-1 §9.7.4.2가 요구하는 /CIDToGIDMap 스트림으로 이전-신규 매핑을 방출할 때 선택하는 컴팩트 빌더 _BuildCompactSubsetTTF입니다. 둘 다 내부적으로 _TTFWalkCompositeClosure를 호출하므로, 우연히 합성 글리프인 셰이핑된 글리프는 이제 그 구성 요소도 함께 끌어옵니다. 합성 클로저는 애초에 고장 난 적이 없었습니다. 단지 이 글리프 ID들에 대해서는 도달한 적이 없었을 뿐입니다. 그 글리프 ID들이 클로저가 순회하는 집합에 속해 있지 않았기 때문입니다. 내장된 정제 패스에 의존하지 않고 GSUB 엔진을 직접 구동한다면, 클로저는 여러분의 책임이 되며, 방출하는 모든 대체 글리프 ID는 EndDoc이 사용된 글리프 집합을 확정하기 전에 마킹되어야 합니다
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset은 단일 GID 호출에 대응하는 일괄 처리 버전이며, 의도적으로 보수적으로 설계되었습니다. 이 함수는 현재 선택된 스크립트와 언어 경로 아래 특정 4바이트 기능 태그에 연결된 룩업들에 대해 GSUB 룩업 목록을 순회하며, 그 룩업들이 만들어낼 수 있는 대체 글리프 ID들을 마킹합니다. 폰트에 GSUB 테이블이 없거나 해당 경로에 그 기능이 없을 때는 방어적으로 아무 동작도 하지 않으므로, 조건 없이 호출해도 안전합니다. 또한 의도적으로 과대 근사입니다. 특정 문서가 절대 그리지 않는 글리프도 남길 수 있습니다. 서브셋팅에서는 과다 포함이 바이트를 소모하고 과소 포함이 정확성을 해치므로, 이 트레이드오프는 쉬운 선택입니다. 이러한 룩업의 구조와 어떤 글리프가 참여할지 결정하는 커버리지 테이블은 순수 델파이로 구현한 GSUB 스타일리스틱 대체 글리프 안내서에서 다룹니다
글리프가 실제로 서브셋에 포함되었는지 어떻게 증명하는가
뷰어의 눈짐작이 아니라 방출된 폰트를 직접 읽어서 확인합니다. 뷰어는 여러분 몰래 시스템 폰트로 대체하고 있을 수도 있습니다. 이 부류의 버그 전체를 잡아내는 검사는 기계적입니다: 출력 PDF에서 /FontFile2 스트림을 추출하고, loca를 파싱한 다음, 기대하는 글리프 ID가 비어 있지 않은 항목을 갖고 있는지, 즉 시작 오프셋과 끝 오프셋이 서로 다른지 확인합니다. 빈 항목은 서브셋터가 그 글리프를 미사용으로 판단했다는 뜻입니다. 그런 다음 두 가지 습관이 이 실패를 다시 배포하기 훨씬 어렵게 만듭니다. 셰이핑된 스크립트 페이지를 수동 교정 세트뿐 아니라 자동화된 스모크 코퍼스에도 유지하는 것입니다. 아랍어, 데바나가리, 크메르어는 아무리 많은 라틴 문자 커버리지로도 건드릴 수 없는 클로저 경로를 운동시키기 때문입니다. 그리고 누산기가 존재할 때마다 무언가가 그것을 소비하는지 단언하는 것입니다. 쓰기만 되고 읽히지 않는 필드는 컴파일은 되고 잘못된 코퍼스에서는 테스트가 통과하며 아무 일도 하지 않는 기능이기 때문입니다
이 수정이 멈추는 지점
서브셋 클로저는 셰이핑된 글리프가 렌더링되기 위한 필요조건이지 충분조건은 아닙니다. 글리프는 콘텐츠 스트림에서 주소로 지정될 수도 있어야 하며, 이는 자신만의 경계를 가진 별개의 문제입니다. HotPDF 내장 아랍어 정제 패스는 모든 대체 글리프 ID가 U+FB50에서 U+FDFF까지, 그리고 U+FE70에서 U+FEFF까지 약 690개 코드 포인트에 걸친 역방향 cmap 스캔을 통해 유니코드 표현형 코드 포인트로 도달 가능할 때만 치환을 커밋합니다. 대체 글리프가 그 범위 밖의 글리프 ID에 착지하면, 입력 윈도우는 리더가 주소를 지정할 수 없는 무언가를 방출하는 대신 변경 없이 그대로 통과합니다. 임의의 글리프 ID에 있는 폰트별 대체 글리프는 방출 경로를 통과시키기 위해 U+E000에서 U+F8FF 사이에 할당된 합성 사용자 정의 영역 코드 포인트가 필요합니다. 그러므로 솔직한 요약은, 2.435.0 수정이 이야기를 완성한 것이 아니라 강력한 차단 요소 하나를 제거했다는 것입니다. 그 이전에는 글리프가 올바르게 셰이핑되고 올바르게 방출되고도 서브셋 시점에 사라질 수 있었고, 이는 셰이핑 엔진의 룩업이 아무리 훌륭해도 처음부터 끝까지 신뢰할 수 없었다는 뜻입니다. 남은 것은 주소 지정 가능성이며, 적어도 그 제약은 여러분이 지켜보는 모든 과정이 끝난 뒤 빌드 단계에서 조용히가 아니라, 방출 시점에 눈에 보이게 실패합니다. 같은 파이프라인의 방출 쪽에 관해서는 델파이 PDF에서의 아랍어 및 RTL 텍스트 셰이핑 가이드를 참고하십시오
여기서 설명한 폰트 서브셋팅, GSUB 엔진, 복합 스크립트 셰이핑은 델파이와 C++Builder용 표준 HotPDF Component에 포함되어 제공됩니다. 위에서 언급한 유니코드 폰트와 셰이핑 호출에 대한 전체 API 레퍼런스는 제품 페이지에서 확인할 수 있습니다