%% MIT License %% Copyright (c) 2019, Sergei Semichev %% Permission is hereby granted, free of charge, to any person obtaining a copy %% of this software and associated documentation files (the "Software"), to deal %% in the Software without restriction, including without limitation the rights %% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell %% copies of the Software, and to permit persons to whom the Software is %% furnished to do so, subject to the following conditions: %% The above copyright notice and this permission notice shall be included in all %% copies or substantial portions of the Software. %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, %% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE %% SOFTWARE. -module(uef_format). -export([format_number/3, format_number/4]). -export([format_price/1, format_price/2, format_price/3]). -export([format_bytes/1, format_bytes/2]). %%%------------------------------------------------------------------------------ %%% EUnit %%%------------------------------------------------------------------------------ -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. %%%------------------------------------------------------------------------------ %%% Macros %%%------------------------------------------------------------------------------ -define(DEFAULT_PRICE_PRECISION, 2). -define(DEFAULT_PRICE_DECIMALS, 2). -define(THOUSANDS_SEP, <<"">>). -define(DECIMAL_POINT, <<".">>). -define(CURRENCY_POSITION, left). -define(CURRENCY_SEP, <<"">>). -define(MULTI_BYTE_UNITS, ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']). %%%------------------------------------------------------------------------------ %%% Types %%%------------------------------------------------------------------------------ -type formatted_number() :: binary(). -type precision() :: integer(). -type decimals() :: 0..253. % see types for erlang:float_to_binary/2 -type cur_symbol() :: binary() | string(). -type format_number_opts() :: #{ thousands_sep => binary() | string(), decimal_point => binary() | string(), cur_symbol => cur_symbol(), cur_pos => left | right, cur_sep => binary() | string() }. -type multi_byte_unit() :: 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'. -type byte_opts_in() :: #{ base => 2 | 10, units => auto | multi_byte_unit(), to_type => bin | int }. -type valid_byte_opts() :: #{ base => 1000 | 1024, units => auto | multi_byte_unit(), to_type => bin | int }. -type formatted_bytes() :: binary() | integer() | {integer(), multi_byte_unit()}. -type byte_opts_error() :: {invalid_base|invalid_units|invalid_output_type, term()}. %%%------------------------------------------------------------------------------ %%% API %%%------------------------------------------------------------------------------ %% format_number/3 -spec format_number(number(), precision(), decimals()) -> formatted_number(). %% @doc %% The same as uef_format:format_number/4 with #{} as the forth argument. See uef_format:format_number/4 docs. %% @end format_number(Number, Precision, Decimals) -> format_number(Number, Precision, Decimals, #{}). %% format_number/4 -spec format_number(number(), precision(), decimals(), format_number_opts()) -> formatted_number(). %% @doc %% Formats Number by adding thousands separator between each set of 3 digits to the left of the decimal point, %% substituting Decimals for the decimal point, and rounding to the specified Precision. %% Returns a binary value. %% @end format_number(Number, Precision, Decimals, Opts) when is_integer(Number) -> format_number(erlang:float(Number), Precision, Decimals, Opts); format_number(Number, Precision, Decimals, Opts) when is_float(Number) -> Precision2 = case Precision > 0 andalso Decimals < Precision of true -> Decimals; false -> Precision end, RoundedNumber = uef_num:round_number(Number, Precision2), % round to Precision2 before formatting do_format_number(RoundedNumber, Decimals, Opts). %% format_price/1 -spec format_price(Number:: number()) -> FormattedPrice :: formatted_number(). %% @doc %% Formats Number in price-like style. %% Returns a binary containing FormattedPrice formatted with a precision of 2 and decimal digits of 2. %% The same as uef_format:format_price/2 with a precision of 2 as the second argument. See uef_format:format_price/2 docs. %% @end format_price(Price) -> format_price(Price, ?DEFAULT_PRICE_PRECISION). %% format_price/2 -spec format_price(Number :: number(), Precision :: precision()) -> FormattedPrice :: formatted_number(). %% @doc %% Formats Number in price-like style. %% Returns a binary containing FormattedPrice formatted with a specified precision as the second argument and decimal digits of 2. %% The same as uef_format:format_price/3 with #{} as the third argument. See uef_format:format_price/3 docs. %% @end format_price(Price, Precision) -> format_price(Price, Precision, #{}). %% format_price/3 -spec format_price(Number :: number(), Precision :: precision(), CurrencySymbol_OR_Options :: format_number_opts() | cur_symbol()) -> FormattedPrice :: formatted_number(). %% @doc %% Formats Number in price-like style. %% Returns a binary containing FormattedPrice formatted with a specified precision as the second argument, decimal digits of 2, %% and with currency symbol (or options) as the third argument. %% If CurrencySymbol_OR_Options is a map the functions works as uef_format:format_number/4 %% with decimal digits of 2 as the third argument and with options as the forth one. %% If CurrencySymbol_OR_Options is a binary or a string, the corresponding currency symbol is added to the left. %% @end format_price(Price, Precision, Opts) when is_map(Opts) -> format_number(Price, Precision, ?DEFAULT_PRICE_DECIMALS, Opts); format_price(Price, Precision, CurSymbol) when is_binary(CurSymbol) orelse is_list(CurSymbol) -> format_number(Price, Precision, ?DEFAULT_PRICE_DECIMALS, #{cur_symbol => CurSymbol}); format_price(Price, Precision, Opts) -> erlang:error({badarg, Opts}, [Price, Precision, Opts]). %% format_bytes/1 -spec format_bytes(integer()) -> formatted_bytes(). %% @equiv format_bytes(Bytes, #{}) format_bytes(Bytes) -> format_bytes(Bytes, #{}). %% format_bytes/2 %% @doc %% Converts bytes to multiples of bytes (KB, MB, GB, TB, PB, EB, ZB, YB). %% See README for details. %% @end -spec format_bytes(integer(), byte_opts_in()) -> formatted_bytes(). format_bytes(Bytes, Opts0) when is_integer(Bytes), is_map(Opts0) -> case validate_byte_opts(Opts0) of {ok, Opts} -> do_format_bytes(Bytes, Opts); {error, Reason} -> erlang:error(Reason, [Bytes, Opts0]) end; format_bytes(Bytes, Opts) -> BadArg = case is_integer(Bytes) of true -> Opts; false -> Bytes end, erlang:error({badarg, BadArg}, [Bytes, Opts]). %%%------------------------------------------------------------------------------ %%% Internal functions %%%------------------------------------------------------------------------------ %% do_format_number/3 -spec do_format_number(number(), decimals(), format_number_opts()) -> formatted_number(). do_format_number(Number, Decimals, Opts) -> PositiveNumber = case Number < 0 of false -> Number; true -> erlang:abs(Number) end, BinNum = erlang:float_to_binary(PositiveNumber, [{decimals, Decimals}]), {IntegerPart, DecimalPart} = case uef_bin:split(BinNum, <<".">>) of [I, D] -> {I, D}; % ex: <<"12.345">> -> [<<"12">>, <<"345">>] [I] -> {I, <<>>} % ex: <<"12345">> -> [<<"12345">>] (when Precision < 1) end, HeadSize = erlang:byte_size(IntegerPart) rem 3, <> = IntegerPart, % ex: <<"12", "345678">> = <<"12345678">> ThousandParts = split_thousands(IntRest), % ex: <<"345678">> -> [<<"345">>, <<678>>] AllIntegerParts = case HeadSize > 0 of true -> [Head|ThousandParts]; false -> ThousandParts end, ThousandsSep = maybe_to_binary(maps:get(thousands_sep, Opts, ?THOUSANDS_SEP)), % Join with thousands separator FormattedIntegerPart = <<(uef_bin:binary_join(AllIntegerParts, ThousandsSep))/binary>>, PositiveFormattedNumber = case DecimalPart of <<>> -> FormattedIntegerPart; _ -> DecimalPoint = maybe_to_binary(maps:get(decimal_point, Opts, ?DECIMAL_POINT)), <> end, % Insert "-" before number if negative FormattedNumber1 = case Number < 0 of false -> PositiveFormattedNumber; true -> <<"-", PositiveFormattedNumber/binary>> end, % Format with currency options format_number_with_currency(FormattedNumber1, Opts). %% format_number_with_currency/2 -spec format_number_with_currency(binary(), map()) -> binary(). format_number_with_currency(FmtNum, #{cur_symbol := CurSymbol0} = Opts) -> CurSymbol = maybe_to_binary(CurSymbol0), CurSep = maybe_to_binary(maps:get(cur_sep, Opts, ?CURRENCY_SEP)), case maps:get(cur_pos, Opts, ?CURRENCY_POSITION) of left -> <>; _ -> <> end; format_number_with_currency(FmtNum, _) -> FmtNum. %% maybe_to_binary/1 -spec maybe_to_binary(binary() | string()) -> binary(). maybe_to_binary(B) when is_binary(B) -> B; maybe_to_binary(L) when is_list(L) -> case unicode:characters_to_binary(L, utf8, utf8) of B when is_binary(B) -> B; _ -> erlang:error(badarg) end; maybe_to_binary(_)-> erlang:error(badarg). %% split_thousands/1 -spec split_thousands(binary()) -> [<<_:24>>]. split_thousands(Bin) -> split_thousands(Bin, []). %% split_thousands/2 -spec split_thousands(binary(), [<<_:24>>]) -> [<<_:24>>]. split_thousands(<<>>, List) -> lists:reverse(List); split_thousands(Bin, List) -> <> = Bin, split_thousands(Rest, [B | List]). %% do_format_bytes/2 -spec do_format_bytes(integer(), valid_byte_opts()) -> formatted_bytes(). do_format_bytes(Bytes, Opts) -> #{base := Base, units := Units0, to_type := Type} = Opts, {MultiBytes, Units} = bytes_to_multiple(Bytes, Units0, Base), case Type of bin -> BinMultiBytes = erlang:integer_to_binary(MultiBytes), BinUnits = erlang:atom_to_binary(Units, latin1), <>; int when (Units0 =:= auto) -> {MultiBytes, Units}; int -> MultiBytes end. %% bytes_to_multiple/3 -spec bytes_to_multiple(integer(), auto | multi_byte_unit(), 1000 | 1024) -> {integer(), multi_byte_unit()}. bytes_to_multiple(Bytes, Units, Base) -> bytes_to_multiple(Bytes, Units, Base, ?MULTI_BYTE_UNITS, 0, 'KB', 1). %% bytes_to_multiple/7 -spec bytes_to_multiple(integer(), auto | multi_byte_unit(), 1000 | 1024, [multi_byte_unit()], integer(), multi_byte_unit(), pos_integer()) -> {integer(), multi_byte_unit()}. bytes_to_multiple(_Bytes, _Units0, _Base, [], MultiBytes, Units, _Pow) -> {MultiBytes, Units}; bytes_to_multiple(Bytes, Units0, Base, [CurUnits | Tail], MultiBytesBefore, UnitsBefore, Pow) -> MultiBytes = erlang:trunc(Bytes/math:pow(Base, Pow)), case Units0 of CurUnits -> {MultiBytes, Units0}; auto when (MultiBytes =:= 0) -> {MultiBytesBefore, UnitsBefore}; _ -> bytes_to_multiple(Bytes, Units0, Base, Tail, MultiBytes, CurUnits, Pow + 1) end. %% validate_byte_opts/1 -spec validate_byte_opts(byte_opts_in()) -> {ok, valid_byte_opts()} | {error, byte_opts_error()}. validate_byte_opts(Opts0) -> validate_byte_opts([base, units, to_type], Opts0, #{}). %% validate_byte_opts/2 -spec validate_byte_opts([base|units|to_type,...], byte_opts_in(), valid_byte_opts()) -> {ok, valid_byte_opts()} | {error, byte_opts_error()}. validate_byte_opts([], _Opts0, Acc) -> {ok, Acc}; validate_byte_opts([base|Tail], Opts0, Acc) -> % base case maps:find(base, Opts0) of error -> validate_byte_opts(Tail, Opts0, Acc#{base => 1024}); % default {ok, 2} -> validate_byte_opts(Tail, Opts0, Acc#{base => 1024}); {ok, 10} -> validate_byte_opts(Tail, Opts0, Acc#{base => 1000}); {ok, Base} -> {error, {invalid_base, Base}} end; validate_byte_opts([units|Tail], Opts0, Acc) -> % units case maps:find(units, Opts0) of error -> % Units not specified, set them to 'auto' validate_byte_opts(Tail, Opts0, Acc#{units => auto}); {ok, auto} -> % auto validate_byte_opts(Tail, Opts0, Acc#{units => auto}); {ok, Units} -> % Units specified, check them case lists:member(Units, ?MULTI_BYTE_UNITS) of true -> validate_byte_opts(Tail, Opts0, Acc#{units => Units}); false -> {error, {invalid_units, Units}} end end; validate_byte_opts([to_type|Tail], Opts0, Acc) -> % to_type case maps:find(to_type, Opts0) of error -> % Output type not specified, set it to 'bin' validate_byte_opts(Tail, Opts0, Acc#{to_type => 'bin'}); {ok, Type} when (Type =:= int) orelse (Type =:= bin) -> validate_byte_opts(Tail, Opts0, Acc#{to_type => Type}); {ok, Type} -> {error, {invalid_output_type, Type}} end. %%%------------------------------------------------------------------------------ %%% Tests %%%------------------------------------------------------------------------------ -ifdef(TEST). format_number_test_() -> [ ?_assertEqual(<<"1.00">>, format_number(1, 2, 2, #{})), ?_assertEqual(<<"1.99">>, format_number(1.99, 2, 2, #{})), ?_assertEqual(<<"2.00">>, format_number(1.99, 1, 2, #{})), ?_assertEqual(<<"1 000 999.00">>, format_number(1000999, 2, 2, #{thousands_sep => <<" ">>})), ?_assertEqual(<<"2,000,000.00">>, format_number(2000000, 2, 2, #{thousands_sep => <<",">>})), ?_assertEqual(<<"9 999 999 999.00">>, format_number(9999999999, 2, 2, #{thousands_sep => <<" ">>})), ?_assertEqual(<<"99 999 999 999.99">>, format_price(99999999999.99, 2, #{thousands_sep => <<" ">>})), ?_assertEqual(<<"999 999 999 999.99">>, format_price(999999999999.99, 2, #{thousands_sep => <<" ">>})), ?_assertEqual(<<"999,999,999,999.99">>, format_price(999999999999.99, 2, #{thousands_sep => <<",">>})), ?_assertEqual(<<"USD 1,234,567,890==4600">>, uef_format:format_number(1234567890.4567, 2, 4, #{thousands_sep => ",", decimal_point => "==", cur_symbol => "USD", cur_sep => " ", cur_pos => left})), ?_assertEqual(<<"$1000.88">>, format_price(1000.8767, 4, "$")), ?_assertEqual(<<"1000.88 руб."/utf8>>, format_price(1000.8767, 4, #{cur_symbol => <<"руб."/utf8>>, cur_sep => " ", cur_pos => right})), ?_assertEqual(<<"1000.88 руб."/utf8>>, format_price(1000.8767, 4, #{cur_symbol => "руб.", cur_sep => " ", cur_pos => right})), ?_assertEqual(<<"€€1000.00"/utf8>>, format_price(1000, 4, #{cur_symbol => "€", cur_sep => "€", cur_pos => left})), ?_assertEqual(format_number(100, 2, 3), format_number(100, 2, 3, #{})), ?_assertEqual(format_price(1000), format_price(1000, 2)), ?_assertEqual(format_price(1000), format_price(1000, 2, <<>>)), ?_assertEqual(format_price(1000), format_number(1000, 2, 2, #{})) ]. format_bytes_test_() -> KB10_1000 = 10 * 1000, KB10_1024 = 10 * 1024, MB10_1000 = 10 * 1000 * 1000, MB10_1024 = 10 * 1024 * 1024, [ ?_assertEqual(<<"0KB">>, format_bytes(1023)), ?_assertEqual(<<"1KB">>, format_bytes(1024)), ?_assertEqual(<<"0KB">>, format_bytes(999, #{base => 10})), ?_assertEqual(<<"1KB">>, format_bytes(1023, #{base => 10})), ?_assertEqual(<<"9KB">>, format_bytes(KB10_1000)), ?_assertEqual(<<"10KB">>, format_bytes(KB10_1024)), ?_assertEqual(format_bytes(KB10_1000), format_bytes(KB10_1000, #{})), ?_assertEqual(format_bytes(MB10_1000), format_bytes(MB10_1000, #{})), ?_assertEqual(format_bytes(KB10_1024), format_bytes(KB10_1024, #{})), ?_assertEqual(format_bytes(MB10_1024), format_bytes(MB10_1024, #{})), ?_assertEqual(format_bytes(10000), format_bytes(10000, #{base => 2, units => auto})), ?_assertEqual(format_bytes(10000), format_bytes(10000, #{base => 2, units => auto, to_type => bin})), ?_assertEqual({9, 'KB'}, format_bytes(10000, #{to_type => int})), ?_assertEqual(9, format_bytes(KB10_1000, #{to_type => int, units => 'KB'})), ?_assertEqual(9, format_bytes(MB10_1000, #{to_type => int, units => 'MB'})), ?_assertEqual({9, 'MB'}, format_bytes(MB10_1000, #{to_type => int, units => auto})), ?_assertEqual(10, format_bytes(MB10_1024, #{to_type => int, units => 'MB'})), ?_assertEqual({10, 'MB'}, format_bytes(MB10_1024, #{to_type => int})), ?_assertEqual(<<"0MB">>, format_bytes(1000, #{units => 'MB'})), ?_assertError({badarg, bad_int}, format_bytes(bad_int)), ?_assertError({badarg, bad_opts}, format_bytes(1, bad_opts)) ]. -endif. % end of tests