跳转到内容

模組:Graph/RenderUtils

被永久保护的模块
维基百科,自由的百科全书
-- Module:Graph/RenderUtils
-- 圖表幾何運算、數值/日期格式化、自訂符號正規化與 HTML 疊層輸出工具庫
local p = {}

local format, sub, gsub, match, gmatch, find = string.format, string.sub, string.gsub, string.match, string.gmatch, string.find
local floor, ceil, abs, max, min = math.floor, math.ceil, math.abs, math.max, math.min
local log, log10, sqrt = math.log, math.log10, math.sqrt

local CSS_COLORS = {
	white = {255, 255, 255}, black = {0, 0, 0}, silver = {192, 192, 192},
	gray = {128, 128, 128}, grey = {128, 128, 128}, red = {255, 0, 0},
	darkred = {139, 0, 0}, green = {0, 128, 0}, blue = {0, 0, 255},
	darkblue = {0, 0, 139}, lightblue = {173, 216, 230}, steelblue = {70, 130, 180},
	yellow = {255, 255, 0}, gold = {255, 215, 0}, orange = {255, 165, 0},
	purple = {128, 0, 128}, pink = {255, 192, 203}, brown = {165, 42, 42},
	cyan = {0, 255, 255}, teal = {0, 128, 128}, navy = {0, 0, 128}
}

local MONTH_ABBR = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }
local MONTH_FULL = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }

-- 轉義 XML/HTML 特殊字元,但保護 MediaWiki 繁簡轉換 Strip Marker 不被 unstrip 抹除
function p.esc(s)
	local str = tostring(s or "")
	str = gsub(str, "\127'\"`UNIQ", "__MW_SM_PRE__UNIQ")
	str = gsub(str, "QINU`\"'\127", "QINU__MW_SM_SUF__")
	return mw.text.encode(str)
end

-- 還原 Strip Marker 供 HTML 疊層參與 MediaWiki 後期繁簡轉換管線
function p.restoreStripMarkers(s)
	if type(s) ~= "string" then return "" end
	s = gsub(s, "__MW_SM_PRE__UNIQ", "\127'\"`UNIQ")
	s = gsub(s, "QINU__MW_SM_SUF__", "QINU`\"'\127")
	return s
end

-- 新增千分位逗號
local function addCommas(numStr)
	local sign, intPart, decPart = match(numStr, "^([%+%-]?)(%d+)(%.?%d*)$")
	if not intPart then return numStr end
	local rev = string.reverse(intPart)
	rev = gsub(rev, "(%d%d%d)", "%1,")
	local res = string.reverse(rev)
	res = gsub(res, "^,", "")
	return sign .. res .. decPart
end

-- 支援 D3 數字格式化 (含千分位 ','、小數位 '.2f'、百分比 '%'、千分比 '‰'、科學記號 's')
function p.formatNumber(val, fmt, isInt)
	local n = tonumber(val)
	if not n then return tostring(val or "") end
	if abs(n) < 1e-12 then n = 0 end
	if not fmt or fmt == "" then
		if isInt or abs(n - floor(n + 0.5)) < 1e-7 then
			return addCommas(tostring(floor(n + 0.5)))
		end
		return addCommas(format("%.2f", n):gsub("%.?0+$", ""))
	end

	local useComma = find(fmt, ",", 1, true) ~= nil
	local isPct = find(fmt, "%", 1, true) ~= nil
	local isPermille = find(fmt, "‰", 1, true) ~= nil
	local isSI = find(fmt, "s", 1, true) ~= nil
	local prec = tonumber(match(fmt, "%.(%d+)"))

	if isPct then
		n = n * 100
	elseif isPermille then
		n = n * 1000
	end
	if abs(n) < 1e-12 then n = 0 end

	local suffix = ""
	if isSI and n ~= 0 then
		local absN = abs(n)
		if absN >= 1e9 then n = n / 1e9; suffix = "G"
		elseif absN >= 1e6 then n = n / 1e6; suffix = "M"
		elseif absN >= 1e3 then n = n / 1e3; suffix = "k" end
	end

	local out
	if prec then
		out = format("%." .. prec .. "f", n)
	elseif (isInt and not isPct and not isPermille) or abs(n - floor(n + 0.5)) < 1e-7 then
		out = tostring(floor(n + 0.5))
	else
		out = format("%.2f", n):gsub("%.?0+$", "")
	end

	if useComma then out = addCommas(out) end
	if isPct then
		out = out .. "%"
	elseif isPermille then
		out = out .. "‰"
	end
	return out .. suffix
end

-- 將 ISO 日期字串或年份轉為小數年份 (如 "2021-07-01" -> 2021.497)
function p.parseDateValue(v)
	if v == nil or v == false then return nil end
	if type(v) == "number" then
		if v > 1e8 then return 1970 + v / 31557600000 end
		return v
	end
	local s = mw.text.trim(tostring(v))
	local y, m, d = match(s, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)")
	if y then
		return tonumber(y) + (tonumber(m) - 1) / 12 + (tonumber(d) - 1) / 365.25
	end
	y, m = match(s, "^(%d%d%d%d)[%-%/](%d%d?)$")
	if y then
		return tonumber(y) + (tonumber(m) - 1) / 12
	end
	return tonumber(s)
end

-- 將小數年份格式化為日期字串 (支援 %Y, %y, %b, %B, %m, %d)
function p.formatDateValue(decYear, fmt)
	local y = floor(decYear + 1e-6)
	local rem = max(0, decYear - y)
	local m = min(12, max(1, floor(rem * 12 + 0.5) + 1))
	local d = min(28, max(1, floor((rem * 12 - (m - 1)) * 30 + 0.5) + 1))

	if not fmt or fmt == "" then
		if rem < 0.02 or rem > 0.98 then
			return tostring(floor(decYear + 0.5))
		end
		return format("%04d-%02d", y, m)
	end

	local out = fmt
	out = gsub(out, "%%Y", format("%04d", y))
	out = gsub(out, "%%y", format("%02d", y % 100))
	out = gsub(out, "%%B", MONTH_FULL[m] or "")
	out = gsub(out, "%%b", MONTH_ABBR[m] or "")
	out = gsub(out, "%%m", format("%02d", m))
	out = gsub(out, "%%d", format("%02d", d))
	return out
end

-- 比例尺映射 (linear, log, sqrt, pow)
function p.applyScale(val, minV, maxV, scaleType)
	if maxV <= minV then return 0.5 end
	local sType = scaleType and string.lower(tostring(scaleType)) or "linear"
	if sType == "log" then
		local lMin = log10(max(1e-9, minV))
		local lMax = log10(max(1e-8, maxV))
		if lMax <= lMin then return 0.5 end
		return (log10(max(1e-9, val)) - lMin) / (lMax - lMin)
	elseif sType == "sqrt" then
		local sMin = sqrt(max(0, minV))
		local sMax = sqrt(max(1e-9, maxV))
		return (sqrt(max(0, val)) - sMin) / (sMax - sMin)
	elseif sub(sType, 1, 3) == "pow" then
		local expN = tonumber(match(sType, "([%d%.]+)")) or 2
		local pMin = (minV >= 0 and 1 or -1) * (abs(minV) ^ expN)
		local pMax = (maxV >= 0 and 1 or -1) * (abs(maxV) ^ expN)
		local pVal = (val >= 0 and 1 or -1) * (abs(val) ^ expN)
		return (pVal - pMin) / (pMax - pMin)
	end
	return (val - minV) / (maxV - minV)
end

-- 計算漂亮座標軸刻度 (Nice Ticks)
function p.computeNiceTicks(minV, maxV, targetCount, scaleType, isInteger)
	targetCount = max(2, tonumber(targetCount) or 5)
	if minV == maxV then
		minV = minV - 1
		maxV = maxV + 1
	end

	local sType = scaleType and string.lower(tostring(scaleType)) or "linear"
	if sType == "log" and minV > 0 and maxV > minV then
		local pMin = floor(log10(minV) + 1e-9)
		local pMax = ceil(log10(maxV) - 1e-9)
		local ticks = {}
		for expP = pMin, pMax do
			ticks[#ticks + 1] = 10 ^ expP
		end
		if #ticks >= 2 then
			return ticks, min(minV, ticks[1]), max(maxV, ticks[#ticks])
		end
	end

	local span = maxV - minV
	local rawStep = span / targetCount
	local mag = 10 ^ floor(log10(max(1e-12, rawStep)))
	local norm = rawStep / mag
	local step
	if norm <= 1.5 then step = 1 * mag
	elseif norm <= 3.0 then step = 2 * mag
	elseif norm <= 7.0 then step = 5 * mag
	else step = 10 * mag end

	if isInteger and step < 1 then step = 1 end

	local niceMin = floor(minV / step + 1e-9) * step
	local niceMax = ceil(maxV / step - 1e-9) * step
	local ticks = {}
	local cur = niceMin
	local guard = 0
	while cur <= niceMax + step * 0.001 and guard < 100 do
		if abs(cur) < 1e-11 then cur = 0 end
		ticks[#ticks + 1] = cur
		cur = cur + step
		guard = guard + 1
	end
	return ticks, niceMin, niceMax
end

-- 判斷字串是否為合法的單一顏色表示法 (支援 CSS 色名、#rgb、#rrggbb、#aarrggbb、rgb()/rgba())
function p.isValidColorToken(cStr)
	if type(cStr) ~= "string" then return false end
	local s = string.lower(mw.text.trim(cStr))
	if s == "" then return false end
	if CSS_COLORS[s] then return true end
	if match(s, "^#%x%x%x$") or match(s, "^#%x%x%x%x%x%x$") or match(s, "^#%x%x%x%x%x%x%x%x$") then
		return true
	end
	if match(s, "^rgba?%s*%([%d%s,%.%%]+%)$") then
		return true
	end
	return false
end

-- 解析顏色字串為 {r, g, b}
function p.parseRGB(cStr)
	if not cStr then return {128, 128, 128} end
	cStr = string.lower(mw.text.trim(tostring(cStr)))
	if CSS_COLORS[cStr] then return CSS_COLORS[cStr] end
	local r, g, b = match(cStr, "^#(%x%x)(%x%x)(%x%x)$")
	if r then return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) } end
	local _, ar, ag, ab = match(cStr, "^#(%x%x)(%x%x)(%x%x)(%x%x)$")
	if ar then return { tonumber(ar, 16), tonumber(ag, 16), tonumber(ab, 16) } end
	local sr, sg, sb = match(cStr, "^#(%x)(%x)(%x)$")
	if sr then return { tonumber(sr .. sr, 16), tonumber(sg .. sg, 16), tonumber(sb .. sb, 16) } end
	local rr, rg, rb = match(cStr, "^rgba?%s*%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)")
	if rr then return { tonumber(rr), tonumber(rg), tonumber(rb) } end
	return {128, 128, 128}
end

function p.rgbToHex(r, g, b)
	return format("#%02x%02x%02x",
		max(0, min(255, floor(r + 0.5))),
		max(0, min(255, floor(g + 0.5))),
		max(0, min(255, floor(b + 0.5)))
	)
end

-- 多錨點線性色階插值 (供歷史氣溫圖與二維熱圖使用)
function p.interpolateColorRamp(val, domain, range)
	if type(domain) ~= "table" or type(range) ~= "table" or #domain == 0 or #range == 0 then
		return "#3182bd"
	end
	local v = tonumber(val) or 0
	if v <= tonumber(domain[1]) then return range[1] end
	local n = min(#domain, #range)
	if v >= tonumber(domain[n]) then return range[n] end

	for i = 1, n - 1 do
		local d0, d1 = tonumber(domain[i]), tonumber(domain[i + 1])
		if d0 and d1 and v >= d0 and v <= d1 then
			local t = (d1 > d0) and ((v - d0) / (d1 - d0)) or 0
			local c0 = p.parseRGB(range[i])
			local c1 = p.parseRGB(range[i + 1])
			return p.rgbToHex(
				c0[1] + (c1[1] - c0[1]) * t,
				c0[2] + (c1[2] - c0[2]) * t,
				c0[3] + (c1[3] - c0[3]) * t
			)
		end
	end
	return range[n]
end

-- 科學視覺化連續色階表
p.COLORMAPS = {
	viridis = {
		{ 68, 1, 84 }, { 59, 82, 139 }, { 33, 145, 140 }, { 94, 201, 98 }, { 253, 231, 37 }
	},
	coolwarm = {
		{ 59, 76, 192 }, { 141, 176, 254 }, { 221, 221, 221 }, { 244, 154, 123 }, { 180, 4, 38 }
	},
	plasma = {
		{ 13, 8, 135 }, { 126, 3, 168 }, { 204, 71, 120 }, { 248, 149, 64 }, { 240, 249, 33 }
	},
	inferno = {
		{ 0, 0, 4 }, { 87, 16, 110 }, { 188, 55, 84 }, { 249, 142, 9 }, { 252, 255, 164 }
	},
	magma = {
		{ 0, 0, 4 }, { 81, 18, 124 }, { 183, 55, 121 }, { 251, 136, 97 }, { 252, 253, 191 }
	},
	rainbow = {
		{ 48, 18, 59 }, { 40, 188, 235 }, { 164, 252, 60 }, { 251, 126, 33 }, { 122, 4, 3 }
	},
	gray = {
		{ 0, 0, 0 }, { 255, 255, 255 }
	},
	greys = {
		{ 255, 255, 255 }, { 0, 0, 0 }
	}
}

-- 吃進 CPython Matplotlib _cm._jet_data 原生分段線性公式
local function evalJetRGB(t)
	local rf, gf, bf
	if t <= 0.35 then rf = 0.0
	elseif t <= 0.66 then rf = (t - 0.35) / 0.31
	elseif t <= 0.89 then rf = 1.0
	else rf = 1.0 - 0.5 * (t - 0.89) / 0.11 end

	if t <= 0.125 then gf = 0.0
	elseif t <= 0.375 then gf = (t - 0.125) / 0.25
	elseif t <= 0.64 then gf = 1.0
	elseif t <= 0.91 then gf = 1.0 - (t - 0.64) / 0.27
	else gf = 0.0 end

	if t <= 0.11 then bf = 0.5 + 0.5 * (t / 0.11)
	elseif t <= 0.34 then bf = 1.0
	elseif t <= 0.65 then bf = 1.0 - (t - 0.34) / 0.31
	else bf = 0.0 end

	return rf * 255, gf * 255, bf * 255
end

local customCmapCache = {}

-- 解析內建或自訂色階規格 (支援漸層式 #000:#f00:#fff 與離散式 discrete:#000:#f00:#fff 或 #000;#f00;#fff)
function p.parseColormapSpec(rawName)
	if type(rawName) ~= "string" then return nil end
	local s = mw.text.trim(rawName)
	if s == "" then return nil end
	if customCmapCache[s] ~= nil then
		return customCmapCache[s] or nil
	end

	local body = s
	local isDiscrete = false
	local prefix, rest = match(body, "^([%a]+)%s*:%s*(.+)$")
	if prefix then
		local lp = string.lower(prefix)
		if lp == "discrete" or lp == "step" or lp == "steps" then
			isDiscrete = true
			body = mw.text.trim(rest)
		elseif lp == "gradient" or lp == "continuous" or lp == "linear" then
			body = mw.text.trim(rest)
		end
	end

	local bracketInner = match(body, "^%[%s*(.-)%s*%]$")
	if bracketInner then body = bracketInner end

	local cname = string.lower(body)
	if cname == "jet" then
		local spec = { kind = "jet", isDiscrete = isDiscrete, nSteps = 7 }
		customCmapCache[s] = spec
		return spec
	elseif p.COLORMAPS[cname] then
		local stops = p.COLORMAPS[cname]
		local spec = { kind = "stops", stops = stops, isDiscrete = isDiscrete, nSteps = #stops }
		customCmapCache[s] = spec
		return spec
	end

	local sep = nil
	if find(body, ";", 1, true) then
		sep = ";"
		isDiscrete = true
	elseif find(body, ":", 1, true) then
		sep = ":"
	elseif find(body, ",", 1, true) then
		sep = ","
	end

	if sep then
		local parts = mw.text.split(body, "%s*" .. sep .. "%s*")
		if #parts >= 2 then
			local stops = {}
			local allValid = true
			for i = 1, #parts do
				local tk = mw.text.trim(parts[i])
				if p.isValidColorToken(tk) then
					stops[#stops + 1] = p.parseRGB(tk)
				else
					allValid = false
					break
				end
			end
			if allValid and #stops >= 2 then
				local spec = { kind = "stops", stops = stops, isDiscrete = isDiscrete, nSteps = #stops }
				customCmapCache[s] = spec
				return spec
			end
		end
	end

	customCmapCache[s] = false
	return nil
end

function p.isColormapName(name)
	return p.parseColormapSpec(name) ~= nil
end

-- 將正規化數值 t ∈ [0, 1] 映射為色階 Hex (同時支援內建與自訂之漸層式/離散式色階)
function p.sampleColormap(t, mapName)
	t = max(0, min(1, tonumber(t) or 0))
	local spec = p.parseColormapSpec(mapName) or p.parseColormapSpec("viridis")

	if spec.isDiscrete then
		local n = max(2, spec.nSteps or (spec.stops and #spec.stops) or 5)
		local bucket = min(n, max(1, floor(t * n) + 1))
		if spec.kind == "jet" then
			local bt = (bucket - 1) / max(1, n - 1)
			return p.rgbToHex(evalJetRGB(bt))
		end
		local stops = spec.stops
		if bucket <= #stops then
			local c = stops[bucket]
			return p.rgbToHex(c[1], c[2], c[3])
		end
	end

	if spec.kind == "jet" then
		return p.rgbToHex(evalJetRGB(t))
	end

	local stops = spec.stops or p.COLORMAPS.viridis
	local n = #stops
	local scaled = t * (n - 1)
	local idx = min(n - 1, floor(scaled) + 1)
	local frac = scaled - (idx - 1)
	local c0, c1 = stops[idx], stops[idx + 1]
	return p.rgbToHex(
		c0[1] + (c1[1] - c0[1]) * frac,
		c0[2] + (c1[2] - c0[2]) * frac,
		c0[3] + (c1[3] - c0[3]) * frac
	)
end

-- 內部共用:根據連續或離散色階產生 SVG <stop> 列表
local function buildGradientStops(mapName)
	local spec = p.parseColormapSpec(mapName)
	local out = {}
	if spec and spec.isDiscrete then
		local n = max(2, spec.nSteps or (spec.stops and #spec.stops) or 5)
		for k = 1, n do
			local p0 = floor(((k - 1) / n) * 100 + 0.5)
			local p1 = floor((k / n) * 100 + 0.5)
			local col = p.sampleColormap((k - 0.5) / n, mapName)
			out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>', p0, col)
			out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>', p1, col)
		end
	else
		local nStops = (spec and spec.stops and #spec.stops > 10) and #spec.stops or 10
		for s = 0, nStops do
			local t = s / nStops
			out[#out + 1] = format('<stop offset="%d%%" stop-color="%s"/>',
				floor(t * 100 + 0.5), p.sampleColormap(t, mapName))
		end
	end
	return table.concat(out)
end

-- 建構科學色階之 SVG <linearGradient> 節點 (支援 userSpaceOnUse 絕對座標精準對齊,供折線/面積圖使用)
function p.buildColormapGradientDef(gradId, mapName, x1, y1, x2, y2, useUserSpace)
	local units = useUserSpace and ' gradientUnits="userSpaceOnUse"' or ''
	local x1Str = type(x1) == "number" and format("%.2f", x1) or (x1 or "0%")
	local y1Str = type(y1) == "number" and format("%.2f", y1) or (y1 or "0%")
	local x2Str = type(x2) == "number" and format("%.2f", x2) or (x2 or "100%")
	local y2Str = type(y2) == "number" and format("%.2f", y2) or (y2 or "0%")

	return format('<linearGradient id="%s" x1="%s" y1="%s" x2="%s" y2="%s"%s>%s</linearGradient>',
		gradId, x1Str, y1Str, x2Str, y2Str, units, buildGradientStops(mapName))
end

-- 建構科學色階之 SVG <radialGradient> 節點 (支援 userSpaceOnUse 絕對座標精準對齊,供雷達圖使用)
function p.buildColormapRadialDef(gradId, mapName, cx, cy, r)
	return format('<radialGradient id="%s" cx="%.2f" cy="%.2f" r="%.2f" gradientUnits="userSpaceOnUse">%s</radialGradient>',
		gradId, cx, cy, r, buildGradientStops(mapName))
end

-- 建構折線/面積 SVG Path (支援 linear, step, step-before, step-after, monotone, basis, cardinal)
function p.buildPathD(pts, interpolate, isReverse)
	local n = #pts
	if n == 0 then return "" end

	local list = {}
	if isReverse then
		for i = n, 1, -1 do list[#list + 1] = pts[i] end
	else
		for i = 1, n do list[i] = pts[i] end
	end

	local mode = interpolate and string.lower(tostring(interpolate)) or "linear"
	local startCmd = isReverse and "L" or "M"
	local d = { format("%s %.2f,%.2f", startCmd, list[1][1], list[1][2]) }
	if n == 1 then return d[1] end

	if mode == "step" then
		for i = 1, n - 1 do
			local midX = (list[i][1] + list[i + 1][1]) / 2
			d[#d + 1] = format("H %.2f V %.2f H %.2f", midX, list[i + 1][2], list[i + 1][1])
		end
	elseif mode == "step-after" then
		for i = 1, n - 1 do
			d[#d + 1] = format("H %.2f V %.2f", list[i + 1][1], list[i + 1][2])
		end
	elseif mode == "step-before" then
		for i = 1, n - 1 do
			d[#d + 1] = format("V %.2f H %.2f", list[i + 1][2], list[i + 1][1])
		end
	elseif (mode == "monotone" or mode == "cardinal" or mode == "basis" or mode == "bundle") and n >= 3 then
		for i = 1, n - 1 do
			local p0 = list[max(1, i - 1)]
			local p1 = list[i]
			local p2 = list[i + 1]
			local p3 = list[min(n, i + 2)]
			local cp1x = p1[1] + (p2[1] - p0[1]) / 6
			local cp1y = p1[2] + (p2[2] - p0[2]) / 6
			local cp2x = p2[1] - (p3[1] - p1[1]) / 6
			local cp2y = p2[2] - (p3[2] - p1[2]) / 6
			if mode == "monotone" then
				local minY, maxY = min(p1[2], p2[2]), max(p1[2], p2[2])
				cp1y = max(minY, min(maxY, cp1y))
				cp2y = max(minY, min(maxY, cp2y))
			end
			d[#d + 1] = format("C %.2f,%.2f %.2f,%.2f %.2f,%.2f", cp1x, cp1y, cp2x, cp2y, p2[1], p2[2])
		end
	else
		for i = 2, n do
			d[#d + 1] = format("L %.2f,%.2f", list[i][1], list[i][2])
		end
	end

	return table.concat(d, " ")
end

-- 解析自訂 <svg>...</svg> 字串,隔離內部 ID 命名空間,並正規化為中心 (0,0)、單位尺寸 1x1 的 <defs> 群組
function p.buildCustomSvgDef(svgStr, defId)
	if type(svgStr) ~= "string" then return nil, false end
	local s = mw.text.unstripNoWiki(p.restoreStripMarkers(svgStr))
	if find(s, "&lt;") or find(s, "&#") then
		s = mw.text.decode(s)
	end
	local openTag, innerHtml = match(s, "<[sS][vV][gG]%s*([^>]*)>(.*)</[sS][vV][gG]>")
	if not openTag or not innerHtml then return nil, false end

	innerHtml = gsub(innerHtml, "<[sS][cC][rR][iI][pP][tT].-</[sS][cC][rR][iI][pP][tT]>", "")
	innerHtml = gsub(innerHtml, '%s+[oO][nN][%w_]+%s*=%s*["\'][^"\']*["\']', "")
	innerHtml = gsub(innerHtml, "</?[dD][eE][fF][sS]%s*>", "")

	local idPrefix = defId .. "_"
	innerHtml = gsub(innerHtml, '([%s<])id%s*=%s*(["\'])([^"\']+)(%2)', '%1id=%2' .. idPrefix .. '%3%4')
	innerHtml = gsub(innerHtml, 'url%(%s*#([^%)%s"\']+)%s*%)', 'url(#' .. idPrefix .. '%1)')
	innerHtml = gsub(innerHtml, 'href%s*=%s*(["\'])#([^"\']+)(%1)', 'href=%1#' .. idPrefix .. '%2%3')

	-- 判斷是否為多色複雜圖示 (含漸層或含有非黑白之彩色色碼)
	local hasNonMonoColor = false
	for hex in gmatch(innerHtml, '["\']#(%x+)["\']') do
		local lh = string.lower(hex)
		if lh ~= "000" and lh ~= "000000" and lh ~= "fff" and lh ~= "ffffff" then
			hasNonMonoColor = true
			break
		end
	end
	local isMultiColor = (find(innerHtml, "[gG]radient") ~= nil)
		or (find(innerHtml, "stop%-color") ~= nil)
		or hasNonMonoColor

	if not isMultiColor then
		-- 單色圖示:將內部寫死的黑色填色/描邊轉為 currentColor,以繼承外層 <use> 的數列顏色
		innerHtml = gsub(innerHtml, '([fF][iI][lL][lL]%s*=%s*["\'])#0000?0?0?(["\'])', '%1currentColor%2')
		innerHtml = gsub(innerHtml, '([fF][iI][lL][lL]%s*=%s*["\'])[bB][lL][aA][cC][kK](["\'])', '%1currentColor%2')
		innerHtml = gsub(innerHtml, '([sS][tT][rR][oO][kK][eE]%s*=%s*["\'])#0000?0?0?(["\'])', '%1currentColor%2')
		innerHtml = gsub(innerHtml, '([sS][tT][rR][oO][kK][eE]%s*=%s*["\'])[bB][lL][aA][cC][kK](["\'])', '%1currentColor%2')
	end

	local vx, vy, vw, vh = match(openTag,
		'[vV][iI][eE][wW][bB][oO][xX]%s*=%s*["\']%s*([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)[%s,]+([%+%-]?[%d%.]+)%s*["\']')
	local aw = match(openTag, '[wW][iI][dD][tT][hH]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')
	local ah = match(openTag, '[hH][eE][iI][gG][hH][tT]%s*=%s*["\']%s*([%+%-]?[%d%.]+)')

	local minX = tonumber(vx) or 0
	local minY = tonumber(vy) or 0
	local w = tonumber(vw) or tonumber(aw) or 24
	local h = tonumber(vh) or tonumber(ah) or 24
	if w <= 0 then w = 24 end
	if h <= 0 then h = 24 end

	local inheritedAttrs = {}
	local allowedAttr = {
		["fill"] = true, ["stroke"] = true, ["stroke-width"] = true,
		["stroke-linecap"] = true, ["stroke-linejoin"] = true,
		["fill-rule"] = true, ["clip-rule"] = true
	}
	for k, q, v in gmatch(openTag, '([%w%-]+)%s*=%s*(["\'])(.-)%2') do
		local lk = string.lower(k)
		if allowedAttr[lk] then
			local lv = string.lower(mw.text.trim(v))
			if not isMultiColor and (lk == "fill" or lk == "stroke") and lv ~= "none" then
				inheritedAttrs[#inheritedAttrs + 1] = format(' %s="currentColor"', lk)
			else
				inheritedAttrs[#inheritedAttrs + 1] = format(' %s="%s"', lk, v)
			end
		end
	end
	if isMultiColor and #inheritedAttrs == 0 then
		inheritedAttrs[1] = ' fill="#000000" stroke="none"'
	end

	local maxDim = max(w, h)
	local normScale = 1.0 / maxDim
	local tx = -(minX + w / 2) * normScale
	local ty = -(minY + h / 2) * normScale

	local defNode = format('<g id="%s"><g transform="translate(%.4f,%.4f) scale(%.6f)"%s>%s</g></g>',
		defId, tx, ty, normScale, table.concat(inheritedAttrs), innerHtml)
	return defNode, isMultiColor
end

local FAST_BUILTIN_SHAPES = {
	circle = true, square = true, cross = true, diamond = true,
	["triangle-up"] = true, ["triangle-down"] = true,
	["triangle-right"] = true, ["triangle-left"] = true
}
local wikiFileCache = {}

-- 識別並提取維基共享資源/站內圖片檔名與明確像素尺寸 (具備 O(1) 標誌性特徵預篩與結果快取)
function p.extractWikiFileName(shape)
	if type(shape) ~= "string" or FAST_BUILTIN_SHAPES[shape] then
		return nil, nil
	end

	-- 標誌性特徵預篩:若以 < (SVG 標籤)、& (&lt;svg)、或 M/m (SVG Path 指令) 開頭且無冒號,直接略過
	local b1 = string.byte(shape, 1)
	if b1 == 60 or b1 == 38 then -- '<' or '&'
		return nil, nil
	end
	if (b1 == 77 or b1 == 109) and not find(shape, ":", 1, true) then -- 'M' or 'm'
		local b2 = string.byte(shape, 2)
		if b2 == 32 or b2 == 45 or b2 == 43 or b2 == 46 or (b2 and b2 >= 48 and b2 <= 57) then
			return nil, nil
		end
	end
	-- 檔案名稱必含 '.' (附檔名) 或 ':' (命名空間)
	if not find(shape, ".", 1, true) and not find(shape, ":", 1, true) then
		return nil, nil
	end

	local cached = wikiFileCache[shape]
	if cached ~= nil then
		if cached == false then return nil, nil end
		return cached[1], cached[2]
	end

	local s = mw.text.trim(p.restoreStripMarkers(shape))
	local dq = match(s, '^"(.*)"$')
	if dq then
		s = mw.text.trim(dq)
	else
		local sq = match(s, "^'(.*)'$")
		if sq then s = mw.text.trim(sq) end
	end
	if s == "" or find(s, "<[sS][vV][gG]") or find(s, "&lt;[sS][vV][gG]") then
		wikiFileCache[shape] = false
		return nil, nil
	end

	local explicitPx = tonumber(match(s, "%|%s*x?(%d+)%s*[pP][xX]"))
		or tonumber(match(s, "%|%s*%d+x(%d+)%s*[pP][xX]"))

	local inner = match(s, "^%[%[%s*([^%|%]]+)")
	if inner then
		s = mw.text.trim(inner)
	else
		s = mw.text.trim(match(s, "^([^%|]+)") or s)
	end

	local stripped = gsub(s, "^[fF][iI][lL][eE]%s*:%s*", "")
	if stripped == s then stripped = gsub(s, "^[iI][mM][aA][gG][eE]%s*:%s*", "") end
	if stripped == s then stripped = gsub(s, "^檔案%s*:%s*", "") end
	if stripped == s then stripped = gsub(s, "^文件%s*:%s*", "") end
	if stripped == s then stripped = gsub(s, "^圖片%s*:%s*", "") end
	if stripped == s then stripped = gsub(s, "^图像%s*:%s*", "") end

	if stripped ~= s then
		local res = mw.text.trim(stripped)
		wikiFileCache[shape] = { res, explicitPx }
		return res, explicitPx
	end
	if match(s, "^[^<>%[%]%|{}]+%.[pP][nN][gG]$")
		or match(s, "^[^<>%[%]%|{}]+%.[jJ][pP][eE]?[gG]$")
		or match(s, "^[^<>%[%]%|{}]+%.[gG][iI][fF]$")
		or match(s, "^[^<>%[%]%|{}]+%.[wW][eE][bB][pP]$")
		or match(s, "^[^<>%[%]%|{}]+%.[sS][vV][gG]$") then
		wikiFileCache[shape] = { s, explicitPx }
		return s, explicitPx
	end

	wikiFileCache[shape] = false
	return nil, nil
end

-- 將 customState 收集之維基圖片轉為百分比定位的 HTML 疊層字串
function p.FlushImgOverlays(customState, totalW, totalH)
	if not customState or not customState.imgOverlays or #customState.imgOverlays == 0 then
		return ""
	end
	local tw, th = max(1, totalW), max(1, totalH)
	local out = {}
	for i = 1, #customState.imgOverlays do
		local it = customState.imgOverlays[i]
		local leftPct = (it.cx / tw) * 100
		local topPct = (it.cy / th) * 100
		local opStyle = (it.alpha and it.alpha < 1) and format(";opacity:%.2f", it.alpha) or ""
		out[#out + 1] = format(
			'<span class="gr-sym-img" style="left:%.2f%%;top:%.2f%%%s">[[File:%s|%dpx|link=]]</span>',
			leftPct, topPct, opStyle, it.file, it.pxSize
		)
	end
	return table.concat(out)
end

function p.renderSymbol(shape, cx, cy, r, fill, stroke, strokeW, alpha, customState)
	local opAttr = (alpha and alpha < 1) and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', alpha, alpha) or ""
	local sAttr = (strokeW and strokeW > 0) and format(' stroke="%s" stroke-width="%.1f"', stroke, strokeW) or ""
	local fAttr = format(' fill="%s"', fill or "none")

	if not FAST_BUILTIN_SHAPES[shape] then
		local wikiFile, explicitPx = p.extractWikiFileName(shape)
		if wikiFile then
			if customState and customState.allowImgOverlay then
				customState.imgCount = (customState.imgCount or 0) + 1
				if customState.imgCount <= 100 then
					customState.imgOverlays = customState.imgOverlays or {}
					local calcPx = floor((tonumber(r) or 4.5) * 2.6 + 0.5)
					local pxSize = explicitPx and max(explicitPx, calcPx) or calcPx
					if customState.isLegend then
						pxSize = min(24, calcPx)
					end
					pxSize = max(6, min(180, pxSize))
					customState.imgOverlays[#customState.imgOverlays + 1] = {
						file = wikiFile,
						cx = cx,
						cy = cy,
						pxSize = pxSize,
						alpha = alpha
					}
					return ""
				end
			end
			shape = "circle"
			if fill == "none" then
				fAttr = format(' fill="%s"', stroke or "#1f77b4")
			end
		elseif type(shape) == "string" and (find(shape, "<[sS][vV][gG]") or find(shape, "&lt;[sS][vV][gG]")) then
			if customState then
				local defId = customState.map[shape]
				if not defId then
					customState.count = (customState.count or 0) + 1
					defId = "gr-csym-" .. customState.count
					local defNode = p.buildCustomSvgDef(shape, defId)
					if defNode then
						customState.defs[#customState.defs + 1] = defNode
						customState.map[shape] = defId
					else
						customState.map[shape] = false
						defId = false
					end
				end
				if defId then
					local d = r * 2.2
					local seriesCol = (fill and fill ~= "none") and fill or (stroke or "#1f77b4")
					local cAttr = format(' color="%s"', seriesCol)
					return format('<use href="#%s" xlink:href="#%s" transform="translate(%.2f,%.2f) scale(%.2f)"%s%s%s%s/>',
						defId, defId, cx, cy, d, cAttr, fAttr, sAttr, opAttr)
				end
			end
			shape = "circle"
		end
	end

	if shape == "square" then
		local half = (customState and customState.isScatterGrid) and (r * 1.02) or (r * 0.9)
		return format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f"%s%s%s/>', cx - half, cy - half, half * 2, half * 2, fAttr, sAttr, opAttr)
	elseif shape == "diamond" then
		local d = r * 1.3
		return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy - d, cx + d, cy, cx, cy + d, cx - d, cy, fAttr, sAttr, opAttr)
	elseif shape == "triangle-up" then
		local d = r * 1.25
		return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy - d, cx + d, cy + d * 0.75, cx - d, cy + d * 0.75, fAttr, sAttr, opAttr)
	elseif shape == "triangle-down" then
		local d = r * 1.25
		return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx, cy + d, cx + d, cy - d * 0.75, cx - d, cy - d * 0.75, fAttr, sAttr, opAttr)
	elseif shape == "triangle-right" then
		local d = r * 1.25
		return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx + d, cy, cx - d * 0.75, cy - d, cx - d * 0.75, cy + d, fAttr, sAttr, opAttr)
	elseif shape == "triangle-left" then
		local d = r * 1.25
		return format('<polygon points="%.2f,%.2f %.2f,%.2f %.2f,%.2f"%s%s%s/>', cx - d, cy, cx + d * 0.75, cy - d, cx + d * 0.75, cy + d, fAttr, sAttr, opAttr)
	elseif shape == "cross" then
		local d = r * 0.95
		local cStroke = (strokeW and strokeW > 0) and stroke or (fill ~= "none" and fill or "#1f77b4")
		local cw = (strokeW and strokeW > 0) and strokeW or 2.0
		return format('<path d="M %.2f,%.2f H %.2f M %.2f,%.2f V %.2f" stroke="%s" stroke-width="%.1f" stroke-linecap="round"%s/>',
			cx - d, cy, cx + d, cx, cy - d, cy + d, cStroke, cw, opAttr)
	elseif type(shape) == "string" and shape ~= "circle" then
		local scale = r * 2.2
		return format('<path d="%s" transform="translate(%.2f,%.2f) scale(%.2f)"%s%s%s/>', shape, cx, cy, scale, fAttr, sAttr, opAttr)
	else
		return format('<circle cx="%.2f" cy="%.2f" r="%.2f"%s%s%s/>', cx, cy, r, fAttr, sAttr, opAttr)
	end
end

-- 快取 Tooltip 圖示之 mw.svg Strip Marker (固定 18px 清晰尺寸,確保 Tooltip 外框穩定不破版)
local swatchMarkerCache = {}

function p.buildSwatchHtml(colorHex, swatchSpec)
	if type(swatchSpec) == "table" and swatchSpec.shape then
		local shp = tostring(swatchSpec.shape)
		local fillCol = swatchSpec.fill or colorHex or "#1f77b4"
		local sw = tonumber(swatchSpec.strokeWidth) or 0

		-- 快速通道:只要是實心圓點 (circle),一律走純 CSS .gr-swatch,零 mw.svg 開銷
		if shp == "circle" and fillCol ~= "none" and sw <= 0 then
			return colorHex and format('<span class="gr-swatch" style="background:%s"></span>', p.esc(colorHex)) or ""
		end

		local wikiFile = p.extractWikiFileName(shp)
		if wikiFile then
			return format('<span class="gr-swatch-file">[[File:%s|18px|link=]]</span>', wikiFile)
		end

		local strokeCol = swatchSpec.stroke or colorHex or "#1f77b4"
		if fillCol == "none" and sw <= 0 then sw = 1.6 end

		local cacheKey = table.concat({ shp, tostring(fillCol), tostring(strokeCol), tostring(sw) }, "|")
		if swatchMarkerCache[cacheKey] then
			return swatchMarkerCache[cacheKey]
		end

		local cs = { defs = {}, map = {}, count = 0 }
		local symNode = p.renderSymbol(shp, 10, 10, 8.5, fillCol, strokeCol, sw, 1, cs)
		local innerContent = (cs.count > 0)
			and format('<defs>%s</defs>%s', table.concat(cs.defs), symNode)
			or symNode

		local svgObj = mw.svg.new()
			:setAttribute('width', '18')
			:setAttribute('height', '18')
			:setAttribute('viewBox', '0 0 20 20')
			:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
		if cs.count > 0 then
			svgObj:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
		end
		local marker = svgObj
			:setImgAttribute('width', '18')
			:setImgAttribute('height', '18')
			:setImgAttribute('class', 'gr-swatch-img')
			:setContent(innerContent)
			:toImage()

		swatchMarkerCache[cacheKey] = marker
		return marker
	end

	return colorHex and format('<span class="gr-swatch" style="background:%s"></span>', p.esc(colorHex)) or ""
end

-- 根據感應區在畫布上的百分比座標,自動附加防切邊方向類別 (.gr-tip-b, .gr-tip-l, .gr-tip-r)
local function buildTipNode(leftPct, topPct, tipHtml, colorHex, swatchSpec)
	local cls = { "gr-tip" }
	if topPct < 32 then cls[#cls + 1] = "gr-tip-b" end
	if leftPct < 22 then
		cls[#cls + 1] = "gr-tip-l"
	elseif leftPct > 78 then
		cls[#cls + 1] = "gr-tip-r"
	end
	local swatch = p.buildSwatchHtml(colorHex, swatchSpec)
	return format('<span class="%s">%s%s</span>', table.concat(cls, " "), swatch, p.restoreStripMarkers(tipHtml))
end

-- 建立點狀懸停感應區 (折線圖點、散佈氣泡、圓餅扇形、雷達頂點、地圖標記)
function p.buildPointHit(cx, cy, totalW, totalH, tipHtml, colorHex, diameterPx, swatchSpec)
	if not tipHtml or tipHtml == "" then return "" end
	local leftPct = (cx / max(1, totalW)) * 100
	local topPct = (cy / max(1, totalH)) * 100
	local sizeStyle = (diameterPx and diameterPx > 14) and format(";width:%dpx;height:%dpx", ceil(diameterPx), ceil(diameterPx)) or ""
	return format('<span class="gr-hit" tabindex="0" style="left:%.2f%%;top:%.2f%%%s">%s</span>',
		leftPct, topPct, sizeStyle, buildTipNode(leftPct, topPct, tipHtml, colorHex, swatchSpec))
end

-- 建立矩形懸停感應區 (長條圖、堆疊長條、箱形圖、甘特圖、二維熱圖、氣候區間)
function p.buildRectHit(x, y, w, h, totalW, totalH, tipHtml, colorHex, swatchSpec)
	if not tipHtml or tipHtml == "" then return "" end
	local tw, th = max(1, totalW), max(1, totalH)
	local leftPct = (x / tw) * 100
	local topPct = (y / th) * 100
	local wPct = (max(2, w) / tw) * 100
	local hPct = (max(2, h) / th) * 100
	local centerLeftPct = leftPct + wPct / 2
	return format('<span class="gr-hbar" tabindex="0" style="left:%.2f%%;top:%.2f%%;width:%.2f%%;height:%.2f%%">%s</span>',
		leftPct, topPct, wPct, hPct, buildTipNode(centerLeftPct, topPct, tipHtml, colorHex, swatchSpec))
end

-- 載入 Module:Graph/style.css 模板樣式標籤
local cachedStyleTag = nil
function p.getTemplateStyleTag()
	if cachedStyleTag then return cachedStyleTag end
	local wf = mw.getCurrentFrame()
	if wf and wf.extensionTag then
		local ok, tag = pcall(function()
			return wf:extensionTag{ name = 'templatestyles', args = { src = 'Module:Graph/style.css' } }
		end)
		if ok and tag then
			cachedStyleTag = tag
			return tag
		end
	end
	return ""
end

-- 將 SVG 緩衝區中的 <text> 標籤抽離為瘦身版 HTML 疊層 (結合 Module:Graph/style.css 類別)
function p.finalizeSvgWithOverlay(buf, totalW, totalH, imgClass, hasXlink, extraOverlayHtml, animClass)
	local wInt = max(1, ceil(totalW))
	local hInt = max(1, ceil(totalH))
	local svgOnlyBuf = {}
	local overlayBuf = {}

	for i = 1, #buf do
		local item = buf[i]
		if type(item) == "string" and sub(item, 1, 6) == "<text " and not find(item, 'data%-in%-svg="1"', 1, false) then
			local attrs, innerText = match(item, "^<text%s+([^>]+)>(.*)</text>$")
			if attrs and innerText and innerText ~= "" then
				local x = tonumber(match(attrs, 'x="([^"]+)"')) or 0
				local y = tonumber(match(attrs, 'y="([^"]+)"')) or 0
				local anchor = match(attrs, 'text%-anchor="([^"]+)"') or "start"
				local fSize = match(attrs, 'font%-size="([^"]+)"')
				local fWeight = match(attrs, 'font%-weight="([^"]+)"')
				local fill = match(attrs, 'fill="([^"]+)"')
				local opacity = match(attrs, 'opacity="([^"]+)"')
				local rotDeg = tonumber(match(attrs, 'rotate%(%s*([%+%-]?[%d%.]+)'))
				local hasHalo = match(attrs, 'data%-halo="1"') ~= nil

				local cls = { "gr-t" }
				local anchorCls = "gr-ts"
				local tx = "0%"
				if anchor == "middle" then
					anchorCls = "gr-tm"
					tx = "-50%"
				elseif anchor == "end" then
					anchorCls = "gr-te"
					tx = "-100%"
				end
				if not rotDeg or abs(rotDeg) <= 1e-4 then
					cls[#cls + 1] = anchorCls
				end
				if fWeight == "bold" then
					cls[#cls + 1] = "gr-b"
				end
				if hasHalo then
					cls[#cls + 1] = "gr-halo"
				end

				local styles = { format("left:%.2f%%;top:%.2f%%", (x / wInt) * 100, (y / hInt) * 100) }
				if rotDeg and abs(rotDeg) > 1e-4 then
					styles[#styles + 1] = format("transform:rotate(%.1fdeg) translate(%s,-80%%)", rotDeg, tx)
				end
				if fSize and fSize ~= "11px" then
					styles[#styles + 1] = "font-size:" .. fSize
				end
				if fill and fill ~= "#54595d" then
					styles[#styles + 1] = "color:" .. fill
				end
				if opacity then
					styles[#styles + 1] = "opacity:" .. opacity
				end

				overlayBuf[#overlayBuf + 1] = format('<span class="%s" style="%s">%s</span>',
					table.concat(cls, " "), table.concat(styles, ";"), p.restoreStripMarkers(innerText))
			end
		elseif type(item) == "string" and item ~= "" then
			local cleanSvgItem = gsub(item, '%s*data%-in%-svg="1"', "")
			svgOnlyBuf[#svgOnlyBuf + 1] = gsub(cleanSvgItem, "__MW_SM_PRE__UNIQ.-QINU__MW_SM_SUF__", "")
		end
	end


	if extraOverlayHtml and extraOverlayHtml ~= "" then
		overlayBuf[#overlayBuf + 1] = extraOverlayHtml
	end

	local svgObj = mw.svg.new()
		:setAttribute('viewBox', format('0 0 %d %d', wInt, hInt))
		:setAttribute('xmlns', 'http://www.w3.org/2000/svg')
	if hasXlink then
		svgObj:setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
	end
	local imgHtml = svgObj
		:setImgAttribute('width', tostring(wInt))
		:setImgAttribute('height', tostring(hInt))
		:setImgAttribute('class', imgClass or 'graph-chart-svg')
		:setContent(table.concat(svgOnlyBuf))
		:toImage()

	local styleTag = p.getTemplateStyleTag()
	local rootCls = (animClass and animClass ~= "") and ("svg-graph " .. animClass) or "svg-graph"
	if #overlayBuf == 0 then
		return styleTag .. format('<div class="%s" style="width:%dpx">%s</div>', rootCls, wInt, imgHtml)
	end

	return styleTag .. format(
		'<div class="%s" style="width:%dpx">%s<div class="gr-ov">%s</div></div>',
		rootCls, wInt, imgHtml, table.concat(overlayBuf)
	)
end

return p