py3 中文字符串对齐问题

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/DaiHaoC83E15/article/details/80985218

一、综述

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个汉字域宽是差不多的。
Markdown

为了让文本在这种环境下也能对齐,需要添加中文空格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,整个程序的开发结构如图:
Markdown

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

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

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

六、总结与展望

6.1 总结

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

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

6.2 展望

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

猜你喜欢

转载自blog.csdn.net/DaiHaoC83E15/article/details/80985218