【Web国际化】漫谈阿拉伯语情境下的RTL布局适配

【Web国际化】漫谈阿拉伯语情境下的RTL布局适配
Photo by Masjid MABA / Unsplash

人类并不是活在国家之中,而是活在语言之中。[母语]才是我们的[祖国] ——罗马尼亚思想家 《合金装备V》

从文字方向说起:BiDi

在中国古代,文人墨客在书写文字时,秉承着从上到下,从右到左的的顺序。据说是为了不让衣袖弄脏未干的墨水。

目前,我们接触的绝大部分语言,都是从左至右书写的(LTR,left to right)。

事实上,仍然有不少语言是从右往左书写(RTL,right to left),例如:希伯来语、阿拉伯语。

在计算机领域,为了兼容这两种布局模式,文字的显示顺序与内存中的字符顺序并非是固定的。

Unicode 定义了它其中每个字符的方向属性,由Unicode双向算法( Bidirectional Algorithm ,简称Bidi)在显示时产生正确的顺序。。

双向算法,顾名思义,只有当两种不同方向的文本混合排列时,才会发挥作用。

文字方向原理与Unicode控制字符

Unicode方向属性包含以下三种类型:

强字符:大部分的字符都属于强字符,比如英文字母、汉字和阿拉伯字母。它们的方向性是确定的(英文字母是从左到右,而阿拉伯字母则从右到左),和其上下文的bidi属性无关。并且**,强字符在bidi算法中可能会影响其前后字符的方向性**;

弱字符:数字和数字相关的符号属于弱字符。它们的方向性是确定的,但对其前后字符的方向性不会产生影响;

中性字符:大部分的标点符号和空格属于中性字符。它们的方向性是不确定的,由上下文的bidi属性来决定其方向

小知识:阿拉伯数字

很多人都以为阿拉伯数字是阿拉伯发明的,其实是由印度人发明的。

然后被阿拉伯地区引进并且广泛传播到西方世界,因此才被叫做阿拉伯数字,真正阿拉伯地区使用的数字是叫做阿拉伯文数字,那边使用两套数字

而且很有意思的是:同阿拉伯文不一样,当代的阿拉伯文数字一般从左向右排列.而在一些古典作品里是从右向左排列的.

全局方向

全局方向是一个区域中的总体方向。中英文环境一般是 (LTR) 从左至右,而阿拉伯文环境则为 (RTL) 右至左的书写顺序。我们可以通过dir属性或者direction样式,设置指定方向。

当指定HTML标签的dir属性之后,其他所有常规子元素的dir属性将会默认继承HTML标签的dir属性。当然,也可以手动更改,或者在css中设置direction进行更改

<style>
  html {
    direction: 'rtl'
  }
</style>

<html dir="rtl"></html>

已显示电话号码为例:

<!-- ⚠️Do Not Use,错误的使用方式,显示的方向并不正确 -->
<p dir="rtl">هاتف:(415)555-3695</p>

这段HTML代码在浏览器会被渲染成下图所示的内容:

如何兼容: <bdi>、dir和direction

浏览器提供了bdi元素用于显示未知方向的文字。

HTML 双向隔离元素( ) 告诉浏览器的双向算法将其包含的文本与周围的文本隔离,当网站动态插入一些文本且不知道所插入文本的方向性时,此功能特别有用。 -MDN

同时,大多数HTML元素都提供了dir="auto"属性,可以自动设置文字显示方向。

<!-- 以下两种方式等价 -->
<div dir="auto">This text has been displayed in LTR</div>

<div><bdi>This text has been displayed in LTR</bdi></div>

但是,经过研究,dir="auto"和bdi这两种选择方向文字的算法,都是通过判断第一个强方向字符的方向,在一些边缘case下,可能会出现问题,例如:

<!-- Hello 我是一名前端开发 -->
<bdi>Hello أنا مطور الواجهة الأمامية</bdi>

<!-- 很明显,这段文字应该以RTL显示,但由于是单词开头,导致整段文本以LTR显示,造成语序错误 -->

显示效果,注意本来应该从右往左阅读:

更优的方案

为了解决bdi和dir="auto"的误判问题,我们需要选择更优的算法

人类最古老而又最强烈的情感是恐惧,而最古老又最强烈的恐惧是未知。 -克苏鲁神话

Flutter 是如何判断文字方向的

Flutter提供了Bidi包用于判断文字方向,由于Flutter开源,我们很容易就可以看到其判断文字方向的逻辑:

https://github.com/dart-lang/intl/blob/master/lib/src/intl/bidi.dart

核心逻辑在256行的estimateDirectionOfText函数,代码很清晰,注释也很清楚,简单解释就是如果是URL或者有数字,则强制以LTR显示。

否则,用空格分隔字符元素,已RTL文字元素开头占比超过总元素占比的0.40(_RTL_DETECTION_THRESHOLD),则认为整端文本已RTL显示。

至于_RTL_DETECTION_THRESHOLD为何取0.4,代码中并没有给出原因。个人猜测是一个经验值。

  /// Constant to define the threshold of RTL directionality.
  static const _RTL_DETECTION_THRESHOLD = 0.40;

  static TextDirection estimateDirectionOfText(String text,
      {bool isHtml = false}) {
    text = isHtml ? stripHtmlIfNeeded(text) : text;
    var rtlCount = 0;
    var total = 0;
    var hasWeaklyLtr = false;
    // Split a string into 'words' for directionality estimation based on
    // relative word counts.
    for (var token in text.split(RegExp(r'\s+'))) {
      if (startsWithRtl(token)) {
        rtlCount++;
        total++;
      } else if (RegExp(r'^http://').hasMatch(token)) {
        // Checked if token looks like something that must always be LTR even in
        // RTL text, such as a URL.
        hasWeaklyLtr = true;
      } else if (hasAnyLtr(token)) {
        total++;
      } else if (RegExp(r'\d').hasMatch(token)) {
        // Checked if token contains any numerals.
        hasWeaklyLtr = true;
      }
    }

    if (total == 0) {
      return hasWeaklyLtr ? TextDirection.LTR : TextDirection.UNKNOWN;
    } else if (rtlCount > _RTL_DETECTION_THRESHOLD * total) {
      return TextDirection.RTL;
    } else {
      return TextDirection.LTR;
    }
  }

Google Doc根据测试,好像也用的是相似的算法,由于代码做了混淆,这里并没有去深入研究。

最佳实践

在阿拉伯语的环境下,需要格外小心中性字符和英文字符

一般来说,如果用户当前设置的语言是LTR语言(英语、中文等),页面中是不太可能出现RTL语言的。

但是,当用户当前设置的语言是RTL语言时,则需要格外注意。由于阿拉伯语数字和英文的广泛使用,如果不加以区分,很容易出现排版错误。

尽可能对电话号码、email地址,使用单独的标签进行包裹,并强制其direction为ltr。例如上图出现的“电话号码”例子:

<!-- 正确的排版方向 -->
<p dir="rtl"><span>هاتف:</span><span dir="ltr">(415)555-3695</span></p>

正确的显示效果

一个更加详细的例子:

用户输入的内容,不要继承默认的布局方向

应该使用使用<bdi>、或dir=auto针对用户输入的内容进行单独的方向判断。

更进一步,可以考虑使用开源的工具进行方向判断。(笔者正在仿照Flutter的intl工具,提供自动判断文字算法的NPM包,后续有机会会更新到这篇文章里面)

工作流选择国际化支持好的工具

在项目早期,我们使用腾讯文档进行内容翻译工作的组织和进行。

由于腾讯文档并没有国际化支持,默认全部使用LTR进行文本渲染,当遇到阿拉伯语、英语混排的场景时,会出现文本语序显示错误的情况。

我们的阿拉伯语翻译小姐姐为了解决问题,使用中性字符串代替了英语字符串,从而显示正确的文本顺序。但是这给后期协作带来了很多不必要的麻烦。

文档

深入到布局方向

除了文字的方向不同,在页面布局上,也有RTL和LTR之分;

左和右、开始和结束

先看看Flutter是如何实现RTL布局兼容的:

Flex方向改变

此外,Flutter中元素布局大多使用Flex布局,针对Flex布局方案,RTL模式下主轴方向将从右上角开始,左下角结束。

逻辑方向

在Flutter的世界中,为每个布局属性都提供了等价的Directional属性,Directional属性使用start和end替换了left和right这两种方向描述,我们称之为** 逻辑方向 **。当APP为LTR模式时,start对应left,end对应right;而在RTL模式下则正好相反,start对应right,end对应left;

例如:

// before
Container(
      width: 24,
      height: 24,
      margin: EdgeInsets.only(left: 8),
);

// after
Container(
      width: 24,
      height: 24,
      margin: EdgeInsetsDirectional.only(start: 8),
);

通过这两种方式的兼容,页面便可以在RTL模式下显示正确的布局方向

在Web的场景下,当设置HTML的dir='rtl',Flex布局的方向就会改变。

CSS也提供了类似于逻辑方向的属性,称之为Logical Properties

MDN Logical Properties

blockquote {
    text-align: start; /* left in latin, right in arabic */
    margin-inline-start: 0px; /* margin-left in latin, margin-right in arabic */
    border-inline-start: 5px solid gray; /* border-left in latin, border-right in arabic */
    padding-inline-start: 5px; /* padding-left in latin, padding-right in arabic */
}

/* 下面这两种方式等价,由于兼容性问题,请不要用到生产环境 */
blockquote {
  margin: logical 1em 2em 3em 4em;
}
blockquote {
  margin-block-start:  1em;
  margin-inline-start: 2em;
  margin-block-end:    3em;
  margin-inline-end:   4em;
}

目前Logical Properties规范仍然出于**W3C草案(Draft)**阶段:

CSS Logical Properties and Values Level 1

出于兼容性考虑,并不推荐在生产环境中使用Logical Properties进行页面布局。

多种方案的权衡

大多也只是权衡利弊罢了...

他山之石:antd如何兼容RTL

antd在4.0版本提供了RTL兼容,详情查看这个PR:

feat: added rtl direction to all of ant-design components

antd通过JavaScript判断页面是否处于RTL环境,并单独书写了RTL样式,对组件一个一个做单独的兼容。

这种改造成本是相当大的,这次PR,作者一共提交了250此Commit,涉及了262个文件的修改,7000多行代码的新增。

那么。是否有其他的方案进行RTL自动适配,减少开发成本,社区提供了一些方案:

rtlcss

https://rtlcss.com/

RTLCSS通过预处理的方式将CSS中的方向性定位反转(left -> right, right -> left)。

并且能够通过CSS注释的方式,提供更加细粒度的控制。

如下截图所示,分别是原始css和经过RTLCSS处理后的CSS:

RTLCSS提供CLI以及Webpack的方式,可以很方便的集成到现有的工作流中,为项目提供RTL方案的支持。

但由于该方案只能处理CSS,对于写在JS中的内联样式就无能为力了。

总结

Pros

  • 完善的工具链支持
  • 对开发没有任何侵入性
  • 通过侦测用户语言,可以实现RTL样式的按需加载

Cons

  • 对内联样式无能为力

Hack的方式:transform: scaleX(-1)

国际化 - 通用 LTR/RTL 布局解决方案 Alibaba-Lindz

Alibaba的Lindz同学想到了一种比较Hack的方式对页面进行镜像翻转:transform: scaleX(-1)

通过 transform: scaleX(-1) 可以使页面沿着中轴进行水平翻转

并且这种方式在布局上具有良好的兼容性,跟 direction 改变方向不同,根本无需考虑你的布局:flex/浮动/绝对定位等等,都可以很好地从 LTR 布局变成 RTL 布局。并且这个方案对于内联样式也可以进行镜像。

但是也引入的新的问题,就是文字,图片等等信息全部都翻转了,所以我们在文字部分需要将文字再翻转回来:

比如说在文字的容器上再次叠加 transform: scaleX(-1),将其再次翻转。

总结

Pros

  • 可以实现内联样式的镜像翻转
  • 对于第三方库,也可以进行镜像翻转

Cons

  • 比较hack,与标准背道而驰
  • 多次翻转,可能造成不必要的渲染性能开销
  • 对开发侵入大,心智负担较高

事实上,在生产实际中,我们完全可以同时使用两种不同的方案;个人更加推荐使用RTL CSS打包生成两个CSS文件,通过Visibility Change侦测用户语言变化,做到CSS样式的按需加载

但是,面对一些第三方开源库不支持CSS预处理的情况,则可以使用transform方案进行兼容。

参考文章:

国际化 - 通用 LTR/RTL 布局解决方案 https://github.com/happylindz/blog/issues/16

css3中的unicode-bidi与direction使用 https://juejin.cn/post/6844903688406843406