기술 문서

Delphi에서 유니코드에 안전한 PDF 텍스트 검색: NFC와 NFD

PDF Library for Delphi는 코드 단위가 아니라 정준 동치로 텍스트를 매칭할 수 있어, 완성형 문자로 입력한 쿼리가 기본 글자와 결합 부호로 저장된 콘텐츠를 찾아내고 그 반대도 가능합니다. 두 가지 검색 옵션이 이를 제어합니다. soCanonicalEquivalent는 매칭 중 유니코드 정규화를 활성화하고, soGraphemeClusters는 모든 히트와 와일드카드 단계를 완전한 그래프힘 클러스터 안으로 제한합니다

이 기능이 고치는 버그는 문서 검색에서 가장 많이 보고되면서도 가장 이해받지 못하는 버그 중 하나입니다. 사용자가 이름을 검색하고 결과가 없는 것을 확인한 다음, 문서에서 그 이름을 복사해 검색창에 붙여넣으면 찾아냅니다. 겉으로는 아무것도 눈에 띄게 고장 나지 않았습니다. 두 문자열은 똑같아 보이고, 똑같이 인쇄되지만, 비교하면 다른데, 하나는 U+00E9이고 다른 하나는 U+0065 다음에 U+0301이 오는 조합이기 때문입니다

같은 단어인데 왜 다르게 비교될까?

유니코드는 같은 추상 문자에 여러 인코딩을 허용합니다. 발음 구별 부호가 있는 라틴 문자는 완성형 코드 포인트로도, 기본 문자와 결합 문자 시퀀스로도 존재합니다. 한글 음절은 완성형 음절로도, 분해된 자모로도 존재합니다. PDF가 그중 어느 쪽을 담고 있는지는 생성기, 플랫폼, 때로는 폰트에 달려 있으며, 이 중 어느 것도 검색하는 사람에게는 보이지 않습니다

단순한 대소문자 접기로는 이 문제를 해결할 수 없는 이유는 우연이 아니라 구조적입니다. 대소문자 접기와 악센트 접기는 코드 단위 수준에서 일대일 대응입니다. 접힌 문자열은 원본과 길이가 같으므로 접힌 텍스트 안의 매칭 위치가 원본에서의 매칭 위치이기도 합니다. 정규화는 일대일이 아닙니다. 완성형 문자 하나가 코드 단위 두세 개가 되기도 하고, 분해된 시퀀스가 하나로 합쳐지기도 하며, 이 변환 이후에는 위치가 더는 여러분이 추출한 텍스트와 맞아떨어지지 않습니다

히트 좌표를 원본 텍스트에 그대로 고정하기

바로 이 부분이 정규화된 검색이 정확한 데 그치지 않고 실제로 쓸 수 있게 만드는 지점입니다. 정규화로 만들어진 모든 코드 단위는 자신을 만들어낸 원본 UTF-16 텍스트의 시작과 끝 위치를 함께 기록합니다. 재귀적인 분해는 부모의 원본 범위를 물려받고, 결합은 입력들의 범위를 합치며, 매칭이 발견되면 라이브러리는 매핑 구간을 훑어 가장 작은 시작과 가장 큰 끝을 찾아냅니다

그 결과 MatchStart, MatchLength, 컨텍스트 문자열, 그리고 두 가지 치환 진입점 모두가 계속해서 정규화된 중간 결과가 아니라 원본으로 추출된 텍스트를 가리킵니다. 이 매핑이 없다면 정규화된 검색은 히트가 존재한다고는 말해줄 수 있어도 그것이 어디였는지는 신뢰성 있게 알려주지 못하며, 이는 강조 표시를 틀리게 만들고 편집(redaction)을 위험하게 만듭니다

정규화기 자체는 독립적입니다. 유니코드 15.1의 정준 분해, 결합, 정준 결합 클래스를 위한 소형 테이블을 사용하며, 한글은 테이블 항목이 아니라 알고리즘 규칙으로 처리합니다. 외부 데이터 파일에서 불러오는 것도, 플랫폼 정규화 API를 호출하는 것도 전혀 없으므로, Windows 서비스와 Linux 데몬과 FPC 빌드가 같은 입력에서 모두 동일한 결과를 만들어냅니다

정준 동치로 검색하기

옵션은 집합이므로 정준 동치는 전체 단어 매칭, 와일드카드, 발음 구별 부호 무시 접기 같은 기존 동작과 결합됩니다:

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Hits: array of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contracts.pdf', '');
    SetLength(Hits, 500);

    Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
      '', Hits);                       // empty page range = whole document

    for I := 0 to Found - 1 do
      Log(Format('page %d: "%s" at %d (%d chars)',
        [Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
         Hits[I].MatchLength]));
  finally
    Lib.Free;
  end;
end;

정규화는 옵트인이며, 여기에는 이유가 있습니다. NFD 텍스트와 그 위치 매핑을 만드는 데는 작업 비용이 들고, ASCII만 담은 문서를 대상으로 한 대부분의 검색은 이를 전혀 필요로 하지 않습니다. 이 옵션을 쓰면 각 텍스트 블록은 결합 부호가 제거된 형태와 제거되지 않은 형태, 두 가지 변환된 형태를 캐시하므로, 같은 블록에 대한 배치 쿼리는 쿼리마다가 아니라 한 번만 정규화됩니다. 대소문자 접기는 여전히 더 저렴한 일대일 경로를 그대로 지나갑니다

그래프힘 클러스터 경계 없이는 무엇이 깨질까?

코드 단위는 문자가 아니고, 문자는 사용자가 지각하는 것도 아닙니다. 국기 이모지는 지역 표시자 코드 포인트 두 개입니다. 가족 이모지는 폭 없는 결합자로 이어진 여러 코드 포인트입니다. 인도계 문자의 결합 자음군은 자음, 비라마, 또 다른 자음입니다. 스택된 두 개의 악센트가 있는 문자는 코드 포인트 세 개입니다. 이들 중 어느 것의 중간에서든 매칭하거나 자르면 렌더링했을 때 깨진 조각이 나옵니다

soGraphemeClusters는 리터럴이든 와일드카드든 모든 히트의 양 끝을 완전한 확장 그래프힘 클러스터 경계로 제한합니다. 이 세그멘테이션은 CR과 LF의 짝짓기, 제어 문자, 한글 음절 클래스, Extend와 SpacingMark, Prepend, 이모지 ZWJ 시퀀스, 지역 표시자 짝짓기, 인도계 문자의 결합 자음군 경계까지 아우르는 확장 규칙을 구현합니다. 경계는 서로게이트 쌍 중간에서는 결코 만들어지지 않으며, 이것만으로도 기본 다국어 평면을 넘어서는 모든 콘텐츠에서 발생할 수 있는 손상된 결과의 한 부류 전체를 없애줍니다

이 옵션은 와일드카드 소비도 관장하는데, 순진한 구현이라면 여전히 잘못된 위치에서 자를 만한 지점입니다. 한 문자짜리 와일드카드는 정확히 완전한 클러스터 하나만큼 전진하고, 연속 와일드카드의 백트래킹도 클러스터 경계 사이에서만 움직입니다:

// Without soGraphemeClusters, "?" can consume half a cluster and
// return a hit whose text ends in a dangling combining mark
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// The same boundaries protect replacement, so redaction and
// content rewriting never split an emoji or an accented letter
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

실제 워크로드에 맞는 옵션 고르기

대부분의 경우는 세 가지 조합으로 다룰 수 있습니다. 사내 문서 검색창이라면, soCanonicalEquivalentsoDiacriticInsensitive를 더하면 사용자가 기대하는 관대한 동작이 나오며, 두 인코딩 형태와 악센트가 있고 없는 철자 모두를 매칭합니다. 오탐지에 비용이 따르는 법률·컴플라이언스 검색이라면, soCanonicalEquivalentsoCaseSensitivesoWholeWord를 함께 쓰고 악센트 접기는 끈 채로 두어, 동치는 정확하면서도 인코딩과는 무관하게 유지하십시오

문서를 수정하는 모든 작업에는 예외 없이 soGraphemeClusters를 더하십시오. 조금 잘못된 범위를 반환하는 검색은 독자를 오도하는 데 그치지만, 같은 잘못된 범위를 사용하는 치환이나 편집은 그 실수를 파일에 그대로 새겨 넣습니다. 제거 범위를 잘못 잡았을 때의 결과는 진짜 편집과 콘텐츠 제거에서 다룹니다

처리량이 중요할 때는 배치 진입점을 우선하십시오. SearchTextBatch는 각 페이지의 텍스트 블록이 메모리에 남아 있는 동안 비어 있지 않은 모든 쿼리를 실행해, 쿼리마다 페이지를 다시 추출하는 것을 피하고 캐시된 정규화를 재사용하며, 스트리밍 변형은 호출자가 크기를 정한 버퍼 없이 히트를 내보냅니다. 그 아래에 있는 추출 모델은 텍스트 검색과 페이지 요소 열거에서 설명합니다

이것이 선택이 아닌 스크립트

한국어는 정준 동치가 있느냐 없느냐가 이름을 찾느냐 찾지 못하느냐를 가르는 문제인데, 완성형 음절과 분해된 자모 모두 실제 문서에서 흔하기 때문입니다. 베트남어는 스택된 발음 구별 부호 때문에 결합 형태가 전적으로 생성기에 따라 달라집니다. 인도계 스크립트는 결합 자음군 처리가 히트 경계가 읽을 수 있는 자리에 놓이는지를 결정합니다. 일본어와 중국어는 검색 쪽은 비교적 단순하지만 레이아웃 쪽은 그렇지 않으며, 이는 일본어와 중국어를 위한 세로쓰기에서 설명합니다

경험칙은 짧습니다. 코퍼스에 영어 아닌 언어가 하나라도 있다면 정준 동치를 켜고 비용을 측정한 다음 너무 비싼지 판단하십시오. 대부분의 문서 세트에서는 그렇지 않으며, 대안은 사용자가 가장 찾고 싶어하는 바로 그 이름들에서 조용히 실패하는 검색 기능입니다

유니코드를 인식하는 검색, 추출, 편집, 텍스트 다시 쓰기는 Delphi, C++Builder, Free Pascal용 하나의 엔진을 공유합니다. 전체 기능 목록은 Delphi용 PDF Library 페이지에 있습니다