Unicode 控制字符及其有关的双向算法(BIDI算法)

coverImage.jpg

前言

前段时间项目中遇到了一个有关 Unicode 字符处理的问题,今天抽空做个总结。先来说下现象吧,有个用户的昵称中带了阿拉伯文,在App里显示的时候,所有和这个带有阿拉伯文的username做过拼接的字符串,都被翻转了,比如下面这样:

// 阿拉伯字符 "ه٥"   Unicode编号:U+0647  U+0665
let text = "ه٥晴子" + "middle." + "last." + ❤️
// 最后打印结果:text = ٥❤️last.middle.晴子ه
print("text = \(text)")

最后展示在App上的时候是 ٥❤️last.middle.晴子ه这个样子的,其实第一眼看到的时候没感觉到什么异样,因为我们也不知道用户的名字是啥样的,当时发现这个问题是因为我们有个场景需要加VIP标示,在username后面通过富文本加了一个VIP的图片,最后发现这个VIP图片展示在了最前面,这才发现了此问题。

关键知识点

  1. Unicode(统一码、万国码、单一码)是一种在计算机上使用的字符编码。它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。

如果把各种文字编码形容为各地的方言,那么Unicode就是世界各国合作开发的一种语言。

在这种语言环境下,不会再有语言的编码冲突,在同屏下,可以显示任何语言的内容,这就是Unicode的最大好处。 就是将世界上所有的文字用2个字节统一进行编码。那样,像这样统一编码,2个字节就已经足够容纳世界上所有的语言的大部分文字了。

Unicode的学名是"Universal Multiple-Octet Coded Character Set",简称为UCS。

现在用的是UCS-2,即2个字节编码,而UCS-4是为了防止将来2个字节不够用才开发的。

  1. 字体的设计原理(字符集、编码、字体三者的关系)

1)、字符集:就是各种字符的集合,比如Unicode字符集ASCII字符集GB2312字符集BIG5字符集GB18030字符集等等。

2)、编码:一个字符要能被计算机所接受,需要进行两次编码,因为计算机只能表示二进制,对于人们常使用的10进制来讲不是很方便,因此字符的第一次编码就是把相应的字符使用一个整数值与其相对应,比如ASCII码字符集把字符'a'编码为10进制的61,就是一次编码,Unicode字符集也是一次编码。为了能让计算机的二进制识别,需要把第一次编码后的整数值再次编码为二进制值,比如使用一个字节来表示字符'a'一次编码后的整数值61,再如中文汉字使用两个字节进行表示,再如对于Unicode字符集有3种不同的二次编码方案,分别是UTF-8UTF-16UTF-32,目前使用较多的是使用UTF-8来存储的Unicode字符集。

3)、字形(glyph):用于表示字符的外形,比如字母aASCII码为61,但这个字母可以以多种外形对其进行书写,再如中文字符中的每一笔画都是一个字形。注:glyph也翻译为图元,图像。其实对象的外形都是使用图元进行描述的。

4)、字形与字符的关系:一个字形可以用于表示多个字符,一个字符也可以由多个字形组成,比如中文字符,就经常共享字形,而且是由多个字形组成的。字符的衬线、粗细等都是字形设计的元素。

5)、字体:是一个拥有相同设计风格的字形及从字符到字形映射关系的集合,字体使字符能被显示出来,字体是计算机显示文字的一种方式,比如早期电报就把字符表示为一长串数字,这一长串数字就相当于是字符集,当接收到电报后,是使用宋体、草书或者其他形式显示出来,就需要使用字体了,每种字体都有一个相应的名字,比如“Times New Roman”、“宋体”等,相同的字体显示的字符具有相似的风格,比如以宋体显示的字体,其风格都是相似的。另外,字体名通常有版权,是受到法律保护的。

6)、计算机显示字符的原理简述:当计算机接收到一串二进制之后,表示的具体是什么字符,需要由字符集来决定,然后字符需要被显示出来(即字体以什么外观进行显示),这时就需要寻找相应的字体,若字库中没有相应字符的字体,则可能会被显示为乱码,所以要让计算机正确显示文字,不仅编码应正确,还要有相应的字体才行。

文本显示顺序与Unicode双向算法

1、文本的方向

大多数语言的文本在水平方向都是按从左到右(Left To Right,简称LTR)的顺序显示字符的,但也有不少语言是按从右到左(Right To Left,简称RTL)的顺序显示字符的(比如阿拉伯语、希伯来语)。当然还有按垂直方向书写的文本,比如中国古代的汉字、蒙语就是从上到下从右到左书写的,本文只讨论水平方向书写的文本,垂直方向书写的文本不予讨论。

双向文本是指一个字符串中同时包含LTR和RTL的文文,既包含从左到右的文本又包含从右到左的文本。现实中,从右向左书写的语言通常会夹杂着从左向右的文本(比如外语、引用、数字、符号等),因此,像阿拉伯语、希伯来语这些语言通常都是双向文本。另外,当在从左向右的文本中插入从右向左的文本时也会产生双向文本的问题。

2、逻辑顺序与显示顺序

逻辑顺序在Unicode标准内规定为文本在内存中表示的顺序,而显示顺序就是最终显示在我们面前所看到的文本的顺序,文本的逻辑顺序和显示顺序并不一定会一致,比如对于从右向左显示的文本,显示顺序应是从右向左的,而逻辑顺序则可能是从左向右的。逻辑顺序是属于计算机底层的问题,不属于本文讨论的范围,我们需要解决的是文本的显示顺序题。

3、Unicode双向算法(BIDI)与定向格式化字符

1)、对于双向文本,若不明确的确定文本的显示顺序,在显示时就可能会出现歧义,为此,需要为双向文本的显示定义一种算法(或者一种规则),用于规范双向文本的显示顺序。

2)、通常有一种隐式算法(或称为隐式双向排序或隐式布局算法)来定义双向文本的显示,但是隐式算法并不足以产生可供理解的文本,为此,对某些字符的显示顺序需要明确地进行控制,就是使用一系列的控制符(类似于HTML中的元素)来控制字符的显示顺序,这些控制符被Unicode称为定向格式化字符。比如,使用RLO控制符来控制字符从右向左显示,PDF表示RLO的终止字符,那么ab cd RLO EF GH PDF x,将被显示为ab cd HG FE x,可见,Unicode控制符的原理与HTML的元素是相似的。

3)、定向格式化字符只影响文本的显示顺序,在其它方面会被忽略,也就是说定向格式化字符不会对文本的比较、断句、词法分析、数值分析等方面造成影响。

4)、Unicode双向算法(也称为BIDI)是对隐式算法的扩展,Unicode双向算法定义了定向格式化字符(即控制符),并且定义了一套算法,用于规定这些控制符对需要显示的字符产生怎样的影响。

Unicode字符的分类与定向格式化字符

  • 字符的属性
    Unicode为字符定义了很多属性,以用于描述该字符,比如Bidi_Paired_Bracket_Type属性用于描述该字符是开括号(值为open)还是闭括号(值为close),再如General_Category描述了该字符的通用类别,比如若该字符是行分隔符,则值为Line_Separator,是控制符,则值为Control等。

  • 字符的类型
    Unicode为每个Unicode字符都定义了一种类型(称为双向字符类型或bidi类型),双向字符类型被分为:强字符(强类型)弱字符(弱类型)中性字符(中性类型)定向格式化字符,详见表1。

表1   -   字符的类型

分类 类型 简述
强字符(strong) L left to right
R right to left RLM(见表2),希伯来字母和相关的标点符号
AL right to left Arabic ALM(见表2),阿拉伯语(Arabic)、它拿字母(Thaana)、叙利亚字母,及大多数特定于这些文字的标点符号
弱字符(weak) EN 欧洲数字(European Number) [欧洲数字、东阿拉伯-印度数字,经常使用的数字1,2,3等就是属于EN类型]
ES 欧洲数字分隔符 加号,减号
ET 欧洲数字终止符 度的符号,货币符号,比如,$(美元),¥(人民币)等
AN 阿拉伯数字 阿拉伯-印度数字,阿拉伯小数和千位分隔符,平时使用的数字虽然叫做阿拉伯数字,但阿拉伯拥有自已的数字,比如,4的阿拉伯数字字符为٤(u+0664)
CS 普通数字分隔符 冒号,逗号,句点(即小数点),不间断空格(no-break space)等,注意:单引号、双引号、分号不属于该类型,中文的句号也不属于该类型
NSM 无间距标记(Nonspacing mark) 属性General_Category为以下值的字符:Mn(Nonspacing_Mark)和Me(Enclosing_Mark)比如,组合用发音字符的上左角 ̚ (u+031A),西非书面文中的ࣾ (u+08FE) 。详见后文对组合字符的讲解
BN 中性边界 不是明确给定类型的字符,比如:可忽略的默认值,非字符,控制字符等。比如,广义标点中的不可见乘号(u+2062)就是BN类型
中性字符(neutral) B 段落分隔符[段落分隔符(u+2029),适当的换行符函数,高级别确定段落的协议]
S 节分隔符(Segment Separator) Tab
WS 空白(Whitespace) 空格,图形空格,行分隔符,换页符,常用标点符号的空格等
ON 其他中性符 所有其他字符,包括对象替换字符,比如,[、]、(、)、"、'、@、&、*、、<、>、|、{、}、;(分号)、!、?、~、=。注意:/ 属于CS类型、%、#属于ET类型

  • 定向格式化字符的分类
    字向格式化字符分为隐式定向格式化字符显示定向格式化字符两大类,显示定向格式化字符又分为显示定向嵌入格式化字符、显示定向重写格式化字符、显示定向隔离格式化字符,分别简称为嵌入格式化字符、重写格式化字符、隔离格式化字符。其中隔离格式化字符是在Unicode 6.3中引入的。详见表2。

表2   -   定向格式化字符的分类

类型 控制符 Unicode代码 简述 说明
隐式定向格式化字符 LRM U+200E left to right mark 从左到右的零宽度字符
RLM U+200F right to left mark 从右到左的零宽度非阿拉伯字符
ALM U+061C arabic letter mark 从右到左的零宽度阿拉伯语字符
显示定向嵌入重写格式化字符 LRE U+202A left to right embedding 嵌入。把后面的文本看作是从左到右(LRE)或从右到左(RLE)的嵌入
RLE U+202B right to left embedding
LRO U+202D left to right override 重写。强制改变其后的文本的方向为从左向右(LRO)或从右向左(RLO)
RLO U+202E right to left override
PDF U+202C pop directional formatting 嵌入和重写终止符。用于终止LRE、RLE、LRO、RLO的作用范围
显示定向隔离格式化字符 LRI U+2066 left to right isolate 从左到右(LRI)或从右到左(RLI)的隔离之后的文本
RLI U+2067 right to left isolate
FSI U+2068 first strong isolate 隔离之后的文本,文本的方向由第一个非嵌套在隔离中的强字符决定
PDI U+2069 pop directional isolate 隔离终止符。用于终止LRI、RLI、FSI的作用范围,该终止符还会同时终止LRE、RLE、LRO、RLO的作用范围

  • 组合字符
    1)、组合字符(Combining character)是指General_Category属性的值为Mc (Spacing Combining Mark,间距组合标记)、Mn (Nonspacing Mark,无间距标记)、Me(Enclosing Mark,嵌入标记)的所有字符。
    2)、组合字符通常用于与它的基本字符组合为一个字符。
    3)、无间距标记(Mn)通常不单独占据空间位置,其占据的位置取决于它的基本字符。

  • 零宽度字符 ( LRM、RLM、ALM )
    表2中的LRM、RLM、ALM是一种零宽度字符,可将其理解为在该处插入了一个相应方向的强字符,但该字符是不可见的(宽度为零所以不可见)。比如aLRMb,相当于在a和b之间插入了一个从左向右的强字符,但该字符宽度为零且不可见(即不会被显示)。

  • 与定向格式化字符对应的HTML5元素和CSS等效项
    1)、HTML5没有提供对LRE、RLE、LRO、RLO的精确等效项,可使用CSS来获取LRE、RLE、LRO、RLO、LRI、RLI、FSI的精确等效项。表3为HTML和CSS与bidi算法的等效情形。由表3可见,在HTML5中,bdi元素更多用于对字符的隔离,以避免文本被周围字符方向性所影响,或避免隔离的文本影响周围字符的方向性,而bdo元素主要用于强制改变文本的方向性。
    2)、注意:HTML5与HTML4.0不同,早期版本的bdi元素与隔离(LRI、RLI)相对应,bdo元素与重写(LRO、RLO)相对应。

表3   -   各平台对BIDI算法的实现

BIDI算法 HTML5的等效项 CSS的等效项
RLI ... PDI dir = "rtl" (任意元素的dir属性) direction:rtl; unicode-bidi:isoloate;
LRI ... PDI dir = "ltr"(任意元素的dir属性) direction:ltr; unicode-bidi:isoloate;
FSI ... PDI 或dir = "auto" unicode-bidi:plaintext;
RLE ... PDF 无对应元素 direction:rtl; unicode-bidi:embed;
LRE ... PDF 无对应元素 direction:ltr; unicode-bidi:embed;
RLO ... PDF 无对应元素 direction:rtl; unicode-bidi:bidi-override;
LRO ... PDF 无对应元素 direction:ltr; unicode-bidi:bidi-override;
FSI RLO ... PDF PDI direction:rtl; unicode-bidi:isoloate-override;
FSI LRO ... PDF PDI direction:ltr; unicode-bidi:isoloate-override;

运行等级与隔离运行序列

一、基本名词
  • 嵌入等级(或嵌入水平)((level,可翻译为:等级、水平、级别):表示字符的嵌入层次,数字越大嵌入得越深,需要注意的是,在bidi算法中,字符串中的每个字符都有一个嵌入等级。

  • 基础方向(base direction):分段的方向被称为基础方向,基础方向决定了该段文本从浏览器的左侧还是右侧开始书写。

  • 隔离启动器:是对LRI、RLI、FSI的统称,注意:隔离启动器不包括PDI

  • 嵌入启动器:是对LRE、RLE、LRO、RLO的统称,注意:嵌入启动器不包括PDF

二、运行等级和隔离运行序列

1. 运行等级(level run):也称为定向运行(directional run),是指具有相同嵌入等级的字符所形成的最大子串,该子串与其直接接触的前后字符的嵌入等级不相同,比如ab cd RLE ef gh PDF kk mm,假设分段的嵌入等级为0,则字符a、b、c、d(含其中的空格)的嵌入等级都为0,字符e、f、g、h的嵌入等级都为1,字符k、k、m、m的嵌入等级为0,因此,该字符串共有3个运行等级,分别是子串ab cd,子串ef gh,子串kk mm。

2. 隔离运行序列(简称为运行序列或序列):是由一系列运行等级组成的序列,其规则如下:

  • 含有隔离启动器时:除最后一个运行等级外,隔离运行序列中运行等级的最后一个字符是隔离启动器,与该隔离启动器匹配的PDI是序列中下一个运行等级的第一个字符,也就是说,序列中的运行等级是以隔离启动器结束的(最后一个运行等级除外),以PDI开始的(除第一个运行等级外)。
  • 无隔离启动器时:此时每个运行等级构成一个独立的隔离运行序列。

3. 隔离运行序列具有如下特点:

  • 每个运行等级只属于一个隔离运行序列,也就是说,不存一个运行等级属于两个序列的情形。
  • 在同一个隔离运行序列中所有的运行等级具有相同的嵌入等级,因为隔离运行序列是以隔离启动器开始一个运行等级,又以与其匹配的PDI开始另一个运行等级,很明显,这两个运行等级具有相同的嵌入等级。
  • 紧随着隔离启动器之后的运行等级会开启一个新的隔离运行序列,与之匹配的PDI之前的运行等级会结束它的隔离运行序列。

4. 隔离启动器的重要规则:隔离启动器和与其匹配的PDI拥有的嵌入等级是提升之前的原始嵌入等级,而不是提升之后的嵌入等级。

解决问题

通过上面的介绍,我们发现文章最开始提到的问题是如何产生的了,就是因为阿拉伯字符的原因,导致我们的文字方向被改变了。那解决这个问题的方法当然就是强制设定为文字的方向就行,所以整体思路就是:

targetString -> unicodeString -> 插入`Unicode`的`定向格式化字符 -> resultString

相关代码(Swift):

    // MARK: - Unicode双向算法(BIDI算法) -
    /// 强制字符串从左到右(在阿拉伯文环境中默认是从右到左)
    static func unicodeDirectionalRun(string: String) -> String {
        /*
         显式双向控制字符 (Explicit Markers)
         显示定向嵌入和重写格式化字符
         U+202A:   LEFT-TO-RIGHT EMBEDDING      (LRE)
         U+202B:   RIGHT-TO-LEFT EMBEDDING      (RLE)
         U+202D:   LEFT-TO-RIGHT OVERRIDE       (LRO)   重写。强制改变其后的文本的方向为从左向右(LRO)或从右向左(RLO)
         U+202E:   RIGHT-TO-LEFT OVERRIDE       (RLO)
         U+202C:   POP DIRECTIONAL FORMATTING   (PDF)   嵌入和重写终止符。用于终止LRE、RLE、LRO、RLO的作用范围
         显式控制字符需要成对使用,前四个字符 LER RLE LRO RLO 为开始字符,最后一个 PDF 为结束字符。
         
         LRE & RLE : 接下来的文字片段内的方向变为 从左至右 / 从右至左。效果类似基础方向,将一段文本中的基础方向变更。
         LRO & RLO : 顾名思义 override,接下来的所有 Unicode 字符的方向性将被覆盖为 从左至右强字符 / 从右至左强字符。
         
         显示定向隔离格式化字符 (从左到右(LRI)或从右到左(RLI)的隔离之后的文本)
         U+2066:   LEFT-TO-RIGHT ISOLATE    (LRI)
         U+2067:   RIGHT-TO-LEFT ISOLATE    (RLI)
         U+2068:   FIRST STRONG  ISOLATE    (FSI)   隔离之后的文本,文本的方向由第一个非嵌套在隔离中的强字符决定
         U+2069:   POP DIRECTIONAL ISOLATE  (PDI)   隔离终止符。用于终止LRI、RLI、FSI的作用范围,该终止符还会同时终止LRE、RLE、LRO、RLO的作用范围
         */
                
        //eg:牛逼的字符(阿拉伯文) = "ه٥"  会把字符串翻转
        //let nameStr = "ه٥" + "晴子" + "."
        var unicodeStr = String.utf8ToUnicode(string: string)
        // 这里用 contains 和 hasPrefix 看自己需求
       if unicodeStr.hasPrefix("\\u2066") && !unicodeStr.hasPrefix("\\U2066") {
            return string
        }
        else {
            // 此处使用 LRI
            unicodeStr = "\\u2066" + String.utf8ToUnicode(string: string) + "\\u2069"
            let result = String.replaceUnicode(unicodeStr: unicodeStr)
            
            return result
        }

    /// unicode转中文
    ///
    /// - Parameter unicodeStr: str
    /// - Returns:
    static func replaceUnicode(unicodeStr: String) -> String {
        if unicodeStr.isEmpty {
            return unicodeStr
        }
        let tempStr1 = unicodeStr.replacingOccurrences(of: "\\u", with: "\\U")
        let tempStr2 = tempStr1.replacingOccurrences(of: "\"", with: "\\\"")
        let tempStr3 = "\"".appending(tempStr2).appending("\"")
        let tempData = tempStr3.data(using: String.Encoding.utf8)
        var returnStr: String = ""
        do {
            returnStr = try PropertyListSerialization.propertyList(from: tempData!, options: [.mutableContainers], format: nil) as! String
        } catch {
            print("replaceUnicode(unicodeStr: String) -> String  error = \(error)")
        }
        return returnStr.replacingOccurrences(of: "\\r\\n", with: "\n")
    }

    /// 中文转unicode (utf8)
    ///
    /// - Parameter string: str
    /// - Returns:unicode
    static func utf8ToUnicode(string: String) -> String {
        if string.isEmpty {
            return string
        }
        // 全英文的时候 .nonLossyASCII 会在前面加 "\\255" ,导致最后转中文的时候多了个 ">" 字符
        //let dataEncode = string.data(using: String.Encoding.nonLossyASCII)
        if let dataEncode = string.data(using: String.Encoding.utf8) {
            if let unicodeStr = String(data: dataEncode, encoding: String.Encoding.utf8) {
                return unicodeStr
            }
            else {
                return string
            }
        }
        else {
            return string
        }
    }

通过上面的案例,突然想到翻转字符串又多了一种新的思路,可以通过BIDI算法灵活的控制按子串或者整串翻转。

有兴趣的同学可以试试 LRE 和 LRO 算法会是怎样的效果

突然发现这个符号"ه",单个和连起来多个显示还不一样,有点意思
1个:ه
2个:هه
3个:ههه
4个:هههه
5个:ههههه
6个:هههههه
٥٥٥٥晴子.ههههههه

最后推荐大家看 Unicode官网 ,上面有关 Unicode 的知识点都很全面。
今天就分享这么多,希望能解决各位客官的问题^_^

参考资料

  1. Unicode官网
  2. Understanding Bidirectional (BIDI) Text in Unicode
  3. UNICODE BIDIRECTIONAL ALGORITHM
  4. Unicode 控制字符及其有关的双向算法
  5. Unicode双向算法(bidi算法)详解(一)
  6. 符号字工具网站

你可能感兴趣的:(Unicode 控制字符及其有关的双向算法(BIDI算法))