国际化

从右到左与混合方向文本、阿拉伯文塑形及焦点输入区翻译。

Text.js 在 DevTools、终端和 DOM 镜像中按正确顺序渲染希伯来文、阿拉伯文、波斯文等从右到左的文字,并对齐单元格网格。它实现了 Unicode 双向算法(UAX #9,Unicode 18.0),并通过了官方一致性测试套件:全部 770,241 个 BidiTest.txt 用例(每种段落方向下的每个输入)和全部 91,707 个 BidiCharacterTest.txt 用例。

实时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>  );}

方向

每个段落(<Text> 中以 \n 分隔的每一行)都根据首个强方向字符选择自己的基础方向,与 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> 一样隔离 span:其内容独立布局,不会改变周围文本的顺序。对于用户提供的姓名、标题以及其他方向未知的文本,使用此属性。

<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. 阿拉伯文塑形。 字母从阿拉伯文表现形式区块中取得上下文形式(独立形、首形、中形、尾形),在行内每个同层级片段中按逻辑顺序计算(ZWJ 和 ZWNJ 在各自所在位置参与计算)。一个字母占一个单元格,连接形式跨越任何 span 边界(光标、颜色变化)都能保留,控制台无需再做塑形。
  4. 固定顺序。 此时单元格中的字形已按显示顺序排列。浏览器和许多终端会再次对其运行双向算法,因此驱动会锁定顺序:
输出固定顺序的方式
DevTools(CssConsoleDriver)包含从右到左字符的 span 用 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 宿主页面无法翻转它),包含从右到左文本的 span 会设置 unicode-bidi: bidi-override。

<Box title>、<Box bottomTitle> 和 <Divider title> 经过相同的步骤(drawLogicalText(buffer, x, y, text, …) 以这种方式绘制任意单行文本),因此其标签按顺序显示,并保留字母连接。

不含从右到左字符的文本会跳过以上所有步骤。纯从左到右的文本模型会在字素分段时被识别(每个字素比较一次),并通过未改动的路径绘制;控制台帧逐字节一致。

单元格宽度

当前发布版的字体栈是 "SF Mono", Menlo, "Cascadia Mono", Consolas, monospace。这些字体都没有希伯来文或阿拉伯文字形,因此浏览器会逐字符回退到其他字体。在 macOS 上,回退字体是 Courier New,其等宽字形宽度为 0.6 em:字号为 12 px 时,每个字形宽 7.2012 px,而 Menlo 为 7.2246 px。它窄了 0.3%,足以让一整行希伯来文偏移约半个单元格。

CssConsoleDriver 使用控制台字体在 canvas 上测量每个从右到左的 span(采用与 DevTools 相同的 Blink 塑形和字体回退机制),然后按精确的 LayoutUnit(1/64 px)差值补充内边距。span 的最终宽度与其覆盖的网格单元格相同(emoji 等宽字素占两个单元格)。span 最多收缩半个单元格,因此比例回退字体(Windows 上与 Cascadia Mono 搭配的 Times New Roman)会溢出,而不会让下一个 span 覆盖其字形。在真实 DevTools 前端(Chromium 151)中,测量了一个 64×21 的帧,其中包含希伯来文、阿拉伯文、混合文本、希伯来文中夹有 emoji 的行,以及 RTL 框标题(bun bench/devtools/bidi-shot.ts):

帧各行右边缘偏移
未固定顺序时输出的单元格14.5 px(DevTools 重排并重新塑形)
固定顺序,未适配宽度2.22 px
当前发布版驱动0.156 px
对照组:相同单元格,将所有 RTL 字形替换为 x1.67 px

当前发布版的帧在除 emoji 行之外的每一行上都与全拉丁文对照组一致,而在 emoji 行上表现更好:emoji 会随周围从右到左的 span 一起适配宽度(0.03 px),而对照组中从左到右的 emoji 行未做宽度适配(1.66 px)。

DevTools 使用 contain: paint 绘制每个 %c span,并将各个 span 分别对齐到设备像素,因此当两个背景在小数位置相接时,中间的像素可能不属于任何一方,留下 1 px 的细缝。在 Chrome 和 Edge 中,带背景的 span 会将背景向右延伸 1 px,伸入不透明的右侧相邻 span 下方,经过宽度适配的 span 则会将这个像素加入内边距。上面的四个帧都没有出现细缝。

传入 measureText: false 可关闭宽度适配,也可以提供自己的 (text, bold, italic) => px 测量函数。

输入与编辑

<Input> 和 <Textarea> 使用逻辑光标。方向键按存储顺序在文本中移动,因此在从右到左的文本中,← 在视觉上向右移动。无论重排将字符放到哪里,块状光标都会位于其覆盖的逻辑字符上。在从右到左的片段中,这个位置是前一个字符左侧的单元格。选区也是逻辑范围,因此在混合方向的行中,高亮可能覆盖不连续的单元格。单行 <Input> 的水平滚动跟随逻辑列,因此较长的从右到左文本值可能使光标滚出可见区域。空格上或文本末尾的光标会绘制为隔离的不换行空格,因此保持中性,不会与任一侧的数字连接。

限制

  • 段落分隔符。 文本在 \n 处换行。U+2029 和其他段落分隔符会结束双向段落,但不会开始新行。
  • 制表符。 制表符在双向算法运行前展开为空格,因此按空白字符(WS)处理,而非分段分隔符(S)。
  • 塑形。 仅对具有表现形式的字母塑形:阿拉伯文、波斯文、乌尔都文,以及 U+FB50–U+FDFF 和 U+FE70–U+FEFF 中的其他字母。Lam-alef 保持为占据两个单元格的两个相连字母,而非单字形合字。叙利亚文、西非书面文字及其他连写文字保留其标称形式。
  • 复制。 从控制台复制的文本按显示顺序排列(带有 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 上编辑此页