Module documentation

This module implements {{th-rm}}.

local p = {}

local arguments = require('Module:Arguments')
local tt = require('Module:Tt')

local AUTO_TOOLTIP = 'This romanization was generated automatically. Please add a user-curated romanization as soon as possible.'

local THAI = '[ก-๛]'

-- Initial consonants.
local consonants = {
	['ก'] = 'k',
	['ข'] = 'kh',
	['ฃ'] = 'kh',
	['ค'] = 'kh',
	['ฅ'] = 'kh',
	['ฆ'] = 'kh',
	['ง'] = 'ng',
	['จ'] = 'ch',
	['ฉ'] = 'ch',
	['ช'] = 'ch',
	['ซ'] = 's',
	['ฌ'] = 'ch',
	['ญ'] = 'y',
	['ฎ'] = 'd',
	['ฏ'] = 't',
	['ฐ'] = 'th',
	['ฑ'] = 'th',
	['ฒ'] = 'th',
	['ณ'] = 'n',
	['ด'] = 'd',
	['ต'] = 't',
	['ถ'] = 'th',
	['ท'] = 'th',
	['ธ'] = 'th',
	['น'] = 'n',
	['บ'] = 'b',
	['ป'] = 'p',
	['ผ'] = 'ph',
	['ฝ'] = 'f',
	['พ'] = 'ph',
	['ฟ'] = 'f',
	['ภ'] = 'ph',
	['ม'] = 'm',
	['ย'] = 'y',
	['ร'] = 'r',
	['ล'] = 'l',
	['ว'] = 'w',
	['ศ'] = 's',
	['ษ'] = 's',
	['ส'] = 's',
	['ห'] = 'h',
	['ฬ'] = 'l',
	['ฮ'] = 'h',
	['อ'] = ''
}

-- Final consonants.
local finals = {
	['ก'] = 'k',
	['ข'] = 'k',
	['ฃ'] = 'k',
	['ค'] = 'k',
	['ฅ'] = 'k',
	['ฆ'] = 'k',

	['จ'] = 't',
	['ฉ'] = 't',
	['ช'] = 't',
	['ซ'] = 't',
	['ฌ'] = 't',
	['ฎ'] = 't',
	['ฏ'] = 't',
	['ฐ'] = 't',
	['ฑ'] = 't',
	['ฒ'] = 't',
	['ด'] = 't',
	['ต'] = 't',
	['ถ'] = 't',
	['ท'] = 't',
	['ธ'] = 't',
	['ศ'] = 't',
	['ษ'] = 't',
	['ส'] = 't',

	['บ'] = 'p',
	['ป'] = 'p',
	['ผ'] = 'p',
	['ฝ'] = 'p',
	['พ'] = 'p',
	['ฟ'] = 'p',
	['ภ'] = 'p',

	['ง'] = 'ng',

	['ญ'] = 'n',
	['ณ'] = 'n',
	['น'] = 'n',
	['ร'] = 'n',
	['ล'] = 'n',
	['ฬ'] = 'n',

	['ม'] = 'm'
}

-- Vowels.
local vowels = {
	['เียะ'] = 'ia',
	['เีย'] = 'ia',
	['เือะ'] = 'uea',
	['เือ'] = 'uea',
	['เือย'] = 'ueai',
	['เียว'] = 'iao',

	['ัวะ'] = 'ua',
	['ัว'] = 'ua',

	['เิ็ย'] = 'oei',
	['เิย'] = 'oei',
	['เอย'] = 'oei',

	['็อย'] = 'oi',
	['อย'] = 'oi',

	['ุย'] = 'ui',
	['วย'] = 'uai',

	['ัย'] = 'ai',
	['ไย'] = 'ai',
	['าย'] = 'ai',
	['ใ'] = 'ai',
	['ไ'] = 'ai',

	['เ็ว'] = 'eo',
	['แ็ว'] = 'aeo',
	['เา'] = 'ao',
	['าว'] = 'ao',
	['แว'] = 'aeo',
	['เว'] = 'eo',

	['ิว'] = 'io',
	['ีว'] = 'io',

	['ำ'] = 'am',

	['ือ'] = 'ue',
	['ื'] = 'ue',
	['ึ'] = 'ue',

	['ิ'] = 'i',
	['ี'] = 'i',

	['ุ'] = 'u',
	['ู'] = 'u',

	['เ็'] = 'e',
	['เ'] = 'e',

	['แ็'] = 'ae',
	['แ'] = 'ae',

	['โะ'] = 'o',
	['โ'] = 'o',

	['เาะ'] = 'o',
	['อ'] = 'o',

	['เอะ'] = 'oe',
	['เอ'] = 'oe',
	['เะ'] = 'e',

	['ะ'] = 'a',
	['ั'] = 'a',
	['า'] = 'a'
}

-- Longest vowel forms must be checked first.
local vowel_order = {
	'เือย',
	'เียว',
	'เือะ',
	'เียะ',
	'เีย',
	'เือ',
	'ัวะ',
	'ัว',
	'เิ็ย',
	'เิย',
	'็อย',
	'เ็ว',
	'แ็ว',
	'เอย',
	'เา',
	'าว',
	'เว',
	'แว',
	'าย',
	'ไย',
	'ัย',
	'ุย',
	'วย',
	'อย',
	'ำ',
	'ิว',
	'ีว',
	'ิ',
	'ี',
	'ึ',
	'ื',
	'ุ',
	'ู',
	'เ็',
	'แ็',
	'เาะ',
	'เอะ',
	'เอ',
	'เะ',
	'ะ',
	'ั',
	'า',
	'เ',
	'แ',
	'โ',
	'อ',
	'ใ',
	'ไ'
}

local consonant_set = {}

for c in pairs(consonants) do
	consonant_set[c] = true
end

-- Thai consonant clusters.
local valid_clusters = {
	['กร'] = true,
	['กล'] = true,
	['กว'] = true,
	['ขร'] = true,
	['ขล'] = true,
	['ขว'] = true,
	['คร'] = true,
	['คล'] = true,
	['คว'] = true,
	['ฆร'] = true,
	['ฆล'] = true,
	['จร'] = true,
	['ตร'] = true,
	['ปร'] = true,
	['ปล'] = true,
	['พร'] = true,
	['พล'] = true,
	['ผล'] = true,
	['ทร'] = true,
	['ธร'] = true,
	['ศร'] = true,
	['สร'] = true,
	['สล'] = true,
	['สว'] = true,
	['ซร'] = true,
	['ฝร'] = true,
	['ฟร'] = true
}

-- ห is silent in these clusters when followed by a vowel.
local h_silent_clusters = {
	['หง'] = true,
	['หน'] = true,
	['หม'] = true,
	['หย'] = true,
	['หร'] = true,
	['หล'] = true,
	['หว'] = true,
	['หญ'] = true
}

-- ร is silent in these clusters when followed by a vowel.
local r_silent_clusters = {
	['จร'] = true,
	['ซร'] = true,
	['ศร'] = true,
	['สร'] = true
}

local function chars(text)
	local result = {}

	for c in mw.text.gsplit(text, '') do
		table.insert(result, c)
	end

	return result
end

local function isConsonant(c)
	return c and consonant_set[c] == true
end

local function isThai(c)
	return c and mw.ustring.find(c, THAI) ~= nil
end

local function isFrontVowel(c)
	return c == 'เ' or c == 'แ' or c == 'โ' or c == 'ใ' or c == 'ไ'
end

local function skipMarks(c, pos)
	while c[pos] and (
		c[pos] == '็' or
		c[pos] == '่' or
		c[pos] == '้' or
		c[pos] == '๊' or
		c[pos] == '๋' or
		c[pos] == 'ํ' or
		c[pos] == '๎'
	) do
		pos = pos + 1
	end

	return pos
end

local function removeSilent(chars_)
	local result = {}
	local i = 1

	while chars_[i] do
		if chars_[i] == '์' then
			-- ์ makes the following consonant(s) silent.
			i = i + 1

			while chars_[i] and (
				isConsonant(chars_[i]) or
				chars_[i] == '็' or
				chars_[i] == '่' or
				chars_[i] == '้' or
				chars_[i] == '๊' or
				chars_[i] == '๋'
			) do
				i = i + 1
			end
		else
			table.insert(result, chars_[i])
			i = i + 1
		end
	end

	return result
end

local function getVowel(c, pos)
	local remaining = table.concat(c, '', pos)

	for _, form in ipairs(vowel_order) do
		local length = mw.ustring.len(form)

		if mw.ustring.sub(remaining, 1, length) == form then
			return form, vowels[form]
		end
	end

	return nil, nil
end

local function getInitial(c, pos)
	local c1 = c[pos]

	if not isConsonant(c1) then
		return '', pos
	end

	local c2 = c[pos + 1]

	-- ห is not pronounced in หง หน หม หย หร หล หว หญ.
	if c1 == 'ห' and c2 and h_silent_clusters[c1 .. c2] then
		return consonants[c2], pos + 2, true
	end

	-- ทร is pronounced s in the relevant Sanskrit-derived words.
	-- This is the broad automatic fallback; exceptions can be manually
	-- supplied with th_rm.
	if c1 == 'ท' and c2 == 'ร' then
		return 's', pos + 2, false
	end

	if c2 and valid_clusters[c1 .. c2] then
		-- ร is silent in these clusters.
		if r_silent_clusters[c1 .. c2] then
			return consonants[c1], pos + 2, false
		end

		return consonants[c1] .. consonants[c2], pos + 2, false
	end

	return consonants[c1], pos + 1, false
end

local function getCoda(c, pos)
	local current = c[pos]

	if not current or not isConsonant(current) then
		return '', pos
	end

	-- These do not act as ordinary codas here.
	if current == 'อ' or current == 'ห' or current == 'ฮ' then
		return '', pos
	end

	-- ย and ว are generally part of the vowel/diphthong.
	if current == 'ย' or current == 'ว' then
		return '', pos
	end

	if finals[current] then
		return finals[current], pos + 1
	end

	return '', pos + 1
end

local function startsNextSyllable(c, pos)
	if not c[pos] or not isConsonant(c[pos]) then
		return false
	end

	if isFrontVowel(c[pos + 1]) then
		return true
	end

	local _, vowel = getVowel(c, pos + 1)

	return vowel ~= nil
end

local function parseSyllable(c, start)
	local pos = start

	if not c[pos] then
		return nil, pos
	end

	local initial = ''
	local cluster = false

	-- Preposed vowels.
	local front = ''

	if isFrontVowel(c[pos]) then
		front = c[pos]
		pos = pos + 1
	end

	local initialPos = pos

	initial, pos, _, cluster =
		getInitial(c, pos)

	if initial == '' and not isConsonant(c[initialPos]) then
		return nil, start + 1
	end

	local vowel
	local vowelForm

	if front ~= '' then
		-- Handle the vowel forms which contain a preposed vowel.
		if front == 'เ' and c[pos] == 'ี' and c[pos + 1] == 'ย' then
			vowel = 'ia'
			vowelForm = 'เีย'
			pos = pos + 2

		elseif front == 'เ' and c[pos] == 'ื' and c[pos + 1] == 'อ' then
			vowel = 'uea'
			vowelForm = 'เือ'
			pos = pos + 2

		elseif front == 'เ' and c[pos] == 'อ' then
			vowel = 'oe'
			vowelForm = 'เอ'
			pos = pos + 1

		elseif front == 'เ' and c[pos] == 'า' then
			vowel = 'ao'
			vowelForm = 'เา'
			pos = pos + 1

		elseif front == 'เ' and c[pos] == '็' then
			vowel = 'e'
			vowelForm = 'เ็'
			pos = pos + 1

		elseif front == 'แ' and c[pos] == '็' then
			vowel = 'ae'
			vowelForm = 'แ็'
			pos = pos + 1

		elseif front == 'โ' and c[pos] == 'ะ' then
			vowel = 'o'
			vowelForm = 'โะ'
			pos = pos + 1

		elseif front == 'ใ' or front == 'ไ' then
			vowel = 'ai'
			vowelForm = front

		else
			vowel = vowels[front]
			vowelForm = front
		end
	else
		vowelForm, vowel = getVowel(c, pos)

		if vowelForm then
			pos = pos + mw.ustring.len(vowelForm)
		end
	end

	-- Implied vowels.
	if not vowel then
		if c[pos] and isConsonant(c[pos]) then
			if startsNextSyllable(c, pos) then
				vowel = 'a'
			else
				vowel = 'o'
			end
		else
			vowel = 'a'
		end
	end

	pos = skipMarks(c, pos)

	-- Final consonant.
	local coda = ''
	local codaPos = pos

	if c[pos] and isConsonant(c[pos]) then
		-- If another consonant begins the next syllable, don't consume
		-- it as a coda.
		if startsNextSyllable(c, pos) then
			coda = ''
		else
			coda, codaPos = getCoda(c, pos)
		end
	end

	pos = codaPos
	pos = skipMarks(c, pos)

	return {
		initial = initial,
		vowel = vowel,
		coda = coda,
		next = pos
	}, pos
end

local function shouldHyphen(previous, current)
	if not previous or not current then
		return false
	end

	-- Before a succeeding syllable beginning with a vowel.
	if current.initial == '' and current.vowel ~= '' then
		return true
	end

	-- Before ng when the preceding syllable ends in a vowel.
	if previous.coda == '' and current.initial == 'ng' then
		return true
	end

	return false
end

local function romanizeWord(word)
	local c = chars(word)
	c = removeSilent(c)

	local syllables = {}
	local pos = 1
	local safety = 0

	while pos <= #c and safety < 1000 do
		safety = safety + 1

		if isConsonant(c[pos]) or isFrontVowel(c[pos]) then
			local syllable, nextPos = parseSyllable(c, pos)

			if syllable then
				table.insert(syllables, syllable)

				if nextPos <= pos then
					pos = pos + 1
				else
					pos = nextPos
				end
			else
				-- Fail safely rather than looping forever.
				pos = pos + 1
			end
		else
			-- Thai punctuation/marks that aren't part of a syllable.
			pos = pos + 1
		end
	end

	local output = {}

	for i, syllable in ipairs(syllables) do
		if shouldHyphen(syllables[i - 1], syllable) then
			table.insert(output, '-')
		end

		table.insert(
			output,
			(syllable.initial or '') ..
			(syllable.vowel or '') ..
			(syllable.coda or '')
		)
	end

	return table.concat(output)
end

-- Parse the text while treating <> and <-> as hard boundaries.
--
-- <>  = literal space
-- <-> = literal hyphen
--
-- Because each Thai section is romanized independently, automatic
-- hyphenation can never cross one of these markers.
local function romanizeThaiText(text)
	local result = {}
	local pos = 1
	local length = mw.ustring.len(text)

	while pos <= length do
		-- Manual hyphen.
		if mw.ustring.sub(text, pos, pos + 2) == '<->' then
			table.insert(result, '-')
			pos = pos + 3

		-- Manual space.
		elseif mw.ustring.sub(text, pos, pos + 1) == '<>' then
			table.insert(result, ' ')
			pos = pos + 2

		-- Thai run.
		elseif isThai(mw.ustring.sub(text, pos, pos)) then
			local start = pos

			while pos <= length do
				local character = mw.ustring.sub(text, pos, pos)

				if not isThai(character) then
					break
				end

				-- Stop before a manual control marker.
				if mw.ustring.sub(text, pos, pos + 2) == '<->' then
					break
				end

				if mw.ustring.sub(text, pos, pos + 1) == '<>' then
					break
				end

				pos = pos + 1
			end

			local word = mw.ustring.sub(text, start, pos - 1)

			table.insert(result, romanizeWord(word))

		-- Everything else is preserved.
		else
			table.insert(
				result,
				mw.ustring.sub(text, pos, pos)
			)

			pos = pos + 1
		end
	end

	return table.concat(result)
end

local function convertThaiDigits(text)
	local digits = {
		['๐'] = '0',
		['๑'] = '1',
		['๒'] = '2',
		['๓'] = '3',
		['๔'] = '4',
		['๕'] = '5',
		['๖'] = '6',
		['๗'] = '7',
		['๘'] = '8',
		['๙'] = '9'
	}

	for thai, latin in pairs(digits) do
		text = mw.ustring.gsub(text, thai, latin)
	end

	return text
end

local function hasThai(text)
	return mw.ustring.find(text, THAI) ~= nil
end

local function validateRomanization(text)
	-- Never allow Thai characters to leak into the final result.
	if hasThai(text) then
		return false
	end

	return true
end

local function capitalizeWords(text)
	return mw.ustring.gsub(
		text,
		'(%a)([%a]*)',
		function(first, rest)
			return mw.ustring.upper(first) .. rest
		end
	)
end

local function capitalizeSentence(text)
	local prefix, first = mw.ustring.match(
		text,
		'^([%s%p]*)(%a)'
	)

	if not first then
		return text
	end

	return prefix ..
		mw.ustring.upper(first) ..
		mw.ustring.sub(text, mw.ustring.len(prefix) + 2)
end

function p.romanize(text)
	if not text or mw.text.trim(text) == '' then
		return ''
	end

	-- Do not automatically romanize Latin-only text.
	if not hasThai(text) then
		return ''
	end

	text = convertThaiDigits(text)

	local result = romanizeThaiText(text)

	result = mw.ustring.gsub(result, '%s+', ' ')
	result = mw.text.trim(result)

	if not validateRomanization(result) then
		return ''
	end

	return result
end

function p.main(frame)
	local args = arguments.getArgs(frame, {
		parentFirst = true,
		wrappers = {'Template:Th-rm'}
	})

	local text = args[1]

	if not text or mw.text.trim(text) == '' then
		return ''
	end

	if not hasThai(text) then
		return ''
	end

	local result = p.romanize(text)

	if result == '' then
		return ''
	end

	if args.capi then
		result = capitalizeWords(result)
	end

	if args.sent then
		result = capitalizeSentence(result)
	end

	-- Automatically generated romanizations get a Tt warning.
	return tt._main({
		[1] = result,
		[2] = AUTO_TOOLTIP,
		['class'] = args.class
	})
end

return p