Как фильтровать (или заменять) символы Unicode, которые занимают более 3 байтов в UTF-8?
Я использую Python и Django, но у меня проблема, связанная с ограничением MySQL. Согласно документации MySQL 5.1, их реализация utf8
не поддерживает 4-байтовые символы. MySQL 5.5 будет поддерживать 4-байтовые символы, используя utf8mb4
; и, когда-нибудь в будущем, utf8
также может поддержать его.
Но мой сервер не готов к обновлению до MySQL 5.5, и поэтому я ограничен символами UTF-8, которые занимают 3 байта или меньше.
Мой вопрос: Как фильтровать (или заменять) символы Unicode, которые занимают более 3 байтов?
Я хочу заменить все 4-байтные символы официальным \ufffd
(U + FFFD REPLACEMENT CHARACTER) или ?
.
Другими словами, я хочу, чтобы поведение было похоже на собственный метод str.encode()
Python (при передаче параметра 'replace'
). Изменить: мне нужно поведение, подобное encode()
, но я не хочу кодировать строку. Я хочу по-прежнему иметь строку unicode после фильтрации.
Я НЕ хочу, чтобы избежать символа перед хранением в MySQL, потому что это означало бы, что мне нужно было бы unescape все строки, которые я получаю из базы данных, что очень раздражает и неосуществимо.
См. также:
[EDIT] Добавлены тесты о предлагаемых решениях
Итак, до сих пор я получил хорошие ответы. Спасибо, люди! Теперь, чтобы выбрать один из них, я быстро проверил, чтобы найти самый простой и быстрый.
#!/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 секунд процессора (1,511 секунды процессора при вызове join()
и 1,900 секунд процессора, вычисляющих выражение генератора)
- Я не тестировал, используя
itertools
, потому что... ну... это решение, хотя и интересное, было довольно большим и сложным.
Заключение
Решение RegEx было, безусловно, самым быстрым.
Ответы
Ответ 1
Юникодовые символы в диапазонах \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 из Denilson Sá script в тело вопроса:
re_pattern = re.compile(u'[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)
filtered_string = re_pattern.sub(u'\uFFFD', unicode_string)
Ответ 2
Вы можете пропустить шаги декодирования и кодирования и напрямую определить значение первого байта (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-байтовые символы, это может быть даже быстрее, чем фильтрация регулярных выражений.
Ответ 3
Кодировать как 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]
𝐟𝐨𝐨
Ответ 4
И только ради удовольствия, itertools
monstrosity:)
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))
Ответ 5
Согласно документации MySQL 5.1: "Наборы символов ucs2 и utf8 не поддерживают дополнительные символы, которые лежат вне BMP". Это указывает на то, что может возникнуть проблема с суррогатными парами.
Обратите внимание, что стандарт Unicode 5.2 глава 3 фактически запрещает кодирование суррогатной пары как две 3-байтовые последовательности UTF-8 вместо одного 4 -byte UTF-8... см., например, стр. 93 "" Поскольку суррогатные кодовые точки не являются сканирующими значениями Unicode, любая последовательность байтов UTF-8, которая иначе отображалась бы в кодовые точки D800..DFFF плохо сформировалась "." Однако это запрещение - насколько я знаю, в значительной степени неизвестно или игнорируется.
Возможно, неплохо проверить, что делает MySQL с суррогатными парами. Если они не будут сохранены, этот код обеспечит достаточно простую проверку:
all(uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' for uc in unicode_string)
и этот код заменит "nasties" на u\ufffd
:
u''.join(
uc if uc < u'\ud800' or u'\ue000' <= uc <= u'\uffff' else u'\ufffd'
for uc in unicode_string
)
Ответ 6
Я предполагаю, что это не самый быстрый, но довольно простой ( "pythonic":):
def max3bytes(unicode_string):
return u''.join(uc if uc <= u'\uffff' else u'\ufffd' for uc in unicode_string)
NB: этот код не учитывает тот факт, что Unicode имеет суррогатные символы в диапазонах U + D800-U + DFFF.