[2.3.0] FormatNumber - Format numbers easily

I’ve remade this module. With more user-friendly functions, and it’s easy to use, just call the function with the value as the argument, with an optional option argument if you’re not satsified with the default!

2.3.0
FormatNumber.rbxm (12.3 KB)

2.2.0
FormatNumber.rbxm

2.1.0
FormatNumber.rbxm (7.8 KB)

2.0.1
FormatNumber.rbxm (7.3 KB)

2.0.0
FormatNumber.rbxm (7.3 KB)

Features

  • Negaive number, decimal, infinity and NaN support.
  • BigNum/BigInt and numeric string support
  • Range formatting
  • Formatting to parts.

Does not include

  • Unit formatting.
  • Pluralised number abbreviation.
  • Currency names

Functions

string FormatNumber.FormatStandard(number/BigNum/BigInt value, table options)
Format numbers in the standard pattern.

Example

print(FormatNumber.FormatStandard(1234.56)) --> 1,234.56
print(FormatNumber.FormatStandard(-1234.56)) --> -1,234.56
print(FormatNumber.FormatStandard(0/0)) --> NaN

string FormatNumber.FormatCompact(number/BigNum/BigInt value, table options)
Want to abbreviate numbers? No problem. Uses @berezaa’s suffixes (MoneyLib) and up to 10^308.

Example

print(FormatNumber.FormatCompact(1234)) --> 1.2k
print(FormatNumber.FormatCompact(12345)) --> 12k

string FormatNumber.FormatScientific(number/BigNum/BigInt start_value, number/BigNum/BigInt, end_value, table options)
Show numbers in scientific notation. Supports engineering

string, string FormatNumber.FormatStandardRange(number/BigNum/BigInt start_value, number/BigNum/BigInt end_value, table options)
string FormatNumber.FormatCompactRange(number/BigNum/BigInt start_value, number/BigNum/BigInt end_value, table options)
string, string FormatNumber.FormatScientificRange(number/BigNum/BigInt start_value, number/BigNum/BigInt end_value, table options)
Format numbers in range, experimental. (Expect for FormatCompactRange which only returns 1 value (don’t ask), it returns 2 value, the 1st is the formatted value while the 2nd is the rounding result)

print((FormatNumber.FormatStandardRange(1234, 12345))) --> 1,234–12,345
print((FormatNumber.FormatStandardRange(-math.huge, math.huge))) --> -∞–∞
print((FormatNumber.FormatCompactRange(1000, 2000))) --> 1k–2k

table FormatNumber.FormatStandardToParts(number/BigNum/BigInt value, table options)
table FormatNumber.FormatCompactToParts(number/BigNum/BigInt value, table options)
table FormatNumber.FormatScientificToParts(number/BigNum/BigInt value, table options)
Format numbers to parts, experimental.

type meaning
integer The integral part of the value
decimal The decimal symbol
group The grouping symbol
fraction The fraction part of the value
minusSign The minus sign
plusSign The plus sign
currency The currency value
literal The literal value
nan The Not-a-Number value
infinity The infinity value

table Format
Number
.AbbreviationToCLDR(table abbreviations, boolean include_currency)
Converts abbreviation suffixes of 3 digits to Unicode CLDR compact number pattern so you can use it on compactPattern as that only accept that “CLDR”-styled pattern (no plurals though).

Example FormatNumber.AbbreviationsToCLDR{'k', 'M', 'B', 'T'} converts it to {'0k', '00k', '000k', '0M', '00M', '000M', '0B', '00B', '000B', '0T', '00T', '000T'}

Options

Applies to FormatStandard and FormatCompact
groupSymbol
The grouping symbol, the default is ,

decimalSymbol
The decimal symbol, the default is .

useGrouping
Determine the number should be grouped. This modules assumes the default minimum grouping digits is 1 so:

  • "always" group the number (default for FormatStandard) (Trivial info: It also ignores locales that disables grouping like bg currency and the en-US-POSIX locale and still group the number)
  • "min2" group the number only if it has 5 digits or over (default for FormatCompact) (Trivial info: The actual function is locale dependant and it actually means the minimum grouping digits will be overrided to 2 if it’s lower than 2, so if the default minimum grouping digits is 3 like locale ee or 4 like locale hu before CLDR 36, it still won’t group values below 6 digits, this info does not matter here and this can be safely ignored)
  • "never", don’t group the number

Trivial info: The "auto" value isn’t supported because that’s locale dependant, and is redundant because the default minimum grouping digits is assumed to be 1 and “always” sets the minimum grouping digits to 1

style
The formatting style to use

  • "decimal" plain number formatting
  • "currency" currency number formatting
  • "percent" percent formatting

Trivial info: The "unit" value isn’t supported because it doesn’t support unit formatting

currency
The currency to format, (For this module, this is a currency symbol, while in International, this is the ISO 4217 currency code)

rounding
Rounding types

  • "halfEven" rounds to the nearest even in when it’s halfway or over (default)
  • "halfUp" rounds up the value when it’s halfway or over
  • "halfDown" rounds down the value when it’s halfway or over
  • "down" rounds down the value
  • "up" rounds up the value

only available on FormatCompact and FormatCompactRange
compactPattern
The compact pattern in tables, it starts at thousands so if the value is 1000 and the pattern is {'0K'}, it’ll format it as 1K

The pattern string is based around Unicode CLDR’s, ; for separation of positive and negative, ¤ for currency placeholder, etc. Keep in mind 0 fallbacks to FormatStandard and any value below 1000 have a pattern of 0 and only the 0 (size) and literal pattern (’) are suppored.
For example if I insert the value of 12345

pattern formatted
0 12,345
0 ten thousand 1.2 ten thousand
00k 12k

The default is the abbreviation pattern is ber’s Miner Haven, it’ll format values similarly to MoneyLib (except that values over 10^309 won’t be in scientific notation)

If at least one of the minimumSignificantDigits and maximumSignificantDigits option is not nil, minimumFractionDigits, maximumFractionDigits, minimumIntegerDigits and maximumIntegerDigits are ignored, and (Only applies to FormatCompact and FormatCompactRange) if at least one of the minimumSignificantDigits, maximumSignificantDigits, minimumFractionDigits, maximumFractionDigits, minimumIntegerDigits and maximumIntegerDigits option is not nil, minimumSignificantDigitsToKeep and trailingZeroesIfRounded are ignored.

minimumFractionDigits
The minimum of fractional digit to use, defaults to 0 (unlike International.NumberFormat which defaults depending on the ISO 4217 currency entered if the style is currency, this still defaults to 0 if the style is currency)

maximumFractionDigits
The maximum of fracitonal digit to use, defaults to 3, use math.huge for unlimited maximum fraciton digits.

minimumIntegerDigits
The minimum of integral digits to use (Zero padded), for example if the minimum integer digit is 2 and 1 is entered, it’ll format it as 01, defaults to 1

maximumIntegerDigits
(undocumented)

minimumSignificantDigits
The minimum of significant digits to use.

maximumSignificantDigits
The maximum of significant digits to use.

experemental
only available on FormatCompact and FormatCompactRange
minimumSignificantDigitsToKeep
The minimum significant digits to keep, and don’t round those, defaults to 2.

only available on FormatCompact and FormatCompactRange
trailingZeroesIfRounded
The minimum trailing zeroes if the FormatCompact/FormatCompactRange is rounded, defaults to 0.

Example
value trailingZeroesIfRounded minimumSignificantDigitsToKeep rounded/trauncated
1 0 2 1
1.2 0 2 1.2
9.87 0 2 9.8
10 0 2 10
12.3 0 2 12
98.76 0 2 98.7
100 0 2 100
123.4 0 2 123
987.65 0 2 987
1000 0 2 1000
1234.5 0 2 1234
9876.54 0 2 9876
10,000 0 2 10,000
98,765.43 0 2 98,765
12,345.6 0 2 12,345
1 0 3 1
1.2 0 3 1.2
9.87 0 3 9.87
10 0 3 10
12.3 0 3 12.3
98.76 0 3 98.7
100 0 3 100
123.4 0 3 123
987.65 0 3 987
1000 0 3 1000
1234.5 0 3 1234
9876.54 0 3 9876
10,000 0 3 10,000
98,765.43 0 3 98,765
12,345.6 0 3 12,345
1 1 2 1.0
1.2 1 2 1.2
9.87 1 2 9.8
10 1 2 10
12.3 1 2 12
98.76 1 2 98.7
1 2 3 1.00
1.2 2 3 1.20
9.87 2 3 9.87
10 2 3 10.0
12.3 2 3 12.3
98.76 2 3 98.7
100 2 3 100
123.4 2 3 123
987.65 2 3 987
1 1 3 1.0
1.2 1 3 1.2
9.87 1 3 9.87
10 1 3 10
12.3 1 3 12.3
98.76 1 3 98.7

Only available on FormatScientific and FormatScientificRange
engineering
Show in exponent in 10s only when it’s divisble by three, defaults to false

exponentLowercased
experemental, might change
Shows the exponent symbol as e instead of E, defaults to false

Question

Is it really easy to use?

Yep, all you need to is just call the function, with the value argument and if you’re not happy you can create a dictionary for the option argument with options you can change.

print(FormatNumber.FormatStandard(1000)) --> 1,000
print(FormatNumber.FormatCompact(1234)) --> 1.2k

Why was min2 the default value for useGrouping for numbers abbreviations?

International originally have two options for grouping: true and false, true maps to "auto" and false maps to "never" for standard notation however true maps to "min2" for compact notation/number abbreviation, the reason for this is was back then I was trying to replicate ECMA 402 behaviour. On the 2.1 update, I added more grouping options, and min2 became the default for grouping for compact notation and that’s the hang over from it.
You might also notice ECMA 402 also default min2 as grouping for compact notation (and still uses booleans for useGrouping option):
image
ECMA 402 does this because Unicode ICU defaulting grouping for compact notation to be min2.

That’s the way it is and it had always been that way. In summary, the behaviour is a hangover from International which is a hangover from ECMA 402 which uses Unicode ICU default grouping for compact notation.

Is this locale-aware?

No. If you want a locale aware version, see International. This is a subset of International so it might feel similar.

54 Likes

Good for simulators game, but isnt a good deal format on server on simulators games, cause you get stats per seconds, so the best idea is format the client vision

4 Likes

How does one even make a number look like the first example?? like 193202 —> 193,202 in script form? I can’t figure it out for the life of me

1 Like

Use NumberFormatInfo

local en = FormatNumber.NumberFormatInfo.Preset.en;
print(FormatNumber.FormatDecimal(193202, en));

193,202

2 Likes

Majority of the Arabs just go with the English Numerals and I don’t think anyone even uses Eastern Arabic Numerals anymore.

1 Like

I’d call it Western Arabic Numeral (despite the number system being orginated in India) instead of English Numerals, and yes while the majoirty of Arab world uses Western Arabic Numeral in some cases, CLDR defaults to Eastern Arabic Numerals in these locales, so I added it.

Not true (at least according to arabic locale on Facebook and Twitter):



image

Tip: Don’t assume everything when it comes to i18n.

1 Like

Yea my bad should’ve read properly I I thought of Western Arabic Numerals as the “Arabic Numbers” and Eastern were the ones on the clock in the wiki you linked as it showed up first.

Also you made a typo

1 Like

Thank you so much :)) I greatly appreciate this!

1 Like

now, how would you make 1e+17 for example, become 100,000,000,000,000,000 via script?? lol

1 Like

i figured it out, was a mistake in my code lol

1 Like

Having some trouble figuring out how to convert a number into a format such as 150K. I’ve read the documentation, but it really isn’t making sense to me. Could somebody provide an example?

1 Like

Not the best at documentation. Maybe use NumberFormatInfo?

FormatNumber.FormatCompact(150000, NumberFormatInfo.presets.en)

150K

Number Default NumberFormatInfo.presets.en
1,234 1234 (But will return 1 234) 1.2K
12,345 12 345 12K
123,456 123 456 123K
1,234,567 1,2 M 1.2M
12,345,678 12 M 12M
123,456,789 123 M 123M
1,234,567,890 1 G 1B

Float types can only reach up to 9 long-scale quadrillion (2^53 or 9 007 199 254 740 992) without being weird, I’ve made BigInteger module similar to C#'s BigInteger somewhere to bypass this.

You could do this but this only works for positive integers (just replace spaces with commas, I use spaces to prevent ambiguity)

("%.0f"):format(1e17):reverse():gsub("%d%d%d"):gsub(" $", ''):reverse();

or you could use the untested CLDRTools

CLDR.Numbers.FormatDecimal(CLDR.Locale.new('en'), BigInteger.new('100000000000000000'))

100,000,000,000,000,000

2 Likes

Remade the module.
Source:

--[=[
	Version 2.0.1
	This is intended for Roblox ModuleScripts
	BSD 2-Clause Licence
	Copyright ©, 2020 - Blockzez (devforum.roblox.com/u/Blockzez and github.com/Blockzez)
	All rights reserved.
	
	Redistribution and use in source and binary forms, with or without
	modification, are permitted provided that the following conditions are met:
	
	1. Redistributions of source code must retain the above copyright notice, this
	   list of conditions and the following disclaimer.
	
	2. Redistributions in binary form must reproduce the above copyright notice,
	   this list of conditions and the following disclaimer in the documentation
	   and/or other materials provided with the distribution.
	
	THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
	AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
	IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
	DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
	FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
	DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
	SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
	CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
	OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
	OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
]=]--
local f = { };

local function to_literal(str)
	return "'" .. str .. "'";
end;

function f.AbbreviationToCLDR(abbreviations, include_currency)
	local ret = { };
	for _, p in ipairs(abbreviations) do
		for zcount = 1, 3 do
			if p == '' or not p then
				table.insert(ret, '0');
			else
				table.insert(ret, (include_currency and '¤' or '') .. ('0'):rep(zcount) .. p:gsub('¤', "'¤'"):gsub('‰', "'‰'"):gsub("[.;,#%%]", to_literal):gsub("'", "''"));
			end;
		end;
	end;
	return ret;
end;

-- Berezaa's suffix
local defaultCompactPattern = f.AbbreviationToCLDR {
	-- https://minershaven.fandom.com/wiki/Cash_Suffixes
	"k", "M", "B", "T", "qd", "Qn", "sx", "Sp", "O", "N", "de", "Ud", "DD", "tdD",
	"qdD", "QnD", "sxD", "SpD", "OcD", "NvD", "Vgn", "UVg", "DVg", "TVg", "qtV",
	"QnV", "SeV", "SPG", "OVG", "NVG", "TGN", "UTG", "DTG", "tsTG", "qtTG", "QnTG",
	"ssTG", "SpTG", "OcTG", "NoTG", "QdDR", "uQDR", "dQDR", "tQDR", "qdQDR", "QnQDR",
	"sxQDR", "SpQDR", "OQDDr", "NQDDr", "qQGNT", "uQGNT", "dQGNT", "tQGNT", "qdQGNT",
	"QnQGNT", "sxQGNT", "SpQGNT", "OQQGNT", "NQQGNT", "SXGNTL", "USXGNTL", "DSXGNTL",
	"TSXGNTL", "QTSXGNTL", "QNSXGNTL", "SXSXGNTL", "SPSXGNTL", "OSXGNTL", "NVSXGNTL",
	"SPTGNTL", "USPTGNTL", "DSPTGNTL", "TSPTGNTL", "QTSPTGNTL", "QNSPTGNTL", "SXSPTGNTL",
	"SPSPTGNTL", "OSPTGNTL", "NVSPTGNTL", "OTGNTL", "UOTGNTL", "DOTGNTL", "TOTGNTL", "QTOTGNTL",
	"QNOTGNTL", "SXOTGNTL", "SPOTGNTL", "OTOTGNTL", "NVOTGNTL", "NONGNTL", "UNONGNTL", "DNONGNTL",
	"TNONGNTL", "QTNONGNTL", "QNNONGNTL", "SXNONGNTL", "SPNONGNTL", "OTNONGNTL", "NONONGNTL", "CENT", "UNCENT",
};

local sym_tables = { '¤', '%', '-', '+', 'E', '', '‰', '*' };
local function generatecompact(ptn)
	local org_ptn = ptn;
	if type(ptn) ~= "string" then
		error("Compact patterns must be a table of string", 4);
	end;
	local ret, size, i0 = { }, 0, 1;
	while i0 do
		local i1 = math.min(ptn:find("[0-9@#.,+%%;*'-]", i0) or #ptn + 1, ptn:find('¤', i0) or #ptn + 1, ptn:find('‰', i0) or #ptn + 1);
		local i2 = i1 + ((ptn:sub(i1, i1 + 1) == '¤' or ptn:sub(i1, i1 + 1) == '‰') and 1 or 0);
		local chr = ptn:sub(i1, i2);
		-- Literal charaters
		if chr == "'" then
			local r = ptn:sub(i0, i1 - 1);
			i0 = ptn:find("'", i1 + 1);
			if i0 == i1 + 1 then
				r = r .. "'";
			elseif i0 then
				r = r .. ptn:sub(i1 + 1, i0 - 1);
			else
				error("'" .. org_ptn .. "' is not a valid pattern", 2);
			end;
			table.insert(ret, r);
			i0 = i0 + 1;
		-- This is the rounding, which we're not using
		elseif chr:match('[1-9]') then
			error("The rounding (1-9) pattern isn't supported", 4);
		elseif chr == '¤' or chr == '‰' or chr:match('[%%*+%-]') then
			error("The '" .. chr .. "' pattern isn't supported", 4);
		elseif chr == '0' then
			table.insert(ret, ptn:sub(i0, i1 - 1));
			table.insert(ret, 0);
			
			i0 = ptn:find('[^0]', i1);
			local int = ptn:sub(i1, (i0 or #ptn + 1) - 1);
			
			if (not int:match('^0+$')) or size > 0 then
				error("'" .. org_ptn .. "' is not a valid pattern", 4);
			end;
			
			size = #int;
		else
			table.insert(ret, ptn:sub(i0));
			i0 = nil;
		end;
	end;
	
	return ret, size;
end;

-- From International, modified
local valid_value_property =
{
	groupSymbol = "f/str",
	decimalSymbol = "f/str",
	compactPattern = "f/table",
	
	style = { "decimal", "currency", "percent" },
	useGrouping = { "min2", "always", "never" },
	minimumIntegerDigits = "f/1..",
	maximumIntegerDigits = "f/minimumIntegerDigits..inf",
	minimumFractionDigits = "f/0..",
	maximumFractionDigits = "f/minimumFractionDigits..inf",
	minimumSignificantDigits = "f/1..",
	maximumSignificantDigits = "f/minimumSignificantDigits..inf",
	currency = "f/str",
	rounding = { "halfUp", "halfEven", "halfDown", "ceiling", "floor" },
};

local function check_property(tbl_out, tbl_to_check, property, default)
	local check_values = valid_value_property[property];
	if not check_values then
		return;
	end;
	
	local value = rawget(tbl_to_check, property);
	local valid = false;
	if type(check_values) == "table" then
		valid = table.find(check_values, value);
	elseif check_values == 'f/bool' then
		valid = (type(value) == "boolean");
	elseif check_values == 'f/str' then
		valid = (type(value) == "string");
	elseif check_values == 'f/table' then
		valid = (type(value) == "table");
	elseif not check_values then
		valid = true;
	elseif type(value) == "number" and (value % 1 == 0) or (value == math.huge) then
		local min, max = check_values:match("f/(%w*)%.%.(%w*)");
		valid = (value >= (tbl_out[min] or tonumber(min) or 0)) and ((max == '' and value ~= math.huge) or (value <= tonumber(max)));
	end;
	if valid then
		tbl_out[property] = value;
		return;
	elseif value == nil then
		if type(default) == "string" and (default:sub(1, 7) == 'error: ') then
			error(default:sub(8), 4);
		end;
		tbl_out[property] = default;
		return;
	end;
	error(property .. " value is out of range.", 4);
end;
local function check_options(ttype, options)
	local ret = { };
	if type(options) ~= "table" then
		options = { };
	end;
	check_property(ret, options, "groupSymbol", ',');
	check_property(ret, options, "decimalSymbol", '.');
	check_property(ret, options, 'useGrouping', (ttype == "compact") and "min2" or "always");
	check_property(ret, options, 'style', 'decimal');
	if ttype == "compact" then
		check_property(ret, options, 'compactPattern', defaultCompactPattern);
	end;
	
	if ret.style == "currency" then
		check_property(ret, options, 'currency', 'error: Currency is required with currency style');
	end;
	
	check_property(ret, options, 'rounding', 'halfEven');
	ret.isSignificant = not not (rawget(options, 'minimumSignificantDigits') or rawget(options, 'maximumSignificantDigits'));
	if ret.isSignificant then
		check_property(ret, options, 'minimumSignificantDigits', 1);
		check_property(ret, options, 'maximumSignificantDigits');
	else
		check_property(ret, options, 'minimumIntegerDigits', 1);
		check_property(ret, options, 'maximumIntegerDigits');
		check_property(ret, options, 'minimumFractionDigits');
		check_property(ret, options, 'maximumFractionDigits');
		
		if not (ret.minimumFractionDigits or ret.maximumFractionDigits) then
			if ret.style == "percent" then
				ret.minimumFractionDigits = 0;
				ret.maximumFractionDigits = 0;
			elseif ttype ~= "compact" then
				ret.minimumFractionDigits = 0;
				ret.maximumFractionDigits = 3;
			end;
		end;
	end;
	return ret;
end;

local function quantize(val, exp, rounding)
	local d, e = ('0' .. val):gsub('%.', ''), (val:find('%.') or (#val + 1)) + 1;
	local pos = e + exp;
	if pos > #d then
		return val:match("^(%d*)%.?(%d*)$");
	end;
	d = d:split('');
	local add = rounding == 'ceiling';
	if rounding ~= "ceiling" and rounding ~= "floor" then
		add = d[pos]:match(((rounding == "halfEven" and (d[pos - 1] or '0'):match('[02468]')) or rounding == "halfDown") and '[6-9]' or '[5-9]');
	end;
	for p = pos, #d do
		d[p] = 0
	end;
	if add then
		repeat
			if d[pos] == 10 then
				d[pos] = 0;
			end;
			pos = pos - 1;
			d[pos] = tonumber(d[pos]) + 1;
		until d[pos] ~= 10;
	end;
	return table.concat(d, '', 1, e - 1), table.concat(d, '', e);
end;
local function scale(val, exp)
	val = ('0'):rep(-exp) .. val .. ('0'):rep(exp);
	local unscaled = (val:gsub("[.,]", ''));
	local len = #val;
	local dpos = (val:find("[.,]") or (len + 1)) + exp;
	return unscaled:sub(1, dpos - 1) .. '.' .. unscaled:sub(dpos);
end;
local function compact(val, size)
	val = (val:gsub('%.', ''));
	return val:sub(1, size) .. '.' .. val:sub(size + 1);
end;
local function raw_format(val, minintg, maxintg, minfrac, maxfrac, rounding)
	local intg, frac;
	if maxfrac and maxfrac ~= math.huge then
		intg, frac = quantize(val, maxfrac, rounding);
	else
		intg, frac = val:match("^(%d*)%.?(%d*)$");
	end;
	intg = intg:gsub('^0+', '');
	frac = frac:gsub('0+$', '');
	local intglen = #intg;
	local fraclen = #frac;
	if minintg and (intglen < minintg) then
		intg = ('0'):rep(minintg - intglen) .. intg;
	end;
	if minfrac and (fraclen < minfrac) then
		frac = frac .. ('0'):rep(minfrac - fraclen);
	end;
	if maxintg and (intglen > maxintg) then
		intg = intg:sub(-maxintg);
	end;
	if frac == '' then
		return intg;
	end;
	return intg .. '.' .. frac;
end;
local function raw_format_sig(val, min, max, rounding)
	local intg, frac;
	if max and max ~= math.huge then
		intg, frac = quantize(val, max - ((val:find('%.') or (#val + 1)) - 1), rounding);
	else
		intg, frac = val:match("^(%d*)%.?(%d*)$");
	end;
	intg = intg:gsub('^0+', '');
	frac = frac:gsub('0+$', '');
	if min then
		min = math.max(min - #val:gsub('%.%d*$', ''), 0);
		if #frac < min then
			frac = frac .. ('0'):rep(min - #frac);
		end;
	end;
	if frac == '' then
		return intg;
	end;
	return intg .. '.' .. frac;
end;
local function parse_exp(val)
	if not val:find('[eE]') then
		return val;
	end;
	local negt, val, exp = val:match('^([+%-]?)(%d*%.?%d*)[eE]([+%-]?%d+)$');
	if val then
		exp = tonumber(exp);
		if not exp then
			return nil;
		end;
		if val == '' then
			return nil;
		end;
		return negt .. scale(val, exp);
	end;
	return nil;
end;
local function num_to_str(value, scale_v)
	local value_type = typeof(value);
	if value_type == "number" then
		value = ('%.17f'):format(value);
	else
		value = tostring(value);
		value = parse_exp(value) or value:lower();
	end;
	if scale_v then
		value = scale(value, scale_v);
	end;
	return value;
end;

local function format(ttype, ...)
	if select('#', ...) == 0 then
		error("missing argument #1", 3);
	end;
	local value, options = ...;
	options = check_options(ttype, options);
	value = num_to_str(value, options.style == "percent" and 2);
	
	-- from International, modified
	local negt, post = value:match("^([+%-]?)(.+)$");
	local tokenized_compact;
	if post:match("^[%d.]*$") and select(2, post:gsub('%.', '')) < 2 then
		local minfrac, maxfrac = options.minimumFractionDigits, options.maximumFractionDigits;
		if ttype == "compact" then
			post = post:gsub('^0+$', '');
			local intlen = #post:gsub('%..*', '') - 3;
			-- Just in case, that pattern is '0'
			if (options.compactPattern[math.min(intlen, #options.compactPattern)] or '0') ~= '0' then
				local size;
				tokenized_compact, size = generatecompact(options.compactPattern[math.min(intlen, #options.compactPattern)]);
				post = compact(post, size + math.max(intlen - #options.compactPattern, 0));
			-- The '0' pattern indicates no compact number available
			end;
			if not (minfrac or maxfrac) then
				maxfrac = ((#post:gsub('%.%d*$', '') < 2) and 1 or 0);
			end;
		end;
		
		if options.isSignificant then
			post = raw_format_sig(post, options.minimumSignificantDigits, options.maximumSignificantDigits, options.rounding);
		else
			post = raw_format(post, options.minimumIntegerDigits, options.maximumIntegerDigits, minfrac, maxfrac, options.rounding);
		end;
	elseif (post == "inf") or (post == "infinity") then
		return negt == '-' and '-∞' or '∞';
	else
		return 'NaN';
	end;
	negt = (negt == '-');
	
	local ret;
	local first, intg, frac = post:match("^(%d)(%d*)%.?(%d*)$");
	if (options.useGrouping ~= "never") and (#intg > (options.useGrouping == "min2" and 3 or 2)) then
		intg = intg:reverse():gsub("%d%d%d", "%1" .. options.groupSymbol:reverse()):reverse();
	end;
	ret = (negt and '-' or '') .. ((options.currency and (options.currency .. (options.currency:match("%a$") and ' ' or ''))) or '') .. first .. intg .. (frac == '' and '' or (options.decimalSymbol .. frac)) .. (options.style == "percent" and '%' or '');
	if tokenized_compact then
		local value_pos = table.find(tokenized_compact, 0);
		if value_pos then
			tokenized_compact[value_pos] = ret;
		end;
		return table.concat(tokenized_compact);
	end;
	return ret;
end;

function f.FormatStandard(...)
	return format('standard', ...);
end;

function f.FormatCompact(...)
	return format('compact', ...);
end;

return setmetatable({ }, { __metatable = "The metatable is locked", __index = f,
	__newindex = function()
		error("Attempt to modify a readonly table", 2);
	end,
});
Version 1 text

Current Version 1.1.0: FormatNumber.rbxm (6,3 KB)

Previous versions
1.0.0: FormatNumber.rbxm (5,5 KB)

What’s this module?

It’s a module that’s capable for formatting/shortening numbers
image
into something like this:
image

As you can see, this is more visually appealing than the first one.

Features

It supports, decimals, negatives and exponents as well as currencies and percentages. It also supports formatting array-like numeric tables like { 1, 2, 3 }.

Functions

FormatNumber.FormatCustom

Returns the formatted number in the specified pattern

Overloads
FormatNumber.FormatCustom(value, pattern, currency_symbol, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatCustom(value, pattern, currency_symbol, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCustom(value, pattern, currency_symbol, nfi)
FormatNumber.FormatCustom(value, pattern, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatCustom(value, pattern, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCustom(value, pattern, nfi)
FormatNumber.FormatCustom(value, pattern)

Parameters

  • value – The number to format
  • pattern
  • currency_symbol - The currency symbol that the pattern substitutes to.
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
  • decimal_quantization – Round high-percision numbers to the format pattern. Defaults to true
FormatNumber.FormatDecimal

Returns the formatted number in the pattern of a specified locale

Overloads
FormatNumber.FormatDecimal(value, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatDecimal(value, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatDecimal(value, nfi)
FormatNumber.FormatDecimal(value)

Parameters

  • value – The number to format
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
  • decimal_quantization – Round high-percision numbers to the format pattern. Defaults to true
FormatNumber.FormatCurrency

Returns the formatted Currencyial number in the pattern of a specified NumberFormatInfo.

Overloads
FormatNumber.FormatCurrency(value, currency_symbol, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatCurrency(value, currency_symbol, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCurrency(value, currency_symbol, nfi)
FormatNumber.FormatCurrency(value, currency_symbol)

Parameters

  • value – The number to format
  • currency_symbol - The currency symbol that the pattern substitutes to.
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
  • decimal_quantization – Round high-percision numbers to the format pattern. Defaults to true
FormatNumber.FormatExponent

Returns the formatted exponential number in the pattern of a specified NumberFormatInfo.
Overloads
FormatNumber.FormatExponent(value, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatExponent(value, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatExponent(value, nfi)
FormatNumber.FormatExponent(value)

Parameters

  • value – The number to format
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
  • decimal_quantization – Round high-percision numbers to the format pattern. Defaults to true
FormatNumber.FormatPercent

Returns the formatted number in the pattern of a specified locale

Overloads
FormatNumber.FormatPercent(value, nfi, ignore_minimum_grouping_digits, decimal_quantization)
FormatNumber.FormatPercent(value, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatPercent(value, nfi)
FormatNumber.FormatPercent(value)

Parameters

  • value – The number to format
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
  • decimal_quantization – Round high-percision numbers to the format pattern. Defaults to true
FormatNumber.FormatList

(Partially undocumented)
Format lists of numbers, e.g. { 1500, 3000, 4500 } would return 1,500, 3,000, 4,500, supports Vector2, Vector3, Vector2int16, Vector3int16, UDim, UDim2, Color3 and Rect.

FormatNumber.FormatCompactCustom

Returns the formatted compacted number like 1.5K
Overloads
FormatNumber.FormatCompactCustom(value, compactlist, currency_symbol, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCompactCustom(value, compactlist, currency_symbol, nfi)
FormatNumber.FormatCompactCustom(value, compactlist, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCompactCustom(value, compactlist, nfi)
FormatNumber.FormatCompactCustom(value, compactlist)

Parameters

  • value – The number to format
  • compactlist – An array of patterns, the function will get the index the length of the value of this array, '0' or '' indicates there aren’t any compact number format available thus, will just return the formatted number
  • currency_symbol – The currency symbol that the pattern substitutes to.
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
FormatNumber.FormatCompactDecimal

Returns the formatted compacted number like 1.5K depending on the NumberFormatInfo’s pattern
Overloads
FormatNumber.FormatCompactDecimal(value, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCompactDecimal(value, nfi)
FormatNumber.FormatCompactDecimal(value)

Parameters

  • value – The number to format
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
FormatNumber.FormatCompactCurrency

Returns the currency formatted compacted number like 1.5K depending on the NumberFormatInfo’s pattern
Overloads
FormatNumber.FormatCompactCurrency(value, currency_symbol, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCompactCurrency(value, currency_symbol, nfi)
FormatNumber.FormatCompactCurrency(value, nfi, ignore_minimum_grouping_digits)
FormatNumber.FormatCompactCurrency(value, nfi)
FormatNumber.FormatCompactCurrency(nfi)

Parameters

  • value – The number to format
  • currency_symbol – The currency symbol that the pattern substitutes to.
  • nfi – The NumberFormatInfo
  • ignore_minimum_grouping_digits – Ignores the NumberFormatInfo’s MinimumGroupingDigits, so it’ll group numbers like 1000 regardless of MinimumGroupingDigits value
FormatNumber.ParseFloat

Converts formatted string to lua’s number type.

Overloads
FormatNumber.ParseFloat(str, nfi, strict)
FormatNumber.ParseFloat(str, nfi)
FormatNumber.ParseFloat(str)

Parameters

  • str – The string to parse
  • nfi – The NumberFormatInfo
  • strict

FormatNumber.NumberFormatInfo

FormatNumber.NumberFormatInfo.new

Create a new NumberFormat, with the properties as its argument e.g. FormatNumber.NumberFormat.new{ DecimalSymbol = '.' } creates a new NumberFormatInfo where the DecimalSymbol are .
The default are

{
	DecimalSymbol = ',';
	GroupingSymbol = ' ';
	NaNSymbol = 'NaN';
	NegativeSign = '-';
	PositiveSign = '+';
	InfinitySymbol = '∞';
	PercentSymbol = '%';
	PerMilleSymbol = '‰';
	ExponentSymbol = 'E';
	ListSymbol = ';';
	
	DecimalFormat = "#.###,###";
	CurrencyFormat = "#.###,00 ¤";
	ExponentFormat = "#E0";
	PercentFormat = "#.### %";
	
	DecimalCompact = { '0', '0', '0', '0', '0', '0', '0,# M', '00 M', '000 M', '0,# G', '00 G', '000 G', '0,# T', '00 T', '000 T' };
	CurrencyCompact = { '0', '0', '0', '0', '0', '0', '0,# M ¤', '00 M ¤', '000 M ¤', '0,# G ¤', '00 G ¤', '000 G ¤', '0,# T ¤', '00 T ¤', '000 T ¤' };
	
	NativeDigits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
	MinimumGroupingDigits = 1;

	ReadOnly = false;
}
FormatNumber.NumberFormatInfo:Clone

Clones a non read-only version of NumberFormatInfo
Overloads
NumberFormatInfo:Clone()
FormatNumber.NumberFormatInfo.Clone(self)

FormatNumber.NumberFormatInfo.Preset

A table of already done NumberFormatInfo in the following langauges:

  • English (en)
  • French (fr)
  • Spanish (es)
  • Latin American Spanish (es_MX (1.0.0); es_419 (≥1.1.0))
  • German (de)
  • Japanese (ja)
  • Portuguese (pt) (≥1.1.0)
  • Portugal Portuguese (pt_PT) (≥1.1.0)
  • Russian (ru) (≥1.1.0)
  • Simplified Chinese (zh) (≥1.1.0)
Properties
  • DecimalSymbol – Gets the decimal separator.

  • GroupingSymbol – Gets the digit grouping separator.

  • NaNSymbol – Gets the NaN symbol. (Not substituted in 1.0.0)

  • NegativeSign – Gets the negative sign symbol.

  • PositiveSign – Gets the positive sign symbol.

  • InfinitySymbol – Gets the infinity symbol. (Not substituted in 1.0.0)

  • PercentSymbol – Gets the percent symbol.

  • PerMilleSymbol – Gets the per-mille symbol.

  • ExponentSymbol – Gets the exponent symbol.

  • ListSymbol – Gets the list separator. (The term list came from C# TextInfo.ListSeparator)

  • DecimalFormat – FormatNumber.FormatDecimal pattern

  • CurrencyFormat – FormatNumber.FormatCurrencyl pattern

  • ExponentFormat – FormatNumber.FormatExponentl pattern

  • PercentFormat – FormatNumber.FormatPercent pattern

  • DecimalCompact – FormatNumber.FormatCompactDecimal pattern

  • CurrencyCompact – FormatNumber.FormatCompactCurrency pattern

  • MinimumGroupingDigits – Don’t group when a number is below certain value. This is intended for languages such as Polish and Spanish where one would only group on values over 9999. Example:

Minimum­GroupingDigits Pattern Value Formatted
1 #.##0 1 000 1,000
1 #.##0 10000 10,000
2 #.##0 1000 1000
2 #.##0 10000 10,000
  • ReadOnly – Gets the boolean if NumberFormatInfo can be modified, returns true if it can’t and returns false if it can

Patterns

For FormatNumber.FormatCustom and FormatNumber.FormatCompactCustom pattern parameter.
If you just want a decimal number without digit grouping or want to have a custom negative-number format then this is useful. This is bascially the CLDR number format pattern except , for decimal and . for digit grouping.

Format specifier Name Description Examples
0 Zero placeholder Replaces digits with 0 if there aren’t enough digits 1234 (“00000”) → 01234; 24.5 (“000,00”) → 024.50
# Digit placeholder Remove the digit if it’s a non-significant 0 0.5 ("#,#") → .5; 200.00 ("#,##") → 200
, Decimal separator The location of the decimal 0.1234 (“0,0000”) → 0.1234
. Grouping separator The location of grouping and how big the grouping digit is 1234567 ("#.##0") → 1,234,567; 123456 ("##.##.##0") → 1,23,456
% Percentage placeholder Substitutes into NumberFormatInfo’s PercentSymbol note that value will be multiplied by 100 if this is included 0.666666 ("#.##0,##%") → 66.67%
‰ (U+2030) PerMille placeholder Substitutes into NumberFormatInfo’s PerMilleSymbol note that value will be multiplied by 1000 if this is included 0.666666 ("#.##0,##%") → 666.67‰
E Exponent placeholder Substitutes into NumberFormatInfo’s ExponenetSymbol note that value will be converted to exponents if this is included
¤ (U+00A4) Currency placeholder Substitutes into `the currency symbol if there isn’t one, this will be ignored
Literal string delimiter Used to get literal characters of 0, #, ,, ., %, , ¤, E and ;, '' for literal ' 1234 ("#’,’") → 1234,
; Section separator Separate positive and negative pattern sections -1234 ("#;(#)") → (1234)

FormatCompactCustom pattern

It’s bascially the same but provided in a table, the FormatCompactCustom will get the index (the length of the value) of the table e.g. if the value is 1000 it’ll the get 4th index of { '0', '0', '0', '0K' } as the value have 4 digits, if the number of digits gets over the length, it’ll just get the last index of the table.
The length of the shortened number will depend on how many 0s are there in the format. e.g. if the format is 00K and the value is 12345, it’ll return 12K, but if the format is 0K and the value is 12345 it’ll return 1K instead.

But why?

Why not do something like { 'K', 'M', 'B', 'T' }?
Some countries don’t even have the same system, in East Asian cultures, they don’t abbreviate 1,000,000 as 1 but as 100萬 so doing { "萬" ,"億", "兆" } assumes you abbreivate numbers in 3s thus 1000 would be wrongly return as 1萬. If that wasn’t enough, some languages like German don’t even abbreivate numbers until one million, and some languages like Indian English, don’t even have the same number size e.g. 1,234,567 is 12L and 12,345,678 is 1Cr but 1,234,567,890 is 123Cr, and some langauges like Spanish, abbreivate 1,000,000,000 as 1000 M but 10,000,000,000 as 10 MRD. thus I prefer to doing it by { '0', '0', '0', '0K', '00K', '000K', '0M', '00M', etc. }.

Why NumberFormatInfo?

Why can’t you just format 1000 to 1,000 and million to 1M, so I don’t have do NumberFormatInfo.new?
Not everyone writes numbers in the same way, here’s the table of number formats used in certain places.

Number format Used in Notes
1,234,567.89 U.S., English-speaking Canada, Latin America, UK, China, Korea and Japan
1.234.567,89 Spain, Portugal, Majority of South America, Germany, Indonesia, France at one point
1 234 567,89 Frence, French-speaking Canada, Russia Thin spaces! (U+2009)
12,34,567.89 India in some cases Grouped by 3, 2, 2, 3, 2, 2 etc.
1’234’567.89 Switzerland and Liechtenstein for computing
1_234_567.89 Syntax of numbers in Python and Lua
1,234,567·89 United Kingdom at one point
1.234.567’89 Spain(?) Yes a ' for decimal.
1,234,567 89 Literally nowhere
١٬٢٣٤٬٥٦٧٫٨٩ Majority of the Arab world Not on the same number system

So I added NumberFormatInfo so some don’t misinterpret 1,250 as 1 point 250 and to make sure it’ll format the number correctly.

In some of my functions, inserting NumberFormatInfo is optional, and if you didn’t insert it, it’ll just format using the default properties of NumberFormatInfo, which indicates spaces as a digit grouping symbol and comma as decimal to reduce ambiguity.

In future updates I might (but not likely) even add Eastern Arabic Numerals and Thai Numerials

Changelog

Version 1.1.0

17 April 2020, 12:06:52

  • Fixed the FormatCompactNumber issue, now “0,0” will return correctly
  • Now substitutes NaN (e.g. 0/0) and Infinity, both positive and negative (e.g. math.huge)

Edit 1 on 2020-04-16T23:20:46Z: Updated my documentation
Edit 2 on 2020-04-17T12:06:19Z

4 Likes

How would I use FormatCompact to accomplish this:

12,340 -> 12.3k
1,639 -> 1.6k
103,304 -> 103.3k

Also, is there a way to disable rounding?

1 Like

For the first, try maximumFractionDigits = 1 to always have 1 fractional digits (expect when it ends with 0), the default behaviour is round to nearest integer, keep 2 significant digits, no trailing zeroes.

Also what do you mean by disabling rounding? Is rounding = "floor" what you’re looking for (e.g. 1.9 rounds down to 1)? or maybe the “unnecessary” rounding mode which errors when it can’t be represented exactly without rounding (which apologies this module don’t support)?

2 Likes

I still haven’t thought much about the rounding, I might change the default precision (doesn’t affect International, only this module) for the compact notation. For this module, the compact notation has few options you can pick:

  • Nearest integer but keep 2 significant digits (currently the default): 1.2 12 123 1234 12,345 123,456 1,234,567
  • Round to the nearest 1 fractional digits: 1.2 12.3 123.4 1234.5 12,345.6 123,456.7 1,234,567.8
  • Maximum of 3 significant digits: 1.23 12.3 123 1230 12,300 123,000 1,230,000

Which one do you prefer? (Some not available for now)

  • 1.2 12 123 1234 12,345 123,456 1,234,567
  • 1.2 12.3 123.4 1234.5 12,345.6 123,456.7 1,234,567.8
  • 1.23 12.3 123 1230 12,300 123,000 1,230,000
  • 1 12 123 1234 12,345 123,456 1,234,567
  • 1.23 12.34 123.45 1234.56 12,345.67 123,456.78 1,234,567.89
  • 1.23 12.3 123 1234 12,345 123,456 1,234,567

0 voters

And I might change the default rounding for compact notation for this module.
Which rounding for compact notation do you prefer?

  • Half even - 1.9 → 2, 2.5 → 2 (default)
  • Down - 1.9 → 1, 2.5 → 2

0 voters

As for grouping, the default for compact notation will stay as "min2".

Edit 14.08.2020: another decision I’m unsure
Should I merge FormatStandard and FormatCompact into a “notation” option (like International does)

  • Merge them into the notation option
  • Keep it the way it is

0 voters

And here’s stuff from International I might add to this module. Which one should I add?

  • Range formatting
  • Formatting to parts
  • Unit formatting
  • Number formatter class
  • Long compact notation
  • Scientific/engineering notation

0 voters

Feel free to post more suggestions

1 Like

Update 2.1

  • Added FormatStandardRange and FormatCompactRange. But it’s still experemental (in both International and this module), I haven’t used the NumberRangeFormatter from Unicode ICU and I know hardly anything about it yet :frowning:
  • Fixed the rounding bug, now “floor” and “ceiling” has been replaced by “down” and “up” (the term was misleading as “floor” rounds up for negative numbers but down for positive numbers) and 999,999 will no longer return 1000K for halfUp/halfEven/up/halfDown rounding.
  • Rounding now defaults to down for FormatCompact.

Next update, I might be adding:

  • ECMA 402 “styled” FormatToParts
  • Scientific/engineering notation
  • Different (non-algoirthmic) numbering system option

I won’t be adding these because these require plural rules and I’m not adding plural rules, if you want these, you can use International:

  • Unit formatting
  • Long compact notation (you can slightly achieve this via compactPattern option)/pluralised compact notation

And I won’t be adding a NumberFormatter class because I can’t find a use for this for non-i18n situation. (NumberFormatter class was added in International so it doesn’t have to go through the CLDR tree every time it formats the number)

You can get it here: FormatNumber.rbxm (7.8 KB)

4 Likes

Bit late, but what are the permissions of use?
@Blockzez

1 Like

It’s under BSD 2-clause licence, you can pretty much do whatever you want with it as long you include the copyright notice in it.

1 Like

Thank you.
I’ll see if I can use this in my game

1 Like