기술 문서

Uniscribe 없이 PDF 텍스트를 위한 BiDi 임베딩 레벨

Uniscribe는 대부분의 호출자가 실감하는 것보다 더 많은 일을 합니다. ScriptItemize는 한 패스에서 양방향 분석과 스크립트 분절을 수행하고, ScriptLayout은 결과 run들의 시각 순서를 만들어 냅니다. 사람들이 손을 뻗는 이식 대체물인 HarfBuzz는 어느 쪽도 하지 않습니다. 방향과 스크립트가 이미 남이 정해 둔 단일 run을 셰이핑할 뿐입니다. 그래서 Windows PDF 텍스트 파이프라인을 Linux나 macOS로 가져가는 어려운 부분은 셰이핑 엔진을 바인드하는 것이 아닙니다. Uniscribe가 조용히 공급하던 양방향 알고리즘을 공급하는 것이며, PDFium 컴포넌트에서 FPdfBidi가 존재하는 이유가 바로 그것입니다

유닛은 UAX #9를 직접 구현합니다. 단락 방향의 규칙 P2와 P3, 명시적 임베딩과 isolate의 X1부터 X10, 약한 타입의 W1부터 W7, 중립과 괄호의 N0부터 N2, 암시적 레벨의 I1과 I2, 마지막 재정렬의 L1과 L2입니다. 두 함수가 이를 실어 갑니다. PdfResolveBidiLevels는 UTF-16 코드 유닛마다 임베딩 레벨 하나를 돌려주고, PdfBidiVisualOrder는 그 레벨들을 코드 유닛을 왼쪽에서 오른쪽으로 놓는 치환으로 바꿉니다

알고리즘이 주는 것과 주지 않는 것

숫자를 줍니다. 짝수 레벨은 왼쪽에서 오른쪽, 홀수 레벨은 오른쪽에서 왼쪽이며, 각 문자의 레벨은 그 문자가 앉아 있는 방향 run들의 중첩을 인코딩합니다. 그 숫자들에서 L2가 치환을 유도합니다. 알고리즘이 의도적으로 하지 않는 것은 어느 폰트를 쓸지 정하거나, 합자를 만들거나, 클러스터 안의 글리프를 재정렬하는 것입니다. 그것들은 셰이핑의 영사이며 이 다음 단계에 속합니다

Uniscribe 없이 PDF 텍스트를 위한 FPdfBidi 파이프라인: PdfResolveBidiLevels가 UTF-16 코드 유닛마다 UAX #9 임베딩 레벨을 배정하고 PdfBidiVisualOrder가 규칙 L2를 적용해 시각 순서를 만듦
레벨은 run 중첩을 인코딩하며, 규칙 L2는 그것을 왼쪽에서 오른쪽으로 읽는 치환으로 바꿉니다
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto는 P2-P3를 적용합니다. 첫 강한 문자가 결정합니다
  if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
  begin
    Order := PdfBidiVisualOrder(Text, Levels);
    SetLength(Visual, Length(Order));
    for I := 0 to High(Order) do
      Visual[I + 1] := Text[Order[I] + 1];
    // Visual은 이제 왼쪽에서 오른쪽으로 읽힙니다. Levels[]는
    // 여전히 어느 run이 RTL인지 말해 주므로 셰이퍼에게 올바른
    // 방향을 넘겨줄 수 있습니다
  end;
end;

문자 클래스 테이블은 생성되지 손으로 쓰이지 않습니다

모든 코드 포인트는 Bidi_Class 프로퍼티를 갖고 알고리즘은 끊임없이 그것을 조회하므로, 테이블은 다른 모든 것이 서 있는 기초입니다. 손으로 유지되는 것이 아니라 Unicode Character Database에서 생성됩니다. UnicodeData.txt의 다섯 번째 필드가 배정된 클래스를 주고, DerivedBidiClass.txt@missing 선언이 데이터베이스가 배정하지 않는 코드 포인트의 기본값을 줍니다. 미배정 블록이 L이 아니라 R, AL, ET, BN으로 올바르게 기본값을 갖는 방식이 바로 이것입니다

압축 요령은 클래스가 L이 아닌 범위만 내보내는 것입니다. 모든 범위 밖에 떨어지는 것은 L인데, 이는 Unicode 기본값이자 압도적 다수 코드 포인트의 클래스입니다. 이것이 수천 엔트리로 달렸을 테이블을 745개 범위와 약 6.7 KB로 줄입니다. 운영상의 결과는 밝힐 가치가 있습니다. 새 Unicode 버전으로 옮겨갈 때 생성기를 다시 돌리십시오. include 파일을 손으로 고치는 것도 동작하고, 다음 업그레이드에서 데이터베이스와 조용히 갈라지기도 합니다

L2는 UTF-16 코드 유닛이 아니라 코드 포인트를 재정렬해야 합니다

진짜 손상된 출력을 만들어 내는 실수이며, 첫 구현이 범했습니다. L2는 각 레벨에서 최상위부터 최하위 홀수 레벨까지 연속된 run을 뒤집으라고 말합니다. UTF-16 문자열에 대해 쓰면 "run을 뒤집는다"는 자연히 그 안의 코드 유닛을 뒤집는다는 뜻이 됩니다. 기본 다국어 평면(BMP)의 문자라면 괜찮습니다. U+10800 근처의 키프로스 문자나 고대 남아라비아 문자 블록처럼 점성면(astral plane)의 RTL 문자는 그렇지 않습니다. 그 문자는 서러게이트 쌍이며, run을 뒤집으면 낮은 서러게이트가 높은 것 앞에 놓이고 문자열은 이제 한 문자 대신 짝이 없는 서러게이트 둘을 담습니다. 하류의 무엇도 이를 복구할 수 없습니다

해결책은 L2를 코드 포인트 단위로 수행하는 것입니다. 구현은 코드 유닛을 코드 포인트 단위로 병합하고, 그 단위들 위에서 뒤집기를 수행하며, 마지막에 결과를 코드 유닛 인덱스로 다시 펼칩니다. 그래서 PdfBidiVisualOrder가 레벨 배열만이 아니라 텍스트를 받는 이유입니다. 레벨만으로는 서러게이트 경계가 어디인지 알 수 없습니다. 같은 서러게이트 쌍 규율은 일반적으로 텍스트 API를 관통하며, emoji, CJK 및 서러게이트 쌍 문서에서 설명합니다

양방향 재정렬에서의 서러게이트 쌍 손상: UTF-16 코드 유닛을 뒤집으면 U+10800 근처의 점성 문자가 짝 없는 서러게이트로 갈라지고, 병합된 코드 포인트 단위를 뒤집으면 온전하게 남음
규칙 L2는 뒤집기 전에 코드 유닛을 코드 포인트로 병합해야 하고, 뒤에 다시 펼쳐야 합니다

레벨 하강은 발생하지 않는 레벨까지 포함해야 합니다

두 번째 실수는 더 미묘하고 크래시 없이 재정렬되지 않은 텍스트만 남깁니다. L2는 존재하는 최상위 레벨에서 시작해 최하위 홀수 레벨까지 내려가라고 말합니다. 자연스러운 최적화는 실제로 발생하는 레벨의 집합을 모아 그 집합을 순회하는 것입니다. 틀립니다

오른쪽에서 왼쪽 임베딩 안의 Latin 텍스트 한 줄을 생각해 보십시오. 단락 레벨은 0이고, 임베딩은 Latin 문자를 레벨 2로 밀어 올리며, 어느 문자도 레벨 1에 앉지 않습니다. 발생하는 레벨을 순회하면 0과 2만 찾고 홀수 레벨은 전혀 없으므로 루프는 뒤집기를 수행하지 않습니다. 그 답은 올바르지만 최적화가 모르는 이유에서입니다. 레벨 2의 뒤집기에 뒤이은 레벨 1의 뒤집기는 정확히 상쇄되므로, 어느 쪽도 수행하지 않는 것이 올바른 결과입니다. 입력을 조금 바꿔 레벨 1과 레벨 3 문자는 존재하지만 레벨 2는 존재하지 않게 하면, 집합 기반 루프는 알고리즘이 요구하는 레벨 2 뒤집기를 건너뜁니다

// 맞음: 어떤 문자도 실제로 갖지 않는 레벨을 포함해 최대부터
// 최하위 홀수 레벨까지 모든 레벨을 걷습니다
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // 자격 run이 없으면 no-op
  Dec(Level);
end;

평범한 감소 루프로 쓰면 동작이 공짜로 따라오고, no-op 반복의 비용은 측정할 수 없을 만큼 없습니다. 이것은 당연해 보이는 최적화가 약간 틀린 정도가 아니라 작은 테스트 코퍼스로는 영원히 드러나지 않을 입력 의존적 방식으로 틀린 사례입니다

UAX #9의 양방향 레벨 하강 함정: 발생하는 레벨만 순회하면 필요한 레벨 2 뒤집기를 건너뛰고, MaxLevel부터 최하위 홀수 레벨까지의 평범한 감소 루프는 항상 올바르게 재정렬함
최하위 홀수 레벨까지 모든 레벨을 걷는 것은 비용이 없고 필요한 뒤집기를 절대 건너뛰지 않습니다

괄호: 실용적 테이블을 곁들인 BD16

규칙 N0과 BD16 괄호 쌍 알고리즘은 혼합 방향 텍스트의 괄호가 인접한 우연이 아니라 감싼 것의 방향으로 귀결되도록 존재합니다. 그러려면 괄호 쌍 테이블이 필요합니다. 구현은 Unicode 괄호 파일의 전체 내용이 아니라 일반적으로 쓰이는 쌍을 실어 갑니다. ASCII, CJK, 전각, 수학, 장식 괄호입니다

목록에 없는 괄호는 오류가 아닙니다. N1과 N2를 통해 평범한 중립으로 귀결되며, 이는 Unicode 6.3이 N0를 도입하기 전 모든 구현이 갖고 있던 바로 그 동작입니다. 그러므로 경계는 "희귀 괄호에 대해 덜 정교함"이지 "틀림"이 아닙니다. 명시적 처리가 필요한 세부 하나가 있습니다. U+2329와 U+232A의 꺾쇠 괄호와 U+3008과 U+3009의 것들 사이 정칙 동등성은 쌍을 맞출 때 접어야 합니다. 그렇지 않으면 한 방식으로 쓴 열기 괄호가 다른 방식으로 쓴 닫기 괄호와 짝짓기에 실패합니다

상호작용하는 서른 개 규칙을 테스트하는 방법

큰 코퍼스로가 아니라, 적어도 처음에는 아닙니다. 성과를 낸 접근은 열여섯 개의 손 검증 사례였습니다. 각각이 특정 규칙을 구동하도록 골라지고 각각이 UAX #9가 내놓아야 한다고 말하는 레벨과 대조되었습니다. P2와 P3 아래의 단락 방향 탐지, 약한 타입 규칙 W2, W3, W7, 암시적 레벨 규칙 I1과 I2, X2와 X7을 통한 명시적 임베딩, X5a와 X6a를 통한 isolate, 뒤따르는 공백과 구분자의 L1 리셋, N0 괄호 사례, 그리고 서러게이트 처리를 고정하기 위한 점성 문자 사례 하나입니다

올바름이 알려진 기대 레벨을 갖춘 열여섯 사례는 그럴듯해 보이는 출력을 내놓는 천육백 사례보다 더 많이 잡습니다. 양방향 구현의 실패 양상은 거의 맞게 읽히는 텍스트이기 때문입니다. 그것들이 통과한 뒤에는 코퍼스가 테이블 공백과 성능 문제를 찾는 데 유용하며, 이들은 다른 부류의 결함입니다

PDFium 컴포넌트 안에서 레벨은 두 소비자에게 먹입니다. 쓰기 쪽에서 셰이핑 백엔드에게 각 run의 방향을 말해 주는데, 이것이 HarfBuzz가 요구하는 입력입니다. 읽기 쪽에서는 선택 지오메트리와 읽기 순서에 정보를 줍니다. RTL 텍스트 안의 클릭은 시각 위치가 아니라 논리 위치로 매핑되어야 하기 때문입니다. 그 매핑은 시각 줄 선택 문서에서, 읽기 순서 모델은 구조화 텍스트 블록과 읽기 순서에서 다룹니다. 컴포넌트의 플랫폼 지원 세부는 PDFium Delphi component 제품 페이지에 있습니다