Delphi용 PDFium Component는 TPdf.AddText가 쓰는 시스템 폰트를 Unicode 코드포인트를 키로 하는 CID 폰트로 임베드합니다. 모든 CID가 정확히 하나의 ToUnicode 매핑을 지니게 됩니다. 그것이 추출된 공백이 U+00A0(no-break space)으로, 하이픈이 U+00AD(soft hyphen)으로 돌아오는 일을 막습니다. 살아 있는 문서에서도, 저장된 파일에서도요
증상은 보이지 않기 때문에 지독합니다. 검색 인덱스는 저장된 문자열에 soft hyphen이 들어 있어 "two-x"를 놓치고, CSV 내보내기는 다르게 쪼개지고, diff 도구는 모든 뷰어에서 똑같아 보이는 줄을 지적합니다. 렌더된 페이지에는 틀린 게 하나도 없습니다. 글리프 뒤의 Unicode만 틀렸을 뿐입니다
추출된 공백은 왜 U+00A0으로 돌아올까요?
추출된 공백이 U+00A0으로 변하는 이유는 PDFium이 FPDFText_LoadFont에서 생성하는 ToUnicode CMap이 글리프를 키로 하기 때문입니다. 하나의 글리프에 두 코드포인트가 닿을 수 있습니다. Arial에서 글리프 3은 U+0020과 U+00A0을 둘 다 맡고, 하이픈 글리프는 U+002D와 U+00AD를 둘 다 맡습니다. 생성된 CMap은 따라서 같은 CID를 두 번 매핑합니다. 한 번은 bfchar 항목으로, 한 번은 배열 형식 bfrange로요. 리더의 우선순위 규칙이 우선시하는 항목이 추출되는 텍스트가 됩니다
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
오랫동안 이 모순은 무해했습니다. PDFium 리더가 가장 낮은 매핑에 이기게 두었으니까요. 상류 변경이 리더를 last-wins로 바꾸면서, 그 빌드부터 AddText로 쓴 모든 공백은 NBSP로, 모든 하이픈은 soft hyphen으로 추출됐습니다. 쌍의 패턴에 주목하세요. 0x20/0xA0과 0x2D/0xAD는 최상위 비트만 다른데, cmap이 Latin-1 닮은꼴을 같은 아웃라인으로 보내는 폰트라면 정확히 그렇게 기대할 모습입니다. 추출 코드가 어제까지 멀쩡했다가 지금은 보이지 않는 문자에서 실패한다면, 디버거 뷰를 믿지 말고 코드포인트를 덤프하세요. 텍스트를 뽑아내는 기본은 Delphi에서 PDFium으로 PDF 문서 텍스트 추출하기에서 다룹니다
uses
SysUtils, PDFium;
const
// Space/U+00A0과 hyphen/U+00AD는 하나의 Arial 글리프를 공유하며
// 그리스 Omega(U+03A9)와 Ohm 기호(U+2126)도 그렇다
Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;
function CodePoints(const S: WString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;
var
Pdf: TPdf;
Live, Reloaded: WString;
begin
Pdf := TPdf.Create(nil);
try
Pdf.CreateDocument;
Pdf.AddPage(1, 595, 842);
Pdf.AddText(Sample, 'Arial', 12, 72, 770);
Live := Pdf.Text; // 살아 있는, 저장되지 않은 문서
Pdf.SaveAs('codepoints.pdf');
finally
Pdf.Free;
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'codepoints.pdf';
Pdf.Active := True;
Pdf.PageNumber := 1;
Reloaded := Pdf.Text; // 저장 후 다시 로드한 뒤
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
저장 후 CMap을 고치는 것으로는 부족했던 이유
저장된 파일을 고치는 것은 그 파일만 고칩니다. 게다가 패치가 CMap 구조를 바이트 단위로 온전히 유지할 때에만요. 첫 번째 수정인 FPdfCompress 유닛의 RepairSubsetToUnicodeCMaps는 비 증분 TPdf.SaveAs마다 뒤따라 돌며 충돌하는 CID 각각을 해소합니다. bfchar 항목이 이기고, 최상위 비트만 다른 쌍은 더 작은 base-Latin 코드포인트로 정해지며, 그 외는 첫 매핑을 유지합니다
흥미로운 건 부정적 결과입니다. 충돌하는 CMap을 start-code든 배열 형식이든 깔끔하게 다시 만드는 게 당연한 수처럼 보였지만, PDFium은 다시 만든 CMap을 전부 그대로 거부하고 Identity로 폴백했습니다. 네이티브 리더가 받아들인 유일한 출력은 충돌하는 16진수 값의 동일 길이 제자리 교체, 즉 블록 레이아웃과 CID 커버리지를 그대로 둔 교체였습니다. 두 번째 교훈은 더 겸손한 것이었습니다. 당시 노트는 메모리상 사례를 살아 있는 문서에 ToUnicode 스트림이 아예 없는 탓으로 돌렸습니다. DLL을 직접 호출해 보니 그 반대였습니다. 살아 있는 문서도 같은 애매한 스트림을 지니고 있었고, 그렇다면 진짜 수정은 PDFium이 CMap을 생성하기 전에 일어나야 했습니다. 복구 루틴은 다른 PDFium 기반 도구가 만든 PDF를 막아 주는 방어로 라이브러리에 남습니다
uses
Classes, FPdfCompress;
var
Source, Dest: TFileStream;
begin
Source := TFileStream.Create('from-other-tool.pdf',
fmOpenRead or fmShareDenyWrite);
try
Dest := TFileStream.Create('repaired.pdf', fmCreate);
try
// 동일 길이 편집만 수행; 복구 가능한 충돌이 없는 파일과
// 크로스 레퍼런스 스트림 또는 객체 스트림 파일은 그대로 복사됨
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
글리프 대신 코드포인트로 폰트 키 묶기
근본 수정은 PDFium에게 CMap 생성을 아예 부탁하지 않는 것입니다. TPdf.LoadCachedFont는 이제 시스템 폰트 바이트를 TPdf.LoadUnicodeKeyedCidFont로 넘기고, 이것은 폰트 자체의 sfnt cmap 테이블을 읽으며 format 12 서브테이블을 선호하고 format 4로 폴백합니다. 코드포인트는 정렬되고 중복 제거된 채 돌아오고, k번째 코드포인트에 CID k+1이 배정되며 CID 0은 .notdef로 남습니다. 명시적인 CIDToGIDMap이 각 CID를 자기 글리프로 보내므로, U+0020과 U+00A0은 같은 아웃라인을 그리는 서로 다른 두 CID를 얻고, ToUnicode CMap은 각 CID를 하나의 코드포인트에만 매핑합니다. 폰트는 이어서 FPDFText_LoadCidType2Font로 로드되는데, 명시적 CID-to-GID 맵을 곁들인 CID Type 2 폰트 임베딩의 글리프 수준 쓰기 뒤에 있는 같은 진입점입니다
// TPdf.LoadUnicodeKeyedCidFont에서 축약
SetLength(CidToGidMap, (Length(Entries) + 1) * 2); // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
CidToGidMap[(I + 1) * 2] := Byte(Entries[I].GlyphID shr 8);
CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries); // 하나의 CID, 하나의 코드포인트
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
나중에 FPDFText_SetText가 문자열을 쓸 때 역방향 조회는 문자마다 단 하나의 CID에 떨어지므로, NBSP와 soft hyphen, Ohm 기호는 어느 우선순위 규칙 아래에서도 자기 모습 그대로 살아남습니다. 메모리에서도, 어떤 저장 후에도요. 저장된 파일은 엔진이 생성한 스트림이 아니라 컴포넌트 자체의 ToUnicode 스트림을 지니므로, RepairSubsetToUnicodeCMaps는 거기서 고칠 것을 찾지 못합니다
bfrange 항목 하나가 왜 블록 전체를 지워버릴까요?
CID 구간이 xxFF 경계를 가로지르는 bfrange 하나가 PDFium으로 하여금 그것이 앉아 있는 블록 전체를 버리게 만듭니다. ISO 32000-1 §9.10.3은 목적지의 마지막 바이트만 범위 안에서 변할 수 있게 하지만, CID 쪽에는 자체 함정이 있습니다. PDFium의 HandleBeginBFRange는 상위 CID를 (low and $FFFFFF00) or (high and $FF)로 유도합니다. CID 00FE부터 0101까지의 구간은 따라서 00FE부터 0001로, 즉 low가 high보다 큰 것으로 읽히고 블록 전체가 무효 표시됩니다. 실패는 조용합니다. SetText는 성공하고 페이지는 완벽히 렌더되는데, 추출은 그 블록의 모든 문자에 대해 U+0000을 돌려줍니다
BuildUnicodeKeyedCidCMap은 코드포인트든 CID든 낮은 바이트가 FF에 닿기 전에 구간을 끝내고, 모든 블록을 CMap 문법의 100 항목 한도 안에 유지하며, 보조 평면 코드포인트는 UTF-16 서러게이트 쌍 목적지를 지닌 개별 bfchar 항목으로 씁니다. 범위 안에서 서러게이트 쌍을 증가시키는 것은 정의된 의미가 없기 때문입니다. 그 이야기의 서러게이트 쪽은 Delphi의 emoji, CJK, 서러게이트 쌍 처리에 있습니다. bfchar 전용 CMap은 크기가 몇 배로 뛰는 대가로 경계 문제를 완전히 우회할 것입니다
코드포인트 키 폰트가 커버하지 못하는 것은?
코드포인트 키 경로는 Unicode cmap 서브테이블을 드러내는 모든 폰트를 커버하고, 나머지는 예전 글리프 키 동작으로 폴백합니다. 이것에 의지하기 전에 알아 둘 경계들입니다:
- (3,0) cmap만 지닌 Symbol 폰트와 CID 경로가 로드에 실패한 폰트는 예전처럼
FPDFText_LoadFont를 거칩니다. 두 코드포인트가 공유하는 글리프는 거기서 여전히 애매하게 추출될 수 있습니다 - format 12 서브테이블이 없으면 맵은 BMP로 제한되고, 항목 수는 65535로 갑힙니다. 모든 CID가 0 위의 두 바이트에 들어맞도록 하기 위해서입니다
- 증분 저장(
saIncremental)은 설계상RepairSubsetToUnicodeCMaps를 건너뜁니다. 증분 리비전은 append-only여야 하기 때문입니다. 코드포인트 키 폰트는 컴포넌트가 스스로 쓰는 텍스트에 한해 그 문제를 무의미하게 만듭니다 - TrueType Collection은 각별한 주의가 필요합니다. GDI
GetFontData는 .ttc 전체를 돌려주고FPDFText_LoadCidType2Font에는 face index 파라미터가 없어서, 예전에는 simsun.ttc에서 NSimSun을 요청하면 SimSun(face 0)이 임베드되고 렌더됐습니다. 컴포넌트는 이제 패밀리 이름을 name 테이블(nameID 1과 16)과 대조하고 cmap을 파싱하기 전에 요청된 face를 독립 sfnt로 뽑아냅니다. 파싱이 실패하면 컬렉션 바이트가 그대로 통과하고 동작은 face 0으로 되돌아갑니다
텍스트 쓰기, 폰트 임베딩, 추출은 Delphi와 C++Builder, Lazarus에서 하나의 페이지 모델을 공유하며, 전체 API는 Delphi용 PDFium Component 제품 페이지에 기술되어 있습니다