편집 요약 없음 |
편집 요약 없음 태그: 되돌려진 기여 |
||
35번째 줄: | 35번째 줄: | ||
end | end | ||
-- transpose without re-stringifying | -- transpose without re-stringifying (보존) | ||
local function transpose(m, fill) | local function transpose(m, fill) | ||
fill = fill or "" | fill = fill or "" | ||
52번째 줄: | 52번째 줄: | ||
end | end | ||
-- === 열 스타일 | -- === 열 스타일 프리컴파일 (고정/스크롤 분리) === | ||
local | local fixed_col_styles -- 왼쪽 고정표 스타일 캐시 | ||
local scroll_col_styles -- 오른쪽 스크롤표 스타일 캐시 | |||
local function | local function ensure_fixed_col_styles(ncols, headerColCount) | ||
if | if fixed_col_styles then return end | ||
fixed_col_styles = {} | |||
for j = 1, ncols do | for j = 1, ncols do | ||
if | local absj = j -- 고정표는 1..headerColCount | ||
if headerColCount >= 3 and absj == headerColCount - 2 then | |||
elseif | fixed_col_styles[j] = [[style="vertical-align: middle !important; border-right: none; white-space: nowrap;"]] | ||
elseif headerColCount >= 2 and absj == headerColCount - 1 then | |||
elseif | fixed_col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; border-right: none; vertical-align: middle !important; min-width: 8.5ch; white-space: nowrap;"]] | ||
elseif absj == headerColCount then | |||
fixed_col_styles[j] = [[style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem; white-space: nowrap;"]] | |||
elseif absj < (headerColCount - 2) then | |||
elseif | fixed_col_styles[j] = [[style="border-right: 1px solid; white-space: nowrap;"]] | ||
else | else | ||
fixed_col_styles[j] = [[style="width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]] | |||
end | end | ||
end | end | ||
end | end | ||
local function ensure_scroll_col_styles(ncols, headerColCount) | |||
if scroll_col_styles then return end | |||
scroll_col_styles = {} | |||
-- | for j = 1, ncols do | ||
local absj = headerColCount + j -- 스크롤표의 절대 열 인덱스 | |||
if absj == headerColCount - 2 then | |||
scroll_col_styles[j] = [[style="vertical-align: middle !important; border-right: none; white-space: nowrap;"]] | |||
elseif absj == headerColCount - 1 then | |||
scroll_col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; border-right: none; vertical-align: middle !important; min-width: 8.5ch; white-space: nowrap;"]] | |||
elseif absj == headerColCount then | |||
scroll_col_styles[j] = [[style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem; white-space: nowrap;"]] | |||
elseif absj == headerColCount + 1 then | |||
scroll_col_styles[j] = [[style="min-width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]] | |||
elseif absj < headerColCount - 2 then | |||
scroll_col_styles[j] = [[style="border-right: 1px solid; white-space: nowrap;"]] | |||
else | |||
scroll_col_styles[j] = [[style="width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]] | |||
end | end | ||
end | |||
end | |||
end | end | ||
-- Memoizers | -- Memoizers (보존) | ||
local C_cache = setmetatable({}, { __mode = "kv" }) | local C_cache = setmetatable({}, { __mode = "kv" }) | ||
local S_cache = setmetatable({}, { __mode = "kv" }) | local S_cache = setmetatable({}, { __mode = "kv" }) | ||
170번째 줄: | 172번째 줄: | ||
end | end | ||
-- | -- per-char wrapper for text contained in single quotes, with memoization | ||
local justify_cache = setmetatable({}, { __mode = "kv" }) | |||
local function justifys(s) | |||
if not str_find(s, "'", 1, true) then return s end -- 따옴표 없으면 패스 | |||
return u.gsub(s, "'(.-)'", function(inner) | |||
local cached = justify_cache[inner] | |||
if cached then return cached end | |||
local pieces = {} | |||
for ch in u.gmatch(inner, ".") do | |||
pieces[#pieces+1] = "<span>" .. ch .. "</span>" | |||
end | |||
local out = '<span class="justify-chars">' .. tbl_concat(pieces) .. "</span>" | |||
justify_cache[inner] = out | |||
return out | |||
end) | |||
end | |||
-- ==== TOKEN NORMALIZER (멀티→단일→사후 치환 최소화) ==== | -- ==== TOKEN NORMALIZER (멀티→단일→사후 치환 최소화) ==== | ||
local function normalize_tokens(s) | local function normalize_tokens(s) | ||
216번째 줄: | 235번째 줄: | ||
local v = raw | local v = raw | ||
-- 1) 아주 빠른 no-op 경로(확장): 토큰 없음/따옴표 없음/대문자 토큰 없음/배지 토큰 없음 | |||
-- 1) 아주 빠른 no-op 경로(확장): | local has_ascii_tokens = str_find(v, "[<>hr#&g%^;x%.iq%-/vkbCNS{}]", 1) | ||
local has_quote = str_find(v, "'", 1, true) | |||
local has_caps_token = str_find(v, "%%u%%u%%u") -- ARR, DEP, LEX, ... | |||
local has_braced_token = str_find(v, "[BCNS][S C]?%%b{}") -- BC{..}, BS{..}, C{..}, S{..}, N{..} | |||
local has_ascii_tokens = str_find(v, "[<>hr#&g%^;x%.iq%-/vkbCNS{}]", 1) | |||
local has_quote = str_find(v, "'", 1, true) | |||
local has_caps_token = str_find(v, "%u%u%u") -- ARR, DEP, LEX | |||
local has_braced_token = str_find(v, "[BCNS][S C]?%b{}") -- BC{..}, BS{..}, C{..}, S{..}, N{..} | |||
if not (has_ascii_tokens or has_quote or has_caps_token or has_braced_token) then | |||
cell_cache[raw] = v | |||
return v | |||
end | |||
-- 2) 단순 토큰 | -- 2) 단순 토큰 | ||
297번째 줄: | 310번째 줄: | ||
title = title:gsub("l", '<div style="border: var(--text) 1.5px solid; display: inline-block; padding-top: 2px; padding-bottom: 3px; font-size: 1.14rem; font-weight: normal; vertical-align: top; line-height: 1.35rem;">') | title = title:gsub("l", '<div style="border: var(--text) 1.5px solid; display: inline-block; padding-top: 2px; padding-bottom: 3px; font-size: 1.14rem; font-weight: normal; vertical-align: top; line-height: 1.35rem;">') | ||
title = title:gsub("UNBOLD", '<div style="font-weight: 400; display: inline; font-size: 1.17rem; vertical-align: 1.5px; vertical-align: -webkit-baseline-middle;">') | title = title:gsub("UNBOLD", '<div style="font-weight: 400; display: inline; font-size: 1.17rem; vertical-align: 1.5px; vertical-align: -webkit-baseline-middle;">') | ||
title = title:gsub("KUDARI", '<div class="arrow arrow--down outline">') | title = title:gsub("KUDARI", '<div class="arrow arrow--down outline">') | ||
title = title:gsub("NOBORI", '<div class="arrow arrow--up solid">') | title = title:gsub("NOBORI", '<div class="arrow arrow--up solid">') | ||
local arr = csv_to_array(csv) | local arr = csv_to_array(csv) | ||
-- 출력 버퍼 | |||
local out = {} | local out = {} | ||
out[#out+1] = '<div style="display: flex; flex-direction: row; padding-bottom: 1rem;"><div class="wm-rl timetabletitle" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">' | out[#out+1] = '<div style="display: flex; flex-direction: row; padding-bottom: 1rem;">' | ||
out[#out+1] = '<div class="wm-rl timetabletitle" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">' | |||
out[#out+1] = title | out[#out+1] = title | ||
out[#out+1] = | out[#out+1] = '</div>' | ||
-- 컨테이너: 고정표 + 스크롤표 | |||
out[#out+1] = '<div style="display: flex; flex-direction: row; gap: 0;">' | |||
local total_rows = #arr | local total_rows = #arr | ||
-- 만약 headerColCount < 1 이면 기존 단일 스크롤 표로 폴백 | |||
if headerColCount < 1 then | |||
out[#out+1] = string.format('<div style="border: 2.5px solid var(--text); padding: 3px !important; overflow-x: auto;">\n{| class="timetable" style="text-align: right; border-spacing: 30px;">\n') | |||
else | |||
-- 왼쪽 고정 표 시작 (경계 겹침 방지 위해 오른쪽 테두리 제거) | |||
out[#out+1] = '<div class="timetable-fixed" style="border: 2.5px solid var(--text); border-right: 0; padding: 3px !important; overflow: hidden;">\n' | |||
out[#out+1] = '{| class="timetable timetable--fixed" style="text-align: right; border-spacing: 30px; table-layout: fixed;">\n' | |||
end | |||
-- 스타일 프리컴파일 (테이블 분리 반영) | |||
if headerColCount >= 1 then ensure_fixed_col_styles(headerColCount, headerColCount) end | |||
-- 행 루프 (왼쪽 고정 표) | |||
if headerColCount >= 1 then | |||
for i = 1, total_rows do | |||
local row = arr[i] | |||
local left_values = {} | |||
-- 왼쪽 고정 열 채우기 | |||
for c = 1, math.min(headerColCount, #row) do | |||
local v = transform_cell(row[c]) | |||
if not str_find(v, "|", 1, true) then v = "|" .. v end | |||
left_values[#left_values+1] = v | |||
end | |||
-- 열 스타일 부여 | |||
for j = 1, #left_values do | |||
left_values[j] = fixed_col_styles[j] .. left_values[j] | |||
end | |||
-- "!!" 제거 | |||
for j = #left_values, 1, -1 do | |||
if string.find(left_values[j], "!!", 1, true) then table.remove(left_values, j) end | |||
end | |||
-- 구분선 규칙 동일 적용 | |||
if i < headerRowCount + 1 then | |||
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | |||
elseif i == headerRowCount + 1 then | |||
out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | |||
elseif i == total_rows - footerRowCount + 1 then | |||
out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | |||
elseif i == total_rows and footerRowCount > 1 then | |||
out[#out+1] = '|- class="forcetopalign" style="border-top: 1px solid var(--text);"\n| ' | |||
elseif i > total_rows - footerRowCount + 1 then | |||
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | |||
else | |||
out[#out+1] = '|-\n| ' | |||
end | |||
out[#out+1] = tbl_concat(left_values, ' \n| ') | |||
out[#out+1] = '\n' | |||
end | |||
out[#out+1] = '|}\n' | |||
out[#out+1] = '</div>' | |||
end | |||
-- 오른쪽 스크롤 표 시작 (왼쪽 경계 제거해 이음매 자연스럽게) | |||
out[#out+1] = '<div class="timetable-scroll" style="border: 2.5px solid var(--text); border-left: 0; padding: 3px !important; overflow-x: auto;">\n' | |||
out[#out+1] = '{| class="timetable timetable--scroll" style="text-align: right; border-spacing: 30px; table-layout: fixed; white-space: nowrap;">\n' | |||
-- 스타일 프리컴파일 (스크롤 영역) | |||
local max_cols = 0 | |||
for i = 1, total_rows do if #arr[i] > max_cols then max_cols = #arr[i] end end | |||
local right_cols = math.max(0, max_cols - headerColCount) | |||
ensure_scroll_col_styles(right_cols, headerColCount) | |||
-- 행 루프 (오른쪽 스크롤 표) | |||
for i = 1, total_rows do | for i = 1, total_rows do | ||
local row = arr[i] | local row = arr[i] | ||
local | local right_values = {} | ||
for c = 1, #row do | for c = headerColCount + 1, #row do | ||
local v = transform_cell(row[c]) | local v = transform_cell(row[c]) | ||
if not str_find(v, "|", 1, true) then | if not str_find(v, "|", 1, true) then v = "|" .. v end | ||
right_values[#right_values+1] = v | |||
end | end | ||
for j = 1, #right_values do | |||
right_values[j] = scroll_col_styles[j] .. right_values[j] | |||
for j = 1, # | |||
end | end | ||
for j = #right_values, 1, -1 do | |||
for j = # | if string.find(right_values[j], "!!", 1, true) then table.remove(right_values, j) end | ||
if string.find( | |||
end | end | ||
if i < headerRowCount + 1 then | if i < headerRowCount + 1 then | ||
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | ||
elseif i == headerRowCount + 1 then | elseif i == headerRowCount + 1 then | ||
out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | ||
elseif i == total_rows - footerRowCount + 1 then | elseif i == total_rows - footerRowCount + 1 then | ||
out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | out[#out+1] = '|- style="border-top: double var(--text);"\n| ' | ||
elseif i == total_rows and footerRowCount > 1 then | elseif i == total_rows and footerRowCount > 1 then | ||
out[#out+1] = '|- class="forcetopalign" style="border-top: 1px solid var(--text);"\n| ' | out[#out+1] = '|- class="forcetopalign" style="border-top: 1px solid var(--text);"\n| ' | ||
elseif i > total_rows - footerRowCount + 1 then | elseif i > total_rows - footerRowCount + 1 then | ||
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| ' | ||
else | else | ||
out[#out+1] = | out[#out+1] = '|-\n| ' | ||
end | end | ||
out[#out+1] = tbl_concat(right_values, ' \n| ') | |||
out[#out+1] = '\n' | |||
end | end | ||
out[#out+1] = | out[#out+1] = '|}\n' | ||
out[#out+1] = '</div>' | |||
-- 컨테이너 닫기 | |||
out[#out+1] = '</div>' -- split container | |||
out[#out+1] = '</div>' -- 전체 컨테이너 | |||
return tbl_concat(out) | return tbl_concat(out) | ||
end | end | ||
return p | return p |
2025년 8월 15일 (금) 21:40 판
이 모듈에 대한 설명문서는 모듈:Timetable/설명문서에서 만들 수 있습니다
local p = {}
local u = mw.ustring
-- ==== CACHES ====
local cell_cache = setmetatable({}, { __mode = "kv" }) -- 셀 단위 전체 결과 캐시
local justify_cache = setmetatable({}, { __mode = "kv" }) -- 기존 유지
-- ==== TOKEN MAPS (단일 gsub용) ====
local single_map = {
["<"] = " colspan=", [">"] = "⇨", ["h"] = "HEADERSTYLE", ["~"] = "HEIGHTSET",
["r"] = "RIGHTLEFT", ["#"] = "NOBORDERTOP", ["&"] = "YESBORDERTOP",
["g"] = "GREATER", ["^"] = " rowspan=", [";"] = " |", ["x"] = "ELLIPSIS",
["."] = "ENDSPAN", ["i"] = "OPENCENTERSPAN|", ["q"] = "OPENCENTERSPAN〃",
["-"] = "OPENCENTERSPAN_", ["v"] = "OPENCENTERSPAN↓",
["k"] = '<span style="font-weight: 700;">',
["b"] = '<span style="font-size: 1.25em; font-weight: 700;">',
["/"] = "<br/>"
}
-- hot locals
local str_gsub, str_find, str_match, tbl_concat = string.gsub, string.find, string.match, table.concat
-- CSV -> array (simple; same behavior as your original)
local function csv_to_array(csv)
local rows = {}
for line in tostring(csv):gmatch("[^\r\n]+") do
local row = {}
for cell in line:gmatch("([^,]+)") do
cell = cell:gsub("^%s+", ""):gsub("%s+$", "")
row[#row+1] = cell
end
rows[#rows+1] = row
end
return rows
end
-- transpose without re-stringifying (보존)
local function transpose(m, fill)
fill = fill or ""
local out, rows, maxc = {}, #m, 0
for r = 1, rows do
if #m[r] > maxc then maxc = #m[r] end
end
for c = 1, maxc do
local newrow = {}
for r = 1, rows do
newrow[r] = m[r][c] or fill
end
out[c] = newrow
end
return out
end
-- === 열 스타일 프리컴파일 (고정/스크롤 분리) ===
local fixed_col_styles -- 왼쪽 고정표 스타일 캐시
local scroll_col_styles -- 오른쪽 스크롤표 스타일 캐시
local function ensure_fixed_col_styles(ncols, headerColCount)
if fixed_col_styles then return end
fixed_col_styles = {}
for j = 1, ncols do
local absj = j -- 고정표는 1..headerColCount
if headerColCount >= 3 and absj == headerColCount - 2 then
fixed_col_styles[j] = [[style="vertical-align: middle !important; border-right: none; white-space: nowrap;"]]
elseif headerColCount >= 2 and absj == headerColCount - 1 then
fixed_col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; border-right: none; vertical-align: middle !important; min-width: 8.5ch; white-space: nowrap;"]]
elseif absj == headerColCount then
fixed_col_styles[j] = [[style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem; white-space: nowrap;"]]
elseif absj < (headerColCount - 2) then
fixed_col_styles[j] = [[style="border-right: 1px solid; white-space: nowrap;"]]
else
fixed_col_styles[j] = [[style="width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]]
end
end
end
local function ensure_scroll_col_styles(ncols, headerColCount)
if scroll_col_styles then return end
scroll_col_styles = {}
for j = 1, ncols do
local absj = headerColCount + j -- 스크롤표의 절대 열 인덱스
if absj == headerColCount - 2 then
scroll_col_styles[j] = [[style="vertical-align: middle !important; border-right: none; white-space: nowrap;"]]
elseif absj == headerColCount - 1 then
scroll_col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; border-right: none; vertical-align: middle !important; min-width: 8.5ch; white-space: nowrap;"]]
elseif absj == headerColCount then
scroll_col_styles[j] = [[style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem; white-space: nowrap;"]]
elseif absj == headerColCount + 1 then
scroll_col_styles[j] = [[style="min-width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]]
elseif absj < headerColCount - 2 then
scroll_col_styles[j] = [[style="border-right: 1px solid; white-space: nowrap;"]]
else
scroll_col_styles[j] = [[style="width: 37.5px; border-left: 1px solid var(--text); white-space: nowrap;"]]
end
end
end
-- Memoizers (보존)
local C_cache = setmetatable({}, { __mode = "kv" })
local S_cache = setmetatable({}, { __mode = "kv" })
local N_cache = setmetatable({}, { __mode = "kv" })
local BC_cache = setmetatable({}, { __mode = "kv" })
local BS_cache = setmetatable({}, { __mode = "kv" })
local function transform_BC(inner)
local cached = BC_cache[inner]; if cached then return cached end
inner = inner:gsub("^%s*(.-)%s*$", "%1")
local out
if tonumber(inner) ~= nil then
out = '<span class="badge badge--circle badge--filled badge--num forcedcenter">' .. inner .. '</span>'
else
out = '<span class="badge badge--circle badge--filled forcedcenter"><span class="stretch-text" data-max-width="1.38rem"><span>' .. inner .. '</span></span></span>'
end
BC_cache[inner] = out
return out
end
local function transform_N(inner)
local cached = N_cache[inner]; if cached then return cached end
inner = inner:gsub("^%s*(.-)%s*$", "%1")
local out = '<span class="seriftt">' .. inner .. '</span>'
N_cache[inner] = out
return out
end
local function transform_BS(inner)
local cached = BS_cache[inner]; if cached then return cached end
inner = inner:gsub("^%s*(.-)%s*$", "%1")
local out
if tonumber(inner) ~= nil then
out = '<span class="badge badge--filled badge--tight forcetopalign forcedcenter badge--num">' .. inner .. '</span>'
else
out = '<span class="badge badge--filled badge--tight forcetopalign forcedcenter"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
end
BS_cache[inner] = out
return out
end
local function transform_C(inner)
local cached = C_cache[inner]; if cached then return cached end
inner = inner:gsub("^%s*(.-)%s*$", "%1")
local as_num = tonumber(inner)
local result
if as_num and as_num >= 0 and as_num <= 34 then
if as_num == 0 then
result = "⓪"
elseif as_num <= 20 then
result = '<span class="forcedcenter"></span>' .. u.char(0x2460 + (as_num - 1))
else
result = '<span class="forcedcenter"></span>' .. u.char(0x3251 + (as_num - 21))
end
elseif as_num then
result = '<span class="badge badge--circle forcedcenter badge--num">' .. inner .. '</span>'
else
result = '<span class="badge badge--circle forcedcenter"><span class="stretch-text" data-max-width="1.38rem"><span>' .. inner .. '</span></span></span>'
end
C_cache[inner] = result
return result
end
local function transform_S(inner)
local cached = S_cache[inner]; if cached then return cached end
inner = inner:gsub("^%s*(.-)%s*$", "%1")
local result
if tonumber(inner) ~= nil then
result = '<span class="badge badge--tight forcetopalign forcedcenter badge--num">' .. inner .. '</span>'
else
result = '<span class="badge badge--tight forcetopalign forcedcenter"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
end
S_cache[inner] = result
return result
end
-- per-char wrapper for text contained in single quotes, with memoization
local justify_cache = setmetatable({}, { __mode = "kv" })
local function justifys(s)
if not str_find(s, "'", 1, true) then return s end -- 따옴표 없으면 패스
return u.gsub(s, "'(.-)'", function(inner)
local cached = justify_cache[inner]
if cached then return cached end
local pieces = {}
for ch in u.gmatch(inner, ".") do
pieces[#pieces+1] = "<span>" .. ch .. "</span>"
end
local out = '<span class="justify-chars">' .. tbl_concat(pieces) .. "</span>"
justify_cache[inner] = out
return out
end)
end
-- ==== TOKEN NORMALIZER (멀티→단일→사후 치환 최소화) ====
local function normalize_tokens(s)
-- 멀티문자 먼저 (4회만)
s = s:gsub("%-%-", "DOUBLEFINISHLINE")
:gsub(">>", "➡")
:gsub("<>", "OPENCENTERSPAN◆")
:gsub("%.%.", "FULLSTOP")
:gsub("rr", "OPENTCUSPAN")
-- 단일문자 치환 1회
s = s:gsub("([<>h~r#&g%^;x%.iq%-/vkb])", function(ch)
return single_map[ch] or ch
end)
-- 사후 치환
s = s:gsub("ENDSPAN","</span>")
:gsub("ELLIPSIS","OPENCENTERSPAN⋯")
:gsub("OPENSPAN", '<span></span>')
:gsub("HEADERSTYLE", [[colspan="3" style="padding-left: 1.08rem; padding-right: 1.08rem; text-align: center; border-right: none; vertical-align: middle;"]])
:gsub("HEIGHTSET", ' height=')
:gsub("RIGHTLEFT", '<span class="wm-rl">')
:gsub("NOBORDERTOP", '<span class="nobordertop"></span>')
:gsub("YESBORDERTOP", '<span class="yesbordertop"></span>')
:gsub("GREATER", '<span style="font-size: 1.2em;">')
:gsub("OPENCENTERSPAN", '<span class="forcedcenter"></span>')
:gsub("DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
:gsub("FULLSTOP", '%.')
:gsub("OPENTCUSPAN", '<span style="text-combine-upright: all;">')
return s
end
-- Per-cell full transform (캐시/단축경로/조건부 justifys)
local function transform_cell(raw)
-- 0) 캐시
local hit = cell_cache[raw]
if hit then return hit end
-- "!!"은 원래대로 유지(뒤에서 제거 루프가 처리) — #3은 적용 안 함
if str_find(raw, "!!", 1, true) then
cell_cache[raw] = "!!"
return "!!"
end
local v = raw
-- 1) 아주 빠른 no-op 경로(확장): 토큰 없음/따옴표 없음/대문자 토큰 없음/배지 토큰 없음
local has_ascii_tokens = str_find(v, "[<>hr#&g%^;x%.iq%-/vkbCNS{}]", 1)
local has_quote = str_find(v, "'", 1, true)
local has_caps_token = str_find(v, "%%u%%u%%u") -- ARR, DEP, LEX, ...
local has_braced_token = str_find(v, "[BCNS][S C]?%%b{}") -- BC{..}, BS{..}, C{..}, S{..}, N{..}
if not (has_ascii_tokens or has_quote or has_caps_token or has_braced_token) then
cell_cache[raw] = v
return v
end
-- 2) 단순 토큰
v = str_gsub(v, "c", 'OPENCENTERSPAN')
-- 3) 정규화 (멀티→단일→사후)
v = normalize_tokens(v)
-- 4) 고정 기호 치환
v = str_gsub(v, "DEP", '<span style="font-size: 0.92rem;">發</span>')
v = str_gsub(v, "ARR", '<span style="font-size: 0.92rem;">着</span>')
v = str_gsub(v, "DIV", '<span class="forcedcenter" style="font-size: 1.1rem; vertical-align: middle; line-height: 0.9rem;">╌</span>')
v = str_gsub(v, "GRR", '<span class="forcedcenter"></span>')
v = str_gsub(v, "GRN", '<span class="forcedcenter"></span>')
v = str_gsub(v, "LEX", '<span class="forcetopalign forcedcenter icon-lg"></span>')
v = str_gsub(v, "LSL", '<span class="forcetopalign forcedcenter icon-lg">🌃</span>')
v = str_gsub(v, "XSL", '<span class="forcetopalign forcedcenter icon-lg"></span>')
v = str_gsub(v, "TRD", '<span class="forcedcenter">⬎</span>')
v = str_gsub(v, "TRU", '<span class="forcedcenter">⬏</span>')
v = str_gsub(v, "TLD", '<span class="forcedcenter">⬐</span>')
v = str_gsub(v, "TLU", '<span class="forcedcenter">⬑</span>')
v = str_gsub(v, "TDL", '<span class="forcedcenter">↲</span>')
v = str_gsub(v, "TDR", '<span class="forcedcenter">↳</span>')
v = str_gsub(v, "TUL", '<span class="forcedcenter">↰</span>')
v = str_gsub(v, "TUR", '<span class="forcedcenter">↱</span>')
v = str_gsub(v, "MLD", '<span class="forcedcenter">┏</span>')
v = str_gsub(v, "MRD", '<span class="forcedcenter">┓</span>')
v = str_gsub(v, "MLU", '<span class="forcedcenter">┗</span>')
v = str_gsub(v, "MRU", '<span class="forcedcenter">┛</span>')
v = str_gsub(v, "TNO", '<span class="forcedcenter bracket-open">[</span>')
v = str_gsub(v, "TNC", '<span class="forcedcenter bracket-close">]</span>')
v = str_gsub(v, "SUO", '<span class="forcedcenter bracket-open">{</span>')
v = str_gsub(v, "SUC", '<span class="forcedcenter bracket-close">}</span>')
v = str_gsub(v, "PAO", '<span class="forcedcenter bracket-open">(</span>')
v = str_gsub(v, "PAC", '<span class="forcedcenter bracket-close">)</span>')
v = str_gsub(v, "SPA", '<span class="forcedcenter"> </span>')
-- 5) 배지류
v = u.gsub(v, 'BC{(.-)}', transform_BC)
v = u.gsub(v, 'BS{(.-)}', transform_BS)
v = u.gsub(v, 'C{(.-)}', transform_C)
v = u.gsub(v, 'S{(.-)}', transform_S)
v = u.gsub(v, 'N{(.-)}', transform_N)
-- 6) 따옴표 있을 때만 per-char justify
v = justifys(v)
cell_cache[raw] = v
return v
end
function p.csv(frame)
local headerRowCount = tonumber(frame.args["header"]) or 4
local headerColCount = tonumber(frame.args["left"]) or 3
local footerRowCount = tonumber(frame.args["footer"]) or 1
local title = frame.args["title"] or '제목 (title 변수 입력)'
local csv = frame.args[1] or ''
-- title transforms (unchanged)
title = title:gsub(">", '—')
title = title:gsub("%.", '</div>')
title = title:gsub("u", 'UNBOLD')
title = title:gsub("k", 'KUDARI')
title = title:gsub("n", 'NOBORI')
title = title:gsub("l", '<div style="border: var(--text) 1.5px solid; display: inline-block; padding-top: 2px; padding-bottom: 3px; font-size: 1.14rem; font-weight: normal; vertical-align: top; line-height: 1.35rem;">')
title = title:gsub("UNBOLD", '<div style="font-weight: 400; display: inline; font-size: 1.17rem; vertical-align: 1.5px; vertical-align: -webkit-baseline-middle;">')
title = title:gsub("KUDARI", '<div class="arrow arrow--down outline">')
title = title:gsub("NOBORI", '<div class="arrow arrow--up solid">')
local arr = csv_to_array(csv)
-- 출력 버퍼
local out = {}
out[#out+1] = '<div style="display: flex; flex-direction: row; padding-bottom: 1rem;">'
out[#out+1] = '<div class="wm-rl timetabletitle" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">'
out[#out+1] = title
out[#out+1] = '</div>'
-- 컨테이너: 고정표 + 스크롤표
out[#out+1] = '<div style="display: flex; flex-direction: row; gap: 0;">'
local total_rows = #arr
-- 만약 headerColCount < 1 이면 기존 단일 스크롤 표로 폴백
if headerColCount < 1 then
out[#out+1] = string.format('<div style="border: 2.5px solid var(--text); padding: 3px !important; overflow-x: auto;">\n{| class="timetable" style="text-align: right; border-spacing: 30px;">\n')
else
-- 왼쪽 고정 표 시작 (경계 겹침 방지 위해 오른쪽 테두리 제거)
out[#out+1] = '<div class="timetable-fixed" style="border: 2.5px solid var(--text); border-right: 0; padding: 3px !important; overflow: hidden;">\n'
out[#out+1] = '{| class="timetable timetable--fixed" style="text-align: right; border-spacing: 30px; table-layout: fixed;">\n'
end
-- 스타일 프리컴파일 (테이블 분리 반영)
if headerColCount >= 1 then ensure_fixed_col_styles(headerColCount, headerColCount) end
-- 행 루프 (왼쪽 고정 표)
if headerColCount >= 1 then
for i = 1, total_rows do
local row = arr[i]
local left_values = {}
-- 왼쪽 고정 열 채우기
for c = 1, math.min(headerColCount, #row) do
local v = transform_cell(row[c])
if not str_find(v, "|", 1, true) then v = "|" .. v end
left_values[#left_values+1] = v
end
-- 열 스타일 부여
for j = 1, #left_values do
left_values[j] = fixed_col_styles[j] .. left_values[j]
end
-- "!!" 제거
for j = #left_values, 1, -1 do
if string.find(left_values[j], "!!", 1, true) then table.remove(left_values, j) end
end
-- 구분선 규칙 동일 적용
if i < headerRowCount + 1 then
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| '
elseif i == headerRowCount + 1 then
out[#out+1] = '|- style="border-top: double var(--text);"\n| '
elseif i == total_rows - footerRowCount + 1 then
out[#out+1] = '|- style="border-top: double var(--text);"\n| '
elseif i == total_rows and footerRowCount > 1 then
out[#out+1] = '|- class="forcetopalign" style="border-top: 1px solid var(--text);"\n| '
elseif i > total_rows - footerRowCount + 1 then
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| '
else
out[#out+1] = '|-\n| '
end
out[#out+1] = tbl_concat(left_values, ' \n| ')
out[#out+1] = '\n'
end
out[#out+1] = '|}\n'
out[#out+1] = '</div>'
end
-- 오른쪽 스크롤 표 시작 (왼쪽 경계 제거해 이음매 자연스럽게)
out[#out+1] = '<div class="timetable-scroll" style="border: 2.5px solid var(--text); border-left: 0; padding: 3px !important; overflow-x: auto;">\n'
out[#out+1] = '{| class="timetable timetable--scroll" style="text-align: right; border-spacing: 30px; table-layout: fixed; white-space: nowrap;">\n'
-- 스타일 프리컴파일 (스크롤 영역)
local max_cols = 0
for i = 1, total_rows do if #arr[i] > max_cols then max_cols = #arr[i] end end
local right_cols = math.max(0, max_cols - headerColCount)
ensure_scroll_col_styles(right_cols, headerColCount)
-- 행 루프 (오른쪽 스크롤 표)
for i = 1, total_rows do
local row = arr[i]
local right_values = {}
for c = headerColCount + 1, #row do
local v = transform_cell(row[c])
if not str_find(v, "|", 1, true) then v = "|" .. v end
right_values[#right_values+1] = v
end
for j = 1, #right_values do
right_values[j] = scroll_col_styles[j] .. right_values[j]
end
for j = #right_values, 1, -1 do
if string.find(right_values[j], "!!", 1, true) then table.remove(right_values, j) end
end
if i < headerRowCount + 1 then
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| '
elseif i == headerRowCount + 1 then
out[#out+1] = '|- style="border-top: double var(--text);"\n| '
elseif i == total_rows - footerRowCount + 1 then
out[#out+1] = '|- style="border-top: double var(--text);"\n| '
elseif i == total_rows and footerRowCount > 1 then
out[#out+1] = '|- class="forcetopalign" style="border-top: 1px solid var(--text);"\n| '
elseif i > total_rows - footerRowCount + 1 then
out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| '
else
out[#out+1] = '|-\n| '
end
out[#out+1] = tbl_concat(right_values, ' \n| ')
out[#out+1] = '\n'
end
out[#out+1] = '|}\n'
out[#out+1] = '</div>'
-- 컨테이너 닫기
out[#out+1] = '</div>' -- split container
out[#out+1] = '</div>' -- 전체 컨테이너
return tbl_concat(out)
end
return p