Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

5. Working with numbers

livebooks_external/05-numbers.livemd

5. Working with numbers

# Install dependencies
Mix.install([
  :ex_cldr,
  :ex_cldr_numbers,
  # {:ex_cldr_numbers, git: "https://github.com/elixir-cldr/cldr_numbers.git", branch: "main", override: true},
  {:ex_money, "~> 5.15"},
  :ex_phone_number,
  :jason,
  :phoenix_html
])

# Define a backend module
defmodule DemoApp.Backend do
  use Cldr,
    # , "es", "pt", "fr", "de", "ja", "id", "hi", "bn", "ar", "mr", "te", "ta", "gu"],
    locales: ["en", "en-GB"],
    default_locale: "en",
    providers: [Cldr.Number],
    json_library: Jason
end

# Set an app-wide default backend
Application.put_env(:ex_cldr, :default_backend, DemoApp.Backend)

DemoApp.Backend.put_locale("en-US")

:ok

Decimals, delimeters and predefined formats

# tmp
Cldr.Number.to_string!(77_500, locale: "en-GB", currency: "GBP")
|> IO.puts()

Cldr.Number.to_string!(6_458, locale: "en-GB", currency: "GBP")
|> IO.puts()
£77,500.00
£6,458.00
:ok
# to help a user make sense of large numbers, they should include delimeters
Cldr.Number.to_string!(1_234_567_890)
"1,234,567,890"
# you may be used to showing one delimeter every three chartacters, but some
# locales have different conventions, e.g. in India:
Cldr.Number.to_string!(1_234_567_890, locale: "hi-IN")
"1,23,45,67,890"
sample_number = 1_234_567.89
# sample_number = 10000000

# the characters used for delimeters and decimals often differ by locale, e.g:
Cldr.Number.to_string!(sample_number, locale: "en-US")
|> IO.inspect(label: "English       ")

Cldr.Number.to_string!(sample_number, locale: "pt-BR")
|> IO.inspect(label: "Portuguese    ")

Cldr.Number.to_string!(sample_number, locale: "fr-FR")
|> IO.inspect(label: "French        ")

# but notice how the delimeters can also be expected at different places:
Cldr.Number.to_string!(sample_number, locale: "hi-IN")
|> IO.inspect(label: "Hindi (India) ")

# and the script or numbering system itself might also differ:
Cldr.Number.to_string!(sample_number, locale: "ar-EG")
|> IO.inspect(label: "Arabic (Egypt)")

:ok
English       : "1,234,567.89"
Portuguese    : "1.234.567,89"
French        : "1 234 567,89"
Hindi (India) : "12,34,567.89"
Arabic (Egypt): "١٬٢٣٤٬٥٦٧٫٨٩"
:ok
# we can display numbers in a few predefined formats
Cldr.Number.to_string!(0.12345, format: :standard)
|> IO.puts()

Cldr.Number.to_string!(0.12345, format: :scientific)
|> IO.puts()

Cldr.Number.to_string!(0.12345, format: :percent)
|> IO.puts()
0.123
1.2345E-1
12%
:ok
# you can list the available formats
Cldr.Number.Format.format_styles_for("en", :latn, DemoApp.Backend)
{:ok,
 [:scientific, :currency, :accounting, :currency_long, :percent, :standard,
  :accounting_alpha_next_to_number, :accounting_no_symbol, :currency_alpha_next_to_number,
  :currency_no_symbol, :currency_short, :decimal_long, :decimal_short]}
# but there are a few number systems (e.g. Roman, Greek, Japanese, Chinese & Korean) 
# where these format rules wouldn't make sense
Cldr.Number.Format.format_styles_for("ja", :jpan, DemoApp.Backend)
{:ok, []}
# a special format template string can be prescribed
# the characters are interpreted as:
#   . - decimal point
#   , - delimeter
#   0 - zero-padded number
#   # - number without padding
Cldr.Number.to_string!(1234.56789, format: "#,#,#,0.###")
|> IO.puts()

09:32:23.415 [warning] ex_cldr_numbers: number format "#,#,#,0.###" is being compiled. For performance reasons please consider adding this format to the `precompile_number_formats` list in the backend configuration.
1,2,3,4.568
:ok
Cldr.Number.System.number_systems_for!("hi", DemoApp.Backend)
%{default: :latn, native: :deva}
Cldr.Number.to_string!(123_456_789, locale: "hi", number_system: :default)
"12,34,56,789"
Cldr.Number.to_string!(12345, locale: "hi", number_system: :native)
"१२,३४५"
Cldr.Number.to_string!(12345, locale: "ta", number_system: :traditional)
# depending on the locale, a 'native' number system may be used
# e.g. for some of the most popular languages in India:

[
  {"hi", "hindi"},
  {"bn", "bengali"},
  {"mr", "marathi"},
  {"te", "telugu"},
  {"ta", "tamil"},
  {"gu", "gujarati"}
]
|> Enum.each(fn {locale, language} ->
  IO.puts(language)

  Cldr.Number.System.number_systems_for!(locale, DemoApp.Backend)
  |> Enum.each(fn {type, name} ->
    IO.puts("#{type} (#{name})")

    IO.puts(
      "\t#{type} (#{name}): " <>
        Cldr.Number.to_string!(12345, locale: locale, number_system: type)
    )
  end)

  # IO.puts("\tdefault: " <> Cldr.Number.to_string!(12345, locale: locale))
  # IO.puts("\tnative:  " <> Cldr.Number.to_string!(12345, locale: locale, number_system: :native))
  IO.puts("")
end)
hindi
default (latn)
	default (latn): 12,345
native (deva)
	native (deva): १२,३४५

bengali
default (beng)
	default (beng): ১২,৩৪৫
native (beng)
	native (beng): ১২,৩৪৫

marathi
default (deva)
	default (deva): १२,३४५
native (deva)
	native (deva): १२,३४५

telugu
default (latn)
	default (latn): 12,345
native (telu)
	native (telu): ౧౨,౩౪౫

tamil
default (latn)
	default (latn): 12,345
native (tamldec)
	native (tamldec): ௧௨,௩௪௫
traditional (taml)
[:en, :ar, :hi, :ja]
|> Enum.each(fn locale ->
  available_number_systems = Cldr.Number.System.number_systems_for!(locale, DemoApp.Backend)
  IO.puts("#{locale} ->  #{inspect(available_number_systems)}")
end)
en ->  %{default: :latn, native: :latn}
ar ->  %{default: :arab, native: :arab}
hi ->  %{default: :latn, native: :deva}
ja ->  %{default: :latn, native: :latn, traditional: :jpan, finance: :jpanfin}
:ok
Cldr.Number.System.number_system_from_locale(:hi)
:latn

Monetary values

Cldr.Number.to_string!(59.95, locale: "en", currency: "USD")
|> IO.puts()

# Cldr.Number.to_string!(59.95, locale: "fr", currency: "USD")
# |> IO.puts()

Cldr.Number.to_string!(59.95, locale: "de", currency: "USD")
|> IO.puts()

Cldr.Number.to_string!(59.95, locale: "pt", currency: "USD")
|> IO.puts()

# Cldr.Number.to_string!(59.95, locale: "id", currency: "USD")
# |> IO.puts()

# Cldr.Number.to_string!(59.95, locale: "ja", currency: "USD")
# |> IO.puts()

# Cldr.Number.to_string!(59.95, locale: "hi", currency: "USD")
# |> IO.puts()
$59.95
59,95 $
US$ 59,95
:ok
# the currency format is very useful
Cldr.Number.to_string!(1345.32, currency: "USD")
"$1,345.32"
# the same currency and value might be diplayed differently, depending on the locale
# e.g in Brazil:
Cldr.Number.to_string!(1345.32, locale: "pt-BR", currency: "USD")
"US$ 1.345,32"
Cldr.Number.to_string!(10.0, currency: "USD")
"$10.00"
# using ex_money, we can decide to display the fractional digits only if they are relevant
3
|> Money.to_string!(no_fraction_if_integer: true)
|> IO.puts()

Money.from_float!(:USD, 10.0)
|> Money.to_string!(no_fraction_if_integer: true)
|> IO.puts()

:ok
$9.99
$10
:ok
# the 'accounting' format can be used to display negative values in brackets
[
  1345.32,
  -10.00,
  99.95
]
|> Enum.map(fn number ->
  Cldr.Number.to_string!(number, currency: "BRL", format: :accounting)
end)
|> Enum.each(&amp;IO.puts/1)
R$1,345.32
(R$10.00)
R$99.95
:ok
# if you are using the ex_money package, you can format money structs 
# a little less verbosely
money_struct = Money.from_float!(:USD, 12.50)

Cldr.to_string(money_struct)
"$12.50"

Phone numbers

# for phone numbers, we need to look outside of CLDR, to the 'ex_phone_number' package
# e.g. a phone number from India

{:ok, phone_number} = ExPhoneNumber.parse("+91 2212345678", nil)
{:ok,
 %ExPhoneNumber.Model.PhoneNumber{
   country_code: 91,
   national_number: 2212345678,
   extension: nil,
   italian_leading_zero: nil,
   number_of_leading_zeros: nil,
   raw_input: nil,
   country_code_source: nil,
   preferred_domestic_carrier_code: nil
 }}
# this can then be formatted in a few differnet ways

ExPhoneNumber.format(phone_number, :national)
|> IO.puts()

ExPhoneNumber.format(phone_number, :international)
|> IO.puts()

ExPhoneNumber.format(phone_number, :e164)
|> IO.puts()

ExPhoneNumber.format(phone_number, :rfc3966)
|> IO.puts()

:ok
022 1234 5678
+91 22 1234 5678
+912212345678
tel:+91-22-1234-5678
:ok
# notice how in different countries the numbers are formatted differently
# e.g. for France the spaces are in differnt placese
{:ok, phone_number} = ExPhoneNumber.parse("+33 109758351", nil)
ExPhoneNumber.format(phone_number, :international)
"+33 1 09 75 83 51"
# numbers can be parsed from a variety of input formats

parsed_numbers =
  [
    {"US      ", "+1 (555) 123-4567"},
    {"UK      ", "+44 7911 123456"},
    {"India   ", "+91 22 1234 5678"},
    {"France  ", "+33 1 23 45 67 89"},
    {"Brazil  ", "+55 21 9 1234 5678"}
  ]
  |> Enum.map(fn {country, str_phone_number} ->
    {:ok, phone_number} = ExPhoneNumber.parse(str_phone_number, nil)
    {country, phone_number}
  end)
[
  {"US      ",
   %ExPhoneNumber.Model.PhoneNumber{
     country_code: 1,
     national_number: 5551234567,
     extension: nil,
     italian_leading_zero: nil,
     number_of_leading_zeros: nil,
     raw_input: nil,
     country_code_source: nil,
     preferred_domestic_carrier_code: nil
   }},
  {"UK      ",
   %ExPhoneNumber.Model.PhoneNumber{
     country_code: 44,
     national_number: 7911123456,
     extension: nil,
     italian_leading_zero: nil,
     number_of_leading_zeros: nil,
     raw_input: nil,
     country_code_source: nil,
     preferred_domestic_carrier_code: nil
   }},
  {"India   ",
   %ExPhoneNumber.Model.PhoneNumber{
     country_code: 91,
     national_number: 2212345678,
     extension: nil,
     italian_leading_zero: nil,
     number_of_leading_zeros: nil,
     raw_input: nil,
     country_code_source: nil,
     preferred_domestic_carrier_code: nil
   }},
  {"France  ",
   %ExPhoneNumber.Model.PhoneNumber{
     country_code: 33,
     national_number: 123456789,
     extension: nil,
     italian_leading_zero: nil,
     number_of_leading_zeros: nil,
     raw_input: nil,
     country_code_source: nil,
     preferred_domestic_carrier_code: nil
   }},
  {"Brazil  ",
   %ExPhoneNumber.Model.PhoneNumber{
     country_code: 55,
     national_number: 21912345678,
     extension: nil,
     italian_leading_zero: nil,
     number_of_leading_zeros: nil,
     raw_input: nil,
     country_code_source: nil,
     preferred_domestic_carrier_code: nil
   }}
]
# parsed numbers can then be cast into a few different useful formats, 
# e.g, for dispaying them to a local audience, without the international dialling codes

IO.puts("National display format")
IO.puts("-----------------------")

parsed_numbers
|> Enum.each(fn {country, phone_number} ->
  "#{country} #{ExPhoneNumber.format(phone_number, :national)}"
  |> IO.puts()
end)
National display format
-----------------------
US       (555) 123-4567
UK       07911 123456
India    022 1234 5678
France   01 23 45 67 89
Brazil   (21) 91234-5678
:ok
{:ok, phone_number} = ExPhoneNumber.parse("21 912 345678", "BR")
ExPhoneNumber.format(phone_number, :international)
"+55 21 91234-5678"
# or for international audiences

IO.puts("International format")
IO.puts("-----------------------")

parsed_numbers
|> Enum.each(fn {country, phone_number} ->
  "#{country} #{ExPhoneNumber.format(phone_number, :international)}"
  |> IO.puts()
end)
International format
-----------------------
US       +1 555-123-4567
UK       +44 7911 123456
India    +91 22 1234 5678
France   +33 1 23 45 67 89
Brazil   +55 21 91234-5678
:ok
# or for storing them consistently in a database

IO.puts("E164 Standard format")
IO.puts("-----------------------")

parsed_numbers
|> Enum.each(fn {country, phone_number} ->
  "#{country} #{ExPhoneNumber.format(phone_number, :e164)}"
  |> IO.puts()
end)
E164 Standard format
-----------------------
US       +15551234567
UK       +447911123456
India    +912212345678
France   +33123456789
Brazil   +5521912345678
:ok

Ordinals, Roman numerals, words

# and there are certain applications where you may want to spell out numbers explicitly

["en-US", "hi-IN", "pt-BR"]
|> Enum.each(fn locale ->
  Cldr.Number.to_string!(1234, format: :spellout, locale: locale)
  |> IO.puts()
end)
one thousand two hundred thirty-four
एक हज़ार दो सौ चौंतीस
mil duzentos e trinta e quatro
:ok
# in some languages, year values are sometimes treated differently:
Cldr.Number.to_string!(1989, format: :spellout, locale: "en-US")
|> IO.puts()

Cldr.Number.to_string!(1989, format: :spellout_year, locale: "en-US")
|> IO.puts()
one thousand nine hundred eighty-nine
nineteen eighty-nine
:ok
# why is it so difficult to count in French?
IO.puts("English:")

[60, 70, 80, 90]
|> Enum.each(fn number ->
  Cldr.Number.to_string!(number, format: :spellout, locale: "en-US")
  |> IO.puts()
end)

IO.puts("\nvs French:")

[60, 70, 80, 90]
|> Enum.each(fn number ->
  Cldr.Number.to_string!(number, format: :spellout, locale: "fr-FR")
  |> IO.puts()
end)
English:
sixty
seventy
eighty
ninety

vs French:
soixante
soixante-dix
quatre-vingts
quatre-vingt-dix
:ok
# Roman numerals are sometimes used for naming things, like sections, chapters, or events
("Super Bowl " <> Cldr.Number.to_string!(59, format: :roman))
|> IO.puts()

# they can also useful for displaying a year value in an interresting way
("Since " <> Cldr.Number.to_string!(2018, format: :roman))
|> IO.puts()
Super Bowl LIX
Since MMXVIII
:ok
# you may want to show a number as a position (a.k.a. an ordinal number), e.g.

Cldr.Number.to_string!(1, format: :ordinal)
|> IO.puts()

Cldr.Number.to_string!(1, format: :ordinal, locale: "hi-IN")
|> IO.puts()

Cldr.Number.to_string!(1, format: :ordinal, locale: "pt-BR")
|> IO.puts()

08:05:28.898 [warning] ex_cldr_numbers: number format "#,##0" is being compiled. For performance reasons please consider adding this format to the `precompile_number_formats` list in the backend configuration.
1st
1ला
1º
:ok

Using formatted numbers in markup

# you can specify your own wrapper function for adding custom markup to number elements
Cldr.Number.to_string!(73.46,
  format: :currency,
  currency: :USD,
  locale: "es-US",
  wrapper: fn
    curr, :currency_symbol -> "" <> curr <> ""
    num, :number -> "" <> num <> ""
    el, _other -> el
  end
)
"73,46 US$"
# we suggest using Phoenix.HTML.Tag to ensure valid HTML is generated
Cldr.Number.to_string!(73.46,
  format: :currency,
  currency: :USD,
  wrapper: fn
    curr, :currency_symbol -> Phoenix.HTML.Tag.content_tag(:span, curr, class: "text-sm")
    num, :number -> Phoenix.HTML.Tag.content_tag(:span, num, class: "text-lg font-semibold")
    el, _other -> el
  end
)
"$73.46"