Как отфильтровать (или заменить) символы Юникода, которые занимают более 3 байтов в UTF-8? - PullRequest
37 голосов
/ 10 июля 2010

Я использую Python и Django, но у меня возникла проблема, вызванная ограничением MySQL. Согласно документации MySQL 5.1 , их реализация utf8 не поддерживает 4-байтовые символы. MySQL 5.5 будет поддерживать 4-байтовые символы, используя utf8mb4; и когда-нибудь в будущем utf8 также может это поддержать.

Но мой сервер не готов к обновлению до MySQL 5.5, и поэтому я ограничен символами UTF-8, которые занимают 3 байта или меньше.

Мой вопрос: Как отфильтровать (или заменить) символы Юникода, которые занимают более 3 байтов?

Я хочу заменить все 4-байтовые символы на официальные \ufffd ( U + FFFD REPLACEMENT CHARACTER ) или на ?.

Другими словами, мне нужно поведение, очень похожее на собственный метод Python str.encode() (при передаче параметра 'replace'). Редактировать: я хочу поведение, подобное encode(), но я не хочу на самом деле кодировать строку. Я хочу, чтобы после фильтрации оставалась строка Unicode.

Я не хочу экранировать символ перед сохранением в MySQL, потому что это означало бы, что мне нужно будет удалить все строки, которые я получаю из базы данных, что очень раздражает и неосуществимо.

Смотри также:

[ПРАВИТЬ] Добавлены тесты по предлагаемым решениям

Так что я получил хорошие ответы до сих пор. Спасибо, люди! Теперь, чтобы выбрать один из них, я провел быстрое тестирование, чтобы найти самый простой и быстрый.

#!/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() совершил 515 вызовов функций за 0,139 секунд ЦП (0,138 секунд ЦП при встроенном sub())
  • filter_using_python() сделал 2097923 вызовов функций за 3.413 секунд CPU (1.511 секунд CPU при вызове join() и 1.900 секунд CPU при оценке выражения генератора)
  • Я не тестировал с использованием itertools, потому что ... хорошо ... это решение, хотя и было интересным, было довольно большим и сложным.

Заключение

Решение RegEx, безусловно, было самым быстрым.

Ответы [ 6 ]

34 голосов
/ 10 июля 2010

Символы Юникода в диапазонах \ u0000- \ uD7FF и \ uE000- \ uFFFF будут иметь 3-байтовые (или менее) кодировки в UTF8. Диапазон \ uD800- \ uDFFF предназначен для многобайтового UTF16. Я не знаю Python, но вы должны иметь возможность настроить регулярное выражение для соответствия за пределами этих диапазонов.

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

Редактировать добавление Python из сценария Денилсона Са в теле вопроса:

re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)
filtered_string = re_pattern.sub(u'\uFFFD', unicode_string)    
6 голосов
/ 07 октября 2012

Вы можете пропустить этапы декодирования и кодирования и непосредственно определить значение первого байта (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-байтовые символы, это может быть даже быстрее, чем фильтрация регулярных выражений.

1 голос
/ 11 июля 2010

Согласно документации MySQL 5.1 : "Наборы символов ucs2 и utf8 не поддерживают дополнительные символы, которые находятся вне BMP". Это указывает на возможную проблему с суррогатными парами.

Обратите внимание, что стандарт 3 стандарта Unicode 5.2 фактически запрещает кодировать суррогатную пару в виде двух 3-байтовых последовательностей UTF-8 вместо одной 4-байтовой последовательности UTF-8 ... см., Например, страницу 93 "" "Поскольку суррогатные кодовые точки не являются скалярными значениями Unicode, любая последовательность байтов UTF-8, которая в противном случае отображалась бы в кодовые точки D800..DFFF, не сформирована." "" Однако, насколько я знаю, это запрещение неизвестно игнорироваться.

Может быть, неплохо проверить, что 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
    )
1 голос
/ 11 июля 2010

И просто для удовольствия, чудовище 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 голос
/ 10 июля 2010

Кодировать как 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'

Обратите внимание, что вы не можете кодировать после объединения, поскольку суррогатные пары могут быть декодированы до перекодирования.

EDIT:

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]
???
0 голосов
/ 11 июля 2010

Я предполагаю, что это не самый быстрый, но довольно простой («питонический» :):

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.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...