--[[ Apply optional formatting and translation to a number.
For example, an infobox may accept numbers in international or
local digits, but processing may require international digits.
The result may be wanted in international or local digits, and
may be formatted by grouping digits.
]]
local MINUS = '−' -- Unicode U+2212 MINUS SIGN (UTF-8: e2 88 92)
local ustring = mw.ustring
local unknown_error = 'अज्ञात त्रुटि'
local mtext_hi = {
-- Module text: input parameter names/values, and output messages.
-- The name on the left of "=" is the name used in this module.
-- The text in single quotes is input used in the template, or
-- is output text displayed by the result.
parm_type = 'प्रकार', -- parameter to specify wanted type of output
type_int = 'इंग्रजी', -- ...output in international digits
type_dev = 'नागरी', -- ...output in devanagari digits
parm_group = 'स्वरूपण', -- parameter to specify wanted number grouping in output
grp_int = 'आंतरराष्ट्रीय', -- ...three-digit groups
grp_dev = 'भारतीय', -- ...three-then-two-digit groups
parm_numdot = 'decimal', -- parameter to specify decimal mark (single byte; default dot)
parm_numsep = 'sep', -- parameter to specify group separator (single byte; default comma)
-- Output messages ("%s" is replaced with the invalid input text).
invalid_number = 'त्रुटि: "%s" अयोग्य अंक आहे',
invalid_parm = 'त्रुटि: प्राचलामध्ये दिलेली माहिती "%s" अमान्य आहे',
missing_parm = 'त्रुटि: कृपया संख्या प्रदान करा',
}
local mtext_en = {
-- English text for testing.
parm_type = 'type',
type_int = 'int',
type_dev = 'dev',
parm_group = 'format',
grp_int = 'int',
grp_dev = 'dev',
parm_numdot = 'decimal',
parm_numsep = 'sep',
invalid_number = 'Value "%s" is not a valid number',
invalid_parm = 'Parameter "%s" has invalid value "%s"',
missing_parm = 'Need value',
}
local from_international_table = {
['0'] = '०',
['1'] = '१',
['2'] = '२',
['3'] = '३',
['4'] = '४',
['5'] = '५',
['6'] = '६',
['7'] = '७',
['8'] = '८',
['9'] = '९',
}
local to_international_table = {
['०'] = '0',
['१'] = '1',
['२'] = '2',
['३'] = '3',
['४'] = '4',
['५'] = '5',
['६'] = '6',
['७'] = '7',
['८'] = '8',
['९'] = '9',
}
local function collection()
-- Return a table to hold items.
return {
n = 0,
add = function (self, item)
self.n = self.n + 1
self[self.n] = item
end,
}
end
local function empty(text)
-- Return true if text is nil or empty (assuming a string).
return text == nil or text == ''
end
local function strip(text)
-- If text is a string, return its content with no leading/trailing
-- whitespace. Otherwise return nil (a nil argument gives a nil result).
if type(text) == 'string' then
return text:match("^%s*(.-)%s*$")
end
end
local function strip_to_nil(text)
-- Return stripped text or nil if empty.
if text ~= nil then
text = strip(text)
if text == '' then
text = nil
end
end
return text
end
local function from_international(parms, text)
-- Input is a string representing a number in en digits with '.' decimal mark,
-- without digit grouping (which is done just after calling this).
-- Return the string with numdot and, if wanted, after translating
-- each digit to the local language.
if parms.numdot ~= '.' then
text = text:gsub('%.', parms.numdot)
end
if parms.otype ~= 'int' then
text = text:gsub('%d', from_international_table)
end
return text
end
local function to_international(parms, text)
-- Input is a string representing a number in the local language with
-- optional numdot decimal mark and numsep digit grouping.
-- The input may also use international digits (which are not changed).
-- Return the translation of the string with '.' mark and en digits,
-- and no separators (they have to be removed here to handle cases like
-- numsep = '.' and numdot = ',' with input "1.234.567,8").
if parms.numsep ~= '' then
text = text:gsub('[' .. parms.numsep .. ']', '') -- use '[x]' in case x is '.'
end
if parms.numdot ~= '.' then
text = text:gsub('[' .. parms.numdot .. ']', '.')
end
text = ustring.gsub(text, '%d', to_international_table)
return text
end
local function extract_groups(parms, digits)
-- Return digits split into groups and translated, if wanted.
-- Parameter digits is 0..9 only (no sign, no decimal point, no exponent).
-- Each digit must be a byte because am not using mw.ustring functions.
local length = #digits
local places = collection()
local pos, step = 0, 3
while pos < length do
places:add(pos)
pos = pos + step
if parms.grouping == 'dev' then
step = 2
end
end
places:add(length)
local groups = collection()
for i = places.n, 2, -1 do
local p1 = length - places[i] + 1
local p2 = length - places[i - 1]
groups:add(from_international(parms, digits:sub(p1, p2)))
end
return groups
end
local function with_separator(parms, text)
-- Return text with group separators inserted, if wanted.
-- Input uses international digits.
-- Output optionally uses digits in local language.
-- The given text is like '123' or '12345.6789' or '1.23e45'.
-- The text has no sign (caller inserts that later, if necessary).
-- Separator is inserted only in the integer part of the significand
-- (not after numdot, and not after 'e' or 'E').
if parms.grouping == nil or parms.numsep == '' then
return from_international(parms, text)
end
local last = text:match('()[.eE]') -- () returns position
if last == nil then
last = #text
else
last = last - 1 -- index of last character before dot/e/E
end
if last < 4 or (last == 4 and parms.opt_comma5) then
return from_international(parms, text)
end
local groups = extract_groups(parms, text:sub(1, last))
return table.concat(groups, parms.numsep) .. from_international(parms, text:sub(last+1))
end
local function get_parms(args)
-- Return a table of parameter names and values, using terms known in this module.
-- Input numstr is converted to international digits with no formatting.
-- Input padlen is converted to a number (or nil if no padding).
-- Throw an error if input is invalid.
local parms = {}
local mtext = (args.lang == 'en') and mtext_en or mtext_hi
local function die(code, parm1, parm2)
error(string.format(mtext[code] or unknown_error, parm1, parm2), 0)
end
local numstr = strip_to_nil(args[1])
if not numstr then
die('missing_parm')
end
local otype = strip_to_nil(args[mtext.parm_type])
if otype then
if otype == mtext.type_int then
otype = 'int'
elseif otype == mtext.type_dev then
otype = 'dev'
else
die('invalid_parm', mtext.parm_type, otype)
end
else
-- Output type is opposite of input type.
-- Assume finding any type_int digit means input is type_int.
otype = numstr:find('%d') and 'dev' or 'int'
end
parms.otype = otype
local grp = strip_to_nil(args[mtext.parm_group])
if grp then
if grp == mtext.grp_int then
grp = 'int'
elseif grp == mtext.grp_dev then
grp = 'dev'
else
die('invalid_parm', mtext.parm_group, grp)
end
end
parms.grouping = grp -- nil is no grouping
local dot = strip_to_nil(args[mtext.parm_numdot])
if dot then
if not (dot == '.' or dot == ',') then
die('invalid_parm', mtext.parm_numdot, dot)
end
else
dot = '.'
end
parms.numdot = dot
local sep = strip_to_nil(args[mtext.parm_numsep])
if sep then
if not (sep == '.' or sep == ',') then
die('invalid_parm', mtext.parm_numsep, sep)
end
else
sep = ','
end
parms.numsep = sep
-- Clean input number and remove any sign.
local clean = to_international(parms, numstr)
local sign
for _, prefix in ipairs({ '+', '-', MINUS }) do
local plen = #prefix
if clean:sub(1, plen) == prefix then
if sign then -- more than one sign
die('invalid_number', numstr)
end
sign = (prefix == '+') and '+' or MINUS
clean = strip(clean:sub(plen + 1))
end
end
parms.sign = sign or ''
if tonumber(clean) then
-- Number is valid, but omit any trailing '.' as redundant.
if clean:sub(-1) == '.' then
clean = clean:sub(1, -2)
end
parms.numstr = clean
else
die('invalid_number', numstr)
end
local padlen = strip_to_nil(args[2])
if padlen then
local value = tonumber(to_international(parms, padlen))
if value then
parms.padlen = value
else
die('invalid_number', padlen)
end
end
return parms
end
local function do_number(args)
-- Return the processed input number, or throw an error if invalid.
-- Padding applies to the total number of digits, and does not count any
-- group separators or decimal mark. For example, if a number would give
-- "1,234,567.890" without padding, the result with padlen = 15 would
-- have 5 extra zeros, giving "000,001,234,567.890".
local parms = get_parms(args)
local numstr = parms.numstr
local padlen = parms.padlen
if padlen then
local reslen = #numstr
if numstr:find('.', 1, true) then
reslen = reslen - 1 -- do not count decimal mark
end
if padlen > reslen then
if padlen > 100 then -- silently limit to something reasonable
padlen = 100
end
numstr = string.rep('0', padlen - reslen) .. numstr
end
end
return parms.sign .. with_separator(parms, numstr)
end
local function number(frame)
local success, result = pcall(do_number, frame.args)
if success then
return result
end
return '<strong class="error">' .. result .. '</strong>'
end
return { number = number }