편집 요약 없음
편집 요약 없음
(같은 사용자의 중간 판 59개는 보이지 않습니다)
1번째 줄: 1번째 줄:
local p = {}
local p = {}
local u = mw.ustring
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&#124;", ["q"] = "OPENCENTERSPAN〃",
  ["-"] = "OPENCENTERSPAN_", ["v"] = "OPENCENTERSPAN↓",
  ["k"] = '<span style="font-weight: 700;">',
  ["b"] = '<span style="font-size: 1.25em; font-weight: 700;">',
  ["/"] = "<br/>"
}


-- hot locals
-- hot locals
34번째 줄: 50번째 줄:
   end
   end
   return out
   return out
end
-- === 열 스타일 1회 프리컴파일 ===
local col_styles -- upvalue
local function ensure_col_styles(ncols, headerColCount)
  if col_styles then return end
  col_styles = {}
  for j = 1, ncols do
    if j == headerColCount - 2 then
      col_styles[j] = [[class="tt-nly" style="vertical-align: middle !important;"]]
    elseif j == headerColCount - 1 then
      col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; vertical-align: middle !important; min-width: 8.5ch;"]]
    elseif j == headerColCount then
      col_styles[j] = [[class="tt-ply" style="text-align: center; margin-left: -1rem;"]]
    elseif j == headerColCount + 1 then
      col_styles[j] = [[style="min-width: 37.5px;"]]
    elseif j < headerColCount - 2 then
      col_styles[j] = [[class="tt-nnp"]]
    else
      col_styles[j] = [[style="min-width: 37.5px; border-left: 1px solid var(--text);"]]
    end
  end
end
end


-- per-char wrapper for text contained in single quotes, with memoization
-- per-char wrapper for text contained in single quotes, with memoization
local justify_cache = setmetatable({}, { __mode = "kv" })
local justify_cache = setmetatable({}, { __mode = "kv" })
-- per-char wrapper for text in single quotes (조건부 실행)
local function justifys(s)
local function justifys(s)
  if not str_find(s, "'", 1, true) then return s end  -- 따옴표 없으면 패스
   return u.gsub(s, "'(.-)'", function(inner)
   return u.gsub(s, "'(.-)'", function(inner)
     local cached = justify_cache[inner]
     local cached = justify_cache[inner]
50번째 줄: 92번째 줄:
     return out
     return out
   end)
   end)
end  
end


-- Memoizers for C{...} and S{...}
-- 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" })
local N_cache = setmetatable({}, { __mode = "kv" })
local N_cache = setmetatable({}, { __mode = "kv" })
-- Memoizers for BC{...} and BS{...}
local BC_cache = setmetatable({}, { __mode = "kv" })
local BC_cache = setmetatable({}, { __mode = "kv" })
local BS_cache = setmetatable({}, { __mode = "kv" })
local BS_cache = setmetatable({}, { __mode = "kv" })
65번째 줄: 106번째 줄:
   local out
   local out
   if tonumber(inner) ~= nil then
   if tonumber(inner) ~= nil then
     out = '<span class="forcedcenter" style="font-family: SeoulAlrimTTF, Zen Kaku Gothic Antique, CUSTOMFONT, TIMETABLEFONT, TIMETABLEFONT2 !important; height: 1rem; width: 1.9rem; display: inline-block; border: 1px solid var(--text); border-radius: 40%; font-size: 0.75rem; line-height: 1; position: relative; vertical-align: middle; overflow: hidden; background: var(--text); color: var(--bg);">' .. inner .. '</span>'
     out = '<span class="badge badge--circle badge--filled badge--num forcedcenter">' .. inner .. '</span>'
   else
   else
     out = '<span class="forcedcenter" style="height: 1rem; width: 1.9rem; display: inline-block; border: 1px solid var(--text); border-radius: 40%; font-size: 0.75rem; line-height: 1.1; position: relative; vertical-align: middle; overflow: hidden; background: var(--text); color: var(--bg);"><span class="stretch-text" data-max-width="1.22rem"><span>' .. inner .. '</span></span></span>'
     out = '<span class="badge badge--circle badge--filled forcedcenter"><span class="stretch-text" data-max-width="1.38rem"><span>' .. inner .. '</span></span></span>'
   end
   end
   BC_cache[inner] = out
   BC_cache[inner] = out
76번째 줄: 117번째 줄:
   local cached = N_cache[inner]; if cached then return cached end
   local cached = N_cache[inner]; if cached then return cached end
   inner = inner:gsub("^%s*(.-)%s*$", "%1")
   inner = inner:gsub("^%s*(.-)%s*$", "%1")
   out = '<span class="seriftt">' .. inner .. '</span>'
   local out = '<span class="seriftt">' .. inner .. '</span>'
   N_cache[inner] = out
   N_cache[inner] = out
   return out
   return out
86번째 줄: 127번째 줄:
   local out
   local out
   if tonumber(inner) ~= nil then
   if tonumber(inner) ~= nil then
     out = '<span class="forcetopalign forcedcenter" style="height: 0.95rem; width: 1.8rem; display: inline-block; overflow: hidden; border: 1px solid var(--text); font-size: 0.75rem; line-height: 0.9; vertical-align: middle; background: var(--text); color: var(--bg);">' .. inner .. '</span>'
     out = '<span class="badge badge--filled badge--tight forcetopalign forcedcenter badge--num">' .. inner .. '</span>'
   else
   else
     out = '<span class="forcetopalign forcedcenter" style="height: 1rem; width: 1.9rem; display: inline-block; overflow: hidden; border: 1px solid var(--text); font-size: 0.75rem; position: relative; line-height: 1.1; vertical-align: middle; background: var(--text); color: var(--bg);"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
     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
   end
   BS_cache[inner] = out
   BS_cache[inner] = out
108번째 줄: 149번째 줄:
     end
     end
   elseif as_num then
   elseif as_num then
      result = '<span class="forcedcenter" style="font-family: SeoulAlrimTTF, Zen Kaku Gothic Antique, CUSTOMFONT, TIMETABLEFONT, TIMETABLEFONT2 !important; height: 1rem; width: 1.9rem; display: inline-block; border: 1px solid var(--text); border-radius: 40%; font-size: 0.75rem; line-height: 1; position: relative; vertical-align: middle; overflow: hidden;">' .. inner .. '</span>'
    result = '<span class="badge badge--circle forcedcenter badge--num">' .. inner .. '</span>'
else
  else
     result = '<span class="forcedcenter" style="height: 1rem;  width: 1.9rem;  display: inline-block;  border: 1px solid var(--text);  border-radius: 40%;  font-size: 0.75rem;  line-height: 1.1;  position: relative; vertical-align: middle; overflow: hidden;"><span class="stretch-text" data-max-width="1.22rem"><span>' .. inner .. '</span></span></span>'
     result = '<span class="badge badge--circle forcedcenter"><span class="stretch-text" data-max-width="1.38rem"><span>' .. inner .. '</span></span></span>'
   end
   end
   C_cache[inner] = result
   C_cache[inner] = result
121번째 줄: 162번째 줄:
   local result
   local result
   if tonumber(inner) ~= nil then
   if tonumber(inner) ~= nil then
     result = '<span class="forcetopalign forcedcenter" style="height: 0.95rem;  width: 1.8rem;  display: inline-block; overflow: hidden; border: 1px solid var(--text);  font-size: 0.75rem;  line-height: 0.9;  vertical-align: middle;">' .. inner .. '</span>'
     result = '<span class="badge badge--tight forcetopalign forcedcenter badge--num">' .. inner .. '</span>'
   else
   else
     result = '<span class="forcetopalign forcedcenter" style="height: 1rem;  width: 1.9rem;  display: inline-block; overflow: hidden; border: 1px solid var(--text);  font-size: 0.75rem; position: relative;  line-height: 1.1;  vertical-align: middle;"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
     result = '<span class="badge badge--tight forcetopalign forcedcenter"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
   end
   end
   S_cache[inner] = result
   S_cache[inner] = result
130번째 줄: 171번째 줄:


-- Single-pass token normalizer for most ASCII tokens (multi-char handled first)
-- Single-pass token normalizer for most ASCII tokens (multi-char handled first)
local function normalize_tokens(value)
-- ==== TOKEN NORMALIZER (멀티→단일→사후 치환 최소화) ====
   -- multi-char first
local function normalize_tokens(s)
   value = str_gsub(value, "%-%-", "DOUBLEFINISHLINE")
   -- 멀티문자 먼저 (4회만)
  value = str_gsub(value, ">>", "➡")
   s = s:gsub("%-%-", "DOUBLEFINISHLINE")
   value = str_gsub(value, "%.%.",  "FULLSTOP")
      :gsub(">>",   "➡")
      :gsub("<>",   "OPENCENTERSPAN◆")
      :gsub("%.%.", "FULLSTOP")
      :gsub("rr",  "OPENTCUSPAN")


   -- single-char bucket via callback
   -- 단일문자 치환 1회
   value = str_gsub(value, "([<>h~r#&g%^;x.iq%-/vkb])", function(ch)
   s = s:gsub("([<>h~r#&g%^;x%.iq%-/vkb])", function(ch)
     if    ch == "<" then return " colspan="
     return single_map[ch] or ch
    elseif ch == ">" then return "⇨"
    elseif ch == ">" then return "⇨"
    elseif ch == "h" then return "HEADERSTYLE"
    elseif ch == "~" then return "HEIGHTSET"
    elseif ch == "r" then return "RIGHTLEFT"
    elseif ch == "#" then return "NOBORDERTOP"
    elseif ch == "&" then return "YESBORDERTOP"
    elseif ch == "g" then return "GREATER"
    elseif ch == "^" then return " rowspan="
    elseif ch == ";" then return " |"
    elseif ch == "x" then return "ELLIPSIS"
    elseif ch == "." then return "ENDSPAN"
    elseif ch == "i" then return "OPENCENTERSPAN&#124;"
    elseif ch == "q" then return "OPENCENTERSPAN〃"
    elseif ch == "-" then return "OPENCENTERSPAN_"
    elseif ch == "v" then return "OPENCENTERSPAN↓"
    elseif ch == "k" then return '<span style="font-weight: 700;">'
    elseif ch == "b" then return '<span style="font-size: 1.25em; font-weight: 700;">'
    elseif ch == "/" then return "<br/>"
    end
    return ch
   end)
   end)


   -- post-placeholders (same order as your original)
   -- 사후 치환
   value = str_gsub(value, "ENDSPAN","</span>")
   s = s:gsub("ENDSPAN","</span>")
  value = str_gsub(value, "ELLIPSIS",'OPENCENTERSPAN⋯')
      :gsub("ELLIPSIS","OPENCENTERSPAN⋯")
  value = str_gsub(value, "OPENSPAN", '<span></span>')
      :gsub("OPENSPAN", '<span></span>')
  value = str_gsub(value, "HEADERSTYLE", [[colspan="]] .. "3" .. [[" style="padding-left: 1.08rem; padding-right: 1.08rem; text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle;"]])
      :gsub("HEADERSTYLE", [[colspan="3" style="padding-left: 1.08rem; padding-right: 1.08rem; text-align: center; box-shadow: -1px 0 0 var(--text), 1px 0 0 var(--text); vertical-align: middle;"]])
  value = str_gsub(value, "HEIGHTSET", ' height=')
      :gsub("HEIGHTSET", ' height=')
  value = str_gsub(value, "RIGHTLEFT", '<span class="wm-rl">')
      :gsub("RIGHTLEFT", '<span class="wm-rl">')
  value = str_gsub(value, "NOBORDERTOP", '<span class="nobordertop"></span>')
      :gsub("NOBORDERTOP", '<span id="nobordertop"></span>')
  value = str_gsub(value, "YESBORDERTOP", '<span class="yesbordertop"></span>')
      :gsub("YESBORDERTOP", '<span id="yesbordertop"></span>')
  value = str_gsub(value, "GREATER", '<span style="font-size: 1.2em;">')
      :gsub("GREATER", '<span style="font-size: 1.2em;">')
  value = str_gsub(value, "OPENCENTERSPAN", '<span class="forcedcenter"></span>')
      :gsub("OPENCENTERSPAN", '<span class="forcedcenter"></span>')
  value = str_gsub(value, "DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
      :gsub("DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
  value = str_gsub(value, "FULLSTOP", '%.')
      :gsub("FULLSTOP", '%.')
   return value
      :gsub("OPENTCUSPAN", '<span style="text-combine-upright: all;">')
   return s
end
end


-- Per-cell full transform (order preserved vs your original)
-- Per-cell full transform (캐시/단축경로/조건부 justifys)
local function transform_cell(raw)
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
   if str_find(raw, "!!", 1, true) then
     return "!!" -- keep placeholder; no transforms
    cell_cache[raw] = "!!"
     return "!!"
   end
   end


   local v = raw
   local v = raw


   -- simple markers first
   -- 1) 아주 빠른 no-op 경로: 토큰 후보/따옴표가 하나도 없으면 그대로
   v = str_gsub(v, "c", 'OPENCENTERSPAN')  -- left as-is from your order
   -- 1) 아주 빠른 no-op 경로(확장):
--  - ASCII 토큰 없음
--  - 따옴표 없음
--  - 대문자 3연속 토큰(ARR/DEP/LEX/… 등) 없음
--  - 배지류 토큰 패턴(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, SPA, ...
local has_braced_token = str_find(v, "[BCNS][S C]?%b{}")  -- BC{..}, BS{..}, C{..}, S{..}, N{..}


   -- normalize tokens & placeholders
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)
   v = normalize_tokens(v)


   -- fixed symbols / arrows (same as your code)
   -- 4) 고정 기호 치환
   v = str_gsub(v, "DEP", '<span style="font-size: 0.92rem;">發</span>')
   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, "ARR", '<span style="font-size: 0.92rem;">着</span>')
198번째 줄: 245번째 줄:
   v = str_gsub(v, "GRR", '<span class="forcedcenter"></span>')
   v = str_gsub(v, "GRR", '<span class="forcedcenter"></span>')
   v = str_gsub(v, "GRN", '<span class="forcedcenter"></span>')
   v = str_gsub(v, "GRN", '<span class="forcedcenter"></span>')
   v = str_gsub(v, "LEX", '<span class="forcedcenter" style="font-size: 1.8rem; display: inline-block; margin-top: 7px;"></span>')
   v = str_gsub(v, "LEX", '<span class="forcetopalign forcedcenter icon-lg"></span>')
   v = str_gsub(v, "LSL", '<span class="forcetopalign forcedcenter" style="font-size: 1.8rem; display: inline-block; margin-top: 7px;">🌃</span>')
   v = str_gsub(v, "LSL", '<span class="forcetopalign forcedcenter icon-lg">🌃</span>')
   v = str_gsub(v, "XSL", '<span class="forcetopalign forcedcenter" style="font-size: 1.8rem; display: inline-block; margin-top: 7px;"></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, "TRD", '<span class="forcedcenter">⬎</span>')
   v = str_gsub(v, "TRU", '<span class="forcedcenter">⬏</span>')
   v = str_gsub(v, "TRU", '<span class="forcedcenter">⬏</span>')
213번째 줄: 260번째 줄:
   v = str_gsub(v, "MLU", '<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, "MRU", '<span class="forcedcenter">┛</span>')
   v = str_gsub(v, "TNO", '<span class="forcedcenter" style="display: block; margin-top: -7.5px; margin-bottom: -20px;">[</span>')
   v = str_gsub(v, "TNO", '<span class="forcedcenter bracket-open">[</span>')
   v = str_gsub(v, "TNC", '<span class="forcedcenter" style="display: block; margin-top: -5px; margin-bottom: -9.5px;">]</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>')
   v = str_gsub(v, "SPA", '<span class="forcedcenter"> </span>')


   -- C{...} and S{...}
   -- 5) 배지류
   v = u.gsub(v, 'BC{(.-)}', transform_BC)
   v = u.gsub(v, 'BC{(.-)}', transform_BC)
   v = u.gsub(v, 'BS{(.-)}', transform_BS)
   v = u.gsub(v, 'BS{(.-)}', transform_BS)
   v = u.gsub(v, 'C{(.-)}', transform_C)
   v = u.gsub(v, 'C{(.-)}', transform_C)
   v = u.gsub(v, 'S{(.-)}', transform_S)
   v = u.gsub(v, 'S{(.-)}', transform_S)
   v = u.gsub(v, 'N{(.-)}', transform_N)
   v = u.gsub(v, 'N{(.-)}', transform_N)


   -- quoted per-char justify
   -- 6) 따옴표 있을 때만 per-char justify
   v = justifys(v)
   v = justifys(v)
  cell_cache[raw] = v
   return v
   return v
end
end
249번째 줄: 302번째 줄:
   -- parse once, transpose once
   -- parse once, transpose once
   local arr = csv_to_array(csv)
   local arr = csv_to_array(csv)
  arr = transpose(arr)


   local out = {}
   local out = {}
   out[#out+1] = '<div style="display: flex; flex-direction: row;"><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;"><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] = '</div><div style="border: 2.5px solid var(--text); padding: 3px !important; overflow-x: scroll;">\n{| class="timetable" style="text-align: right; border-spacing: 30px;"\n'
out[#out+1] = string.format(
  '</div><div class="tt-scroller" style="border: 2.5px solid var(--text); padding: 3px !important; overflow-x: auto; position: relative;">\n{| class="timetable" data-sticky-cols="%d" style="text-align: right; border-spacing: 30px;"\n',
  headerColCount
)
 


   local total_rows = #arr
   local total_rows = #arr
270번째 줄: 326번째 줄:


     -- prepend style per column index (same logic/order as yours)
     -- prepend style per column index (same logic/order as yours)
    -- prepend style per column index (precompiled)
    ensure_col_styles(#values, headerColCount)
     for j = 1, #values do
     for j = 1, #values do
       if j == headerColCount - 2 then
       values[j] = col_styles[j] .. values[j]
        values[j] = [[style="vertical-align: middle !important; text-align: justify; text-align-last: justify; border-right: none;"]] .. values[j]
    end
      elseif j == headerColCount - 1 then
 
        values[j] = [[style="padding-left: 0.2rem; padding-right: 0.2rem; text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle !important; min-width: 8.5ch;"]] .. values[j]
    -- (기존 유지) "!!" 제거 루프
      elseif j == headerColCount then
    for j = #values, 1, -1 do
        values[j] = 'style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem;"' .. values[j]
       if string.find(values[j], "!!", 1, true) then
       elseif j == headerColCount + 1 then
         table.remove(values, j)
        values[j] = [[style="width: 37.5px; border-left: 1px solid var(--text);"]] .. values[j]
      elseif j < headerColCount - 2 then
        values[j] = [[style="border-right: 1px solid;"]] .. values[j]
      else
         values[j] = [[style="width: 37.5px;"]] .. values[j]
       end
       end
     end
     end
for j = #values, 1, -1 do
 
  if string.find(values[j], "!!", 1, true) then
    table.remove(values, j)
  end
end
     -- row separators (unchanged semantics)
     -- row separators (unchanged semantics)
     if i < headerRowCount + 1 then
     if i < headerRowCount + 1 then
       out[#out+1] = '|- style="border-top: 1px solid var(--text);"\n| '
       out[#out+1] = '|- class="tt-nnp yesbordertop" style="border-top: 1px solid var(--text);"\n| '
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = "\n"
       out[#out+1] = "\n"
     elseif i == headerRowCount + 1 then
     elseif i == headerRowCount + 1 then
       out[#out+1] = '|- style="border-top: double var(--text);"\n| '
       out[#out+1] = '|- id="doublelines" style="border-top: double var(--text);"\n| '
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = "\n"
       out[#out+1] = "\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] = '|- id="doublelines" class="tt-nnp" style="border-top: double var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i == total_rows and footerRowCount > 1 then
      out[#out+1] = '|- class="tt-nnp yesbordertop forcetopalign" style="border-top: 1px solid var(--text);"\n| '
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = "\n"
       out[#out+1] = "\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] = '|- class="tt-nnp yesbordertop" style="border-top: 1px solid var(--text);"\n| '
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = tbl_concat(values, " \n| ")
       out[#out+1] = "\n"
       out[#out+1] = "\n"

2025년 8월 15일 (금) 19:10 판

이 모듈에 대한 설명문서는 모듈: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&#124;", ["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

-- === 열 스타일 1회 프리컴파일 ===
local col_styles -- upvalue

local function ensure_col_styles(ncols, headerColCount)
  if col_styles then return end
  col_styles = {}
  for j = 1, ncols do
    if j == headerColCount - 2 then
      col_styles[j] = [[class="tt-nly" style="vertical-align: middle !important;"]]
    elseif j == headerColCount - 1 then
      col_styles[j] = [[style="padding-left: 0rem; padding-right: 0rem; vertical-align: middle !important; min-width: 8.5ch;"]]
    elseif j == headerColCount then
      col_styles[j] = [[class="tt-ply" style="text-align: center; margin-left: -1rem;"]]
    elseif j == headerColCount + 1 then
      col_styles[j] = [[style="min-width: 37.5px;"]]
    elseif j < headerColCount - 2 then
      col_styles[j] = [[class="tt-nnp"]]
    else
      col_styles[j] = [[style="min-width: 37.5px; border-left: 1px solid var(--text);"]]
    end
  end
end

-- per-char wrapper for text contained in single quotes, with memoization
local justify_cache = setmetatable({}, { __mode = "kv" })

-- per-char wrapper for text in single quotes (조건부 실행)
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

-- 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

-- Single-pass token normalizer for most ASCII tokens (multi-char handled first)
-- ==== 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; box-shadow: -1px 0 0 var(--text), 1px 0 0 var(--text); vertical-align: middle;"]])
       :gsub("HEIGHTSET", ' height=')
       :gsub("RIGHTLEFT", '<span class="wm-rl">')
       :gsub("NOBORDERTOP", '<span id="nobordertop"></span>')
       :gsub("YESBORDERTOP", '<span id="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 경로: 토큰 후보/따옴표가 하나도 없으면 그대로
  -- 1) 아주 빠른 no-op 경로(확장): 
--  - ASCII 토큰 없음
--  - 따옴표 없음
--  - 대문자 3연속 토큰(ARR/DEP/LEX/… 등) 없음
--  - 배지류 토큰 패턴(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, SPA, ...
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">')

  -- parse once, transpose once
  local arr = csv_to_array(csv)

  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] = title
out[#out+1] = string.format(
  '</div><div class="tt-scroller" style="border: 2.5px solid var(--text); padding: 3px !important; overflow-x: auto; position: relative;">\n{| class="timetable" data-sticky-cols="%d" style="text-align: right; border-spacing: 30px;"\n',
  headerColCount
)


  local total_rows = #arr
  for i = 1, total_rows do
    local row = arr[i]
    local values = {}

    for c = 1, #row do
      local v = transform_cell(row[c])
      if not str_find(v, "|", 1, true) then
    v = "|" .. v
  end
      if v then values[#values+1] = v end
    end

    -- prepend style per column index (same logic/order as yours)
    -- prepend style per column index (precompiled)
    ensure_col_styles(#values, headerColCount)
    for j = 1, #values do
      values[j] = col_styles[j] .. values[j]
    end

    -- (기존 유지) "!!" 제거 루프
    for j = #values, 1, -1 do
      if string.find(values[j], "!!", 1, true) then
        table.remove(values, j)
      end
    end

    -- row separators (unchanged semantics)
    if i < headerRowCount + 1 then
      out[#out+1] = '|- class="tt-nnp yesbordertop" style="border-top: 1px solid var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i == headerRowCount + 1 then
      out[#out+1] = '|- id="doublelines" style="border-top: double var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i == total_rows - footerRowCount + 1 then 
      out[#out+1] = '|- id="doublelines" class="tt-nnp" style="border-top: double var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i == total_rows and footerRowCount > 1 then
      out[#out+1] = '|- class="tt-nnp yesbordertop forcetopalign" style="border-top: 1px solid var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i > total_rows - footerRowCount + 1 then
      out[#out+1] = '|- class="tt-nnp yesbordertop" style="border-top: 1px solid var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    else
      out[#out+1] = "|-\n| "
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    end
  end

  out[#out+1] = "|}\n</div></div>"
  return tbl_concat(out)
end

return p