main

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, например очень любимый тыжвестальщиками &nbsp;, который декодируется в 0xA0 (dec 160, oct 240).

$ perl -MMojo::DOM -MDevel::Peek -E 'my $s = "<p>ПРИ&nbsp;ВЕТ!</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

Следите за руками: в результате декодирования &nbsp; в типа "бинарную" строку добавился одиночный символ \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>ПРИ&nbsp;ВЕТ!</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>&nbsp;<br>.

Тогда при попытке утащить со странички какой-либо текст, он будет обрезаться в процессе обработки ровно после первого абзаца. И тогда в процессе поиска причины бага ты перетряхнешь полпроекта, настройки DBD и mysql'я.

...зато детально узнал как работают строки в perl'е в части юникода.

Литература, скуренная в процессе

...не считая исходников Mojo::DOM, Mojo::Util и HTML::Entities.