UniText 는 양방향 알고리즘 UAX #9 를 C# 으로 직접 구현했다
저장: a b ␠ C D (C, D 는 히브리 문자)
화면: ab DC (히브리어 부분만 뒤집힌다)
영어는 왼쪽에서 오른쪽으로, 아랍어와 히브리어는 오른쪽에서 왼쪽으로 씁니다. 한 줄에 둘이 섞이면 화면에 놓는 순서가 저장한 순서와 달라집니다. 이것을 양방향(BiDi) 텍스트라고 부릅니다.
글리프를 고르는 HarfBuzz 는 이 순서를 정해 주지 않습니다. “이 구간을 이 방향으로 셰이핑하라” 는 지시를 받을 뿐입니다. 그래서 UniText 는 유니코드가 정한 양방향 알고리즘(UAX #9)을 C# 으로 직접 구현했습니다. 이 글은 그 알고리즘이 무엇을 정하는지부터 차례로 봅니다. 예시에서는 오른쪽에서 왼쪽으로 쓰는 글자를 대문자로 적습니다.
저장은 입력한 순서로, 뒤집기는 그릴 때만 한다
문자열은 사용자가 입력한 순서대로 저장됩니다. 이것을 논리 순서라고 부릅니다. 화면에 놓이는 순서는 시각 순서입니다.
| 순서 | |
|---|---|
| 논리 순서 (메모리) | a b 공백 C D |
| 시각 순서 (화면, 왼쪽부터) | a b 공백 D C |
저장할 때부터 뒤집어 두지 않는 이유는 검색, 정렬, 편집 때문입니다. 저장 순서가 읽는 순서와 같아야 “CD” 를 찾으면 찾아지고, 커서가 글자를 하나씩 지나갑니다. 그래서 뒤집는 일은 그리기 직전에만 합니다.
글자마다 방향 성질이 다르다
유니코드는 글자마다 방향 성질(BiDi 클래스)을 정해 두었습니다. 크게 셋입니다.
| 종류 | 예 | 방향 |
|---|---|---|
| 강한 문자 | 라틴 알파벳(L), 히브리와 아랍 문자(R) | 스스로 방향이 있음 |
| 약한 문자 | 숫자 | 아랍어 문장 안에서도 왼쪽에서 오른쪽으로 남음 |
| 중립 문자 | 공백, 구두점, 괄호 | 방향이 없음. 주변을 보고 정함 |
강한 문자만 있으면 쉽습니다. 어려운 것은 약한 문자와 중립 문자입니다. ab CD 의 가운데 공백은 영어 쪽에 붙을 수도, 히브리어 쪽에 붙을 수도 있습니다. 알고리즘의 대부분은 이런 글자의 소속을 정하는 규칙입니다.
이때 기준이 되는 것이 문단 방향입니다. 문단 전체가 어느 쪽 글인지를 뜻하고, 정해 주지 않으면 문단에서 처음 나오는 강한 문자를 따릅니다. 양쪽이 서로 다른 글자 사이에 낀 공백은 문단 방향을 따릅니다.
결과는 글자마다 매긴 레벨이다
알고리즘의 출력은 글자마다 붙는 숫자 하나, 레벨입니다.
- 짝수(0, 2, …)는 왼쪽에서 오른쪽입니다.
- 홀수(1, 3, …)는 오른쪽에서 왼쪽입니다.
- 숫자가 클수록 더 안쪽에 낀 글입니다. 영어 문단(0) 안의 히브리어는 1, 히브리어 문단(1) 안의 영어는 2 입니다.
방향을 왼쪽, 오른쪽 둘로만 적지 않고 숫자로 적는 것은 끼어든 깊이까지 담기 위해서입니다. 뒤에서 뒤집을 때 이 깊이를 씁니다.
UniText 는 규칙을 단계대로 밟아 레벨을 매긴다
UAX #9 는 규칙에 이름(P2, X1, W1, N0 등)을 붙여 순서대로 적용하라고 정합니다. BidiEngine.ProcessInternal 은 그 순서를 그대로 따릅니다.
// 1. 글자마다 방향 성질을 표에서 읽는다 (L, R, 공백, 숫자 등)
for (i < length)
bidiClasses[i] = unicodeData.GetBidiClass(codePoints[i]);
// 2. 문단 방향을 정한다. 정해 준 값이 없으면 첫 강한 문자를 따른다
if (forcedParagraphLevel.HasValue)
BuildParagraphsWithExplicitBaseLevel(...);
else
BuildParagraphs(...);
ResolveExplicitLevelsForParagraph(...); // 3. 방향을 강제하는 제어 문자를 처리한다
var seqs = BuildIsolatingRunSequences(...); // 4. 같이 판정할 구간으로 묶는다
ResolveWeakTypesWithSequences(seqs, ...); // 5. 숫자와 그 주변 기호의 소속을 정한다
ResolvePairedBracketsForParagraph(...); // 6. 괄호는 여는 쪽과 닫는 쪽을 같은 방향으로 맞춘다
ResolveNeutralTypesWithSequences(seqs, ...); // 7. 공백과 구두점의 소속을 정한다
ResolveImplicitLevelsForParagraph(...); // 8. 글자마다 레벨을 확정한다
ApplyLineBreakRuleL1ForParagraph(...); // 9. 줄 끝 공백을 문단 방향으로 되돌린다
ab CD 로 따라가 봅니다. 제어 문자, 숫자, 괄호가 없어서 값이 바뀌는 단계는 2, 7, 8 뿐입니다. 문단 방향을 자동으로 둘 때와 오른쪽에서 왼쪽으로 정해 줄 때를 견줍니다.
| 단계 | 자동 (Auto) |
오른쪽에서 왼쪽 (RTL) |
|---|---|---|
| 2. 문단 방향 | 첫 강한 문자 a 를 따라 0 |
정해 준 대로 1 |
| 7. 공백 | 문단 방향을 따라 영어 쪽 | 문단 방향을 따라 히브리어 쪽 |
| 8. 레벨 | 0 0 0 1 1 |
2 2 1 1 1 |
| 화면 | ab DC |
DC ab |
같은 다섯 글자인데 문단 방향 하나로 결과가 달라집니다. 자동에서는 영어 문단에 히브리어가 낀 것이고, RTL 에서는 히브리어 문단에 영어가 낀 것입니다.
이 단계가 내는 것은 레벨 배열뿐입니다. 글자를 실제로 뒤집지는 않습니다.
이렇게 쓴다
문단 방향은 컴포넌트의 BaseDirection 으로 정합니다.
| 상황 | BaseDirection |
|---|---|
| 언어가 정해진 UI (아랍어 버전의 메뉴 등) | 그 언어의 방향으로 고정 |
| 어느 언어일지 모르는 사용자 입력 (채팅, 닉네임) | Auto |
Auto 는 첫 강한 문자를 따르므로, 아랍어 UI 에서 라틴 브랜드 이름으로 시작하는 문장은 문단 전체가 왼쪽에서 오른쪽이 됩니다. 정렬과 문장부호 위치가 다른 문장과 어긋납니다. 숫자와 기호만 있는 문자열도 왼쪽에서 오른쪽으로 떨어집니다. 방향을 바꾸면 셰이핑부터 다시 도니 매 프레임 바꾸지 않습니다.
레벨이 같은 구간이 런이 되어 HarfBuzz 로 간다
레벨이 바뀌는 자리에서 텍스트를 자릅니다. 이렇게 잘린, 방향이 같은 한 토막을 런이라고 부릅니다. ab DC 는 ab 와 CD 두 런입니다.
런은 하나씩 HarfBuzz 에 넘어가고, 이때 런의 방향을 같이 알려 줍니다. 레벨이 홀수면 오른쪽에서 왼쪽입니다(bidiLevel & 1). HarfBuzz 는 오른쪽에서 왼쪽 런을 받으면 두 가지를 해서 돌려줍니다.
- 글리프를 화면 순서로 돌려줍니다.
CD를 넘기면D,C순서의 글리프가 옵니다. 런 안의 글자 순서는 UniText 가 뒤집을 필요가 없습니다. - 괄호를 거울상으로 바꿉니다. 오른쪽에서 왼쪽 글 안의
(는)모양으로 그려야 맞습니다. UAX #9 에는 이 일을 하는 규칙(L4)이 있지만 UniText 의 엔진에는 그 코드가 없고, HarfBuzz 가 합니다.
그래서 UniText 가 직접 뒤집어야 하는 것은 런들 사이의 순서뿐입니다.
런의 순서는 줄을 나눈 뒤 줄마다 뒤집는다
런을 뒤집는 일은 줄나누기가 끝난 뒤에 합니다. 줄이 바뀌면 답이 달라지기 때문입니다.
CD EF 라는 히브리어 두 단어는 한 줄에서는 FE DC 로 놓입니다. 먼저 읽는 CD 가 오른쪽입니다. 그런데 폭이 좁아 둘 사이에서 줄이 바뀌면, 첫 줄에는 먼저 읽는 CD 가 와야 합니다. 문단 전체를 먼저 뒤집고 줄을 나누면 첫 줄에 FE 가 옵니다. 그래서 줄을 먼저 나누고, 줄마다 따로 뒤집습니다.
UniText 에서 이 일은 LineBreaker.ReorderRunsInLine 이 합니다. 가장 높은 레벨부터 내려오며, 그 레벨 이상인 이어진 런들을 통째로 뒤집습니다.
if (count <= 1)
return; // 런이 하나면 뒤집을 것이 없다
// 줄 안 런들의 maxLevel, minLevel 을 모은다
// 가장 낮은 홀수 레벨. 홀수 레벨이 오른쪽에서 왼쪽이다
var lowestOdd = (minLevel & 1) == 1 ? minLevel : minLevel + 1;
if (lowestOdd > maxLevel)
return; // 홀수 레벨이 없다. 전부 왼쪽에서 오른쪽이다
// 높은 레벨부터 내려오며, 그 레벨 이상인 이어진 런들을 뒤집는다
for (level = maxLevel; level >= lowestOdd; level--)
{
// 레벨이 level 이상인 이어진 구간을 찾아 ReverseRuns(구간)
}
괄호 안 숫자가 런의 레벨입니다.
| 줄의 런 | 하는 일 | 결과 |
|---|---|---|
A(0) B(0) |
홀수 레벨이 없어 바로 반환 | A B |
A(0) B(1) C(1) D(0) |
레벨 1 이상인 B C 를 뒤집음 |
A C B D |
A(1) B(2) C(1) |
레벨 2 인 B 를 뒤집고(하나라 그대로), 레벨 1 이상인 줄 전체를 뒤집음 |
C B A |
셋째 줄이 레벨을 숫자로 적는 이유입니다. 히브리어 문단 안의 영어 B 는 두 번 뒤집혀 제 방향으로 돌아옵니다.
오른쪽에서 왼쪽 글자가 없는 줄은 앞의 반환 두 개로 빠져나갑니다. 한국어와 영어만 쓰는 게임에서 이 단계의 비용은 비교 몇 번입니다.
글자의 방향 성질은 직접 실은 표에서 읽는다
단계 1 의 “표에서 읽는다” 에 쓰는 표를 UniText 는 패키지에 직접 싣습니다(Resources/UnicodeData.bytes, 173,560바이트). 글자마다의 방향 성질, 줄바꿈 성질, 문자 체계, 이모지 여부가 들어 있습니다. .NET 이 기본으로 주는 것은 이 가운데 일반 범주 하나뿐이라 따로 실어야 합니다.
읽은 표는 메모리에 펴 둡니다. 유니코드의 앞쪽 65,536자(U+0000 ~ U+FFFF, 한글과 한자 대부분이 여기 있습니다)는 글자 번호를 그대로 칸 번호로 쓰는 배열 아홉 개로 펴서, 조회가 배열 읽기 한 번입니다.
대가는 첫 텍스트가 처리되는 프레임에 한 번 드는 초기화와, 계속 차지하는 메모리입니다.
| 런타임 | 초기화 시간 | 늘어나는 관리 힙 |
|---|---|---|
| Mono | 12.2 ms | 819,200 B |
| IL2CPP | 4.0 ms | 819,200 B |
Mono 값에는 첫 호출의 JIT 비용이 들어 있습니다.
이렇게 쓴다
첫 화면이 텍스트로 가득하면 그 프레임에 표 초기화, 첫 셰이핑, 첫 글리프 굽기가 한꺼번에 몰립니다. 텍스트가 없는 로딩이나 스플래시 구간에서 표를 먼저 읽어 둡니다.
UnicodeData.EnsureInitialized(); // 메인 스레드에서 부른다. Resources.Load 를 쓰기 때문이다
약 0.8 MB 는 UniText 를 쓰는 동안 계속 차지하는 고정비로 잡아 둡니다.
구현이 맞는지는 유니코드의 시험 파일로 확인했다
유니코드는 알고리즘 구현을 검증하는 시험 파일을 함께 냅니다. 소스 주석은 100% 통과라고 적지만 시험을 돌리는 코드는 패키지에 없어서, 유니코드 17.0.0 의 BidiCharacterTest.txt 91,707줄을 직접 BidiEngine.Process 에 넣어 봤습니다.
| 대조한 것 | 통과 |
|---|---|
| 문단 방향 | 91,707 / 91,707 |
| 글자별 레벨 | 91,707 / 91,707 |
| 시각 순서 | 91,707 / 91,707 |
레벨과 순서는 모두 맞았습니다. 엔진에 없는 것은 둘입니다.
- 괄호를 거울상으로 바꾸는 규칙(L4)은 위에서 본 대로 HarfBuzz 가 합니다.
א(ב)ג의 괄호는 거울상으로 그려졌습니다. - 결합 문자의 순서를 바꾸는 규칙(L3)은 어디에서도 찾지 못했습니다.
이렇게 쓴다
괄호가 거울상이 되려면 그 괄호가 오른쪽에서 왼쪽 런에 들어가야 합니다. 괄호의 레벨이 어떻게 매겨지느냐에 결과가 달려 있고, 틀려도 그 언어를 읽지 못하면 알아채기 어렵습니다. 실린 표보다 나중에 유니코드에 추가된 글자는 표에서 찾지 못해 왼쪽에서 오른쪽(L)으로 처리되고, 사용자가 표를 새로 만들 방법은 없습니다.
괄호, 따옴표, 부등호가 든 오른쪽에서 왼쪽 문자열과 최근에 추가된 이모지를 테스트 목록에 넣고, 실제 기기에서 한 번 그려 봅니다.
정리
UniText 는 글자마다 방향 레벨을 매기는 UAX #9 를 C# 으로 구현했습니다.
- 저장은 입력한 순서 그대로 두고, 그리기 직전에만 순서를 바꿉니다.
- 레벨은 규칙 단계를 차례로 밟아 매기고, 공백 같은 중립 문자의 소속은 문단 방향이 가릅니다.
- 런 안의 글리프 순서와 괄호 거울상은 HarfBuzz 가 하고, UniText 는 줄을 나눈 뒤 런의 순서만 줄마다 뒤집습니다.
- 글자의 방향 성질은 직접 실은 표에서 읽고, 대가는 첫 프레임의 초기화와 약 0.8 MB 입니다.
그래서 언어가 정해진 UI 는 BaseDirection 을 고정하고, 표는 로딩 중에 미리 읽어 둡니다.