如何过滤(或替换)在UTF-8中占用超过3个字节的Unicode字符?

43
我正在使用Python和Django,但是由于MySQL的限制,我遇到了问题。根据MySQL 5.1文档,他们的utf8实现不支持4字节字符。MySQL 5.5将使用utf8mb4支持4字节字符;未来,utf8也可能支持它。
但我的服务器还没有准备好升级到MySQL 5.5,因此我只能使用占用3个或更少字节的UTF-8字符。
我的问题是:如何过滤(或替换)需要超过3个字节的Unicode字符? 我想将所有4字节字符都替换为官方的\ufffd(U+FFFD替换字符),或者用?替换。
换句话说,我希望行为与Python自己的str.encode()方法非常相似(当传递'replace'参数时)。编辑:我希望行为类似于encode(),但我不想实际编码字符串。我仍然希望在过滤后保留一个Unicode字符串。 我不希望在存储到MySQL之前转义字符,因为这意味着我需要对从数据库获取的所有字符串进行取消转义,这非常麻烦和不可行。
另请参见:
- 在Django票务系统上,“保存某些Unicode字符时出现不正确的字符串值”警告 - ‘’虽然在Unicode字符集中,但不是有效的Unicode字符吗?(在Stack Overflow上)

【编辑】添加了有关所提出的解决方案的测试

所以到目前为止,我得到了很好的答案。谢谢大家!现在,为了选择其中一个,我进行了快速测试,以找到最简单和最快的方法。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vi:ts=4 sw=4 et

import cProfile
import random
import re

# How many times to repeat each filtering
repeat_count = 256

# Percentage of "normal" chars, when compared to "large" unicode chars
normal_chars = 90

# Total number of characters in this string
string_size = 8 * 1024

# Generating a random testing string
test_string = u''.join(
        unichr(random.randrange(32,
            0x10ffff if random.randrange(100) > normal_chars else 0x0fff
        )) for i in xrange(string_size) )

# RegEx to find invalid characters
re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)

def filter_using_re(unicode_string):
    return re_pattern.sub(u'\uFFFD', unicode_string)

def filter_using_python(unicode_string):
    return u''.join(
        uc if uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' else u'\ufffd'
        for uc in unicode_string
    )

def repeat_test(func, unicode_string):
    for i in xrange(repeat_count):
        tmp = func(unicode_string)

print '='*10 + ' filter_using_re() ' + '='*10
cProfile.run('repeat_test(filter_using_re, test_string)')
print '='*10 + ' filter_using_python() ' + '='*10
cProfile.run('repeat_test(filter_using_python, test_string)')

#print test_string.encode('utf8')
#print filter_using_re(test_string).encode('utf8')
#print filter_using_python(test_string).encode('utf8')

结果:
  • filter_using_re()0.139 CPU秒内进行了515次函数调用(在sub()内置中花费了0.138 CPU秒)
  • filter_using_python()3.413 CPU秒内进行了2097923次函数调用(在join()调用时花费了1.511 CPU秒,在生成器表达式评估时花费了1.900 CPU秒)
  • 我没有使用itertools进行测试,因为......嗯...虽然有趣,但那个解决方案相当大和复杂。

结论

正则表达式解决方案是迄今为止最快的。

7个回答

40

Unicode字符在范围\u0000-\uD7FF和\uE000-\uFFFF内的将使用3个字节或更少的UTF8编码。\uD800-\uDFFF范围用于多字节UTF16。我不熟悉Python,但您应该能够设置一个正则表达式来匹配这些范围之外的内容。

pattern = re.compile("[\uD800-\uDFFF].", re.UNICODE)
pattern = re.compile("[^\u0000-\uFFFF]", re.UNICODE)

编辑问题主体,加入了来自Denilson Sá脚本的Python代码:

re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)
filtered_string = re_pattern.sub(u'\uFFFD', unicode_string)    

1
请注意,字符串"[^\u0000-\uFFFF]"等不是原始字符串,也就是说,字符串文字没有前缀r - Flimm
我不得不将 u'[^\u0000-\uD7FF\uE000-\uFFFF]' 中的第一个范围结束符从 '\uD7FF' 更改为 '\u07FF',因为仍然有一些字符没有被清除。 - Rolando Urquiza

6

您可以跳过解码和编码步骤,直接检测每个字符的第一个字节(8位字符串)的值。根据UTF-8:

#1-byte characters have the following format: 0xxxxxxx
#2-byte characters have the following format: 110xxxxx 10xxxxxx
#3-byte characters have the following format: 1110xxxx 10xxxxxx 10xxxxxx
#4-byte characters have the following format: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

根据这个,你只需要检查每个字符的第一个字节的值来过滤掉4字节字符:
def filter_4byte_chars(s):
    i = 0
    j = len(s)
    # you need to convert
    # the immutable string
    # to a mutable list first
    s = list(s)
    while i < j:
        # get the value of this byte
        k = ord(s[i])
        # this is a 1-byte character, skip to the next byte
        if k <= 127:
            i += 1
        # this is a 2-byte character, skip ahead by 2 bytes
        elif k < 224:
            i += 2
        # this is a 3-byte character, skip ahead by 3 bytes
        elif k < 240:
            i += 3
        # this is a 4-byte character, remove it and update
        # the length of the string we need to check
        else:
            s[i:i+4] = []
            j -= 4
    return ''.join(s)

跳过解码和编码部分可以省下一些时间,对于大多数只包含单字节字符的较小字符串来说,这甚至比常规表达式过滤更快。


1

先以 UTF-16 编码,然后再重新编码为 UTF-8。

>>> t = u''
>>> e = t.encode('utf-16le')
>>> ''.join(unichr(x).encode('utf-8') for x in struct.unpack('<' + 'H' * (len(e) // 2), e))
'\xed\xa0\xb5\xed\xb0\x9f\xed\xa0\xb5\xed\xb0\xa8\xed\xa0\xb5\xed\xb0\xa8'

请注意,在连接后无法进行编码,因为代理对可能会在重新编码之前被解码。
编辑:
MySQL(至少5.1.47)处理代理对没有问题:
mysql> create table utf8test (t character(128)) collate utf8_general_ci;
Query OK, 0 rows affected (0.12 sec)

  ...

>>> cxn = MySQLdb.connect(..., charset='utf8')
>>> csr = cxn.cursor()
>>> t = u''
>>> e = t.encode('utf-16le')
>>> v = ''.join(unichr(x).encode('utf-8') for x in struct.unpack('<' + 'H' * (len(e) // 2), e))
>>> v
'\xed\xa0\xb5\xed\xb0\x9f\xed\xa0\xb5\xed\xb0\xa8\xed\xa0\xb5\xed\xb0\xa8'
>>> csr.execute('insert into utf8test (t) values (%s)', (v,))
1L
>>> csr.execute('select * from utf8test')
1L
>>> r = csr.fetchone()
>>> r
(u'\ud835\udc1f\ud835\udc28\ud835\udc28',)
>>> print r[0]


1
也许可以使用 struct.unpack('<%dH' % (len(e)//2), e) - tzot
(1) 我所参考的MySQL文档声明字符集是列定义的一部分:t character(128) character set utf8...你确定你所拥有的是等价的吗? (2) 用Python 3.1尝试一下你的UTF-16特技 :-) - John Machin
@John:(1)在2.6上使用“字符集utf8”进行了重新测试,结果相同。(2)这只是股票UTF-8编解码器的限制。可以通过自定义编解码器或MySQL一开始就做正确的事情来解决它。 - Ignacio Vazquez-Abrams

1

只是为了好玩,一个 itertools 的怪物 :)

import itertools as it, operator as op

def max3bytes(unicode_string):

    # sequence of pairs of (char_in_string, u'\N{REPLACEMENT CHARACTER}')
    pairs= it.izip(unicode_string, it.repeat(u'\ufffd'))

    # is the argument less than or equal to 65535?
    selector= ft.partial(op.le, 65535)

    # using the character ordinals, return 0 or 1 based on `selector`
    indexer= it.imap(selector, it.imap(ord, unicode_string))

    # now pick the correct item for all pairs
    return u''.join(it.imap(tuple.__getitem__, pairs, indexer))

1
根据MySQL 5.1文档:“ucs2和utf8字符集不支持位于BMP之外的补充字符。” 这表明代理对可能存在问题。
请注意,Unicode标准5.2第3章实际上禁止将代理对编码为两个3字节UTF-8序列而不是一个4字节UTF-8序列...例如,请参见第93页“因为代理代码点不是Unicode标量值,任何本应映射到代码点D800..DFFF的UTF-8字节序列都是非法的。” 然而,据我所知,这种禁令在很大程度上是未知或被忽视的。
检查MySQL对代理对的处理情况可能是一个好主意。 如果它们不需要保留,那么此代码将提供足够简单的检查:
all(uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' for uc in unicode_string)

这段代码将用u\ufffd替换任何“不良字符”:

u''.join(
    uc if uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' else u'\ufffd'
    for uc in unicode_string
    )

然而,据我所知,这种禁令在很大程度上是未知或被忽视的。希望不是这样!至少Python 3拒绝对代理代码点进行编码(尝试chr(55349).encode("utf-8"))。 - Philipp
@Philipp:Python 3 确实做了“正确的事情”——但是你的例子是一个孤立代理,这是一个不同的问题;Python 2 可以通过该测试,但不能通过此测试:"\xed\xa0\x80\xed\xb0\x80".decode('utf8') 会产生 u'\U00010000' 而不是异常。 - John Machin
哦,你忘记给所有字符串添加 u 前缀了!应该是 u'\ufffd'。 ;) - Denilson Sá Maia

0

这不仅过滤掉了3个或更多字节的UTF-8 Unicode字符,而且以温和的方式尝试删除Unicode,并在可能的情况下用相关的ASCII字符替换它。如果您的文本中没有各种Unicode撇号和引号(通常来自苹果手持设备),而只有常规的ASCII撇号和引号,那么这将是未来的福音。

unicodedata.normalize("NFKD", sentence).encode("ascii", "ignore")

这是很强大的,我会加上更多的保护来使用它:

import unicodedata

def neutralize_unicode(value):
    """
    Taking care of special characters as gently as possible

    Args:
        value (string): input string, can contain unicode characters

    Returns:
        :obj:`string` where the unicode characters are replaced with standard
        ASCII counterparts (for example en-dash and em-dash with regular dash,
        apostrophe and quotation variations with the standard ones) or taken
        out if there's no substitute.
    """
    if not value or not isinstance(value, basestring):
        return value

    if isinstance(value, str):
        return value

    return unicodedata.normalize("NFKD", value).encode("ascii", "ignore")

顺便说一下,这是Python 2。


0
我猜它不是最快的,但相当简单(“Pythonic” :):
def max3bytes(unicode_string):
    return u''.join(uc if uc <= u'\uffff' else u'\ufffd' for uc in unicode_string)

注意:此代码未考虑Unicode范围U+D800至U+DFFF中代理字符的情况。

1
也许应该排除代理项。此外:uc <= u'\uffff' 可能比 ord(uc) < 65536 更好。 - John Machin

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