多语言与从右往左
爬山虎的界面和用户写的内容可能是任何语言。这一章规定:每种文字用什么字体、行高和字距怎么随文字变、阿拉伯文这类从右往左的语言怎么镜像,以及数字、日期和长文本的写法。规则都落在 token 里,Web 和 Flutter 读同一份数据。
支持的语言
| 语言 | 标签 | 文字系统组 | 方向 | 备注 |
|---|---|---|---|---|
| 英语 | en |
默认 | 从左往右 | |
| 简体中文 | zh-CN |
hans |
从左往右 | 不带地区的 zh 也归这一组 |
| 繁体中文 | zh-TW |
hant |
从左往右 | 香港 zh-HK 单独一组 hant-hk |
| 日语 | ja |
jpan |
从左往右 | |
| 韩语 | ko |
kore |
从左往右 | |
| 西班牙语、法语、德语、意大利语、葡萄牙语(巴西)、印尼语 | es fr de it pt-BR id |
默认 | 从左往右 | 品牌字体全部覆盖 |
| 俄语、土耳其语 | ru tr |
默认 | 从左往右 | 萌虎、像素的部分字体缺字母,整段换字体,见下文 |
| 越南语 | vi |
viet |
从左往右 | 拉丁字母,但大写上叠两层符号,行高放宽 |
| 印地语 | hi |
deva |
从左往右 | |
| 孟加拉语 | bn |
beng |
从左往右 | |
| 泰语 | th |
thai |
从左往右 | |
| 阿拉伯语 | ar |
arab |
从右往左 | 波斯语、乌尔都语也归这一组 |
先告诉界面用的是哪种语言
字体、行高、字距和镜像全部跟着语言标签走,所以第一步是把语言写对。
Web:在 <html> 上写 lang 和 dir,用 @bostonivy/design 的 applyLocale:
import { applyLocale } from '@bostonivy/design';
applyLocale(document.documentElement, 'ar'); // <html lang="ar" dir="rtl">
- 已知语言的内容块(比如一段日文评论)可以单独加
lang,它会用自己那一组的字体和行高。 - 用户写的任务标题、评论、描述,语言事先不知道:元素加
dir="auto",按第一个强方向字符决定方向。
Flutter:MaterialApp 配好 locale、supportedLocales 和 GlobalMaterialLocalizations 等代理,Directionality 会跟着语言自动变成从右往左。主题要把同一个 locale 传进去:
MaterialApp(
locale: locale,
supportedLocales: supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: bostonIvyTheme(BiStyle.clean, Brightness.light, locale: locale),
darkTheme: bostonIvyTheme(BiStyle.clean, Brightness.dark, locale: locale),
);
跟随系统语言时,用 WidgetsBinding.instance.platformDispatcher.locale,并在 didChangeLocales 里重建主题。
字体回退
品牌字体(Inter、Fredoka、JetBrains Mono、Silkscreen)只负责拉丁字母。其他文字交给系统自带的字体,客户端不打包:一套中日韩字体就有 10 到 20 MB,而且系统字体和用户在别的应用里看到的一致。
每个字体栈都按这个顺序拼起来:
- 风格的品牌字体
- 界面语言那一组的系统字体
- 其余各组的系统字体(一段文字里混着几种文字时,每种文字都有固定的字体)
- 彩色 emoji
system-ui,然后是通用字族(sans-serif或monospace)
第 5 步的 system-ui 不能省。实测 macOS 上的 Chrome:品牌字体是等宽的 JetBrains Mono 时,如果没写明阿拉伯文字体,浏览器会挑一个等宽的阿拉伯文字体来回退,字母一个个断开。所以阿拉伯文组里写了 macOS 自带的 Geeza Pro。
| 组 | 语言 | macOS / iOS | Windows | Android / Linux |
|---|---|---|---|---|
hans |
简体中文 | 苹方-简、冬青黑体简体中文 | 微软雅黑 | 思源黑体 SC(Noto Sans CJK SC) |
hant |
繁体中文(台湾) | 苹方-繁 | 微軟正黑體 | 思源黑體 TC |
hant-hk |
繁体中文(香港) | 苹方-港 | 微軟正黑體 | 思源黑體 HK |
jpan |
日语 | ヒラギノ角ゴシック | Yu Gothic UI、Meiryo UI | Noto Sans CJK JP |
kore |
韩语 | Apple SD Gothic Neo | 맑은 고딕(Malgun Gothic) | Noto Sans CJK KR |
deva |
印地语等 | Kohinoor Devanagari | Nirmala UI | Noto Sans Devanagari |
beng |
孟加拉语等 | Kohinoor Bangla | Nirmala UI | Noto Sans Bengali |
thai |
泰语 | Thonburi | Leelawadee UI | Noto Sans Thai |
arab |
阿拉伯语等 | Geeza Pro | Segoe UI | Noto Sans Arabic UI、Noto Naskh Arabic UI |
完整的字体名单在 bostonivy-tokens.json 的 core.script.<组>.font,emoji 在 core.font.emoji。
为什么中日韩要分组:同一个汉字码位在简体、繁体、日文、韩文里字形不同,比如「骨」「直」「說」。浏览器和 Flutter 都靠语言标签挑字形,语言标签不对,日文界面里就会出现中文字形。
三种风格怎么保持一致
| 风格 | 拉丁字母 | 其他文字 | 风格靠什么撑住 |
|---|---|---|---|
| 青藤 | Inter | 系统黑体 | Inter 和各语言的系统黑体气质接近,不需要特殊处理 |
| 萌虎 | Fredoka | 先找圆体(圆体-简、圆体-繁、ヒラギノ丸ゴ),没有就用系统黑体 | 粗描边、硬阴影、糖果色;Windows、Android 和非中日文没有圆体,字形是普通黑体 |
| 像素 | JetBrains Mono,标题和计时器用 Silkscreen | 系统黑体,不用点阵字体 | 直角、1px 描边、[ ] !!! #标签 这些写法、像素吉祥物 |
- 整段换字体,不在一个词里混字体。 Fredoka 和 Silkscreen 缺土耳其文(ğ ş İ)、越南文、西里尔字母、波兰文、捷克文、罗马尼亚文、匈牙利文(ő ű)和希腊字母。界面是这些语言时,萌虎整体换成 Inter,像素的标题和计时器整体换成 JetBrains Mono。如果不换,「Değiştir」里的 ğ 会落到另一种字体上。名单在
style.sticker.font和style.pixel.font.display的substituteFor里。 - 像素风格不用点阵中文。 12 到 13px 的点阵汉字只有 12×12 的格子,「藤」「警」这种笔画多的字会糊成一团;开源点阵字体的字数也不够,生僻字会掉回黑体,同一行里一半点阵一半黑体反而更乱;汉字也不是 JetBrains Mono 字宽的整两倍,对不齐等宽网格。
- Silkscreen 只有大写形状,只用于英文和数字。中文、日文、阿拉伯文这些没有大小写的文字,
text-transform: uppercase不起作用,也不需要。
行高与字距
天城文、孟加拉文、泰文、阿拉伯文的符号会伸到拉丁字母上下方很远的地方,越南文的大写字母上叠两层符号。行高不够时,多行文字会互相碰到;单行省略的元素(overflow: hidden)还会直接把字形切掉。每组都有一个行高下限,三种风格的行高只会被抬到这个下限,不会被压低。
| 组 | 行高下限(字号的倍数) | 字距 | 依据 |
|---|---|---|---|
| 默认(拉丁、西里尔、希腊) | 不限 | 保留 | |
hans hant hant-hk jpan kore |
不限 | 归零 | 方块字本身排得很满,现有行高按中文调过 |
viet |
1.45 | 保留 | Inter 的「Ẫ」顶部到 1.09 em |
deva |
1.45 | 归零 | 元音符号、连字上下伸出;字母靠顶线连在一起,加减字距都会断开 |
beng |
1.45 | 归零 | 同天城文 |
thai |
1.6 | 归零 | 声调符号叠在元音上,Thonburi 顶部到 1.15 em |
arab |
1.75 | 归零 | 连写体,字距会断开连笔;Geeza Pro 下伸 0.51 em |
依据一栏的数值是用 HarfBuzz 排版常见界面文字、量出来的墨迹范围。下限的算法:品牌字体的行盒在基线上下留出的空间,要能装下墨迹的最高点和最低点。
- Web:
--type-*写成max(原行高, 字号 × var(--leading-min)),字距写成原字距 × var(--tracking-scale)。例如青藤的任务标题是 14 / 20,在阿拉伯文界面里变成 14 / 24.5。 - Flutter:
bostonIvyTheme(..., locale:)里,TextStyle.height取max(原比例, BiScript.leadingMin),letterSpacing乘以BiScript.trackingScale。 - 行高变高以后,任务行、按钮这些容器要用最小高度(
min-height、ConstrainedBox),不要写死高度。现有组件已经这样做。 - 字号不随语言变,最小 12px。
- 不用斜体表示强调:中日韩、阿拉伯文、天城文都没有真正的斜体,浏览器会把字形硬拉斜。强调用字重。
从右往左
阿拉伯语、波斯语、乌尔都语、希伯来语界面整体镜像:阅读从右边开始,界面也从右边开始。
布局
一律写逻辑方向,不写左右:
| 不要写 | 要写(Web) | 要写(Flutter) |
|---|---|---|
margin-left、padding-right |
margin-inline-start、padding-inline-end |
EdgeInsetsDirectional |
border-left |
border-inline-start |
BorderDirectional |
left: 0 |
inset-inline-start: 0 |
PositionedDirectional |
text-align: left |
text-align: start |
TextAlign.start |
Alignment.centerLeft |
— | AlignmentDirectional.centerStart |
跟着镜像的:侧栏移到右边,详情面板移到左边;勾选框在行首,也就是右边;看板的列从右往左排,甘特图的时间从右往左走;进度条从右往左填充;返回箭头指向右边;头像叠放的方向反过来;Android 上左滑、右滑对应的操作对调。在树和横向列表里,键盘的 ← → 键的含义也随方向对调,按 WAI-ARIA 的写法实现。
不镜像的:时钟和计时器圆环(永远顺时针);播放、停止这类媒体控件;数字本身(永远从左往右写);代码、快捷键、URL;Logo、吉祥物和插画。
图标
35 个图标里,有 7 个指向阅读方向,或者画的是从起始边排起的文字和布局,从右往左时要水平翻转:
| 翻转 | 原因 |
|---|---|
chevron-right |
指向「下一个」「展开」 |
comment |
气泡的尾巴在起始边 |
label |
标签的尖头指向行尾 |
relation |
带方向的折返箭头 |
view-list、view-gantt、view-kanban |
画的是从起始边排起的列表、时间轴和看板列 |
其余图标不翻转,包括 timer(时钟)、play stop(媒体)、repeat(循环)、check checklist(勾)、search、attachment、priority(旗子是实物)。
- Web:给需要翻转的图标加
class="bi-mirror-rtl"。tokens.css 在dir="rtl"下把它镜像过来,名单是mirroredIcons。组件库的Icon已经自动加上这个类。 - Flutter:
BiIcon读Directionality,BiIcons.mirrorInRtl为真时自动翻转,不用额外处理。 - 名单在
bostonivy-tokens.json的$extensions.app.bostonivy.rtl.mirrorIcons。新增图标时先判断要不要翻转。
三种风格的装饰
| 风格 | 元素 | 从右往左时 |
|---|---|---|
| 青藤 | 投影 | 投影是垂直方向的,不用处理 |
| 萌虎 | 右下方的硬阴影;按下时向阴影方向移动 3px | 不镜像。阴影表示光从左上方照过来,光源是实物方向,不跟阅读方向走 |
| 萌虎 | 勾选时旋转 −6°、拖动卡片倾斜 3°、侧栏吉祥物歪头 | 不镜像,都是装饰 |
| 像素 | 选中行起始边的 3px 竖条 | 镜像,移到右边。Web 用 --inline-sign(从右往左时为 −1),Flutter 用 BorderDirectional(start: …) |
| 像素 | [ ] [x] 勾选框 |
当作文字放在从右往左的文本里,方括号会按 Unicode 规则自动镜像显示。不要再包一层 dir="ltr" |
| 像素 | !!!、#标签、@林 |
跟着文字方向走,# 和 @ 自然出现在词的右边 |
| 像素 | 空状态里的命令 $ bivy ls --today |
这是代码,永远从左往右。Web 包在 <code dir="ltr"> 里,Flutter 用 Directionality(textDirection: TextDirection.ltr) |
| 像素 | 快捷键提示条 | 条目顺序镜像,键帽上的字母不变 |
双向混排
从右往左的文本里夹着数字和拉丁字母时,「#42」「00:42:18」「v0.3.0」「report.pdf」这类片段会被前后文字拉到错误的位置。规则是把它们隔离开:
- Web 用
<bdi>或dir="ltr"包起来。 - Flutter 在字符串两头加 U+2066 和 U+2069(LRI、PDI),或者把这一段放进单独的
Directionality。
用户输入的任务标题和评论:Web 用 dir="auto";Flutter 用 intl 的 Bidi.detectRtlDirectionality 判断方向,再传给 TextField 和 Text 的 textDirection。
数字与日期
一律交给平台的本地化接口,不要自己拼字符串:Web 用 Intl.DateTimeFormat、Intl.NumberFormat、Intl.RelativeTimeFormat、Intl.PluralRules,Flutter 用 intl 包的 DateFormat、NumberFormat 和 MaterialLocalizations。
| 规则 | 写法 |
|---|---|
| 数字字形 | 跟随语言的默认写法:ar-EG、ar-SA 用阿拉伯–印度数字 ٠١٢,fa 用 ۰۱۲,bn 用 ০১২,其余用 0–9 |
| 例外:编号和代码 | 任务编号 #42、版本号、快捷键、URL、ISO 日期永远用 0–9(numberingSystem: 'latn'),并按上一节隔离方向 |
| 等宽数字 | 计时器、表格里的数字列、计数一律等宽:Web 用 font-variant-numeric: tabular-nums,Flutter 用 FontFeature.tabularFigures()。Fredoka 不带等宽数字,萌虎的计时器容器要按「00:00:00」预留宽度,数字变化时才不会跳 |
| 历法 | 截止日期一律用公历(calendar: 'gregory')。th-TH 默认佛历(2569 年),fa 默认波斯历,ar-SA 可能用伊斯兰历;同一个截止日对团队里的每个人都要是同一天 |
| 时区 | 存 UTC,按用户时区显示;全天任务只存日期 |
| 12 / 24 小时制 | 跟随语言和系统设置(hourCycle):英语、韩语、阿拉伯语、印地语默认 12 小时制,中文(大陆)、日语、德语、俄语默认 24 小时制 |
| 一周的第一天 | 跟随地区:美国、日本是周日,中国大陆和欧洲大部分是周一,中东是周六(Intl.Locale 的 getWeekInfo(),Flutter 的 MaterialLocalizations.firstDayOfWeekIndex) |
| 相对时间 | 「明天」「3 天前」用 RelativeTimeFormat,numeric: 'auto' |
| 复数 | 用 ICU MessageFormat。俄语有 4 种复数形式,阿拉伯语有 6 种;不要写成「数字 + 天」 |
| 千分位和小数点 | 跟随语言:1,234,567.5(英、中、日)、1.234.567,5(德、西)、1 234 567,5(法、俄)、12,34,567.5(印地语) |
同一时间点(10月20日 18:00)在列表里的短格式,由 Intl.DateTimeFormat 生成:
| 语言 | 短格式 | 语言 | 短格式 |
|---|---|---|---|
en |
Oct 20, 6:00 PM | de |
20. Okt., 18:00 |
zh-CN |
10月20日 18:00 | ru |
20 окт., 18:00 |
zh-TW |
10月20日 下午6:00 | ar-EG |
٢٠ أكتوبر، ٦:٠٠ م |
ja |
10月20日 18:00 | hi |
20 अक्टू॰, 6:00 pm |
ko |
10월 20일 오후 6:00 | th |
20 ต.ค. 18:00 |
文本长度
按英文设计,给翻译留余量:德语、俄语、法语通常比英文长 30%,两三个词的短标签可能长一倍;中日韩短 30% 到 50%,但字更高。
| 元素 | 放不下时 |
|---|---|
| 按钮 | 不截断。按钮随文字变宽;一行放不下时,按钮组改成纵向排列。只有图标的按钮要有 aria-label |
| 标签、胶囊 | 最宽 160px,单行省略;全名放进 tooltip 和无障碍名称 |
| 导航项、项目名 | 单行省略,加 tooltip |
| 任务标题 | 列表里单行省略,详情里完整换行 |
| 页面和分组标题 | 换行,不截断 |
| 表头 | 最多两行,不截断 |
| 计数徽章 | 超过 99 写「99+」 |
断行规则:
- 德语、俄语里长的复合词:
hyphens: auto,前提是lang写对。 - URL 和文件名:
overflow-wrap: anywhere。 - 韩语按词断行:
word-break: keep-all。中文和日语用line-break: strict,标点不出现在行首。 - 泰语词与词之间没有空格,浏览器和 Flutter 按词典断行,不要手动插空格。
测试方法:每个界面至少用德语、阿拉伯语、日语各过一遍。Android 打开开发者选项里的伪语言 en-XA(文字拉长并加上重音符号)和 ar-XB(强制从右往左);Web 用脚本把文案拉长 40% 再看一遍。
Token 与变量一览
| 用途 | token | CSS 变量 | Flutter |
|---|---|---|---|
| 各组系统字体 | core.script.<组>.font |
--font-script(界面语言那一组在前)、--font-scripts(全部) |
BiScript.<组>.fonts、BiFonts.scripts |
| 萌虎圆体 | core.script.<组>.font-rounded |
--font-script-rounded |
BiScript.roundedFonts |
| emoji | core.font.emoji |
包含在 --font-scripts 里 |
BiFonts.emoji |
| 品牌字体替换 | style.*.font 的 substitute / substituteFor |
--font-sticker-face、--font-pixel-display-face |
BiFonts.family(style, role, locale) |
| 完整字体栈 | — | --font-clean、--font-sticker、--font-pixel、--font-pixel-display、--font-mono(经 --font-ui 等别名使用) |
BiFonts.fallback(style, role, locale) |
| 行高下限 | core.script.<组>.leading-min |
--leading-min |
BiScript.leadingMin |
| 字距倍数 | core.script.<组>.tracking-scale |
--tracking-scale |
BiScript.trackingScale |
| 语言 → 组 | core.script.<组> 的 languages |
:lang() 选择器 |
BiScript.of(locale) |
| 镜像图标 | rtl.mirrorIcons |
.bi-mirror-rtl、mirroredIcons |
BiIcons.mirrorInRtl |
| 行内方向符号 | — | --inline-sign(1 或 −1) |
Directionality.of(context) |