Perl, utf8 и Mojo::DOM
Нашел замечательный подводный камень, вызванный особенностями работы perl'овых строк, в сочетании со всякими legacy-кривостями в стандартах.
Немного о внутреннем представлении строк у perl'а
Вот так выглядит обычная строка из символов ASCII (диапазон 0-127).
$ perl -MDevel::Peek -E 'my $s = "HELLO!"; Dump($s);'
SV = PV(0x1a89a90) at 0x1ab0250
FLAGS = (PADMY,POK,IsCOW,pPOK)
PV = 0x1ab2c80 "HELLO!"\0
CUR = 6
LEN = 10
Существенны здесь флаги (PADMY,POK,IsCOW,pPOK) и длинна (6).
Теперь возьмём строку с символами, выходящими за рамки ASCII.
$ perl -MDevel::Peek -E 'my $s = "ПРИВЕТ!"; Dump($s);'
SV = PV(0x1841a90) at 0x1868250
FLAGS = (PADMY,POK,IsCOW,pPOK)
PV = 0x186ac80 "\320\237\320\240\320\230\320\222\320\225\320\242!"\0
CUR = 13
LEN = 15
Эта строка фактически состоит из валидного utf8, но perl об этом не знает (нет флага юникода). Флаги остались прежними, длинна изменилась (каждая буква кодируется 2 байтами + '!').
Попробуем теперь сказать perl'у, что эта строка таки юникодная.
$ perl -MDevel::Peek -E 'my $s = "ПРИВЕТ!"; utf8::upgrade($s); Dump($s);'
SV = PV(0x147ea90) at 0x14a5260
FLAGS = (PADMY,POK,pPOK,UTF8)
PV = 0x149ffa0 "\303\220\302\237\303\220\302\240\303\220\302\230\303\220\302\222\303\220\302\225\303\220\302\242!"\0 [UTF8 "\x{d0}\x{9f}\x{d0}\x{a0}\x{d0}\x{98}\x{d0}\x{92}\x{d0}\x{95}\x{d0}\x{a2}!"]
CUR = 25
LEN = 27
Внутреннее представление изменилось, длинна выросла вдвое. Сейчас это по прежнему "бинарная" строка с точки зрения perl'а, но с выставленным флагом юникода. Это не то что нам нужно, поэтому делаем ещё попытку:
$ perl -MDevel::Peek -E 'my $s = "ПРИВЕТ!"; utf8::decode($s); Dump($s);'
SV = PV(0xd66a90) at 0xd8d260
FLAGS = (PADMY,POK,pPOK,UTF8)
PV = 0xd7e1e0 "\320\237\320\240\320\230\320\222\320\225\320\242!"\0 [UTF8 "\x{41f}\x{420}\x{418}\x{412}\x{415}\x{422}!"]
CUR = 13
LEN = 15
Вот так гораздо лучше. Внутреннее представление и длинна остались прежними, но теперь строка корректно разобрана на юникодные символы.
Что будет, если каждую из этих строк скормить Mojo::DOM?
$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИВЕТ!</p>"; my $dom = Mojo::DOM->new($s); Dump($dom->to_string);'
PV = 0x25bd280 "<p>\320\237\320\240\320\230\320\222\320\225\320\242!</p>"\0
#
$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИВЕТ!</p>"; utf8::upgrade($s); my $dom = Mojo::DOM->new($s); Dump($dom->to_string);'
PV = 0xe670a0 "<p>\303\220\302\237\303\220\302\240\303\220\302\230\303\220\302\222\303\220\302\225\303\220\302\242!</p>"\0 [UTF8 "<p>\x{d0}\x{9f}\x{d0}\x{a0}\x{d0}\x{98}\x{d0}\x{92}\x{d0}\x{95}\x{d0}\x{a2}!</p>"]
#
$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИВЕТ!</p>"; utf8::decode($s); my $dom = Mojo::DOM->new($s); Dump($dom->to_string);'
PV = 0xd08310 "<p>\320\237\320\240\320\230\320\222\320\225\320\242!</p>"\0 [UTF8 "<p>\x{41f}\x{420}\x{418}\x{412}\x{415}\x{422}!</p>"]
И последний этап. Теперь возьмём и воткнём в середину строки что-нибудь из html entities,
например очень любимый тыжвестальщиками
, который декодируется в 0xA0 (dec 160, oct 240).
$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИ ВЕТ!</p>"; my $dom = Mojo::DOM->new($s); Dump($dom->to_string);'
FLAGS = (PADMY,POK,IsCOW,pPOK)
PV = 0x26f0690 "<p>\320\237\320\240\320\230\240\320\222\320\225\320\242!</p>"\0
Следите за руками: в результате декодирования
в типа "бинарную" строку добавился одиночный символ \240
,
и в валидная utf8-последовательность "\320\230" стала невалидной "\320\230\240".
При декодировании такой строки после символа "И" появится черная НЁХ: <p>ПРИ�ВЕТ!</p>
.
Что делать?
Вариант первый - "повырезать из строки все символы из диапазона 128-255", очевидно негодный, т.к. угробим строку окончательно. Вырезать во-первых нужно только одиночные символы, а во-вторых они всё-таки несут полезную информацию (тот же злополучный nbsp - это просто такой хитровыделанный пробел).
Вариант второй - "заменить одиночные символы на правильные комбинации utf-8" также потерпит неудачу, т.к. очень сложно вычленить такие символы, не написав при этом половину парсера utf8.
Вариант третий - "поубирать из входного html'я все entities" также не самая лучшая идея, т.к. сочетает недостатки двух предыдущих: теряет информацию, заставляет писать свою версию entities_decode или юзать стороннюю из HTML::Parser, добавив себе ещё одну зависимость в проект.
Правильный вариант состоит из нескольких этапов: переводить весь входной html в юникод, потом явно вызывать на нём utf8::decode, и только потом отдавать Mojo::DOM:
$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИ ВЕТ!</p>"; utf8::decode($s); my $dom = Mojo::DOM->new($s); $s = $dom->to_string; utf8::decode($s); Dump($s)'
FLAGS = (PADMY,POK,pPOK,UTF8)
PV = 0x24cda10 "<p>\320\237\320\240\320\230\302\240\320\222\320\225\320\242!</p>"\0 [UTF8 "<p>\x{41f}\x{420}\x{418}\x{a0}\x{412}\x{415}\x{422}!</p>"]
Вот мы видим выставленный флаг UTF8, корректно добавленную последовательность \302\240
(0xC2 0xA0) и корректно разобранный символ \x{a0}
, он же 'U+00A0'.
Как я это заметил?
А очень просто: если вы попытаетесь такую невалидную строку запихнуть в mysql, то у вас очень долго будет болеть голова на тему "какого же хера она обрезается?".
В моём случае дело осложнялось спецификой входных данных. На самом деле редко какой русский тыжверстальщик
пихает спецсимволы в середину слова (у нас нет обязательных акцентов, как в том же французском).
Зато он может не знать, зачем нужен тег <p>
, и делать межабзацные отступы следующим образом: <br> <br>
.
Тогда при попытке утащить со странички какой-либо текст, он будет обрезаться в процессе обработки ровно после первого абзаца. И тогда в процессе поиска причины бага ты перетряхнешь полпроекта, настройки DBD и mysql'я.
...зато детально узнал как работают строки в perl'е в части юникода.
Литература, скуренная в процессе
- perlunicode, perlre, perlguts, utf8
- UTF-8, Дополнение к латинице — 1
- UTF Perl Practice, или как использовать UTF-8 в перле
- Вся правда о UTF-8 флаге
...не считая исходников Mojo::DOM, Mojo::Util и HTML::Entities.