Bước tới nội dung

Mô đun:ca-IPA

Từ điển mở Wiktionary
local export = {}

local lang = require("Module:languages").getByCode("ca")

local m_IPA = require("Module:IPA")
local m_table = require("Module:table")

local parse_utilities_module = "Module:parse utilities"
local strutil_module = "Module:string utilities"

local listToSet = require("Module:table").listToSet

--[=[
FIXME:

1. [zʒ] should reduce to [ʒ] in Central and Balearic ([[disjunt]], [[disjuntor]]). Similar for [sʃ]
   ([[desxifrar]]). [DONE]
2. There needs to be a way of forcing [ʃ]. (Maybe just ʃ?) [DONE]
3. Make sure manual dot for syllable break works, cf. [[best-seller]] respelled `bèst.sèlerr'. [DONE]
4. Explicit accents on a/i/u should be removed in split_syllables(). [NOT DONE; not needed]
5. Compress double schwa in Central/Balearic in e.g. [[sobreescalfament]], [[centreafricà]], [[contraatac]],
   [[contraescarpa]]; seems not to operate in Valencian.
6. Compress unstressed <ie> and <oe> followed by coda consonant -> [u] in Central/Balearic in e.g. [[aeroespacial]],
   [[autoescola]], [[antiespasmòdic]], but not [[autoerotisme]], [[antiemètic]]; seems not to operate in Valencian.
   NOTE: It does operate in an open syllable in [[fotoelèctric]], [[fotoelectricitat]], [[macroeconomia]]; not sure why.
7. Compress unstressed <oo> followed by a coda consonant -> [u] in Central/Balearic in e.g. [[microorganisme]]. Seems
   not to operate in Valencian.
8. bm -> [mm] e.g. [[subministrament]]; seems not to operate in Valencian.
9. ë (and presumably ê) doesn't work in secondary stress, always becomes /ɛ/ (e.g. in [[extrajudicial]] respelled
   'ëxtrajudiciàl'; this seems to be because the handling of ë goes through mid_vowel_hint, which doesn't work for
   secondary stress. [DONE]
10. Respect ʃ at beginning of word in Valencian. [DONE]
11. [ʃ] in single substitution specs should match against written x. [DONE]
12. Prefixes e.g. [[xilo-]] should not have stress by default, and written primary stresses should be converted to
    secondary. [DONE]
13. Convert apostrophe near beginning to tie (‿) and make sure we take account of it later, so that words like
	[[captindre's]] and phrases like [[dona d'aigua]] work correctly. [DONE]
14. Correctly handle -bl and -gl in respelling, generating [bl] and [gl]. [NOT DONE; use _bl, _gl]
15. Correctly handle [βðɣ] in respelling forcing fricatives; should not be fortitioned. [NOT DONE; not needed]
16. [βðɣ] in single substitution specs should match against b/d/g. [NOT DONE; not needed]
17. [ss] in single substitution specs should match against ss?; used to force a pronounced [s]. [DONE]
18. [dm] in single substitution specs should match against [td]m. [NOT DONE; not needed]
19. Correctly handle written -dg- after [rz]: fricatives in Valencian, stops in Central (and Balearic?). [DONE]
20. Correctly handle lenition of written -bdg-: (1) -b- not lenited in Valencian or Balearic, lenited to [β] in
    Central Catalan after vowels and consonants except nasals and [rz]; (2) -g- not lenited after nasals, also not
	after [rz] in Central Catalan (and maybe Balearic?), otherwise yes except utterance initial; (3) -d- not lenited
	after nasals or laterals, also not after [rz] in Central Catalan (and maybe Balearic?), otherwise yes except
	utterance initial. Verify against ca-IPA equivalent on cawikt and also based on {{w|Catalan phonology}} and the IEC
	grammar that Vriullop linked. [DONE]
21. Finish rewriting do_dialect_specific() to operate on whole word using Lua patterns. [DONE]
22. Implement multiword handling. [DONE]
23. Make sure suffix handling works correctly. [DONE]
24. Add many more test cases and redo test harness ala the German test harness. [DONE]
25. Redo handling of mid-vowel hints so it gets done early and in one place. [DONE]
26. Think about how to solve the issue of mid-vowel hints along with secondary stress marks in substitution specs.
    Maybe a single mid-vowel spec should be rewritten to be a single substitution spec and the insertion of the
	mid-vowel spec should happen during resolution of substitution specs. [DONE]
27. <tm> should default to [dm] not [mm]. [DONE]
28. Fix handling of mid vowel default in -è/-ès/-esa so it doesn't affect [[tèbia]] etc. [DONE]
29. x- after hyphen should probably become tx- in Valencian, cf. [[para-xocs]]. [DONE]
30. Implement DOTOVER to indicate lack of stress in a word, e.g. in a suffix. [DONE]
31. Handle words without vowels. [DONE]
32. Finish reviewing places where we may need to check for tie symbols.
33. Handle tie indicating liaison in e.g. [[Sant Antoni de Portmany]]. [DONE]
34. Handle pronunciation of [[amb]] correctly. [DONE]
35. Handle tie indicating liaison before h- correctly, e.g. [[Sant Hipòlit]]. [DONE]
36. Lenition should happen in Valencian in [[regla]] whether respelled 'réggla', 'régla' or 'rég_la'. [DONE]
37. Syllabification should happen correctly when underscore is used in 'bíb_lia' to block doubling of <bl>. [DONE]
38. <cn> should show up as [ŋn]. [DONE]
39. Delete [t] after [s] before anything but [s] ([[best-seller]]) or [ɾ] ([[postrem]]). [DONE]
40. Delete <t/d> after <n> before consonant even in Valencian; likewise for <p/b> after <m>, <c/g> after <n>. [DONE]
41. DOTOVER in single substitution specs should work. [DONE]
42. Underline in single substitution specs should work. [DONE]
43. LINEUNDER should work to indicate secondary stress after the primary stress, including in single substitution
    specs. [DONE]
]=]


local usub = mw.ustring.sub
local rfind = mw.ustring.find
local rmatch = mw.ustring.match
local rsplit = mw.text.split
local rsubn = mw.ustring.gsub
local ulower = mw.ustring.lower
local u = require("Module:string/char")
local ugcodepoint = mw.ustring.gcodepoint


export.dialects = {"bal", "cen", "val"}
export.dialects_to_names = {
	bal = "Balearic",
	cen = "Central Catalan",
	val = "Valencian",
}
export.dialect_groups = {
	east = {"bal", "cen"},
}


local written_unaccented_vowel_l = "aeiouyAEIOUY"
local written_stressed_vowel_l = "àèéêëíòóôúýÀÈÉÊËÍÒÓÔÚÝ"
local written_accented_not_stressed_vowel_l = "ïüÏÜ"
local written_accented_vowel_l = written_stressed_vowel_l .. written_accented_not_stressed_vowel_l
local ipa_vowel_l = "ɔɛə"
local written_vowel_l = written_unaccented_vowel_l .. written_accented_vowel_l
local vowel_l = written_vowel_l .. ipa_vowel_l
local V = "[" .. vowel_l .. "]"
local written_accented_to_plain_vowel = {
	["à"] = "a",
	["è"] = "e",
	["é"] = "e",
	["ê"] = "e",
	["ë"] = "e",
	["í"] = "i",
	["ï"] = "i",
	["ò"] = "o",
	["ó"] = "o",
	["ô"] = "o",
	["ú"] = "u",
	["ü"] = "u",
	["ý"] = "y",
	["À"] = "A",
	["È"] = "E",
	["É"] = "E",
	["Ê"] = "E",
	["Ë"] = "E",
	["Í"] = "I",
	["Ï"] = "I",
	["Ò"] = "O",
	["Ó"] = "O",
	["Ô"] = "O",
	["Ú"] = "U",
	["Ü"] = "U",
	["Ý"] = "Y",
}

local AC = u(0x0301) -- acute =  ́
local GR = u(0x0300) -- grave =  ̀
local CFLEX = u(0x0302) -- circumflex =  ̂
local DOTOVER = u(0x0307) -- dot over =  ̇
local DIA = u(0x0308) -- diaeresis =  ̈
local LINEUNDER = u(0x0331) -- lineunder =  ̱

local stress_l = AC .. GR
local stress_c = "[" .. stress_l .. "]"
local ipa_stress_l = "ˈˌ"
local ipa_stress_c = "[" .. ipa_stress_l .. "]"
local sylsep_l = "%-." -- hyphen included for syllabifying from spelling; FIXME: formerly included SYLDIV
local sylsep_c = "[" .. sylsep_l .. "]"
local tie_l = "‿'"
local tie_c = "[" .. tie_l .. "]"
local charsep_l = sylsep_l .. tie_l .. stress_l .. ipa_stress_l
local charsep_c = "[" .. charsep_l .. "]"
local wordsep_l = "# "
local wordsep_c = "[" .. wordsep_l .. "]"
local separator_l = charsep_l .. wordsep_l
local separator_c = "[" .. separator_l .. "]"
local neg_guts_of_cons = vowel_l .. separator_l
local C = "[^" .. neg_guts_of_cons .. "]" -- consonant class including h

export.mid_vowel_hints = "éèêëóòô"
export.mid_vowel_hint_c = "[" .. export.mid_vowel_hints .. "]"

local TEMP_PAREN_R = u(0xFFF1)
local TEMP_PAREN_RR = u(0xFFF2)
-- Pseudo-consonant at the edge of prefixes ending in a vowel and suffixes beginning with a vowel; FIXME: not currently
-- used.
local PSEUDOCONS = u(0xFFF3)
-- local PREFIX_MARKER = u(0xFFF4) -- marker indicating a prefix so we can convert primary to secondary accents


local valid_onsets = listToSet {
	"b", "bl", "br",
	"c", "cl", "cr",
	"ç",
	"d", "dj", "dr",
	"f", "fl", "fr",
	"g", "gl", "gr", "gu", "gü",
	"h",
	"i",
	"j",
	"k", "kl", "kr",
	"l", "ll",
	"m",
	"n", "ny", "ñ",
	"p", "pl", "pr",
	"qu", "qü",
	"r", "rr",
	"s", "ss",
	"t", "tg", "tj", "tr", "tx", "tz",
	"u",
	"v", "vl", "vr",
	"w",
	"x",
	"ʃ", -- e.g. 'χruʃóf' respelling of [[Khrusxov]]
	"χ", -- in case of respelling
	"y",
	"z",
} 

local decompose_dotover = {
	-- No composed i, u or U with DOTOVER.
	["ȧ"] = "a" .. DOTOVER,
	["ė"] = "e" .. DOTOVER,
	["ȯ"] = "o" .. DOTOVER,
	["ẏ"] = "y" .. DOTOVER,
	["Ȧ"] = "A" .. DOTOVER,
	["Ė"] = "E" .. DOTOVER,
	["İ"] = "I" .. DOTOVER,
	["Ȯ"] = "O" .. DOTOVER,
	["Ẏ"] = "Y" .. DOTOVER,
}

local unstressed_words = listToSet {
	-- proclitic object pronouns
	"em", "et", "es", "el", "la", "els", "les", "li", "ens", "us", "ho", "hi", "en",
	-- enclitic object pronouns usually attach with hyphen to preceding verb but not always, cf. [[tant me fa]]
	"me", "te", "se", "lo", "los", "nos", "vos", "ne",
	-- contracted object pronouns and articles attached with apostrophe so no need to include
	-- unstressed possessives
	"mon", "ma", "mos", "mes", "ton", "ta", "tos", "tes", "son", "sa", "sos", "ses",
	-- prepositions
	"a", "de", "per", "amb", "ab", -- 'en' already included as proclitic object pronouns
	-- prepositional contractions
	"al", "als", "del", "dels", "pel", "pels",
	-- articles 'el', 'la', 'els', 'les' already included as proclitic pronouns
	-- personal articles
	"na", -- 'en' already included above
	-- indefinite articles
	"un", "uns",
	-- salat articles
	"ets", "so", -- 'es' already included as proclitic object pronouns and 'ses', 'sa', 'sos' as possessives
	-- conjunctions
	"i", "o", "si", "ni", "que",
}

-- Version of rsubn() that discards all but the first return value.
local function rsub(term, foo, bar)
	local retval = rsubn(term, foo, bar)
	return retval
end

-- Version of rsubn() that returns a 2nd argument boolean indicating whether a substitution was made.
local function rsubb(term, foo, bar)
	local retval, nsubs = rsubn(term, foo, bar)
	return retval, nsubs > 0
end

-- Apply rsub() repeatedly until no change.
local function rsub_repeatedly(term, foo, bar)
	while true do
		local new_term = rsub(term, foo, bar)
		if new_term == term then
			return term
		end
		term = new_term
	end
end

local function split_into_chars(text)
	local chars = {}
	for codepoint in ugcodepoint(text) do
		table.insert(chars, u(codepoint))
	end
	return chars
end

local function split_on_comma(term)
	if term:find(",%s") or term:find("\\") then
		return require(parse_utilities_module).split_on_comma(term)
	else
		return rsplit(term, ",")
	end
end

local function concat_keys(tab)
	local res = {}
	for k, _ in pairs(tab) do
		table.insert(res, k)
	end
	return table.concat(res)
end


local function handle_unstressed_words(words)
	words = m_table.deepcopy(words)

	-- Lowercase all words for ease in further processing.
	for i, wordobj in ipairs(words) do
		wordobj.term = ulower(wordobj.term)
	end

	-- Check if the word at index `i` in `words` is "amb" and the following word begins with a vowel.
	local function is_amb_to_join(words, i)
		return i < #words and words[i].term == "a" .. DOTOVER .. "mb" and rfind(words[i + 1].term, "^h?" .. V)
	end
	local saw_amb_to_join = true

	-- Mark all unstressed words with DOTOVER, so that split_syllables() doesn't assign stress. We need to do this
	-- before special handling for [[amb]], because [[amb]] may join to another unstressed word like [[el]], in the
	-- process losing the identity of the two words. In the process, see if [[amb]] occurs before a following
	-- vowel-initial word (which may begin with h-).
	for i, wordobj in ipairs(words) do
		-- Put DOTOVER after the last vowel (to handle the case of [[que]]). It doesn't actually matter where we put
		-- it, because split_syllables() just looks for DOTOVER anywhere in the word.
		if unstressed_words[wordobj.term] then
			wordobj.term = rsub(wordobj.term, "^(.*" .. V .. ")", "%1" .. DOTOVER)
		end
		if is_amb_to_join(words, i) then
			saw_amb_to_join = true
		end
	end

	-- Join [[amb]] before vowel-initial word with following word.
	if saw_amb_to_join then
		local new_words = {}
		local i = 1
		while i <= #words do
			if is_amb_to_join(words, i) then
				table.insert(new_words, {term = words[i].term .. "‿" .. words[i + 1].term, pos = words[i + 1].pos})
				i = i + 2
			else
				table.insert(new_words, words[i])
				i = i + 1
			end
		end
		words = new_words
	end

	-- Finally, rewrite some unstressed words to get the right pronunciation. Any remaining [[amb]] not before a
	-- vowel-initial word is pronounced [am] even in Valencian (where [amp]/[amb] would be expected), and [[per]] always
	-- has a pronounced <r>.
	local unstressed_word_replacement = {
		["a" .. DOTOVER .. "mb"] = "a" .. DOTOVER .. "m",
		["pe" .. DOTOVER .. "r"] = "pe" .. DOTOVER .. "rr",
	}

	for i, wordobj in ipairs(words) do
		wordobj.term = unstressed_word_replacement[wordobj.term] or wordobj.term
	end

	return words
end


local function fix_prefixes(word)
	-- Voiced s in prefix roots -fons-, -dins-, -trans-
	word = rsub(word, "^enfons([aàeèéiíoòóuú])", "enfonz%1")
	word = rsub(word, "^endins([aàeèéiíoòóuú])", "endinz%1")
	word = rsub(word, "tr([aà])ns([aàeèéiíoòóuúbdghlmv])", "tr%1nz%2")

	-- in + ex > ineks/inegz
	word = rsub(word, "^inex", "in.ex")

	return word
end

local function restore_diaereses(word)
	-- Some structural forms do not have diaeresis per diacritic savings, let's restore it to identify hiatus

	word = rsub(word, "([iu])um(s?)$", "%1üm%2") -- Latinisms (-ius is ambiguous but rare)

	word = rsub(word, "([aeiou])isme(s?)$", "%1ísme%2") -- suffix -isme
	word = rsub(word, "([aeiou])ist([ae]s?)$", "%1íst%2") -- suffix -ista

	word = rsub(word, "([aeou])ir$", "%1ír") -- verbs -ir
	word = rsub(word, "([aeou])int$", "%1ínt") -- present participle
	word = rsub(word, "([aeo])ir([éà])$", "%1ïr%2") -- future
	word = rsub(word, "([^gq]u)ir([éà])$", "%1ïr%2")
	word = rsub(word, "([aeo])iràs$", "%1ïràs")
	word = rsub(word, "([^gq]u)iràs$", "%1ïràs")
	word = rsub(word, "([aeo])ir(e[mu])$", "%1ïr%2")
	word = rsub(word, "([^gq]u)ir(e[mu])$", "%1ïr%2")
	word = rsub(word, "([aeo])iran$", "%1ïran")
	word = rsub(word, "([^gq]u)iran$", "%1ïran")
	word = rsub(word, "([aeo])iria$", "%1ïria") -- conditional
	word = rsub(word, "([^gq]u)iria$", "%1ïria")
	word = rsub(word, "([aeo])ir(ie[sn])$", "%1ïr%2")
	word = rsub(word, "([^gq]u)ir(ie[sn])$", "%1ïr%2")

	return word
end

local function fix_y(word)
	-- y > vowel i else consonant /j/, except ny

	word = rsub(word, "ny", "ñ")

	word = rsub(word, "y([^aeiouàèéêëíòóôúïü])", "i%1") -- vowel if not next to another vowel
	word = rsub(word, "([^aeiouàèéêëíòóôúïü·%-%.])y", "%1i") -- excluding also syllables separators

	return word
end

local function mid_vowel_fixes(word)
	local function track_mid_vowel(vowel, cont)
		require("Module:debug/track"){"ca-IPA/" .. vowel, "ca-IPA/" .. vowel .. "/" .. cont}
		return true
	end
	local changed
	-- final -el (not -ell) usually è but not too many cases
	word, changed = rsubb(word, "e(nts?)$", "é%1")
	if changed then
		track_mid_vowel("e", "nt-nts")
	end
	word, changed = rsubb(word, "e(rs?)$", "é%1")
	if changed then
		track_mid_vowel("e", "r-rs")
	end
	word, changed = rsubb(word, "o(rs?)$", "ó%1")
	if changed then
		track_mid_vowel("o", "r-rs")
	end
	word, changed = rsubb(word, "è(s?)$", "ê%1")
	if changed then
		track_mid_vowel("è", "s-blank")
	end
	word, changed = rsubb(word, "e(s[oe]s)$", "ê%1")
	if changed then
		track_mid_vowel("e", "sos-sa-ses")
	end
	word, changed = rsubb(word, "e(sa)$", "ê%1")
	if changed then
		track_mid_vowel("e", "sos-sa-ses")
	end
	return word
end

local function word_fixes(word, dialect)
	word = rsub(word, "%(rr%)", TEMP_PAREN_RR)
	word = rsub(word, "%(r%)", TEMP_PAREN_R)
	word = rsub(word, "%-([rs]?)", "-%1%1")
	if dialect == "val" then
		word = rsub(word, "%-x", "-tx")
	end
	word = rsub(word, "rç$", "rrs") -- silent r only in plurals -rs
	word = fix_prefixes(word) -- internal pause after a prefix
	word = restore_diaereses(word) -- no diaeresis saving
	word = fix_y(word) -- ny > ñ else y > i vowel or consonant
	word = mid_vowel_fixes(word)
	-- all words in pn- (e.g. [[pneumotòrax]] and mn- (e.g. [[mnemònic]]) have silent p/m in both Central and Valencian
	word = rsub(word, "^[pm]n", "n")
	-- Respell ch + vowel as tx, before we remove other h's after consonants.
	word = rsub(word, "ch(" .. V ..")", "tx%1")
	-- Delete h after a consonant. This must happen here, before split_syllables(). We don't delete h after a vowel
	-- yet because it indicates a hiatus.
	word = rsub(word, "(" .. C .. ")h", "%1")

	return word
end

local function split_vowels(vowels, saw_dotover, saw_lineunder)
	local syllables = {{onset = "", vowel = usub(vowels, 1, 1), coda = "", separator = "", has_dotover = saw_dotover,
		has_lineunder = saw_lineunder}}
	vowels = usub(vowels, 2)

	while vowels ~= "" do
		local syll = {onset = "", vowel = "", coda = ""}
		syll.onset, syll.vowel, vowels = rmatch(vowels, "^([iu]?)(.)(.-)$")
		table.insert(syllables, syll)
	end

	local count = #syllables

	if count >= 2 and (syllables[count].vowel == "i" or syllables[count].vowel == "u") then
		syllables[count - 1].coda = syllables[count].vowel
		syllables[count] = nil
	end

	return syllables
end

-- Split the word into syllables. Return a list of syllable objects, each of which contains fields `onset`, `vowel`,
-- `coda`, `separator` (a user-specified syllable divider that goes before the syllable; one of '·', '-' or '.') and
-- `stressed` (a boolean indicating that the syllable is stressed). In addition, the list has fields `stress` (the
-- index of the syllable with primary stress) and `is_prefix` (true if the word is a prefix, i.e. it ends in '-').
-- Normally, prefixes are treated as unstressed if a stressed syllable isn't explicitly marked, but this can be
-- overridden with `stress_prefixes`, which causes the automatic stress-assignment algorithm to run for these terms.
local function split_syllables(word, stress_prefixes, may_be_uppercase)
	local syllables = {}
	local saw_dotover = false

	local remainder = word
	local is_prefix = false
	if remainder:find("%-$") then -- prefix
		is_prefix = true
		remainder = remainder:gsub("%-$", "")
	end
	local is_suffix = false
	if remainder:find("^%-") then -- suffix
		is_suffix = true
		remainder = remainder:gsub("^%-", "")
	end

	while remainder ~= "" do
		local consonants, vowels

		-- FIXME: Using C and V below instead of the existing patterns slows things down TREMENDOUSLY.
		-- Not sure why.
		local vowel_list = may_be_uppercase and "aeiouàèéêëíòóôúïüAEIOUÀÈÉÊËÍÒÓÔÚÏÜ" .. DOTOVER .. LINEUNDER or
			"aeiouàèéêëíòóôúïü" .. DOTOVER .. LINEUNDER
		consonants, remainder = rmatch(remainder, "^([^" .. vowel_list .. "]*)(.-)$")
		vowels, remainder = rmatch(remainder, "^([" .. vowel_list .. "]*)(.-)$")
		local this_saw_dotover = not not rfind(vowels, DOTOVER)
		if this_saw_dotover then
			saw_dotover = true
			vowels = vowels:gsub(DOTOVER, "")
		end
		local this_saw_lineunder = not not rfind(vowels, LINEUNDER)
		if this_saw_lineunder then
			vowels = vowels:gsub(LINEUNDER, "")
		end

		if vowels == "" then
			if #syllables > 0 then
				syllables[#syllables].coda = syllables[#syllables].coda .. consonants
			else
				-- word without vowels, e.g. foot boundary |
				table.insert(syllables, {onset = consonants, vowel = "", coda = "", separator = ""})
			end
		else
			local onset = consonants
			local first_vowel = usub(vowels, 1, 1)

			if (rfind(onset, "[gqGQ]$") and (first_vowel == "ü" or (first_vowel == "u" and vowels ~= "u")))
			or ((onset == "" or onset == "h" or onset == "H") and #syllables == 0 and
				(first_vowel == "i" or first_vowel == "I") and (vowels ~= "i" and vowels ~= "I"))
			then
				onset = onset .. usub(vowels, 1, 1)
				vowels = usub(vowels, 2)
			end

			local vsyllables = split_vowels(vowels, this_saw_dotover, this_saw_lineunder)
			vsyllables[1].onset = onset .. vsyllables[1].onset

			for _, s in ipairs(vsyllables) do
				table.insert(syllables, s)
			end
		end
	end

	-- Shift over consonants from the onset to the preceding coda, until the syllable onset is valid
	for i = 2, #syllables do
		local current = syllables[i]
		local previous = syllables[i-1]

		while not (current.onset == "" or valid_onsets[rsub(rsub(current.onset, tie_c .. "[hH]?$", ""), "_", "")]) do
			local letter = usub(current.onset, 1, 1)
			current.onset = usub(current.onset, 2)
			if rfind(letter, "[·%-%.]") then -- syllable separators
				current.separator = letter
				break
			else
				previous.coda = previous.coda .. letter
				if rfind(letter, tie_c) then
					break
				end
			end
		end
	end

	-- Detect stress
	for i, syll in ipairs(syllables) do
		if rfind(syll.vowel, "^[" .. written_stressed_vowel_l .. "]$") then
			syll.stressed = true
			-- primary stress: the last one stressed without LINEUNDER
			if not syll.has_lineunder then
				syllables.stress = i
			end
		end
	end

	-- Assign default stress
	if not syllables.stress and not saw_dotover and (stress_prefixes or not is_prefix) then
		local count = #syllables

		if count == 1 then
			if syllables[1].vowel ~= "" then -- vowel-less words don't get stress
				syllables.stress = 1
			end
		else
			local final = syllables[count]

			-- Take account of tie symbols (apostrophes and ‿).
			if rfind(final.coda, "^[s" .. tie_l .. "]*$") or (rfind(final.coda, "^" .. tie_c .. "*n" .. tie_c .. "*$") and (
				final.vowel == "e" or final.vowel == "i" or final.vowel == "ï")) then
				syllables.stress = count - 1
			else
				syllables.stress = count
			end
		end
		if syllables.stress then
			syllables[syllables.stress].stressed = true
		end
	end

	syllables.is_prefix = is_prefix
	syllables.is_suffix = is_suffix
	return syllables
end

local function reconstitute_word_from_syllables(syllables)
	local parts = {}
	local function ins(txt)
		table.insert(parts, txt)
	end
	if syllables.is_suffix then
		ins("-")
	end
	for _, syl in ipairs(syllables) do
		ins(syl.separator)
		ins(syl.onset)
		ins(syl.vowel)
		if syl.has_dotover then
			ins(DOTOVER)
		end
		if syl.has_lineunder then
			ins(LINEUNDER)
		end
		ins(syl.coda)
	end
	if syllables.is_prefix then
		ins("-")
	end
	return table.concat(parts)
end

local function decompose_respelling(text)
	local dotover_keys = concat_keys(decompose_dotover)
	return rsub(text, "[" .. dotover_keys .. "]", decompose_dotover)
end

local function canon_respelling(text)
	local function canon_spaces(text)
		text = rsub(text, "%s+", " ")
		text = rsub(text, "^ ", "")
		text = rsub(text, " $", "")
		return text
	end

	text = canon_spaces(text)
	-- eliminate upside down punctuation
	text = rsub(text, "[¡¿]", "")
	-- eliminate utterance-final punctuation
	text = rsub(text, "[!?.]$", "")
	-- eliminate double and triple quotes
	text = rsub(text, "''+", "")
	-- Convert commas and em/en dashes to IPA foot boundaries; require a space after commas and en dashes (for the
	-- latter, in particular, to avoid treating the en dash in 'Bose–Einstein condensate' as a foot boundary.
	text = rsub(text, " *[,–] ", " | ")
	text = rsub(text, " *[—] *", " | ")
	-- ... in phrases like [[com es diu...en català]] and [[necessito ...]] become foot boundaries
	text = rsub(text, " *%.%.%. *", " | ")
	-- remaining commas and en dashes become spaces
	text = rsub(text, "[,–]", " ")
	-- may need to eliminate extraneous spaces again, e.g. if there was a space before or after an eliminated
	-- punctuation mark
	text = canon_spaces(text)
	-- question mark or exclamation point in the middle of a sentence -> IPA foot boundary
	text = rsub(text, "([^ ]) *[!?] *([^ ])", "%1 | %2")
	return text
end


local IPA_vowels_central = {
	["ê"] = "ɛ", ["ë"] = "ɛ", ["ô"] = "ɔ",
}
local IPA_vowels_balearic = {
	["ê"] = "ə", ["ë"] = "ɛ", ["ô"] = "ɔ",
}
local IPA_vowels_valencian = {
	["ê"] = "e", ["ë"] = "e", ["ô"] = "o",
}

local IPA_vowels = {
	["à"] = "a",
	["è"] = "ɛ", ["ê"] = "ɛ", ["ë"] = "ɛ", ["é"] = "e",
	["í"] = "i", ["ï"] = "i",
	["ò"] = "ɔ", ["ô"] = "ɔ", ["ó"] = "o",
	["ú"] = "u", ["ü"] = "u",
}

local function replace_context_free(cons)
	cons = rsub(cons, "ŀ", "l")

	cons = rsub(cons, "r", "ɾ")
	cons = rsub(cons, "ɾɾ", "r")
	cons = rsub(cons, "ss", "s")
	cons = rsub(cons, "ll", "ʎ")
	cons = rsub(cons, "ñ", "ɲ") -- hint ny > ñ

	-- NOTE: We use single-character affricate symbols during processing for ease in handling, and convert them
	-- to tied multi-character affricates at the end of join_syllables().
	cons = rsub(cons, "[dt]j", "ʤ")
	cons = rsub(cons, "tx", "ʧ")
	cons = rsub(cons, "[dt]z", "ʣ")

	cons = rsub(cons, "ç", "s")
	cons = rsub(cons, "[cq]", "k")
	cons = rsub(cons, "h", "")
	cons = rsub(cons, "j", "ʒ")
	-- Don't replace x -> ʃ yet so we can distinguish x from manually specified ʃ.

	cons = rsub(cons, "i", "j") -- must be after j > ʒ
	cons = rsub(cons, "y", "j") -- must be after j > ʒ and fix_y
	cons = rsub(cons, "[uü]", "w")
	cons = rsub(cons, "'", "‿")

	return cons
end


-- Do context-sensitive phonological changes. Formerly this was all done syllable-by-syllable but that made the code
-- tricky (since it often had to look at adjacent syllables) and full of subtle bugs. Now we first concatenate the
-- syllables back to words and the words to the combined text and work on the text as a whole. FIXME: We should move
-- more of the work done in preprocess_word(), e.g. most of replace_context_free(), here.
local function postprocess_general(text, dialect)
	local function verify(cond, msg)
		if not cond then
			error(("Internal error: %s; processed respelling at this point is '%s'"):format(msg, text))
		end
		return true
	end

	local voiced = listToSet {"b", "d", "g", "m", "n", "ɲ", "l", "ʎ", "r", "ɾ", "v", "z", "ʒ", "ʣ", "ʤ"}
	local voiced_keys = concat_keys(voiced)
	local voiceless = listToSet {"p", "t", "k", "f", "s", "ʃ", "ʦ", "ʧ"}
	local voiceless_keys = concat_keys(voiceless)
	local voicing = {["p"] = "b", ["t"] = "d", ["k"] = "g", ["f"] = "v", ["s"] = "z", ["ʃ"] = "ʒ", ["ʦ"] = "ʤ",
		["ʧ"] = "ʤ"}
	local voicing_keys = concat_keys(voicing)
	local devoicing = {}
	for k, v in pairs(voicing) do
		devoicing[v] = k
	end
	local devoicing_keys = concat_keys(devoicing)

	------------------ Handle <x>

	-- Handle ex- + vowel > -egz-. We handle -x- on either side of the syllable boundary. Note that this also handles
	-- inex- + vowel because in fix_prefixes we respell inex- as in.ex-, which ends up at this stage as in.e.xV.
	text = rsub_repeatedly(text, "([.#][eɛ]" .. stress_c .. "*)(" .. charsep_c .. "*)x(" .. charsep_c .. "*" .. V ..
		")", function(e, pre, post)
			-- Preserve other character separators (especially the tie character ‿).
			pre = pre:gsub("%.", "")
			post = post:gsub("%.", "")
			return e .. pre .. "g.z" .. post
		end)
	-- -x- at the beginning of a coda becomes [ks], e.g. [[annex]], [[apèndix]], [[extracció]]; but not elsewhere in
	-- the coda, e.g. in [[romanx]], [[ponx]]; words with [ks] in -nx such as [[esfinx]], [[linx]], [[manx]] need
	-- respelling with [ks]; words ending in vowel + x like [[ídix]] need respelling with [ʃ]
	text = rsub(text, "(" .. V .. stress_c .. "*)x", "%1ks")
	if dialect == "val" then
		-- Word-initial <x> as well as <x> after a consonant other than /j/ (including in the coda, e.g. [[ponx]])
		-- becomes [t͡ʃ].
		text = rsub(text, "#x", "#ʧ")
		text = rsub(text, "([^" .. vowel_l .. separator_l .. "j]" .. charsep_c .. "*)x", "%1ʧ")
	end
	-- Other x becomes [ʃ]
	text = rsub(text, "x", "ʃ")

	-- Doubled ss -> s e.g. in exs-, exc(e/i)-, sc(e/i)-; FIXME: should this apply across word boundaries?
	text = rsub(text, "s(" .. charsep_c .. "*)s", "%1s")

	------------------ Coda consonant losses

	-- In Central Catalan, coda losses happen everywhere, but otherwise they don't happen when
	-- absolutely word-finally before a vowel or end of utterance (e.g. [[blanc]] has /k/ in Balearic and
	-- Valencian but not [[blancs]]). Must precede consonant assimilations.
	local boundary = dialect == "cen" and "(.)" or "([^#])"
	text = rsub(text, "m[pb]" .. boundary, "m%1")
	text = rsub(text, "([ln])[td]" .. boundary, "%1%2")
	text = rsub(text, "[nŋ][kg]" .. boundary, "ŋ%1")
	if dialect == "val" or dialect == "bal" then
		local before_cons = "(" .. separator_c .. "*" .. C .. ")"
		text = rsub(text, "m[pb]" .. before_cons, "m%1")
		text = rsub(text, "([ln])[td]" .. before_cons, "%1%2")
		text = rsub(text, "[nŋ][kg]" .. before_cons, "ŋ%1")
	end

	-- Delete /t/ between /s/ and any consonant other than /s/ or /ɾ/. Must precede voicing assimilation and
	-- t + lateral/nasal assimilation.
	text = rsub(text, "st(" .. sylsep_c .. "*[^" .. neg_guts_of_cons .. "sɾ])", "s%1")
	
	------------------ Consonant assimilations

	if dialect == "cen" then
		-- v > b in onsets (not in codas, e.g. [[ovni]] [ɔ́vni] and [[hafni]] [ávni]). This needs to precede
		-- assimilation of nb -> mb.
		text = rsub(text, "v(" .. C .. "*" .. V ..")", "b%1")
	end

	-- t + lateral assimilation -> geminate across syllable boundary. We don't any more do t + nasal assimiation
	-- because there are too many exceptions, e.g. [[aritmètic]], [[atmosfèric]], [[ètnia]]. Instead, we require that
	-- cases where it does happen use respelling to effect this. FIXME: this doesn't always happen in -tl- either,
	-- e.g. [[atlàntic]] has [əllántik] in GDLC but [adlántik] in DNV.
	--
	-- FIXME: Clean this up, maybe move below voicing assimilation, investigate whether it operates across words,
	-- move stuff below that special-cases tll in Valencian here.
	text = rsub(text, "t(" .. sylsep_c .. ")([lʎ])", "%2%1%2")

	-- n + labial > labialized assimilation
	text = rsub(text, "n(" .. separator_c .. "*[mbp])", "m%1")
	text = rsub(text, "n(" .. separator_c .. "*[fv])", "ɱ%1")

	-- n + velar > velarized assimilation
	text = rsub(text, "n(" .. separator_c .. "*[kg])", "ŋ%1")

	-- l/n + palatal > palatalized assimilation
	text = rsub(text, "([ln])(" .. separator_c .. "*[ʎɲʃʒʧʤ])", function(ln, palatal)
		ln = ({["l"] = "ʎ", ["n"] = "ɲ"})[ln]
		return ln .. palatal
	end)

	-- ɲs > ɲʃ; FIXME: not sure the purpose of this; it doesn't apply in [[menys]] or derived terms like [[menyspreu]]
	-- NOTE: Per [https://giec.iec.cat/textgramatica/codi/4.4], it does apply in these scenarios but the result is
	-- somewhere between [s] and [ʃ], which is why it isn't shown in GDLC.
	-- text = rsub(text, "ɲs", "%1ʃ")

	------------------ Handle <r>

	-- In replace_context_free(), we converted single r to ɾ and double rr to r.
	if dialect == "cen" then
		text = rsub(text, TEMP_PAREN_R, "")
		text = rsub(text, TEMP_PAREN_RR, "r")
	elseif dialect == "bal" then
		text = rsub(text, TEMP_PAREN_R, "")
		text = rsub(text, TEMP_PAREN_RR, "")
	else
		verify(dialect == "val", ("Unrecognized dialect '%s'"):format(dialect))
		text = rsub(text, TEMP_PAREN_R, "ɾ")
		text = rsub(text, TEMP_PAREN_RR, "ɾ")
	end
	if dialect ~= "val" then
		-- Coda /ɾ/ -> /r/
		-- FIXME: This is inherited from the older code. Correct?
		text = rsub(text, "(" .. V .. stress_c .. "*" .. C .. "*)ɾ", "%1r")
	end		

	-- ɾ -> r word-initially or after [lns]; needs to precede voicing assimilation as <s> will be voiced to [z] before
	-- /ɾ/.
	text = rsub(text, "([#lns]" .. sylsep_c .. "*)ɾ", "%1r")

	------------------ Voicing assimilation

	-- Voicing or devoicing; we want to proceed from right to left, and due to the limitations of patterns (in
	-- particular, the lack of support for alternations), it's difficult to do this cleanly using Lua patterns, so we
	-- do it character by character.
	local chars = split_into_chars(text)
	-- We need to look two characters ahead in some cases, so start two characters from the end. This is safe because
	-- the overall respelling ends in "##". (Similarly, as an optimization, don't check the first two characters, which
	-- are always "##".)
	for i = #chars - 2, 3, -1 do
		-- We are looking for two consonants next to each other, possibly separated by a syllable or word divider.
		-- We also handle a consonant followed by a syllable divider then a vowel, and a consonant word-finally.
		-- Note that only coda consonants can change voicing, so we need to check to make sure we're in the coda.
		local first = chars[i]
		-- If `second` is nil, no assimilation occurs. Otherwise, `second` should be a consonant or empty string (which
		-- represents a syllable or word boundary followed by a vowel or end of string), and we assimilate to that
		-- consonant (empty string forces devoicing).
		local second
		-- If set to true, we're processing a consonant directly before a word boundary followed by a word beginning
		-- with a vowel. In this context, voiceless sibilants voice. Note that we handle voicing of <s> word-internally
		-- separately, in preprocess_word() [FIXME: maybe move much of the processing in preprocess_word() into this
		-- function].
		local word_boundary_before_vowel
		if not rfind(first, C) then
			-- leave `second` at nil; no assimilation
		elseif chars[i + 1] == "#" then -- word boundary
			if chars[i + 2] == " " then
				-- chars[i + 3] should always be "#"
				verify(chars[i + 3] == "#", "Word boundary followed by space but not #")
				if rfind(chars[i + 4], C) then
					second = chars[i + 4]
				else
					second = ""
					word_boundary_before_vowel = true
				end
			else
				second = ""
			end
		elseif rfind(chars[i + 1], sylsep_c) then -- syllable boundary
			if rfind(chars[i + 2], C) then
				second = chars[i + 2]
			else
				second = ""
			end
		elseif rfind(chars[i + 1], C) then
			second = chars[i + 1]
		else
			-- followed by a vowel not across a syllable or word boundary; leave `second` as nil, no assimilation
		end
		if second then
			-- Make sure we're in the coda. We have to look backwards until we find a vowel or syllable/word boundary.
			local in_coda = false
			local j = i - 1
			while true do
				verify(j > 0, "Missing word boundary at beginning of overall respelling")
				if rfind(chars[j], "[" .. sylsep_l .. wordsep_l .. "]") then
					break
				elseif rfind(chars[j], V) then
					in_coda = true
					break
				end
				j = j - 1
			end
			if in_coda then
				if word_boundary_before_vowel and rfind(first, "[zʒʣʤ]") then
					-- leave alone
				elseif voiced[second] and voicing[first] or word_boundary_before_vowel and rfind(first, "[sʃʦʧ]") then
					chars[i] = voicing[first]
				elseif (voiceless[second] or second == "") and devoicing[first] then
					chars[i] = devoicing[first]
				end
			end
		end
	end
	text = table.concat(chars)

	-- gn -> ŋn e.g. [[regnar]] (including word-initial gn- e.g. [[gnòmic]], [[gneis]]) 
	-- FIXME: This should be moved below voicing assimilation, and we need to investigate if it operates across words
	-- (here I'm guessing yes).
	if dialect ~= "cen" then
		text = rsub(text, "#gn", "#n")
	end
	text = rsub(text, "g(" .. separator_c .. "*n)", "ŋ%1")

	-- gʒ > d͡ʒ
	-- FIXME: We need to investigate if it operates across words
	text = rsub(text, "g(" .. sylsep_c .. "*)ʒ", "%1ʤ")
	-- sʃ -> ʃ ([[desxifrar]]), zʒ -> ʒ ([[disjuntor]])
	if dialect ~= "val" then
		text = rsub(text, "s(" .. separator_c .. "*ʃ)", "%1")
		text = rsub(text, "z(" .. separator_c .. "*ʒ)", "%1")
	end

	------------------ Gemination of <bl>, <gl>

	if dialect ~= "val" then
		-- bl -> bbl, gl -> ggl after the stress when following a vowel; to avoid this, use <b_l> or <g_l>.
		-- This must follow v > b above. To force a hard ungeminated [b] or [g], use <_b> or <_g>.
		text = rsub(text, "(" .. stress_c .. ")(" .. sylsep_c .. ")([bg])l", "%1%3%2%3l")
	else -- Valencian; undo manually written 'bbl', 'ggl' in words like [[poblar]], [[reglament]]
		text = rsub(text, "([bg])(" .. sylsep_c .. ")%1l", "%2%1l")
	end

	------------------ Lenition of voiced stops

	-- In Central Catalan, b/d/g become fricatives (actually approximants, like in Spanish) in the onset following a
	-- vowel and (except for <d>) after <l> and <ll> (cf. GDLC [[cabellblanc]] [kəβɛ̀ʎβláŋ]). This also happens across
	-- word boundaries but doesn't happen after stops, nor in Central Catalan after [r], [ɾ] or [z] (and hence probably
	-- not after [ʒ] either, although I can't find any examples in GDLC).
	--
	-- In Valencian, <b> doesn't lenite (at least formally?), but <d> and <g> do lenite after [r], [ɾ] or [z].
	--
	-- Balearic is like Valencian in not leniting <b>, and probably like Central Catalan otherwise.
	local lenite_bdg = {["b"] = "β", ["d"] = "ð", ["g"] = "ɣ"}
	if dialect == "cen" then
		text = rsub(text, "([" .. vowel_l .. "jwlʎv]" .. separator_c .. "*[.#]" .. separator_c .. "*)([bdg])",
			function(before, bdg) return before .. lenite_bdg[bdg] end)
	elseif dialect == "val" then
		text = rsub(text, "([" .. vowel_l .. "jwlʎvrɾzʣ]" .. separator_c .. "*[.#]" .. separator_c .. "*)([dg])",
			function(before, dg) return before .. lenite_bdg[dg] end)
	else
		verify(dialect == "bal", ("Unrecognized dialect '%s'"):format(dialect))
		text = rsub(text, "([" .. vowel_l .. "jwlʎv]" .. separator_c .. "*[.#]" .. separator_c .. "*)([dg])",
			function(before, dg) return before .. lenite_bdg[dg] end)
	end

	------------------ Vowel reduction

	-- Reduction of unstressed a,e in Central and Balearic (Eastern Catalan).
	if dialect ~= "val" then
		-- The following rules seem to apply, based on the old code:
		-- (1) Stressed a and e are never reduced.
		-- (2) Unstressed e directly following ə is not reduced.
		-- (3) Unstressed e directly before written <a> or before /ɔ/ is not reduced.
		-- (4) Written <ee> when both vowels precede the primary stress is reduced to [əə]. (This rule preempts #2.)
		-- (5) Written <ee> when both vowels follow the primary stress isn't reduced at all.
		-- Rule #2 in particular seems to require that we proceed left to right, which is how the old code was
		-- implemented.
		-- FIXME: These rules seem overly complex and may produce incorrect results in some circumstances.
		local words = rsplit(text, " ")
		for j, word in ipairs(words) do
			local chars = split_into_chars(word)
			-- See above where voicing assimilation is handled. The overall respelling begins and ends in #, which we
			-- can ignore. We need to look ahead three chars in some circumstances, but in all those circumstances we
			-- shoudn't run off the end (and have assertions to check this).
			local seen_primary_stress = false
			for i = 2, #chars - 1 do
				local this = chars[i]
				if chars[i] == AC then
					seen_primary_stress = true
				end
				if (this ~= "a" and this ~= "e") or rfind(chars[i + 1], stress_c) then
					-- Not a/e, or a stressed vowel; continue
				else
					local reduction = true
					local prev, prev_stress, nxt, nxt_stress
					if not rfind(chars[i - 1], sylsep_c) then
						prev = ""
					else
						prev = chars[i - 2] -- this should be non-nil as chars[i - 1] is a syllable separator (not #)
						verify(prev, "Missing # at word boundary")
						prev_stress = ""
						if rfind(prev, stress_c) then
							prev_stress = prev
							prev = chars[i - 3]
							-- As above; chars[i - 2] is a stress indicator (not #).
							verify(prev, "Missing # at word boundary")
						end
					end
					if not rfind(chars[i + 1], sylsep_c) then
						nxt = ""
						-- leave nxt at nil
					else
						nxt = chars[i + 2]
						nxt_stress = chars[i + 3]
						-- chars[i + 1] is a syllable separator, so chars[i + 2] should not be a word boundary, so
						-- chars[i + 3] should exist.
						verify(nxt and nxt_stress, "Syllable separator at word boundary or missing # at word boundary")
					end
					if this == "e" and rfind(prev, "ə") then
						reduction = false
					elseif this == "e" and rfind(nxt, "[aɔ]") then
						reduction = false
					elseif this == "e" and nxt == "e" and not rfind(nxt_stress, AC) then
						-- FIXME: Check specifically for AC duplicates previous logic but is probably wrong or unnecessary.
						if not seen_primary_stress then
							chars[i + 2] = "ə"
						else
							reduction = false
						end
					end
					if reduction then
						chars[i] = "ə"
					end
				end
			end
			words[j] = table.concat(chars)
		end
		text = table.concat(words, " ")
	end

	if dialect == "cen" then
		-- Reduction of unstressed o (not before w)
		text = rsub(text, "o([^" .. stress_l .. "w])", "u%1")
	elseif dialect == "bal" then
		-- Reduction of unstressed o per vowel harmony: unstressed /o/ -> /u/ directly before stressed /i/ or /u/;
		-- as a Lua pattern, o can be followed only by consonants and/or syllable separators (no vowels, stress marks
		-- or word separators).
		text = rsub(text, "o([^" .. vowel_l .. stress_l .. wordsep_l .. "]*[iu]" .. stress_c .. ")", "u%1")
	end

	-- Final losses.
	text = rsub(text, "j(ʧs?#)", "%1") -- boigs /bɔt͡ʃ/
	text = rsub(text, "([ʃʧs])s#", "%1#") -- homophone plurals -xs, -igs, -çs

	if dialect ~= "val" then
		-- Remove j before palatal obstruents
		text = rsub(text, "j(" .. sylsep_c .. "*[ʃʒʧʤ])", "%1")
	else -- Valencian
		-- Fortition of palatal fricatives
		text = rsub(text, "ʒ", "ʤ")
		text = rsub(text, "(i" .. stress_c .. "*" .. sylsep_c .. ")ʣ", "%1z")
	end

	if dialect ~= "cen" then
		-- No palatal gemination ʎʎ > ll or ʎ, in Valencian and Balearic.
		-- FIXME: These conditions seem to be targeting specific words and should probably be fixed using respelling
		-- instead.
		text = rsub(text, "([bpw]a" .. stress_c .. "*)ʎ(" .. sylsep_c .. "*)ʎ", "%1l%2l")
		text = rsub(text, "([mv]e" .. stress_c .. "*)ʎ(" .. sylsep_c .. "*)ʎ", "%1l%2l")
		text = rsub(text, "(ti" .. stress_c .. "*)ʎ(" .. sylsep_c .. "*)ʎ", "%1l%2l")
		text = rsub(text, "(m[oɔ]" .. stress_c .. "*)ʎ(" .. sylsep_c .. "*)ʎ", "%1l%2l")
		text = rsub(text, "(u" .. stress_c .. "*)ʎ(" .. sylsep_c .. "*)ʎ", "%1l%2l")
		text = rsub(text, "ʎ(" .. sylsep_c .. "*ʎ)", "%1")
	end

	---------- Convert pseudo-symbols to real ones.

	-- Convert g to IPA ɡ.
	text = rsub(text, "g", "ɡ")

	-- Convert pseudo-afficate symbols to full affricates.
	local full_affricates = { ["ʦ"] = "t͡s", ["ʣ"] = "d͡z", ["ʧ"] = "t͡ʃ", ["ʤ"] = "d͡ʒ" }
	text = rsub(text, "([ʦʣʧʤ])", full_affricates)

	---------- Generate IPA stress marks.

	-- Convert acute and grave to IPA stress marks.
	text = rsub(text, AC, "ˈ")
	text = rsub(text, GR, "ˌ")
	-- Move IPA stress marks to the beginning of the syllable.
	text = rsub_repeatedly(text, "([#.])([^#.]*)(" .. ipa_stress_c .. ")", "%1%3%2")
	-- Suppress syllable divider before IPA stress indicator.
	text = rsub(text, "%.(#?" .. ipa_stress_c .. ")", "%1")
	-- Make all primary stresses but the last one in a given word be secondary. May be fed by the first rule above.
	-- FIXME: Currently this is handled earlier, but we might want to move it here, as is done in [[Module:pt-pronunc]].
	-- text = rsub_repeatedly(text, "ˈ([^ ]+)ˈ", "ˌ%1ˈ")
	-- Make primary stresses in prefixes become secondary. (FIXME: Handled earlier now.)
	-- text = rsub_repeatedly(text, "ˈ([^#]*#" .. PREFIX_MARKER .. ")", "ˌ%1")

	-- Remove # symbols at word/text boundaries, as well as _ (which forces separate interpretation), pseudo-consonant
	-- markers (at edges of some prefixes/suffixes), and prefix markers, and recompose.
	text = rsub(text, "[#_" .. PSEUDOCONS .. "]", "")
	text = mw.ustring.toNFC(text)

	return text
end


local function preprocess_word(syllables, suffix_syllables, dialect, pos, orig_word)
	-- Stressed vowel is ambiguous
	if syllables.stress then
		local stressed_vowel = syllables[syllables.stress].vowel
		if rfind(stressed_vowel, "[eo]") then
			local marks = {["e"] = {AC, GR, CFLEX, DIA}, ["o"] = {AC, GR, CFLEX}}
			local marked_vowels = {}
			for _, mark in ipairs(marks[stressed_vowel]) do
				table.insert(marked_vowels, stressed_vowel .. mark)
			end

			error(("In respelling '%s', the stressed vowel '%s' is ambiguous. Please mark it with an acute, " ..
				"grave, or combined accent: %s."):format(orig_word, stressed_vowel,
				m_table.serialCommaJoin(marked_vowels, {dontTag = true, conj = "or"})))
		end
	end

	-- Final -r is ambiguous in many cases.
	local final = syllables[#syllables]
	-- Stressed final r after a or i in non-monosyllables is treated as (r), i.e. verbal infinitives are assumed (NOTE:
	-- not always the case, e.g. there are many adjectives and nouns in -ar that should be marked as '(rr)', and
	-- several loanword nouns in -ir that should be marked as 'rr'). Likewise for stressed final r or rs after é in
	-- non-monosyllables (which are usually adjectives or nouns with the -er ending, but may be verbal infinitives,
	-- which should be marked as 'ê(r)'). That is, it disappears other than in Valencian. All other final r and final
	-- rs are considered ambiguous and need to be rewritten using rr, (rr) or (r).
	if #syllables > 1 and final.stressed then
		if final.coda == "r" and rfind(final.vowel, "[aàiíé]") or final.coda == "rs" and final.vowel == "é" or
			final.vowel == "ó" and rfind(final.coda, "^rs?$") and rfind(final.onset, "[stdç]") then
			final.coda = TEMP_PAREN_R
		end
	end
	if rfind(final.coda, "^rs?$") or rfind(final.coda, "[^r]rs?$") then
		error(("In respelling '%s', final -r by itself or in -rs is ambiguous except in the verbal endings -ar or " ..
			"-ir, in the nominal or adjectival endings -er(s) and -[dtsç]or(s). In all other cases it needs to be " ..
			"rewritten using one of 'rr' (pronounced everywhere), '(rr)' (pronounced everywhere but Balearic) or " ..
			"'(r)' (pronounced only in Valencian). Note that adjectives in -ar usually need rewriting using '(rr)'; " ..
			"nouns in -ar referring to places should be rewritten using '(r)'; and loanword nouns in -ir usually " ..
			"need rewriting using 'rr'."):format(orig_word))
	end

	local syllables_IPA = {stress = syllables.stress, is_prefix = syllables.is_prefix, is_suffix = syllables.is_suffix}

	for key, val in ipairs(syllables) do
		syllables_IPA[key] = {onset = val.onset, vowel = val.vowel, coda = val.coda, stressed = val.stressed}
	end

	-- Replace letters with IPA equivalents
	for i, syll in ipairs(syllables_IPA) do
		-- Voicing of s
		if syll.onset == "s" and i > 1 and rfind(syllables[i - 1].coda, "^[iu]?$") then
			syll.onset = "z"
		end

		if rfind(syll.vowel, "^[eèéêëií]$") then
			syll.onset = rsub(syll.onset, "tg$", "ʤ")
			syll.onset = rsub(syll.onset, "[cg]$", {["c"] = "s", ["g"] = "ʒ"})
			syll.onset = rsub(syll.onset, "[qg]u$", {["qu"] = "k", ["gu"] = "g"})
		end

		syll.coda = rsub(syll.coda, "igs?$", "iʤ")

		syll.onset = replace_context_free(syll.onset)
		syll.coda = replace_context_free(syll.coda)

		syll.vowel = rsub(syll.vowel, ".",
			dialect == "cen" and IPA_vowels_central or
			dialect == "bal" and IPA_vowels_balearic or
			IPA_vowels_valencian
		)
		syll.vowel = rsub(syll.vowel, ".", IPA_vowels)
	end

	for _, suffix_syl in ipairs(suffix_syllables) do
		table.insert(syllables_IPA, suffix_syl)
	end

	return syllables_IPA
end


-- Given a single substitution spec, `to`, figure out the corresponding value of `from` used in a complete
-- substitution spec. `pagename` is the name of the page, either the actual one or taken from the `pagename` param.
-- `whole_word`, if set, indicates that the match must be to a whole word (it was preceded by ~).
local function convert_single_substitution_to_original(to, pagename, whole_word)
	-- Replace specially-handled characters with a class matching the character and possible replacements.
	local escaped_from = to
	-- Handling of '(rr)', '(r)', '.' and '-' needs to be done before calling pattern_escape(); otherwise they will be
	-- escaped.
	escaped_from = escaped_from:gsub("%(rr%)", "r")
	escaped_from = escaped_from:gsub("%(r%)", "r")
	escaped_from = escaped_from:gsub("ks", "x"):gsub("Ks", "X"):gsub("gz", "x"):gsub("([bg])%1l", "%1l"):gsub("[._]", "")
	escaped_from = require(strutil_module).pattern_escape(escaped_from)
	escaped_from = escaped_from:gsub("rr", "rr?")
	escaped_from = escaped_from:gsub("ss", "ss?")
	escaped_from = escaped_from:gsub("ʃ", "[xX]")
	-- This is tricky, because we already passed `escaped_from` through pattern_escape() causing a hyphen to get a
	-- % sign before it, and have to double up the percent signs to match and replace a literal %.
	escaped_from = escaped_from:gsub("%%%-", "%%-?")
	-- Tie sign (‿) should match against space, hyphen or nothing in the original.
	escaped_from = escaped_from:gsub("‿", "[ %%-]?")
	escaped_from = rsub(escaped_from, "[" .. written_accented_vowel_l .. "]",
		function(v) return "[" .. v .. written_accented_to_plain_vowel[v] .. "]" end)
	escaped_from = escaped_from:gsub(DOTOVER, DOTOVER .. "?"):gsub(LINEUNDER, LINEUNDER .. "?")
	escaped_from = "(" .. escaped_from .. ")"
	if whole_word then
		escaped_from = "%f[%a]" .. escaped_from .. "%f[%A]"
	end
	local match = rmatch(pagename, escaped_from)
	if match then
		if match == to then
			error(("Single substitution spec '%s' found in pagename '%s', replacement would have no effect"):
				format(to, pagename))
		end
		return match
	end
	error(("Single substitution spec '%s' couldn't be matched to pagename '%s'"):format(to, pagename))
end


local function apply_substitution_spec(respelling, pagename, pos, allow_mid_vowel_hints, parse_err)
	local subs = split_on_comma(rmatch(respelling, "^%[(.*)%]$"))
	respelling = pagename
	local mid_vowel_hint
	local regular_subs = {}
	for _, sub in ipairs(subs) do
		if rfind(sub, "^" .. export.mid_vowel_hint_c .. "$") then
			if mid_vowel_hint then
				parse_err(("Specified mid vowel hint twice, '%s' and '%s'"):format(
					mid_vowel_hint, sub))
			end
			mid_vowel_hint = sub
		else
			table.insert(regular_subs, sub)
		end
	end
	if mid_vowel_hint then
		if not allow_mid_vowel_hints then
			parse_err(("Mid vowel hint '%s' not allowed when apply one substitution spec to multiple words"):format(
				mid_vowel_hint))
		end
		local suffix = ""
		-- FIXME: This duplicates logic in to_IPA().
		if not pos or pos == "adverb" then
			local part_before_ment, ment = rmatch(respelling, "^(.*)(m[eé]nt)$")
			if part_before_ment and (pos == "adverb" or not rfind(part_before_ment, "[iï]$") and
				rfind(part_before_ment, V .. ".*" .. V)) then
				suffix = ment
				respelling = part_before_ment
			end
		end
		local syllables = split_syllables(respelling, "stress prefixes", "may be uppercase")
		local stressed_vowel = syllables[syllables.stress].vowel
		if stressed_vowel == mid_vowel_hint then
			-- do nothing
		elseif rfind(mid_vowel_hint, "[èéêë]") and rfind(stressed_vowel, "[eEèÈ]") or
			rfind(mid_vowel_hint, "[òóô]") and rfind(stressed_vowel, "[oO]") then
				syllables[syllables.stress].vowel = mid_vowel_hint
		else
			parse_err(("Stressed vowel '%s' not compatible with mid vowel hint '%s'"):format(
				stressed_vowel, mid_vowel_hint))
		end
		respelling = reconstitute_word_from_syllables(syllables) .. suffix
	end

	for _, sub in ipairs(regular_subs) do
		local from, escaped_from, to, escaped_to, whole_word
		if rfind(sub, "^~") then
			-- whole-word match
			sub = rmatch(sub, "^~(.*)$")
			whole_word = true
		end
		if sub:find(":") then
			from, to = rmatch(sub, "^(.-):(.*)$")
		else
			to = sub
			from = convert_single_substitution_to_original(to, pagename, whole_word)
		end
		if from then
			local strutil = require(strutil_module)
			escaped_from = strutil.pattern_escape(from)
			if whole_word then
				escaped_from = "%f[%a]" .. escaped_from .. "%f[%A]"
			end
			escaped_to = strutil.replacement_escape(to)
			local subbed_respelling, nsubs = rsubn(respelling, escaped_from, escaped_to)
			if nsubs == 0 then
				parse_err(("Substitution spec %s -> %s didn't match processed pagename '%s'"):format(
					from, to, respelling))
			elseif nsubs > 1 then
				parse_err(("Substitution spec %s -> %s matched multiple substrings in processed pagename '%s', add " ..
					"more context"):format(from, to, respelling))
			else
				respelling = subbed_respelling
			end
		end
	end

	return respelling
end


local canonicalize_pos = {
	n = "noun",
	noun = "noun",
	v = "verb",
	vb = "verb",
	verb = "verb",
	a = "adjective",
	adj = "adjective",
	adjective = "adjective",
	av = "adverb",
	adv = "adverb",
	adverb = "adverb",
	o = "other",
	other = "other",
}


local function parse_off_pos(respelling, parse_err)
	local pos, rest = respelling:match("^([a-z]+)/(.*)$")
	if pos then
		if not canonicalize_pos[pos] then
			local valid_pos = {}
			for vp, _ in pairs(canonicalize_pos) do
				table.insert(valid_pos, vp)
			end
			table.sort(valid_pos)
			parse_err(("Unrecognized part of speech '%s', should be one of %s"):format(pos,
				table.concat(valid_pos, ", ")))
		end
		pos = canonicalize_pos[pos]
		respelling = rest
		if respelling == "" then
			respelling = "+"
		end
	end
	return pos, respelling
end


-- Parse a respelling given by the user, allowing for '+' for pagename, mid vowel hints in place of a respelling and
-- substitution specs like '[ks]' or [val:vol,ê,ks]. In general, return an object {words = {WORD, WORD, ...}} where
-- WORD is of the form {term = PARSED_RESPELLING, pos = POS}. Other fields are set in special cases: If a raw respelling
-- was seen, the fields `raw_phonemic` and/or `raw_phonetic` are set; if '?' is seen, the field `unknown` is set; and if
-- '-' is seen, the field `omitted` is set.
local function parse_respelling(respelling, pagename, parse_err)
	if respelling == "?" then
		return {
			unknown = true
		}
	end
	if respelling == "-" then
		return {
			omitted = true
		}
	end
	local saw_raw
	local remaining_respelling = respelling:match("^raw:(.*)$")
	if remaining_respelling then
		saw_raw = true
		respelling = remaining_respelling
	end
	local raw_phonemic, raw_phonetic = respelling:match("^/(.*)/ %[(.*)%]$")
	if not raw_phonemic then
		raw_phonemic = respelling:match("^/(.*)/$")
	end
	if not raw_phonemic and saw_raw then
		raw_phonetic = respelling:match("^%[(.*)%]$")
	end
	if raw_phonemic or raw_phonetic then
		return {
			raw_phonemic = raw_phonemic,
			raw_phonetic = raw_phonetic,
		}
	end

	pagename = decompose_respelling(pagename)
	respelling = decompose_respelling(respelling)

	local function split_respelling_into_words(respelling, parse_pos)
		respelling = canon_respelling(respelling)
		local word_objs = {}
		local respelling_words = rsplit(respelling, " ")
		for _, word in ipairs(respelling_words) do
			local pos
			if parse_pos then
				pos, word = parse_off_pos(word, parse_err)
			end
			table.insert(word_objs, {term = word, pos = pos})
		end
		return {words = word_objs}
	end

	local function substitute_respelling_word(respelling_word, pagename_word)
		local pos
		pos, respelling_word = parse_off_pos(respelling_word, parse_err)
		if respelling_word == "+" then
			respelling_word = pagename_word
		else
			if rfind(respelling_word, "^" .. export.mid_vowel_hint_c .. "$") then
				respelling_word = "[" .. respelling_word .. "]"
			end
			if rfind(respelling_word, "^%[.*%]$") then
				respelling_word = apply_substitution_spec(respelling_word, pagename_word, pos,
					"allow mid vowel hint", parse_err)
			end
		end
		return {term = respelling_word, pos = pos}
	end

	-- At this point, if there are multiple words in the pagename, there are three syntaxes allowed: all-at-once,
	-- replacement or word-by-word. All-at-once syntax involves either a + representing the entire pagename, or a
	-- substitution spec that applies to all words in the pagename. This syntax cannot have a prefixed part of speech
	-- because it wouldn't be clear which word to apply the part of speech to. Replacement syntax simply spells out the
	-- respelling without any substitution specs or +'s (but possibly with parts of speech prefixed to individual
	-- words), and can have a different number of words than the pagename (essentially, the pagename is disregarded).
	-- Word-by-word syntax involves a combination of respelled words, per-word substitution specs and/or a +
	-- representing an individual word, and must have the same number of words as the pagename so that substitution
	-- specs and +'s can be lined up with words in the pagename. In all cases, the return value is in the same format;
	-- see comment at top of function.
	if pagename:find(" ") or respelling:find(" ") then
		if respelling == "+" then
			return split_respelling_into_words(pagename)
		elseif rfind(respelling, "^%[.*%]$") then
			-- all-at-once syntax with substitution spec
			return split_respelling_into_words(apply_substitution_spec(respelling, pagename, nil, false, parse_err))
		elseif rfind(respelling, "^([a-z]+)/$") or rfind(respelling, "^([a-z]+)/%[[^%[%]]*%]$") then
			-- attempt to include a part of speech in all-at-once syntax
			parse_err(("Part of speech not allowed when pagename is multiword and all-at-once syntax is used in " ..
				"the respelling, but saw '%s'"):format(respelling))
		elseif rfind(respelling, "^" .. export.mid_vowel_hint_c .. "$") then
			-- attempt to use a mid-vowel hint in all-at-once syntax
			parse_err(("Single mid-vowel hint not allowed when pagename is multiword because it's not clear which " ..
				"word to apply it to, but saw '%s'"):format(respelling))
		elseif rfind(respelling, "[+%[%]]") or rfind(respelling, "^" .. export.mid_vowel_hint_c .. " ") or
			rfind(respelling, " " .. export.mid_vowel_hint_c .. " ") or
			rfind(respelling, " " .. export.mid_vowel_hint_c .. "$") then
			-- word-by-word syntax
			local sub_with_space = rmatch(respelling, "%[[^%[%]]* [^%[%]]*%]")
			if sub_with_space then
				parse_err(("When using word-by-word syntax with a multiword pagename, saw substitution spec '%s' " ..
					"with spaces, which is not allowed because it must match a single word"):format(sub_with_space))
			end
			pagename = canon_respelling(pagename)
			respelling = canon_respelling(respelling)
			local pagename_words = rsplit(pagename, " ")
			local respelling_words = rsplit(respelling, " ")
			if #pagename_words ~= #respelling_words then
				parse_err(("When using word-by-word syntax with a multiword pagename, saw %s words in pagename but " ..
					"%s word%s in respelling; they need to match"):format(#pagename_words, #respelling_words,
						#respelling_words > 1 and "s" or ""))
			end
			local word_objs = {}
			for i = 1, #pagename_words do
				table.insert(word_objs, substitute_respelling_word(respelling_words[i], pagename_words[i]))
			end
			return {words = word_objs}
		else
			-- replacement syntax; pagename ignored
			return split_respelling_into_words(respelling, "parse pos")
		end
	else
		local word_obj = substitute_respelling_word(respelling, pagename)
		word_obj.term = canon_respelling(word_obj.term)
		return {words = {word_obj}}
	end
end


-- Parse a list of comma-split runs containing one or more respellings, i.e. after calling parse_balanced_segment_run()
-- or the like followed by split_alternating_runs() or the like (see [[Module:parse utilities]]). `pagename` is the
-- pagename, for use when a respelling is just '+', a mid-vowel hint like 'ê' or a substitution spec like '[ks]'.
-- `original_input` is the raw input and `input_param` the name of the param containing the raw input; both are used
-- only in error messages. Return an object specifying the respellings, currently with a single field 'terms' (this
-- format is used in case other outer properties exist in the future), where 'terms' is a list of term objects. Each
-- term object contains either a field `term` with the respelling and an optional part of speech `pos`, or fields
-- `raw_phonemic` and/or `raw_phonetic` (if the user specified raw IPA using "/.../" or "/.../ [...]" or "raw:[...]"),
-- `unknown` (if the user specified "?"), or `omitted` (if the user specified "-"). In addition, there may be fields
-- `q`, `qq`, `a`, `aa`, and/or `ref` corresponding to inline modifiers. Each such field is a list; all are lists of
-- strings except for `ref`, which is a list of objects as returned by parse_references() in [[Module:references]].
function export.parse_comma_separated_groups(comma_separated_groups, pagename, original_input, input_param)
	local function generate_obj(respelling, parse_err)
		return parse_respelling(respelling, pagename == true and respelling or pagename, parse_err)
	end
	local put = require(parse_utilities_module)

	local outer_container = {terms = {}}
	for _, group in ipairs(comma_separated_groups) do
		-- Rejoin runs that don't involve <...>.
		local j = 2
		while j <= #group do
			if not group[j]:find("^<.*>$") then
				group[j - 1] = group[j - 1] .. group[j] .. group[j + 1]
				table.remove(group, j)
				table.remove(group, j)
			else
				j = j + 2
			end
		end

		local param_mods = {
			-- pre = { overall = true },
			-- post = { overall = true },
			ref = { store = "insert", convert = function(arg, parse_err)
				return require("Module:references").parse_references(arg)
			end },
			q = { store = "insert" },
			qq = { store = "insert" },
			a = { store = "insert" },
			aa = { store = "insert" },
		}

		table.insert(outer_container.terms, put.parse_inline_modifiers_from_segments {
			group = group,
			arg = original_input,
			props = {
				paramname = input_param,
				param_mods = param_mods,
				generate_obj = generate_obj,
				splitchar = ",",
				outer_container = outer_container,
			},
		})
	end

	return outer_container
end


-- Generate the pronunciation of `words` (a list of word objects representing respellings, each of which is an object
-- of the form {term = RESPELLING, pos = PART_OF_SPEECH} in `dialect` ("cen", "bal" or "val").
local function to_IPA(words, dialect)
	local pronuns = {}

	for _, wordobj in ipairs(words) do
		if rfind(wordobj.term, "[áìùÁÌÙ]") then
			error(("Invalid accented character in respelling '%s'; use accented à í ú, not the reversed versions"
				):format(wordobj.term))
		end
	end
	
	words = handle_unstressed_words(words)
	
	for _, wordobj in ipairs(words) do
		local word = wordobj.term
		local pos = wordobj.pos
		local suffix_syllables = {}
		local orig_word = word

		word = ulower(word)
		if not pos or pos == "adverb" then
			local word_before_ment, ment = rmatch(word, "^(.*)(m[eé]nt)$")
			if word_before_ment and (pos == "adverb" or not rfind(word_before_ment, "[iï]$") and
				rfind(word_before_ment, V .. ".*" .. V)) then
				suffix_syllables = {{onset = "m", vowel = "e", coda = "nt", stressed = true}}
				pos = "adjective"
				word = word_before_ment
			end
		end

		word = word_fixes(word, dialect)
		local syllables = split_syllables(word)
		syllables = preprocess_word(syllables, suffix_syllables, dialect, pos, orig_word)
		-- Combine syllables.
		local combined = {}
		local has_ment = #suffix_syllables > 0
		for i, syll in ipairs(syllables) do
			local ac = (i == syllables.stress and not syllables.is_prefix and not has_ment or
				has_ment and i == #syllables) and AC or -- primary stress
				syllables[i].stressed and GR or -- secondary stress
				""
			table.insert(combined, syll.onset .. syll.vowel .. ac .. syll.coda)
		end
		table.insert(pronuns, table.concat(combined, "."))
	end

	-- Put double ## at utterance boundaries (beginning/end of string) and at foot boundaries (marked with |).
	-- Note that if the string without pound signs is 'foo bar baz | bat quux', the final string will be
	-- '##foo# #bar# #baz## #|# ##bat# #quux##'.
	local text = "##" .. table.concat(pronuns, " ") .. "##"
	text = rsub(text, " | ", "# | #")
	text = rsub(text, " ", "# #")
	return postprocess_general(text, dialect)
end


-- Generate the phonemic and phonetic pronunciations of the respellings in `parsed_respellings`, which is a table whose
-- keys are dialect identifiers (e.g. "cen" for Central Catalan, "val" for Valencian) and whose values are objects of
-- the format returned by parse_comma_separated_groups() (see comment above that function). This destructively modifies
-- `parsed_respellings`, adding fields `phonemic` and `phonetic` containing the generated pronunciations and removing
-- the input fields used to generate those output fields. (FIXME: Currently only phonetic pronunciation is generated.)
function export.generate_phonemic_phonetic(parsed_respellings)
	-- Convert each canonicalized respelling to phonemic/phonetic IPA.
	for dialect, respelling_spec in pairs(parsed_respellings) do
		for _, termobj in ipairs(respelling_spec.terms) do
			if termobj.unknown or termobj.omitted then
				-- leave alone, will handle later
			elseif termobj.raw_phonemic or termobj.raw_phonetic then
				termobj.phonemic = termobj.raw_phonemic
				termobj.phonetic = termobj.raw_phonetic
				-- set to nil so by-value comparisons respect only the resulting phonemic/phonetic and qualifiers
				termobj.raw_phonemic = nil
				termobj.raw_phonetic = nil
			else
				termobj.phonetic = to_IPA(termobj.words, dialect)
				-- set to nil so by-value comparisons respect only the resulting phonemic/phonetic and qualifiers
				termobj.words = nil
			end
		end
	end
end


-- Group pronunciations by dialect, i.e. grouping pronunciations that are identical in every way (including both the
-- pronunciation(s) and any qualifiers and other inline modifiers). `parsed_respellings` contains the output from
-- generate_phonemic_phonetic(), and the return value is a list of grouped pronunciations, where each object in the list
-- contains fields `dialects` (a list of dialects containing the pronunciations) and `pronuns` (a list of
-- pronunciations, where each pronunciation is specified by an object containing fields `phonemic` and `phonetic`, as
-- generated by generate_phonemic_phonetic(), along with any inline modifier fields `q`, `qq`, `a`, `aa` and/or `ref`).
function export.group_pronuns_by_dialect(parsed_respellings)
	local grouped_pronuns = {}
	for dialect, pronun_spec in pairs(parsed_respellings) do
		local saw_omitted = false
		for _, termobj in ipairs(pronun_spec.terms) do
			if termobj.omitted then
				saw_omitted = true
				break
			end
		end
		if not saw_omitted then
			local saw_existing = false
			for _, group in ipairs(grouped_pronuns) do
				if m_table.deepEquals(group.pronuns, pronun_spec.terms) then
					table.insert(group.dialects, dialect)
					saw_existing = true
					break
				end
			end
			if not saw_existing then
				table.insert(grouped_pronuns, {dialects = {dialect}, pronuns = pronun_spec.terms})
			end
		end
	end
	return grouped_pronuns
end


-- Format pronunciations grouped by dialect. `grouped_pronuns` contains the output of group_pronuns_by_dialect().
-- This destructively modifies `grouped_pronuns`, adding a field 'formatted' to the first-level values of
-- `grouped_pronuns` containing the formatted pronunciation(s) for a given set of dialects.
function export.format_grouped_pronunciations(grouped_pronuns)
	for _, grouped_pronun_spec in pairs(grouped_pronuns) do
		local pronunciations = {}

		-- Loop through each pronunciation. For each one, add the phonemic and phonetic versions to `pronunciations`,
		-- for formatting by [[Module:IPA]] or raw (for use in [[Module:ca-headword]]).
		for j, pronun in ipairs(grouped_pronun_spec.pronuns) do
			-- Add dialect tags to left accent qualifiers if first one
			local as = pronun.a
			if j == 1 then
				if as then
					as = m_table.deepcopy(as)
				else
					as = {}
				end
				for _, dialect in ipairs(grouped_pronun_spec.dialects) do
					table.insert(as, export.dialects_to_names[dialect])
				end
			end

			local first_pronun = #pronunciations + 1

			if pronun.unknown then
				-- FIXME: This is a massive hack but it works for now.
				table.insert(pronunciations, { pron = "", pretext = "''unknown''" })
			else
				if not pronun.phonemic and not pronun.phonetic then
					error("Internal error: Saw neither phonemic nor phonetic pronunciation")
				end

				if pronun.phonemic then -- missing if 'raw:[...]' given
					local slash_pron = "/" .. pronun.phonemic .. "/"
					table.insert(pronunciations, {
						pron = slash_pron,
					})
				end

				if pronun.phonetic then -- missing if '/.../' given
					local bracket_pron = "[" .. pronun.phonetic .. "]"
					table.insert(pronunciations, {
						pron = bracket_pron,
					})
				end
			end

			local last_pronun = #pronunciations

			if pronun.q then
				pronunciations[first_pronun].q = pronun.q
			end
			if as then
				pronunciations[first_pronun].a = as
			end
			if j > 1 then
				pronunciations[first_pronun].separator = ", "
			end
			if pronun.qq then
				pronunciations[last_pronun].qq = pronun.qq
			end
			if pronun.aa then
				pronunciations[last_pronun].aa = pronun.aa
			end
			if pronun.refs then
				pronunciations[last_pronun].refs = pronun.refs
			end
			if first_pronun ~= last_pronun then
				pronunciations[last_pronun].separator = " "
			end
		end

		grouped_pronun_spec.formatted = m_IPA.format_IPA_full {
			lang = lang,
			items = pronunciations,
			separator = "",
		}
	end
end


function export.show(frame)
	local params = {
		[1] = {},
		indent = {},
		pagename = {} -- for testing or documentation pages
	}

	for _, dialect in ipairs(export.dialects) do
		params[dialect] = {}
	end
	for dialect_group, _ in pairs(export.dialect_groups) do
		params[dialect_group] = {}
	end

	local args = require("Module:parameters").process(frame:getParent().args, params)
	local pagename = args.pagename or mw.title.getCurrentTitle().subpageText

	-- Set inputs
	local inputs = {}
	-- If 1= specified, do all dialects.
	if args[1] then
		for _, dialect in ipairs(export.dialects) do
			inputs[dialect] = {input = args[1], param = 1}
		end
	end
	-- Then do dialect groups.
	for dialect_group, group_dialects in pairs(export.dialect_groups) do
		if args[dialect_group] then
			for _, dialect in ipairs(group_dialects) do
				inputs[dialect] = {input = args[dialect_group], param = dialect_group}
			end
		end
	end
	-- Then do individual dialect settings.
	for _, dialect in ipairs(export.dialects) do
		if args[dialect] then
			inputs[dialect] = {input = args[dialect], param = dialect}
		end
	end
	-- If no inputs given, set all dialects based on current pagename.
	if not next(inputs) then
		for _, dialect in ipairs(export.dialects) do
			inputs[dialect] = {input = "+", param = "(pagename)"}
		end
	end

	-- Parse the arguments.
	local parsed_respellings = {}
	for dialect, inputspec in pairs(inputs) do
		local function generate_obj(respelling, parse_err)
			return parse_respelling(respelling, pagename, parse_err)
		end

		if inputspec.input:find("[<%[]") then
			local put = require(parse_utilities_module)
			-- Parse balanced segment runs involving either [...] (substitution notation) or <...> (inline modifiers).
			-- We do this because we don't want commas inside of square or angle brackets to count as respelling
			-- delimiters. However, we need to rejoin square-bracketed segments with nearby ones after splitting
			-- alternating runs on comma. For example, if we are given
			-- "a[x]a<q:learned>,[vol:vôl,ks]<q:nonstandard>", after calling
			-- parse_multi_delimiter_balanced_segment_run() we get the following output:
			--
			-- {"a", "[x]", "a", "<q:learned>", ",", "[vol:vôl,ks]", "", "<q:nonstandard>", ""}
			--
			-- After calling split_alternating_runs(), we get the following:
			--
			-- {{"a", "[x]", "a", "<q:learned>", ""}, {"", "[vol:vôl,ks]", "", "<q:nonstandard>", ""}}
			--
			-- We need to rejoin stuff on either side of the square-bracketed portions.
			local segments = put.parse_multi_delimiter_balanced_segment_run(inputspec.input, {{"<", ">"}, {"[", "]"}})

			local comma_separated_groups = put.split_alternating_runs_on_comma(segments)

			-- Process each value.
			local outer_container = export.parse_comma_separated_groups(comma_separated_groups, pagename,
				inputspec.input, inputspec.param)
			parsed_respellings[dialect] = outer_container
		else
			local termobjs = {}
			local function parse_err(msg)
				error(msg .. ": " .. inputspec.param .. "=" .. inputspec.input)
			end
			for _, term in ipairs(split_on_comma(inputspec.input)) do
				table.insert(termobjs, generate_obj(term, parse_err))
			end
			parsed_respellings[dialect] = {
				terms = termobjs,
			}
		end
	end

	-- Convert each canonicalized respelling to phonemic/phonetic IPA.
	export.generate_phonemic_phonetic(parsed_respellings)

	-- Group the results.
	local grouped_pronuns = export.group_pronuns_by_dialect(parsed_respellings)

	-- Format the results.
	export.format_grouped_pronunciations(grouped_pronuns)

	-- Concatenate formatted results.
	local formatted = {}
	for _, grouped_pronun_spec in ipairs(grouped_pronuns) do
		table.insert(formatted, grouped_pronun_spec.formatted)
	end
	local indent = (args.indent or "*") .. " "
	local out = table.concat(formatted, "\n" .. indent)
	if args.indent then
		out = indent .. out
	end

	return out
end

-- Used by [[Module:ca-IPA/testcases]].
function export.test(pagename, respelling, dialect)
	local function parse_err(msg)
		error(msg)
	end
	local parsed = parse_respelling(respelling, pagename, parse_err)
	return to_IPA(parsed.words, dialect)
end

return export