defmodule Text.Emoji do @moduledoc """ Emoji detection and short-name conversion. Detection uses the Unicode `Extended_Pictographic` property, so it picks up the full emoji repertoire including newer additions without needing a per-release data update. Implementation note: the `Extended_Pictographic` regex is built at compile time from `Unicode.Emoji.emoji/0` rather than via PCRE2's `\\p{Extended_Pictographic}` syntax. Older PCRE2 versions bundled with OTP 26/27 do not always recognise the full property name, so expanding to an explicit codepoint character class keeps the library portable across OTP releases. Short-name conversion (`demojize/2`, `emojize/2`) uses a small bundled lookup of the most common emoji. It is not a complete CLDR annotation set; rare emoji round-trip as themselves. Users can extend the lookup at runtime via `add_emoji/1`. Sentiment scoring is provided by `sentiment/1` and `text_sentiment/1`, backed by the bundled Emoji Sentiment Ranking v1.0 (Kralj Novak et al., 2015 โ€” see `priv/emoji_sentiment/`) covering ~750 emoji with per-emoji negative/neutral/positive proportions and an aggregate score in `[-1.0, 1.0]`. """ # A curated lookup of common emoji -> CLDR-style short names (no colons). # Source: hand-picked from CLDR annotations for the most-used emoji. @short_names %{ "๐Ÿ˜€" => "grinning_face", "๐Ÿ˜ƒ" => "grinning_face_with_big_eyes", "๐Ÿ˜„" => "grinning_face_with_smiling_eyes", "๐Ÿ˜" => "beaming_face_with_smiling_eyes", "๐Ÿ˜†" => "grinning_squinting_face", "๐Ÿ˜…" => "grinning_face_with_sweat", "๐Ÿคฃ" => "rolling_on_the_floor_laughing", "๐Ÿ˜‚" => "face_with_tears_of_joy", "๐Ÿ™‚" => "slightly_smiling_face", "๐Ÿ™ƒ" => "upside_down_face", "๐Ÿ˜‰" => "winking_face", "๐Ÿ˜Š" => "smiling_face_with_smiling_eyes", "๐Ÿ˜‡" => "smiling_face_with_halo", "๐Ÿฅฐ" => "smiling_face_with_hearts", "๐Ÿ˜" => "smiling_face_with_heart_eyes", "๐Ÿคฉ" => "star_struck", "๐Ÿ˜˜" => "face_blowing_a_kiss", "๐Ÿ˜—" => "kissing_face", "๐Ÿ˜š" => "kissing_face_with_closed_eyes", "๐Ÿ˜™" => "kissing_face_with_smiling_eyes", "๐Ÿ˜‹" => "face_savoring_food", "๐Ÿ˜›" => "face_with_tongue", "๐Ÿ˜œ" => "winking_face_with_tongue", "๐Ÿคช" => "zany_face", "๐Ÿ˜" => "squinting_face_with_tongue", "๐Ÿค‘" => "money_mouth_face", "๐Ÿค—" => "smiling_face_with_open_hands", "๐Ÿค”" => "thinking_face", "๐Ÿคจ" => "face_with_raised_eyebrow", "๐Ÿ˜" => "neutral_face", "๐Ÿ˜‘" => "expressionless_face", "๐Ÿ˜ถ" => "face_without_mouth", "๐Ÿ˜" => "smirking_face", "๐Ÿ˜’" => "unamused_face", "๐Ÿ™„" => "face_with_rolling_eyes", "๐Ÿ˜ฌ" => "grimacing_face", "๐Ÿคฅ" => "lying_face", "๐Ÿ˜Œ" => "relieved_face", "๐Ÿ˜”" => "pensive_face", "๐Ÿ˜ช" => "sleepy_face", "๐Ÿ˜ด" => "sleeping_face", "๐Ÿ˜ท" => "face_with_medical_mask", "๐Ÿค’" => "face_with_thermometer", "๐Ÿค•" => "face_with_head_bandage", "๐Ÿคข" => "nauseated_face", "๐Ÿคฎ" => "face_vomiting", "๐Ÿคง" => "sneezing_face", "๐Ÿฅต" => "hot_face", "๐Ÿฅถ" => "cold_face", "๐Ÿฅด" => "woozy_face", "๐Ÿ˜ต" => "dizzy_face", "๐Ÿคฏ" => "exploding_head", "๐Ÿฅณ" => "partying_face", "๐Ÿ˜Ž" => "smiling_face_with_sunglasses", "๐Ÿค“" => "nerd_face", "๐Ÿ˜•" => "confused_face", "๐Ÿ˜Ÿ" => "worried_face", "๐Ÿ™" => "slightly_frowning_face", "โ˜น" => "frowning_face", "๐Ÿ˜ฎ" => "face_with_open_mouth", "๐Ÿ˜ฏ" => "hushed_face", "๐Ÿ˜ฒ" => "astonished_face", "๐Ÿ˜ณ" => "flushed_face", "๐Ÿฅบ" => "pleading_face", "๐Ÿ˜ฆ" => "frowning_face_with_open_mouth", "๐Ÿ˜ง" => "anguished_face", "๐Ÿ˜จ" => "fearful_face", "๐Ÿ˜ฐ" => "anxious_face_with_sweat", "๐Ÿ˜ฅ" => "sad_but_relieved_face", "๐Ÿ˜ข" => "crying_face", "๐Ÿ˜ญ" => "loudly_crying_face", "๐Ÿ˜ฑ" => "face_screaming_in_fear", "๐Ÿ˜–" => "confounded_face", "๐Ÿ˜ฃ" => "persevering_face", "๐Ÿ˜ž" => "disappointed_face", "๐Ÿ˜“" => "downcast_face_with_sweat", "๐Ÿ˜ฉ" => "weary_face", "๐Ÿ˜ซ" => "tired_face", "๐Ÿฅฑ" => "yawning_face", "๐Ÿ˜ค" => "face_with_steam_from_nose", "๐Ÿ˜ก" => "pouting_face", "๐Ÿ˜ " => "angry_face", "๐Ÿคฌ" => "face_with_symbols_on_mouth", "๐Ÿ˜ˆ" => "smiling_face_with_horns", "๐Ÿ‘ฟ" => "angry_face_with_horns", "๐Ÿ’€" => "skull", "๐Ÿ’ฉ" => "pile_of_poo", "๐Ÿคก" => "clown_face", "๐Ÿ‘ป" => "ghost", "๐Ÿ‘ฝ" => "alien", "๐Ÿ‘พ" => "alien_monster", "๐Ÿค–" => "robot", # Hearts "โค" => "red_heart", "๐Ÿงก" => "orange_heart", "๐Ÿ’›" => "yellow_heart", "๐Ÿ’š" => "green_heart", "๐Ÿ’™" => "blue_heart", "๐Ÿ’œ" => "purple_heart", "๐ŸคŽ" => "brown_heart", "๐Ÿ–ค" => "black_heart", "๐Ÿค" => "white_heart", "๐Ÿ’”" => "broken_heart", # Hands "๐Ÿ‘" => "thumbs_up", "๐Ÿ‘Ž" => "thumbs_down", "๐Ÿ‘Œ" => "ok_hand", "โœŒ" => "victory_hand", "๐Ÿคž" => "crossed_fingers", "๐ŸคŸ" => "love_you_gesture", "๐Ÿค˜" => "sign_of_the_horns", "๐Ÿค™" => "call_me_hand", "๐Ÿ‘ˆ" => "backhand_index_pointing_left", "๐Ÿ‘‰" => "backhand_index_pointing_right", "๐Ÿ‘†" => "backhand_index_pointing_up", "๐Ÿ‘‡" => "backhand_index_pointing_down", "โ˜" => "index_pointing_up", "โœ‹" => "raised_hand", "๐Ÿคš" => "raised_back_of_hand", "๐Ÿ–" => "hand_with_fingers_splayed", "๐Ÿ––" => "vulcan_salute", "๐Ÿ‘‹" => "waving_hand", "๐Ÿค" => "handshake", "๐Ÿ™" => "folded_hands", "๐Ÿ‘" => "clapping_hands", "๐Ÿ™Œ" => "raising_hands", # Common objects / symbols "๐Ÿ”ฅ" => "fire", "โญ" => "star", "โœจ" => "sparkles", "๐ŸŽ‰" => "party_popper", "๐ŸŽŠ" => "confetti_ball", "๐ŸŽˆ" => "balloon", "๐ŸŽ" => "wrapped_gift", "๐Ÿ†" => "trophy", "๐Ÿฅ‡" => "first_place_medal", "๐Ÿฅˆ" => "second_place_medal", "๐Ÿฅ‰" => "third_place_medal", "๐Ÿ’ฏ" => "hundred_points", "โœ…" => "check_mark_button", "โŒ" => "cross_mark", "โš " => "warning", "๐Ÿšจ" => "police_car_light", "๐Ÿ’ก" => "light_bulb", "๐Ÿ“Œ" => "pushpin", "๐Ÿ”—" => "link", "๐Ÿ“Ž" => "paperclip", "๐Ÿ“" => "memo", "๐Ÿ“š" => "books", "๐Ÿ’ป" => "laptop", "๐Ÿ“ฑ" => "mobile_phone", "โŒš" => "watch", "๐ŸŽต" => "musical_note", "๐ŸŽถ" => "musical_notes", "โ˜•" => "hot_beverage", "๐Ÿบ" => "beer_mug", "๐Ÿ•" => "pizza", "๐Ÿ”" => "hamburger", "๐ŸŒ" => "globe_showing_europe_africa", "๐ŸŒŽ" => "globe_showing_americas", "๐ŸŒ" => "globe_showing_asia_australia", "โ˜€" => "sun", "๐ŸŒ™" => "crescent_moon", "โ›…" => "sun_behind_cloud", "โ˜" => "cloud", "โ„" => "snowflake", "โšก" => "high_voltage", "๐Ÿ’ง" => "droplet", "๐ŸŒˆ" => "rainbow" } @reverse Enum.into(@short_names, %{}, fn {emoji, name} -> {name, emoji} end) @emoji_sentiment_path "priv/emoji_sentiment/emoji_sentiment_v1.csv" @external_resource @emoji_sentiment_path # Parse the Emoji Sentiment Ranking v1.0 CSV at compile time. # Header: Emoji,Unicode codepoint,Occurrences,Position,Negative,Neutral,Positive,Unicode name,Unicode block @emoji_sentiments @emoji_sentiment_path |> File.read!() |> String.split(~r/\r?\n/, trim: true) |> Enum.drop(1) |> Enum.flat_map(fn line -> case String.split(line, ",", parts: 9) do [emoji, _cp, occ, _pos, neg, neu, pos, name, _block] -> occurrences = String.to_integer(occ) negative = String.to_integer(neg) neutral = String.to_integer(neu) positive = String.to_integer(pos) score = if occurrences > 0, do: (positive - negative) / occurrences, else: 0.0 [ {emoji, %{ emoji: emoji, occurrences: occurrences, negative: negative, neutral: neutral, positive: positive, score: score, name: name }} ] _ -> [] end end) |> Map.new() @doc """ Returns a list of every emoji found in the text, in order of appearance. ### Arguments * `text` is the input string. ### Returns * A list of single-emoji strings. Emoji ZWJ sequences (e.g. family emoji) currently appear as their constituent pictographs rather than as a single grouped sequence. ### Examples iex> Text.Emoji.extract("Hello ๐Ÿ˜€ world ๐ŸŽ‰") ["๐Ÿ˜€", "๐ŸŽ‰"] iex> Text.Emoji.extract("no emoji here") [] """ # Build the Extended_Pictographic character class at compile time # from `Unicode.Emoji.emoji(:extended_pictographic)` ranges. This # avoids depending on PCRE2's `\p{Extended_Pictographic}` property # name, which varies in support across OTP-bundled PCRE2 versions # (notably failing on some OTP 26/27 builds). @extended_pictographic_class Unicode.Emoji.emoji() |> Map.get(:extended_pictographic, []) |> Enum.map_join(fn {a, a} -> "\\x{#{Integer.to_string(a, 16)}}" {a, b} -> "\\x{#{Integer.to_string(a, 16)}}-\\x{#{Integer.to_string(b, 16)}}" end) |> then(&("[" <> &1 <> "]")) @extended_pictographic_regex Regex.compile!(@extended_pictographic_class, "u") @spec extract(String.t()) :: [String.t()] def extract(text) when is_binary(text) do Regex.scan(@extended_pictographic_regex, text) |> Enum.map(&hd/1) end @doc """ Returns the number of emoji in the text. ### Examples iex> Text.Emoji.count("Hello ๐Ÿ˜€ world ๐ŸŽ‰") 2 """ @spec count(String.t()) :: non_neg_integer() def count(text) when is_binary(text), do: length(extract(text)) @doc """ Returns true when the text contains at least one emoji. ### Examples iex> Text.Emoji.contains?("hello ๐Ÿ˜€") true iex> Text.Emoji.contains?("hello") false """ @spec contains?(String.t()) :: boolean() def contains?(text) when is_binary(text) do Regex.match?(@extended_pictographic_regex, text) end @doc """ Removes every emoji from the text. ### Examples iex> Text.Emoji.strip("Hello ๐Ÿ˜€ world ๐ŸŽ‰!") "Hello world !" """ @spec strip(String.t()) :: String.t() def strip(text) when is_binary(text) do Regex.replace(@extended_pictographic_regex, text, "") end @doc """ Replaces emoji in the text with `:short_name:` placeholders. Emoji not in the bundled lookup are left as-is. ### Arguments * `text` is the input string. ### Options * `:delimiter` is the delimiter character used around the short name. Default `":"` produces `:smile:`. ### Returns * The text with known emoji replaced by their short names. ### Examples iex> Text.Emoji.demojize("Hello ๐Ÿ˜€ world") "Hello :grinning_face: world" iex> Text.Emoji.demojize("rare emoji ๐Ÿชฟ") "rare emoji ๐Ÿชฟ" """ @spec demojize(String.t(), keyword()) :: String.t() def demojize(text, options \\ []) when is_binary(text) do delimiter = Keyword.get(options, :delimiter, ":") lookup = short_names() Regex.replace(@extended_pictographic_regex, text, fn match -> case Map.get(lookup, match) do nil -> match name -> delimiter <> name <> delimiter end end) end @doc """ Replaces `:short_name:` placeholders with their emoji. Unknown short names are left as-is. ### Arguments * `text` is the input string. ### Options * `:delimiter` is the delimiter character around the short name. Default `":"`. ### Returns * The text with short names replaced by emoji. ### Examples iex> Text.Emoji.emojize("Hello :grinning_face: world") "Hello ๐Ÿ˜€ world" """ @spec emojize(String.t(), keyword()) :: String.t() def emojize(text, options \\ []) when is_binary(text) do delimiter = Keyword.get(options, :delimiter, ":") delim_pattern = Regex.escape(delimiter) pattern = Regex.compile!(delim_pattern <> "([a-z0-9_]+)" <> delim_pattern) reverse = reverse_lookup() Regex.replace(pattern, text, fn full, name -> case Map.get(reverse, name) do nil -> full emoji -> emoji end end) end @doc """ Returns the bundled sentiment record for a single emoji. The data is the Emoji Sentiment Ranking v1.0 (Kralj Novak et al., 2015), licensed CC-BY-SA 3.0. Coverage is ~750 of the most-used emoji in tweets at the time of the study; rare emoji return `nil`. ### Arguments * `emoji` is a single-emoji string. Surface form must match the upstream entry exactly (no skin-tone or ZWJ-sequence variants). ### Returns * A map with keys `:emoji`, `:occurrences`, `:negative`, `:neutral`, `:positive`, `:score` (range `[-1.0, 1.0]`), and `:name` (Unicode name). Returns `nil` if the emoji is not in the ranking. ### Examples iex> %{score: score} = Text.Emoji.sentiment("๐Ÿ˜‚") iex> score > 0.0 true iex> Text.Emoji.sentiment("not_an_emoji") nil """ @spec sentiment(String.t()) :: %{ emoji: String.t(), occurrences: non_neg_integer(), negative: non_neg_integer(), neutral: non_neg_integer(), positive: non_neg_integer(), score: float(), name: String.t() } | nil def sentiment(emoji) when is_binary(emoji) do Map.get(@emoji_sentiments, emoji) end @doc """ Returns the aggregate sentiment of every known emoji in a text. Each emoji's score is weighted by its corpus `:occurrences` so that high-confidence emoji dominate noisy ones โ€” matching the weighted-average approach used by the original paper. Emoji not in the ranking are skipped. ### Arguments * `text` is the input string. ### Returns * `{score, count}` where `score` is a float in `[-1.0, 1.0]` and `count` is the number of emoji that contributed. Returns `nil` when the text contains no scoreable emoji. ### Examples iex> {score, 2} = Text.Emoji.text_sentiment("Great day ๐Ÿ˜‚โค") iex> score > 0.0 true iex> Text.Emoji.text_sentiment("no emoji here") nil """ @spec text_sentiment(String.t()) :: {float(), pos_integer()} | nil def text_sentiment(text) when is_binary(text) do {weighted, weight, count} = text |> extract() |> Enum.reduce({0.0, 0, 0}, fn emoji, {w_acc, occ_acc, n} -> case Map.get(@emoji_sentiments, emoji) do nil -> {w_acc, occ_acc, n} %{score: s, occurrences: o} -> {w_acc + s * o, occ_acc + o, n + 1} end end) cond do count == 0 -> nil weight == 0 -> {0.0, count} true -> {weighted / weight, count} end end @doc """ Adds project-specific emoji to the runtime short-name lookup. Useful for custom platform emoji or for filling in gaps in the bundled set. ### Arguments * `entries` is a map or keyword list of `emoji => short_name` pairs (where `short_name` is the bare name without colons). ### Returns * `:ok` on success. """ @spec add_emoji(map() | keyword()) :: :ok def add_emoji(entries) do new_forward = entries |> Enum.into(%{}) |> Map.new(fn {k, v} -> {to_string(k), to_string(v)} end) new_reverse = Enum.into(new_forward, %{}, fn {emoji, name} -> {name, emoji} end) forward = :persistent_term.get({__MODULE__, :forward}, %{}) reverse = :persistent_term.get({__MODULE__, :reverse}, %{}) :persistent_term.put({__MODULE__, :forward}, Map.merge(forward, new_forward)) :persistent_term.put({__MODULE__, :reverse}, Map.merge(reverse, new_reverse)) :ok end defp short_names do Map.merge(@short_names, :persistent_term.get({__MODULE__, :forward}, %{})) end defp reverse_lookup do Map.merge(@reverse, :persistent_term.get({__MODULE__, :reverse}, %{})) end end