# 004 — 글자: 양방향, 합성, 셰이핑을 빌드할 때 컴파일한 표로 2026-09-28. Pure 외부 피드백 002의 셋째 항목. [002](002-symbol-format.md)는 FreeType을 실행 중에서 빼고 글꼴을 빌드할 때 곡선 레코드로 바꿨다. 그 대가로 Vega는 코드 포인트 하나를 글리프 하나로 그렸다. 합자, 커닝, 아랍 문자의 위치별 형태, 결합 부호의 자리, 오른쪽에서 왼쪽으로 쓰는 글(양방향)이 없었다. 착수 기준: - 바꿀 결정: 셰이핑을 실행 중에 OpenType 표(GSUB, GPOS)를 해석해서 할지(HarfBuzz의 방식), 빌드할 때 글꼴의 행동을 단순한 표로 컴파일해 두고 실행 중에는 검증한 표를 제자리에서 읽기만 할지. 심볼과 같은 원칙이면 뒤쪽이다. (결과: 둘을 나눴다. 계획은 빌드할 때 컴파일하고, 조회는 OpenType 그대로 읽는다. 아래 셰이핑 절.) - 남은 질문: 컴파일한 표만으로 HarfBuzz와 같은 글리프와 자리가 나오는가? 어느 문자 체계까지인가? 양방향과 합성은 유니코드의 적합성 시험을 모두 통과하는가? - 최소 확인: 유니코드 15.1의 `BidiTest.txt`(49만 줄)와 `BidiCharacterTest.txt`(9.6만 줄), `NormalizationTest.txt`를 모두 통과한다. 셰이핑은 라틴(커닝, 합자), 아랍(위치별 형태, lam-alef, 결합 부호), 히브리(결합 부호) 문장에서 HarfBuzz 10.2와 글리프와 자리가 같다. - 중단 기준: 적합성 시험에 틀린 줄이 하나라도 있으면 그 단계를 쓰지 않는다. 셰이핑이 HarfBuzz와 다른 문장은 원인을 찾을 때까지 그 문자 체계를 받지 않는다(빌드 도구가 거부한다). ## 단계 1. 유니코드 성질 표: UCD에서 생성한 두 단계 표(코드 포인트 → 값). [text/unicode.py](../../text/unicode.py)가 [text/unicode-data.c](../../text/unicode-data.c)를 만든다. 2. 양방향(UAX #9): 문단 수준, 명시적 수준과 격리(X1–X10), 약한 형(W1–W7), 괄호 쌍(N0), 중립(N1–N2), 암시적 수준(I1–I2), 줄(L1)과 보이는 순서(L2). 3. 정규화: 한글 음절과 정준 합성(NFC). 4. 셰이핑: 빌드 도구가 글꼴의 셰이핑 계획을 컴파일해 배치 표와 함께 심볼 파일에 넣는다. 실행 중에는 HarfBuzz의 절차를 그대로 옮긴 엔진이 그것을 읽는다. 빌드 도구는 HarfBuzz와 결과가 다른 파일을 만들지 않는다. 5. 비례폭 글줄: 셰이핑한 글리프를 제 advance로 늘어놓아 그린다. 6. 접근성. ## 양방향 [text/bidi.c](../../text/bidi.c)는 UAX #9를 규칙 이름 그대로 짠다. 문단 수준(P2–P3), 명시적 수준과 방향 상태 스택(깊이 125, 넘친 격리·내장·유효 격리의 계수, X1–X8), 제거(X9)와 격리 실행 열(X10: 격리 시작자로 끝나는 실행은 짝 PDI로 시작하는 실행으로 이어지고, sos와 eos는 명시적 수준으로 정한다), 약한 형(W1–W7), 괄호 쌍(N0: 깊이 63의 스택, U+2329·232A와 U+3008·3009의 정준 동치, 바뀐 괄호 뒤의 결합 부호), 중립(N1–N2), 암시적 수준(I1–I2), 그리고 한 줄로서의 L1과 보이는 순서(L2)다. 성질은 UCD에서 만든 표다(양방향 클래스는 두 단계 표 37 KiB, 괄호 128개, 거울 글자 428개). 유니코드 15.1의 적합성 시험을 모두 통과한다. `BidiTest.txt`의 770,241 경우(클래스 열마다 비트 집합이 가리키는 문단 방향)와 `BidiCharacterTest.txt`의 91,707 경우(코드 포인트, 괄호 포함)에서 수준과 보이는 순서가 하나도 틀리지 않는다. Pi 5에서 전체가 0.74초다. 처음 짠 것은 148 경우가 틀렸다. 앞선 격리 실행 열의 암시적 수준(I1–I2)이 바꾼 수준으로 다음 열의 sos를 정했기 때문이다(sos와 eos는 명시적 수준으로 정해야 한다). ## 정규화 [text/normalize.c](../../text/normalize.c)는 NFD와 NFC다. 코드 포인트를 끝까지 분해하고(표에 다 풀어 둔 정준 분해 2,061개, 한글 음절은 산술로), 결합 등급으로 부호를 안정 정렬한 뒤, 마지막 기저 문자와 막히지 않은 문자를 합친다(원시 합성 941쌍, 합성 제외 목록 반영, 한글 L+V와 LV+T는 산술로). 코드 포인트 하나는 많아야 넷으로 풀린다(UCD 전체를 확인했다). `NormalizationTest.txt`의 19,074줄(줄마다 NFC와 NFD 16가지 대조)과, 그 1부에 없는 코드 포인트 1,095,035개가 제 자신의 NFC이자 NFD라는 것을 모두 지킨다. 적합성 시험 셋을 합쳐 Pi 5에서 0.9초다. ## 줄 바꿈과 분절 글줄을 채우려면 어디서 줄을 바꿀 수 있는지(UAX #14)가 필요하다. 커서와 선택, 읽어 주기에는 자소 묶음, 낱말, 문장의 경계(UAX #29)가 필요하다. 모두 성질 표(Line_Break, Grapheme_Cluster_Break와 Indic_Conjunct_Break, Word_Break, Sentence_Break, 동아시아 폭이 F·W·H인 여는·닫는 괄호)와 규칙을 순서대로 적용하는 코드다. 각 경계는 규칙 가운데 그것을 처음으로 정하는 것이 정한다. - [text/linebreak.c](../../text/linebreak.c): LB1–LB31. 결합 부호를 앞 글자에 붙이고(LB9), 공백을 건너 뒤를 보는 규칙(LB8, LB14, LB15a, LB16, LB17), 숫자 규칙(LB25는 유니코드의 시험처럼 8.2절 예 7로 맞춘 것), 브라흐미 계열 음절(LB28a), 지역 표시 짝(LB30a)을 한 번 앞에서 뒤로 훑어 만든 배열로 본다. - [text/grapheme.c](../../text/grapheme.c): GB1–GB999. 인도계 결합 자음(GB9c), 이모지 ZWJ 열(GB11), 지역 표시 짝(GB12–13)은 훑으며 상태 셋으로 본다. - [text/word.c](../../text/word.c): WB1–WB999. 확장·서식·ZWJ를 앞 글자에 접고(WB4), 앞뒤로 한 단위씩 더 본다. - [text/sentence.c](../../text/sentence.c): SB1–SB998. 문장 끝 뒤의 닫는 부호와 공백은 훑으며 기억하고, SB8의 앞보기는 뒤에서 앞으로 한 번 훑어 위치마다 미리 구한다. 모두 선형 시간이다. 유니코드 15.1의 적합성 시험을 모두 통과한다. `LineBreakTest.txt` 10,274 경우, `GraphemeBreakTest.txt` 1,187 경우, `WordBreakTest.txt` 1,826 경우, `SentenceBreakTest.txt` 512 경우에서 틀린 경계가 하나도 없다. ## 셰이핑 처음 계획은 HarfBuzz가 표본 문자열(글자 쌍, 부호 하나 붙은 글자, 위치별 형태)을 셰이핑한 결과를 표로 뽑는 것이었다. 글꼴을 열어 보고 버렸다. DejaVu Sans 한 벌에만 표본으로 잡히지 않는 규칙이 여럿이다. - 라틴의 `ccmp`: i와 j는 위 부호가 뒤에 오면 점 없는 꼴이 된다. 사이에 아래 부호가 둘까지 있어도 그렇고, 셋이면 안 된다. 대문자 뒤의 위 부호는 대문자용 꼴이 된다. - 히브리: ayin 뒤에 아래 부호가 오면 ayin이 다른 꼴이 된다. yod와 hiriq는 한 글리프가 된다. - 아랍: 부호끼리의 합자가 있다(shadda와 fatha). lam-alef는 사이의 부호를 건너뛰어 합쳐지고, 그 부호는 합자의 어느 성분에 붙었는지를 따라간다(mark-to-ligature). 이런 문맥은 규칙 자체를 옮기지 않고는 정확히 재현할 수 없다. 그래서 나눴다. - 빌드할 때: 계획을 컴파일한다. 문자 체계와 방향마다 HarfBuzz의 계획기가 하는 일을 그대로 한다. 기능을 모으고(기본, 아랍, 히브리, 한글 셰이퍼의 목록과 단계), 중복을 합치고, 마스크 비트를 나누고, 글꼴의 문자 체계·언어 체계에서 기능을 찾고, 단계별로 조회를 모아 정렬해 합친다. 누가 자리를 잡는지도 여기서 정한다. GPOS, 옛 `kern` 표(GPOS에 `kern` 기능이 없을 때), 부호의 대체 배치(GPOS가 없을 때) 가운데 무엇을 쓸지, 부호의 폭을 0으로 만들며 오프셋을 옮길지를 HarfBuzz의 조건 그대로 고른다. 결과는 조회 번호, 마스크, ZWJ/ZWNJ 규칙, 이 깃발들의 목록이다. 문자 지도는 HarfBuzz가 매기는 대로 싣는다. 모든 코드 포인트를 `hb_font_get_nominal_glyph`로 물어, 이어지는 글리프로 가거나 한 글리프로 가는 묶음으로 적는다. 목록 (`hb_face_collect_unicodes`)으로 만들면 틀린다. 형식 13 지도는 목록이 잘리고, 기호 지도(3,0)의 라틴과 아랍 문자는 HarfBuzz가 사용자 영역으로 옮겨 찾는다. 이체 선택자 순서(cmap 형식 14)도 HarfBuzz가 주는 글리프로 싣는다(바탕 글자와 선택자마다 하나, 기본 순서는 바탕 글자의 글리프). 정규화는 글꼴이 가진 순서를 그 글리프 하나로 바꾸고, 없는 순서는 두 글자를 따로 넘기며 선택자를 숨긴다. advance, GDEF·GSUB·GPOS와 `kern` 표는 그대로 싣고, 대체 배치를 하는 계획이 있으면 글리프마다 외곽 상자(HarfBuzz의 glyph extents, 16비트 넷)를 싣는다 ([text/shape.h](../../text/shape.h), [text/shape-build.c](../../text/shape-build.c)). - 실행 중에: [text/shape.c](../../text/shape.c)가 HarfBuzz 10의 절차를 함수 단위로 옮긴다. 유니코드 성질과 자소 묶음, 글꼴을 보는 정규화(없는 글자는 분해하고 있는 합성은 합치며, 부호는 HarfBuzz의 수정 결합 등급과 아랍 UTR #53, 히브리 순서로 정렬한다), 거울 글자, 아랍 연결 상태 기계, GSUB(유형 1–8과 확장, 문맥과 연쇄 문맥의 형식 1–3, 역연쇄), 합자의 성분 추적, GPOS(유형 1–8, 짝 형식 1·2, 필기체, 기저·합자·부호에 붙는 부호), 부착 오프셋의 전파, 보이지 않는 문자 숨기기를 한다. 한글은 글꼴에 통째로 있는 음절은 자모를 합치고, 없는 음절은 자모로 풀어 `ljmo`·`vjmo`·`tjmo`를 걸고, 성조 기호는 음절 앞으로 옮긴다(앞에 음절이 없으면 점선 원을 넣는다). `kern` 표는 OpenType(판 0)과 Apple(1.0)의 짝 하위 표 (형식 0)와 등급 하위 표(형식 2, 3)를 부호를 건너뛰며 적용하고 커닝을 두 글리프에 나눠 준다(HarfBuzz의 이진 탐색을 그대로 해서, 정렬이 어긋난 표도 같은 답을 낸다). 형식 2는 HarfBuzz처럼 등급 표의 글리프를 다이제스트로 걸러, 다이제스트가 잘못 통과시킨 글리프는 등급 0으로 읽는다. 읽는 곳은 하위 표 안이어야 하고, 마지막 하위 표만 표 끝까지 읽는다. GPOS가 없는 글꼴은 부호를 결합 등급(태국·라오·히브리·아랍·티베트는 HarfBuzz처럼 다시 분류한 등급)과 외곽 상자로 기저의 위·아래·양옆에 쌓는다. 합자 위의 부호는 제 성분의 몫에 놓는다. 표는 제자리에서 읽고, 읽을 때마다 표의 끝을 넘는지 확인한다. 망가진 표는 틀리게 셰이핑할 뿐 밖을 읽지 않는다. 심볼 파일은 VEGASYM4가 됐다. 헤더의 깃발이 점들 뒤에 셰이핑 구역이 있다고 알린다. 셰이핑이 닿는 글리프(문자 지도, 분해와 합성, 거울, GSUB의 닫힘)는 `0x110000 + 글리프 번호`로 심볼이 된다. 같은 글리프의 심볼들은 점을 나눠 쓴다. HarfBuzz와 다른 파일은 만들어지지 않는다. `vega-symbols build --shape`는 파일을 쓴 뒤 계획마다 무작위 실행을 HarfBuzz 10.2와 함께 셰이핑한다. 실행은 두 종류다. 하나는 낱말들(글꼴의 치환이 시작되는 글자를 자주 섞고, 부호를 하나에서 셋까지 붙이고, ZWJ·ZWNJ를 끼운다)이고, 다른 하나는 아무 순서의 문자(맨 앞의 부호, 쌓인 부호)다. 한글 낱말에는 음절을 만드는 순서의 자모(L V, L V T, LV T)와 그렇지 않은 순서, 옛 자모, 성조 기호를 섞는다. 글리프, 클러스터, advance, 오프셋 가운데 하나라도 다른 실행이 있으면 파일을 지운다. 결과(Pi 5): - DejaVu Sans, 라틴·그리스·키릴·히브리·아랍·Common, 양방향 12개 계획에 각 5만 실행(60만 실행, 1,270만 문자): 다른 실행 0. - 설치된 글꼴 전부(Free, Liberation, Lato, Noto Sans Mono, URW, DejaVu 계열 등 60여 벌), 계획마다 1천 실행: 받아들인 글꼴은 모두 다른 실행 0. 한 번 달랐다. 짝 위치 형식 2에서 두 번째 글리프의 등급이 0이면 다음 하위 표로 넘어가게 짰는데, HarfBuzz 10.2는 그 하위 표를 적용하고 멈춘다(FreeSerif의 y와 마침표 커닝이 40 단위 달랐다). 고친 뒤 FreeSerif, FreeMono, DejaVu Sans Bold, Liberation Mono, Lato에 계획마다 2만 실행으로 다시 확인했다. - 속도: 조회마다, 하위 표마다 시작 글리프의 요약(HarfBuzz의 set digest: 64비트 무늬 셋)을 열 때 만들어 두면, 버퍼에 없는 글리프로 시작하는 조회와 하위 표를 건너뛴다. 문자 지도는 BMP를 쪽마다 바로 찾는다. 이것으로 HarfBuzz의 1.05–1.18배 시간이다(문자당 라틴 422 대 404 ns, 아랍 428 대 371 ns, 히브리 313 대 274 ns). 요약 전에는 2.5배였다. - GPOS 없는 글꼴과 `kern` 표, 한글 셰이퍼를 더한 뒤(설치된 글꼴 209벌, 계획 1,520개에 각 1천 실행): 받아들인 글꼴은 모두 다른 실행 0. 전에 거부하던 글꼴은 따로 계획마다 2만 실행으로 확인했다(Noto Sans CJK KR과 백묵 글꼴의 한글, IPA 고딕과 Noto Mono의 대체 부호 배치, FreeSans·FreeSerif·Liberation·DejaVu Sans ExtraLight의 `kern` 표, Unifont의 라틴·그리스·키릴· 히브리·한글 12개 계획 24만 실행). Unifont에서 한 번 달랐다. 이디시 yod-yod와 patah를 FB1F로 합치는 히브리 표시형 규칙에 yod(05D9)를 적어 두었다(HarfBuzz는 05F2). 부호를 놓는 GPOS `mark`가 없는 글꼴에서만 걸리는 규칙이라 전에는 드러나지 않았다. Unifont의 히브리는 HarfBuzz보다 2.3배 빠르다(문자당 116 대 272 ns). HarfBuzz는 부호마다 CFF 외곽을 풀어 상자를 재고, Vega는 빌드할 때 잰 상자를 읽는다. ### 인도계 문자(데바나가리, 벵골, 구르무키, 구자라트, 오리야, 타밀, 텔루구, 칸나다, 말라얄람) HarfBuzz의 인도계 셰이퍼는 음절 단위로 일한다. 음절을 찾고(Ragel로 짠 문법), 음절마다 기저 자음을 찾아 그 앞뒤로 모음 부호와 reph를 옮기고, 기본 기능(nukt, akhn, rphf, rkrf, pref, blwf, abvf, half, pstf, vatu, cjct)을 하나씩 음절 안에서만 적용한 뒤, 그 결과를 보고 모음 부호와 reph와 앞으로 가는 자음을 다시 옮긴다. 그 사이사이가 GSUB의 멈춤(pause)이다. - 계획: 멈춤은 조회 목록 안의 표지(`VEGA_STEP_PAUSE`와 멈춤 번호)로 싣는다. 셰이퍼가 "이 기능이 이 글리프들을 바꾸겠는가"를 묻는 기능(rphf, pref, blwf, pstf, vatu)은 그 단계의 조회 범위를 계획에 싣고, 실행 중에 조회의 하위 표를 문맥 없이 대어 본다 (`would_apply`, 형식마다 HarfBuzz의 판단 그대로). 옛 규격(deva)과 새 규격(dev2)은 글꼴이 고른 문자 체계 태그로 가른다. - 음절 기계: [text/syllabic.py](../../text/syllabic.py)가 HarfBuzz의 `.rl` 문법(인도계, 크메르, 미얀마)을 그대로 읽어 정규식에서 NFA, DFA를 만들고 최소화한다. Ragel 스캐너의 뜻(각 위치에서 가장 긴 일치, 같으면 먼저 적은 패턴)을 따른다. 같은 문법으로 Ragel이 만든 HarfBuzz의 기계(`hb-ot-shaper-*-machine.hh`)를 해석해 무작위 범주열 10만 개씩 대 보면 세 기계 모두 음절 경계와 종류가 하나도 다르지 않다(`--check`). 문자마다의 범주와 자리는 HarfBuzz 10.2의 `hb-ot-shaper-indic-table.cc`를 칸마다 읽는다 (유니코드 16 기준이라 여기의 15.1 UCD에서 만들지 않는다). - 실행: 모음 제약(다른 모음처럼 읽히는 모음 연쇄에 점선 원), 인도계 분해·합성(몇 글자는 분해하지 않고, 쪼갠 모음 부호는 다시 합치지 않는다), 음절 찾기, 초기 재배치(기저 찾기, 자리순 안정 정렬, 마스크), 깨진 음절의 점선 원, 음절 안에서만 맞추는 조회(`F_PER_SYLLABLE`), 최종 재배치(pref가 만든 기저, 말라얄람의 아래 형태, 모음 부호와 reph와 pref 자음의 자리, 단어 첫머리의 init). HarfBuzz 10.2의 원본을 기준으로 삼았다. 처음에는 rustybuzz 0.20(HarfBuzz 10.0.1을 옮긴 Rust)을 보고 옮겼는데, 설치된 HarfBuzz 10.2와 대 보니 넷이 달랐다. rustybuzz가 틀린 것 둘: 새 규격에서 would-substitute를 문맥 없이 묻는 조건이 뒤집혀 있고, 기저를 찾을 때 미얀마의 자음 집합을 쓴다. 10.0.1 뒤에 HarfBuzz가 바꾼 것 둘: 위첨자 2·3(U+00B2·00B3)이 SMPst라는 새 범주가 됐고(문법에 `sm = SM | SMPst`), 텔루구 길이 부호의 수정 결합 등급이 0이 아니라 4·5다. 넷 모두 HarfBuzz 원본대로 고쳤다. 다섯째는 계획기에 있었다. HarfBuzz는 단계마다 조회를 번호로 정렬한 뒤 같은 조회를 합치는데, 합칠 때 음절 제한과 무작위 깃발은 앞선 쪽의 것을 남긴다. 그 정렬이 자체 퀵정렬(hb_qsort)이라 안정적이지 않고, 앞뒤는 그 알고리즘이 정한다. Noto Sans Malayalam은 psts와 rlig가 한 조회를 나눠 쓰는데, 안정 정렬로는 psts 쪽(음절 안에서만)이 남아 음절을 넘는 합자(ma와 결합 점)가 생기지 않았다. 계획기가 같은 퀵정렬을 그대로 쓰게 했다. 결과(Pi 5): - 설치된 글꼴 213벌, 계획 1,822개에 각 1천 실행: 다른 실행 0(글자 모양이 비트맵뿐인 Noto Color Emoji는 심볼 단계에서 빠진다). 설치된 글꼴이 거부되는 일은 이제 없다. 인터넷에서 받은 Noto 21벌(인도계 아홉, 싱할라, 크메르, 미얀마, 티베트, 자바, 발리, 몽골, 태국, 라오, 시리아, 아랍 등)과 태국 문자 체계를 지운 Loma, 계획 92개에 각 2천 실행: 다른 실행 0. 인도계가 있는 글꼴은 따로 계획마다 2만 실행: FreeSerif(여덟 문자), FreeSans, 굵은 꼴들, FreeMono, Unifont(아홉 문자, GSUB 없음) 130개 계획 260만 실행, 다른 실행 0. - 새 규격 글꼴(dev2 등): 인터넷에서 받은 Noto Sans 아홉 벌(데바나가리부터 말라얄람까지), 계획마다 5천 실행, 다른 실행 0. - 속도: HarfBuzz의 1.25–1.3배 시간(Noto Sans Devanagari 문자당 1,614 대 1,235 ns, Tamil 674 대 526 ns). ### 크메르와 미얀마 같은 틀에 두 셰이퍼를 더 올렸다(HarfBuzz 10.2의 `hb-ot-shaper-khmer.cc`, `-myanmar.cc`). 음절 기계는 앞의 생성기가 이미 만들고 검증한 것이고, 범주는 같은 인도계 표에서 읽는다. 크메르는 음절을 찾은 뒤 조회 전에 재배치한다. coeng과 Ro를 기저 앞으로 옮겨 pref로 표시하고 그 뒤를 cfar로 표시하며, 왼쪽 모음 부호를 맨 앞으로 옮긴다. 쪼개진 모음(U+17BE 등)은 유니코드에 분해가 없어도 U+17C1과 함께 쪼갠다. 미얀마는 kinzi(Ra, asat, virama) 뒤의 기저를 찾고, 글리프마다 기저 둘레의 자리를 매겨 안정 정렬한다. 미얀마는 부호의 폭을 GPOS 앞에서 0으로 만든다(다른 셰이퍼는 뒤에서). 결과: Noto Sans Khmer와 Noto Sans Myanmar(인터넷에서 받은 OpenType 글꼴), 계획마다 2만 실행, 다른 실행 0. Unifont(GSUB 없음)의 두 문자도 0. 처음 돌린 검사에서 바로 맞았다. 음절 기계와 표를 HarfBuzz 원본에서 그대로 가져온 덕이다. ### 범용 셰이핑 엔진(USE): 싱할라, 티베트, 자바, 발리, 몽골 등 80여 문자 마이크로소프트의 USE 규격을 HarfBuzz가 옮긴 셰이퍼(`hb-ot-shaper-use.cc`)다. 범주 표는 HarfBuzz의 압축 표 (`hb-ot-shaper-use-table.hh`, 유니코드 16)를 배열과 조회식 그대로 가져오고, 음절 기계는 앞의 생성기가 USE 문법을 컴파일한다 (Ragel 기계와 10만 실행 비교에서 다른 음절 0). 생성기는 syllabic.h에 손으로 적은 범주 번호가 기계의 번호와 같은지 `_Static_assert`로 컴파일할 때 확인한다. - 음절: 기계는 CGJ를 읽지 않고, 뒤에 부호가 오는 ZWNJ도 읽지 않는다. 읽지 않은 글리프는 앞 음절에 든다(맨 앞의 것은 음절 번호 0으로 남는다. HarfBuzz도 그렇다). - rphf는 음절의 첫 세 글리프(인코딩된 repha면 그 하나)에만 걸고, 적용 뒤에 치환된 첫 글리프를 repha로 기록한다. pref가 만든 글리프는 앞 모음으로 기록한다. 그 사이마다 치환 표시를 지운다. - 재배치: repha를 첫 뒤-기저 글리프 앞(없으면 끝)으로, 앞 모음을 음절 처음이나 마지막 halant 뒤로 옮긴다. - 아랍처럼 잇는 문자(몽골, 응코 등)는 아랍의 연결 상태 기계로 형태를 고르고(몽골의 자유 이체 선택자는 앞 글자의 형태를 따른다), 나머지는 음절끼리 이어 isol·init·medi·fina를 고른다. - 부호의 폭은 GPOS 앞에서 0으로 만든다. 결과: Noto Sans Sinhala·Javanese·Balinese·Mongolian과 Noto Serif Tibetan, FreeSerif의 싱할라, Unifont의 다섯 문자, 계획마다 5천 실행, 다른 실행 0. Unifont의 몽골에서 한 번 달랐다. HarfBuzz 10.2의 기본 무시 문자 목록은 몽골 블록에서 U+180B–180E만 넣고 네 번째 자유 이체 선택자(U+180F)를 빠뜨렸다. Vega는 유니코드대로 넣었었는데, HarfBuzz를 따라 뺐다. ### 태국과 라오 `hb-ot-shaper-thai.cc`: SARA AM을 NIKHAHIT와 SARA AA로 풀고 NIKHAHIT를 위 부호들 앞으로 옮긴다(NIKHAHIT는 폭을 없앨 때 부호로 셈한다). 글꼴에 태국 문자 체계가 없으면(GSUB가 DFLT나 latn만 가지면) 부호를 쌓임에 따라 글꼴의 사용자 영역 글리프(Windows의 F700대, 없으면 Mac의 F880대)로 바꾼다. 계획기가 HarfBuzz처럼 글꼴이 문자 체계를 제 태그로 가졌는지 기록한다. 결과: Noto Sans Thai·Lao, TLWG Loma 네 벌, FreeSerif 계열의 태국, DejaVu Sans 계열의 라오, Unifont, 계획마다 3천 실행, 다른 실행 0. 사용자 영역 경로는 설치된 글꼴에 쓸 일이 없어서, Loma에서 GSUB의 thai 문자 체계만 지운 글꼴을 만들어 5천 실행으로 확인했다(다른 실행 0). ### 아랍 셰이퍼의 나머지: 시리아 문자, 늘이기, 대체 셰이핑 - 연결 종류는 HarfBuzz의 표(`hb-ot-shaper-arabic-table.hh`, 유니코드 16)에서 읽는다. 시리아 문자의 Alaph와 Dalath-Rish 무리가 거기 있고, 상태 기계의 두 열이 그것을 쓴다(fin2, fin3, med2). 표에 없는 글자는 일반 범주로 가른다(Mn, Me, Cf는 T). 이 표를 읽다가 생성기의 결함을 찾았다. HarfBuzz의 생성 표는 범위가 행 중간에서 시작하면 앞 칸을 공백으로 비우는데, 행 주석만 믿고 칸을 세던 파서가 그런 행에서 어긋났다(아랍 보충 블록 등). 이제 표를 조회 함수의 범위와 오프셋으로 읽는다. 인도계 표를 같은 방식으로 다시 읽어 보니 한 칸도 바뀌지 않았다. - 늘이기(stch): 시리아 약어 표시(U+070F)와 아랍 숫자 기호(U+0600–0605) 같은 글자를 글꼴의 stch가 여러 조각으로 나누면, 위치를 다 잡은 뒤 조각들이 앞 낱말의 폭을 덮도록 반복 조각을 복사해 늘어놓는다(재고, 넘치면 조금씩 겹친다). - 대체 셰이핑: 아랍 형태 기능이 없는 글꼴(Unifont)이면 HarfBuzz는 글꼴의 문자 지도에 있는 표시형(U+FB50–FEFF)으로 조회를 만들어 적용한다. 빌드 도구가 같은 조회를 OpenType으로 직접 써서(init·medi·fina·isol의 단일 치환, rlig의 세 합자 조회) 구역에 싣고, 런타임은 멈춤에서 그것을 여느 GSUB 조회처럼 적용한다. 두 글자가 한 글리프를 나누는 글꼴은 HarfBuzz의 정렬 순서가 결과를 가르므로 거부한다. 결과: Noto Sans Syriac, FreeSans의 시리아 문자(늘이기 포함), Unifont의 아랍(대체 셰이핑), Noto Sans Arabic, 계획마다 3천 실행, 다른 실행 0. 약어 표시 하나가 낱말 아래로 아홉 조각까지 늘어나는 것도 HarfBuzz와 글리프·위치가 같다. 받지 않는 것(빌드 도구가 이유를 말하고 거부한다): - `kern` 표의 상태 기계(Apple 형식 1), 교차 방향 커닝. - AAT 표(morx, kerx, trak). - 미얀마 Zawgyi(표준이 아닌 인코딩, 문자 체계 태그 Qaag로만 오는 것). ### HarfBuzz의 시험 모음 무작위 실행은 흔한 경우를 덮지만, 글꼴 하나가 드러낸 버릇은 그 글꼴과 그 입력이 있어야 보인다. HarfBuzz의 시험 모음 (`test/shape/data`: in-house, text-rendering-tests, aots)이 그 목록이다. 버릇이 드러날 때마다 글꼴과 입력을 더해 온 것이다. [text/harfbuzz-suite.py](../../text/harfbuzz-suite.py)는 그 모든 경우를 `vega-symbols shape-cases`로 양쪽에서 셰이핑한다. 경우들이 쓰는 문자 체계로 글꼴의 구역을 컴파일하고(문자 체계와 방향은 HarfBuzz가 추측하는 대로), 실행마다 HarfBuzz와 글리프· 위치를 댄다. 기대 출력 대신 HarfBuzz를 그 자리에서 돌린다. 기록된 출력은 Vega에 없는 선택(글꼴 함수, 크기, 출력 형식)에 딸려 있기 때문이다. Vega가 하지 않는 것을 묻는 경우(사용자 기능, 가변 글꼴, 언어, 앞뒤 문맥, 클러스터 수준, 버퍼 깃발, 세로쓰기)와 다른 시스템의 글꼴은 이유별로 세고 뺀다. [text/harfbuzz-suite.known](../../text/harfbuzz-suite.known)에 적힌 것 밖의 차이는 실패다. 적힌 것이 맞게 되면 알린다(`make check-harfbuzz HARFBUZZ_SRC=...`). 처음 돌렸을 때 비교한 5,199건 가운데 1,355건만 같았다. 차이 대부분은 문자 지도였다(위의 형식 13과 기호 지도). 그것을 고치자 5,011건이 같다. 이체 선택자 순서를 싣자 5,014건이 같고 다른 것이 없다. 남은 것은 거부하는 글꼴(AAT, 글리프를 나눠 쓰는 아랍 대체 셰이핑, `kern` 형식 2, Zawgyi)과 계획이 없는 실행 하나다. 무작위 실행에도 글꼴이 가진 순서와 아무 선택자를 섞는다. Noto Sans CJK의 한자 이체 순서(14,787개)를 섞은 3만 실행이 HarfBuzz와 같다. `kern` 형식 2를 받자 5,017건이 같다. 형식 3은 시험 모음에도 설치된 글꼴에도 없다. 그래서 [text/test-kern.py](../../text/test-kern.py)가 글꼴을 만든다. DejaVu Sans에서 GPOS를 빼고, 형식 3과 형식 2 하위 표를 여러 순서로 넣는다. 등급 값 1, 표 밖 글리프, 배열보다 앞이거나 홀수인 오프셋, 하위 표와 표 끝을 넘는 값, 개수를 넘는 등급과 색인을 담는다. 글꼴 24개, 계획 96개의 무작위 실행이 HarfBuzz와 같다. 처음에는 형식 2가 달랐다. HarfBuzz가 등급 표 밖의 글리프를 다이제스트로 거르므로, 거짓 양성이 된 공백이 등급 0으로 커닝되었다. GSUB가 끝없이 늘리는 글꼴(text-rendering-tests의 GSUB-3)에서 Vega는 버퍼 한도를 넘으면 실패를 돌려주었다. HarfBuzz는 마지막으로 끝난 패스의 글리프로 계속한다. 그 뒤의 패스는 아무것도 하지 않고, 위치는 기본 advance가 된다. Vega도 그렇게 한다. 깨진 글꼴에서도 글이 사라지지 않고 HarfBuzz와 같은 모습으로 남는다(5,018건이 같다). ## 비례폭 글줄 [text/layout.c](../../text/layout.c)는 검증한 부품을 UAX #9 3.4절과 UAX #14가 말하는 순서로 잇는다. 1. 문단의 수준(P2–I2). 제거된 문자는 앞 문자의 수준을 받는다. 2. 문자마다 문자 체계. Common과 Inherited는 앞 문자의 것을 받고, 맨 앞이면 뒤에서 처음 나오는 것을 받는다. 그리고 자소 클러스터(UAX #29)마다 글꼴. 클러스터 하나가 두 글꼴로 갈라지지 않는다. 3. 줄 바꿈 기회와, 문단을 한 번 셰이핑해 잰 문자마다의 폭. 4. 줄: 보이는 부분이 아직 들어가는 마지막 기회에서 바꾼다. 줄 끝의 공백은 줄 밖으로 걸린다. 들어가는 기회가 없으면 첫 기회에서 바꾼다. 줄로 다시 셰이핑하면 잰 것보다 넓을 수 있다(줄 끝 글자가 끝 꼴이 되는 아랍 문자). 그러면 앞 기회로 물러난다. 5. 줄마다: 줄 끝에서 L1을 하고, 수준과 문자 체계와 글꼴이 같은 실행으로 나눠 다시 셰이핑하고(연결과 커닝이 줄과 함께 끝난다), 실행들을 L2 순서로 놓고, 글리프를 왼쪽부터 놓는다. 오른쪽에서 왼쪽으로 쓰는 문단의 줄은 오른쪽 끝에 맞춘다. 글꼴은 선호 순서의 목록이다(`VegaFonts`). 클러스터는 그 문자를 모두 가진 첫 글꼴로 간다. 기본 무시 문자(ZWJ, 이형 선택자 등)는 셰이핑이 숨기므로 없어도 된다. Common과 Inherited만으로 된 클러스터(공백, 숫자, 문장 부호)는 앞 클러스터의 글꼴에 있으면 그 글꼴에 남는다. 그래서 아랍 낱말 사이의 공백이 아랍 글꼴에 남아 실행이 공백마다 끊기지 않는다. 모두 가진 글꼴이 없으면 첫 문자를 가진 첫 글꼴로, 그것도 없으면 첫 글꼴(의 .notdef)로 간다. "가졌다"는 기본으로 문자표이고, 부르는 쪽이 좁힐 수 있다. 심볼 파일은 코드가 닿는 글리프만 담으므로, 픽셀 표면은 문자표에 있고 그 글리프가 파일에도 있는 문자만 가졌다고 한다. 위치는 첫 글꼴의 단위다. 다른 단위(upem)의 글꼴은 실행 안의 펜을 통째로 바꿔 반올림이 쌓이지 않게 한다. 정답인 참조 구현이 없으므로(브라우저마다 다르다), [text/test-layout.c](../../text/test-layout.c)는 부품이 약속하는 것을 확인한다. 무작위 문단(라틴·아랍·히브리 낱말, 부호, 숫자, 괄호, 방향 표시, 줄 구분자)을 무작위 폭으로 짠다. 그리고 다음을 본다. - 줄이 문단을 순서대로 빈틈없이 덮는다. - 필수 줄 바꿈마다 줄이 끝난다. - 안에서 바꿀 기회가 있는 줄은 폭을 넘지 않는다. - 글리프의 클러스터가 자기 줄 안에 있다. - 글리프가 `vega_bidi_order`(줄 끝의 L1 뒤)가 문자를 놓는 순서대로 놓인다. 3만 문단(565만 문자, 22.9만 줄)에서 어긋남이 없다. 글꼴 목록은 픽셀 표면의 조판이 끝에서 끝까지 확인한다 ([003](003-pixel-surface.md)). 문자당 776 ns다(재기 위한 셰이핑과 줄마다의 셰이핑 두 번, 양방향, 줄 바꿈 포함). `vega-symbols paragraph`는 문단을 짜서 심볼로 그린다. 라틴(ffi·fi·fj 합자, AVAWA 커닝), 아랍(연결, lam-alef, 부호, 오른쪽 맞춤과 줄 사이의 순서), 히브리 부호, 섞인 문장(왼쪽에서 오른쪽 문장 안의 아랍 낱말과 괄호, 숫자)을 눈으로도 확인했다. ## 접근성: 웹 웹 표면은 말하기 표면(NUI)이 말하는 것을 화면 낭독기에도 준다. - 목록은 listbox다. 키보드 포커스를 가진 채로 남고, 포커스된 행이 그 목록의 활성 자손(`aria-activedescendant`)이다. 화면 낭독기는 목록 안에서 선택이 옮겨 가는 것을 따라 읽는다. - 페이지는 들어가는 행만 그린다. 그래서 각 선택지가 목록 전체에서의 자리(`aria-posinset`, `aria-setsize`)를 안다. 천 개 가운데 열두 개가 보여도 "12 가운데 3"이 아니라 "1000 가운데 437"로 읽힌다. - 표는 grid다. 행이 전체에서의 번호(`aria-rowindex`, `aria-rowcount`)를 가진다. - 보통과 흐림이 아닌 어조(강조, 좋음, 경고, 위험)는 색만이 아니라 말로도 붙는다. 말하기 표면과 같은 규칙이다. - 키는 이름 있는 단추다(`aria-keyshortcuts`). 글 입력은 그 입력의 이름을 가진다. - 다시 그릴 때 요소를 새로 만들지 않고 제자리에서 고친다. 전에는 갱신마다 목록을 새로 만들어 화면 낭독기가 쫓던 포커스가 사라졌다(초마다 바뀌는 필드 하나가 목록을 초마다 다시 읽게 했다). 확인은 Chromium의 접근성 트리로 한다(`tests/browser.mjs`, DevTools 프로토콜의 `Accessibility.getFullAXTree`). - 제목이 수준 1의 heading이다. - 목록이 뷰 이름을 가진 listbox이고 포커스를 가진다. - 선택지가 할 일들이고, 포커스된 것이 선택되어 활성 자손이다. - 위 화살표 뒤에도 같은 목록 노드가 포커스를 가진 채 활성 자손만 옮긴다. - 추가한 할 일이 "4 가운데 4"이다. - 한 일은 이름에 "done"이 들어간다. 집합 안의 자리는 DevTools 트리에 없어서, Chromium이 플랫폼 접근성 API로 넘기는 속성으로 본다. ## 접근성: 리눅스 데스크톱(AT-SPI2) 디스플레이 표면(`vega display --access`)은 접근성 버스에 AT-SPI2 객체로 나타난다. Orca 같은 화면 낭독기가 읽는 표준이다. GTK나 ATK, libdbus 없이 D-Bus를 명세의 전송 형식 그대로 말한다([surfaces/dbus.c](../../surfaces/dbus.c)). 유닉스 소켓, EXTERNAL 인증, 리틀 엔디언의 고정 머리와 머리 필드, 정렬된 본문, 메서드 호출과 응답과 신호다. [surfaces/access.c](../../surfaces/access.c)는 세션 버스에 접근성 버스의 주소를 묻고, 그 버스에서 레지스트리에 자신을 끼운다 (`Socket.Embed`). 객체는 말하기 표면이 말하는 순서를 따른다. application 아래 frame(제목)이 있고, 그 아래에 다음이 온다. - heading - 필드마다 label("이름: 값") - 목록마다 list box. 행마다 list item이 있다. 포커스된 행은 selected이고, 키가 움직이는 목록이면 focused다. 행은 전체에서의 자리(`posinset`, `setsize`)를 속성으로 가진다. - 표마다 table. 칸들을 가진 행으로 이루어진다. - entry - 키들의 tool bar와 push button 대답은 물을 때마다 앱에서 구하므로 낡을 것이 없다. focused는 한 객체만 가진다. 글을 치는 동안은 entry, 아니면 키가 움직이는 목록의 포커스된 행이다. 보조 기술은 앱을 움직일 수도 있다. 키의 단추를 누르면(Action) 그 키가 눌리고, 행을 고르면(Selection) 그 행이 포커스된다. 앱이 바뀌면 옮겨 간 포커스를 사건(`Object:StateChanged` focused·selected, `SelectionChanged`)으로 알린다. 화면 낭독기가 묻기만 할 때는 다시 그리지 않는다. 전에는 깨어날 때마다 그렸고, 질문이 몰리면 되돌려 쓰기(writeback) 프레임을 놓쳤다. 확인은 `tests/check.py`의 `accessibility`가 한다. 개인 세션 버스를 띄우고 그 안에서 `at-spi-bus-launcher`를 돌린다. 그리고 화면 낭독기처럼 gdbus로 다음을 본다. - 레지스트리가 앱을 나열한다. - 트리의 역할과 이름이 맞다(제목 heading, 필드 label, 목록 list box와 a·b·c 항목과 자리, entry, 키 단추). - 처음에는 entry만 focused다. - Tab 뒤에는 c, 위 화살표 뒤에는 b가 focused다. Selection으로 고르면 a, Action으로 토글 키를 누르면 "a, done"이다. - 모니터한 사건에서 포커스가 c, b, a 순서로 옮겨 간다.