defmodule Text.Clean do @moduledoc """ Text cleanup utilities: HTML stripping, whitespace collapse, Unicode normalization, and mojibake repair. These are the small, fiddly transforms that every text-processing pipeline needs but no single library exposes coherently. The `clean/2` function chains them in a sensible default order; the individual transforms are also exposed so callers can compose their own pipeline. ### What each function does * `strip_html/1` — removes HTML/XML tags and decodes the most common HTML entities. * `collapse_whitespace/1` — replaces runs of any whitespace (including non-breaking space and other Unicode spaces) with a single ASCII space, and trims the ends. * `strip_control/1` — removes ASCII and Unicode control characters except `\\n`, `\\t`, and `\\r`. * `normalize/2` — Unicode normalization. Defaults to NFC. * `fix_mojibake/1` — repairs the most common mojibake patterns (UTF-8 misinterpreted as Windows-1252 or Latin-1). Inspired by `ftfy`. Only handles the well-known cases — not a complete replacement for `ftfy`. * `clean/2` — applies all of the above in a sensible default order. Steps can be turned off via options. """ @entities %{ "&" => "&", "<" => "<", ">" => ">", """ => "\"", "'" => "'", " " => " ", "©" => "©", "®" => "®", "™" => "™", "…" => "…", "—" => "—", "–" => "–", "‘" => "‘", "’" => "’", "“" => "“", "”" => "”", "«" => "«", "»" => "»", "·" => "·", "°" => "°", "±" => "±", "×" => "×", "÷" => "÷", "¶" => "¶", "§" => "§", "•" => "•", "†" => "†", "€" => "€", "£" => "£", "¥" => "¥", "¢" => "¢" } # The most frequent UTF-8-as-Windows-1252 mojibake bytes. Built from # the canonical observed-from -> intended-to substitutions. @mojibake [ {"’", "’"}, {"‘", "‘"}, {"“", "“"}, {"…", "…"}, {"•", "•"}, {"£", "£"}, {"©", "©"}, {"®", "®"}, {"°", "°"}, {"·", "·"}, {"»", "»"}, {"«", "«"}, {" ", " "}, {"é", "é"}, {"è", "è"}, {"ê", "ê"}, {"ë", "ë"}, {"à ", "à"}, {"á", "á"}, {"â", "â"}, {"ã", "ã"}, {"ä", "ä"}, {"Ã¥", "å"}, {"Ã", "í"}, {"î", "î"}, {"ï", "ï"}, {"ó", "ó"}, {"ô", "ô"}, {"õ", "õ"}, {"ö", "ö"}, {"ø", "ø"}, {"ú", "ú"}, {"û", "û"}, {"ü", "ü"}, {"ÿ", "ÿ"}, {"ñ", "ñ"}, {"ç", "ç"}, {"É", "É"}, {"À", "À"}, {"Â", "Â"}, {"È", "È"}, {"Ê", "Ê"}, {"ÃŽ", "Î"} ] @doc """ Removes HTML/XML tags and decodes common HTML entities. This is a pragmatic regex-based stripper, not a security-grade HTML parser. Use it for cleaning user input or scraped text, not for sanitizing output to a browser. ### Arguments * `text` is a string that may contain HTML/XML tags and entities. ### Returns * The text with tags removed and entities decoded. ### Examples iex> Text.Clean.strip_html("
Hello, world!
") "Hello, world!" iex> Text.Clean.strip_html("Tom & Jerry — cats & mice") "Tom & Jerry — cats & mice" """ @spec strip_html(String.t()) :: String.t() def strip_html(text) when is_binary(text) do text |> String.replace(~r/<[^>]*>/, "") |> decode_entities() end defp decode_entities(text) do text = Enum.reduce(@entities, text, fn {entity, replacement}, acc -> String.replace(acc, entity, replacement) end) # Decode numeric entities NNN; and HHH;. text |> then( &Regex.replace(~r/(\d+);/, &1, fn _, code -> codepoint_to_string(String.to_integer(code)) end) ) |> then( &Regex.replace(~r/([0-9a-fA-F]+);/, &1, fn _, code -> codepoint_to_string(String.to_integer(code, 16)) end) ) end defp codepoint_to_string(cp) when cp >= 0 and cp <= 0x10FFFF, do: <Hello, world!
") "Hello, world!" iex> Text.Clean.clean("it’s cool") "it’s cool" """ @spec clean(String.t(), keyword()) :: String.t() def clean(text, options \\ []) when is_binary(text) do text |> maybe(Keyword.get(options, :strip_html, true), &strip_html/1) |> maybe(Keyword.get(options, :fix_mojibake, true), &fix_mojibake/1) |> maybe(Keyword.get(options, :strip_control, true), &strip_control/1) |> maybe_normalize(Keyword.get(options, :normalize, :nfc)) |> maybe(Keyword.get(options, :collapse_whitespace, true), &collapse_whitespace/1) end defp maybe(text, true, fun), do: fun.(text) defp maybe(text, false, _fun), do: text defp maybe_normalize(text, false), do: text defp maybe_normalize(text, form) when form in [:nfc, :nfd, :nfkc, :nfkd], do: normalize(text, form) end