본문으로 이동

모듈:ConvertNumeric

위키낱말사전, 말과 글의 누리

이 모듈에 대한 설명문서는 모듈:ConvertNumeric/설명문서에서 만들 수 있습니다

local export = {}  -- functions that can be called from another module

-- Module for converting between different representations of numbers.
-- For unit tests see: [[Module:ConvertNumeric/testcases]]
-- When editing, preview with: Module:ConvertNumeric/testcases/documentation


export.sino_ones_position = {
	[0] = '영',
	[1] = '일',
	[2] = '이',
	[3] = '삼',
	[4] = '사',
	[5] = '오',
	[6] = '육',
	[7] = '칠',
	[8] = '팔',
	[9] = '구',
	[10] = '십',
	[11] = '십일',
	[12] = '십이',
	[13] = '십삼',
	[14] = '십사',
	[15] = '십오',
	[16] = '십육',
	[17] = '십칠',
	[18] = '십팔',
	[19] = '십구'
}

export.sino_tens_position = {
	[2] = '이십',
	[3] = '삼십',
	[4] = '사십',
	[5] = '오십',
	[6] = '육십',
	[7] = '칠십',
	[8] = '팔십',
	[9] = '구십'
}

export.native_ones_position = {
	[1] = '하나',
	[2] = '둘',
	[3] = '셋',
	[4] = '넷',
	[5] = '다섯',
	[6] = '여섯',
	[7] = '일곱',
	[8] = '여덟',
	[9] = '아홉'
}

export.native_ones_modifier = {
	[1] = '한',
	[2] = '두',
	[3] = '세',
	[4] = '네',
	[20] = '스무'
}

export.native_tens_position = {
	[1] = '열',
	[2] = '스물',
	[3] = '서른',
	[4] = '마흔',
	[5] = '쉰',
	[6] = '예순',
	[7] = '일흔',
	[8] = '여든',
	[9] = '아흔'
}

export.groups = {
	[1] = '만',
	[2] = '억',
	[3] = '조',
	[4] = '경',
	[5] = '해',
	[6] = '자',
	[7] = '양',
	[8] = '구',
	[9] = '간',
	[10] = '정',
	[11] = '재',
	[12] = '극',
	[13] = '항하사',
	[14] = '아승기',
	[15] = '나유타',
	[16] = '불가사의',
	[17] = '무량대수',
	[18] = '겁',
	[19] = '업'
}

export.roman_numerals = {
	I = 1,
	V = 5,
	X = 10,
	L = 50,
	C = 100,
	D = 500,
	M = 1000
}

-- 로마 숫자를 변환하는 함수
function export.roman_to_numeral(roman)
	if type(roman) ~= "string" then return -1, "roman numeral not a string" end
	local rev = roman:reverse()
	local raising = true
	local last = 0
	local result = 0
	for i = 1, #rev do
		local c = rev:sub(i, i)
		local next = export.roman_numerals[c]
		if next == nil then return -1, "roman numeral contains illegal character " .. c end
		if next > last then
			result = result + next
			raising = true
		elseif next < last then
			result = result - next
			raising = false
		elseif raising then
			result = result + next
		else
			result = result - next
		end
		last = next
	end
	return result
end

-- 100 미만의 숫자를 변환하는 함수
local function numeral_to_korean_less_100(num, num_system)
	if num_system == 'native' then
		if num == 0 then return "" end
		local ten = math.floor(num / 10)
		local one = num % 10
		local s = ""
		if ten > 0 then s = s .. export.native_tens_position[ten] end
		if one > 0 then s = s .. export.native_ones_position[one] end
		return s
	else -- sino
		if num < 20 then return export.sino_ones_position[num]
		elseif num % 10 == 0 then return export.sino_tens_position[num / 10]
		else return export.sino_tens_position[math.floor(num / 10)] .. export.sino_ones_position[num % 10]
		end
	end
end

-- 10000 미만의 숫자를 한국어로 변환하는 함수
local function numeral_to_korean_less_10000(num_str, num_system, force_one)
	if num_system == 'native' then -- 고유어는 99까지만 지원
		local num = tonumber(num_str)
		if num >= 100 then error("고유어 숫자 체계는 99까지만 지원합니다.") end
		return numeral_to_korean_less_100(num, 'native')
	end

	-- 한자어 처리
	local num = tonumber(num_str)
	if num == 0 then return "" end

	local s = ""
	local units = {"", "십", "백", "천"}
	local num_rev = num_str:reverse()

	for i = #num_rev, 1, -1 do
		local digit = tonumber(num_rev:sub(i, i))
		if digit > 0 then
			-- '1'은 십, 백, 천 단위에서 기본적으로 생략 (force_one이 true가 아닌 경우)
			if digit > 1 or (i == 1 and digit == 1) or force_one then
				s = s .. export.sino_ones_position[digit]
			end
			s = s .. units[i]
		end
	end
	return s
end

-- 과학적 표기법을 10진수로 변환하는 함수
local function scientific_notation_to_decimal(num)
	local exponent, subs = num:gsub("^%-?%d*%.?%d*%-?[Ee]([+%-]?%d+)$", "%1")
	if subs == 0 then return num end  -- Input not in scientific notation, just return unmodified
	exponent = tonumber(exponent)

	local negative = num:find("^%-")
	local _, decimal_pos = num:find("%.")
	-- Mantissa will consist of all decimal digits with no decimal point
	local mantissa = num:gsub("^%-?(%d*)%.?(%d*)%-?[Ee][+%-]?%d+$", "%1%2")
	if negative and decimal_pos then decimal_pos = decimal_pos - 1 end
	if not decimal_pos then decimal_pos = #mantissa + 1 end

	-- Remove leading zeros unless decimal point is in first position
	while decimal_pos > 1 and mantissa:sub(1,1) == '0' do
		mantissa = mantissa:sub(2)
		decimal_pos = decimal_pos - 1
	end
	-- Shift decimal point right for exponent > 0
	while exponent > 0 do
		decimal_pos = decimal_pos + 1
		exponent = exponent - 1
		if decimal_pos > #mantissa + 1 then mantissa = mantissa .. '0' end
		-- Remove leading zeros unless decimal point is in first position
		while decimal_pos > 1 and mantissa:sub(1,1) == '0' do
			mantissa = mantissa:sub(2)
			decimal_pos = decimal_pos - 1
		end
	end
	-- Shift decimal point left for exponent < 0
	while exponent < 0 do
		if decimal_pos == 1 then
			mantissa = '0' .. mantissa
		else
			decimal_pos = decimal_pos - 1
		end
		exponent = exponent + 1
	end

	-- Insert decimal point in correct position and return
	return (negative and '-' or '') .. mantissa:sub(1, decimal_pos - 1) .. '.' .. mantissa:sub(decimal_pos)
end

-- 숫자를 어림수(round number)로 변환하는 함수
local function round_for_korean(num, round)
	-- 이미 두 자리 이하 정수이면 그대로 반환
	if num:find("^%-?%d?%d%.?$") then return num end

	local negative = num:find("^-")
	if negative then
		-- 크기를 기준으로 반올림하므로 방향을 뒤집음
		if round == 'up' then round = 'down' elseif round == 'down' then round = 'up' end
	end

	-- 소수점 앞이 두 자리 이하이면 정수로 반올림
	local _, _, small_int, trailing_digits, round_digit = num:find("^%-?(%d?%d?)%.((%d)%d*)$")
	if small_int then
		if small_int == '' then small_int = '0' end
		if (round == 'up' and trailing_digits:find('[1-9]')) or (round == 'on' and tonumber(round_digit) >= 5) then
			small_int = tostring(tonumber(small_int) + 1)
		end
		return (negative and '-' or '') .. small_int
	end

	-- 0이 아닌 숫자의 개수 확인
	local nonzero_digits = 0
	for digit in num:gmatch("[1-9]") do
		nonzero_digits = nonzero_digits + 1
	end

	num = num:gsub("%.%d*$", "") -- 소수 부분 제거
	
	-- 첫 유효숫자를 기준으로 반올림
	local _, _, lead_digit, round_digit, rest = num:find("^%-?(%d)(%d?)(.*)$")
	
	if (round == 'up' and nonzero_digits > 1) or (round == 'on' and tonumber(round_digit or 0) >= 5) then
		lead_digit = tostring(tonumber(lead_digit) + 1)
	end
	
	-- 나머지 자릿수를 0으로 채움
	rest = (round_digit or "") .. rest
	rest = rest:gsub("%d", "0")
	
	return (negative and '-' or '') .. lead_digit .. rest
end

-- Return status, fraction where:
-- status is a string:
--     "finished" if there is a fraction with no whole number;
--     "ok" if fraction is empty or valid;
--     "unsupported" if bad fraction;
-- fraction is a string giving (numerator / denominator) as English text, or is "".
-- Only unsigned fractions with a very limited range of values are supported,
-- except that if whole is empty, the numerator can use "-" to indicate negative.
-- whole (string or nil): nil or "" if no number before the fraction
-- numerator (string or nil): numerator, if any (default = 1 if a denominator is given)
-- denominator (string or nil): denominator, if any
-- sp_us (boolean): true if sp=us
-- negative_word (string): word to use for negative sign, if whole is empty
-- use_one (boolean): false: 2+1/2 → "two and a half"; true: "two and one-half"
local function fraction_to_korean(whole, numerator, denominator, negative_word)
	if numerator or denominator then
		local finished = (whole == nil or whole == '')
		local sign = ''
		if numerator then
			if finished and numerator:sub(1, 1) == '-' then
				numerator = numerator:sub(2); sign = negative_word .. ' '
			end
		else
			numerator = '1'
		end
		if not numerator:match('^%d+$') or not denominator or not denominator:match('^%d+$') then
			return 'unsupported', ''
		end
		-- 분수 변환 시에는 한자어 체계를 사용
		local numstr = export.spell_number(numerator, nil, nil, false, 'sino', false, nil, negative_word, nil, nil)
		local denstr = export.spell_number(denominator, nil, nil, false, 'sino', false, nil, negative_word, nil, nil)

		local fraction_text = denstr .. '분의 ' .. numstr
		if finished then return 'finished', sign .. fraction_text end
		return 'ok', '과 ' .. fraction_text
	end
	return 'ok', ''
end

-- Takes a decimal number and converts it to English text.
-- Return nil if a fraction cannot be converted (only some numbers are supported for fractions).
-- num (string or nil): the number to convert.
--      Can be an arbitrarily large decimal, such as "-123456789123456789.345", and
--      can use scientific notation (e.g. "1.23E5").
--      May fail for very large numbers not listed in "groups" such as "1E4000".
--      num is nil if there is no whole number before a fraction.
-- numerator (string or nil): numerator of fraction (nil if no fraction)
-- denominator (string or nil): denominator of fraction (nil if no fraction)
-- capitalize (boolean): whether to capitalize the result (e.g. 'One' instead of 'one')
-- use_and (boolean): whether to use the word 'and' between tens/ones place and higher places
-- hyphenate (boolean): whether to hyphenate all words in the result, useful for use as an adjective
-- ordinal (boolean): whether to produce an ordinal (e.g. 'first' instead of 'one')
-- plural (boolean): whether to pluralize the resulting number
-- links: nil: do not add any links; 'on': link "billion" and larger to Orders of magnitude article;
--        any other text: list of numbers to link (e.g. "billion,quadrillion")
-- negative_word: word to use for negative sign (typically 'negative' or 'minus'; nil to use default)
-- round: nil or '': no rounding; 'on': round to nearest two-word number; 'up'/'down': round up/down to two-word number
-- zero: word to use for value '0' (nil to use default)
-- use_one (boolean): false: 2+1/2 → "two and a half"; true: "two and one-half"
function export.spell_number(num, numerator, denominator, capitalize, num_system, force_one, ordinal, negative_word, round, zero)
	-- 기본값 설정
	num_system = num_system or 'sino'
	negative_word = negative_word or '마이너스'
	
	if type(num) == "number" then num = tostring(num) end

	-- 분수 처리
	local status, fraction_text = fraction_to_korean(num, numerator, denominator, negative_word)
	if status == 'unsupported' then return nil end
	if status == 'finished' then
		local s = fraction_text
		if capitalize then s = s:gsub("^.", mw.ustring.upper) end
		return s
	end
	
	if num then
		num = scientific_notation_to_decimal(num)
		if round and round ~= '' then
			if round ~= 'on' and round ~= 'up' and round ~= 'down' then error("잘못된 반올림 방식입니다.") end
			num = round_for_korean(num, round)
		end
	else
		return nil -- 분수만 있고 정수부가 없는 경우
	end

	-- 음수, 정수, 소수 부분 분리
	local MINUS = '−'; if num:sub(1, #MINUS) == MINUS then num = '-' .. num:sub(#MINUS + 1) end
	if num:sub(1, 1) == '+' then num = num:sub(2) end
	local negative = num:find("^-")
	local decimal_places, subs = num:gsub("^%-?%d*%.(%d+)$", "%1")
	if subs == 0 then decimal_places = nil end
	num, subs = num:gsub("^%-?(%d*)%.?%d*$", "%1")
	if num == '' and decimal_places then num = '0' end
	if subs == 0 or num == '' then error("잘못된 10진수 숫자입니다.") end
	
	if num == '0' then
		if ordinal == '번째' then return '영 번째' -- 서수 '0'에 대한 처리
		elseif ordinal == '제' then return '제영'
		else return zero or '영' end
	end

	-- 고유어 체계는 99까지
	if num_system == 'native' and tonumber(num) >= 100 then
		error("고유어 숫자 체계는 99까지만 지원합니다.")
	end

	local s = ''
	if num_system == 'native' then
		s = numeral_to_korean_less_100(tonumber(num), 'native')
	else -- 한자어 체계
		local original_num_len = #num
		-- 네 자리씩 끊어 처리
		while #num > 4 do
			local group_num = math.floor((#num - 1) / 4)
			local group = export.groups[group_num]
			if not group then error("너무 큰 숫자입니다.") end
			
			local group_digits = #num - group_num * 4
			local current_group_text = numeral_to_korean_less_10000(num:sub(1, group_digits), 'sino', force_one)
			
			if current_group_text ~= "" then
				-- 1만, 1억 등에서 '일'을 표시할지 여부 결정
				if tonumber(num:sub(1, group_digits)) == 1 and not force_one then
					s = s .. group .. ' '
				else
					s = s .. current_group_text .. group .. ' '
				end
			end
			
			num = num:sub(1 + group_digits)
			num = num:gsub("^0*", "")
		end

		-- 마지막 네 자리 처리
		if num ~= "" then
			s = s .. numeral_to_korean_less_10000(num, 'sino', force_one)
		end
	end

	-- 소수점 처리
	if decimal_places then
		s = (s == '' and '영' or s) .. ' 점' -- 정수부가 0일 경우 '영' 추가
		for i = 1, #decimal_places do
			s = s .. ' ' .. export.sino_ones_position[tonumber(decimal_places:sub(i,i))]
		end
	end

	s = s:gsub("^%s*(.-)%s*$", "%1")
	
	-- 서수 처리
	if ordinal then
		if ordinal == '번째' then
			if num_system ~= 'native' then error("'-번째' 서수는 고유어 숫자 체계에서만 사용할 수 있습니다.") end
			local last_digit = tonumber(num:sub(-1))
			local modifier = export.native_ones_modifier[last_digit]
			if modifier then
				s = s:sub(1, -1 - #export.native_ones_position[last_digit]) .. modifier
			end
			if tonumber(num) == 1 then s = "첫" end -- 1은 '한 번째'가 아닌 '첫 번째'
			s = s .. ' 번째'
		elseif ordinal == '제' then
			if num_system ~= 'sino' then error("'제-' 서수는 한자어 숫자 체계에서만 사용할 수 있습니다.") end
			s = '제' .. s
		end
	end
	
	if negative and s ~= (zero or '영') then s = negative_word .. ' ' .. s end
	s = s .. fraction_text
	
	if capitalize then s = s:gsub("^.", mw.ustring.upper) end
	return s
end

-- Template-callable equivalent of export.spell_number().
function export.numeral_to_korean(frame)
	local args = frame.args
	local p_args = frame:getParent().args
	
	local function get_arg(name) return args[name] or p_args[name] end
	
	local num = get_arg(1)
	if num then
		num = tostring(num):gsub("^%s*(.-)%s*$", "%1"):gsub(",", "")
		if num ~= '' then
			if not num:find("^%-?%d*%.?%d*%-?[Ee]?[+%-]?%d*$") then
				num = frame:preprocess('{{#expr: ' .. num .. '}}')
			end
		end
	end

	return export.spell_number(
		num,
		get_arg('numerator') or get_arg('분자'),
		get_arg('denominator') or get_arg('분모'),
		(get_arg('case') == 'U' or get_arg('case') == 'u'),
		get_arg('system') or get_arg('체계'), -- 'sino' 또는 'native'
		(get_arg('force_one') == 'on' or get_arg('일') == '포함'),
		get_arg('ord') or get_arg('서수'), -- '제' 또는 '번째'
		get_arg('negative') or get_arg('음수'),
		get_arg('round') or get_arg('반올림'),
		get_arg('zero') or get_arg('영')
	) or ''
end

---- recursive function for export.decToHex
local function decToHexDigit(dec)
	local dig = {"0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"}
	local div = math.floor(dec/16)
	local mod = dec-(16*div)
	if div >= 1 then return decToHexDigit(div)..dig[mod+1] else return dig[mod+1] end
end -- I think this is supposed to be done with a tail call but first I want something that works at all
---- finds all the decimal numbers in the input text and hexes each of them
function export.decToHex(frame)
	local args=frame.args
	local parent=frame.getParent(frame)
	local pargs={}
	if parent then pargs=parent.args end
	local text=args[1] or pargs[1] or ""
	local minlength=args.minlength or pargs.minlength or 1
	minlength=tonumber(minlength)
	local prowl=mw.ustring.gmatch(text,"(.-)(%d+)")
	local output=""
	repeat
		local chaff,dec=prowl()
		if not(dec) then break end
		local hex=decToHexDigit(dec)
		while (mw.ustring.len(hex)<minlength) do hex="0"..hex end
		output=output..chaff..hex
	until false
	local chaff=mw.ustring.match(text,"(%D+)$") or ""
	return output..chaff
end

return export