如何在Python 3中获取组合Unicode字符的显示宽度?

15

在Python 3中,Unicode字符串应该会友好地提供给您Unicode字符的数量,但我无法弄清楚如何获得字符串的最终显示宽度,因为有些字符是组合而成的。

创世记1:1 -- בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ

>>> len('בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ')
60

但是这个字符串只有37个字符宽度。规范化并不能解决问题,因为元音字母(位于较大字符下面的点)被视为不同字符。

>>> len(unicodedata.normalize('NFC', 'בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ'))
60

顺带提一下:在这方面,textwrap模块完全失效了,会过度换行。str.format看起来也有类似问题。


计算字形簇可能不足够,例如,不同的字体可能导致不同的文本大小 - jfs
1
即使我们保证使用等宽字体? - Conley Owens
请点击链接,尝试代码并亲自验证。 - jfs
2个回答

6
问题在于组合字符,在计算__len__时Python将其视为不同的字符,但在打印时合并为单个字符。
要确定一个字符是否为组合字符,我们可以使用unicodedata模块

unicodedata.combining(unichr)

返回分配给Unicode字符unichr的规范组合类作为整数。如果没有定义组合类,则返回0。

一个简单的解决方案是只剥离任何带有非零组合类的字符。这样留下来的是独立存在的字符,并且应该给我们一个具有可见字符和底层字符之间1对1映射的字符串。 (我是一个Unicode新手,它可能比这更复杂。组合字符和字形扩展器有微妙之处,我真的不理解,但似乎对这个特定的字符串无关紧要。)
所以我想出了这个函数:
import unicodedata

def visible_length(unistr):
    '''Returns the number of printed characters in a Unicode string.'''
    return len([char for char in unistr if unicodedata.combining(char) == 0])

该函数返回字符串的正确长度:

>>> visible_length('בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ')
37

这可能不是所有Unicode字符串的完整解决方案,但根据您正在处理的Unicode子集,这可能已经足够满足您的需求。


3
如果您需要完整的Unicode字形簇分割算法或行拆分功能,那就有点复杂了——请参考第三方模块,例如uniseg。 - bobince
这个想法我也有过,但当我尝试使用unicodedata.combining时发现它返回了很多不同的值,让我感到有些吓人,但或许它适合我的需求。谢谢。希望有人能提出更加健壮的解决方案。 - Conley Owens

5
以下是使用第三方工具uniseg提供的一些解决方案,这些建议来自@bobince:
>>> from uniseg.graphemecluster import grapheme_cluster_breakables
>>> sum(grapheme_cluster_breakables('בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ'))
37
>>>
>>> from uniseg.graphemecluster import grapheme_clusters
>>> list(grapheme_clusters('בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְ  הָאָרֶץ'))
['בְּ', 'רֵ', 'א', 'שִׁ', 'י', 'ת', ',', ' ', 'בָּ', 'רָ', 'א', ' ', 'אֱ', 'לֹ', 'הִ', 'י', 'ם', ',', ' ', 'אֵ', 'ת', ' ', 'הַ', 'שָּׁ', 'מַ', 'יִ', 'ם', ',', ' ', 'וְ', 'אֵ', 'ת', ' ', 'הָ', 'אָ', 'רֶ', 'ץ']
>>> len(list(grapheme_clusters('בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַי , ואֵת הָאָרֶץ')))
37

这看起来是正确的做法。

下面是一个示例,用于修补 textwrap。修补其他模块的解决方案应该类似。

>>> import textwrap
>>> text = 'בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשּׁמַיִם, וְאֵת הָאָרֶץ'
>>> print(textwrap.fill(text, width=40))  # bad, aggressive wrapping
בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת
הַשָּׁמַיִם, וְאֵת הָאָרֶץ
>>> import uniseg.graphemecluster
>>> def new_len(x):
...     if isinstance(x, str):
...         return sum(1 for _ in uniseg.graphemecluster.grapheme_clusters(x))
...     return len(x)
>>> textwrap.len = new_len
>>> print(textwrap.fill(text, width=40))  # Good wrapping
בְּרֵאשִׁית, בָּרָא אֱלֹהִים, אֵת הַשָּׁמַיִם, וְאֵת הָאָרֶץ

3
您也可以使用 regex 模块:count_user_perceived_characters = lambda text: len(regex.findall(r'\X', text))。该代码的功能是计算文本中用户感知字符的数量,使用正则表达式模块实现。 - jfs
@J.F.Sebastian 很好!那个项目说它打算取代 re。您有没有什么想法,它是否真的会取代? - Conley Owens

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接