«Sanitize this!» и «Search that!»

в 8:24, , рубрики: html, Unicode, usability, utf-8

На дворе подходит к концу 2014 год, слово «юникод» уже не вызывает, вроде бы, священный трепет даже у Ричи Столлмана. Казалось бы, мы знаем и умеем хотя бы UTF-8. Неприятно вас удивлю: это не так. Давайте для начала взглянем на несколько картинок редактирования простого HTML (смотреть нужно внутрь тега body):

IntelliJ IDEA
«Sanitize this!» и «Search that!»

Sublime
«Sanitize this!» и «Search that!»

Eclipse
«Sanitize this!» и «Search that!»

Это один и тот же файл. Вот его код:

<!doctype html>
<head>
  <meta charset="utf-8">
</head>
<body>
    Barçelona Barçelona
</body>
</html>


Вот что мы увидим при попытке поиска текста в браузерах (у меня не бывает IE, поэтому буду признателен за картинку UPD спасибо, a553, IE11 тоже знает только про одно вхождение):

Firefox
«Sanitize this!» и «Search that!»

Chrome
«Sanitize this!» и «Search that!»

Внезапно FF нашел одно вхождение, а хром — два! И это не фейк, каждый может дома попробовать. Ну, не каждый, конечно. А только тот, кто умеет настраивать клавиатуру на так называемые deadkeys.

Я написал это слово по-разному. В первом — использована кнопка с испанской раскладки клавиатуры (и, как следствие, U+00E7, ç). Во втором — цедилла пришпилена из комбинируемой диакритики. Firefox сломал об это зубы, а хром — нет.

К чему это все?

Это все к тому, что пора перестать мыслить восьмибитно. Особенно, если вы вдруг столкнулись с поиском в нерусскоязычном сегменте интернета. Потому что кои-то-веки прошли и в кириллице все уже давно свои шишки набили. А вот не в кириллице все хуже. В основном, потому, что «who cares!».

В общем, я предлагаю экстравагантный вариант санации (санитаризации?) входного текста. Нужно всю однобайтную диакритику, которая внезапно осталась в utf-8 (убил бы) перевести в комбинируемую перед сохранением в базу (или куда там вы сохраняете тексты). Пример будет в конце (чтобы сразу весь спектр охватить), но тут главное — смысл, код очень простой, когда понимаешь, чего ты от него хочешь. Кроме того, не всегда понятно, что именно требуется для конкретного проекта, одной таблетки тут не существует, смысл данной заметки только в том, чтобы упомянуть проблему; дальше каждый расчесывает, как ему нравится.

Лигатуры, товарищи.

В юникоде есть лигатуры. И текст, сверстанный современным ТеХом, ими кишит (или нет, зависит от гуманизма автора). Так что как минимум хорошо бы об этом помнить:

LIGATURES = {
      'ffi' => 'ffi',
      'ffl' => 'ffl',
      'ff'  => 'ff',
      'fi'  => 'fi',
      'fl'  => 'fl',
      'ft'  => 'ſt',
      'st'  => 'st'
    }

Что еще?

Вот тут-то и начинается самое интересное. В utf-8 есть слепок ASCII-7, в «полноширинной форме». То есть, можно легко выполнить санацию любого входного текста без потери смысла:

  Scenario: substitute ascii with wide characters                  # features/language.feature:25
    Given input string is "Efficient Real Estate"                  # features/step_definitions/language_steps.rb:3
    When monkeypatched method wideize is called on string instance # features/step_definitions/language_steps.rb:25
    Then output string is printed out                              # features/step_definitions/language_steps.rb:43
      Result: Efficient Real Estate
    And output string length is 21                                 # features/step_definitions/language_steps.rb:51
    And output string equals to "Efficient Real Estate"            # features/step_definitions/language_steps.rb:47

И никакие injections больше не страшны, потому что в тексте после санации не остается ASCII вовсе (предполагается, что вы не называете хранимые процедуры символами из U+FF00 диапазона.

Если кому-то интересно, код тоже есть:

class String
    ASCII_SYMBOLS, ASCII_DIGITS, ASCII_LETTERS_SMALL, ASCII_LETTERS_CAP = [
        [(0x21..0x2F), (0x3A..0x40), (0x5B..0x60), (0x7B..0x7E)],
        [(0x30..0x39)],
        [(0x61..0x7A)],
        [(0x41..0x5A)]
    ].map { |current| current.map(&:to_a).flatten.map { |i| [i].pack('U') } }
    ASCII_ALL = [ASCII_SYMBOLS, ASCII_DIGITS, ASCII_LETTERS_SMALL, ASCII_LETTERS_CAP]

    CODEPOINT_ORIGIN = 0xFF00 - 0x0020 # For FULLWIDTH characters

    UTF_SYMBOLS, UTF_DIGITS, UTF_LETTERS_SMALL, UTF_LETTERS_CAP = ASCII_ALL.map { |current|
      Hash[* current.join.each_codepoint.map { |char|
        [[char].pack("U"), [char + CODEPOINT_ORIGIN].pack("U")]
      }.flatten]
    }
    UTF_ALL = [UTF_SYMBOLS.values, UTF_DIGITS.values, UTF_LETTERS_SMALL.values, UTF_LETTERS_CAP.values]

    UTF_ASCII = UTF_SYMBOLS.merge(UTF_DIGITS).merge(UTF_LETTERS_SMALL).merge(UTF_LETTERS_CAP)
    ASCII_UTF = UTF_ASCII.invert

  def shift_ascii_to_wide
    self.gsub /#{ASCII_ALL.flatten.map{ |k| Regexp::quote k }.join('|')}/um, UTF_ASCII
  end

И таблица перевода диакритики:

    DIACRITICS_MAP = {
      'À' => [ 'A', "u{0300}" ],
      'Á' => [ 'A', "u{0301}" ],
      'Â' => [ 'A', "u{0302}" ],
      'Ã' => [ 'A', "u{0303}" ],
      'Ä' => [ 'A', "u{0308}" ],
      'Å' => [ 'A', "u{030A}" ],
      'Ç' => [ 'C', "u{0327}" ],
      'È' => [ 'E', "u{0300}" ],
      'É' => [ 'E', "u{0301}" ],
      'Ê' => [ 'E', "u{0302}" ],
      'Ë' => [ 'E', "u{0308}" ],
      'Ì' => [ 'I', "u{0300}" ],
      'Í' => [ 'I', "u{0301}" ],
      'Î' => [ 'I', "u{0302}" ],
      'Ï' => [ 'I', "u{0308}" ],
      'Ð' => [ 'D', "u{0335}" ],
      'Ñ' => [ 'N', "u{0303}" ],
      'Ò' => [ 'O', "u{0300}" ],
      'Ó' => [ 'O', "u{0301}" ],
      'Ô' => [ 'O', "u{0302}" ],
      'Õ' => [ 'O', "u{0303}" ],
      'Ö' => [ 'O', "u{0308}" ],
      'Ø' => [ 'O', "u{0337}" ],
      'Ù' => [ 'U', "u{0300}" ],
      'Ú' => [ 'U', "u{0301}" ],
      'Û' => [ 'U', "u{0302}" ],
      'Ü' => [ 'U', "u{0308}" ],
      'Ý' => [ 'Y', "u{0301}" ],
      'à' => [ 'a', "u{0300}" ],
      'á' => [ 'a', "u{0301}" ],
      'â' => [ 'a', "u{0302}" ],
      'ã' => [ 'a', "u{0303}" ],
      'ä' => [ 'a', "u{0308}" ],
      'å' => [ 'a', "u{030A}" ],
      'ç' => [ 'c', "u{0327}" ],
      'è' => [ 'e', "u{0300}" ],
      'é' => [ 'e', "u{0301}" ],
      'ê' => [ 'e', "u{0302}" ],
      'ë' => [ 'e', "u{0308}" ],
      'ì' => [ 'ı', "u{0300}" ],
      'í' => [ 'ı', "u{0301}" ],
      'î' => [ 'ı', "u{0302}" ],
      'ï' => [ 'ı', "u{0308}" ],
      'ð' => [ 'd', "u{0335}" ],
      'ñ' => [ 'n', "u{0303}" ],
      'ò' => [ 'o', "u{0300}" ],
      'ó' => [ 'o', "u{0301}" ],
      'ô' => [ 'o', "u{0302}" ],
      'õ' => [ 'o', "u{0303}" ],
      'ö' => [ 'o', "u{0308}" ],
      'ø' => [ 'o', "u{0337}" ],
      'ù' => [ 'u', "u{0300}" ],
      'ú' => [ 'u', "u{0301}" ],
      'û' => [ 'u', "u{0302}" ],
      'ü' => [ 'u', "u{0308}" ],
      'ý' => [ 'y', "u{0301}" ],
      'ÿ' => [ 'y', "u{0308}" ]
    }.map { |k,v| [k, v.join('')] }.to_h

Привет, Хабр

Разработчики хабра, например, настолько суровы, что режут нахрен первую плоскость unicode (можете попробовать скопировать любой смайл в комментарий и обломаться).

Спасибо за внимание.

Автор: mudasobwa

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js