이 모듈에 대한 설명문서는 모듈:Timetable/설명문서에서 만들 수 있습니다
--------------------------------------------------------------------------
-- Module:Timetable v3 — DiaPro 版
--
-- {{#invoke:Timetable|csv|header=3|left=4|footer=2|title=…|<CSV>}}
--
-- 時刻表専用フォント DiaPro (https://github.com/ButTaiwan/diapro, OFL) の
-- グリフ設計に合わせて全面的に作り直した版。
--
-- ・DiaPro は「時刻欄 1 列 = 2000 単位 = 数字 4 桁」のグリッドで作られている。
-- ‥ レ ║ ① ︵︶ 等の記号はこの幅の中に収まるよう設計済みなので、
-- CSS で回転・ずらしをする必要は無い。モジュールは正しいコードポイントを
-- 出すことだけに専念する。
-- ・縦組み用括弧 ︵︶︹︺ は「開きは行の下端・閉じは行の上端」に描かれている。
-- 1 行を占有させるだけで、上下の文字に自然に寄る。
-- ・DiaPro は ASCII 英字に合字(SL→SL 機関車, Bus→バス…)を持つため、
-- CSS 側で unicode-range からラテン文字・かなを外してある。アイコンは
-- 合字ではなく D{名前} で私用領域のコードポイントを直接出す。
--
-- CSS: Timetable.css(@font-face は MediaWiki:Common.css かスキンへ)
--------------------------------------------------------------------------
local p = {}
local EDMAP = false -- エディタのプレビュー用: 行・列の対応を data- 属性で出す
local byte, char, sub, find, gsub, match, gmatch, format =
string.byte, string.char, string.sub, string.find, string.gsub,
string.match, string.gmatch, string.format
local concat, tonumber, floor, min = table.concat, tonumber, math.floor, math.min
-- TemplateStyles を使う場合はページ名(nil なら出さない)
local STYLES_PAGE = nil -- 例: "Template:Timetable/styles.css"
--------------------------------------------------------------------------
-- UTF-8
--------------------------------------------------------------------------
local function U(cp)
if cp < 0x80 then return char(cp)
elseif cp < 0x800 then
return char(0xC0 + floor(cp / 0x40), 0x80 + cp % 0x40)
elseif cp < 0x10000 then
return char(0xE0 + floor(cp / 0x1000), 0x80 + floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
end
return char(0xF0 + floor(cp / 0x40000), 0x80 + floor(cp / 0x1000) % 0x40,
0x80 + floor(cp / 0x40) % 0x40, 0x80 + cp % 0x40)
end
local function ulen(s) -- 文字数(継続バイト以外を数える)
local _, n = gsub(s, "[^\128-\191]", "")
return n
end
--------------------------------------------------------------------------
-- 1. 記号 — すべて DiaPro が持つコードポイント
--------------------------------------------------------------------------
local G = {
pass = U(0x21C2), -- v 通過(レ形) w1400
none = U(0x2025), -- x 経由しない ‥ w1600
other = U(0x2551), -- i 他線区経由 ║ w1000
term = U(0x2594), -- - 終着(上寄せの一本線) w2000
term2 = U(0x207C), -- -- 終着(上寄せの二本線) w2000
dia = U(0x25C6), -- <> ◆ w1000
div = U(0x254C), -- DIV ╌ w2000
ditto = "〃", -- q (着発欄・本文フォント)
arr = "着", dep = "發", sp = " ",
}
-- 縦組み用括弧(DiaPro: 開きは行の下端、閉じは行の上端に描画)
local function bk(cp, o)
return '<span class="jtt-bk jtt-bk--' .. (o and "o" or "c") .. '">' .. U(cp) .. "</span>"
end
local BRACKET = {
PAO = bk(0xFE35, true), PAC = bk(0xFE36), -- ︵ ︶
TNO = bk(0xFE39, true), TNC = bk(0xFE3A), -- ︹ ︺
SUO = bk(0xFE3B, true), SUC = bk(0xEF3C), -- 黒レンズ 開/閉
}
-- 2 文字トークン
local PAIR = {
["--"] = { t = G.term2, c = true },
[">>"] = { t = "➡", c = true },
["<>"] = { t = G.dia, c = true },
[".."] = { t = "." },
["rr"] = { t = '<span class="jtt-tcy">' },
[":."] = { t = '<span class="jtt-td">.</span>', w = 0.36 }, -- 時刻の時と分の間の点
}
-- 1 文字トークン
local SINGLE = {
x = { t = G.none, c = true },
v = { t = G.pass, c = true },
i = { t = G.other, c = true },
q = { t = G.ditto, c = true },
["-"] = { t = G.term, c = true },
[">"] = { t = "⇨", c = true },
c = { t = "", c = true },
k = { t = '<span class="jtt-k">' },
b = { t = '<span class="jtt-b">' },
g = { t = '<span class="jtt-g">' },
r = { t = '<span class="jtt-v">' },
["."] = { t = "</span>" },
["/"] = { t = "<br>" },
}
-- 3 文字(大文字)トークン
local function cen(s) return { t = s, c = true } end
local WORD = {
ARR = { t = G.arr }, DEP = { t = G.dep }, SPA = { t = G.sp },
DIV = cen(G.div),
-- 矢印・接続線(DiaPro 幅 2000)
TRD = cen(U(0x2B0E)), TRU = cen(U(0x2B0F)), TLD = cen(U(0x2B10)), TLU = cen(U(0x2B11)),
TDL = cen(U(0x21B2)), TDR = cen(U(0x21B3)), TUL = cen(U(0x21B0)), TUR = cen(U(0x21B1)),
MLD = cen(U(0x250F)), MRD = cen(U(0x2513)), MLU = cen(U(0x2517)), MRU = cen(U(0x251B)),
-- 旧版の空アイコン → DiaPro の該当グリフ
LEX = cen(U(0xE05C)), -- L特急
LSL = cen(U(0xE05A)), -- 寝台特急
XSL = cen(U(0xE05B)), -- 寝台急行
GRR = cen(U(0xE050)), -- グリーン車指定席
GRN = cen(U(0xE051)), -- グリーン車自由席
}
-- D{名前} — DiaPro のアイコン・種別マーク(大文字小文字を区別しない)
local ICON = {}
for name, cp in pairs({
-- 種別マーク(幅 2000 = 時刻欄 1 列ぴったり)
kukankaisoku = 0xE040, kukai = 0xE040, jyunkaisoku = 0xE041, jyunkai = 0xE041,
kaisoku = 0xE042, shinkaisoku = 0xE043, shinkai = 0xE043,
tokubetsukaisoku = 0xE044, tokkai = 0xE044, tsukinkaisoku = 0xE045, tsukai = 0xE045,
chokutsukaisoku = 0xE046, chokkai = 0xE046, kyuko = 0xE047, tokkyu = 0xE048,
kaisokutokkyu = 0xE049, kaitoku = 0xE049, tsukintokkyu = 0xE04A, tsutoku = 0xE04A,
-- 設備・座席(幅 1000)
reserved = 0xE020, somereserved = 0xE021, allreserved = 0xE022,
greenreservedold = 0xE023, allnonreserved = 0xE029,
greenreserved = 0xE050, greennonreserved = 0xE051, premiumgreen = 0xE052,
granclass = 0xE053, granclassb = 0xE054, doubledecker = 0xE055,
buffet = 0xE056, dining = 0xE057, nosmoking = 0xE059,
sleeper = 0xE030, sleepersa1 = 0xE031, sleepersa2 = 0xE032,
sleepera = 0xE033, sleepera1 = 0xE034, sleepera2 = 0xE035,
sleeperb = 0xE036, sleeperb1 = 0xE037, sleeperb2 = 0xE038,
limitedsleeper = 0xE05A, expresssleeper = 0xE05B, lexpress = 0xE05C,
sl = 0xE060, n700 = 0xE061, e5 = 0xE062, bus = 0xE063,
midorinomadoguchi = 0xE064, bicycle = 0xE068, airport = 0x1F6E7,
inbound = 0x21E7, outbound = 0x21E9, -- 上り / 下り 札
attention = 0x329F, ben = 0x5F01, -- ㊟ / 囲み「弁」
}) do ICON[name] = U(cp) end
local ICON_WIDE = { [0xE040]=1,[0xE041]=1,[0xE042]=1,[0xE043]=1,[0xE044]=1,[0xE045]=1,
[0xE046]=1,[0xE047]=1,[0xE048]=1,[0xE049]=1,[0xE04A]=1 }
-- バッジ C{}丸 BC{}丸塗 S{}角 BS{}角塗 N{}列車番号 D{}DiaPro アイコン
local BADGE = { C = "jtt-bd jtt-bd--o", BC = "jtt-bd jtt-bd--o jtt-bd--f",
S = "jtt-bd", BS = "jtt-bd jtt-bd--f", N = true, D = true,
VR = true, VP = true, VK = true, VB = true, DG = true, DR = true }
--------------------------------------------------------------------------
-- 2. セル内容の変換(1 パス)+ 行ごとの幅の見積もり
--
-- 幅の単位は「基準文字サイズの em」。時刻欄の中身は 2em(DiaPro 数字 4 桁)。
-- 2em を超える行(「청량리」「K8002」等)は、時刻欄では横方向に詰めて
-- 列幅をそろえる(jtt-sq)。
--------------------------------------------------------------------------
local COL_W = 2.0 -- 時刻欄の中身の幅(em)
local SM = 0.8 -- 小さく組む部分の倍率(CSS の --jtt-sm と合わせる)
local function cpw(cp) -- 1 文字の幅(em)
if cp < 0x80 then
if cp >= 48 and cp <= 57 then return 0.5 end -- DiaPro 数字
if cp == 46 or cp == 32 then return 0.3 end
-- 英字は DiaPro に無く本文書体(明朝・ゴシック)で出る。プロポーショナルなので大文字は広い
if cp >= 65 and cp <= 90 then return 0.7 end -- A–Z
if cp >= 97 and cp <= 122 then return 0.52 end -- a–z
return 0.42 -- 記号
end
if cp == 0x2025 then return 1.6 end -- ‥
if cp == 0x21C2 then return 1.4 end -- 通過
if (cp >= 0x2460 and cp <= 0x2473) or cp == 0x24EA
or (cp >= 0x3251 and cp <= 0x325E) then return 1.6 end -- 横長丸数字
if cp == 0x2594 or cp == 0x207C or cp == 0x254C
or (cp >= 0xE040 and cp <= 0xE04A)
or (cp >= 0x2B0E and cp <= 0x2B11) or (cp >= 0x21B0 and cp <= 0x21B3)
or cp == 0x250F or cp == 0x2513 or cp == 0x2517 or cp == 0x251B then return 2.0 end
return 1.0 -- 漢字・かな・ハングル等
end
local function textw(str) -- タグを除いた文字列の幅
str = gsub(str, "<[^>]*>", "")
local w, i, len = 0, 1, #str
while i <= len do
local b = byte(str, i)
local cp, n
if b < 0x80 then cp, n = b, 1
elseif b < 0xE0 then cp, n = (b % 0x20) * 0x40 + byte(str, i + 1) % 0x40, 2
elseif b < 0xF0 then cp, n = ((b % 0x10) * 0x40 + byte(str, i + 1) % 0x40) * 0x40 + byte(str, i + 2) % 0x40, 3
else cp, n = 0x10000, 4 end
w = w + cpw(cp); i = i + n
end
return w
end
local function tokw(tok) -- トークンの幅(初回に計算して保持)
local w = tok.w
if not w then w = textw(tok.t); tok.w = w end
return w
end
local render
local function justify(s, cell) -- '…' 均等割付
local chars, n, i, len = {}, 0, 1, #s
while i <= len do
local b = byte(s, i)
local w = (b < 0x80 and 1) or (b < 0xE0 and 2) or (b < 0xF0 and 3) or 4
n = n + 1; chars[n] = sub(s, i, i + w - 1); i = i + w
end
if n <= 1 then cell.c = true; return s, textw(s) end
return '<span class="jtt-j"><span>' .. concat(chars, "</span><span>") .. "</span></span>", textw(s)
end
-- 返り値: html, 幅(em)
-- 縦書きの注記ブロック(右から左へ読む)。括弧は DiaPro の縦組み用括弧(時刻欄 1 列の幅)を上下に置く
-- VR{…} 括弧なし / VP{…} ︵︶ / VK{…} ︹︺ / VB{…} 黒レンズ ︻…
local VBOX = { VR = false, VP = { 0xFE35, 0xFE36 }, VK = { 0xFE39, 0xFE3A }, VB = { 0xFE3B, 0xEF3C } }
-- 맨 바깥의 / 로만 나눈다({ } の中の / はそのまま)
local function split_top(raw)
local parts, depth, last = {}, 0, 1
for i = 1, #raw do
local ch = sub(raw, i, i)
if ch == "{" then depth = depth + 1
elseif ch == "}" then depth = depth - 1
elseif ch == "/" and depth == 0 then parts[#parts + 1] = sub(raw, last, i - 1); last = i + 1 end
end
parts[#parts + 1] = sub(raw, last)
return parts
end
local function badge(tag, raw, cell)
if tag == "DG" or tag == "DR" then
-- 斜線で区切った見出し(左上の「行先\列車番号\駅名」など)
-- DG{右上/…/左下} は \ の線、DR{左上/…/右下} は /(左右反対)。
-- 最後の線は角から角へ。それより上の線は、右(DR は左)の見出し行の境目で縁に当たる。
-- 部分ごとに受け持つ見出し行の数を「行先:1」のように書ける(既定は 1 行ずつ、最後の部分の手前が残り全部)。
-- 行の高さは表を組むときに見積もって --jtt-dgyK に入れる(render_grid)
local parts, rows = split_top(raw), {}
for k, part in ipairs(parts) do
local body, nrow = match(part, "^(.-):(%d+)$")
if body then parts[k] = body; rows[k] = tonumber(nrow) else rows[k] = 1 end
end
local n = #parts
local cum, acc = {}, 0
for k = 1, n - 2 do acc = acc + rows[k]; cum[k] = acc end
local total = acc + (n >= 2 and rows[n - 1] or 1)
local vars = { "--y0:0" }
for k = 1, n - 2 do vars[#vars + 1] = format("--y%d:var(--jtt-dgy%d,%.3f)", k, k, cum[k] / total) end
if n >= 2 then vars[#vars + 1] = format("--y%d:1", n - 1) end
local DR = (tag == "DR")
local o, sz, wmax, wsum = {}, {}, 0, 0
local function place(xexpr, yexpr, inner)
o[#o + 1] = '<span class="jtt-dgc1" style="left:calc(' .. xexpr .. ' * 100%);top:calc(' .. yexpr .. ' * 100%)">' .. inner .. "</span>"
end
for k, part in ipairs(parts) do
local h, w = render(part, cell)
if w > wmax then wmax = w end
wsum = wsum + w
sz[#sz + 1] = h
local plain = gsub(h, "<[^>]*>", "")
local chars = {}
if not find(h, "<", 1, true) then
for ch in gmatch(plain, "[%z\1-\127\194-\244][\128-\191]*") do chars[#chars + 1] = ch end
else chars[1] = h end -- 装飾付きの部分はまとめて 1 か所に
local L = #chars
if k < n then o[#o + 1] = format('<span style="--m:calc((var(--y%d) + var(--y%d)) / 2)">', k - 1, k) end
for j, ch in ipairs(chars) do
local sp = (L == 1) and 0.5 or (0.12 + (j - 0.5) / L * 0.76)
local x, y
if k < n then
-- 帯の中心線: 縁の (1, m) から上の縁の (1 − m, 0) まで(線と平行)
x = format("(1 - var(--m) * %.3f)", 1 - sp); y = format("(var(--m) * %.3f)", sp)
else
-- いちばん下の部分: 角から角への線より下の三角形
x = format("%.3f", 0.5 * sp); y = format("%.3f", 0.5 + 0.5 * sp)
end
if DR then x = "(1 - " .. x .. ")" end
place(x, y, ch)
end
if k < n then o[#o + 1] = "</span>" end
end
-- 線: 縁の高さ y で終わる線の位置は 100% − 50%·y(角から角が 50%)。色の位置は小さい順に
local stops = {}
for k = n - 1, 1, -1 do
local pc = format("calc(100%% - var(--y%d) * 50%%)", k)
stops[#stops + 1] = format("transparent calc(%s - .6px),var(--jtt-fg) calc(%s - .6px),var(--jtt-fg) calc(%s + .6px),transparent calc(%s + .6px)", pc, pc, pc, pc)
end
local dir = DR and "to top left" or "to top right"
cell.dg = true; cell.dgcum = cum; cell.dgtotal = total
return '<span class="jtt-dg" style="' .. concat(vars, ";") .. '"><span class="jtt-dgs" aria-hidden="true">' .. concat(sz, "<br>") .. "</span>"
.. '<span class="jtt-dga"><span class="jtt-dgl" style="background:linear-gradient(' .. dir .. ",transparent," .. concat(stops, ",") .. ',transparent)"></span>'
.. concat(o) .. "</span></span>", min(wmax + 1.5, COL_W)
end
if VBOX[tag] ~= nil then
local h = render(raw, cell)
local _, nbr = gsub(h, "<br>", "")
local br = VBOX[tag]
local o = '<span class="jtt-vbox">' .. (br and bk(br[1], true) or "")
.. '<span class="jtt-vb">' .. h .. "</span>" .. (br and bk(br[2]) or "") .. "</span>"
cell.c = true
return o, math.max(nbr + 1, br and 2 or 0)
end
if tag == "N" then
-- 列車番号は本文(明朝)・同じ大きさ。数字も明朝で出るので約 0.55em、英字は textw と同じ見積もり
local h = render(raw, cell)
local w = textw(h) + select(2, gsub(gsub(h, "<[^>]*>", ""), "%d", "")) * 0.05
cell.c = true -- 列車番号は既定で中央揃え
return '<span class="jtt-no">' .. h .. "</span>", w
end
cell.c = true
if tag == "D" then
local key = gsub(string.lower(raw), "[%s_%-]", "")
local g = ICON[key]
if not g then
return '<span class="jtt-err" title="D{} unknown">' .. raw .. "</span>", textw(raw)
end
return g, textw(g)
end
local inner, iw = render(raw, cell)
local text = gsub(inner, "<[^>]*>", "")
local num = tonumber(text)
-- C{0..34}: DiaPro の横長丸数字(幅 1600)
if tag == "C" and num and num >= 0 and num <= 34 and num == floor(num) then
if num == 0 then return U(0x24EA), 1.6 end
if num <= 20 then return U(0x2460 + num - 1), 1.6 end
return U(0x3251 + num - 21), 1.6
end
local cls = BADGE[tag]
-- 여러 줄(S{急/行})・縦書き(S{r急行.})の囲み: 高さを中身に合わせる。幅は いちばん長い行で決める
local nl = select(2, gsub(inner, "<br>", "")) + 1
local vert = find(inner, 'class="jtt-v"', 1, true) ~= nil
local n = 0
for line in gmatch(gsub(inner, "<br>", "\n") .. "\n", "([^\n]*)\n") do
local u = ulen(gsub(line, "<[^>]*>", "")); if u > n then n = u end
end
local w
if vert then
cls = cls .. " jtt-bd--vt"
w = 1.3 * SM
return '<span class="' .. cls .. '"><span>' .. inner .. "</span></span>", w
end
if nl > 1 then cls = cls .. " jtt-bd--ml" end
if num then
-- 数字バッジ(「1.2」等)は字幅どおり。横長クラスは付けない
cls = cls .. " jtt-bd--n"
w = (iw + 0.4) * SM
elseif n >= 2 then
cls = cls .. " jtt-bd--w" .. min(n, 4)
w = (n == 2 and 2.2 or 2.4) * SM
else
w = 1.3 * SM
end
return '<span class="' .. cls .. '"><span>' .. inner .. "</span></span>", w
end
-- lines を渡すと最上位の呼び出しとして行ごとの {first,last,w,nosq} を記録する
render = function(s, cell, lines)
local out, n, i, len = {}, 0, 1, #s
local cur = lines and { first = 1, w = 0 } or nil
local w, maxw = 0, 0
local function add(x) w = w + x; if cur then cur.w = cur.w + x end end
local function newline(nosq_prev, excl)
if w > maxw then maxw = w end
w = 0
if cur then
cur.last = excl and (n - 1) or n; if nosq_prev then cur.nosq = true end
lines[#lines + 1] = cur
cur = { first = n + 1, w = 0 }
end
end
while i <= len do
local b = byte(s, i)
if b >= 0x80 then
local bw = (b < 0xE0 and 2) or (b < 0xF0 and 3) or 4
local ch = sub(s, i, i + bw - 1)
n = n + 1; out[n] = ch; add(textw(ch)); i = i + bw
else
local tok = PAIR[sub(s, i, i + 1)]
if tok then
n = n + 1; out[n] = tok.t; add(tokw(tok)); if tok.c then cell.c = true end; i = i + 2
elseif b == 1 and find(s, "\2", i + 1, true) then -- 時刻の区切り(timesep)
local close = find(s, "\2", i + 1, true)
n = n + 1; out[n] = '<span class="jtt-td">' .. gsub(gsub(sub(s, i + 1, close - 1), "&", "&"), "<", "<") .. "</span>"
add(0.36); i = close + 1
elseif b == 96 and find(s, "`", i + 1, true) then -- `…` 記号として読まない素の文字列(英字など)
local close = find(s, "`", i + 1, true)
local lit = sub(s, i + 1, close - 1)
n = n + 1; out[n] = '<span class="jtt-lit">' .. gsub(gsub(lit, "&", "&"), "<", "<") .. "</span>"
add(textw(lit)); i = close + 1
elseif b == 106 and byte(s, i + 1) == 39 and find(s, "'", i + 2, true) then
-- j'…' 縦に均等割付(字間と同じだけ上下にも空ける)。見出しのキロ欄など
local close = find(s, "'", i + 2, true)
local t, cs = sub(s, i + 2, close - 1), {}
for ch in gmatch(t, "[%z\1-\127\194-\244][\128-\191]*") do cs[#cs + 1] = "<span>" .. ch .. "</span>" end
n = n + 1; out[n] = '<span class="jtt-vj">' .. concat(cs) .. "</span>"
add(1); cell.c = true; i = close + 1
elseif b == 39 then -- '
local close = find(s, "'", i + 1, true)
if close then
local h, jw = justify(sub(s, i + 1, close - 1), cell)
n = n + 1; out[n] = h; add(jw); i = close + 1
else
n = n + 1; out[n] = "'"; add(0.3); i = i + 1
end
elseif b >= 65 and b <= 90 then -- A-Z
local tag, body = match(s, "^(%u%u?)(%b{})", i)
if tag and BADGE[tag] then
local h, bw = badge(tag, sub(body, 2, -2), cell)
n = n + 1; out[n] = h; add(bw)
i = i + #tag + #body
else
local w3 = sub(s, i, i + 2)
local br = BRACKET[w3]
if br then
-- 括弧は必ず 1 行を占有(前後で行を区切る)。直後の "/" は読み飛ばす
if n > 0 then newline() end
n = n + 1; out[n] = br
cell.c = true; i = i + 3
newline(true)
if sub(s, i, i) == "/" then i = i + 1 end
else
local wt = WORD[w3]
if wt then
n = n + 1; out[n] = wt.t; add(tokw(wt)); if wt.c then cell.c = true end; i = i + 3
else
n = n + 1; out[n] = char(b); add(cpw(b)); i = i + 1
end
end
end
else
local ch = char(b)
tok = SINGLE[ch]
if tok then
n = n + 1; out[n] = tok.t; if tok.c then cell.c = true end
if ch == "/" then newline(false, true)
elseif ch == "r" then if cur then cur.nosq = true end; cell.vert = true
else add(tokw(tok)) end
else
n = n + 1; out[n] = ch; add(cpw(b))
end
i = i + 1
end
end
end
if w > maxw then maxw = w end
if cur then cur.last = n; lines[#lines + 1] = cur end
return concat(out), maxw, out
end
-- 時刻欄用: 2em を超える行を jtt-sq で横に詰めた HTML(不要なら nil)
local function squeeze(out, lines)
local changed = false
local o = {}
for k = 1, #out do o[k] = out[k] end
for _, ln in ipairs(lines) do
if not ln.nosq and ln.w > COL_W + 0.05 and ln.last >= ln.first then
local seg = concat(o, "", ln.first, ln.last)
local _, opens = gsub(seg, "<span", "")
local _, closes = gsub(seg, "</span>", "")
if opens == closes then
local r = floor(COL_W / ln.w * 1000) / 1000
o[ln.first] = '<span class="jtt-sq" style="--r:' .. r .. '"><span>' .. o[ln.first]
o[ln.last] = o[ln.last] .. "</span></span>"
changed = true
end
end
end
return changed and concat(o) or nil
end
--------------------------------------------------------------------------
-- 3. セル解析(属性 ; 内容)— 同一文字列はキャッシュ
--------------------------------------------------------------------------
local parsed = {}
--------------------------------------------------------------------------
-- セル属性("属性;内容" の属性部)
-- 旧形式の短縮記法: h ^N <N ~N & # !!(そのまま使える)
-- 名前付き(空白区切り):
-- 罫線 bt bb bl br(上下左右) b(四辺) bx(左右) by(上下)
-- = 0 無 / 1 細 / 2 太 / 3 極太 / d 二重 / . 点線 / - 破線
-- 揃え al=l|c|r va=t|m|b
-- 文字 fw=b|n fs=xs|sm|lg|xl|<数値em/%> font=dia|text|sans fc=色 ls=字間 lh=行高
-- 背景 bg=色 inv(白黒反転)
-- 寸法 w=幅 ht=高さ pad=余白 rs=N cs=N(結合)
-- その他 v(縦書き) wrap(折り返し可) cls=クラス,… css=生CSS
--------------------------------------------------------------------------
local BSTYLE = { ["0"] = "none", ["1"] = "1px solid", ["2"] = "2px solid", ["3"] = "3px solid",
d = "3px double", ["."] = "1px dotted", ["-"] = "1px dashed" }
local BSIDE = { bt = { "top" }, bb = { "bottom" }, bl = { "left" }, br = { "right" },
b = { "top", "bottom", "left", "right" }, bx = { "left", "right" }, by = { "top", "bottom" } }
local FSIZE = { xs = ".65em", sm = "var(--jtt-sm)", lg = "1.2em", xl = "1.44em" }
local FONT = { dia = '"DiaPro", var(--jtt-text)', text = "var(--jtt-text)", sans = "var(--jtt-sans)",
diasans = '"DiaPro", var(--jtt-sans)' }
local ALIGN = { l = "left", c = "center", r = "right" }
local VALIGN = { t = "top", m = "middle", b = "bottom" }
local function unit(v)
if match(v, "^%-?[%d.]+$") then return v .. "em" end
return v
end
local function parse_attrs(a, c)
local st, cls = {}, {}
for tok in gmatch(a, "%S+") do
local k, v = match(tok, "^([%a_%-]+)=(.*)$")
if k then
if BSIDE[k] then
local b = BSTYLE[v] or v
local col = (b == "none") and "" or " var(--jtt-fg)"
for _, side in ipairs(BSIDE[k]) do st[#st + 1] = "border-" .. side .. ":" .. b .. col end
elseif k == "al" then st[#st + 1] = "text-align:" .. (ALIGN[v] or v)
elseif k == "va" then st[#st + 1] = "vertical-align:" .. (VALIGN[v] or v)
elseif k == "fw" then st[#st + 1] = "font-weight:" .. ((v == "b" and "700") or (v == "n" and "400") or v) .. "!important"
if v == "b" then cls[#cls + 1] = "jtt-fb" end -- DiaPro の数字は太字を "titl" で出す(Regular しか登録しないため)
elseif k == "fs" then st[#st + 1] = "font-size:" .. (FSIZE[v] or unit(v)) .. "!important"
elseif k == "font" then st[#st + 1] = "font-family:" .. (FONT[v] or v) .. "!important"
elseif k == "fc" then st[#st + 1] = "color:" .. v
elseif k == "bg" then st[#st + 1] = "background:" .. v
elseif k == "ls" then st[#st + 1] = "letter-spacing:" .. unit(v) .. "!important"
elseif k == "lh" then st[#st + 1] = "line-height:" .. v
elseif k == "w" then local u = unit(v); st[#st + 1] = "width:" .. u .. ";min-width:" .. u .. ";max-width:" .. u
elseif k == "ht" then st[#st + 1] = "height:" .. unit(v)
elseif k == "pad" then st[#st + 1] = "padding:" .. gsub(v, ",", " ")
elseif k == "ml" then st[#st + 1] = "margin-left:" .. unit(v) -- 左を空ける(上段の項目など)
elseif k == "mr" then st[#st + 1] = "margin-right:" .. unit(v)
elseif k == "rs" then c.rs = tonumber(v)
elseif k == "cs" then c.cs = tonumber(v)
elseif k == "cls" then for x in gmatch(v, "[^,]+") do cls[#cls + 1] = x end
elseif k == "css" then st[#st + 1] = gsub(v, ",", ";")
elseif k == "vl" then -- セルの中央を通る縦線(矢印の軸など)
cls[#cls + 1] = "jtt-vl"; st[#st + 1] = "--jtt-vl:" .. (BSTYLE[v] or v)
elseif k == "vp" then -- 縦線の位置 l=左端 c=中央 r=右端
cls[#cls + 1] = "jtt-vp--" .. v
elseif k == "ah" then -- 矢じり d=下向き u=上向き(大文字は塗り)
local lv = string.lower(v)
cls[#cls + 1] = "jtt-ah jtt-ah--" .. lv .. ((v ~= lv) and " jtt-ah--f" or "")
end
elseif tok == "!!" or find(tok, "!!", 1, true) then
c.skip = true
elseif tok == "v" then c.vwrap = true
elseif tok == "inv" then st[#st + 1] = "color:var(--jtt-bg);background:var(--jtt-fg)"
elseif tok == "wrap" then c.wrapc = true
elseif match(tok, "^[h%^<~&#%d]+$") then -- 旧形式の短縮記法
local rs, cs, ht = match(tok, "%^(%d+)"), match(tok, "<(%d+)"), match(tok, "~(%d+)")
if rs then c.rs = tonumber(rs) end
if cs then c.cs = tonumber(cs) end
if ht then c.ht = tonumber(ht) end
if find(tok, "h", 1, true) then c.th = true end
if find(tok, "&", 1, true) then c.line = 1
elseif find(tok, "#", 1, true) and c.line ~= 1 then c.line = 0 end
end
end
if #st > 0 then c.style = concat(st, ";") end
if #cls > 0 then c.cls = concat(cls, " ") end
end
local function parse_cell(raw)
local c = parsed[raw]
if c then return c end
c = {}
local s = raw
local semi = find(s, ";", 1, true)
if semi then
local a = sub(s, 1, semi - 1)
s = match(sub(s, semi + 1), "^%s*(.-)%s*$")
if find(a, "!!", 1, true) then
c.skip = true
else
parse_attrs(a, c)
end
elseif find(s, "!!", 1, true) then
c.skip = true
end
if not c.skip then
if s == "" or not find(s, "[^%d ]") then
c.html = s
c.w = textw(s)
else
local lines = {}
local html, mw, out = render(s, c, lines)
c.html = html
c.w = mw
c.html_t = squeeze(out, lines) -- 時刻欄で使う版(詰める行が無ければ nil)
end
if c.vwrap then
c.html = '<span class="jtt-v">' .. c.html .. "</span>"
c.html_t = nil
end
if c.wrapc then
-- 表の幅を押し広げずに、他の行で決まった幅の中で折り返す
c.html = '<div class="jtt-wr">' .. c.html .. "</div>"
c.html_t = nil
end
end
parsed[raw] = c
return c
end
--------------------------------------------------------------------------
-- 4. CSV
--------------------------------------------------------------------------
local function parse_csv(csv)
local rows, nr = {}, 0
for line in gmatch(csv, "[^\r\n]+") do
if find(line, "%S") then
local row, nc = {}, 0
for cell in gmatch(line .. ",", "([^,]*),") do
nc = nc + 1; row[nc] = match(cell, "^%s*(.-)%s*$")
end
nr = nr + 1; rows[nr] = row
end
end
return rows
end
--------------------------------------------------------------------------
-- 5. タイトル n=上り札 k=下り札 u=細字 l=囲み .=閉じ >=—
--------------------------------------------------------------------------
local TITLE = {
n = '<span class="jtt-dir jtt-dir--up">',
k = '<span class="jtt-dir jtt-dir--down">',
u = '<span class="jtt-sub">',
l = '<span class="jtt-box">',
["."] = "</span>",
[">"] = "—",
}
local function render_title(s)
s = gsub(s, "'''(.-)'''", "%1") -- 太字指定は不要(タイトルは元々太字)
return (gsub(s, "[nkul%.>]", TITLE))
end
--------------------------------------------------------------------------
-- 6. 本体
--------------------------------------------------------------------------
local ROLE = { "km", "mk", "st", "ad" } -- 左ブロック(右詰めで割り当て)
-- 組み込みの役割。これ以外の名前(cols fare st ad の fare など)は利用者が足す欄で、
-- 値は素の文字列、幅は width.名前(基準文字の倍数、既定 3.6)、見出しは label.名前
local BUILTIN_ROLE = { km = true, mk = true, st = true, ad = true, alt = true }
local DEFAULT_W = { km = 3.0, mk = 2.6, alt = 7.0, st = 6.4, ad = 1.2 } -- CSS の --jtt-km などと同じ値
-- 表示用の 2 次元セル配列(旧 CSV と同じトークン文字列)を HTML にする共通部
local function render_grid(frame, rows, H, L, F, title, opt)
opt = opt or {}
local N = #rows
local maxcols = 0
for r = 1, N do if #rows[r] > maxcols then maxcols = #rows[r] end end
-- 列位置 → 役割 class(足した欄は位置と幅を style で持つ)
local poscls, roles, colsty = {}, {}, {}
if opt.roles then
for k = 1, L do roles[k] = opt.roles[k] end -- 新形式: cols の実際の役割
else
for k = 1, min(L, 4) do roles[k] = ROLE[4 - min(L, 4) + k] end
end
local RR, TT = opt.R or {}, opt.T
for j = 1, maxcols do
local rq = TT and (j - L - TT)
if rq and rq >= 1 and RR[rq] then
poscls[j] = "jtt-rc jtt-r-" .. RR[rq] .. ((rq == 1) and " jtt-rc1" or "")
elseif j > L then
poscls[j] = (j == L + 1) and "jtt-t jtt-t1" or "jtt-t"
else
local r = opt.roles and roles[j] or roles[j - (L - min(L, 4))]
if r and not BUILTIN_ROLE[r] then
poscls[j] = "jtt-cx jtt-cx-" .. r
colsty[j] = "left:var(--jtt-o-" .. r .. ");width:var(--jtt-" .. r .. ");min-width:var(--jtt-" .. r .. ")"
else
poscls[j] = r and ("jtt-" .. r) or "jtt-km"
end
end
end
-- 左の欄に要る幅の見積もり(1 列だけのセル。基準文字の倍数。余白込み)
local colneed = {}
if opt.roles then
for r = 1, N do
for j = 1, min(L, #rows[r]) do
local c = parse_cell(rows[r][j])
if not c.skip and not (c.cs and c.cs > 1) and not c.th and not c.dg and c.w then
local role = roles[j]
local sc = (role == "mk" or role == "ad") and 0.8 or 1
local need = c.w * sc + ((role == "ad") and 0.15 or 0.35)
if not colneed[j] or need > colneed[j] then colneed[j] = need end
end
end
end
end
-- sticky オフセット(CSS 変数)
local vars, acc = {}, {}
for k = 1, #roles do
local t = roles[k]
-- 幅: 足した欄は width.名前(既定 3.6)、組み込みの欄も width.km などで CSS の既定を上書きできる。
-- 固定(sticky)の位置はこの幅の和で決まるので、中身が幅を超えるとスクロール時に重なる
local w = tonumber(opt.set and opt.set["width." .. t]) or (not BUILTIN_ROLE[t] and 3.6) or nil
-- 中身がこの幅を超える欄(「釜山ヨリ」の入ったキロ欄など)は広げる。固定の位置がずれて重ならないように
local need = colneed[k]
if need and need > (w or DEFAULT_W[t] or 0) + 0.05 then w = need end
if w then vars[#vars + 1] = "--jtt-" .. t .. ":calc(" .. format("%.2f", w) .. " * var(--jtt-fs))" end
local off = (#acc == 0 and "0px") or (#acc == 1 and acc[1]) or ("calc(" .. concat(acc, " + ") .. ")")
vars[#vars + 1] = "--jtt-o-" .. t .. ":" .. off
acc[#acc + 1] = "var(--jtt-" .. t .. ")"
end
local tdcache = {}
local function td(c, j, extra) -- extra: その位置だけの style(斜線見出しの線の位置など)
local cls = poscls[j]
local plain = not (c.cs or c.th or colsty[j] or extra)
local bucket
if plain then
bucket = tdcache[cls]
if not bucket then bucket = {}; tdcache[cls] = bucket end
local hit = bucket[c]
if hit then return hit end
end
local a = { '<td class="', cls }
if j == 1 then a[#a + 1] = " jtt-c1" end
if opt.roles and L > 0 then
-- 新形式: 左ブロックの区切り線は「最後の左列」に引く(着発が最後でない並びもあるため)
if j == L then a[#a + 1] = " jtt-lend"
elseif roles[j] == "ad" then a[#a + 1] = " jtt-adin" end
end
if c.th then a[#a + 1] = " jtt-th" end
if c.c then a[#a + 1] = " jtt-c" end
if c.dg then a[#a + 1] = " jtt-dgc" end
if c.cls then a[#a + 1] = " " .. c.cls end
a[#a + 1] = '"'
if c.rs and c.rs > 1 then a[#a + 1] = ' rowspan="' .. c.rs .. '"' end
local cs = c.cs or (c.th and (L - 1))
if cs then
cs = min(cs, maxcols - j + 1)
if opt.roles and j <= L then cs = min(cs, L - j + 1) end -- 左欄の結合は左欄の中で止める
if cs > 1 then a[#a + 1] = ' colspan="' .. cs .. '"' end
end
local xs = colsty[j]
if extra then xs = xs and (xs .. ";" .. extra) or extra end
if c.ht or c.style or xs then
a[#a + 1] = ' style="' .. (xs and (xs .. ((c.ht or c.style) and ";" or "")) or "") .. (c.ht and ("height:" .. c.ht .. "px" .. (c.style and ";" or "")) or "")
.. (c.style or "") .. '"'
end
a[#a + 1] = ">"
-- 横詰めは時刻欄 1 列分のセルだけ(結合セル・見出し・右欄には使わない)
a[#a + 1] = (j > L and (not TT or j <= L + TT) and not c.th and not (c.cs and c.cs > 1) and c.html_t) or c.html
a[#a + 1] = "</td>"
local s = concat(a)
if plain then bucket[c] = s end
return s
end
local out = {}
if STYLES_PAGE then
out[#out + 1] = frame:extensionTag("templatestyles", "", { src = STYLES_PAGE })
end
local tp = opt.top
if tp then
-- 枠の外の上段(左の縦書きタイトルと同じく、セルにしない)。[top] の各行が 1 段、
-- 各項目は al=l|c|r で左・中央・右の組に入り、fs= fw= fc= bg= pad= などで自由に組める
local sty = ""
if tp.style and tp.style ~= "" then
local pc = parse_cell(tp.style .. "; x")
if pc.style then sty = ' style="' .. pc.style .. '"' end
end
local o = { '<div class="jtt-wrap jtt-wrap--top' .. (opt.tside and " jtt-wrap--tr" or "") .. '"><div class="jtt-top"' .. sty .. '>' }
for _, row in ipairs(tp.rows) do
local g = { l = {}, c = {}, r = {} }
for _, item in ipairs(row) do
if item and item ~= "" and not find(item, "!!", 1, true) then
local a = match(item, "^([^;]*);")
local al = a and match(" " .. a .. " ", "%sal=(%a)") or "l"
if not g[al] then al = "l" end
local pc = parse_cell(item)
local list = g[al]
list[#list + 1] = '<span class="jtt-ti' .. (pc.cls and (" " .. pc.cls) or "") .. '"'
.. (pc.style and (' style="' .. gsub(pc.style, "text%-align:%a+;?", "") .. '"') or "") .. '>' .. (pc.html or "") .. '</span>'
end
end
o[#o + 1] = '<div class="jtt-trow"><div class="jtt-tl">' .. concat(g.l) .. '</div><div class="jtt-tm">' .. concat(g.c)
.. '</div><div class="jtt-tr">' .. concat(g.r) .. '</div></div>'
end
o[#o + 1] = '</div><div class="jtt-row"><div class="jtt-title">'
out[#out + 1] = concat(o)
else
out[#out + 1] = '<div class="jtt-wrap' .. (opt.tside and " jtt-wrap--tr" or "") .. '"><div class="jtt-title">'
end
out[#out + 1] = render_title(title or "")
out[#out + 1] = '</div><div class="jtt-frame">'
-- 1 行目が h セル 1 個だけ → スクロールしない路線見出し帯
local first = 1
if N >= 1 then
local only, cnt = nil, 0
for j = 1, #rows[1] do
local c = parse_cell(rows[1][j])
if not c.skip then cnt = cnt + 1; only = c end
end
if cnt == 1 and only.th then
out[#out + 1] = '<div class="jtt-head"'
.. (only.ht and (' style="height:' .. only.ht .. 'px"') or "") .. '>'
.. (find(only.html, "<br>", 1, true) and ('<div class="jtt-hin">' .. only.html .. '</div>') or only.html) .. '</div>'
first = 2
end
end
if opt.head then
local hd = opt.head
local function part(s) if not s or s == "" then return "" end; return parse_cell("c; " .. s).html or "" end
local sty = {}
if hd.ht and hd.ht ~= "" then sty[#sty + 1] = "height:" .. (match(hd.ht, "^%d+$") and (hd.ht .. "px") or hd.ht) end
if hd.style and hd.style ~= "" then
local pc = parse_cell(hd.style .. "; x")
if pc.style then sty[#sty + 1] = pc.style end
local va = match(" " .. hd.style .. " ", "%sva=(%a)%s")
if va then sty[#sty + 1] = "align-items:" .. (({ t = "flex-start", b = "flex-end" })[va] or "center") end
end
out[#out + 1] = '<div class="jtt-head jtt-head--x"' .. (#sty > 0 and (' style="' .. concat(sty, ";") .. '"') or "") .. '>'
.. '<div class="jtt-hl">' .. part(hd.l) .. '</div><div class="jtt-hm">' .. part(hd.c)
.. '</div><div class="jtt-hrt">' .. part(hd.r) .. '</div></div>'
end
local dcols = ""
if opt.cinfo then
local cc = {}
for j = 1, maxcols do
local ci = opt.cinfo[j]
cc[j] = not ci and "-" or ci.kind == "train" and ("t:" .. ci.ti) or ((ci.kind == "right" and "r:" or "l:") .. (ci.role or ""))
end
dcols = ' data-cols="' .. concat(cc, ",") .. '"'
end
out[#out + 1] = '<div class="jtt-scroll"><table class="jtt"' .. dcols .. ' style="' .. concat(vars, ";") .. '">'
-- 行の高さの見積もり(斜線見出しの線を右の見出し行の境目に合わせるため。単位は基準文字)
local esth = {}
local function est_cell(c)
local html = c.html or ""
if html == "" then return 1.2 end
if find(html, "jtt-v", 1, true) then
local t = gsub(gsub(html, "<br>", "\n"), "<[^>]*>", "")
local mx = 0
for seg in gmatch(t .. "\n", "([^\n]*)\n") do local u = ulen(seg); if u > mx then mx = u end end
return mx * 1.05 + 0.5
end
local _, nbr = gsub(html, "<br>", "")
return (nbr + 1) * 1.25 + (find(html, "jtt-bd", 1, true) and 0.4 or 0.2)
end
local function row_h(r)
if esth[r] then return esth[r] end
local h = 1.2
for j = L + 1, #rows[r] do
local c = parse_cell(rows[r][j])
if not c.skip and not (c.rs and c.rs > 1) then local e = est_cell(c); if e > h then h = e end end
end
esth[r] = h
return h
end
local function dg_vars(c, r)
local rs = c.rs or 1
if rs < 2 or not c.dgcum or #c.dgcum == 0 then return nil end
local hs, tot = {}, 0
for i = 0, rs - 1 do local h = rows[r + i] and row_h(r + i) or 1.2; hs[i + 1] = h; tot = tot + h end
local o = {}
for k, ck in ipairs(c.dgcum) do
local acc = 0
for i = 1, min(ck, rs - 1) do acc = acc + hs[i] end
o[#o + 1] = format("--jtt-dgy%d:%.3f", k, acc / tot)
end
return concat(o, ";")
end
for r = first, N do
local row = rows[r]
local cells, nc, line = {}, 0, nil
for j = 1, #row do
local c = parse_cell(row[j])
if not c.skip then
if c.line then line = c.line end
nc = nc + 1; cells[nc] = td(c, j, c.dg and dg_vars(c, r) or nil)
end
end
local cls
if r <= H then cls = "jtt-hr"
elseif r == H + 1 then cls = "jtt-b1"
elseif r == N - F + 1 then cls = "jtt-f1"
elseif r > N - F then cls = "jtt-fr" end
if line == 1 then cls = (cls and cls .. " " or "") .. "jtt-ln"
elseif line == 0 then cls = (cls and cls .. " " or "") .. "jtt-nl" end
local dm = ""
if opt.rinfo then
local ri = opt.rinfo[r] or { kind = "raw" }
dm = ' data-m="' .. (ri.kind or "") .. ":" .. gsub((gsub(ri.key or "", "&", "&")), '"', """) .. '"'
end
out[#out + 1] = cls and ('<tr class="' .. cls .. '"' .. dm .. '>') or ("<tr" .. dm .. ">")
out[#out + 1] = concat(cells)
out[#out + 1] = "</tr>"
end
out[#out + 1] = "</table></div>"
if opt.notes then out[#out + 1] = opt.notes end
out[#out + 1] = tp and "</div></div></div>" or "</div></div>"
return concat(out)
end
-- 旧形式: {{#invoke:Timetable|csv|header=|left=|footer=|title=|<CSV>}}
function p.csv(frame)
local args = frame.args
return render_grid(frame, parse_csv(args[1] or ""),
tonumber(args.header) or 4, tonumber(args.left) or 3, tonumber(args.footer) or 1, args.title)
end
--------------------------------------------------------------------------
-- 7. 新形式(列車ごとに 1 行の専用記法)
--
-- {{#invoke:Timetable|main|data=
-- title "k下り. 函館本線 u(青森 > 函館 > 倶知安)."
-- [rows] 見出し行 キー ラベル [fmt=…]
-- [footer] 脚注行 キー ラベル [fmt=…]
-- [stations] 駅(表示行) キロ 印 駅名 着発 / --- は罫線
-- [trains] 列車 見出し値… : 時刻… : 脚注値…
-- }}
--
-- 詳細は Module:Timetable/doc(FORMAT.md)。
--------------------------------------------------------------------------
-- 空白区切り。"…" は空白を含む生セル("" で " 自体)
local function tokenize(line)
local t, i, len = {}, 1, #line
while i <= len do
local c = sub(line, i, i)
if c == " " or c == "\t" then
i = i + 1
else
-- 空白まで 1 トークン。途中の "…" は空白を含められる("" で " 自体)。
-- 引用の外の , で区切った部分(着発の「着,発」)も parts に記録する
local buf, parts = {}, {}
local part, pq, pplain = {}, false, false
local function endpart()
parts[#parts + 1] = { v = concat(part), q = pq and not pplain }
part, pq, pplain = {}, false, false
end
while i <= len do
local d = sub(line, i, i)
if d == " " or d == "\t" then break
elseif d == '"' then
pq = true
local j = i + 1
while j <= len do
local e = sub(line, j, j)
if e == '"' then
if sub(line, j + 1, j + 1) == '"' then buf[#buf + 1] = '"'; part[#part + 1] = '"'; j = j + 2 else break end
else
buf[#buf + 1] = e; part[#part + 1] = e; j = j + 1
end
end
i = j + 1
else
if d == "," then endpart() else part[#part + 1] = d; pplain = true end
buf[#buf + 1] = d; i = i + 1
end
end
endpart()
local tok = { v = concat(buf), q = (#parts == 1 and parts[1].q) or false }
if #parts > 1 then tok.parts = parts end
t[#t + 1] = tok
end
end
return t
end
local function trim(s) return match(s, "^%s*(.-)%s*$") end
-- 時刻: 分(0〜1439)→ 表示文字列(0 時台は「015」のように 0 を付ける)
local function fmt_time(mins)
mins = mins % 1440
local h, mm = floor(mins / 60), mins % 60
if h == 0 then return format("0%02d", mm) end
return format("%d%02d", h, mm)
end
local KIND = {
["発"] = "DEP", ["發"] = "DEP", dep = "DEP",
["着"] = "ARR", arr = "ARR",
["〃"] = "q", q = "q",
}
-- 着発の 2 段組。値は 着行・発行の着発欄に出すトークン(韓国語の 착발 は 착/발 のまま)
local PAIRKIND = { ["着発"] = { "ARR", "DEP" }, ["着發"] = { "ARR", "DEP" }, arrdep = { "ARR", "DEP" },
["착발"] = { "착", "발" } }
-- 着・発の意味(上り表示での入れ替え・駅時刻表の着駅判定に使う)。韓国語の 발/착 は字のまま出す
local SEM = { ["発"] = "DEP", ["發"] = "DEP", dep = "DEP", ["발"] = "DEP",
["着"] = "ARR", arr = "ARR", ["착"] = "ARR" }
local FLIP = { ["발"] = "착", ["착"] = "발" }
local DEFAULT_COLS = { "km", "mk", "st", "ad" }
local SPECIAL = { cls = true, style = true, tstyle = true } -- tstyle: 列車の時刻欄(駅の行)だけの体裁
local function cells_of(tk) -- 自由行のトークン → セル文字列
local row = {}
for k, t in ipairs(tk) do
if not t.q and t.v == "_" then row[k] = ""
elseif not t.q and t.v == "!" then row[k] = "!!;"
else row[k] = t.v end
end
return row
end
local function parse_model(text)
local m = { set = {}, setorder = {}, cols = DEFAULT_COLS, rcols = {}, rows = {}, foot = {}, st = {},
tr = { down = {}, up = {} }, styles = {}, pre = {}, post = {}, notes = {}, grid = {},
top = {}, raw = { styles = {}, pre = {}, post = {}, notes = {}, grid = {}, top = {} } }
local sec, cur, pending_line, last, pending_tline
-- 字下げして「:」で始まる行は前の行の続き(長い列車行を 見出し値 / 時刻 に分けて書ける)
text = gsub(text, "\r?\n[ \t]+(:[^\n]*)", " %1")
for raw in gmatch(text .. "\n", "([^\n]*)\n") do
local l = trim(gsub(raw, "\r", ""))
if l == "" or sub(l, 1, 1) == "#" then
-- 空行・コメント
elseif match(l, "^%[%s*%a+%s*%a*%s*%]$") then
local name, arg = match(l, "^%[%s*(%a+)%s*(%a*)%s*%]")
sec = name
if name == "trains" then cur = m.tr[(arg == "up") and "up" or "down"]; last = nil end
elseif not sec then -- 設定
local tk = tokenize(l)
local vals = {}
for k = 2, #tk do vals[#vals + 1] = tk[k].v end
if m.set[tk[1].v] == nil then m.setorder[#m.setorder + 1] = tk[1].v end
m.set[tk[1].v] = concat(vals, " ")
if tk[1].v == "cols" then m.cols = vals end
if tk[1].v == "rcols" then m.rcols = vals end
elseif sec == "rows" or sec == "footer" then -- 見出し・脚注の定義
local tk = tokenize(l)
local d = { key = tk[1].v }
local k = 2
if tk[2] and not tk[2].q and tk[2].v == "@raw" then
d.raw = {}; k = 3
while tk[k] and (tk[k].q or not (match(tk[k].v, "^fmt=") or match(tk[k].v, "^style=") or tk[k].v == "merge")) do
d.raw[#d.raw + 1] = tk[k].v; k = k + 1
end
elseif tk[2] and not tk[2].q and tk[2].v == "^" then
-- ラベル「^」: 左右の見出し名を上の行とつなげる(列車名を 種別・愛称・設備 の行に分けるとき)
d.join = true; k = 3
else
d.label = tk[2] and tk[2].v or ""; k = 3
end
for q = k, #tk do
if not tk[q].q then
local f = match(tk[q].v, "^fmt=(.*)$")
if f then d.fmt = f end
local st = match(tk[q].v, "^style=(.*)$")
if st then d.style = st end
if tk[q].v == "merge" then d.merge = true end
local rl = match(tk[q].v, "^r=(.*)$") -- 右側駅欄に出す見出し名(別の言語・表記)
if rl then d.rlabel = rl end
end
end
local list = (sec == "rows") and m.rows or m.foot
list[#list + 1] = d
elseif sec == "stations" then
if match(l, "^%-%-%-+$") then
pending_line = true
elseif sub(l, 1, 1) == "+" then
-- 自由行: + "左" : "本文" / + "全幅の本文" (style=… 可)
local tk = tokenize(trim(sub(l, 2)))
local s = { free = true, f = {}, line = pending_line }
pending_line = false
local parts, pi = { {}, {} }, 1
for _, t in ipairs(tk) do
local st = (not t.q) and match(t.v, "^style=(.*)$")
if st then s.style = st
elseif not t.q and t.v == ":" then pi = 2
else parts[pi][#parts[pi] + 1] = t.v end
end
if pi == 2 then s.left, s.body = concat(parts[1], " "), concat(parts[2], " ")
else s.body = concat(parts[1], " ") end
m.st[#m.st + 1] = s
else
local tk = tokenize(l)
local s = { f = {}, line = pending_line }
pending_line = false
local pos = {}
for _, t in ipairs(tk) do
local key, val, rrole, frole = nil, nil, nil, nil
if not t.q then
-- 欄ごとの体裁: st.style="fw=b fs=lg" r.ad.style=bl=0(右側だけ) ad.dep.style=bt=0(発行だけ)
frole, val = match(t.v, "^([%a%.]+)%.style=(.*)$")
if not frole then rrole, val = match(t.v, "^r%.(%a+)=(.*)$") end
if not frole and not rrole then val = match(t.v, "^r=(.*)$"); if val then rrole = "st" end end
if not frole and not rrole then key, val = match(t.v, "^(%a+)=(.*)$") end
end
if frole then
s.fs = s.fs or {}; s.fs[frole] = val
elseif rrole then
-- 右側の駅欄だけ別の値(仮名書きの駅名など): r=しゅんせん r.km=81.3
s.r = s.r or {}; s.r[rrole] = { v = val }
elseif key and SPECIAL[key] then s[key] = val else pos[#pos + 1] = t end
end
for k, role in ipairs(m.cols) do s.f[role] = pos[k] or { v = "_" } end
local extra = #m.cols -- 右側にだけある役割の値は、cols の後ろに順に並ぶ
for _, role in ipairs(m.rcols) do
if not s.f[role] then extra = extra + 1; s.f[role] = pos[extra] or { v = "_" } end
end
local ad = s.f.ad
if ad and not ad.q then
if PAIRKIND[ad.v] then s.pair = true; s.pk = PAIRKIND[ad.v]
elseif ad.parts and #ad.parts == 2 then
-- 「着,発」「着,〃」のように 2 つ書けば、その文字で 2 段組
s.pair = true; s.pk = { ad.parts[1].v, ad.parts[2].v }
end
end
m.st[#m.st + 1] = s
end
elseif sec == "styles" then
m.styles[#m.styles + 1] = tokenize(l)
m.raw.styles[#m.raw.styles + 1] = l
elseif sec == "pre" or sec == "post" or sec == "grid" then
local list = m[sec]
list[#list + 1] = cells_of(tokenize(l))
m.raw[sec][#m.raw[sec] + 1] = l
elseif sec == "top" then
-- 枠の外の上段: 1 行 = 1 段。項目は "属性; 内容"(al=l|c|r で左・中・右、fs= で大きさ)
m.top[#m.top + 1] = cells_of(tokenize(l))
m.raw.top[#m.raw.top + 1] = l
elseif sec == "notes" then
m.notes[#m.notes + 1] = l
m.raw.notes[#m.raw.notes + 1] = l
elseif sec == "trains" and cur and match(l, "^%-%-%-+%s*%S*%s*$") then
-- --- : 次の列車の左に縦罫(既定は太線。--- d や --- 3 で種類を指定)
local v = match(l, "^%-%-%-+%s*(%S*)")
pending_tline = (v ~= "" and v) or "2"
elseif sec == "trains" and cur then
local rep = { match(l, "^@repeat%s+(%d+)%s+%+?(%-?%d+)%s*%+?(%-?%d*)") }
if rep[1] and last then
local n, dm, dk = tonumber(rep[1]), tonumber(rep[2]), tonumber(rep[3]) or 2
for k = 1, n do
local c = { attr = {}, foot = last.foot, times = last.times,
off = (last.off or 0) + dm * k }
for key, v in pairs(last.attr) do
local pre, num, post = match(v, "^(.-)(%d+)(%D*)$")
if pre and key == "no" then
c.attr[key] = pre .. tostring(tonumber(num) + dk * k) .. post
else
c.attr[key] = v
end
end
cur[#cur + 1] = c
end
else
local tk = tokenize(l)
local parts, pi = { {}, {}, {} }, 1
for _, t in ipairs(tk) do
if not t.q and t.v == ":" then pi = pi + 1
elseif parts[pi] then parts[pi][#parts[pi] + 1] = t end
end
local tr = { attr = {}, foot = {}, times = {} }
local A, T, Fo
if pi == 1 then A, T, Fo = {}, parts[1], {} else A, T, Fo = parts[1], parts[2], parts[3] end
local prev = cur[#cur]
local function assign(list, defs, into, prevmap)
local pos = 1
for _, t in ipairs(list) do
local key, val = nil, nil
if not t.q then key, val = match(t.v, "^([%w_]+)=(.*)$") end
local isnamed = key and SPECIAL[key] or false
if key and not isnamed then
for _, d in ipairs(defs) do if d.key == key then isnamed = true end end
end
if not isnamed then
key, val = defs[pos] and defs[pos].key, t.v
pos = pos + 1
end
if key then
if not t.q and val == "_" then val = ""
elseif not t.q and val == "%" then val = prevmap and prevmap[key] or "" end
into[key] = val
end
end
end
assign(A, m.rows, tr.attr, prev and prev.attr)
assign(Fo, m.foot, tr.foot, prev and prev.foot)
for _, t in ipairs(T) do
local base, n = nil, nil
if not t.q then base, n = match(t.v, "^(.+)%*(%d+)$") end
if base then
for _ = 1, tonumber(n) do tr.times[#tr.times + 1] = { v = base } end
else
tr.times[#tr.times + 1] = t
end
end
tr.line = pending_tline; pending_tline = nil
cur[#cur + 1] = tr
last = tr
end
end
end
return m
end
-- 列車の時刻トークンを、駅(表示行)ごとのセル文字列に解決する
-- 着発駅は { a = 着, d = 発 }、それ以外は { v = … }
local function resolve(train, stations)
local off, lastm = train.off or 0, nil
local function dec(t)
if not t then return "" end
if t.q then return t.v end
local v = t.v
if v == "_" then return "" end
if v == "!" then return "!!;" end
-- 「属性;時刻」— 属性を残したまま時刻を解釈する
local attr, body = match(v, "^([^;]*;)%s*(.*)$")
if attr then v = body else attr = "" end
if match(v, "^%d%d$") and lastm then -- 分だけ(時は直前から)
local h, mm = floor(lastm / 60), tonumber(v)
if mm < lastm % 60 then h = h + 1 end
lastm = (h * 60 + mm) % 1440
return attr .. fmt_time(lastm + off)
elseif match(v, "^%d%d%d%d?$") then -- 完全な時刻
local n = tonumber(v)
lastm = floor(n / 100) * 60 + n % 100
if off == 0 then return attr .. v end
return attr .. fmt_time(lastm + off)
end
return attr .. v
end
local out, ti = {}, 1
for i, s in ipairs(stations) do
if s.free then
out[i] = {}
else
local t = train.times[ti]; ti = ti + 1
if s.pair then
local pa = t and t.parts
if pa and #pa >= 2 then
out[i] = { a = dec(pa[1]), d = dec(pa[2]) }
else
local r = dec(t)
out[i] = { a = r, d = r }
end
else
out[i] = { v = dec(t) }
end
end
end
return out
end
-- 着発駅の欄を 着行・発行で別の値にするとき(連絡船の乗り継ぎで「739.2,0.0」など)の 2 値。分けないなら nil
local function split2(t)
if not t or t.q then return nil end
if t.parts and #t.parts == 2 then return t.parts[1], t.parts[2] end
local a, b = match(t.v or "", "^([^,]*),([^,]*)$")
if a then return { v = a }, { v = b } end
return nil
end
local function flip_ad(v)
if FLIP[v] then return FLIP[v] end
if SEM[v] == "DEP" then return "着" elseif SEM[v] == "ARR" then return "発" end
return v
end
-- 上り: 駅順を逆に、着↔発を入れ替え、キロ程を終点からに
local function reverse_stations(st)
local maxkm = 0
for _, s in ipairs(st) do
local k = s.f.km
if k and not k.q then
for x in gmatch(k.v, "[^,]+") do
if match(x, "^%d+%.?%d*$") then local n = tonumber(x); if n > maxkm then maxkm = n end end
end
end
end
local function flipkm(v)
if match(v, "^%d+%.?%d*$") then return format("%.1f", maxkm - tonumber(v)) end
return v
end
local r = {}
for i = #st, 1, -1 do
local s = st[i]
local c = {}
for k, v in pairs(s) do c[k] = v end
c.f, c.line = {}, st[i + 1] and st[i + 1].line or false
for k, v in pairs(s.f) do c.f[k] = v end
local ad = s.f.ad
if ad and not ad.q then
if FLIP[ad.v] then c.f.ad = { v = FLIP[ad.v] }
elseif SEM[ad.v] == "DEP" then c.f.ad = { v = "着" }
elseif SEM[ad.v] == "ARR" then c.f.ad = { v = "発" } end
end
local k = s.f.km
if k and not k.q and match(k.v, "^%d+%.?%d*$") then
c.f.km = { v = format("%.1f", maxkm - tonumber(k.v)) }
end
if s.pair then
-- 着行・発行で分けた欄は、上りでは上下が入れ替わる
for role, t in pairs(s.f) do
local a, b = split2(t)
if a and role ~= "ad" and role ~= "st" then
local x, y = b.v, a.v
if role == "km" then x, y = flipkm(x), flipkm(y) end
c.f[role] = { v = x .. "," .. y, parts = { { v = x }, { v = y } } }
end
end
if s.pk and not PAIRKIND[s.f.ad and s.f.ad.v or ""] then
c.pk = { flip_ad(s.pk[2]), flip_ad(s.pk[1]) }
end
end
r[#r + 1] = c
end
return r
end
-- セル文字列に属性を足す("^2" や "&")
local function addattr(cell, a)
if a == nil or a == "" then return cell end
if find(cell, ";", 1, true) then return a .. " " .. cell end
return a .. "; " .. cell
end
local function fmtv(fmt, v)
if v == nil or v == "" then return "" end
if not fmt then return v end
-- 値にセル属性が付いていれば("fw=b; 88")、属性は外に出して中身だけを包む → "fw=b; N{88}"
local at, body = match(v, "^([^;]*);%s*(.*)$")
if at then
if body == "" then return v end
return at .. "; " .. gsub(fmt, "%*", (gsub(body, "%%", "%%%%")))
end
return (gsub(fmt, "%*", (gsub(v, "%%", "%%%%"))))
end
-- 駅欄の各フィールド → セル文字列
local function station_cell(role, t)
if not t then return "" end
if t.q then return t.v end
local v = t.v
if v == "_" then return "" end
if role == "km" then
if match(v, "^%d+%.%d+$") then return (gsub(v, "%.", "..")) end
return v
elseif role == "st" then
local b2 = match(v, "^%*%*(.+)$")
if b2 then return "b'" .. b2 .. "'." end
local b1 = match(v, "^%*(.+)$")
if b1 then return "k'" .. b1 .. "'." end
return "'" .. v .. "'"
elseif role == "ad" then
return KIND[v] or v
elseif role == "alt" or not BUILTIN_ROLE[role] then
-- 併記欄(ローマ字など)と利用者が足した欄(運賃など)は素の文字列として出す
return find(v, "`", 1, true) and v or ("`" .. v .. "`")
end
return v
end
-- 駅欄のセル + その駅・その欄の体裁(st.style= など)
-- 欄ごとの体裁を重ねる(後が勝つ)。role.style → role.arr/dep.style(着発駅の着行・発行だけ)
-- → 右側の欄なら r.role.style → r.role.arr/dep.style
local function fsattr(s, role, right, part)
local fs = s.fs
if not fs then return nil end
local o = {}
local function add(k) if fs[k] then o[#o + 1] = fs[k] end end
add(role); if part then add(role .. "." .. part) end
if right then add("r." .. role); if part then add("r." .. role .. "." .. part) end end
return #o > 0 and concat(o, " ") or nil
end
local function fcell(s, role, t, right, part)
return addattr(station_cell(role, t), fsattr(s, role, right, part))
end
-- 見出し行の左側(既定の形)
-- @raw の 1 セル: ! は結合に覆われたセル、_ は空欄(列車の時刻と同じ書き方も受け付ける)
local function rawcell(v)
if v == nil or v == "!" or v == "!!" then return "!!;" end
if v == "_" then return "" end
return v
end
local function header_left(k, nH, label, cols, labels, ja)
-- labels: 役割 → 見出し(km は kmlabel)。見出しのある欄は全見出し行を縦に通す 1 セル、
-- 残りの欄のうち左端のひとつながりに行の名前を置く
if type(labels) ~= "table" then labels = { km = labels } end
local L, row = #cols, {}
ja = ja and (" " .. ja) or ""
local sep = {}
for q, r in ipairs(cols) do
if (q == 1 and r == "km" and L >= 2) or (labels[r] ~= nil and labels[r] ~= "" and r ~= "st" and r ~= "ad") then sep[q] = labels[r] or "" end
end
local li, q = nil, 1
while q <= L do
if sep[q] then
row[q] = (k == 1) and ("^" .. nH .. "; " .. (sep[q] ~= "" and ("j'" .. sep[q] .. "'") or "")) or "!!;"
q = q + 1
else
local q2 = q
while q2 + 1 <= L and not sep[q2 + 1] do q2 = q2 + 1 end
if not li then
li = q
if labels._corner then
-- corner 設定: 行の名前の代わりに、全見出し行を通す 1 セル(斜線見出し DG{…} など)
row[q] = (k == 1) and ("^" .. nH .. " <" .. (q2 - q + 1) .. "; " .. labels._corner) or "!!;"
else
row[q] = "h<" .. (q2 - q + 1) .. ja .. "; '" .. (label or "") .. "'"
end
else row[q] = "<" .. (q2 - q + 1) .. ";" end
for x = q + 1, q2 do row[x] = "!!;" end
q = q2 + 1
end
end
return row, li or 1
end
local function footer_left(label, cols, ja)
local L, row = #cols, { "h<" .. #cols .. (ja and (" " .. ja) or "") .. "; '" .. label .. "'" }
for q = 2, L do row[q] = "!!;" end
return row
end
-- 右側の駅欄の見出し: 左と同じ見出し行の名前を鏡写しに置く(キロ欄は外側で縦に通す)
local function header_right(k, nH, label, rc, kmlabel, ja)
local R, row = #rc, {}
ja = ja and (" " .. ja) or ""
local lab = label and ("'" .. label .. "'") or ""
local kmq = (R >= 2 and rc[R] == "km" and R) or (R >= 2 and rc[1] == "km" and 1) or nil
if not kmq then
row[1] = "h<" .. R .. ja .. "; " .. lab
for q = 2, R do row[q] = "!!;" end
return row
end
local kc = (k == 1) and ("^" .. nH .. "; " .. (kmlabel and kmlabel ~= "" and ("j'" .. kmlabel .. "'") or "")) or "!!;"
if kmq == 1 then
row[1] = kc; row[2] = "h<" .. (R - 1) .. ja .. "; " .. lab
for q = 3, R do row[q] = "!!;" end
else
row[1] = "h<" .. (R - 1) .. ja .. "; " .. lab
for q = 2, R - 1 do row[q] = "!!;" end
row[R] = kc
end
return row
end
-- 表示ビュー共通: 方向に応じた駅と列車
local function select_dir(m, dir)
if dir == "up" then return reverse_stations(m.st), m.tr.up end
return m.st, m.tr.down
end
-- JTB 式(縦組み): モデル → 旧形式と同じセル配列
-- 見出し・脚注の値(merge 指定なら同じ値の連続を横に結合)
local function value_cells(d, trains, getter)
local vals = {}
for t = 1, #trains do vals[t] = fmtv(d.fmt, getter(trains[t])[d.key]) end
if not d.merge then return vals end
local out, t = {}, 1
while t <= #vals do
local j = t
while j < #vals and vals[j + 1] == vals[t] and vals[t] ~= "" do j = j + 1 end
out[t] = (j > t) and addattr(vals[t], "cs=" .. (j - t + 1)) or vals[t]
for q = t + 1, j do out[q] = "!!;" end
t = j + 1
end
return out
end
local function skips(n) local r = {}; for q = 1, n do r[q] = "!!;" end; return r end
local function append(dst, src) for _, v in ipairs(src) do dst[#dst + 1] = v end; return dst end
-- JTB 式(縦組み): モデル → セル配列+行・列の素性(スタイル適用に使う)
local JA, RJA
local function build_grid(m, dir)
local meta_head
local cols, L = m.cols, #m.cols
local rcols, R = m.rcols, #m.rcols
local stations, trains = select_dir(m, dir)
local nt = #trains
local W = L + nt + R
local res = {}
for t = 1, nt do res[t] = resolve(trains[t], stations) end
-- 見出しの項目名(列車番号・列車名・行先…)は、いちばん長い名前の字間に合わせて両端をそろえる
local function jattr(names)
local nmax = 0
for _, s in ipairs(names) do if s then local n = ulen(s); if n > nmax then nmax = n end end end
if nmax < 2 then return nil end
return "cls=jtt-jn css=--jtt-jn:" .. nmax
end
local ln, rn = {}, {}
for _, list in ipairs({ m.rows, m.foot }) do
for _, d in ipairs(list) do
if not d.raw then ln[#ln + 1] = d.label end
rn[#rn + 1] = d.rlabel or (not d.raw and d.label) or nil
end
end
JA, RJA = jattr(ln), jattr(rn)
-- timesep: 時と分の間に点などを入れる(6.45 のような表記)。3〜4 桁の時刻だけが対象
local tsep = m.set.timesep
if tsep and tsep ~= "" then
-- 区切りは幅を決めた箱に入れて時と分の真ん中に置く(\1…\2 を render が箱にする)
local tok = "\1" .. tsep .. "\2"
local function addsep(x)
if type(x) ~= "string" then return x end
local pre, body = match(x, "^(.*;%s*)(.-)$")
if not pre then pre, body = "", x end
return pre .. gsub(body, "%f[%d](%d?%d)(%d%d)%f[%D]", "%1" .. gsub(tok, "%%", "%%%%") .. "%2")
end
for t = 1, nt do
for _, x in pairs(res[t]) do
if type(x) == "table" then x.v, x.a, x.d = addsep(x.v), addsep(x.a), addsep(x.d) end
end
end
end
local rows, rinfo = {}, {}
local function push(row, info) rows[#rows + 1] = row; rinfo[#rows] = info end
local nhead = 0
local hs = m.set
local custom = (hs.headl and hs.headl ~= "") or (hs.headr and hs.headr ~= "") or (hs.headstyle and hs.headstyle ~= "")
if custom then
-- 見出し帯を左・中・右の 3 区画で組む(headl / head / headr、体裁は headstyle)
meta_head = { l = hs.headl, c = hs.head, r = hs.headr, style = hs.headstyle, ht = hs.headheight }
elseif m.set.head and m.set.head ~= "" then
local hh = m.set.headheight
push({ "h" .. ((hh and hh ~= "") and ("~" .. hh) or "") .. "; " .. m.set.head }, { kind = "head" })
nhead = 1
end
for k, row in ipairs(m.pre) do push(row, { kind = "pre", k = k }) end
local nH = #m.rows
-- 見出し名をつなげる行(label ^)の数を数えておく
local span = {}
for k, d in ipairs(m.rows) do
if not d.join then
local n, q = 1, k + 1
while m.rows[q] and m.rows[q].join do n = n + 1; q = q + 1 end
span[k] = n
end
end
-- 欄の見出し(kmlabel・label.役割)
local labels = { km = m.set.kmlabel or m.set["label.km"], _corner = (m.set.corner ~= "" and m.set.corner) or nil }
for _, r in ipairs(cols) do if r ~= "km" and m.set["label." .. r] then labels[r] = m.set["label." .. r] end end
for k, d in ipairs(m.rows) do
local row
if d.join then
row = skips(L)
else
local li
if d.raw then row = {}; for q = 1, L do row[q] = rawcell(d.raw[q]) end
else row, li = header_left(k, nH, d.label, cols, labels, JA) end
if span[k] > 1 and not d.raw and L > 0 and not labels._corner then
row[li] = addattr(row[li], "^" .. span[k])
end
end
local vals = value_cells(d, trains, function(tr) return tr.attr end)
if d.join and vals[1] then vals[1] = addattr(vals[1], "#") end -- つなげた行の間は既定で罫なし(style=bt=1 で引ける)
append(row, vals)
if R > 0 then
if m.set.rlabel and m.set.rlabel ~= "" then
-- rlabel 指定: 右側見出しを 1 つのセルにまとめる
if k == 1 then
append(row, { "^" .. nH .. " <" .. R .. "; " .. m.set.rlabel }); append(row, skips(R - 1))
else append(row, skips(R)) end
else
if d.join then
append(row, skips(R))
else
local rr = header_right(k, nH, d.rlabel or (not d.raw and d.label) or nil, rcols, m.set.kmlabel, RJA)
if span[k] > 1 then
local li = (R >= 2 and rcols[1] == "km") and 2 or 1
rr[li] = addattr(rr[li], "^" .. span[k])
end
append(row, rr)
end
end
end
push(row, { kind = "hdr", key = d.key, style = d.style })
end
for i, s in ipairs(stations) do
local info = { kind = "body", si = i, st = s }
if s.free then
local row
if s.left then
row = { "<" .. L .. "; " .. s.left }; append(row, skips(L - 1))
append(row, { "<" .. (nt + R) .. " wrap al=l; " .. (s.body or "") }); append(row, skips(nt + R - 1))
else
row = { "<" .. W .. " wrap al=l; " .. (s.body or "") }; append(row, skips(W - 1))
end
if s.line then row[1] = addattr(row[1], "&") end
info.free = true
push(row, info)
elseif s.pair then
local r1, r2 = {}, {}
local pk = s.pk or PAIRKIND["着発"]
local pa, pd = fcell(s, "ad", { v = pk[1] }, false, "arr"), fcell(s, "ad", { v = pk[2] }, false, "dep")
-- 欄の値を「着行,発行」と 2 つ書けば別々のセル、1 つなら 2 行を結合
local function both(role, t, right)
local x, y
if role ~= "st" then x, y = split2(t) end -- 駅名は分けない(常に 2 行を結合)
if x then return fcell(s, role, x, right, "arr"), fcell(s, role, y, right, "dep") end
return addattr(fcell(s, role, t, right), "^2"), "!!;"
end
local rpa, rpd = fcell(s, "ad", { v = pk[1] }, true, "arr"), fcell(s, "ad", { v = pk[2] }, true, "dep") -- 右側の着発欄
for q, role in ipairs(cols) do
if role == "ad" then
r1[q] = s.line and addattr(pa, "&") or pa -- pa・pd に ad.style の属性が付いていても壊さない
r2[q] = addattr(pd, "&")
else
r1[q], r2[q] = both(role, s.f[role])
end
end
for t = 1, nt do r1[L + t] = res[t][i].a; r2[L + t] = res[t][i].d end
for q, role in ipairs(rcols) do
if role == "ad" then
-- 右側の着発欄も左と同じく 着/発 の 2 段
r1[L + nt + q] = rpa; r2[L + nt + q] = rpd
else
r1[L + nt + q], r2[L + nt + q] = both(role, s.r and s.r[role] or s.f[role], true)
end
end
push(r1, { kind = "body", si = i, st = s, part = "arr" })
push(r2, { kind = "body", si = i, st = s, part = "dep" })
else
local r = {}
for q, role in ipairs(cols) do r[q] = fcell(s, role, s.f[role]) end
if s.line and L > 0 then r[L] = addattr(r[L], "&") end
for t = 1, nt do r[L + t] = res[t][i].v end
for q, role in ipairs(rcols) do r[L + nt + q] = fcell(s, role, s.r and s.r[role] or s.f[role], true) end
push(r, info)
end
end
for k, d in ipairs(m.foot) do
local row = d.raw and {} or footer_left(d.label, cols, JA)
if d.raw then for q = 1, L do row[q] = rawcell(d.raw[q]) end end
append(row, value_cells(d, trains, function(tr) return tr.foot end))
if R > 0 then
local fl = d.rlabel or (not d.raw and d.label)
append(row, { "h<" .. R .. (RJA and (" " .. RJA) or "") .. "; " .. (fl and ("'" .. fl .. "'") or "") })
append(row, skips(R - 1))
end
push(row, { kind = "foot", key = d.key, style = d.style })
end
for k, row in ipairs(m.post) do push(row, { kind = "post", k = k }) end
local cinfo = {}
for q, role in ipairs(cols) do cinfo[q] = { kind = "left", role = role } end
for t = 1, nt do
cinfo[L + t] = { kind = "train", ti = t, tr = trains[t] }
end
for q, role in ipairs(rcols) do cinfo[L + nt + q] = { kind = "right", role = role } end
return rows, nhead + #m.pre + nH, L, #m.foot + #m.post, { rows = rinfo, cols = cinfo, R = rcols, T = nt, roles = cols, head = meta_head }
end
-- セル文字列から時刻(分)を取り出す。時刻でなければ nil
local function cell_time(v)
if not v or find(v, "/", 1, true) then return nil end
v = gsub(v, "^[^;]*;%s*", "")
local n = match(v, "^%D*(%d%d?%d%d)%D*$")
if not n then return nil end
n = tonumber(n)
return floor(n / 100) * 60 + n % 100
end
local function first_char(s)
if s == "" then return "" end
local b = byte(s)
return sub(s, 1, (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or (b >= 0xC0 and 2) or 1)
end
-- DiaPro の私用領域グリフ(種別マーク等)か
local function isdia(ch)
local b = byte(ch or "")
return b == 0xEE or b == 0xEF -- U+E000–U+FFFF の 3 バイト形の先頭
end
local function plain(html) return trim(gsub(gsub(html, "<[^>]*>", ""), " ", " ")) end
-- 駅時刻表の種別マーク: DiaPro のマークはそのまま、文字なら頭文字を角囲み
local function typemark(typ)
local fc = first_char(typ)
if isdia(fc) then return '<span class="jtt-dmk">' .. fc .. "</span>" end
return parse_cell("S{" .. fc .. "}").html
end
local function nospace(s) return (gsub(gsub(s, " ", ""), "%s", "")) end
local function station_name(s)
local t = s.f.st
if not t then return "" end
return nospace(plain(parse_cell(station_cell("st", t)).html))
end
local function wrap(frame, title, inner)
local out = {}
if STYLES_PAGE then
out[#out + 1] = frame:extensionTag("templatestyles", "", { src = STYLES_PAGE })
end
out[#out + 1] = '<div class="jtt-wrap"><div class="jtt-title">' .. render_title(title or "")
.. '</div><div class="jtt-frame">' .. inner .. "</div></div>"
return concat(out)
end
-- 駅時刻表(時 × 分)
local function view_board(frame, m, dir, args)
local stations, trains = select_dir(m, dir)
local want = nospace(args.station or m.set.station or "")
local si
for i, s in ipairs(stations) do
if station_name(s) == want then si = i; break end
end
if not si then
return '<span class="jtt-err">station "' .. want .. '" not found</span>'
end
local tkey, dkey = args.type or m.set.type or "name", args.dest or m.set.dest
local ent, tcount, dcount = {}, {}, {}
for _, tr in ipairs(trains) do
local r = resolve(tr, stations)
local cell = stations[si].pair and r[si].d or r[si].v
local ad = stations[si].f.ad
local isarr = ad and not ad.q and SEM[ad.v] == "ARR"
local tm = (not isarr) and cell_time(cell)
if tm then
-- 行先: 指定キーがあればそれ、無ければ時刻のある最後の駅
local dest
if dkey then
local raw = tr.foot[dkey] or tr.attr[dkey] or ""
dest = nospace(plain(parse_cell((match(raw, "^([^/]*)") or raw)).html))
end
if not dest or dest == "" then
for k = #stations, si + 1, -1 do
local x = r[k]
if cell_time(x.v or x.a) then dest = station_name(stations[k]); break end
end
end
if dest and dest ~= "" then
local typ = plain(parse_cell(fmtv(nil, tr.attr[tkey] or "")).html)
ent[#ent + 1] = { t = tm, typ = typ, dest = dest }
tcount[typ] = (tcount[typ] or 0) + 1
dcount[dest] = (dcount[dest] or 0) + 1
end
end
end
local function most(c) local b, n = nil, -1; for k, v in pairs(c) do if v > n then b, n = k, v end end; return b end
local mt, md = most(tcount), most(dcount)
table.sort(ent, function(a, b) return ((a.t - 180) % 1440) < ((b.t - 180) % 1440) end)
local hours, order = {}, {}
for _, e in ipairs(ent) do
local h = floor(e.t / 60)
if not hours[h] then hours[h] = {}; order[#order + 1] = h end
local mark = (e.typ ~= mt and e.typ ~= "") and typemark(e.typ) or ""
local dsub = (e.dest ~= md) and ('<span class="jtt-bdst">' .. e.dest .. "</span>") or ""
hours[h][#hours[h] + 1] = '<span class="jtt-bmin"><span class="jtt-bmk">' .. mark .. "</span>"
.. format("%02d", e.t % 60) .. dsub .. "</span>"
end
local o = { '<div class="jtt-head"><span class="jtt-b">' .. station_name(stations[si])
.. "</span> " .. (md and (md .. "方面") or "") .. '</div><div class="jtt-scroll"><table class="jtt jtt-board">' }
for _, h in ipairs(order) do
o[#o + 1] = '<tr><td class="jtt-bh">' .. h .. '</td><td class="jtt-bm">'
.. concat(hours[h]) .. "</td></tr>"
end
o[#o + 1] = "</table></div>"
-- 凡例
local leg = {}
for k in pairs(tcount) do
if k ~= mt and k ~= "" then
local fc = first_char(k)
leg[#leg + 1] = typemark(k) .. (isdia(fc) and "" or k)
end
end
table.sort(leg)
if #leg > 0 or md then
o[#o + 1] = '<div class="jtt-legend">' .. (mt and (mt .. "(無印)") or "") .. " " .. concat(leg, " ")
.. (md and (" 行先表示の無いものは " .. md .. " 行") or "") .. "</div>"
end
return wrap(frame, args.title or m.set.title, concat(o))
end
-- 横組み一覧(列車が行・駅が列)
local function view_list(frame, m, dir, args)
local stations, trains = select_dir(m, dir)
local o = { '<div class="jtt-scroll"><table class="jtt jtt-list"><tr class="jtt-hr">' }
for _, d in ipairs(m.rows) do
o[#o + 1] = '<td class="jtt-th jtt-lh">' .. parse_cell("'" .. (d.label or d.key) .. "'").html .. "</td>"
end
for i, s in ipairs(stations) do
if not s.free then
o[#o + 1] = '<td class="jtt-th jtt-ls' .. (i == 1 and " jtt-t1" or "") .. '"><span class="jtt-v">'
.. station_name(s) .. "</span></td>"
end
end
o[#o + 1] = "</tr>"
for _, tr in ipairs(trains) do
local r = resolve(tr, stations)
o[#o + 1] = "<tr>"
for _, d in ipairs(m.rows) do
o[#o + 1] = '<td class="jtt-lh">' .. parse_cell(fmtv(d.fmt, tr.attr[d.key])).html .. "</td>"
end
for i, s in ipairs(stations) do if not s.free then
local v = s.pair and ((r[i].d ~= "" and r[i].d) or r[i].a) or r[i].v
if find(v, "!!", 1, true) then v = "" end
local c = parse_cell(v)
o[#o + 1] = '<td class="jtt-t' .. (c.c and " jtt-c" or "") .. '">' .. (c.html_t or c.html or "") .. "</td>"
end end
o[#o + 1] = "</tr>"
end
o[#o + 1] = "</table></div>"
return wrap(frame, args.title or m.set.title, concat(o))
end
--------------------------------------------------------------------------
-- スタイル規則([styles] 節・駅/列車/行定義の style=)
-- row:選択子 col:選択子 cell:行/列 all (選択子は , で複数)
-- 行: 駅名 #N #N-M(駅の番号) .クラス arr dep free hdr body foot pre post @N(表の行番号)
-- 列: 列車番号 #N #N-M(列車の番号) .クラス left right trains km mk st ad @N(表の列番号)
--------------------------------------------------------------------------
local function in_range(sel, n)
if not n then return false end
local a, b = match(sel, "^(%d+)%-(%d+)$")
if a then return n >= tonumber(a) and n <= tonumber(b) end
return tonumber(sel) == n
end
local function has_cls(list, c)
if not list then return false end
for x in gmatch(list, "[^,%s]+") do if x == c then return true end end
return false
end
local function row_match(sel, info, r)
if sel == "all" or sel == "*" then return info.kind ~= "head" end
local p1 = sub(sel, 1, 1)
if p1 == "@" then return in_range(sub(sel, 2), r) end
if p1 == "#" then return info.kind == "body" and in_range(sub(sel, 2), info.si) end
if p1 == "." then return info.st and has_cls(info.st.cls, sub(sel, 2)) end
if sel == "arr" or sel == "dep" then return info.part == sel end
if sel == "free" then return info.free == true end
if sel == "hdr" or sel == "body" or sel == "foot" or sel == "pre" or sel == "post" then return info.kind == sel end
if info.kind == "body" and info.st and not info.free then
if station_name(info.st) == nospace(sel) then return true end
end
return (info.kind == "hdr" or info.kind == "foot") and info.key == sel
end
local function col_match(sel, info, j)
if sel == "all" or sel == "*" then return true end
if not info then return false end
local p1 = sub(sel, 1, 1)
if p1 == "@" then return in_range(sub(sel, 2), j) end
if p1 == "#" then return info.kind == "train" and in_range(sub(sel, 2), info.ti) end
if p1 == "." then return info.kind == "train" and has_cls(info.tr.attr.cls, sub(sel, 2)) end
if sel == "left" or sel == "right" then return info.kind == sel end
if sel == "trains" then return info.kind == "train" end
if sel == "km" or sel == "mk" or sel == "st" or sel == "ad" or sel == "alt" then return info.role == sel end
if info.role and info.role == sel and (info.kind == "left" or info.kind == "right") then return true end
return info.kind == "train" and info.tr.attr.no == sel
end
local function apply_styles(m, rows, meta)
local rules = {}
for _, tk in ipairs(m.styles) do
local rs, cs, at = {}, {}, {}
for _, t in ipairs(tk) do
local kind, val = match(t.v, "^(%a+):(.+)$")
if not t.q and kind == "row" then for x in gmatch(val, "[^,]+") do rs[#rs + 1] = x end
elseif not t.q and kind == "col" then for x in gmatch(val, "[^,]+") do cs[#cs + 1] = x end
elseif not t.q and kind == "cell" then
local a, b = match(val, "^(.-)/(.+)$")
rs[#rs + 1] = a; cs[#cs + 1] = b
elseif not t.q and t.v == "all" then rs[#rs + 1] = "all"
else at[#at + 1] = t.v end
end
rules[#rules + 1] = { rows = rs, cols = cs, attrs = concat(at, " ") }
end
-- 行・列・行定義に付けた style=
for r, info in ipairs(meta.rows) do
local st = (info.st and info.st.style) or info.style
if st then rules[#rules + 1] = { rows = { "@" .. r }, cols = info.style and { "trains" } or {}, attrs = st } end
end
for j, info in ipairs(meta.cols) do
if info.kind == "train" and info.tr.attr.tstyle then -- 時刻欄だけ(列車番号などの見出しは除く)
rules[#rules + 1] = { rows = { "body" }, cols = { "@" .. j }, attrs = info.tr.attr.tstyle }
end
if info.kind == "train" and info.tr.attr.style then
rules[#rules + 1] = { rows = {}, cols = { "@" .. j }, attrs = info.tr.attr.style }
end
end
if #rules == 0 then return end
for r, row in ipairs(rows) do
local info = meta.rows[r] or { kind = "raw" }
if info.kind ~= "head" then
for _, rule in ipairs(rules) do
local rok = #rule.rows == 0
for _, sel in ipairs(rule.rows) do if row_match(sel, info, r) then rok = true; break end end
if rok then
for j, v in ipairs(row) do
local cok = #rule.cols == 0
for _, sel in ipairs(rule.cols) do if col_match(sel, meta.cols[j], j) then cok = true; break end end
if cok and not find(v, "!!", 1, true) then row[j] = addattr(v, rule.attrs) end
end
end
end
end
end
end
local function notes_html(m)
if #m.notes == 0 then return nil end
local o = { '<div class="jtt-notes">' }
for _, l in ipairs(m.notes) do o[#o + 1] = "<div>" .. parse_cell(l).html .. "</div>" end
o[#o + 1] = "</div>"
return concat(o)
end
p.views = {
jtb = function(frame, m, dir, args)
local rows, H, L, F, meta
if #m.grid > 0 and #m.st == 0 then
-- [grid] だけの自由表
rows, H, L, F = m.grid, tonumber(m.set.header) or 0, tonumber(m.set.left) or 0, tonumber(m.set.footer) or 0
meta = { rows = {}, cols = {} }
for r = 1, #rows do meta.rows[r] = { kind = "raw" } end
for j = 1, 512 do meta.cols[j] = { kind = (j <= L) and "left" or "raw" } end
else
rows, H, L, F, meta = build_grid(m, dir)
-- colsep: 駅欄の列と列の間の縦罫。値は罫線の種類(1 2 3 d . -)
-- colsep 1 … 左右とも、すべての列の間
-- colsep st=1 r.alt=d … 列ごと(その列の左側の線。右側の駅欄は r. を付ける)
local sep = m.set.colsep
if sep and sep ~= "" then
local all = (not find(sep, "=", 1, true)) and match(sep, "^%s*(%S+)") or nil
local per = {}
for k, v in gmatch(sep, "([%w%.]+)=(%S+)") do per[k] = v end
local rfirst = L + (meta.T or 0) + 1
local function want(j)
local ci = meta.cols[j]
if not ci or not ci.role then return nil end
if ci.kind == "left" then return per[ci.role] or (j > 1 and all) or nil end
if ci.kind == "right" then return per["r." .. ci.role] or (j > rfirst and all) or nil end
end
for r = 1, #rows do
local ri = meta.rows[r]
if ri and (ri.kind == "hdr" or ri.kind == "body" or ri.kind == "foot") then
local row = rows[r]
for j = 1, #row do
local v = want(j)
if v and v ~= "0" and row[j] and not find(row[j], "!!", 1, true) then
row[j] = addattr(row[j], "bl=" .. v)
end
end
end
end
end
end
-- 列車の左の縦罫([trains] の --- 行)
if meta.T then
for t = 1, meta.T do
local ci = meta.cols[L + t]
local lv = ci and ci.tr and ci.tr.line
if lv and lv ~= "0" then
for r = 1, #rows do
local ri = meta.rows[r]
local cell = rows[r][L + t]
if ri and (ri.kind == "hdr" or ri.kind == "body" or ri.kind == "foot") and cell and not find(cell, "!!", 1, true) then
rows[r][L + t] = addattr(cell, "bl=" .. lv)
end
end
end
end
end
apply_styles(m, rows, meta)
return render_grid(frame, rows, H, L, F, args.title or m.set.title,
{ R = meta.R, T = meta.T, notes = notes_html(m), roles = meta.roles, head = meta.head,
rinfo = EDMAP and meta.rows or nil, cinfo = EDMAP and meta.cols or nil, set = m.set,
tside = (m.set.titleside == "r" or m.set.titleside == "right") or nil,
top = (#m.top > 0 and { rows = m.top, style = m.set.topstyle })
or ((m.set.top or m.set.topl or m.set.topr) and { style = m.set.topstyle, rows = { {
m.set.topl and ("al=l; " .. m.set.topl) or "", m.set.top and ("al=c; " .. m.set.top) or "",
m.set.topr and ("al=r; " .. m.set.topr) or "" } } })
or nil })
end,
board = view_board,
list = view_list,
}
-- 新形式: {{#invoke:Timetable|main|data=…|view=jtb/board/list|dir=down/up|station=…}}
function p.main(frame)
local args = frame.args
local m = parse_model(args.data or args[1] or "")
local dir = args.dir or m.set.dir or "down"
-- 方向別の設定(title.up / head.up など)で上書き
for k, v in pairs(m.set) do
local base, d = match(k, "^(.-)%.(%a+)$")
if base and d == dir then m.set[base] = v end
end
local view = args.view or m.set.view or "jtb"
local f = p.views[view]
if not f then return '<span class="jtt-err">unknown view: ' .. view .. "</span>" end
return f(frame, m, dir, args)
end
--------------------------------------------------------------------------
-- 8. 旧 CSV → 新形式 変換
-- {{#invoke:Timetable|convert|header=|left=|footer=|title=|<CSV>}}
-- → 新形式のテキストを <pre> で出す(コピーして貼り替える)
--------------------------------------------------------------------------
local function split_attr(cell)
local a, rest = match(cell, "^([^;]*);%s*(.*)$")
if a then return trim(a), rest end
return "", cell
end
-- 新形式のトークンとして安全な形にする(必要なら "…")
local function enc(v)
if v == "" then return "_" end
if find(v, '[%s"=]') or v == "_" or v == "!" or v == "%" or v == ":" or v == "---"
or match(v, "%*%d+$") or match(v, "^[#%[@]") or match(v, "^%d%d$") or find(v, ",", 1, true) then
return '"' .. gsub(v, '"', '""') .. '"'
end
return v
end
local function dispw(s) -- 等幅で揃えるための表示幅(全角 2)
local w, i = 0, 1
while i <= #s do
local b = byte(s, i)
if b < 0x80 then w = w + 1; i = i + 1
elseif b < 0xE0 then w = w + 1; i = i + 2
elseif b < 0xF0 then w = w + 2; i = i + 3
else w = w + 2; i = i + 4 end
end
return w
end
local function pad(s, w) return s .. string.rep(" ", math.max(0, w - dispw(s))) end
function p.legacy_to_text(rows, H, L, F, title)
local N = #rows
local cols = {}
for k = 1, min(L, 4) do cols[k] = DEFAULT_COLS[4 - min(L, 4) + k] end
local maxc = 0
for r = 1, N do if #rows[r] > maxc then maxc = #rows[r] end end
local nt = maxc - L
local function cell(r, j) return trim((rows[r] and rows[r][j]) or "") end
local function isskip(v) return find(v, "!!", 1, true) ~= nil end
local adq; for q, role in ipairs(cols) do if role == "ad" then adq = q end end
local o = { "{{#invoke:Timetable|main|data=",
"# 旧 CSV から変換(header=" .. H .. " left=" .. L .. " footer=" .. F .. ")" }
o[#o + 1] = "title " .. '"' .. gsub(title or "", '"', '""') .. '"'
if L ~= 4 then o[#o + 1] = "cols " .. concat(cols, " ") end
-- 1 行目の路線見出し帯
local r0 = 1
do
local cnt, only = 0, nil
for j = 1, #(rows[1] or {}) do
local v = cell(1, j)
if not isskip(v) then cnt = cnt + 1; only = v end
end
if cnt == 1 then
local a, content = split_attr(only)
if find(a, "h", 1, true) then
o[#o + 1] = "head " .. '"' .. gsub(content, '"', '""') .. '"'
local ht = match(a, "~(%d+)")
if ht then o[#o + 1] = "headheight " .. ht end
r0 = 2
end
end
end
-- 見出し行
local used = {}
local function keyfor(label, fallback)
local l = gsub(nospace(label or ""), "'", "")
local k = ({ ["列車番号"] = "no", ["열차번호"] = "no", ["列車名"] = "name", ["열차명"] = "name",
["注意"] = "note", ["주의"] = "note", ["行先"] = "to", ["행선"] = "to", ["행선지"] = "to",
["次頁"] = "next", ["다음"] = "next", ["備考"] = "remark", ["비고"] = "remark" })[l] or fallback
local base, n = k, 1
while used[k] do n = n + 1; k = base .. n end
used[k] = true
return k
end
local function infer_fmt(vals)
local allN, allQ, any = true, true, false
for _, v in ipairs(vals) do
if v ~= "" then
any = true
if not match(v, "^N{[^{}]*}$") then allN = false end
if not match(v, "^'[^']*'$") then allQ = false end
end
end
if any and allN then return "N{*}", function(v) return match(v, "^N{(.*)}$") or v end end
if any and allQ then return "'*'", function(v) return match(v, "^'(.*)'$") or v end end
return nil, function(v) return v end
end
local hdefs, kmlabel = {}, nil
local nH = H - r0 + 1
for r = r0, H do
local k = r - r0 + 1
local left = {}
for q = 1, L do left[q] = cell(r, q) end
local label
for q = 1, L do
local a, c = split_attr(left[q])
if a == "h" or a == ("h<" .. L) then label = match(c, "^'(.*)'$") end
end
local def = { raw = left }
if label then
local kl = (k == 1 and cols[1] == "km") and match(left[1], "^%^" .. nH .. ";%s*r(.-)%.$") or nil
local exp = header_left(k, nH, label, cols, kl or kmlabel)
local same = true
for q = 1, L do
local e, l = gsub(exp[q], "%s", ""), gsub(left[q], "%s", "")
-- 「h」だけの見出し(右端まで自動で結合)と、幅を明示した「h<N」は同じもの
if e ~= l and gsub(e, "^h<" .. (L - q + 1), "h") ~= gsub(l, "^h<" .. (L - q + 1), "h") then same = false end
end
if same then def = { label = label }; if kl then kmlabel = kl end end
end
def.key = keyfor(label, "h" .. k)
local vals = {}
for t = 1, nt do vals[t] = cell(r, L + t) end
def.fmt, def.strip = infer_fmt(vals)
def.vals = vals
hdefs[#hdefs + 1] = def
end
local fdefs = {}
for r = N - F + 1, N do
local left = {}
for q = 1, L do left[q] = cell(r, q) end
local a1, c1 = split_attr(left[1])
local label = (a1 == "h<" .. L) and match(c1, "^'(.*)'$") or nil
local def = { raw = left }
if label then
local exp, same = footer_left(label, cols), true
for q = 1, L do
local e, l = gsub(exp[q], "%s", ""), gsub(left[q], "%s", "")
-- 「h」だけの見出し(右端まで自動で結合)と、幅を明示した「h<N」は同じもの
if e ~= l and gsub(e, "^h<" .. (L - q + 1), "h") ~= gsub(l, "^h<" .. (L - q + 1), "h") then same = false end
end
if same then def = { label = label } end
end
if not label then
local _, c = split_attr(left[1])
for q = 1, L do local _, cc = split_attr(left[q]); if match(cc, "^'(.*)'$") then c = cc end end
label = match(c, "^'(.*)'$")
end
def.key = keyfor(label, "f" .. (r - (N - F)))
local vals = {}
for t = 1, nt do vals[t] = cell(r, L + t) end
def.fmt, def.strip = infer_fmt(vals)
def.vals = vals
fdefs[#fdefs + 1] = def
end
if kmlabel then o[#o + 1] = "kmlabel " .. kmlabel end
local function defline(d)
local s = d.key
if d.label then s = s .. " " .. enc(d.label)
else
local raws = {}
for q = 1, L do raws[q] = '"' .. gsub(d.raw[q], '"', '""') .. '"' end
s = s .. " @raw " .. concat(raws, " ")
end
if d.fmt then s = s .. " fmt=" .. d.fmt end
return s
end
if #hdefs > 0 then
o[#o + 1] = ""; o[#o + 1] = "[rows]"
for _, d in ipairs(hdefs) do o[#o + 1] = defline(d) end
end
if #fdefs > 0 then
o[#o + 1] = ""; o[#o + 1] = "[footer]"
for _, d in ipairs(fdefs) do o[#o + 1] = defline(d) end
end
-- 駅(表示行)
local st, cols_t = {}, {} -- st[k] = {fields…, line, pair}, cols_t[t][k] = {a,d} or {v}
for t = 1, nt do cols_t[t] = {} end
local r = H + 1
local function norm_field(role, v)
local a, c = split_attr(v)
if a ~= "" then return '"' .. gsub(v, '"', '""') .. '"' end
if role == "km" then
if c == "" then return "_" end
if match(c, "^%d+%.%.%d+$") then return (gsub(c, "%.%.", ".")) end
return enc(c)
elseif role == "mk" then
if c == "" or c == "SPA" then return "_" end
return enc(c)
elseif role == "st" then
local x = match(c, "^'([^']*)'$")
local pre = ""
if not x then x = match(c, "^k'([^']*)'%.$"); pre = "*" end
if not x then x = match(c, "^b'([^']*)'%.$"); pre = "**" end
if x and x ~= "" and not find(x, '[%s"=*]') and not match(x, "^[_!%%:#%[@]") then return pre .. x end
return '"' .. gsub(c, '"', '""') .. '"'
elseif role == "ad" then
if c == "" then return "_" end
local k = ({ DEP = "発", ARR = "着", q = "〃" })[c]
return k or enc(c)
end
return enc(c)
end
local needq = function(v) return enc(v) ~= v and v ~= "" end
while r <= N - F do
local pair = false
if adq and r + 1 <= N - F then
local a1, c1 = split_attr(cell(r, adq))
local a2, c2 = split_attr(cell(r + 1, adq))
pair = c1 == "ARR" and (a1 == "" or a1 == "&") and c2 == "DEP" and a2 == "&"
for q, role in ipairs(cols) do
if role ~= "ad" then
local a = split_attr(cell(r, q))
if a ~= "^2" or not isskip(cell(r + 1, q)) then pair = false end
end
end
if pair then
for t = 1, nt do
local x, y = cell(r, L + t), cell(r + 1, L + t)
if x ~= y and (needq(x) or needq(y)) then pair = false end
end
end
end
local s = { f = {} }
if pair then
local a1 = split_attr(cell(r, adq))
s.line = (a1 == "&")
for q, role in ipairs(cols) do
if role == "ad" then s.f[q] = "着発"
else local _, c = split_attr(cell(r, q)); s.f[q] = norm_field(role, c) end
end
for t = 1, nt do cols_t[t][#st + 1] = { a = cell(r, L + t), d = cell(r + 1, L + t) } end
r = r + 2
else
for q, role in ipairs(cols) do
local v = cell(r, q)
if role == "ad" then
local a, c = split_attr(v)
if a == "&" and (c == "DEP" or c == "ARR" or c == "q" or c == "") then
s.line = true; v = c
end
end
s.f[q] = norm_field(role, v)
end
for t = 1, nt do cols_t[t][#st + 1] = { v = cell(r, L + t) } end
r = r + 1
end
st[#st + 1] = s
end
-- 駅欄(桁をそろえて出力)
local wcol = {}
for _, s in ipairs(st) do
for q = 1, L do local w = dispw(s.f[q]); if w > (wcol[q] or 0) then wcol[q] = w end end
end
o[#o + 1] = ""; o[#o + 1] = "[stations]"
do
local nm, h = { km = "キロ", mk = "印", st = "駅名", ad = "着発" }, {}
for q, role in ipairs(cols) do h[q] = nm[role] end
o[#o + 1] = "# " .. concat(h, " / ")
end
for _, s in ipairs(st) do
if s.line then o[#o + 1] = "---" end
local f = {}
for q = 1, L do f[q] = (q < L) and pad(s.f[q], wcol[q]) or s.f[q] end
o[#o + 1] = concat(f, " ")
end
-- 列車
local names = {}
for _, s in ipairs(st) do
local stq
for q, role in ipairs(cols) do if role == "st" then stq = q end end
local n = stq and gsub(gsub(s.f[stq], '^"', ""), "^%*+", "") or "?"
names[#names + 1] = n
end
o[#o + 1] = ""; o[#o + 1] = "[trains]"
local hl = {}
for _, d in ipairs(hdefs) do hl[#hl + 1] = d.label or d.key end
local fl = {}
for _, d in ipairs(fdefs) do fl[#fl + 1] = d.label or d.key end
o[#o + 1] = "# " .. concat(hl, " ") .. " : " .. concat(names, " ")
.. (#fl > 0 and (" : " .. concat(fl, " ")) or "")
local prevA, prevF = {}, {}
for t = 1, nt do
local A = {}
for k, d in ipairs(hdefs) do
local v = d.strip(d.vals[t])
if v ~= "" and v == prevA[k] then A[k] = "%" else A[k] = enc(v) end
prevA[k] = v
end
local Fo = {}
for k, d in ipairs(fdefs) do
local v = d.strip(d.vals[t])
if v ~= "" and v == prevF[k] then Fo[k] = "%" else Fo[k] = enc(v) end
prevF[k] = v
end
-- 時刻(分だけで書ける所は分だけ)
local lastm
local function enct(v)
if v == "" then return "_" end
if isskip(v) then return "!" end
if match(v, "^%d%d%d%d?$") then
local n = tonumber(v)
local h, mm = floor(n / 100), n % 100
local cur = h * 60 + mm
local outv = v
if lastm and floor(lastm / 60) == h and mm >= lastm % 60 and fmt_time(cur) == v then
outv = format("%02d", mm)
end
lastm = cur
return outv
end
return enc(v)
end
local T = {}
for k = 1, #st do
local c = cols_t[t][k]
if c.v then T[#T + 1] = enct(c.v)
elseif c.a == c.d then
local x = enct(c.a); T[#T + 1] = x
else
local x = enct(c.a); local y = enct(c.d)
T[#T + 1] = x .. "," .. y
end
end
while #T > 0 and T[#T] == "_" do T[#T] = nil end
-- 同じ値の連続を T*N に
local C, k = {}, 1
while k <= #T do
local j = k
while T[j + 1] == T[k] do j = j + 1 end
local n = j - k + 1
if n >= 3 then C[#C + 1] = T[k] .. "*" .. n
else for _ = 1, n do C[#C + 1] = T[k] end end
k = j + 1
end
while #Fo > 0 and Fo[#Fo] == "_" do Fo[#Fo] = nil end
local line = concat(A, " ") .. " : " .. concat(C, " ")
if #Fo > 0 then line = line .. " : " .. concat(Fo, " ") end
o[#o + 1] = line
end
o[#o + 1] = "}}"
return concat(o, "\n")
end
function p.convert(frame)
local args = frame.args
local text = p.legacy_to_text(parse_csv(args[1] or ""),
tonumber(args.header) or 4, tonumber(args.left) or 3, tonumber(args.footer) or 1, args.title)
return "<pre>" .. mw.text.nowiki(text) .. "</pre>"
end
p._parse_model, p._build_grid, p._parse_csv = parse_model, build_grid, parse_csv
--------------------------------------------------------------------------
-- 9. 編集器用(ブラウザ版エディタが Fengari 経由で呼ぶ)
--------------------------------------------------------------------------
local function json(v)
local tv = type(v)
if tv == "string" then
return '"' .. gsub(v, '[%c"\\]', function(ch)
local m2 = { ['"'] = '\\"', ["\\"] = "\\\\", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" }
return m2[ch] or format("\\u%04x", byte(ch))
end) .. '"'
elseif tv == "number" or tv == "boolean" then
return tostring(v)
elseif tv == "table" then
local n = #v
local isarr = n > 0 or next(v) == nil
if isarr then
for k in pairs(v) do if type(k) ~= "number" then isarr = false; break end end
end
local o = {}
if isarr then
for k = 1, n do o[k] = json(v[k]) end
return "[" .. concat(o, ",") .. "]"
end
for k, x in pairs(v) do
if type(x) ~= "function" then o[#o + 1] = json(tostring(k)) .. ":" .. json(x) end
end
return "{" .. concat(o, ",") .. "}"
end
return "null"
end
-- 新形式テキスト → 編集用モデル(JSON)。時刻は駅(下り順)ごとのセル文字列に解決済み
function p.model_json(text)
local m = parse_model(text or "")
local set = {}
for _, k in ipairs(m.setorder) do set[#set + 1] = { k, m.set[k] } end
local function defs(list)
local o = {}
for _, d in ipairs(list) do
o[#o + 1] = { key = d.key, label = d.label, rlabel = d.rlabel, raw = d.raw, fmt = d.fmt, merge = d.merge or false, style = d.style, join = d.join or false }
end
return o
end
local st = {}
for i, s in ipairs(m.st) do
local f = {}
for k, t in pairs(s.f) do f[k] = { v = t.v, q = t.q or false } end
local rr
if s.r then rr = {}; for k, t in pairs(s.r) do rr[k] = t.v end end
st[i] = { f = f, r = rr, line = s.line or false, pair = s.pair or false, free = s.free or false,
left = s.left, body = s.body, cls = s.cls, style = s.style, fs = s.fs }
end
local function trains(list, dir)
local stations = (dir == "up") and reverse_stations(m.st) or m.st
local N, o = #m.st, {}
for _, tr in ipairs(list) do
local r = resolve(tr, stations)
local cells = {}
for k = 1, N do
local di = (dir == "up") and (N - k + 1) or k -- 下り順の駅番号
local x = r[k]
if x.a then cells[di] = { a = x.a, d = x.d } elseif x.v then cells[di] = { v = x.v } else cells[di] = {} end
end
o[#o + 1] = { attr = tr.attr, foot = tr.foot, cells = cells, line = tr.line }
end
return o
end
return json({ set = set, cols = m.cols, rcols = m.rcols, rows = defs(m.rows), foot = defs(m.foot),
st = st, down = trains(m.tr.down, "down"), up = trains(m.tr.up, "up"), raw = m.raw })
end
-- 見出し行・脚注行の左側セル(エディタで「左のセルを直接組む」ときの初期値。表示と同じものを返す)
function p.left_cells_json(text)
local m = parse_model(text or "")
local rows, _, L, _, meta = build_grid(m, "down")
local o = { rows = {}, foot = {} }
for r, row in ipairs(rows) do
local ri = meta.rows[r]
if ri and (ri.kind == "hdr" or ri.kind == "foot") then
local cells = {}
for q = 1, L do cells[q] = row[q] or "!!;" end
local list = (ri.kind == "hdr") and o.rows or o.foot
list[#list + 1] = { key = ri.key, cells = cells }
end
end
return json(o)
end
-- 旧 CSV → 新形式テキスト(エディタの取り込み用)
function p.legacy_text(csv, H, L, F, title)
return p.legacy_to_text(parse_csv(csv or ""), tonumber(H) or 4, tonumber(L) or 3, tonumber(F) or 1, title)
end
-- セル 1 個の表示用 HTML(エディタの表に使う)
function p.cell_json(raw)
local c = parse_cell(raw or "")
return json({ h = c.skip and "" or (c.html_t or c.html or ""), c = c.c or false, skip = c.skip or false,
th = c.th or false, rs = c.rs, cs = c.cs, style = c.style, cls = c.cls })
end
-- 複数セルを一度に("\1" 区切り)。エディタの表の表示用
function p.cells_json(blob)
local o = {}
for raw in gmatch((blob or "") .. "\1", "([^\1]*)\1") do
local c = parse_cell(raw)
o[#o + 1] = json({ h = c.skip and "" or (c.html or ""), c = c.c or false, skip = c.skip or false,
rs = c.rs, cs = c.cs, style = c.style, cls = c.cls })
end
return "[" .. concat(o, ",") .. "]"
end
-- 新形式テキストを描画(エディタのプレビュー)
function p.render_text(text, view, dir, station)
EDMAP = true
local ok, res = pcall(p.main, { args = { data = text, view = (view ~= "" and view) or nil, dir = (dir ~= "" and dir) or nil,
station = (station ~= "" and station) or nil } })
EDMAP = false
if not ok then error(res, 0) end
return res
end
-- テスト用に内部関数を公開
p._render = function(s) local c, lines = {}, {}; local h, w, out = render(s, c, lines); return h, c, w, squeeze(out, lines) end
return p