模組: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