欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

py3 中文字符串对齐问题

程序员文章站 2022-05-29 13:14:54
...

一、综述

py3中str的len是计算字符数量,例如len(‘ab’) --> 2, len(‘a中b’) --> 3。
但在对齐等操作中,是需要将每个汉字当成宽度2来处理,计算字符串实际宽度的。
所以我们需要开发一个strwidth函数,效果: strwidth(‘ab’) --> 2,strwidth(‘a中b’) --> 4。

结论及推荐字符串域宽计算方法为:

def strwidth(s):
    """string width
    中英字符串实际宽度
    >>> strwidth('ab')
    2
    >>> strwidth('a⑪中⑩')
    7
    """
    try:
        res = len(s.encode('gbk'))
    except UnicodeEncodeError:
        count = len(s)
        for x in s:
            if ord(x) > 127:
                count += 1
        res = count
    return res

然后实现关键函数listalign用于处理某一列的对齐:

from align import listalign

ls = ['22', '哈哈', '中_文a']

print(*listalign(ls), sep='\n')
#          22
#      哈哈
#    中_文a

再利用listalign实现一个对齐二维数组的函数arralign:

from align import arralign

ls = [
    [1, '22', 'c'],
    [123, 4, '哈哈', 'cde'],
    [1, '中_文a', 'dd']
]

print(arralign(ls))
# print(arralign(ls, chinese_char_width=1.8)),本篇文章为了显示对齐,要用汉字域宽1.8的参数
#   1          22          c
# 123           4     哈哈  cde
#   1    中_文a         dd

完整代码: align.py

二、实现方法分析

百度一下能找到一些方法资料,我们来研究(TiGuan)一下。

2.1 使用中文空格chr(12288)

沧海漂游_,Python 中英文混输格式对齐问题,CSDN,2017.8
这篇原理是对只有中文的字符串,填充的时候用域宽也是2的中文空格“chr(12288)”代替域宽为1的英文空格,从而实现只有中文的某一列的对齐效果。但这样遇到中英文混合字符串,例如把“清华大学”改成’a清华大学b’,就对不齐了。

2.2 字符集分类处理

mozaibin,python 中英文混合格式化输出对齐,鱼C论坛,2018.4
mozaibin应该是对编码做分类计算,感觉搞复杂了,而且算一个“⑩”就出错了,算出来是1,实际上是2。

2.3 判断ord是否大于127

云涛连雾,【Python】Python中中文的字符串格式化对齐,CSDN,2016.1
用ord计算每个字符的编码值,大于127的就是域宽2的字符,长度加1。

2.4 使用正则

用正则re.sub(r'[^\u0001-\u007f]+', r'', s)删除中文字符,原字符串长度记为len1,删除后长度记为len2,可以用公式得到域宽: 2*len1 - len2。

2.5 使用gbk编码

冬雪雪冬,python 中英文混合格式化输出对齐,鱼C论坛,2015.12
利用gbk编码中每个汉字是2个字节的特点来计算:len(s.encode('gbk'))
我自己在用了一段时间正则后,也独立想到了这个方法。

但是这个方法在处理非gbk编码内的字符,例如“⑪ ⑫ ⑬”,会出错。

三、各方法比较分析

注意第三~五节里的代码仅做测试,实际代码以最终文件align.py为准。

3.1 正确性测试

先实现五种计算方法:len、分类、ord、正则、gbk

import re, time

def strwidth1(s):
    """py自带长度计算函数"""
    return len(s)

def strwidth2(s):
    """
    mozaibin写的是什么鬼。。。
    http://bbs.fishc.org/thread-67465-1-1.html

    测试结果:计算⑩等字符会出错
    """
    widths = [
      (126,  1), (159,  0), (687,   1), (710,  0), (711,  1),
      (727,  0), (733,  1), (879,   0), (1154, 1), (1161, 0),
      (4347,  1), (4447,  2), (7467,  1), (7521, 0), (8369, 1),
      (8426,  0), (9000,  1), (9002,  2), (11021, 1), (12350, 2),
      (12351, 1), (12438, 2), (12442,  0), (19893, 2), (19967, 1),
      (55203, 2), (63743, 1), (64106,  2), (65039, 1), (65059, 0),
      (65131, 2), (65279, 1), (65376,  2), (65500, 1), (65510, 2),
      (120831, 1), (262141, 2), (1114109, 1),
    ]
    width = 0
    for each in s:
        if ord(each) == 0xe or ord(each) == 0xf:
            each_width = 0
            continue
        elif ord(each) <= 1114109:
            for num, wid in widths:
                if ord(each) <= num:
                    each_width = wid
                    width += each_width
                    break
            continue

        else:
            each_width = 1
        width += each_width
    return width

def strwidth3(s):
    """也有人想到算ord编号值即可
    https://github.com/Jueee/PythonLiaoXueFeng/blob/master/81-Chinese.py
    """
    count = len(s)
    for x in s: # 我改成filter后反而变慢了,不知道是不是我姿势不对。
        if ord(x) > 127:
            count += 1
    return count

def strwidth4(s):
    """最开始想到用正则处理"""
    len1 = len(s) # 中文字符数 + 英文字符数 --> len1
    s = re.sub(r'[^\u0001-\u007f]+', r'', s)
    len2 = len(s) # 英文字符数 --> len2
    return 2*len1 - len2

def strwidth5(s):
    """后来想到用gbk编码算长度就好了"""
    return len(s.encode('gbk', errors = 'ignore')) # gbk好像跟gb2312效果没有差别?

正确性测试:

def test(s):
    funcs = globals()
    for i in range(1, 6):
        print(funcs['strwidth' + str(i)](s), end = ' ')
    print()

# 大部分文本,方法2~4都能正确计算出结果
test('広有射怪鳥事 ~ Till When?')
# 19 26 26 26 26

# 方法2计算'⑩'错误
test('⑩')
# 1 1 2 2 2

# 如果出现换行符,其实算宽度已经没有意义了。出现这种问题,应该是外部要有方法决定\n的显示问题
test('aa\ncc')
# 5 5 5 5 5

# 遇到非gbk编码内字符,gbk方法会出错
test('⑪')
# 1 1 2 2 0

3.2 效率测试

_startTimes = {}

def tic(key=0):
    """默认用第0个计时器,注意也可以使用非数字键值"""
    global _startTimes
    _startTimes[key] = time.clock()

def toc(prefix='', key=0, output=True):
    """返回时间秒数,保留4位小数"""
    t = time.clock() - _startTimes[key]
    if output:
        print(prefix, '用时%.2f秒' % t)
    return round(t, 4)

def test_speed(s):
    funcs = globals()
    for i in range(3, 7):
        func = funcs['strwidth' + str(i)]
        tic()
        for _ in range(10**6): func(s)
        toc(f'{i}:')

# 字符串越长gbk方法越快
test_speed('広有射怪鳥事 ~ Till When?')
# 3: 用时 1.55秒
# 4: 用时 1.40秒
# 5: 用时 0.77秒

# 英文较多的时候ord比正则快的多
test_speed('aa\ncc')
# 3: 用时0.43秒
# 4: 用时0.95秒
# 5: 用时0.72秒

# 字符串短的时候ord最快
test_speed('⑩')
# 3: 用时0.25秒
# 4: 用时0.85秒
# 5: 用时0.71秒

# 总得来说,各种算法还是比len函数慢了3~10倍

对方法5和方法3做一个封装,我们发现这是目前最鲁棒且性能最好的,后续采用的strwidth即以这里的strwidth6为准:

def strwidth6(s):
    try:
        return len(s.encode('gbk'))
    except:
        count = len(s)
        for x in s:
            if ord(x) > 127:
                count += 1
        return count

def test_speed(s):
    tic()
    for _ in range(10**6): strwidth5(s)
    toc('5:')
    tic()
    for _ in range(10**6): strwidth6(s)
    toc('6:')

test_speed('広有射怪鳥事 ~ Till When?')
# 5: 用时0.78秒
# 6: 用时0.55秒

test_speed('aa\ncc')
# 5: 用时0.73秒
# 6: 用时0.50秒

test_speed('⑩')
# 5: 用时0.72秒
# 6: 用时0.49秒

test_speed('⑪')
# 5: 用时0.72秒
# 6: 用时0.97秒

四、封装对齐函数

4.1 左对齐ljust、居中center、右对齐rjust

模仿str的成员函数ljust、center、rjust,重写3个对中文字符串做对齐操作的函数

def ljust(s, width, fillchar=' '):
    """
    >>> ljust('a哈b', 6)
    'a哈b  '
    """
    n = strwidth(s)
    s = s + fillchar * (width - n)
    return s


def center(s, width, fillchar=' '):
    """
    >>> center('a哈b', 6)
    ' a哈b '
    >>> center('a哈b', 7)
    '  a哈b '
    """
    n = width - strwidth(s)
    # 遇到奇数长度,这里有两种做法,这里采用尽量往左放的效果
    s = fillchar * (n - n//2) + s + fillchar * (n//2)
    return s


def rjust(s, width, fillchar=' '):
    """
    >>> rjust('a哈b', 6)
    '  a哈b'
    """
    n = strwidth(s)
    s = fillchar * (width - n) + s
    return s

4.2 每个汉字域宽为1.8的处理方法

github码云网站上,每个汉字的域宽并不是2,而是大概1.8的一个值!所以如下图,9个数字跟5个汉字域宽是差不多的。
py3 中文字符串对齐问题

为了让文本在这种环境下也能对齐,需要添加中文空格chr(12288)将字符串的域宽控制在整数,即保证字符串里汉字数量(含中文空格)始终是5的倍数。扩展的strwidthb函数如下,在计算一个字符串域宽的时候,也对原始字符串作了修改,返回两个值,第1个是修改后的字符串,第2个是修改后的字符串域宽。

def strwidthb(s, fmt = 'r'):
    """每个中文域宽为1.8字符时的处理方法
    s:原字符串
    fmt:目标对齐格式

    返回值1:处理后的字符串s
    返回值2:码云标准下的字符串宽度

    >>> strwidthb('哈哈a') # 前面补3个中文不可见字符,并且认为这个字符串长度是10(不是11)
    ('   哈哈a', 10)
    """
    #1、计算一些参数值
    s = str(s)
    l1 = len(s)
    l2 = strwidth(s)
    y = l2 - l1 # 中文字符数
    x = l1 - y  # 英文字符数
    ch = chr(12288) # 中文空格

    #2、如果汉字数量不是5的倍数,则补足
    if y % 5 != 0:
        # 需要补充中文字符数
        t = 5 - (y % 5)

        if fmt == 'r': s = ch * t + s
        elif fmt == 'l': s = s + ch * t
        else: s =  ch * (t - t//2) + s + ch * (t//2)

        y += t

    #3、计算每个汉字是1.8字符宽度的情况下的字符串宽度
    l = round(y*1.8 + x)
    return s, l

添加的中文空格位置跟字符串最终要进行的对齐格式有关,所以还多了一个fmt参数。

如果还有其他不同域宽的汉字,也可以采用这个原理进行处理。将域宽小数部分0.8转为分数4/5=a/b,确保汉字字符数是b的倍数,且每b个汉字只能算2*a个域宽。

五、ArrayAlign数组对齐功能

按照上述思想,完成完整代码align.py,整个程序的开发结构如图:
py3 中文字符串对齐问题

这里主要设计思想是listalign函数,可以对二维数组进行拆解成多个一维数组。

实现最终需要的接口函数:arralign,该函数有非常多灵活控制的功能,详见源码中的注释。

另外还实现了两个类似的功能Array2d、PrintAlign。

六、总结与展望

6.1 总结

本文记录了笔者在网上找了一些处理中文字符串对齐问题的资料后,综合分析得出的一些结论和对齐处理方法,包括github、码云里汉字域宽只有1.8的特殊处理方法。
结合鲁棒性和效率,推荐优先使用len(s.encode('gbk'))保证效率,在gbk处理失败的情况再用ord计算域宽的混合方法来实现strwidth。

最后整合了一些功能,开发了用于二维数组对齐的arralign接口函数。

6.2 展望

  • 研究pandas等表格库的中文对齐方法。(不过pandas好像也会有中文对不齐的情况)
  • 因为暂时能解决笔者的问题和需求,没有再深入研究下去。一些代码细节应该还能再优化。