2015年2月

Django(Python) 中按屏幕显示宽度截取字符串

Django 的模板中默认提供了两个常用的 filter: truncatecharstruncatechars_html, 用于在字符串中截取指定数量的字符(详见 Django 文档说明). 如果是纯英文(或其它半角字符)组成的字符串, 使用这两个 filter 不会感觉有什么不对. 但对于中文网站来讲, 就有很大的局限性.

中文是全角字符, 而西方文字多是半角字符, 在大多数字体中, 全角和半角字符的宽度相差很大, 全角字符在屏幕上显示的宽度大概是半角字符的两倍(注意: 只是大概而以), 如果使用 truncatechars 来截取混合有全角和半角字符的字符串时, 那截取结果在屏幕上的显示宽度就很难控制. 而很多情况下, 我们希望的结果就是要截取一定宽度的字符串, 而不是一定的字符个数. 例如本站首页的讨论版新贴最近回复两块内容中(因网站改版现在的首页已经有所不同), 我就希望显示指定宽度的内容而不是显示指定的字符个数.

我GOOGLE了一些资料,暂时找到以下两种比较可行的解决办法:

方案一: urwid的解决方案

我参考了这篇文章, http://likang.me/blog/2012/04/13/calculate-character-width-in-python/, 里面讲的很详细.

最核心的原理在这句话:

首先根据 unicode 的官方 EastAsianWidth 文档整理出字符宽度的范围表,然后使用unicode代码查表。
我根据他提出的方案, 照着写了一个函数并包装成 Django 的 custom template filter(这也是本站现在使用的方案), 核心函数如下:
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),
]

def get_screen_width(input_str, max_width=None, tail='.', tail_length=3):
    """
    获取输入字符串input_str在屏幕上的显示宽度,全角字符宽度计算为2,半角字符宽度计算为1
    注意这个宽度并不能保证字符串能在屏幕上完美对齐,因为字体的原因,全角字符的宽度并不一定是半角字符的2倍
    如果仅需要获取字符串的宽度,只需提供input_str参数即可
    如果需要截取字符串,需提供最大截取宽度(max_width)和省略替代符号(tail, 可选)及其最大个数(tail_length, 可选)
    例如,最大截取宽度(max_width)为3,输入的字符串为 u"测试字符串"(长度为10)
    那截取结果是:
    u"..."
    如果截取宽度为4,那结果是:
    u"测.."(会自动少用一个表示省略的字符)
    如果截取宽度为5,那结果是:
    u"测..."
    """

    def get_char_width(char):
        """
        查表(WIDTHS)获取单个字符的宽度
        """
        char = ord(char)
        if char == 0xe or char == 0xf:
            return 0

        for num, wid in WIDTHS:
            if char <= num:
                return wid

        return 1

    if max_width and max_width > tail_length*get_char_width(tail):
        # 最大宽度应该至少和表示省略的字符串一样长
        # str_max_width和max_width的区别在于:
        # max_width表示的是返回结果的最大宽度,包括了最后表示省略的点
        # str_max_width表示的是除去表示省略的符号后在输入字符串中截取部分的最大长度
        str_max_width = max_width - tail_length*get_char_width(tail)
    elif max_width and max_width == tail_length*get_char_width(tail):
        # 如果最大宽度刚好和表示省略的字符串宽度一样,那就直接返回表示省略的字符串
        return tail*tail_length
    elif max_width:
        # 如果出现提供了最大宽度但最大宽度还不如结尾表示省略的字符宽度大的时候就抛出异常
        raise AttributeError

    total_width = 0
    result = input_str

    for i in range(0, len(input_str)):
        total_width += get_char_width(input_str[i])

        if not max_width:
            continue

        # 当接近str_max_width时有几种情况:
        # 一种最离str_max_width还有一个半角字符,这种情况就继续循环
        # 另一种是截完当前字符总长度刚好为str_max_width,这种情况就停止分析下面的字符,
        # 直接在当前字符后面加上表示省略的符号后返回,这时总的长度刚好为max_width
        # 最后一种情况是截取完上一个字符后总宽度刚好和str_max_width差一个半角字符,
        # 刚好当前读取的字符的宽度是2(全角字符),那从输入字符串中截取的长度不可能和
        # str_max_width完全相同,会比str_max_width大一个半角宽度,这种情况就把表示
        # 省略的字符少显示一个,加到结尾,这样最后返回值的长度刚好也是max_width.
        if total_width < str_max_width:
            continue
        elif total_width == str_max_width:
            result = input_str[0:i+1] + tail*tail_length
            break
        else:
            result = input_str[0:i+1] + tail*(tail_length-1)
            break

    return result if max_width else total_width

具体如何在template中使用这里不讨论, 详见官方文档相关章节.

方案二: 使用Kitchen

Kitchen 是一个python的模板, 可以通过 PIP 安装. 它提供了不少功能, 其中就包括了我们需要的计算字符串宽度的函数.

具体的文档在这里: https://pythonhosted.org/kitchen/api-text-display.html

它的使用也非常简单, 也可以很方便地包装为Django的custom template filter:

>>> from kitchen.text.display import textual_width
>>> test_str = u"abc字符串123.-/"
>>> textual_width(test_str)
15
我测试了一下, 两种方案的结果基本相同, 我目前采用的是第一种方法.

值得注意的是, 不管采用哪种方法, 都不能确保精确地控制字符串的宽度, 因为字符串的宽度还和字体有关, 半角字符的宽度并不总是全角字符的一半。以上方案只能说比起 Django 自带的 truncatechars 截取效果会好很多。