편집 요약 없음
편집 요약 없음
2번째 줄: 2번째 줄:
local u = mw.ustring
local u = mw.ustring


-- CSV 데이터를 받아 wikitable 형식으로 변환하는 함수
-- hot locals
function p.csv(frame)
local str_gsub, str_find, str_match, tbl_concat = string.gsub, string.find, string.match, table.concat


local function u_chars(s)
-- CSV -> array (simple; same behavior as your original)
     local t = {}
local function csv_to_array(csv)
     for ch in u.gmatch(s, ".") do  -- dot matches one Unicode char in ustring
  local rows = {}
        t[#t+1] = ch
  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
     end
     return t
     rows[#rows+1] = row
  end
  return rows
end
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


-- per-char wrapper for text contained in single quotes, with memoization
local justify_cache = setmetatable({}, { __mode = "kv" })
local function justifys(s)
local function justifys(s)
    return u.gsub(s, "'(.-)'", function(inner)
  return u.gsub(s, "'(.-)'", function(inner)
        local pieces = {}
    local cached = justify_cache[inner]
        for ch in u.gmatch(inner, ".") do
    if cached then return cached end
            pieces[#pieces+1] = "<span>" .. ch .. "</span>"
    local pieces = {}
        end
    for ch in u.gmatch(inner, ".") do
        return '<span class="justify-chars">' .. table.concat(pieces) .. "</span>"
      pieces[#pieces+1] = "<span>" .. ch .. "</span>"
     end)
    end
    local out = '<span class="justify-chars">' .. tbl_concat(pieces) .. "</span>"
     justify_cache[inner] = out
    return out
  end)
end
end


local function csv_to_array(csv)
-- Memoizers for C{...} and S{...}
    local rows = {}
local C_cache = setmetatable({}, { __mode = "kv" })
    for line in tostring(csv):gmatch("[^\r\n]+") do
local S_cache = setmetatable({}, { __mode = "kv" })
        local row = {}
 
        for cell in line:gmatch("([^,]+)") do
local function transform_C(inner)
            cell = cell:gsub("^%s+", ""):gsub("%s+$", "")
  local cached = C_cache[inner]; if cached then return cached end
            table.insert(row, cell)
  inner = inner:gsub("^%s*(.-)%s*$", "%1")
        end
  local as_num = tonumber(inner)
        table.insert(rows, row)
  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
     end
     return rows
  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;  vertical-align: middle; overflow: hidden;"><span class="stretch-text" data-max-width="1.1rem"><span>' .. inner .. '</span></span></span>'
  end
  C_cache[inner] = result
  return result
end
end


local function array_to_csv(t, sep)
local function transform_S(inner)
    sep = sep or ","
  local cached = S_cache[inner]; if cached then return cached end
    local lines = {}
  inner = inner:gsub("^%s*(.-)%s*$", "%1")
    for r = 1, #t do
  local result
        local row = {}
  if tonumber(inner) ~= nil then
        for c = 1, #t[r] do
    result = '<span class="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>'
            local cell = tostring(t[r][c] or "")
  else
            -- 쉼표, 큰따옴표, 줄바꿈이 들어있으면 CSV 규칙에 맞게 감싸기
    result = '<span class="forcedcenter" style="height: 1rem;  width: 1.9rem;  display: inline-block; overflow: hidden; border: 1px solid var(--text);  font-size: 0.75rem;  line-height: 1.1;  vertical-align: middle;"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
            if cell:find('[,"\n]') then
  end
                cell = '"' .. cell:gsub('"', '""') .. '"'
  S_cache[inner] = result
            end
  return result
            table.insert(row, cell)
        end
        table.insert(lines, table.concat(row, sep))
    end
    return table.concat(lines, "\n")
end
end


-- m = { {a,b,c}, {d,e,f}, ... }
-- Single-pass token normalizer for most ASCII tokens (multi-char handled first)
local function transpose(m, fill)
local function normalize_tokens(value)
     fill = fill or ""           -- 빈 칸 채울 값
  -- multi-char first
     local out, rows, maxc = {}, #m, 0
  value = str_gsub(value, "%-%-", "DOUBLEFINISHLINE")
     for r = 1, rows do
  value = str_gsub(value, "%.%.",   "FULLSTOP")
        maxc = math.max(maxc, #m[r])
 
  -- single-char bucket via callback
  value = str_gsub(value, "([<>h~r#&g%^;x.iq%-/vkb])", function(ch)
     if    ch == "<" then return " colspan="
    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
     end
     for c = 1, maxc do
     return ch
        out[c] = {}
  end)
        for r = 1, rows do
 
            out[c][r] = m[r][c] ~= nil and m[r][c] or fill
  -- post-placeholders (same order as your original)
        end
  value = str_gsub(value, "ENDSPAN","</span>")
    end
  value = str_gsub(value, "ELLIPSIS",'OPENCENTERSPAN⋯')
    return out
  value = str_gsub(value, "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;"]])
  value = str_gsub(value, "HEIGHTSET", ' height=')
  value = str_gsub(value, "RIGHTLEFT", '<span class="wm-rl">')
  value = str_gsub(value, "NOBORDERTOP", '<span class="nobordertop"></span>')
  value = str_gsub(value, "YESBORDERTOP", '<span class="yesbordertop"></span>')
  value = str_gsub(value, "GREATER", '<span style="font-size: 1.2em;">')
  value = str_gsub(value, "OPENCENTERSPAN", '<span class="forcedcenter"></span>')
  value = str_gsub(value, "DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
  value = str_gsub(value, "FULLSTOP", '%.')
  return value
end
end


-- Per-cell full transform (order preserved vs your original)
local function transform_cell(raw)
  if raw == "!!" then return nil end  -- skip removed cells early


  local v = raw


  -- simple markers first
  v = str_gsub(v, "c", 'OPENCENTERSPAN')  -- left as-is from your order


    local headerRowCount = tonumber(frame.args["header"]) or 4
  -- normalize tokens & placeholders
    local headerColCount = tonumber(frame.args["left"]) or 3
  v = normalize_tokens(v)
    local footerRowCount = tonumber(frame.args["footer"]) or 1
    local title = frame.args["title"] or '제목 (title 변수 입력)'
    local csv = frame.args[1] or ''
    local csv = array_to_csv(transpose(csv_to_array(csv)))
    local lines = {}


    title = title:gsub(">", '—')
  -- fixed symbols / arrows (same as your code)
    title = title:gsub("%.", '</div>')
  v = str_gsub(v, "DEP", '<span style="font-size: 0.92rem;">發</span>')
    title = title:gsub("u", 'UNBOLD')
  v = str_gsub(v, "ARR", '<span style="font-size: 0.92rem;">着</span>')
    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;">')
  v = str_gsub(v, "GRR", '<span class="forcedcenter" style="font-size: 1.8rem;"></span>')
    title = title:gsub("UNBOLD", '<div style="font-weight: 400; display: inline; font-size: 1.17rem;">')
  v = str_gsub(v, "GRN", '<span class="forcedcenter" style="font-size: 1.8rem;"></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, "SPA", '<span class="forcedcenter"> </span>')


    local result = '<div style="display: flex; flex-direction: row;"><div class="wm-rl" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">' .. title .. '</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'
  -- C{...} and S{...}
  v = u.gsub(v, 'C{(.-)}', transform_C)
  v = u.gsub(v, 'S{(.-)}', transform_S)


    -- CSV 데이터를 줄 단위로 분리
  -- quoted per-char justify
    for line in csv:gmatch("[^\r\n]+") do
  v = justifys(v)
        table.insert(lines, line)
    end


    for i = 1, #lines do
  -- ensure table cell pipe prefix once
        local row = lines[i]
  if not str_find(v, "|", 1, true) then
        local values = {}
    v = "|" .. v
        -- 데이터를 쉼표로 분리하여 values 테이블에 저장
  end
        for value in row:gmatch("[^,]+") do
  return v
end


            value = value:gsub("c",'OPENCENTERSPAN')
function p.csv(frame)
            value = value:gsub("%-%-",'DOUBLEFINISHLINE')
  local headerRowCount = tonumber(frame.args["header"]) or 4
            value = value:gsub("%.%.","FULLSTOP")
  local headerColCount = tonumber(frame.args["left"]) or 3
            value = value:gsub("<", ' colspan=')
  local footerRowCount = tonumber(frame.args["footer"]) or 1
            value = value:gsub(">", '⇨')
  local title = frame.args["title"] or '제목 (title 변수 입력)'
            value = value:gsub("h", 'HEADERSTYLE')
  local csv = frame.args[1] or ''
            value = value:gsub("%~", 'HEIGHTSET')
            value = value:gsub("r", "RIGHTLEFT")
            value = value:gsub("%#", "NOBORDERTOP")
            value = value:gsub("%&", "YESBORDERTOP")
            value = value:gsub("g", 'GREATER')
            value = value:gsub("%^", ' rowspan=')
            value = value:gsub(";", ' |')
            value = value:gsub("x", "ELLIPSIS")
            value = value:gsub("%.","ENDSPAN")
            value = value:gsub("i", 'OPENCENTERSPAN&#124;')
            value = value:gsub("q", 'OPENCENTERSPAN〃')
            value = value:gsub("%-", 'OPENCENTERSPAN-')
            value = value:gsub("v", 'OPENCENTERSPAN↓')
            value = value:gsub("k", '<span style="font-weight: 700;">')
            value = value:gsub("b", '<span style="font-size: 1.25em; font-weight: 700;">')
            value = value:gsub("/", "<br/>")
            value = value:gsub("ENDSPAN","</span>")
            value = value:gsub("ELLIPSIS",'OPENCENTERSPAN⋯')
            value = value:gsub("OPENSPAN", '<span></span>')
            value = value:gsub("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;"]])
            value = value:gsub("HEIGHTSET", ' height=')
            value = value:gsub("RIGHTLEFT", '<span class="wm-rl">') 
            value = value:gsub("NOBORDERTOP", '<span class="nobordertop"></span>')  
            value = value:gsub("YESBORDERTOP", '<span class="yesbordertop"></span>')
            value = value:gsub("GREATER", '<span style="font-size: 1.2em;">')
            value = value:gsub("OPENCENTERSPAN", '<span class="forcedcenter"></span>')
            value = value:gsub("DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
            value = value:gsub("FULLSTOP", '%.')


    value = value:gsub("DEP", '<span style="font-size: 0.92rem;">發</span>')
  -- title transforms (unchanged)
    value = value:gsub("ARR", '<span style="font-size: 0.92rem;">着</span>')
  title = title:gsub(">", '')
    value = value:gsub("GRR", '<span class="forcedcenter" style="font-size: 1.8rem;"></span>')
  title = title:gsub("%.", '</div>')
    value = value:gsub("GRN", '<span class="forcedcenter" style="font-size: 1.8rem;"></span>')
  title = title:gsub("u", 'UNBOLD')
    value = value:gsub("GRR", '<span class="forcedcenter" style="font-size: 1.8rem;"></span>')
  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;">')
    value = value:gsub("TRD", '<span class="forcedcenter">⬎</span>')
  title = title:gsub("UNBOLD", '<div style="font-weight: 400; display: inline; font-size: 1.17rem;">')
    value = value:gsub("TRU", '<span class="forcedcenter">⬏</span>')
    value = value:gsub("TLD", '<span class="forcedcenter">⬐</span>')
    value = value:gsub("TLU", '<span class="forcedcenter">⬑</span>')
    value = value:gsub("TDL", '<span class="forcedcenter">↲</span>')
    value = value:gsub("TDR", '<span class="forcedcenter">↳</span>')
    value = value:gsub("TUL", '<span class="forcedcenter">↰</span>')
    value = value:gsub("TUR", '<span class="forcedcenter">↱</span>')
    value = value:gsub("SPA", '<span class="forcedcenter"> </span>')
 
value = u.gsub(value, 'C{(.-)}', function(inner)
    inner = inner:gsub("^%s*(.-)%s*$", "%1") -- trim spaces
    local as_num = tonumber(inner)
    if as_num and as_num >= 0 and as_num <= 34 then
        if as_num == 0 then return "⓪" end
        if as_num <= 20 then return '<span class="forcedcenter"></span>' .. u.char(0x2460 + (as_num - 1)) end -- ①..⑳
        return '<span class="forcedcenter"></span>' .. u.char(0x3251 + (as_num - 21))                        -- ㉑..㉟
    end
    return '<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; vertical-align: middle; overflow: hidden;"><span class="stretch-text" data-max-width="1.1rem"><span>' .. inner .. '</span></span></span>'
end)


value = u.gsub(value, 'S{(.-)}', function(inner)
  -- parse once, transpose once
    inner = inner:gsub("^%s*(.-)%s*$", "%1") -- trim spaces
  local arr = csv_to_array(csv)
    if tonumber(inner) ~= nil then
  arr = transpose(arr)
return '<span class="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>'
    end
    return '<span class="forcedcenter" style="height: 1rem;  width: 1.9rem;  display: inline-block; overflow: hidden; border: 1px solid var(--text);  font-size: 0.75rem;  line-height: 1.1;  vertical-align: middle;"><span class="stretch-text" data-max-width="1.4rem"><span>' .. inner .. '</span></span></span>'
end)


            value = justifys(value)
  local out = {}
  out[#out+1] = '<div style="display: flex; flex-direction: row;"><div class="wm-rl" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">'
  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'


            if string.find(value, "|") then
  local total_rows = #arr
                table.insert(values, value)
  for i = 1, total_rows do
            else
    local row = arr[i]
                table.insert(values, "|" .. value)
    local values = {}
            end
        end


        for j, value in ipairs(values) do
    for c = 1, #row do
            if j == headerColCount - 2 then
      local v = transform_cell(row[c])
                values[j] = [[style="text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle;"]] .. value
      if v then values[#values+1] = v end
            elseif j == headerColCount - 1 then
    end
                values[j] = [[style="padding-left: 0.2rem; padding-right: 0.2rem; text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle; min-width: 8.5ch;"]] .. value
            elseif j == headerColCount then
                values[j] = 'style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem;"' .. value
            elseif j == headerColCount + 1 then
                values[j] = [[style="min-width: 2.36rem; border-left: 1px solid var(--text);"]] .. value
            elseif j < headerColCount - 2 then
                values[j] = [[style="border-right: 1px solid;"]] .. value
            else
                values[j] = [[style="min-width: 2.36rem;"]] .. value
            end
        end


        for j = #values, 1, -1 do
    -- prepend style per column index (same logic/order as yours)
            if string.find(values[j], "!!") then
    for j = 1, #values do
                table.remove(values, j)
      if j == headerColCount - 2 then
            end
        values[j] = [[style="text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle;"]] .. 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; min-width: 8.5ch;"]] .. values[j]
      elseif j == headerColCount then
        values[j] = 'style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem;"' .. values[j]
      elseif j == headerColCount + 1 then
        values[j] = [[style="min-width: 2.36rem; 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="min-width: 2.36rem;"]] .. values[j]
      end
    end


        if i < headerRowCount + 1 then
    -- row separators (unchanged semantics)
            result = result .. '|- class="serifnumbers" style="border-top: 1px solid var(--text); text-align: center !important;"\n| ' .. table.concat(values, " \n| ") .. "\n"
    if i < headerRowCount + 1 then
        elseif i == headerRowCount + 1 then
      out[#out+1] = '|- class="serifnumbers" style="border-top: 1px solid var(--text); text-align: center !important;"\n| '
            result = result .. '|- style="border-top: double var(--text);"\n| ' .. table.concat(values, " \n| ") .. "\n"
      out[#out+1] = tbl_concat(values, " \n| ")
        elseif i > #lines - footerRowCount then
      out[#out+1] = "\n"
            result = result .. '|- style="border-top: 1px solid var(--text); text-align: center !important;"\n| ' .. table.concat(values, " \n| ") .. "\n"
    elseif i == headerRowCount + 1 then
        else
      out[#out+1] = '|- style="border-top: double var(--text);"\n| '
            result = result .. "|-\n| " .. table.concat(values, " \n| ") .. "\n"
      out[#out+1] = tbl_concat(values, " \n| ")
        end
      out[#out+1] = "\n"
    elseif i > total_rows - footerRowCount then
      out[#out+1] = '|- style="border-top: 1px solid var(--text); text-align: center !important;"\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
  end


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


return p
return p

2025년 8월 10일 (일) 22:42 판

이 모듈에 대한 설명문서는 모듈:Timetable/설명문서에서 만들 수 있습니다

local p = {}
local u = mw.ustring

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

-- per-char wrapper for text contained in single quotes, with memoization
local justify_cache = setmetatable({}, { __mode = "kv" })
local function justifys(s)
  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 for C{...} and S{...}
local C_cache = setmetatable({}, { __mode = "kv" })
local S_cache = setmetatable({}, { __mode = "kv" })

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
  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;  vertical-align: middle; overflow: hidden;"><span class="stretch-text" data-max-width="1.1rem"><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="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>'
  else
    result = '<span class="forcedcenter" style="height: 1rem;  width: 1.9rem;  display: inline-block; overflow: hidden; border: 1px solid var(--text);  font-size: 0.75rem;  line-height: 1.1;  vertical-align: middle;"><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)
local function normalize_tokens(value)
  -- multi-char first
  value = str_gsub(value, "%-%-", "DOUBLEFINISHLINE")
  value = str_gsub(value, "%.%.",   "FULLSTOP")

  -- single-char bucket via callback
  value = str_gsub(value, "([<>h~r#&g%^;x.iq%-/vkb])", function(ch)
    if     ch == "<" then return " colspan="
    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)

  -- post-placeholders (same order as your original)
  value = str_gsub(value, "ENDSPAN","</span>")
  value = str_gsub(value, "ELLIPSIS",'OPENCENTERSPAN⋯')
  value = str_gsub(value, "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;"]])
  value = str_gsub(value, "HEIGHTSET", ' height=')
  value = str_gsub(value, "RIGHTLEFT", '<span class="wm-rl">')
  value = str_gsub(value, "NOBORDERTOP", '<span class="nobordertop"></span>')
  value = str_gsub(value, "YESBORDERTOP", '<span class="yesbordertop"></span>')
  value = str_gsub(value, "GREATER", '<span style="font-size: 1.2em;">')
  value = str_gsub(value, "OPENCENTERSPAN", '<span class="forcedcenter"></span>')
  value = str_gsub(value, "DOUBLEFINISHLINE", '<span class="forcedcenter">=</span>')
  value = str_gsub(value, "FULLSTOP", '%.')
  return value
end

-- Per-cell full transform (order preserved vs your original)
local function transform_cell(raw)
  if raw == "!!" then return nil end  -- skip removed cells early

  local v = raw

  -- simple markers first
  v = str_gsub(v, "c", 'OPENCENTERSPAN')  -- left as-is from your order

  -- normalize tokens & placeholders
  v = normalize_tokens(v)

  -- fixed symbols / arrows (same as your code)
  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, "GRR", '<span class="forcedcenter" style="font-size: 1.8rem;"></span>')
  v = str_gsub(v, "GRN", '<span class="forcedcenter" style="font-size: 1.8rem;"></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, "SPA", '<span class="forcedcenter"> </span>')

  -- C{...} and S{...}
  v = u.gsub(v, 'C{(.-)}', transform_C)
  v = u.gsub(v, 'S{(.-)}', transform_S)

  -- quoted per-char justify
  v = justifys(v)

  -- ensure table cell pipe prefix once
  if not str_find(v, "|", 1, true) then
    v = "|" .. v
  end
  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("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;">')

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

  local out = {}
  out[#out+1] = '<div style="display: flex; flex-direction: row;"><div class="wm-rl" style="font-size: 1.26rem; padding-right: 4px; font-weight: 700; line-height: 1.54rem;">'
  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'

  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 v then values[#values+1] = v end
    end

    -- prepend style per column index (same logic/order as yours)
    for j = 1, #values do
      if j == headerColCount - 2 then
        values[j] = [[style="text-align: justify; text-align-last: justify; border-right: none; vertical-align: middle;"]] .. values[j]
      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; min-width: 8.5ch;"]] .. values[j]
      elseif j == headerColCount then
        values[j] = 'style="text-align: center; border-left: none; border-right: 1px solid var(--text); margin-left: -1rem;"' .. values[j]
      elseif j == headerColCount + 1 then
        values[j] = [[style="min-width: 2.36rem; 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="min-width: 2.36rem;"]] .. values[j]
      end
    end

    -- row separators (unchanged semantics)
    if i < headerRowCount + 1 then
      out[#out+1] = '|- class="serifnumbers" style="border-top: 1px solid var(--text); text-align: center !important;"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i == headerRowCount + 1 then
      out[#out+1] = '|- style="border-top: double var(--text);"\n| '
      out[#out+1] = tbl_concat(values, " \n| ")
      out[#out+1] = "\n"
    elseif i > total_rows - footerRowCount then
      out[#out+1] = '|- style="border-top: 1px solid var(--text); text-align: center !important;"\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