기술 문서

PDFium의 이모지·CJK 문자가 Delphi WideChar를 깨뜨리는 이유

PDF에서 이모지나 일본식 호적 이름을 텍스트로 뽑아내면, 출력물에는 그 문자가 있어야 할 자리에 네모 상자나 물음표, 혹은 아무것도 나오지 않는다. PDFium 컴포넌트의 Character[] 속성이 보통 그 원인이다: 이는 각 글리프를 부호 없는 32비트 값을 반환하는 FPDFText_GetUnicode를 통해 읽은 뒤, 이를 Delphi에 단일 16비트 WideChar로 노출한다. U+FFFF를 넘는 어떤 코드 포인트도 이 여정을 한 조각으로 마칠 수 없으며, 이 손상은 렌더링된 페이지를 보고 있는 동안에는 절대 드러나지 않는다. 렌더링과 텍스트 추출이 PDFium 안에서 서로 다른 코드 경로를 거치기 때문이다 — 문서는 이모지를 완벽하게 표시하면서도, 루프 안에서 Character[]를 읽어 문자열을 만드는 순간 쓰레기 값을 건네줄 수 있다

기본 다국어 평면과 WideChar가 U+FFFF에서 멈추는 이유

Delphi의 WideChar는 UTF-16 코드 유닛 하나만 담을 수 있는 16비트 타입이다. 유니코드의 기본 다국어 평면(Basic Multilingual Plane), 즉 U+0000부터 U+FFFF까지의 범위는 정확히 그 안에 들어맞으며, 이것이 라틴, 키릴, 그리스 문자와 일반적인 CJK 통합 한자 블록이 모두 아무 문제 없이 단일 WideChar로 왕복하는 이유다. 실제 문서에서 예사로 그 범위를 벗어나는 두 부류의 문자가 있다: U+1F600부터 시작하는 Emoticons 블록에 속한 다수를 포함한 이모지, 그리고 흔치 않은 중국·일본·한국 문자를 위해 예약된 U+20000부터 U+2A6DF 범위인 CJK 통합 한자 확장 B에 속한 희귀 한자로, 여기에는 많은 인명과 지명이 포함된다. UTF-16은 U+FFFF를 넘는 모든 것을 서로게이트 쌍으로 처리한다 — $D800부터 $DBFF 범위의 상위 서로게이트와 그 뒤를 따르는 $DC00부터 $DFFF 범위의 하위 서로게이트, 이 두 개의 16비트 코드 유닛이 함께 하나의 코드 포인트를 인코딩한다 — 그리고 그 쌍을 만드는 계산은 파스칼로 직접 보여줄 수 있을 만큼 고정되어 있다

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

웃는 얼굴 이모지인 U+1F600을 이 함수에 넣으면 결과는 상위 서로게이트 $D83D와 하위 서로게이트 $DE00, 즉 하나가 아니라 두 개의 16비트 값이다. 어느 절반도 그 자체로는 아무 의미가 없다; 뒤에 $DE00 없이 문자열 안에 홀로 놓인 $D83D는 매달린 서로게이트이며, 이를 만나는 대부분의 텍스트 처리 코드는 이를 버리거나, 대체 글리프로 치환하거나, 오류를 일으킨다

FPDFText_GetUnicode는 왜 Character[]가 담을 수 없는 값을 반환하는가?

FPDFText_GetUnicodeLongWord, 즉 완전한 32비트 값을 반환하는데, PDF 텍스트 인코딩이 이미 모든 글리프에 대해 완전한 유니코드 스칼라 값을 담고 있기 때문이다. PDF의 ToUnicode CMap은 문자 코드를 유니코드 텍스트로 매핑하며, 글리프가 비공식적으로 "성간 평면(astral-plane)" 문자 — 기본 다국어 평면을 넘어서는 모든 것 — 라고 불리는 것을 나타낼 때, 그 매핑은 16비트 조각이 아니라 완전한 코드 포인트다. PDFium은 내부적으로 이를 스칼라 값으로 다시 디코딩해 FPDFText_GetUnicode를 통해 DLL 경계 너머로 반환하며, 32비트 값이 Delphi 속성이 여러분의 코드로 돌려줄 수 있는 무언가가 되어야 하는 지점이 정확히 그 경계다

명백해 보이는 구현은 WideChar(FPDFText_GetUnicode(TextPage, Index))이며, 이는 또한 잘못된 구현이다. 32비트 값에서 16비트 타입으로의 강제 캐스트는 하위 16비트만 유지하고 나머지는 예외도 범위 검사도 없이 조용히 버린다. U+1F600의 경우 이는 $F600을 유지하고 진짜 값이 U+FFFF를 넘었다는 사실을 잃어버려, 유효한 매달린 서로게이트조차 아닌, 그저 하위 비트를 우연히 공유하는 무관한 기본 다국어 평면 문자를 만들어낸다. 이런 것 수천 개를 문자열로 이어붙이면, 다운스트림 코드에는 손상된 문자와 정당한 문자를 구별할 방법이 더 이상 남지 않는다

Character[]와 Charcode[]는 이제 성간 평면 코드 포인트에 대해 무엇을 반환하는가

PDFium 컴포넌트의 Character[]Charcode[] 속성은 밑에 있는 코드 포인트가 U+FFFF를 초과할 때마다 조용히 이를 잘라내는 대신 유니코드 대체 문자인 U+FFFD를 반환한다. 이 보호는 Character[] 뒤에 있는 속성 getter 안에 직접 자리한다

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

잘려나간 조각 대신 U+FFFD를 반환하는 것은 전면적인 재설계가 아니라 의도적이고 좁은 수정이다. Character[]Charcode[]TPdfTPdfView 양쪽에서 WideChar로 타입이 정해져 있고, 그 반환 타입을 완전한 코드 포인트를 담을 수 있게 넓히면 인덱스당 하나의 글리프가 하나의 16비트 값을 의미한다고 기대하는 기존의 모든 호출자를 깨뜨릴 것이다. U+FFFD는 정확히 이런 상황을 위해 유니코드 표준 자체가 지정한 플레이스홀더이므로, 이를 확인하는 호출자는 조용히 잘못된 데이터 대신 정의되고 문서화된 신호를 얻는다. 알아둘 가치가 있는 경계 케이스 하나: U+FFFD는 그 자체로도 정당한 문자이므로, 이미 진짜 대체 문자 글리프를 담고 있는 드문 문서에서는 그 인덱스가 값만으로는 잘려나간 성간 문자와 구별되지 않는다

Delphi에서 이모지와 CJK 확장 B 텍스트를 올바르게 추출하려면?

실제 텍스트 콘텐츠 자체가 중요할 때는 Character[]를 순회하는 대신 Text를 호출하라. TextFPDFText_GetText를 통해 읽으며, 인덱스당 고정 폭 값 하나 대신 범위 안의 모든 성간 평면 문자에 대해 올바른 서로게이트 쌍을 가진 완전한 WString을 반환한다. Pdf.Text(0, MaxInt), 또는 그 단축형인 Pdf.Text는 한 번의 호출로 페이지 전체를 올바르게 추출하며, Pdf.Text(StartIndex, Count)는 같은 방식으로 더 작은 범위를 뽑아낸다. Character[]는 인덱스에서 위치, 폰트, 플래그 데이터만 필요하고 코드 포인트 자체는 전혀 건드리지 않을 때 여전히 제자리를 갖는다 — CharacterOrigin[], FontSize[], CharacterMapError[]는 밑에 있는 글리프가 성간 문자였는지 신경 쓰지 않는다

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

이 루프 안의 생성됨·매핑되지 않음 건너뛰기 검사는 PDFium 컴포넌트로 PDF 문서에서 텍스트 추출하기의 순수 텍스트 추출에 쓰이는 것과 같은 패턴이다; 유일한 변경은 마지막 줄로, 직접적인 Character[I] 덧붙이기를 Text로의 단일 인덱스 호출로 맞바꿔서, 성간 문자가 대체 플레이스홀더가 아니라 완전한 서로게이트 쌍으로 도착하도록 한다

이것이 실제로 물어뜯는 곳: 채팅 내보내기, 인명, 임베디드 CJK 폰트

이모지는 PDF가 비공식적인 소통을 담는 곳이면 어디에나 나타난다: 내보낸 채팅 로그, 앱스토어 리뷰 덤프, 컴플라이언스 아카이브를 위해 PDF로 저장된 티켓팅 시스템 대화 기록 같은 것이다. CJK 확장 B는 더 좁지만 더 위험 부담이 큰 곳, 즉 인명과 지명에서 나타나는데, 일본의 호적, 중국의 호구 등록 기록, 대만의 신분증 문서는 흔한 CJK 블록에 결코 포함되지 못한 문자의 전형적인 출처이기 때문이다. 스캔된 정부 서류에서 이름을 추출하는 급여나 신원 확인 파이프라인이야말로 조용히 훼손된 문자가 사소한 겉모습 결함이 아니라 매칭 실패로 이어지는 종류의 작업이다

희귀한 CJK 한자는 인코딩 문제뿐 아니라 폰트 문제도 함께 가지고 다니는 경향이 있는데, U+20000 범위의 코드 포인트가 조금이라도 렌더링되려면 폰트가 그 글리프를 담고 있어야 하고, 설치된 시스템 폰트 중 그렇게 하는 것은 드물기 때문이다. PDFium 컴포넌트로 PDF 폰트 속성 읽기에서 설명하는 방식으로 이미 문자마다 FontIsEmbedded[]를 순회하고 있다면 같은 인덱스에서 두 문제를 함께 확인해야 한다: Character[]에서 U+FFFD를 반환하고 임베드되지 않은 폰트를 보고하는 인덱스는 그 문자를 올바르게 추출하지도 인쇄하지도 못할 문서이며, 그 수정은 여러분의 추출 코드가 아니라 그 PDF가 만들어진 방식 쪽 상류에 속한다

여기서 설명한 Character[], Charcode[], Text 속성은 Delphi와 C++Builder용 PDFium 컴포넌트 표준판의 일부다