% -*- coding: utf-8; Mode: erlang; tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*- % ex: set softtabstop=4 tabstop=4 shiftwidth=4 expandtab fileencoding=utf-8: %% coding: utf-8 %% @author Marc Worrell %% @copyright 2009-2025 Marc Worrell %% @doc String normalization, used for search indices. Takes a string, lowercases %% it and then transliterates to ASCII. %% @end -module(z_string_normalize). -export([ normalize/1 ]). %% File with all the word mappings in the priv folder. -define(WORD_MAPPING_FILE, "normalize-words-mapping.csv"). %% Separators in a lowercased string. -define(is_sep(C), C < $0 orelse (C > $9 andalso C < $a) orelse (C > $z andalso C < 127) orelse C =:= 160 % non breaking space orelse C =:= 8220 % left double quote orelse C =:= 8221 % right double quote orelse C =:= 8216 % left single quote orelse C =:= 8217 % right single quote orelse C =:= 8230 % ellipsis orelse C =:= 8232 % line separator orelse C =:= 8233 % paragraph separator orelse C =:= 8212 % mdash orelse C =:= 8211 % ndash ). -define(is_map_space(C), C =:= 65279 % byte order mark orelse C =:= 8232 % line separator orelse C =:= 8233 % paragraph separator orelse C =:= 8203 % zero width space orelse C =:= 8204 % zero width non-joiner orelse C =:= 8239 % narrow no-break space ). -define(is_word_ignore_char(C), C =:= 8205 % zero width joiner orelse C =:= 8288 % word joiner orelse C =:= 173 % soft hyphen ). %% @doc Transliterate an unicode string to an ascii string with lowercase characters. %% Tries to transliterate some characters to a..z -spec normalize(string() | binary() | atom() | {trans, list()} | undefined) -> binary(). normalize(undefined) -> <<>>; normalize(A) when is_atom(A) -> normalize(atom_to_binary(A, utf8)); normalize(L) when is_list(L) -> normalize(unicode:characters_to_binary(L)); normalize(B) when is_binary(B) -> B1 = z_string:sanitize_utf8(B), B2 = unicode:characters_to_nfc_binary(B1), B3 = string:casefold(B2), normalize_words(B3); normalize({trans, []}) -> <<>>; normalize({trans, [{_, First} | _] = Tr}) -> V = proplists:get_value(en, Tr, First), normalize(V). %% Normalize specific words using custom mappings from CSV file. %% This allows language-specific transliterations that differ from %% standard romanization rules. normalize_words(B) when is_binary(B) -> Ws = normalize_words_word(B, <<>>, []), normalize(erlang:iolist_to_binary(Ws), <<>>). normalize_words_word(<<>>, W, Acc) -> lists:reverse([map_word(W)|Acc]); normalize_words_word(<>, W, Acc) when ?is_word_ignore_char(C) -> normalize_words_word(T, W, Acc); normalize_words_word(<>, W, Acc) when ?is_sep(C) orelse ?is_map_space(C) -> normalize_words_sep(T, <>, [map_word(W)|Acc]); normalize_words_word(<>, W, Acc) -> normalize_words_word(T, <>, Acc); normalize_words_word(<<_Byte, T/binary>>, W, Acc) -> % Skip invalid UTF-8 byte normalize_words_word(T, W, Acc). normalize_words_sep(<<>>, W, Acc) -> lists:reverse([W|Acc]); normalize_words_sep(<>, W, Acc) when ?is_word_ignore_char(C) -> normalize_words_sep(T, W, Acc); normalize_words_sep(<>, W, Acc) when not (?is_sep(C) orelse ?is_map_space(C)) -> normalize_words_word(T, <>, [W|Acc]); normalize_words_sep(<>, W, Acc) -> normalize_words_sep(T, <>, Acc); normalize_words_sep(<<_Byte, T/binary>>, W, Acc) -> % Skip invalid UTF-8 byte normalize_words_sep(T, W, Acc). %% Specific word normalizations. The mappings are loaded from a CSV file %% and stored in the persistent term storage for efficiency. map_word(<<>>) -> <<>>; map_word(W) -> try Mapping = persistent_term:get(z_string_normalize_word_mapping), maps:get(W, Mapping, W) catch error:badarg -> Filename = filename:join(code:priv_dir(zotonic_stdlib), ?WORD_MAPPING_FILE), {ok, Data} = file:read_file(Filename), Lines = binary:split(Data, [ <<"\n">>, <<"\r">> ], [ global, trim_all ]), NewMapping = lists:foldl( fun (<<"#", _/binary>>, Acc) -> Acc; (Line, Acc) -> case binary:split(Line, [ <<",">>, <<";">>, <<"\t">> ]) of [From, To] -> From1 = string:casefold(z_string:trim(From)), To1 = string:casefold(z_string:trim(To)), Acc#{ From1 => To1 }; _ -> Acc end end, #{}, Lines), persistent_term:put(z_string_normalize_word_mapping, NewMapping), maps:get(W, NewMapping, W) end. normalize(<<>>, Acc) -> Acc; normalize(<>, Acc) when (C >= $a andalso C =< $z) orelse (C >= $0 andalso C =< $9) orelse C =:= 32 orelse C =:= $\n orelse C =:= $\t orelse C =:= $- orelse C =:= $_ -> % Usual ascii characters - just for efficiency at the top normalize(T, <>); normalize(<<"ä"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ë"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ï"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ü"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ö"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"é"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"è"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"í"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ì"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ú"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ù"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ó"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ò"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ô"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ß"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ç"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ø"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"å"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"€"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ÿ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ã"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ñ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"õ"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Cyrillic support (from http://en.wikipedia.org/wiki/Romanization_of_Russian) % See also the rules for Russian passports (2013, ICAO). normalize(<<"а"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"б"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"в"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"г"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"д"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"е"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ё"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ж"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"з"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"и"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"й"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"к"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"л"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"м"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"н"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"о"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"п"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"р"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"с"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"т"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"у"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ф"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"х"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ц"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ч"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ш"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"щ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ъ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ы"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ь"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"э"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ю"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"я"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Ukrainian support normalize(<<"ґ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ї"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"і"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"є"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Polish support normalize(<<"ą"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ę"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ć"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ł"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ń"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ś"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ź"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ż"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Turkish support normalize(<<"ş"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ğ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ı"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Hebrew support (simplified) https://en.wikipedia.org/wiki/Romanization_of_Hebrew % TODO: check this, as it seems quite broken/incomplete normalize(<<"א"/utf8,T/binary>>, Acc) -> normalize(T, Acc); normalize(<<"ב"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"בּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ג"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"גּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ג׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ד"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"דּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ד׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ה"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"הּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ו"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"וּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ז"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"זּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ז׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ח"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ט"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"י"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"יּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ךכ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ךּ כּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ל"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"לּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"םמ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"מּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ןנ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"נּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ס"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"סּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ע"/utf8,T/binary>>, Acc) -> normalize(T, Acc); normalize(<<"ףפ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ףּ פּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ץצ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"צּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ץ׳צ׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ק"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"קּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ר"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"רּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ש"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"שׁ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"שּׁ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"שׂ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"שּׂ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ת"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"תּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ת׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Hebrew forms used in transliteration from Arabic normalize(<<"ח׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ט׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"ע׳ר׳"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Hebrew vowels normalize(<<"צ"/utf8, T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"חֱ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"חֲ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"חֳ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טִ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֵ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֶ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טַ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טָ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֹ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֻ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טוּ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֵי"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֶי"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טַיטַיְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טָיטָיְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֹיטֹיְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טֻיטֻיְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"טוּיטוּיְ"/utf8,T/binary>>, Acc) -> normalize(T, <>); % Some entities - we might want to add generic code here, depends % on where normalize/1 is used (can we assume that the input is always html?) normalize(<<"&", T/binary>>, Acc) -> normalize(T, <>); normalize(<<"<", T/binary>>, Acc) -> normalize(T, <>); normalize(<<">", T/binary>>, Acc) -> normalize(T, <>); normalize(<<"'", T/binary>>, Acc) -> normalize(T, <>); normalize(<<""",T/binary>>, Acc) -> normalize(T, <>); normalize(<<" ",T/binary>>, Acc) -> normalize(T, <>); normalize(<<"—",T/binary>>, Acc) -> normalize(T, <>); normalize(<<"–",T/binary>>, Acc) -> normalize(T, <>); normalize(<<"—"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<<"–"/utf8,T/binary>>, Acc) -> normalize(T, <>); normalize(<>, Acc) when C >= 32, C =< 126 -> % ASCII characters normalize(T, <>); normalize(<>, Acc) when C =:= $\n; C =:= $\t -> % Keep newlines and tabs normalize(T, <>); normalize(<>, Acc) when ?is_map_space(C) -> % Replace control or space-like characters with spaces normalize(T, <>); normalize(<>, Acc) when ?is_word_ignore_char(C) -> % Zero width space et al normalize(T, Acc); normalize(<>, Acc) -> % Try to remove any accents. C1 = z_string:unaccent(<>), normalize(T, <>); normalize(<<_C,T/binary>>, Acc) -> % Drop non-utf8 normalize(T, Acc).