국제화

오른쪽에서 왼쪽으로 쓰는 텍스트와 방향이 섞인 텍스트, 아랍 문자 형태 처리, 포커스 패드 번역.

Text.js는 DevTools, 터미널, DOM 미러에서 히브리어, 아랍어, 페르시아어와 그 밖의 오른쪽에서 왼쪽으로 쓰는 문자를 셀 격자에 맞춰 올바른 순서로 렌더링해요. 유니코드 양방향 알고리즘(UAX #9, Unicode 18.0)을 구현하며 공식 적합성 테스트 모음을 통과해요. BidiTest.txt의 770,241개 사례 전체(모든 문단 방향에서 모든 입력)와 BidiCharacterTest.txt의 91,707개 사례 전체를 통과해요.

실시간48×6 · booting Turbolog…tsx
preview://bidi:1
import { Box, Text } from "@textjs/react";export default function Page() {  return (    <Box flexDirection="column" padding={1}>      <Text>שלום עולם! זהו טקסט בעברית.</Text>      <Text>مرحبا بالعالم! هذا نص عربي.</Text>      <Text>Order #1234 (<Text color="accent">שלום</Text>) total: 25.50 ₪</Text>      <Text>user <Text dir="auto" color="success">שלום!</Text> replied 3 times</Text>    </Box>  );}

방향

각 문단(\n으로 구분된 <Text>의 각 줄)은 HTML의 dir="auto"처럼 첫 번째 강한 방향성 문자로 기본 방향을 정해요. 오른쪽에서 왼쪽으로 쓰는 문단은 기본적으로 오른쪽에 정렬돼요.

<Text>שלום</Text>                {/* auto: RTL, right-aligned */}<Text dir="rtl">hello!</Text>     {/* "!hello", right-aligned */}<Text dir="ltr">שלום!</Text>      {/* LTR paragraph: "םולש!" reads correctly left of the "!" */}<Text align="left">שלום</Text>    {/* RTL order, pinned to the left edge */}
align왼쪽에서 오른쪽으로 쓰는 문단오른쪽에서 왼쪽으로 쓰는 문단
"start" (기본값)왼쪽오른쪽
"end"오른쪽왼쪽
"left" / "center" / "right"지정한 대로지정한 대로

중첩된 <Text>에서 dir은 HTML의 <bdi>처럼 스팬을 격리해요. 내용은 독립적으로 배치되며 주변 텍스트의 순서를 바꿀 수 없어요. 사용자가 입력한 이름, 제목처럼 방향을 알 수 없는 텍스트에 사용하세요.

<Text>user <Text dir="auto">{name}</Text> replied 3 times</Text>// name = "שלום!"  →  user !םולש replied 3 times   (isolated: "!" ends the name)// without dir     →  user םולש! replied 3 times   ("!" resolves with the Latin text around it)

렌더링 방식

  1. 논리적 순서로 줄 바꿈. 왼쪽에서 오른쪽으로 쓰는 텍스트와 똑같이 셀 너비를 기준으로 줄을 나눠요.
  2. 각 줄의 순서 재배치. 알고리즘은 문단 전체의 임베딩 수준(명시적 임베딩과 격리, 약한 유형과 중립 유형, 괄호 쌍)을 결정한 뒤, 줄 바꿈된 각 줄의 순서를 개별적으로 재배치해요(규칙 L1~L2). 괄호와 그 밖의 대칭 문자는 오른쪽에서 왼쪽 방향 수준에서 반전되므로(L4), 히브리어 안에서도 (abc)는 (abc)로 유지돼요.
  3. 아랍어 자형 처리. 문자는 Arabic Presentation Forms 블록에서 문맥에 맞는 형태(독립형, 어두형, 어중형, 어말형)를 가져오며, 줄에서 수준이 같은 각 구간 안에서 논리적 순서로 계산해요(ZWJ와 ZWNJ는 해당 위치에서 반영돼요). 문자 하나는 셀 하나를 차지하고, 연결된 형태는 커서나 색상 변경 같은 스팬 경계에서도 유지되며, 콘솔이 자형을 처리할 필요가 없어요.
  4. 순서 고정. 이제 셀에는 표시 순서대로 글리프가 들어 있어요. 브라우저와 많은 터미널이 여기에 양방향 처리를 한 번 더 적용하므로 드라이버가 순서를 고정해요:
출력순서 고정 방식
DevTools (CssConsoleDriver)오른쪽에서 왼쪽으로 쓰는 문자가 포함된 스팬을 LRO … PDF(U+202D / U+202C)로 감싸요. DevTools의 %c 정제기는 direction과 unicode-bidi를 제거해요.
터미널 (AnsiDriver, text run)줄마다 양방향 처리를 실행하는 터미널을 위해 오른쪽에서 왼쪽으로 쓰는 텍스트가 있는 행을 LRO … PDF로 감싸요. renderToTerminal은 진입 시 한 번 ECMA-48 BDSM 명시적 모드(CSI 8 l, VTE 기반 터미널에서 지원)로 전환하고 종료 시 암시적 모드(CSI 8 h)로 돌아가며, 오른쪽에서 왼쪽으로 쓰는 텍스트가 포함된 일회성 renderToAnsi 프레임은 CSI 8 l로 시작해요. 셀을 그대로 출력하려면 bidi: "none"을 전달하세요.
DOM 미러 (<ConsoleMirror>)미러는 direction: ltr로 설정되어 RTL 호스트 페이지에서도 방향이 뒤집히지 않으며, 오른쪽에서 왼쪽으로 쓰는 텍스트가 포함된 스팬에는 unicode-bidi: bidi-override가 적용돼요.

<Box title>, <Box bottomTitle>, <Divider title>도 같은 단계를 거치므로(drawLogicalText(buffer, x, y, text, …)는 모든 단일 줄을 이 방식으로 그려요), 레이블이 올바른 순서로 연결되어 표시돼요.

오른쪽에서 왼쪽으로 쓰는 문자가 없는 텍스트는 이 과정을 모두 건너뛰어요. 순수하게 왼쪽에서 오른쪽으로 쓰는 텍스트 모델은 자소를 분리하는 동안 감지되며(자소당 1번 비교), 기존 경로 그대로 그려져요. 콘솔 프레임은 바이트 단위로 동일해요.

셀 너비

기본 제공 글꼴 스택은 "SF Mono", Menlo, "Cascadia Mono", Consolas, monospace예요. 이 글꼴에는 히브리어나 아랍어 글리프가 없어서 브라우저가 문자마다 대체 글꼴을 사용해요. macOS에서는 0.6em 고정폭인 Courier New를 사용하며, 12px에서 글리프당 너비가 7.2012px로 Menlo의 7.2246px와 차이가 나요. 0.3% 더 좁아서 히브리어 한 행 전체가 약 반 셀만큼 어긋날 수 있어요.

CssConsoleDriver는 콘솔 글꼴을 적용한 캔버스에서 오른쪽에서 왼쪽으로 쓰는 각 스팬을 측정하고(DevTools와 동일한 Blink 자형 처리 및 대체 글꼴 사용), 정확한 LayoutUnit(1/64px) 차이만큼 패딩을 더해요. 스팬은 차지하는 격자 셀과 같은 너비가 돼요(이모지처럼 넓은 자소는 2개로 계산해요). 스팬은 최대 반 셀까지만 줄어들므로, 가변폭 대체 글꼴(Windows에서 Cascadia Mono 옆에 쓰이는 Times New Roman)은 다음 스팬이 글리프와 겹치는 대신 영역 밖으로 넘쳐요. 실제 DevTools 프런트엔드(Chromium 151)에서 히브리어, 아랍어, 혼합 텍스트, 히브리어 안의 이모지 줄과 RTL 상자 제목으로 구성된 64×21 프레임을 측정한 결과예요(bun bench/devtools/bidi-shot.ts):

프레임행 사이 오른쪽 가장자리 편차
순서를 고정하지 않고 출력한 셀14.5px (DevTools가 순서 재배치 및 자형 재처리)
순서 고정, 너비 맞춤 없음2.22px
기본 제공 드라이버0.156px
대조군: 동일한 셀에서 모든 RTL 글리프를 x로 대체1.67px

기본 제공 프레임은 이모지 행을 제외한 모든 행에서 전체 라틴 문자 대조군과 일치하며, 이모지 행에서는 더 나은 결과를 보여요. 이모지는 주변의 오른쪽에서 왼쪽으로 쓰는 스팬과 함께 너비가 맞춰지지만(0.03px), 대조군의 왼쪽에서 오른쪽으로 쓰는 이모지 행에는 너비 맞춤이 적용되지 않아요(1.66px).

DevTools는 모든 %c 스팬을 contain: paint로 그리고 각각을 기기 픽셀에 개별적으로 맞추므로, 두 배경이 소수점 위치에서 만나는 곳에서는 사이의 픽셀이 어느 쪽에도 속하지 않아 1px의 가는 틈이 생길 수 있어요. Chrome과 Edge에서는 배경이 있는 스팬이 오른쪽의 불투명한 이웃 아래로 배경을 1px 확장하며, 너비를 맞춘 스팬은 그 픽셀을 패딩에 추가해요. 위의 4개 프레임에는 모두 가는 틈이 없어요.

너비 맞춤을 끄려면 measureText: false를 전달하고, 직접 측정하려면 (text, bold, italic) => px 측정 함수를 제공하세요.

입력과 편집

<Input>과 <Textarea>는 논리적 커서를 유지해요. 방향키는 저장 순서대로 텍스트를 이동하므로, 오른쪽에서 왼쪽으로 쓰는 텍스트에서 ←는 시각적으로 오른쪽으로 이동해요. 블록 커서는 순서 재배치로 어디에 놓이든 해당 논리적 문자 위에 표시돼요. 오른쪽에서 왼쪽으로 쓰는 구간 안에서는 이전 문자의 왼쪽 셀이에요. 선택 영역도 논리적 범위이므로, 방향이 섞인 줄에서는 강조 표시가 서로 떨어진 셀에 걸칠 수 있어요. 한 줄짜리 <Input>의 가로 스크롤은 논리적 열을 따르므로, 오른쪽에서 왼쪽으로 쓰는 값이 길면 스크롤로 커서가 화면 밖으로 나갈 수 있어요. 공백이나 텍스트 끝에 있는 커서는 격리된 줄 바꿈 없는 공백으로 그려지므로, 중립 상태를 유지하며 양옆의 숫자와 연결되지 않아요.

제한 사항

  • 문단 구분자. 줄은 \n에서 나뉘어요. U+2029와 그 밖의 문단 구분자는 양방향 문단을 끝내지만 새 줄을 시작하지는 않아요.
  • 탭. 탭은 양방향 처리를 실행하기 전에 공백으로 확장되므로, 세그먼트 구분자(S)가 아닌 공백 문자(WS)로 동작해요.
  • 자형 처리. 표시형이 있는 문자만 자형을 처리해요. 아랍어, 페르시아어, 우르두어와 U+FB50~U+FDFF 및 U+FE70~U+FEFF의 다른 문자가 해당해요. 람-알레프는 글리프 하나인 합자가 아니라 2개 셀에 연결된 2개 문자로 유지돼요. 시리아 문자, N'Ko와 그 밖의 연결 문자는 기본 형태를 유지해요.
  • 복사. 콘솔에서 복사한 텍스트는 논리적 순서가 아니라 표시 순서이며 LRO/PDF 표시가 포함돼요.
  • 터미널. BDSM과 LRO를 모두 무시하고 자체 양방향 처리를 실행하는 터미널(예: iTerm2에서 양방향 처리를 활성화한 경우)은 오른쪽에서 왼쪽으로 쓰는 행의 순서를 다시 바꿀 수 있어요.
  • DOM 미러. <ConsoleMirror>는 너비를 맞추지 않으므로, 오른쪽에서 왼쪽으로 쓰는 구간은 너비의 최대 0.3%만큼 어긋날 수 있어요.

포커스 패드 번역

@textjs/react/dom의 <FocusPad>는 방문자에게 콘솔을 열고 페이지를 클릭한 다음 키를 누르도록 안내해요(<TextCanvas>도 이를 렌더링해요). 기본 텍스트는 영어예요. 번역하려면 바꾸려는 문자열을 담은 messages를 전달하세요. 생략한 키는 defaultFocusPadMessages에 나열된 영어 텍스트를 유지해요.

import { FocusPad, type FocusPadMessages } from "@textjs/react/dom";const ja: Partial<FocusPadMessages> = {  openConsole: "コンソールを開く",  step: (n, total) => `${total}ステップ中${n}ステップ目`,  then: (action) => `次にここをクリックして、${action}。`,  shortcutOpens: (shortcut) => <>{shortcut}でコンソールが開きます。</>,};<FocusPad root={root} hints={[{ keys: "↑ ↓", label: "件数" }]} action="↑ ↓を押します" messages={ja} />;

값이 포함된 문자열은 문장 전체를 반환하는 함수이므로, 번역문에서 값을 원하는 위치에 놓을 수 있어요. step, pressShortcut, then, defaultAction, shortcutOpens는 함수예요. step은 단계 번호와 전체 단계 수를 받아요. then은 action prop을 받고, action이 없으면 defaultAction의 텍스트를 받아요. defaultAction은 첫 번째 힌트의 키를 받고, 힌트가 없으면 null을 받아요. pressShortcut은 스크린 리더용 단축키를 받고, shortcutOpens는 단축키를 <kbd> 요소로 받아요.

messages에는 스크린 리더가 읽는 레이블과 콘솔 크기 조절 컨트롤(sizeControl)의 텍스트도 포함돼요. <TextCanvas>도 같은 messages prop을 받아요. <ViewportControl>도 이 prop을 받아 크기 조절 컨트롤의 키를 사용해요. ⌘⌥J, Ctrl+Shift+J, Enter 같은 키캡은 번역하지 않아요.

API

import { reorderLine, paragraphDirection, resolveLevels, bidiClass, shapeArabicCodePoints } from "@textjs/core";reorderLine("שלום (abc) 123!");          // "!123 (abc) םולש": one line in display orderreorderLine("مرحبا", "auto", { shape: true }); // presentation forms, display orderparagraphDirection("123 שלום");           // "rtl" (rules P2–P3)

resolveLevels(types, codePoints, direction), applyLineRules, reorderIndices는 알고리즘 자체인 규칙 P1~I2, L1, L2를 공개해요.

GitHub에서 이 페이지 편집