跳转到内容

模組:FunctionGraph/TextRenderer

被永久保护的模块
维基百科,自由的百科全书
-- Module:FunctionGraph/TextRenderer
local p = {}

local format, max, min, abs, floor = string.format, math.max, math.min, math.abs, math.floor

local MATH_FONT = "'Times New Roman', 'STIX Two Math', 'Cambria Math', 'Latin Modern Math', Georgia, serif"

-- 希臘字母與數學常數對照表
local GREEK_MAP = {
	pi = "π", PI = "π",
	alpha = "α", beta = "β", theta = "θ", lambda = "λ",
	mu = "μ", sigma = "σ", omega = "ω", phi = "φ",
	psi = "ψ", zeta = "ζ", delta = "δ", epsilon = "ε",
	tau = "τ", rho = "ρ", eta = "η",
	Gamma = "Γ", Delta = "Δ", Theta = "Θ", Lambda = "Λ",
	Sigma = "Σ", Phi = "Φ", Psi = "Ψ", Omega = "Ω",
	inf = "∞", infty = "∞"
}

-- 標準直立數學函數名稱
local UPRIGHT_FUNCS = {
	sin = true, cos = true, tan = true, cot = true, sec = true, csc = true,
	arcsin = true, arccos = true, arctan = true, asin = true, acos = true, atan = true,
	sinh = true, cosh = true, tanh = true, coth = true,
	ln = true, log = true, exp = true, lg = true,
	arg = true, deg = true, det = true, dim = true, gcd = true,
	max = true, min = true, mod = true, sgn = true, sign = true,
	re = true, im = true, Re = true, Im = true, conj = true,
	round = true, trunc = true, lim = true
}

-- 關鍵字邏輯運算子映射表
local KEYWORD_BINOPS = {
	["and"] = "∧",
	["nand"] = "↑",
	["or"] = "∨",
	["nor"] = "↓",
	["xor"] = "⊕",
	["xnor"] = "↔"
}

-- UTF-8 特殊數學運算符號映射表
local UTF8_OPS = {
	["↦"] = "↦",
	["←"] = "←",
	["·"] = "*",
	["⋅"] = "*",
	["↑"] = "↑",
	["↓"] = "↓",
	["⊕"] = "⊕",
	["⇔"] = "↔",
	["↔"] = "↔",
	["∧"] = "∧",
	["∨"] = "∨",
	["¬"] = "¬",
	["≥"] = "≥",
	["≤"] = "≤",
	["≠"] = "≠"
}

local renderCache = {}

local function esc(s)
	return mw.text.encode(tostring(s or ""))
end

-- 估算字元寬度 (以 1em 為基準比例)
local function estimateTextWidth(str, fontSize, isItalic)
	local w = 0
	for code in mw.ustring.gcodepoint(str) do
		if code == 46 or code == 44 or code == 58 or code == 59 or code == 39 or code == 124 or code == 33 then
			w = w + 0.28 -- . , : ; ' | !
		elseif code == 40 or code == 41 or code == 91 or code == 93 then
			w = w + 0.35 -- ( ) [ ]
		elseif code == 49 or code == 105 or code == 106 or code == 108 or code == 116 or code == 102 then
			w = w + 0.38 -- 1 i j l t f
		elseif code == 109 or code == 119 or (code >= 65 and code <= 90) then
			w = w + 0.68 -- m w 大寫字母
		elseif code >= 0x4E00 and code <= 0x9FFF then
			w = w + 1.00 -- 中文字元
		else
			w = w + 0.54 -- 一般數字、小寫變數與希臘字母
		end
	end
	if isItalic and #str > 0 then w = w + 0.05 end
	return w * fontSize
end

-- 詞法分析器 (Tokenizer)
local function tokenize(expr)
	local tokens = {}
	local i = 1
	local len = #expr
	while i <= len do
		local ch = string.sub(expr, i, i)
		local two = string.sub(expr, i, i + 1)
		if string.match(ch, "%s") then
			i = i + 1
		elseif two == "<=" then
			tokens[#tokens + 1] = { type = "OP", val = "≤" }
			i = i + 2
		elseif two == ">=" then
			tokens[#tokens + 1] = { type = "OP", val = "≥" }
			i = i + 2
		elseif two == "!=" or two == "~=" then
			tokens[#tokens + 1] = { type = "OP", val = "≠" }
			i = i + 2
		elseif string.match(ch, "%d") or (ch == "." and string.match(string.sub(expr, i + 1, i + 1), "%d")) then
			local s, e = string.find(expr, "^%d*%.?%d+", i)
			local mantStr = string.sub(expr, s, e)
			local es, ee, eSign, eDigits = string.find(expr, "^[eE]([%+%-]?)(%d+)", e + 1)
			if es then
				tokens[#tokens + 1] = { type = "SCI", mantissa = mantStr, expSign = eSign, expVal = eDigits }
				i = ee + 1
			else
				tokens[#tokens + 1] = { type = "NUM", val = mantStr }
				i = e + 1
			end
		elseif string.match(ch, "[%a]") then
			local s, e = string.find(expr, "^[%a][%w]*", i)
			local word = string.sub(expr, s, e)
			local lword = string.lower(word)
			if KEYWORD_BINOPS[lword] then
				tokens[#tokens + 1] = { type = "OP", val = KEYWORD_BINOPS[lword] }
			elseif lword == "not" then
				tokens[#tokens + 1] = { type = "OP", val = "¬" }
			else
				tokens[#tokens + 1] = { type = "ID", val = word }
			end
			i = e + 1
		elseif ch == "|" then
			tokens[#tokens + 1] = { type = "PIPE", val = "|" }
			i = i + 1
		elseif ch == "&" then
			tokens[#tokens + 1] = { type = "OP", val = "∧" }
			i = i + 1
		elseif ch == "~" then
			tokens[#tokens + 1] = { type = "OP", val = "¬" }
			i = i + 1
		elseif ch == "+" or ch == "-" or ch == "*" or ch == "/" or ch == "^" or ch == "_" or ch == "@"
			or ch == "=" or ch == ";" or ch == "," or ch == "'" or ch == ":" or ch == "<" or ch == ">" or ch == "!" then
			tokens[#tokens + 1] = { type = "OP", val = ch }
			i = i + 1
		elseif ch == "(" or ch == ")" or ch == "[" or ch == "]" or ch == "{" or ch == "}" then
			tokens[#tokens + 1] = { type = "PAREN", val = ch }
			i = i + 1
		else
			local byte = string.byte(ch)
			local charLen = 1
			if byte >= 240 then charLen = 4
			elseif byte >= 224 then charLen = 3
			elseif byte >= 192 then charLen = 2 end
			local u8 = string.sub(expr, i, i + charLen - 1)
			if UTF8_OPS[u8] then
				tokens[#tokens + 1] = { type = "OP", val = UTF8_OPS[u8] }
			else
				tokens[#tokens + 1] = { type = "SYM", val = u8 }
			end
			i = i + charLen
		end
	end
	return tokens
end

-- 剝除外層純括號
local function stripParen(node)
	if node and node.kind == "PAREN" then return node.inner end
	return node
end

-- 解開惰性求值無名函數 :nil↦expr;
local function unwrapNilLambda(node)
	if node and node.kind == "LAMBDA" and #node.params == 1 and node.params[1] == "nil" then
		return node.body
	end
	return node
end

-- 語法分析器 (Recursive Descent Parser)
local function parseTokens(tokens)
	local pos = 1
	local function peek(offset) return tokens[pos + (offset or 0)] end
	local function consume()
		local t = tokens[pos]
		pos = pos + 1
		return t
	end

	local parseTopStmt, parseAssignOrLambda, parseLogic, parseCompare, parseAdd, parseMul, parsePow, parsePrimary

	-- 檢查從當前位置起是否構成 [: params ↦] 區域函數結構
	local function hasMapsToAhead(startIdx)
		local k = startIdx
		while tokens[k] do
			local tk = tokens[k]
			if tk.type == "OP" and tk.val == "↦" then return true end
			if tk.type == "OP" and tk.val == ";" then return false end
			if tk.type == "PAREN" and (tk.val == "(" or tk.val == ")") then return false end
			k = k + 1
		end
		return false
	end

	-- 解析區域函數定義與可選的立即調用 ;(args)
	local function parseLambdaDef(fnName)
		consume() -- 吃掉 ':'
		local params = {}
		while peek() and not (peek().type == "OP" and peek().val == "↦") do
			local tk = consume()
			if tk.type == "ID" or tk.type == "SYM" or tk.type == "NUM" then
				params[#params + 1] = tk.val
			end
		end
		if peek() and peek().type == "OP" and peek().val == "↦" then
			consume() -- 吃掉 '↦'
		end
		local body = parseLogic()
		local lambdaNode = { kind = "LAMBDA", name = fnName, params = params, body = body }

		if peek() and peek().type == "OP" and peek().val == ";" then
			consume() -- 吃掉結束的 ';'
			-- 若 ';' 後緊接 '(',代表立即將參數代入該區域函數,如 :x,y↦x^2+y^2;(5,2) 或 f:x↦x^2;(5)
			if peek() and peek().type == "PAREN" and peek().val == "(" then
				consume() -- 吃掉 '('
				local callArgs = {}
				if not (peek() and peek().type == "PAREN" and peek().val == ")") then
					callArgs[#callArgs + 1] = parseAssignOrLambda()
					while peek() and peek().type == "OP" and peek().val == "," do
						consume()
						callArgs[#callArgs + 1] = parseAssignOrLambda()
					end
				end
				if peek() and peek().type == "PAREN" and peek().val == ")" then consume() end
				return { kind = "LAMBDA_CALL", lambda = lambdaNode, args = callArgs }
			end
		end
		return lambdaNode
	end

	parseTopStmt = function()
		local left = parseAssignOrLambda()
		while peek() do
			local tk = peek()
			if tk.type == "OP" and (tk.val == ";" or tk.val == ",") then
				consume()
				-- 處理如 f:x↦x^2;,f(5) 的連續 ";," 分隔
				while peek() and peek().type == "OP" and (peek().val == ";" or peek().val == ",") do
					consume()
				end
				if not peek() or (peek().type == "PAREN" and peek().val == ")") then
					break
				end
				local right = parseAssignOrLambda()
				left = { kind = "SEQ", op = ";", left = left, right = right }
			else
				break
			end
		end
		return left
	end

	parseAssignOrLambda = function()
		local tk = peek()
		if tk and tk.type == "OP" and tk.val == ":" and hasMapsToAhead(pos + 1) then
			return parseLambdaDef(nil)
		elseif tk and tk.type == "ID" and peek(1) and peek(1).type == "OP" and peek(1).val == ":" and hasMapsToAhead(pos + 2) then
			local fnName = consume().val
			return parseLambdaDef(fnName)
		end

		local left = parseLogic()
		while peek() and peek().type == "OP" and peek().val == "←" do
			consume()
			local right = parseLogic()
			left = { kind = "DEF_EQ", left = left, right = right }
		end
		return left
	end

	parseLogic = function()
		local left = parseCompare()
		while peek() do
			local tk = peek()
			if tk.type == "PIPE" then
				consume()
				local right = parseCompare()
				left = { kind = "BINOP", op = "∨", left = left, right = right }
			elseif tk.type == "OP" and (tk.val == "∧" or tk.val == "∨" or tk.val == "↑" or tk.val == "↓" or tk.val == "⊕" or tk.val == "↔") then
				local op = consume().val
				local right = parseCompare()
				left = { kind = "BINOP", op = op, left = left, right = right }
			else
				break
			end
		end
		return left
	end

	parseCompare = function()
		local left = parseAdd()
		while peek() and peek().type == "OP" and (
			peek().val == "=" or peek().val == "<" or peek().val == ">"
			or peek().val == "≤" or peek().val == "≥" or peek().val == "≠"
		) do
			local op = consume().val
			local right = parseAdd()
			left = { kind = "BINOP", op = op, left = left, right = right }
		end
		return left
	end

	parseAdd = function()
		local left = parseMul()
		while peek() and peek().type == "OP" and (peek().val == "+" or peek().val == "-") do
			local op = consume().val
			local right = parseMul()
			left = { kind = "BINOP", op = op, left = left, right = right }
		end
		return left
	end

	parseMul = function()
		local left = parsePow()
		while peek() do
			local tk = peek()
			if tk.type == "OP" and (tk.val == "*" or tk.val == "/") then
				local op = consume().val
				local right = parsePow()
				left = { kind = "BINOP", op = op, left = left, right = right }
			elseif tk.type == "ID" or tk.type == "NUM" or tk.type == "SCI" or tk.type == "SYM" or (tk.type == "PAREN" and tk.val == "(") then
				local right = parsePow()
				left = { kind = "BINOP", op = "*", left = left, right = right }
			else
				break
			end
		end
		return left
	end

	parsePow = function()
		local base = parsePrimary()
		while peek() and peek().type == "OP" do
			local v = peek().val
			if v == "^" or v == "_" then
				consume()
				local rhs = parsePow()
				rhs = stripParen(rhs)
				if v == "^" then
					base = { kind = "SUP", base = base, exp = rhs }
				else
					base = { kind = "SUB", base = base, sub = rhs }
				end
			elseif v == "'" then
				consume()
				base = { kind = "PRIME", base = base }
			elseif v == "!" then
				consume()
				base = { kind = "FACTORIAL", inner = base }
			else
				break
			end
		end
		return base
	end

	parsePrimary = function()
		local tk = peek()
		if not tk then return { kind = "VAR", val = "" } end

		if tk.type == "OP" and (tk.val == "-" or tk.val == "+" or tk.val == "¬") then
			local op = consume().val
			local operand = parsePow()
			return { kind = "UNARY", op = op, expr = operand }
		elseif tk.type == "PAREN" and tk.val == "(" then
			consume()
			local first = parseAssignOrLambda()
			if peek() and peek().type == "OP" and peek().val == "," then
				local items = { first }
				while peek() and peek().type == "OP" and peek().val == "," do
					consume()
					items[#items + 1] = parseAssignOrLambda()
				end
				if peek() and peek().type == "PAREN" and peek().val == ")" then consume() end
				return { kind = "TUPLE", items = items }
			elseif peek() and peek().type == "OP" and peek().val == ";" then
				consume()
				local second = parseAssignOrLambda()
				if peek() and peek().type == "PAREN" and peek().val == ")" then consume() end
				return { kind = "PAREN", inner = { kind = "SEQ", op = ";", left = first, right = second } }
			end
			if peek() and peek().type == "PAREN" and peek().val == ")" then consume() end
			return { kind = "PAREN", inner = first }
		elseif tk.type == "PIPE" then
			consume()
			local inner = parseAssignOrLambda()
			if peek() and peek().type == "PIPE" then consume() end
			return { kind = "ABS", inner = inner }
		elseif tk.type == "NUM" then
			consume()
			return { kind = "NUM", val = tk.val }
		elseif tk.type == "SCI" then
			consume()
			local expNode = { kind = "NUM", val = tk.expVal }
			if tk.expSign == "-" then
				expNode = { kind = "UNARY", op = "-", expr = expNode }
			end
			local tenPow = {
				kind = "SUP",
				base = { kind = "NUM", val = "10" },
				exp = expNode
			}
			if tk.mantissa == "1" then
				return tenPow
			else
				return {
					kind = "BINOP",
					op = "×",
					left = { kind = "NUM", val = tk.mantissa },
					right = tenPow
				}
			end
		elseif tk.type == "ID" then
			consume()
			local name = tk.val
			local modName = nil

			-- 支援外部模組調用語法:func@Module:Element(args) 或 func@Element(args)
			if peek() and peek().type == "OP" and peek().val == "@" then
				consume() -- 吃掉 '@'
				local modParts = {}
				while peek() and not (peek().type == "PAREN" and peek().val == "(")
					and not (peek().type == "OP" and (peek().val == ";" or peek().val == ",")) do
					local mtk = consume()
					modParts[#modParts + 1] = tostring(mtk.val or "")
				end
				local rawMod = table.concat(modParts)
				-- 自動剝除 Module: / 模組: / 模块: 命名空間前綴,僅保留核心模組名稱
				rawMod = mw.ustring.gsub(rawMod, "^[mM][oO][dD][uU][lL][eE]%s*:%s*", "")
				rawMod = mw.ustring.gsub(rawMod, "^模[組块]%s*:%s*", "")
				if rawMod ~= "" then
					modName = rawMod
				end
			end

			if peek() and peek().type == "PAREN" and peek().val == "(" then
				consume() -- 吃掉 '('
				local args = {}
				if not (peek() and peek().type == "PAREN" and peek().val == ")") then
					args[#args + 1] = parseAssignOrLambda()
					while peek() and peek().type == "OP" and peek().val == "," do
						consume()
						args[#args + 1] = parseAssignOrLambda()
					end
				end
				if peek() and peek().type == "PAREN" and peek().val == ")" then consume() end

				local lowerName = string.lower(name)
				-- 特化顯示:第 k 個質數 primeIndex@Module:Factorization(k) 或 prime(k) -> p_k
				if (lowerName == "primeindex" and (not modName or modName == "Factorization"))
					or (lowerName == "prime" and not modName) then
					return {
						kind = "SUB",
						base = { kind = "VAR", val = "p" },
						sub = stripParen(args[1] or { kind = "VAR", val = "x" })
					}
				-- 特化顯示:質數計數函數 primeIndexOf@Module:Factorization(x) 或 pi(x) -> π(x)
				elseif (lowerName == "primeindexof" and (not modName or modName == "Factorization"))
					or ((lowerName == "pi" or name == "π") and not modName) then
					return { kind = "FUNC", name = "π", upright = false, args = args }
				end

				if modName then
					return { kind = "FUNC", name = name, module = modName, upright = true, args = args }
				end
				if lowerName == "sqrt" then
					return { kind = "SQRT", inner = args[1] }
				elseif lowerName == "root" and args[2] then
					return { kind = "SQRT", degree = args[1], inner = args[2] }
				elseif lowerName == "abs" then
					return { kind = "ABS", inner = args[1] }
				elseif lowerName == "norm" then
					return { kind = "NORM", inner = args[1], p = args[2] }
				elseif lowerName == "floor" then
					return { kind = "FLOOR", inner = args[1] }
				elseif lowerName == "ceil" then
					return { kind = "CEIL", inner = args[1] }
				elseif lowerName == "binomial" and args[2] then
					return { kind = "BINOMIAL", n = args[1], k = args[2] }
				elseif lowerName == "factorial" then
					return { kind = "FACTORIAL", inner = args[1] }
				elseif lowerName == "gamma" then
					return { kind = "FUNC", name = "Γ", upright = true, args = args }
				elseif lowerName == "lambertw" then
					return { kind = "FUNC", name = "W", upright = false, args = args }
				elseif lowerName == "div" and args[2] then
					return { kind = "FRAC", num = args[1], den = args[2] }
				elseif lowerName == "inverse" and args[1] then
					local bNode = args[1]
					if bNode.kind == "BINOP" or bNode.kind == "UNARY" then
						bNode = { kind = "PAREN", inner = bNode }
					end
					return { kind = "SUP", base = bNode, exp = { kind = "UNARY", op = "-", expr = { kind = "NUM", val = "1" } } }
				elseif lowerName == "exp" and args[1] then
					return { kind = "SUP", base = { kind = "VAR", val = "e" }, exp = stripParen(args[1]) }
				elseif lowerName == "ele" and args[1] then
					return { kind = "SUB", base = { kind = "VAR", val = "e" }, sub = stripParen(args[1]) }
				elseif lowerName == "log" then
					if #args == 1 then
						return { kind = "LOG_FUNC", name = "ln", base = nil, arg = args[1] }
					elseif #args >= 2 then
						return { kind = "LOG_FUNC", name = "log", base = args[1], arg = args[2] }
					end
				elseif (lowerName == "summation" or lowerName == "sum") and #args >= 3 then
					return { kind = "BIGOP", op = "∑", min = args[1], max = args[2], body = args[3] }
				elseif (lowerName == "product" or lowerName == "prod") and #args >= 3 then
					return { kind = "BIGOP", op = "∏", min = args[1], max = args[2], body = args[3] }
				elseif lowerName == "limit" and #args >= 3 then
					return { kind = "LIMIT", target = args[1], dir = args[2], body = args[3] }
				elseif lowerName == "diff" and #args >= 2 then
					return { kind = "DIFF", func = args[1], val = args[2] }
				elseif lowerName == "integral" and #args >= 3 then
					return { kind = "INTEGRAL", min = args[1], max = args[2], body = args[3] }
				elseif (lowerName == "if" or lowerName == "iff") and #args >= 3 then
					return {
						kind = "CASES",
						rows = { { val = unwrapNilLambda(args[2]), cond = args[1] } },
						otherwise = unwrapNilLambda(args[3])
					}
				elseif lowerName == "ifelse" and #args >= 3 then
					local rows = {}
					local idx = 1
					while idx + 1 <= #args - 1 do
						rows[#rows + 1] = { val = unwrapNilLambda(args[idx + 1]), cond = args[idx] }
						idx = idx + 2
					end
					return { kind = "CASES", rows = rows, otherwise = unwrapNilLambda(args[#args]) }
				else
					return { kind = "FUNC", name = name, args = args }
				end
			end

			if modName then
				return {
					kind = "SUB",
					base = { kind = "VAR", val = name },
					sub = { kind = "VAR", val = modName }
				}
			end
			if GREEK_MAP[name] then
				local sym = GREEK_MAP[name]
				local isUpperGreek = (string.match(name, "^%u") ~= nil) or (name == "inf" or name == "infty")
				return { kind = "SYM", val = sym, italic = not isUpperGreek }
			end
			return { kind = "VAR", val = name }
		else
			consume()
			local isGreekItalic = (tk.val == "π" or tk.val == "θ" or tk.val == "α" or tk.val == "β" or tk.val == "λ")
			return { kind = "SYM", val = tk.val, italic = isGreekItalic }
		end
	end

	return parseTopStmt()
end

-- 繪製自適應高度的左右圓括號 SVG
local function wrapWithTallParens(innerBox, fontSize)
	local asc = max(fontSize * 0.74, innerBox.ascent)
	local desc = max(fontSize * 0.22, innerBox.descent)
	local totalH = asc + desc
	if totalH <= fontSize * 1.15 then
		local pW = fontSize * 0.34
		local svg = format(
			'<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">(</text>'
			.. '<g transform="translate(%.2f, 0)">%s</g>'
			.. '<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">)</text>',
			MATH_FONT, fontSize, pW, innerBox.svg, pW + innerBox.width, MATH_FONT, fontSize
		)
		return { svg = svg, width = pW * 2 + innerBox.width, ascent = asc, descent = desc }
	end

	local topY = -asc - 1.5
	local botY = desc + 1.5
	local pW = max(fontSize * 0.38, min(fontSize * 0.65, totalH * 0.18))
	local sw = max(1.05, fontSize * 0.09)
	local leftParen = format(
		'<path d="M %.2f,%.2f C %.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="none" stroke="#1a1a1a" stroke-width="%.2f" stroke-linecap="round"/>',
		pW * 0.85, topY, 0, topY + totalH * 0.25, 0, botY - totalH * 0.25, pW * 0.85, botY, sw
	)
	local rx = pW + innerBox.width
	local rightParen = format(
		'<path d="M %.2f,%.2f C %.2f,%.2f %.2f,%.2f %.2f,%.2f" fill="none" stroke="#1a1a1a" stroke-width="%.2f" stroke-linecap="round"/>',
		rx + pW * 0.15, topY, rx + pW, topY + totalH * 0.25, rx + pW, botY - totalH * 0.25, rx + pW * 0.15, botY, sw
	)
	local svg = format('%s<g transform="translate(%.2f, 0)">%s</g>%s', leftParen, pW, innerBox.svg, rightParen)
	return { svg = svg, width = rx + pW, ascent = -topY, descent = botY }
end

-- 橫向串接多個 Box
local function concatBoxes(boxes, fontSize)
	local svgParts = {}
	local curX = 0
	local maxAsc = fontSize * 0.74
	local maxDesc = fontSize * 0.22
	for _, b in ipairs(boxes) do
		if b and b.width > 0 then
			if curX == 0 then
				svgParts[#svgParts + 1] = b.svg
			else
				svgParts[#svgParts + 1] = format('<g transform="translate(%.2f, 0)">%s</g>', curX, b.svg)
			end
			curX = curX + b.width + (b.padAfter or 0)
			if b.ascent > maxAsc then maxAsc = b.ascent end
			if b.descent > maxDesc then maxDesc = b.descent end
		elseif b and b.padAfter then
			curX = curX + b.padAfter
		end
	end
	return { svg = table.concat(svgParts), width = curX, ascent = maxAsc, descent = maxDesc }
end

-- 將變數名中的 "it" 轉寫為數學慣用的 "i"
local function normalizeIterVar(varName, isBodyNode)
	if varName == "it" then return "i" end
	return varName
end

local function replaceItInAST(node)
	if not node or type(node) ~= "table" then return node end
	if node.kind == "VAR" and node.val == "it" then
		return { kind = "VAR", val = "i" }
	end
	local copy = {}
	for k, v in pairs(node) do
		if type(v) == "table" then
			copy[k] = replaceItInAST(v)
		else
			copy[k] = v
		end
	end
	return copy
end

-- 遞迴將 AST 排版為 SVG Box: { svg = "...", width = W, ascent = A, descent = D, isNum = bool }
local function layoutAST(node, fontSize)
	if not node then
		return { svg = "", width = 0, ascent = fontSize * 0.7, descent = fontSize * 0.2 }
	end

	local kind = node.kind
	local asc = fontSize * 0.74
	local desc = fontSize * 0.22

	if kind == "NUM" then
		local w = estimateTextWidth(node.val, fontSize, false)
		local svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text>', MATH_FONT, fontSize, esc(node.val))
		return { svg = svg, width = w, ascent = asc, descent = desc, isNum = true }

	elseif kind == "VAR" then
		local isSingle = (#node.val == 1) or GREEK_MAP[node.val]
		local isUpright = UPRIGHT_FUNCS[node.val] or (not isSingle)
		local w = estimateTextWidth(node.val, fontSize, not isUpright)
		local styleAttr = isUpright and "" or ' font-style="italic"'
		local svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx"%s fill="#1a1a1a">%s</text>', MATH_FONT, fontSize, styleAttr, esc(node.val))
		return { svg = svg, width = w, ascent = asc, descent = desc }

	elseif kind == "SYM" then
		local w = estimateTextWidth(node.val, fontSize, node.italic)
		local styleAttr = node.italic and ' font-style="italic"' or ''
		local svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx"%s fill="#1a1a1a">%s</text>', MATH_FONT, fontSize, styleAttr, esc(node.val))
		return { svg = svg, width = w, ascent = asc, descent = desc }

	elseif kind == "UNARY" then
		local opSym = (node.op == "-") and "−" or node.op
		local opW = (node.op == "¬") and (fontSize * 0.58) or (fontSize * 0.52)
		local subBox = layoutAST(node.expr, fontSize)
		local svg = format(
			'<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text><g transform="translate(%.2f, 0)">%s</g>',
			MATH_FONT, fontSize, esc(opSym), opW, subBox.svg
		)
		return { svg = svg, width = opW + subBox.width, ascent = max(asc, subBox.ascent), descent = max(desc, subBox.descent) }

	elseif kind == "FACTORIAL" then
		local inner = node.inner
		if inner and (inner.kind == "BINOP" or inner.kind == "UNARY") then
			inner = { kind = "PAREN", inner = inner }
		end
		local bBox = layoutAST(inner, fontSize)
		local bangW = fontSize * 0.32
		local svg = format('%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">!</text>', bBox.svg, bBox.width + fontSize * 0.04, MATH_FONT, fontSize)
		return { svg = svg, width = bBox.width + bangW, ascent = bBox.ascent, descent = bBox.descent }

	elseif kind == "PAREN" then
		local inner = layoutAST(node.inner, fontSize)
		return wrapWithTallParens(inner, fontSize)

	elseif kind == "TUPLE" then
		local parts = {}
		for i, item in ipairs(node.items) do
			if i > 1 then
				parts[#parts + 1] = {
					svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">,</text>', MATH_FONT, fontSize),
					width = fontSize * 0.28, ascent = asc, descent = desc, padAfter = fontSize * 0.18
				}
			end
			parts[#parts + 1] = layoutAST(item, fontSize)
		end
		return wrapWithTallParens(concatBoxes(parts, fontSize), fontSize)

	elseif kind == "SEQ" then
		local lBox = layoutAST(node.left, fontSize)
		local rBox = layoutAST(node.right, fontSize)
		local sepSym = node.op or ";"
		local sepW = fontSize * 0.42
		local svg = format(
			'%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text><g transform="translate(%.2f, 0)">%s</g>',
			lBox.svg, lBox.width + fontSize * 0.05, MATH_FONT, fontSize, esc(sepSym), lBox.width + sepW, rBox.svg
		)
		return { svg = svg, width = lBox.width + sepW + rBox.width, ascent = max(lBox.ascent, rBox.ascent), descent = max(lBox.descent, rBox.descent) }

	elseif kind == "DEF_EQ" then
		-- 渲染帶上方 def 的定義等號 x \stackrel{\text{def}}{=} 7
		local lBox = layoutAST(node.left, fontSize)
		local rBox = layoutAST(node.right, fontSize)
		local defFontSize = fontSize * 0.58
		local eqW = fontSize * 0.85
		local pad = fontSize * 0.18
		local eqCenterX = lBox.width + pad + eqW * 0.5
		local rX = lBox.width + pad * 2 + eqW
		local svg = format(
			'%s<text x="%.2f" y="%.2f" text-anchor="middle" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">def</text>'
			.. '<text x="%.2f" y="0" text-anchor="middle" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">=</text>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			lBox.svg, eqCenterX, -fontSize * 0.48, MATH_FONT, defFontSize,
			eqCenterX, MATH_FONT, fontSize, rX, rBox.svg
		)
		return {
			svg = svg,
			width = rX + rBox.width,
			ascent = max(max(lBox.ascent, rBox.ascent), fontSize * 0.92),
			descent = max(lBox.descent, rBox.descent)
		}

	elseif kind == "LAMBDA" then
		-- 渲染 f : x ↦ (x^2) 或 (x, y) ↦ (x^2 + y^2)
		local boxes = {}
		if node.name then
			local nBox = layoutAST({ kind = "VAR", val = node.name }, fontSize)
			nBox.padAfter = fontSize * 0.16
			boxes[#boxes + 1] = nBox
			boxes[#boxes + 1] = {
				svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">:</text>', MATH_FONT, fontSize),
				width = fontSize * 0.28, ascent = asc, descent = desc, padAfter = fontSize * 0.18
			}
		end

		if #node.params > 1 then
			local pItems = {}
			for _, pName in ipairs(node.params) do pItems[#pItems + 1] = { kind = "VAR", val = pName } end
			local pBox = layoutAST({ kind = "TUPLE", items = pItems }, fontSize)
			pBox.padAfter = fontSize * 0.22
			boxes[#boxes + 1] = pBox
		else
			local pName = node.params[1] or "x"
			local pBox = layoutAST({ kind = "VAR", val = pName }, fontSize)
			pBox.padAfter = fontSize * 0.22
			boxes[#boxes + 1] = pBox
		end

		boxes[#boxes + 1] = {
			svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">↦</text>', MATH_FONT, fontSize),
			width = fontSize * 0.65, ascent = asc, descent = desc, padAfter = fontSize * 0.20
		}

		local bodyBox = layoutAST({ kind = "PAREN", inner = stripParen(node.body) }, fontSize)
		boxes[#boxes + 1] = bodyBox
		return concatBoxes(boxes, fontSize)

	elseif kind == "LAMBDA_CALL" then
		-- 渲染 ((x, y) ↦ (x^2 + y^2)) ← (5, 2) 或 (f : x ↦ (x^2)) ← 5
		local lamBox = wrapWithTallParens(layoutAST(node.lambda, fontSize), fontSize)
		lamBox.padAfter = fontSize * 0.22
		local arrowBox = {
			svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">←</text>', MATH_FONT, fontSize),
			width = fontSize * 0.68, ascent = asc, descent = desc, padAfter = fontSize * 0.22
		}
		local argBox
		if #node.args == 1 then
			argBox = layoutAST(node.args[1], fontSize)
		else
			argBox = layoutAST({ kind = "TUPLE", items = node.args }, fontSize)
		end
		return concatBoxes({ lamBox, arrowBox, argBox }, fontSize)

	elseif kind == "ABS" then
		local inner = layoutAST(node.inner, fontSize)
		local padX = fontSize * 0.22
		local topY = -max(asc, inner.ascent) - 1
		local botY = max(desc, inner.descent) + 1
		local rightBarX = padX * 2 + inner.width
		local svg = format(
			'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.15"/>'
			.. '<g transform="translate(%.2f, 0)">%s</g>'
			.. '<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.15"/>',
			padX * 0.5, topY, padX * 0.5, botY, padX * 1.25, inner.svg, rightBarX - padX * 0.5, topY, rightBarX - padX * 0.5, botY
		)
		return { svg = svg, width = rightBarX, ascent = -topY, descent = botY }

	elseif kind == "NORM" then
		-- 繪製雙直線範數 ‖3 + 4i‖_2
		local inner = layoutAST(node.inner, fontSize)
		local gap = fontSize * 0.16
		local pad = fontSize * 0.20
		local topY = -max(asc, inner.ascent) - 1
		local botY = max(desc, inner.descent) + 1
		local leftW = gap + pad * 1.4
		local rBar1 = leftW + inner.width + pad * 0.6
		local rBar2 = rBar1 + gap
		local totalW = rBar2 + pad * 0.5
		local maxDesc = botY

		local subSvg = ""
		if node.p then
			local pBox = layoutAST(node.p, fontSize * 0.70)
			local subY = max(botY - pBox.ascent * 0.3, fontSize * 0.26)
			subSvg = format('<g transform="translate(%.2f, %.2f)">%s</g>', totalW + 1, subY, pBox.svg)
			totalW = totalW + pBox.width + 2
			maxDesc = max(maxDesc, subY + pBox.descent)
		end

		local svg = format(
			'<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.1"/>'
			.. '<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.1"/>'
			.. '<g transform="translate(%.2f, 0)">%s</g>'
			.. '<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.1"/>'
			.. '<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.1"/>%s',
			pad * 0.4, topY, pad * 0.4, botY,
			pad * 0.4 + gap, topY, pad * 0.4 + gap, botY,
			leftW, inner.svg,
			rBar1, topY, rBar1, botY,
			rBar2, topY, rBar2, botY, subSvg
		)
		return { svg = svg, width = totalW, ascent = -topY, descent = maxDesc }

	elseif kind == "FLOOR" or kind == "CEIL" then
		-- 繪製高斯向下取整 ⌊x⌋ 與向上取整 ⌈x⌉
		local inner = layoutAST(node.inner, fontSize)
		local topY = -max(asc, inner.ascent) - 1.2
		local botY = max(desc, inner.descent) + 1.2
		local hookW = fontSize * 0.26
		local pad = fontSize * 0.14
		local leftX = 1.0
		local innerX = leftX + hookW + pad
		local rightX = innerX + inner.width + pad
		local leftPath, rightPath
		if kind == "FLOOR" then
			leftPath = format('M %.2f,%.2f V %.2f H %.2f', leftX, topY, botY, leftX + hookW)
			rightPath = format('M %.2f,%.2f V %.2f H %.2f', rightX + hookW, topY, botY, rightX)
		else
			leftPath = format('M %.2f,%.2f H %.2f V %.2f', leftX + hookW, topY, leftX, botY)
			rightPath = format('M %.2f,%.2f H %.2f V %.2f', rightX, topY, rightX + hookW, botY)
		end
		local svg = format(
			'<path d="%s %s" fill="none" stroke="#1a1a1a" stroke-width="1.15" stroke-linecap="square" stroke-linejoin="miter"/>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			leftPath, rightPath, innerX, inner.svg
		)
		return { svg = svg, width = rightX + hookW + 1.5, ascent = -topY, descent = botY }

	elseif kind == "BINOMIAL" then
		-- 繪製二項式係數 (n over k)
		local nBox = layoutAST(node.n, fontSize * 0.88)
		local kBox = layoutAST(node.k, fontSize * 0.88)
		local colW = max(nBox.width, kBox.width) + fontSize * 0.16
		local axisY = -fontSize * 0.28
		local nBaseY = axisY - nBox.descent - fontSize * 0.12
		local kBaseY = axisY + kBox.ascent + fontSize * 0.12
		local stackSvg = format(
			'<g transform="translate(%.2f, %.2f)">%s</g><g transform="translate(%.2f, %.2f)">%s</g>',
			(colW - nBox.width) * 0.5, nBaseY, nBox.svg,
			(colW - kBox.width) * 0.5, kBaseY, kBox.svg
		)
		local stackBox = {
			svg = stackSvg,
			width = colW,
			ascent = -nBaseY + nBox.ascent,
			descent = kBaseY + kBox.descent
		}
		return wrapWithTallParens(stackBox, fontSize)

	elseif kind == "FRAC" then
		-- 繪製上下水平分式 A / B
		local numBox = layoutAST(node.num, fontSize * 0.90)
		local denBox = layoutAST(node.den, fontSize * 0.90)
		local padX = fontSize * 0.18
		local barW = max(numBox.width, denBox.width) + padX * 2
		local axisY = -fontSize * 0.28
		local numY = axisY - numBox.descent - 2.2
		local denY = axisY + denBox.ascent + 2.2
		local svg = format(
			'<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<line x1="0" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="%.2f"/>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>',
			(barW - numBox.width) * 0.5, numY, numBox.svg,
			axisY, barW, axisY, max(1.0, fontSize * 0.085),
			(barW - denBox.width) * 0.5, denY, denBox.svg
		)
		return {
			svg = svg,
			width = barW,
			ascent = -numY + numBox.ascent + 1,
			descent = denY + denBox.descent + 1
		}

	elseif kind == "BIGOP" then
		-- 繪製連加 ∑ 與連乘 ∏
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end
		local opFontSize = fontSize * 1.48
		local opW = fontSize * 1.15
		local subFontSize = fontSize * 0.70

		local botNode = { kind = "BINOP", op = "=", left = { kind = "VAR", val = varName }, right = node.min }
		local botBox = layoutAST(botNode, subFontSize)
		local topBox = layoutAST(node.max, subFontSize)
		local bodyBox = layoutAST(bodyNode, fontSize)

		local stackW = max(opW, botBox.width, topBox.width)
		local centerX = stackW * 0.5
		local opBaseY = fontSize * 0.14
		local opTopY = opBaseY - opFontSize * 0.78
		local opBotY = opBaseY + opFontSize * 0.22

		local topBaseY = opTopY - topBox.descent - 1.5
		local botBaseY = opBotY + botBox.ascent + 1.5
		local bodyX = stackW + fontSize * 0.22

		local svg = format(
			'<text x="%.2f" y="%.2f" text-anchor="middle" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			centerX, opBaseY, MATH_FONT, opFontSize, esc(node.op),
			centerX - topBox.width * 0.5, topBaseY, topBox.svg,
			centerX - botBox.width * 0.5, botBaseY, botBox.svg,
			bodyX, bodyBox.svg
		)
		return {
			svg = svg,
			width = bodyX + bodyBox.width,
			ascent = max(-topBaseY + topBox.ascent, bodyBox.ascent),
			descent = max(botBaseY + botBox.descent, bodyBox.descent)
		}

	elseif kind == "LIMIT" then
		-- 繪製極限 \lim_{x \to 0^+} body
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end

		local targetNode = node.target
		if node.dir and node.dir.kind == "NUM" and tonumber(node.dir.val) == 1 then
			targetNode = { kind = "SUP", base = targetNode, exp = { kind = "SYM", val = "+", italic = false } }
		elseif node.dir and node.dir.kind == "UNARY" and node.dir.op == "-" then
			targetNode = { kind = "SUP", base = targetNode, exp = { kind = "SYM", val = "−", italic = false } }
		end

		local subFontSize = fontSize * 0.70
		local vBox = layoutAST({ kind = "VAR", val = varName }, subFontSize)
		local tBox = layoutAST(targetNode, subFontSize)
		local arrowW = subFontSize * 0.95
		local underW = vBox.width + arrowW + tBox.width
		local underSvg = format(
			'%s<text x="%.2f" y="0" text-anchor="middle" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">→</text>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			vBox.svg, vBox.width + arrowW * 0.5, MATH_FONT, subFontSize, vBox.width + arrowW, tBox.svg
		)

		local limW = estimateTextWidth("lim", fontSize, false)
		local stackW = max(limW, underW)
		local centerX = stackW * 0.5
		local underBaseY = desc + max(vBox.ascent, tBox.ascent) + 1.5
		local bodyBox = layoutAST(bodyNode, fontSize)
		local bodyX = stackW + fontSize * 0.22

		local svg = format(
			'<text x="%.2f" y="0" text-anchor="middle" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">lim</text>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			centerX, MATH_FONT, fontSize,
			centerX - underW * 0.5, underBaseY, underSvg,
			bodyX, bodyBox.svg
		)
		return {
			svg = svg,
			width = bodyX + bodyBox.width,
			ascent = max(asc, bodyBox.ascent),
			descent = max(underBaseY + max(vBox.descent, tBox.descent), bodyBox.descent)
		}

	elseif kind == "DIFF" then
		-- 繪製微分評估式 (d cos / dx) |_{x = val}
		local varName = "x"
		local fnNode = node.func
		if fnNode and fnNode.kind == "LAMBDA" then
			varName = normalizeIterVar(fnNode.params[1] or "x")
			fnNode = replaceItInAST(stripParen(fnNode.body))
		elseif fnNode and fnNode.kind == "VAR" then
			-- 如 diff(cos, ...) 保持斜體或函數名
			fnNode = { kind = "SYM", val = fnNode.val, italic = true }
		end

		local numNode = { kind = "BINOP", op = "*", left = { kind = "VAR", val = "d" }, right = fnNode }
		local denNode = { kind = "BINOP", op = "*", left = { kind = "VAR", val = "d" }, right = { kind = "VAR", val = varName } }
		local fracBox = layoutAST({ kind = "FRAC", num = numNode, den = denNode }, fontSize)

		local barX = fracBox.width + fontSize * 0.18
		local topY = -fracBox.ascent - 1
		local botY = fracBox.descent + 3

		local subNode = { kind = "BINOP", op = "=", left = { kind = "VAR", val = varName }, right = node.val }
		local subBox = layoutAST(subNode, fontSize * 0.72)
		local subX = barX + fontSize * 0.16
		local subY = botY

		local svg = format(
			'%s<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#1a1a1a" stroke-width="1.1"/>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>',
			fracBox.svg, barX, topY, barX, botY, subX, subY, subBox.svg
		)
		return {
			svg = svg,
			width = subX + subBox.width + 2,
			ascent = -topY,
			descent = max(botY, subY + subBox.descent)
		}

	elseif kind == "INTEGRAL" then
		-- 繪製定積分 \int_{a}^{b} f(x) dx
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end
		-- 若被積函數為單變數標準三角函數如 sin(x),可省略括號呈現為 sin x
		if bodyNode and bodyNode.kind == "FUNC" and bodyNode.args and #bodyNode.args == 1 and bodyNode.args[1].kind == "VAR" and UPRIGHT_FUNCS[bodyNode.name] then
			bodyNode = { kind = "LOG_FUNC", name = bodyNode.name, arg = bodyNode.args[1] }
		end

		local intFontSize = fontSize * 1.65
		local intW = fontSize * 0.62
		local subFontSize = fontSize * 0.70
		local minBox = layoutAST(node.min, subFontSize)
		local maxBox = layoutAST(node.max, subFontSize)
		local bodyBox = layoutAST(bodyNode, fontSize)
		local dxBox = layoutAST({ kind = "BINOP", op = "*", left = { kind = "VAR", val = "d" }, right = { kind = "VAR", val = varName } }, fontSize)

		local intBaseY = fontSize * 0.22
		local topLimX = intW + fontSize * 0.08
		local topLimY = -intFontSize * 0.56
		local botLimX = intW - fontSize * 0.04
		local botLimY = intBaseY + subFontSize * 0.35

		local afterLimX = max(topLimX + maxBox.width, botLimX + minBox.width) + fontSize * 0.20
		local dxX = afterLimX + bodyBox.width + fontSize * 0.22

		local svg = format(
			'<text x="0" y="%.2f" font-family="%s" font-size="%.2fpx" font-style="italic" fill="#1a1a1a">∫</text>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<g transform="translate(%.2f, %.2f)">%s</g>'
			.. '<g transform="translate(%.2f, 0)">%s</g>'
			.. '<g transform="translate(%.2f, 0)">%s</g>',
			intBaseY, MATH_FONT, intFontSize,
			topLimX, topLimY, maxBox.svg,
			botLimX, botLimY, minBox.svg,
			afterLimX, bodyBox.svg,
			dxX, dxBox.svg
		)
		return {
			svg = svg,
			width = dxX + dxBox.width,
			ascent = max(-topLimY + maxBox.ascent, bodyBox.ascent, intFontSize * 0.72),
			descent = max(botLimY + minBox.descent, bodyBox.descent, intBaseY + 2)
		}

	elseif kind == "CASES" then
		-- 繪製左側大括號分段函數 (if / iff / ifelse)
		local rowBoxes = {}
		local maxValW = 0
		local maxCondW = 0
		local totalH = 0
		local rowGap = fontSize * 0.28

		for _, r in ipairs(node.rows or {}) do
			local vBox = layoutAST(r.val, fontSize * 0.92)
			local cBox = layoutAST(r.cond, fontSize * 0.92)
			local commaW = fontSize * 0.28
			local ifLabelW = estimateTextWidth("if ", fontSize * 0.92, false)
			local vWithCommaSvg = format('%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">,</text>', vBox.svg, vBox.width, MATH_FONT, fontSize * 0.92)
			local cWithIfSvg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">if </text><g transform="translate(%.2f, 0)">%s</g>', MATH_FONT, fontSize * 0.92, ifLabelW, cBox.svg)

			local vw = vBox.width + commaW
			local cw = ifLabelW + cBox.width
			if vw > maxValW then maxValW = vw end
			if cw > maxCondW then maxCondW = cw end
			local rAsc = max(asc * 0.9, vBox.ascent, cBox.ascent)
			local rDesc = max(desc * 0.9, vBox.descent, cBox.descent)
			rowBoxes[#rowBoxes + 1] = { vSvg = vWithCommaSvg, cSvg = cWithIfSvg, asc = rAsc, desc = rDesc }
		end

		if node.otherwise then
			local vBox = layoutAST(node.otherwise, fontSize * 0.92)
			local commaW = fontSize * 0.28
			local owW = estimateTextWidth("otherwise", fontSize * 0.92, false)
			local vWithCommaSvg = format('%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">,</text>', vBox.svg, vBox.width, MATH_FONT, fontSize * 0.92)
			local owSvg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">otherwise</text>', MATH_FONT, fontSize * 0.92)
			local vw = vBox.width + commaW
			if vw > maxValW then maxValW = vw end
			if owW > maxCondW then maxCondW = owW end
			local rAsc = max(asc * 0.9, vBox.ascent)
			local rDesc = max(desc * 0.9, vBox.descent)
			rowBoxes[#rowBoxes + 1] = { vSvg = vWithCommaSvg, cSvg = owSvg, asc = rAsc, desc = rDesc }
		end

		for i, rb in ipairs(rowBoxes) do
			totalH = totalH + rb.asc + rb.desc + (i > 1 and rowGap or 0)
		end

		local axisY = -fontSize * 0.28
		local topY = axisY - totalH * 0.5
		local botY = axisY + totalH * 0.5
		local braceW = fontSize * 0.55
		local colGap = fontSize * 0.65
		local valColX = braceW + fontSize * 0.20
		local condColX = valColX + maxValW + colGap

		-- 繪製動態高度左大括號 '{'
		local bx0 = braceW * 0.88
		local bxMid = braceW * 0.48
		local bxTip = braceW * 0.08
		local braceSvg = format(
			'<path d="M %.2f,%.2f Q %.2f,%.2f %.2f,%.2f L %.2f,%.2f Q %.2f,%.2f %.2f,%.2f Q %.2f,%.2f %.2f,%.2f L %.2f,%.2f Q %.2f,%.2f %.2f,%.2f" fill="none" stroke="#1a1a1a" stroke-width="%.2f" stroke-linecap="round" stroke-linejoin="round"/>',
			bx0, topY, bxMid, topY, bxMid, topY + 4,
			bxMid, axisY - 4, bxMid, axisY, bxTip, axisY,
			bxMid, axisY, bxMid, axisY + 4,
			bxMid, botY - 4, bxMid, botY, bx0, botY,
			max(1.05, fontSize * 0.09)
		)

		local rowsSvg = { braceSvg }
		local curY = topY
		for i, rb in ipairs(rowBoxes) do
			if i > 1 then curY = curY + rowGap end
			local baseY = curY + rb.asc
			rowsSvg[#rowsSvg + 1] = format('<g transform="translate(%.2f, %.2f)">%s</g>', valColX, baseY, rb.vSvg)
			rowsSvg[#rowsSvg + 1] = format('<g transform="translate(%.2f, %.2f)">%s</g>', condColX, baseY, rb.cSvg)
			curY = baseY + rb.desc
		end

		return {
			svg = table.concat(rowsSvg),
			width = condColX + maxCondW + 2,
			ascent = -topY + 2,
			descent = botY + 2
		}

	elseif kind == "LOG_FUNC" then
		-- 繪製 ln e 或 log_2 16
		local fnW = estimateTextWidth(node.name, fontSize, false)
		local curX = fnW
		local maxDesc = desc
		local baseSvg = ""

		if node.base then
			local bBox = layoutAST(node.base, fontSize * 0.72)
			local subY = max(desc * 0.6, fontSize * 0.24)
			baseSvg = format('<g transform="translate(%.2f, %.2f)">%s</g>', curX + 1, subY, bBox.svg)
			curX = curX + bBox.width + 2
			maxDesc = max(maxDesc, subY + bBox.descent)
		end

		local argNode = node.arg
		local needParens = argNode and (argNode.kind == "BINOP" or argNode.kind == "UNARY" or argNode.kind == "SEQ")
		local argBox = layoutAST(needParens and { kind = "PAREN", inner = argNode } or argNode, fontSize)
		local gap = needParens and (fontSize * 0.06) or (fontSize * 0.22)

		local svg = format(
			'<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text>%s<g transform="translate(%.2f, 0)">%s</g>',
			MATH_FONT, fontSize, esc(node.name), baseSvg, curX + gap, argBox.svg
		)
		return {
			svg = svg,
			width = curX + gap + argBox.width,
			ascent = max(asc, argBox.ascent),
			descent = max(maxDesc, argBox.descent)
		}

	elseif kind == "SQRT" then
		local inner = layoutAST(node.inner, fontSize)
		local topY = -max(asc * 0.95, inner.ascent) - 2.4
		local botY = max(desc * 0.65, inner.descent) + 1.2
		local totalH = botY - topY
		local hookY = botY - totalH * 0.42
		local radW = fontSize * 0.72
		local padRight = fontSize * 0.16

		local offsetX = 0
		local degSvg = ""
		if node.degree then
			local degBox = layoutAST(node.degree, fontSize * 0.62)
			degSvg = format('<g transform="translate(0, %.2f)">%s</g>', hookY - 2.5, degBox.svg)
			offsetX = max(0, degBox.width - radW * 0.45)
		end

		local x0 = offsetX + 0.6
		local x1 = offsetX + radW * 0.24
		local x2 = offsetX + radW * 0.52
		local x3 = offsetX + radW * 0.94
		local x4 = x3 + inner.width + padRight * 2

		local pathSvg = format(
			'<path d="M %.2f,%.2f L %.2f,%.2f L %.2f,%.2f L %.2f,%.2f L %.2f,%.2f" fill="none" stroke="#1a1a1a" stroke-width="%.2f" stroke-linejoin="miter" stroke-linecap="square"/>',
			x0, hookY, x1, hookY - 1.2, x2, botY, x3, topY, x4, topY, max(1.05, fontSize * 0.095)
		)
		local innerSvg = format('<g transform="translate(%.2f, 0)">%s</g>', x3 + padRight, inner.svg)
		return { svg = degSvg .. pathSvg .. innerSvg, width = x4 + 1, ascent = -topY + 1.5, descent = botY }

	elseif kind == "SUP" then
		local base = layoutAST(node.base, fontSize)
		local expBox = layoutAST(node.exp, fontSize * 0.72)
		local shiftX = base.width + fontSize * 0.05
		local shiftY = -max(base.ascent * 0.52, fontSize * 0.38)
		local svg = format('%s<g transform="translate(%.2f, %.2f)">%s</g>', base.svg, shiftX, shiftY, expBox.svg)
		return { svg = svg, width = shiftX + expBox.width + fontSize * 0.05, ascent = max(base.ascent, -shiftY + expBox.ascent), descent = base.descent, isNum = base.isNum }

	elseif kind == "SUB" then
		local base = layoutAST(node.base, fontSize)
		local subBox = layoutAST(node.sub, fontSize * 0.72)
		local shiftX = base.width + fontSize * 0.04
		local shiftY = max(base.descent * 0.6, fontSize * 0.24)
		local svg = format('%s<g transform="translate(%.2f, %.2f)">%s</g>', base.svg, shiftX, shiftY, subBox.svg)
		return { svg = svg, width = shiftX + subBox.width + fontSize * 0.04, ascent = base.ascent, descent = max(base.descent, shiftY + subBox.descent) }

	elseif kind == "PRIME" then
		local base = layoutAST(node.base, fontSize)
		local pW = fontSize * 0.28
		local svg = format('%s<text x="%.2f" y="%.2f" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">′</text>', base.svg, base.width, -fontSize * 0.15, MATH_FONT, fontSize * 0.9)
		return { svg = svg, width = base.width + pW, ascent = max(base.ascent, asc), descent = base.descent }

	elseif kind == "FUNC" then
		local fnName = node.name
		local isUpright = (node.upright ~= nil) and node.upright or (UPRIGHT_FUNCS[fnName] or (#fnName > 1))
		local fnW = estimateTextWidth(fnName, fontSize, not isUpright)
		local styleAttr = isUpright and "" or ' font-style="italic"'

		local modSvg = ""
		local modW = 0
		local modDesc = 0
		if node.module and node.module ~= "" then
			local subFs = fontSize * 0.65
			local subY = max(desc * 0.65, fontSize * 0.24)
			modW = estimateTextWidth(node.module, subFs, false) + fontSize * 0.08
			modDesc = subY + subFs * 0.25
			modSvg = format(
				'<text x="%.2f" y="%.2f" font-family="sans-serif" font-size="%.2fpx" fill="#555555">%s</text>',
				fnW + fontSize * 0.03, subY, subFs, esc(node.module)
			)
		end

		local argParts = {}
		for i, aNode in ipairs(node.args or {}) do
			if i > 1 then
				argParts[#argParts + 1] = {
					svg = format('<text x="0" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">,</text>', MATH_FONT, fontSize),
					width = fontSize * 0.28, ascent = asc, descent = desc, padAfter = fontSize * 0.18
				}
			end
			argParts[#argParts + 1] = layoutAST(aNode, fontSize)
		end
		local argsTupleBox = wrapWithTallParens(concatBoxes(argParts, fontSize), fontSize)
		local gap = (not isUpright and #fnName == 1 and modW == 0) and (fontSize * 0.08) or 0
		local argsX = fnW + modW + gap

		local svg = format(
			'<text x="0" y="0" font-family="%s" font-size="%.2fpx"%s fill="#1a1a1a">%s</text>%s<g transform="translate(%.2f, 0)">%s</g>',
			MATH_FONT, fontSize, styleAttr, esc(fnName), modSvg, argsX, argsTupleBox.svg
		)
		return {
			svg = svg,
			width = argsX + argsTupleBox.width,
			ascent = max(asc, argsTupleBox.ascent),
			descent = max(desc, modDesc, argsTupleBox.descent)
		}

	elseif kind == "BINOP" then
		local lBox = layoutAST(node.left, fontSize)
		local rBox = layoutAST(node.right, fontSize)
		local op = node.op

		if op == "*" then
			if lBox.isNum and rBox.isNum then
				local dotW = fontSize * 0.36
				local svg = format(
					'%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">·</text><g transform="translate(%.2f, 0)">%s</g>',
					lBox.svg, lBox.width + fontSize * 0.06, MATH_FONT, fontSize, lBox.width + dotW, rBox.svg
				)
				return { svg = svg, width = lBox.width + dotW + rBox.width, ascent = max(lBox.ascent, rBox.ascent), descent = max(lBox.descent, rBox.descent) }
			else
				local kern = fontSize * 0.06
				local svg = format('%s<g transform="translate(%.2f, 0)">%s</g>', lBox.svg, lBox.width + kern, rBox.svg)
				return { svg = svg, width = lBox.width + kern + rBox.width, ascent = max(lBox.ascent, rBox.ascent), descent = max(lBox.descent, rBox.descent) }
			end
		end

		local opDisplay = (op == "-") and "−" or op
		local pad = fontSize * 0.18
		local opW = estimateTextWidth(opDisplay, fontSize, false)
		if op == "/" then
			pad = fontSize * 0.06
			opW = fontSize * 0.34
		elseif op == "×" then
			pad = fontSize * 0.14
			opW = fontSize * 0.50
		elseif op == ";" or op == "," then
			pad = fontSize * 0.12
			opW = fontSize * 0.30
		elseif op == "↔" or op == "⊕" then
			opW = fontSize * 0.65
		end

		local opX = lBox.width + pad
		local rX = opX + opW + pad
		local svg = format(
			'%s<text x="%.2f" y="0" font-family="%s" font-size="%.2fpx" fill="#1a1a1a">%s</text><g transform="translate(%.2f, 0)">%s</g>',
			lBox.svg, opX, MATH_FONT, fontSize, esc(opDisplay), rX, rBox.svg
		)
		return {
			svg = svg,
			width = rX + rBox.width,
			ascent = max(lBox.ascent, rBox.ascent),
			descent = max(lBox.descent, rBox.descent),
			isNum = (op == "×") and lBox.isNum or nil
		}
	end

	return { svg = "", width = 0, ascent = asc, descent = desc }
end

-- 將「質數(x)」與「π(x)」語法糖預先正規化供 AST 解析為 p_x 與 π(x)
local function preprocessDisplaySugar(s)
	s = mw.ustring.gsub(s, "[質质素][數数]%s*%(", "primeIndex@Module:Factorization(")
	s = mw.ustring.gsub(s, "π%s*%(", "primeIndexOf@Module:Factorization(")
	return s
end

-- 對外主介面:將數學表達式轉為 SVG Box
function p.renderMathSVG(expr_str, opts)
	opts = opts or {}
	local fontSize = tonumber(opts.fontSize) or 12.5
	expr_str = preprocessDisplaySugar(mw.text.trim(mw.text.unstrip(tostring(expr_str or ""))))
	local cacheKey = fontSize .. ":" .. expr_str
	if renderCache[cacheKey] then return renderCache[cacheKey] end

	local ok, box = pcall(function()
		local tokens = tokenize(expr_str)
		local ast = parseTokens(tokens)
		return layoutAST(ast, fontSize)
	end)

	if not ok or not box or box.width == 0 then
		local fallbackW = estimateTextWidth(expr_str, fontSize, false)
		box = {
			svg = format('<text x="0" y="0" font-family="sans-serif" font-size="%.2fpx" fill="#1a1a1a">%s</text>', fontSize, esc(expr_str)),
			width = fallbackW,
			ascent = fontSize * 0.75,
			descent = fontSize * 0.22
		}
	end

	renderCache[cacheKey] = box
	return box
end

-- MediaWiki <math> 內建支援的標準 LaTeX 函數名映射表
local LATEX_STD_FUNCS = {
	sin = "\\sin", cos = "\\cos", tan = "\\tan", cot = "\\cot", sec = "\\sec", csc = "\\csc",
	arcsin = "\\arcsin", arccos = "\\arccos", arctan = "\\arctan",
	asin = "\\arcsin", acos = "\\arccos", atan = "\\arctan",
	sinh = "\\sinh", cosh = "\\cosh", tanh = "\\tanh", coth = "\\coth",
	ln = "\\ln", log = "\\log", exp = "\\exp", lg = "\\lg",
	arg = "\\arg", deg = "\\deg", det = "\\det", dim = "\\dim", gcd = "\\gcd",
	max = "\\max", min = "\\min", lim = "\\lim",
	re = "\\operatorname{Re}", im = "\\operatorname{Im}",
	Re = "\\operatorname{Re}", Im = "\\operatorname{Im}"
}

-- 希臘字母與常數之標準 LaTeX 命令映射表 (避免在 \text{} 外出現非 ASCII 字元)
local LATEX_SYM_MAP = {
	pi = "\\pi", PI = "\\pi", ["π"] = "\\pi",
	alpha = "\\alpha", ["α"] = "\\alpha",
	beta = "\\beta", ["β"] = "\\beta",
	theta = "\\theta", ["θ"] = "\\theta",
	lambda = "\\lambda", ["λ"] = "\\lambda",
	mu = "\\mu", ["μ"] = "\\mu",
	sigma = "\\sigma", ["σ"] = "\\sigma",
	omega = "\\omega", ["ω"] = "\\omega",
	phi = "\\phi", ["φ"] = "\\phi",
	psi = "\\psi", ["ψ"] = "\\psi",
	zeta = "\\zeta", ["ζ"] = "\\zeta",
	delta = "\\delta", ["δ"] = "\\delta",
	epsilon = "\\epsilon", ["ε"] = "\\epsilon",
	tau = "\\tau", ["τ"] = "\\tau",
	rho = "\\rho", ["ρ"] = "\\rho",
	eta = "\\eta", ["η"] = "\\eta",
	Gamma = "\\Gamma", ["Γ"] = "\\Gamma",
	Delta = "\\Delta", ["Δ"] = "\\Delta",
	Theta = "\\Theta", ["Θ"] = "\\Theta",
	Lambda = "\\Lambda", ["Λ"] = "\\Lambda",
	Sigma = "\\Sigma", ["Σ"] = "\\Sigma",
	Phi = "\\Phi", ["Φ"] = "\\Phi",
	Psi = "\\Psi", ["Ψ"] = "\\Psi",
	Omega = "\\Omega", ["Ω"] = "\\Omega",
	inf = "\\infty", infty = "\\infty", ["∞"] = "\\infty"
}

-- 清理並轉義放入 \text{} 或 \operatorname{} 的純 ASCII 名稱
local function cleanLaTeXIdent(s)
	s = tostring(s or "")
	-- 移除所有非 ASCII 字元,確保嚴格符合維基百科 <math> 規範
	s = string.gsub(s, "[\128-\255]", "")
	s = string.gsub(s, "_", "\\_")
	return s
end

-- 將 AST 遞迴轉譯為符合 MediaWiki <math> 規範的純 ASCII LaTeX 程式碼
local function astToLaTeX(node)
	if not node then return "" end
	local kind = node.kind

	if kind == "NUM" then
		return tostring(node.val or "0")

	elseif kind == "VAR" then
		local v = tostring(node.val or "")
		if LATEX_SYM_MAP[v] then return LATEX_SYM_MAP[v] end
		if LATEX_STD_FUNCS[v] then return LATEX_STD_FUNCS[v] end
		if #v == 1 and string.match(v, "^%a$") then return v end
		return "\\operatorname{" .. cleanLaTeXIdent(v) .. "}"

	elseif kind == "SYM" then
		local v = tostring(node.val or "")
		if LATEX_SYM_MAP[v] then return LATEX_SYM_MAP[v] end
		if v == "+" or v == "-" then return v end
		if v == "−" then return "-" end
		return cleanLaTeXIdent(v)

	elseif kind == "UNARY" then
		local op = node.op
		local opTex = (op == "-" or op == "−") and "-" or (op == "¬" and "\\neg " or op)
		return opTex .. astToLaTeX(node.expr)

	elseif kind == "FACTORIAL" then
		local inner = node.inner
		if inner and (inner.kind == "BINOP" or inner.kind == "UNARY") then
			return "\\left(" .. astToLaTeX(inner) .. "\\right)!"
		end
		return astToLaTeX(inner) .. "!"

	elseif kind == "PAREN" then
		return "\\left(" .. astToLaTeX(node.inner) .. "\\right)"

	elseif kind == "TUPLE" then
		local parts = {}
		for _, it in ipairs(node.items or {}) do
			parts[#parts + 1] = astToLaTeX(it)
		end
		return "\\left(" .. table.concat(parts, ", ") .. "\\right)"

	elseif kind == "SEQ" then
		return astToLaTeX(node.left) .. ";\\, " .. astToLaTeX(node.right)

	elseif kind == "DEF_EQ" then
		return astToLaTeX(node.left) .. " \\stackrel{\\text{def}}{=} " .. astToLaTeX(node.right)

	elseif kind == "LAMBDA" then
		local prefix = ""
		if node.name then
			prefix = astToLaTeX({ kind = "VAR", val = node.name }) .. " : "
		end
		local pTex
		if #node.params > 1 then
			local pList = {}
			for _, pn in ipairs(node.params) do pList[#pList + 1] = astToLaTeX({ kind = "VAR", val = pn }) end
			pTex = "\\left(" .. table.concat(pList, ", ") .. "\\right)"
		else
			pTex = astToLaTeX({ kind = "VAR", val = node.params[1] or "x" })
		end
		return prefix .. pTex .. " \\mapsto \\left(" .. astToLaTeX(stripParen(node.body)) .. "\\right)"

	elseif kind == "LAMBDA_CALL" then
		local lamTex = "\\left(" .. astToLaTeX(node.lambda) .. "\\right)"
		local argTex
		if #node.args == 1 then
			argTex = astToLaTeX(node.args[1])
		else
			argTex = astToLaTeX({ kind = "TUPLE", items = node.args })
		end
		return lamTex .. " \\leftarrow " .. argTex

	elseif kind == "ABS" then
		return "\\left|" .. astToLaTeX(node.inner) .. "\\right|"

	elseif kind == "NORM" then
		local base = "\\left\\|" .. astToLaTeX(node.inner) .. "\\right\\|"
		if node.p then
			return base .. "_{" .. astToLaTeX(node.p) .. "}"
		end
		return base

	elseif kind == "FLOOR" then
		return "\\left\\lfloor " .. astToLaTeX(node.inner) .. " \\right\\rfloor"

	elseif kind == "CEIL" then
		return "\\left\\lceil " .. astToLaTeX(node.inner) .. " \\right\\rceil"

	elseif kind == "BINOMIAL" then
		return "\\binom{" .. astToLaTeX(node.n) .. "}{" .. astToLaTeX(node.k) .. "}"

	elseif kind == "FRAC" then
		return "\\frac{" .. astToLaTeX(node.num) .. "}{" .. astToLaTeX(node.den) .. "}"

	elseif kind == "SQRT" then
		if node.degree then
			return "\\sqrt[" .. astToLaTeX(node.degree) .. "]{" .. astToLaTeX(node.inner) .. "}"
		end
		return "\\sqrt{" .. astToLaTeX(node.inner) .. "}"

	elseif kind == "SUP" then
		return "{" .. astToLaTeX(node.base) .. "}^{" .. astToLaTeX(node.exp) .. "}"

	elseif kind == "SUB" then
		return "{" .. astToLaTeX(node.base) .. "}_{" .. astToLaTeX(node.sub) .. "}"

	elseif kind == "PRIME" then
		return astToLaTeX(node.base) .. "'"

	elseif kind == "LOG_FUNC" then
		local fnTex = (node.name == "ln") and "\\ln" or "\\log"
		if node.base then
			fnTex = fnTex .. "_{" .. astToLaTeX(node.base) .. "}"
		end
		local argNode = node.arg
		local needParens = argNode and (argNode.kind == "BINOP" or argNode.kind == "UNARY" or argNode.kind == "SEQ")
		if needParens then
			return fnTex .. "\\left(" .. astToLaTeX(argNode) .. "\\right)"
		end
		return fnTex .. " " .. astToLaTeX(argNode)

	elseif kind == "BIGOP" then
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end
		local opCmd = (node.op == "∏") and "\\prod" or "\\sum"
		local vTex = astToLaTeX({ kind = "VAR", val = varName })
		return format("%s_{%s=%s}^{%s} %s", opCmd, vTex, astToLaTeX(node.min), astToLaTeX(node.max), astToLaTeX(bodyNode))

	elseif kind == "LIMIT" then
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end
		local targetTex = astToLaTeX(node.target)
		if node.dir and node.dir.kind == "NUM" and tonumber(node.dir.val) == 1 then
			targetTex = "{" .. targetTex .. "}^{+}"
		elseif node.dir and node.dir.kind == "UNARY" and node.dir.op == "-" then
			targetTex = "{" .. targetTex .. "}^{-}"
		end
		local vTex = astToLaTeX({ kind = "VAR", val = varName })
		return format("\\lim_{%s \\to %s} %s", vTex, targetTex, astToLaTeX(bodyNode))

	elseif kind == "DIFF" then
		local varName = "x"
		local fnNode = node.func
		if fnNode and fnNode.kind == "LAMBDA" then
			varName = normalizeIterVar(fnNode.params[1] or "x")
			fnNode = replaceItInAST(stripParen(fnNode.body))
		end
		local vTex = astToLaTeX({ kind = "VAR", val = varName })
		return format("\\left.\\frac{d %s}{d %s}\\right|_{%s=%s}", astToLaTeX(fnNode), vTex, vTex, astToLaTeX(node.val))

	elseif kind == "INTEGRAL" then
		local varName = "x"
		local bodyNode = node.body
		if bodyNode and bodyNode.kind == "LAMBDA" then
			varName = normalizeIterVar(bodyNode.params[1] or "x")
			bodyNode = replaceItInAST(stripParen(bodyNode.body))
		end
		local vTex = astToLaTeX({ kind = "VAR", val = varName })
		return format("\\int_{%s}^{%s} %s \\, d%s", astToLaTeX(node.min), astToLaTeX(node.max), astToLaTeX(bodyNode), vTex)

	elseif kind == "CASES" then
		local rowsTex = {}
		for _, r in ipairs(node.rows or {}) do
			rowsTex[#rowsTex + 1] = astToLaTeX(r.val) .. ", & \\text{if } " .. astToLaTeX(r.cond)
		end
		if node.otherwise then
			rowsTex[#rowsTex + 1] = astToLaTeX(node.otherwise) .. ", & \\text{otherwise}"
		end
		return "\\begin{cases} " .. table.concat(rowsTex, " \\\\ ") .. " \\end{cases}"

	elseif kind == "FUNC" then
		local fnName = tostring(node.name or "f")
		local fnTex
		if LATEX_SYM_MAP[fnName] then
			fnTex = LATEX_SYM_MAP[fnName]
		elseif LATEX_STD_FUNCS[fnName] then
			fnTex = LATEX_STD_FUNCS[fnName]
		elseif #fnName == 1 and string.match(fnName, "^%a$") and not node.module then
			fnTex = fnName
		else
			fnTex = "\\operatorname{" .. cleanLaTeXIdent(fnName) .. "}"
		end

		if node.module and node.module ~= "" then
			fnTex = "{" .. fnTex .. "}_{\\text{" .. cleanLaTeXIdent(node.module) .. "}}"
		end

		local argList = {}
		for _, aNode in ipairs(node.args or {}) do
			argList[#argList + 1] = astToLaTeX(aNode)
		end
		return fnTex .. "\\left(" .. table.concat(argList, ", ") .. "\\right)"

	elseif kind == "BINOP" then
		local lTex = astToLaTeX(node.left)
		local rTex = astToLaTeX(node.right)
		local op = node.op
		if op == "*" then
			if node.left and node.right and node.left.kind == "NUM" and node.right.kind == "NUM" then
				return lTex .. " \\cdot " .. rTex
			elseif node.right and node.right.kind == "NUM" then
				return lTex .. " \\cdot " .. rTex
			end
			return lTex .. " " .. rTex
		elseif op == "×" then
			return lTex .. " \\times " .. rTex
		elseif op == "-" or op == "−" then
			return lTex .. " - " .. rTex
		elseif op == "≤" then
			return lTex .. " \\le " .. rTex
		elseif op == "≥" then
			return lTex .. " \\ge " .. rTex
		elseif op == "≠" then
			return lTex .. " \\ne " .. rTex
		elseif op == "∧" then
			return lTex .. " \\land " .. rTex
		elseif op == "∨" then
			return lTex .. " \\lor " .. rTex
		elseif op == "⊕" then
			return lTex .. " \\oplus " .. rTex
		elseif op == "↔" then
			return lTex .. " \\leftrightarrow " .. rTex
		elseif op == "↑" then
			return lTex .. " \\uparrow " .. rTex
		elseif op == "↓" then
			return lTex .. " \\downarrow " .. rTex
		elseif op == ";" then
			return lTex .. ";\\, " .. rTex
		end
		return lTex .. " " .. op .. " " .. rTex
	end

	return ""
end

local latexCache = {}
local mathTagCache = {}

-- 將數學表達式轉為符合維基規範的 LaTeX 字串
function p.toLaTeX(expr_str)
	expr_str = preprocessDisplaySugar(mw.text.trim(mw.text.unstrip(tostring(expr_str or ""))))
	if expr_str == "" then return "" end
	if latexCache[expr_str] then return latexCache[expr_str] end

	local ok, tex = pcall(function()
		local tokens = tokenize(expr_str)
		local ast = parseTokens(tokens)
		return astToLaTeX(ast)
	end)

	if not ok or not tex or tex == "" then
		tex = cleanLaTeXIdent(expr_str)
	end
	latexCache[expr_str] = tex
	return tex
end

-- 單次呼叫 extensionTag('math') 並快取回傳的 Strip Marker,供同一曲線的所有 Tooltips 零成本複用
function p.renderMathTag(expr_str)
	expr_str = mw.text.trim(tostring(expr_str or ""))
	if expr_str == "" then return "" end
	if mathTagCache[expr_str] then return mathTagCache[expr_str] end

	local tex = p.toLaTeX(expr_str)
	local frame = mw.getCurrentFrame()
	if frame and frame.extensionTag and tex ~= "" then
		local ok, tag = pcall(function()
			return frame:extensionTag{ name = 'math', content = tex, args = { 'inline' } }
		end)
		if ok and tag and tag ~= "" then
			mathTagCache[expr_str] = tag
			return tag
		end
	end

	local fallback = "<b>" .. esc(expr_str) .. "</b>"
	mathTagCache[expr_str] = fallback
	return fallback
end

-- 智慧判斷 Tooltip 標題:若含有中文字或 -{}- 繁簡語法則保留為純 HTML 粗體;若為數學算式則單次生成 <math> Strip Marker
function p.formatTooltipTitle(title_str)
	local str = tostring(title_str or "")
	if string.find(str, "-{", 1, true) or string.find(str, "\127", 1, true) then
		return "<b>" .. str .. "</b>"
	end
	for code in mw.ustring.gcodepoint(str) do
		if code >= 0x2E80 and not (code >= 0x2190 and code <= 0x22FF) then
			return "<b>" .. str .. "</b>"
		end
	end
	return p.renderMathTag(str)
end

return p