Modul:FormatNum

--[[
 Main table - used by invoke.
	Exported functions: numberToString, formatNumber
]]
local formatNum = {};
--[[
	Constant for round method "round half to even" (IEEE 754).
]]
local ROUND_TO_EVEN = 0;
--[[ 
	Constant for round method "round half away from zero"
	(German: "kaufmaennisches Runden"), also filters "-0" and
	converts it to "0".
]]
local ROUND_AWAY_FROM_ZERO = 1;
--[[
	Table storing the format options.
]]
local FORMAT_TABLE = {};
--[[
	Format table for "de".
]]
FORMAT_TABLE.de = {};
FORMAT_TABLE.de.decimalMark = ",";
FORMAT_TABLE.de.groupMark = " ";
FORMAT_TABLE.de.groupMinLength = 5;
FORMAT_TABLE.de.groupOnlyIntegerPart = false;
--[[
	Format table for "de_currency".
]]
FORMAT_TABLE.de_currency = {};
FORMAT_TABLE.de_currency.decimalMark = ",";
FORMAT_TABLE.de_currency.groupMark = ".";
FORMAT_TABLE.de_currency.groupMinLength = 5;
FORMAT_TABLE.de_currency.groupOnlyIntegerPart = true;
--[[
	Format table for "ch".
]]
FORMAT_TABLE.ch = {};
FORMAT_TABLE.ch.decimalMark = ",";
FORMAT_TABLE.ch.groupMark = "'";
FORMAT_TABLE.ch.groupMinLength = 5;
FORMAT_TABLE.ch.groupOnlyIntegerPart = true;
--[[
	Format table for "en".
]]
FORMAT_TABLE.en = {};
FORMAT_TABLE.en.decimalMark = ".";
FORMAT_TABLE.en.groupMark = ",";
FORMAT_TABLE.en.groupMinLength = 4;
FORMAT_TABLE.en.groupOnlyIntegerPart = true;
--[[
	Format table for "iso31_0" (ISO 31-0 using comma as decimal mark).
]]
FORMAT_TABLE.iso31_0 = {};
FORMAT_TABLE.iso31_0.decimalMark = ",";
FORMAT_TABLE.iso31_0.groupMark = " ";
FORMAT_TABLE.iso31_0.groupMinLength = 4;
FORMAT_TABLE.iso31_0.groupOnlyIntegerPart = false;
--[[
	Format table for "iso31_0_point" (ISO 31-0 using point as decimal mark).
]]
FORMAT_TABLE.iso31_0_point = {};
FORMAT_TABLE.iso31_0_point.decimalMark = ".";
FORMAT_TABLE.iso31_0_point.groupMark = " ";
FORMAT_TABLE.iso31_0_point.groupMinLength = 4;
FORMAT_TABLE.iso31_0_point.groupOnlyIntegerPart = false;
--[[
	Format table for "pc" (simply nil to prevent formatting).
]]
FORMAT_TABLE.pc = nil;
--[[
 Format table for "comma" (no grouping - groupMark "").
]]
FORMAT_TABLE.comma = {};
FORMAT_TABLE.comma.decimalMark = ",";
FORMAT_TABLE.comma.groupMark = "";
FORMAT_TABLE.comma.groupMinLength = 1000; -- (for performance, but also small values wouldn't matter)
FORMAT_TABLE.comma.groupOnlyIntegerPart = true;
--[[
	Format table for "at" (only for convenience, same as "iso31_0").
]]
FORMAT_TABLE.at = FORMAT_TABLE.iso31_0;
--[[
	Format table for "ch_currency" (only for convenience, same as "de_currency").
]]
FORMAT_TABLE.ch_currency = FORMAT_TABLE.de_currency;
--[[
 Format table for "dewiki" (only for convenience, same as "de_currency").
]]
FORMAT_TABLE.dewiki = FORMAT_TABLE.de_currency;
--[[
	Constant defining digit group lenghts when digit grouping is used.
]]
local DIGIT_GROUPING_SIZE = 3;
--[[
	Internal used function for rounding.
	
	@param a_number : Number to be rounded.
	@param a_precision : Number of significant digits of the fractional part. If it
		is negative, the according number of digits of the integer part are also
		rounded.
	@param a_roundMethod : Numeric constant defining the round method to use.
		Supported are ROUND_TO_EVEN and ROUND_AWAY_FROM_ZERO.
	
	@return String of the rounded number like returned by Lua function string.format().
]]
local function internalNumberToString(a_number, a_precision, a_roundMethod)
	if (a_precision < 0) then
		a_precision = -a_precision;
		if (a_roundMethod == ROUND_TO_EVEN) then
			local integerPart = math.floor(math.abs(a_number) / (10 ^ a_precision));
			if (integerPart % 2 == 0) then
				-- next even number smaller than a_number / 10^precision
				a_number = a_number - 5 * (10 ^ (a_precision - 1));
				a_number = math.ceil(a_number / (10 ^ a_precision)) * (10 ^ a_precision);
			else
				-- next even number bigger than a_number / 10^precision
				a_number = a_number + 5 * (10 ^ (a_precision - 1));
				a_number = math.floor(a_number / (10 ^ a_precision)) * (10 ^ a_precision);
			end
		elseif (a_roundMethod == ROUND_AWAY_FROM_ZERO) then
			if (a_number >= 0) then
				a_number = a_number + 5 * (10 ^ (a_precision - 1));
				a_number = math.floor(a_number / (10 ^ a_precision)) * (10 ^ a_precision);
			else
				a_number = a_number - 5 * (10 ^ (a_precision - 1));
				a_number = math.ceil(a_number / (10 ^ a_precision)) * (10 ^ a_precision);
			end
		end
		-- handle it as normal integer
		a_precision = 0;
	end
	if (a_roundMethod == ROUND_AWAY_FROM_ZERO) then
		if ((a_number * (10 ^ a_precision)) - math.floor(a_number * (10 ^ a_precision)) == 0.5) then
			-- because string.format() uses round to even, we have to add (numbers >0) or
			-- subtract (numbers <0) a little bit to point into the "correct" rounding
			-- direction if a_number is exactly in the middle between two rounded numbers
			if (a_number >= 0) then
				a_number = a_number + (10 ^ -(a_precision + 1));
			else
				a_number = a_number - (10 ^ -(a_precision + 1));
			end
		else
			if (math.abs(a_number * (10 ^ a_precision)) < 0.5) then
				-- filter "-0" and convert it to 0
				a_number = math.abs(a_number);
			end
		end
	end
	return string.format("%." .. tostring(a_precision) .. "f", a_number);
end
--[[
	Internal used function for formatting.
	
	@param a_number : String of a non-negative number to be formatted.
	@param a_decimalMark : String of the decimal mark to use.
	@param a_groupMark : String of the mark used for digit grouping.
	@param a_groupMinLength : Number defining the minimum length of integer part
		to use digit grouping (normally 4 or 5). However if fractional part is
		longer than DIGIT_GROUPING_SIZE (3 as default) and digit grouping of
		fractional part is not disabled via 'a_groupOnlyIntegerPart', then this
		value is ignored and set to DIGIT_GROUPING_SIZE + 1.
	@param a_groupOnlyIntegerPart : Boolean defining whether activating digit
		grouping only for integer part (true) or for integer and fractional part
		(false).
	@return String of the formatted number according to the parameters.
]]
local function internalFormatNumber(a_number, a_decimalMark, a_groupMark, a_groupMinLength, a_groupOnlyIntegerPart)
	-- find the decimal point
	local decimalPosition = mw.ustring.find(a_number, ".", 1, true);
	local needsGrouping = false;
	if (not decimalPosition) then
		-- no decimal point - integer number
		decimalPosition = mw.ustring.len(a_number) + 1;
		if (decimalPosition > a_groupMinLength) then
			needsGrouping = true;
		end
	else
		-- decimal point present
		if ((decimalPosition > a_groupMinLength) or (((mw.ustring.len(a_number) - decimalPosition) > DIGIT_GROUPING_SIZE) and (not a_groupOnlyIntegerPart))) then
			needsGrouping = true;
		end
		-- replace the decimal point
		a_number = mw.ustring.sub(a_number, 1, decimalPosition - 1) .. a_decimalMark .. mw.ustring.sub(a_number, decimalPosition + 1);
	end
	if (needsGrouping and (decimalPosition > DIGIT_GROUPING_SIZE + 1)) then
		-- grouping of integer part necessary
		local i = decimalPosition - DIGIT_GROUPING_SIZE;
		while (i > 1) do
			-- group the integer part
			a_number = mw.ustring.sub(a_number, 1, i - 1) .. a_groupMark .. mw.ustring.sub(a_number, i);
			decimalPosition = decimalPosition + mw.ustring.len(a_groupMark);
			i = i - DIGIT_GROUPING_SIZE;
		end
	end
	-- skip to the end of the new decimal mark (in case it is more than one char)
	decimalPosition = decimalPosition + mw.ustring.len(a_decimalMark) - 1;
	if (a_groupOnlyIntegerPart) then
		needsGrouping = false;
	end
	if (needsGrouping and ((mw.ustring.len(a_number) - decimalPosition) > DIGIT_GROUPING_SIZE)) then
		-- grouping of fractional part necessary
		-- using negative numbers (index from the end of the string)
		local i = decimalPosition - mw.ustring.len(a_number) + DIGIT_GROUPING_SIZE;
		while (i <= -1) do
			-- group the fractional part
			a_number = mw.ustring.sub(a_number, 1, i - 1) .. a_groupMark .. mw.ustring.sub(a_number, i);
			i = i + DIGIT_GROUPING_SIZE;
		end
	end
	return a_number;
end
--[[
	Exported function for rounding numbers.
	
	@param a_frame : Frame holding the arguments from the invoke call.
	
	@argument number : String representation of an unformatted floating point
		or integer number.
	@argument precision : String representation of the number of significant
		fractional part digits. If it is negative, the integer part is rounded
		as well.
	@argument method : String representation of the number defining the
		rounding method to use. Currently only "0" for 'IEEE 754' rounding and
		"1" for 'round half away from zero' are supported. If another number is
		supplied, the result is undefined.
	@return String of the rounded number like returned by Lua function
		string.format(). If one of the arguments is not a number, the argument
		'number' is returned unmodified.
]]
function formatNum.numberToString(a_frame)
	local number = tonumber(a_frame.args["number"]);
	local precision = tonumber(a_frame.args["precision"]);
	local method = tonumber(a_frame.args["method"]);
	if (number and precision and method) then
		return internalNumberToString(number, math.floor(precision), math.floor(method));
	end
	return a_frame.args["number"];
end
--[[
	Exported function for formatting numbers.
	
	@param a_frame : Frame holding the arguments from the invoke call.
	
	@argument number : String representation of an unformatted (but maybe rounded)
		floating point or integer number.
	@argument format : String defining the formatting options. Currently there are
		"at", "comma", "de", "dewiki", "de_currency", "ch", "ch_currency", "en", "iso31_0",
		"iso31_0_point" and "pc" supported. See the FORMAT_TABLE for details.
	@return String of the formatted number. If the argument 'format' is invalid or
		'number' is not a valid string representation of a number, the argument
		'number' is returned unmodified.
]]
function formatNum.formatNumber(a_frame)
	local number = a_frame.args["number"];
	local format = a_frame.args["format"];
	if (number and format) then
		format = FORMAT_TABLE[format];
		if (tonumber(number) and format) then
			-- lua can parse the number (first check passed) and format entry found
			local sign = mw.ustring.sub(number, 1, 1);
			if ((sign == "+") or (sign == "-")) then
				-- remove sign from number, add it later again
				number = mw.ustring.sub(number, 2);
			else
				-- was not a sign
				sign = "";
			end
			if (mw.ustring.sub(number, 1, 1) == ".") then
				-- number begins with "." -> add a 0 to the beginning
				number = "0" .. number;
			else
				if (mw.ustring.sub(number, -1) == ".") then
					-- number ends with "." -> remove it
					number = mw.ustring.sub(number, 1, -2);
				end
			end
			if ((number == mw.ustring.match(number, "^%d+$")) or (number == mw.ustring.match(number, "^%d+%.%d+$"))) then
				-- number has valid format (only digits or digits.digits) -> format it and add sign (if any) again
				number = sign .. internalFormatNumber(number, format.decimalMark, format.groupMark, format.groupMinLength, format.groupOnlyIntegerPart);
			else
				-- number has no valid format -> undo all modifications
				number = a_frame.args["number"];
			end
		end
	end	 
	return number;
end
return formatNum;