Jump to content

Module:Build bracket

Permanently protected module
From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Pbrks (talk | contribs) at 19:41, 9 August 2025 (major changes to modularization, colors, etc). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

local p = {}
local entries = {}
local pathCell = {}
local crossCell = {}
local skipPath = {}
local shift = {}
local hascross = {}
local teams_per_match = {}
local rlegs = {}
local maxlegs = {}
local autolegs
local byes = {}
local hide = {}
local matchgroup = {}
local nowrap
local autocol
local seeds
local forceseeds
local boldwinner
local aggregate
local paramstyle
local masterindex

--========================
-- Color configuration
--========================
local COLORS = {
    -- Cell backgrounds
    cell_bg_light    = 'var(--background-color-neutral-subtle,#f8f9fa)',
    cell_bg_dark     = 'var(--background-color-neutral,#eaecf0)',
    text_color       = 'var(--color-base,#202122)',
    path_line_color  = 'var(--border-color-base,#a2a9b1)',
    cell_border      = 'var(--border-color-base,#a2a9b1)',
}

local function isempty(s)
	return s==nil or s==''
end

local function notempty(s)
	return s~=nil and s~=''
end

local function bargs(s)
	return pargs[s] or fargs[s]
end

local function toChar(num)
	return string.char(string.byte("a")+num-1)
end

local function unboldParenthetical(text)
    if isempty(text) then return text end

    local STYLE_NORMAL = '<span style="font-weight:normal">%s</span>'
    local PLACEHOLDER_PREFIX = '__WIKILINK__'

    -- Step 1: Extract and temporarily replace wikilinks
    local counter = 0
    local placeholders = {}
    text = text:gsub('%[%[(.-)%]%]', function(link)
        counter = counter + 1
        local key = PLACEHOLDER_PREFIX .. counter .. '__'
        placeholders[key] = link
        return key
    end)

    -- Step 2: Wrap parenthetical (...) and bracketed [...] text in normal-weight span
    text = text:gsub("(%b())", function(match)
        return STYLE_NORMAL:format(match)
    end)
    text = text:gsub("(%b[])", function(match)
        return STYLE_NORMAL:format(match)
    end)

    -- Step 3: Restore wikilinks
    for key, link in pairs(placeholders) do
        text = text:gsub(key, '[[' .. link .. ']]')
    end

    return text
end

local function split(str,delim,tonum)
	result = {};
	local a = "[^"..table.concat(delim).."]+"
		for w in str:gmatch(a) do
			if tonum==true then
				table.insert(result, tonumber(w));
			else
				table.insert(result, w);
			end
		end
	return result;
end

local function getWidth(ctype, default)
	local result = bargs(ctype..'-width')
	if isempty(result) then return default end
	if tonumber(result)~=nil then return result..'px' end
	return result
end

local function matchGroups()
	for j=minc,c do
		matchgroup[j]={}
		for i=1,r do
			if entries[j][i]~= nil and entries[j][i]['ctype']=='team' then
				matchgroup[j][i]=math.ceil(entries[j][i]['index']/teams_per_match[j])
				entries[j][i]['group'] = math.ceil(entries[j][i]['index']/teams_per_match[j])
			end
		end
	end
end

local function teamLegs(j,i)
	local legs = rlegs[j]
	if notempty(entries[j][i]['legs']) then
		legs = tonumber(entries[j][i]['legs'])
	end
	if autolegs then
		local l=1
		repeat l=l+1
		until isempty(entries[j][i]['score'][l])
		legs = l-1
	end
	return legs
end

local function boldWinner()

    -- Helper: compare one team's score to its opponent for a given leg
    local function boldScore(j, i, l)
        local e = entries[j][i]
        if not e or e.ctype ~= 'team' then return 'normal' end

        -- Extract numeric score
        local rawscore = e.score[l] or ''
        local myscore = tonumber(rawscore:match('^%d+'))
        if not myscore then return 'normal' end

        -- Find all opponent scores for the same match group
        local compscores = {}
        for oppIndex, groupId in pairs(matchgroup[j]) do
            if groupId == matchgroup[j][i] and oppIndex ~= i then
                local theirraw = entries[j][oppIndex].score[l] or ''
                local theirscore = tonumber(theirraw:match('^%d+'))
                if not theirscore then return 'normal' end
                table.insert(compscores, theirscore)
            end
        end

        -- If any opponent score >= mine, not bold
        for _, v in ipairs(compscores) do
            if myscore <= v then return 'normal' end
        end

        -- Track wins
        if l ~= 'agg' then
            e.wins = e.wins + 1
        else
            e.aggwins = 1
        end

        return 'bold'
    end

    -- Helper: decide if an entire team entry should be bolded
    local function boldTeam(j, i, useAggregate)
        local e = entries[j][i]
        local winsKey = useAggregate and 'aggwins' or 'wins'
        local legs = teamLegs(j, i)

        if not useAggregate then
            -- Bold if majority of legs won
            if e[winsKey] > legs / 2 then return 'bold' end

            -- Additional checks depending on autolegs
            local checkFn = autolegs and notempty or function(val) return not isempty(val) end
            for l = 1, legs do
                local scoreVal = e.score[l]
                if not checkFn(scoreVal) or string.find(scoreVal or '', "nbsp") then
                    return 'normal'
                end
            end
        end

        -- Compare wins to opponent
        for oppIndex, groupId in pairs(matchgroup[j]) do
            if groupId == matchgroup[j][i] and oppIndex ~= i then
                if e[winsKey] <= tonumber(entries[j][oppIndex][winsKey]) then
                    return 'normal'
                end
            end
        end

        return 'bold'
    end

    -- Reset win counts
    for j = minc, c do
        for i = 1, r do
            if entries[j][i] and entries[j][i].ctype == 'team' then
                entries[j][i].wins = 0
                entries[j][i].aggwins = 0
            end
        end

        -- Determine bold for scores
        for i = 1, r do
            if entries[j][i] and entries[j][i].ctype == 'team' then
                local legs = teamLegs(j, i)
                for l = 1, legs do
                    entries[j][i].score.weight[l] = boldScore(j, i, l)
                end
                if aggregate and legs > 1 then
                    entries[j][i].score.weight.agg = boldScore(j, i, 'agg')
                end
            end
        end

        -- Determine bold for entire team
        for i = 1, r do
            if entries[j][i] and entries[j][i].ctype == 'team' then
                local useAggregate = aggregate and teamLegs(j, i) > 1
                entries[j][i].weight = boldTeam(j, i, useAggregate)
            end
        end
    end
end

local function isBlankEntry(col,row,ctype)
	if isempty(entries[col][row]) then return true end
	if isempty(entries[col][row]['team']) and isempty(entries[col][row]['text']) then return true end
	return false
end

local function showSeeds(j,i)
	local showseed=false
	if forceseeds or notempty(entries[j][i]['seed']) then
		showseed=true
	else
		for k=1,teams_per_match[j]-1 do
			if notempty(entries[j][i+2*k]) and entries[j][i]['group']==entries[j][i+2*k]['group'] and notempty(entries[j][i+2*k]['seed']) then
				showseed=true
			end
			if notempty(entries[j][i-2*k]) and entries[j][i]['group']==entries[j][i-2*k]['group'] and notempty(entries[j][i-2*k]['seed']) then
				showseed=true
			end
		end
	end
	return showseed
end

local function cellBorder(b)
	return b[1]..'px '..b[2]..'px '..b[3]..'px '..b[4]..'px'
end

local function Cell(tbl,j,i,rowspan,colspan,text,align,border,border_width,bg,padding,weight,nwrap)
	local cell = tbl:tag('td')
	if colspan~=1 then
		cell:attr('colspan',colspan)
	end
	if rowspan~=1 then
		cell:attr('rowspan',rowspan)
	end
	if notempty(border) then
		cell:css('border',border)
	end
	if notempty(border_width) then
		cell:css('border-width',cellBorder(border_width))
	end
	if notempty(bg) then
		cell:css('background-color',bg)
	end
	if notempty(align) then
		cell:css('text-align',align)
	end
	cell:css('padding','0em 0.3em')
	if weight=='bold' then
		cell:css('font-weight',weight)
	end
	if notempty(text) then
		cell:wikitext(text)
	end
	cell:css('color', COLORS.text_color)
	return cell
end

local function teamCell(tbl,k,j,i,l,colspan)
	local bg = COLORS.cell_bg_dark
	local align
	local padding
	local weight
	local text
	local nwrap
	local b={0,0,1,1}
	if k=='seed' or k=='score' then
		align='center'
	end
	if k~='seed' then
		bg=COLORS.cell_bg_light
	end
	if k=='team' then 
		padding='0.3em'
		if teamLegs(j,i)==0 then
			b[2]=1
		end
	end
	if entries[j][i]['position']=='top' then
		b[1]=1
	end
	if l==teamLegs(j,i) or l=='agg' or k=='seed' then
		b[2]=1
	end
	if (l==nil and entries[j][i]['weight']=='bold') or entries[j][i]['score']['weight'][l]=='bold' then
		weight='bold'
	end
	if l==nil then
		text=unboldParenthetical(entries[j][i][k])
	else
		text=tostring(entries[j][i][k][l])
	end
	return Cell(tbl,j,i,2,colspan,text,align,'solid ' .. COLORS.cell_border,b,bg,padding,weight,nwrap)
end

-- Compute standard colspan for a single "entry" cell in column j
local function getEntryColspan(j)
    local colspan = maxlegs[j] + 2
    if not seeds then
        colspan = colspan - 1
    end
    if (aggregate and maxlegs[j] > 1) or maxlegs[j] == 0 then
        colspan = colspan + 1
    end
    return colspan
end

-- Determine whether the "round" after header at (j,i) is empty (used for bye detection)
local function roundIsEmpty(j, i)
    -- start scanning rows after the header cell at i
    local row = i + 1
    repeat
        if not isBlankEntry(j, row) then
            return false
        end
        row = row + 1
    until (entries[j][row] ~= nil and entries[j][row]['ctype'] == 'header') or row > r
    return true
end

-- Default header text when none is provided
local function defaultHeaderText(j, headerindex)
    if headerindex == 1 then
        if j == c then
            return 'Final'
        elseif j == c - 1 then
            return 'Semifinals'
        elseif j == c - 2 then
            return 'Quarterfinals'
        else
            return 'Round ' .. j
        end
    else
        return 'Lower round ' .. j
    end
end

-- Handle cases where the entry is nil (absent) or explicitly a blank placeholder.
-- Returns true if this function produced the appropriate cell (so caller should return)
local function handleEmptyOrNilEntry(tbl, j, i)
    local entry_colspan = getEntryColspan(j)

    -- nil entry: produce a spanning blank cell if appropriate
    if entries[j][i] == nil then
        -- if previous entry exists or this is the first row, create a rowspan covering following blank rows
        if entries[j][i - 1] ~= nil or i == 1 then
            local rowspan = 0
            local row = i
            repeat
                rowspan = rowspan + 1
                row = row + 1
            until entries[j][row] ~= nil or row > r
            -- produce an empty cell with the computed rowspan/colspan
            Cell(tbl, j, i, rowspan, entry_colspan)
            return true
        else
            -- do nothing (cell intentionally omitted)
            return true
        end
    end

    -- explicit 'blank' ctype: do nothing (no visible content)
    if entries[j][i]['ctype'] == 'blank' then
        return true
    end

    return false
end

-- Insert a header cell
local function insertHeader(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)

    -- If entire round is a bye & header exists, skip rendering (return blank cell)
    if byes[j][entry.headerindex] and roundIsEmpty(j, i) then
        return Cell(tbl, j, i, 2, entry_colspan)
    end

    -- If this header is hidden, render an empty spanning cell
    if hide[j][entry.headerindex] then
        return Cell(tbl, j, i, 2, entry_colspan)
    end

    -- fill default header text if not set
    if isempty(entry.header) then
        if entry.headerindex == 1 then
            entry.header = defaultHeaderText(j, entry.headerindex)
        else
            entry.header = defaultHeaderText(j, entry.headerindex)
        end
    end

    return Cell(tbl, j, i, 2, entry_colspan, entry.header, 'center', '1px solid ' .. COLORS.cell_border, nil, entry.shade)
end

-- Insert a team cell (seed, team, and scores)
local function insertTeam(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)

    -- If this team belongs to a 'bye' header and is blank, render empty cell
    if (byes[j][entry.headerindex] and isBlankEntry(j, i)) or hide[j][entry.headerindex] then
        return Cell(tbl, j, i, 2, entry_colspan)
    end

    local legs = teamLegs(j, i)
    local team_colspan = maxlegs[j] - legs + 1
    if aggregate and legs == 1 and maxlegs[j] > 1 then
        team_colspan = team_colspan + 1
    end
    if maxlegs[j] == 0 then
        team_colspan = team_colspan + 1
    end

    -- Seed column (if seeds enabled). Either render the seed cell or fold it into the team colspan.
    if seeds then
        if showSeeds(j, i) == true then
            teamCell(tbl, 'seed', j, i)
        else
            team_colspan = team_colspan + 1
        end
    end

    -- Team name cell (may span multiple score columns)
    teamCell(tbl, 'team', j, i, nil, team_colspan)

    -- Score cells (one per leg)
    for l = 1, legs do
        teamCell(tbl, 'score', j, i, l)
    end

    -- Aggregate score column (if configured)
    if aggregate and legs > 1 then
        teamCell(tbl, 'score', j, i, 'agg')
    end
end

-- Insert a text cell
local function insertText(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    Cell(tbl, j, i, 2, entry_colspan, entry.text, nil, nil, nil, nil, '0.3em')
end

-- Insert a group cell (spans several columns)
local function insertGroup(tbl, j, i, entry)
    local colspan = 0
    for m = j, entries[j][i]['colspan'] + j - 1 do
        colspan = colspan + maxlegs[m] + 2
        if not seeds then colspan = colspan - 1 end
        if (aggregate and maxlegs[m] > 1) or maxlegs[m] == 0 then
            colspan = colspan + 1
        end
    end
    colspan = colspan + 2 * (entries[j][i]['colspan'] - 1)
    return Cell(tbl, j, i, 2, colspan, entry.group, 'center')
end

-- Insert a line cell (visual path markers)
local function insertLine(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    local b = {0, 0, 0, 0}
    -- Thicken based on pathCell values (mirrors original logic)
    b[3] = 2 * pathCell[j - 1][i + 1][3][1][3]
    return Cell(tbl, j, i, 2, entry_colspan, entry.text, nil, 'solid ' .. COLORS.cell_border, b)
end

-- Insert a line2 cell (alternative path styling)
local function insertLine2(tbl, j, i, entry)
    local entry_colspan = getEntryColspan(j)
    local b = {0, 0, 0, 0}
    b[1] = 2 * pathCell[j - 1][i][3][1][1]
    return Cell(tbl, j, i, 2, entry_colspan, entry.text, nil, 'solid ' .. COLORS.cell_border, b)
end

-- The new compact dispatcher for a single entry
local function insertEntry(tbl, j, i)
    -- Early handling for nil / blank entries (will generate empty spanning cells when necessary)
    if handleEmptyOrNilEntry(tbl, j, i) then return end

    -- Now we know an actual entry exists
    local entry = entries[j][i]
    if not entry then return end

    local ctype = entry['ctype']
    if ctype == 'header' then
        return insertHeader(tbl, j, i, entry)
    elseif ctype == 'team' then
        return insertTeam(tbl, j, i, entry)
    elseif ctype == 'text' then
        return insertText(tbl, j, i, entry)
    elseif ctype == 'group' then
        return insertGroup(tbl, j, i, entry)
    elseif ctype == 'line' then
        return insertLine(tbl, j, i, entry)
    elseif ctype == 'line2' then
        return insertLine2(tbl, j, i, entry)
    else
        -- Unknown ctype: fallback to a blank spanning cell
        return Cell(tbl, j, i, 2, getEntryColspan(j))
    end
end

local function isRoundHidden(j,i,headerindex)
	if notempty(entries[j][i]['pheader']) then
		hide[j][entries[j][i]['headerindex']] = false
	end
	local row = i+1
	repeat
		if not isBlankEntry(j,row) then
			hide[j][entries[j][i]['headerindex']] = false
		end
		row = row+1
	until (entries[j][row]~=nil and entries[j][row]['ctype']=='header') or row>r
end

local function paramNames(cname, j, i, l)
    -- Helper: safely get a parameter by key
    local function getParam(key)
        return bargs(key) or ''
    end

    -- Build round name variants
    local roundBase    = 'RD' .. j
    local roundAlt     = bargs(roundBase .. '-altname') or roundBase
    local roundLetter  = 'RD' .. j .. toChar(entries[j][i].headerindex)
    local roundLetterAlt = bargs(roundLetter .. '-altname') or roundLetter

    -- Build common arrays
    local rname  = { {roundBase, roundAlt}, {roundLetter, roundLetterAlt} }
    local name   = { cname, bargs(cname .. '-altname') or cname }
    local index  = { entries[j][i].index, entries[j][i].altindex }

    local result = {}

    if cname == 'header' then
        if entries[j][i].headerindex == 1 then
            for _, base in ipairs({rname[1], rname[2]}) do
                for k = 2, 1, -1 do
                    table.insert(result, getParam(base[k]))
                end
            end
        else
            for k = 2, 1, -1 do
                table.insert(result, getParam(rname[2][k]))
            end
        end

    elseif cname == 'pheader' then
        if entries[j][i].headerindex == 1 then
            for _, base in ipairs({rname[1], rname[2]}) do
                for k = 2, 1, -1 do
                    table.insert(result, pargs[base[k]] or '')
                end
            end
        else
            for k = 2, 1, -1 do
                table.insert(result, pargs[rname[2][k]] or '')
            end
        end

    elseif cname == 'score' then
        for m = 2, 1, -1 do
            for k = 2, 1, -1 do
                if l == 1 then
                    table.insert(result, getParam(rname[m][k] .. '-' .. name[1] .. index[m]) 
                                             or getParam(rname[m][k] .. '-' .. name[1] .. '0' .. index[m]))
                end
                table.insert(result, getParam(rname[m][k] .. '-' .. name[1] .. index[m] .. '-' .. l) 
                                         or getParam(rname[m][k] .. '-' .. name[1] .. '0' .. index[m] .. '-' .. l))
            end
        end

    elseif cname == 'shade' then
        for k = 2, 1, -1 do
            local roundKey = (entries[j][i].headerindex == 1) and rname[1][k] or rname[2][k]
            table.insert(result, getParam(roundKey .. '-' .. name[1]))
        end
        table.insert(result, getParam('RD-shade'))
        table.insert(result, COLORS.cell_bg_dark)

    elseif cname == 'text' then
        for n = 2, 1, -1 do
            for m = 2, 1, -1 do
                for k = 2, 1, -1 do
                    table.insert(result, getParam(rname[m][k] .. '-' .. name[n] .. index[m]) 
                                             or getParam(rname[m][k] .. '-' .. name[n] .. '0' .. index[m]))
                end
            end
        end

    else -- Default case
        for m = 2, 1, -1 do
            for k = 2, 1, -1 do
                table.insert(result, getParam(rname[m][k] .. '-' .. name[1] .. index[m]) 
                                         or getParam(rname[m][k] .. '-' .. name[1] .. '0' .. index[m]))
            end
        end
    end

    -- Return first non-empty result
    for _, val in ipairs(result) do
        if notempty(val) then
            return val
        end
    end

    return ''
end

local function indexedParams(j)
	for i=1,r do
		if entries[j][i]~=nil then
			if entries[j][i]['ctype']=='team' then
				local legs = rlegs[j]
				if forceseeds then
					entries[j][i]['seed'] = bargs(masterindex) or ''
					masterindex = masterindex+1
				end
				entries[j][i]['team'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
				entries[j][i]['legs'] = paramNames('legs',j,i)
				entries[j][i]['score'] = {}
				entries[j][i]['weight'] = 'normal'
				entries[j][i]['score']['weight'] = {}
				if notempty(entries[j][i]['legs']) then
					legs = tonumber(entries[j][i]['legs'])
				end
				for l=1,legs do
					entries[j][i]['score'][l] = bargs(tostring(masterindex)) or ''
					masterindex = masterindex+1
					entries[j][i]['score']['weight'][l] = 'normal'
				end
				if aggregate and legs>1 then
					entries[j][i]['score']['agg'] = bargs(masterindex) or ''
					masterindex = masterindex+1
					entries[j][i]['score']['weight']['agg'] = 'normal'
				end
			end
			if entries[j][i]['ctype']=='header' then
				entries[j][i]['header'] = paramNames('header',j,i)
				entries[j][i]['pheader'] = paramNames('pheader',j,i)
				entries[j][i]['shade'] = paramNames('shade',j,i)
			end
			if entries[j][i]['ctype']=='text' then
				entries[j][i]['text'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
			end
			if entries[j][i]['ctype']=='group' then
				entries[j][i]['group'] = bargs(tostring(masterindex)) or ''
				masterindex = masterindex+1
			end
			if entries[j][i]['ctype'] == 'line' and entries[j][i]['hastext']==true then
				entries[j][i]['text'] = bargs(masterindex) or ''
				masterindex = masterindex+1
			end
		end
	end
end

local function assignTeamParams(j, i)
    local legs = rlegs[j]
    entries[j][i].seed  = paramNames('seed', j, i)
    entries[j][i].team  = paramNames('team', j, i)
    entries[j][i].legs  = paramNames('legs', j, i)
    entries[j][i].score = { weight = {} }
    entries[j][i].weight = 'normal'

    if notempty(entries[j][i].legs) then
        legs = tonumber(entries[j][i].legs)
    end

    if autolegs then
        local l = 1
        repeat
            entries[j][i].score[l] = paramNames('score', j, i, l)
            entries[j][i].score.weight[l] = 'normal'
            l = l + 1
        until isempty(paramNames('score', j, i, l))
        legs = l - 1
    else
        for l = 1, legs do
            entries[j][i].score[l] = paramNames('score', j, i, l)
            entries[j][i].score.weight[l] = 'normal'
        end
    end

    if aggregate and legs > 1 then
        entries[j][i].score.agg = paramNames('score', j, i, 'agg')
        entries[j][i].score.weight.agg = 'normal'
    end
end

local function assignHeaderParams(j, i)
    entries[j][i].header  = paramNames('header', j, i)
    entries[j][i].pheader = paramNames('pheader', j, i)
    entries[j][i].shade   = paramNames('shade', j, i)
end

local function assignTextParams(j, i)
    entries[j][i].text = paramNames('text', j, i)
end

local function assignGroupParams(j, i)
    entries[j][i].group = paramNames('group', j, i)
end

local function assignLineTextParams(j, i)
    entries[j][i].text = paramNames('text', j, i)
end

local function assignParams()
    masterindex = 1
    local maxcol   = 1
    local byerows  = 1
    local hiderows = 1

    local function updateMaxCol(j, i)
        if autocol and not isBlankEntry(j, i) then
            maxcol = math.max(maxcol, j)
        end
    end

    local function updateByerows(j, i)
        if entries[j][i] and not hide[j][entries[j][i].headerindex] then
            if not byes[j][entries[j][i].headerindex] or
               (byes[j][entries[j][i].headerindex] and not isBlankEntry(j, i)) then
                byerows = math.max(byerows, i)
            end
        end
    end

    for j = minc, c do
        -- Set legs for this column
        rlegs[j] = tonumber(bargs('RD'..j..'-legs')) or tonumber(bargs('legs')) or 1
        if notempty(bargs('RD'..j..'-legs')) or bargs('legs') then
            autolegs = false
        end

        if paramstyle == 'numbered' then
            indexedParams(j)
        else
            for i = 1, r do
                if entries[j][i] ~= nil then
                    local ctype = entries[j][i].ctype
                    if ctype == 'team' then
                        assignTeamParams(j, i)
                    elseif ctype == 'header' then
                        assignHeaderParams(j, i)
                    elseif ctype == 'text' then
                        assignTextParams(j, i)
                    elseif ctype == 'group' then
                        assignGroupParams(j, i)
                    elseif ctype == 'line' and entries[j][i].hastext == true then
                        assignLineTextParams(j, i)
                    end
                end
                updateMaxCol(j, i)
            end
        end

        -- Round hiding check
        for i = 1, r do
            if entries[j][i] and entries[j][i].ctype == 'header' then
                isRoundHidden(j, i)
            end
            updateByerows(j, i)
        end
    end

    -- Adjust row count if some rounds are byes or hidden
    for j = minc, c do
        for k = 1, headerindex[j] do
            if byes[j][k] or hide[j][k] then
                r = byerows + 1
            end
        end
    end

    -- Adjust column count automatically
    if autocol then
        c = maxcol
    end
end

local function getHide(j,headerindex)
	hide[j] = {}
	for k=1,headerindex[j] do
		if bargs('RD'..j..toChar(k)..'-hide')=='yes' or bargs('RD'..j..toChar(k)..'-hide')=='y' then
			hide[j][k]=true
		end
	end
end

local function getByes(j,headerindex)
	byes[j] = {}
	for k=1,headerindex[j] do
		if bargs('byes')=='yes' or bargs('byes')=='y' then
			byes[j][k]=true 
		elseif tonumber(bargs('byes')) then
			if j<=tonumber(bargs('byes')) then
				byes[j][k]=true
			end
		else 
			byes[j][k]=false
		end
		if bargs('RD'..j..'-byes')=='yes' or bargs('RD'..j..'-byes')=='y' then
			byes[j][k]=true
		elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
			byes[j][k]=false
		end
		if bargs('RD'..j..toChar(k)..'-byes')=='yes' or bargs('RD'..j..toChar(k)..'-byes')=='y' then
			byes[j][k]=true
		elseif bargs('RD'..j..'-byes')=='no' or bargs('RD'..j..'-byes')=='n' then
			byes[j][k]=false
		end
	end
end

local function getAltIndices()
	local teamindex=1
	local textindex=1
	local groupindex=1
	for j=minc,c do
		headerindex[j]=0
		for i=1,r do
			if entries[j][i]==nil and i==1 then
				headerindex[j]=headerindex[j]+1
			end
			if entries[j][i]~=nil then
				if entries[j][i]['ctype'] == 'header' then
					entries[j][i]['altindex'] = headerindex[j]
					teamindex=1
					textindex=1
					headerindex[j]=headerindex[j]+1
				elseif entries[j][i]['ctype'] == 'team' then
					entries[j][i]['altindex'] = teamindex
					teamindex=teamindex+1
				elseif entries[j][i]['ctype'] == 'text' then
					entries[j][i]['altindex'] = textindex
					textindex=textindex+1
				elseif entries[j][i]['ctype'] == 'group' then
					entries[j][i]['altindex'] = groupindex
					groupindex=groupindex+1
				elseif entries[j][i]['ctype'] == 'line' and entries[j][i]['hastext']==true then
					entries[j][i]['altindex'] = textindex
					textindex=textindex+1
				end
				entries[j][i]['headerindex'] = headerindex[j]
			end
		end
		getByes(j,headerindex)
		getHide(j,headerindex)
	end
end

local function noPaths(j,i)
	local result = true
	local cols = 2
	if hascross[j]==true then
		cols = 3
	end
	for k=1,cols do
		for n=1,4 do
			if pathCell[j][i][k][1][n]~=0 then
				result = false
				return result
			end
		end
	end
	if hascross[j]==true and (crossCell[j][i]['left'][1]==1 or crossCell[j][i]['right'][1]==1) then
		result = false
		return result
	end
	return result
end

-- Draw a single path cell in the bracket table
local function generatePathCell(tbl, j, i, k, bg, rowspan)
	local colData = pathCell[j][i][k]
	local color   = colData.color

	-- Skip center cell if there is no cross path
	if not hascross[j] and k == 2 then
		return
	end

	local cell = tbl:tag('td')

	-- Rowspan if this cell spans multiple rows
	if rowspan ~= 1 then
		cell:attr('rowspan', rowspan)
	end

	-- Background shading for middle column (cross path visuals)
	if notempty(bg) and k == 2 then
		cell:css('background', bg)
		    :css('transform', 'translate(-1px)')
	end

	-- Draw border if any segment has nonzero width
	local borders = colData[1]
	if borders[1] ~= 0 or borders[2] ~= 0 or borders[3] ~= 0 or borders[4] ~= 0 then
		cell:css('border', 'solid ' .. color)
		    :css('border-width',
				(2 * borders[1]) .. 'px ' ..
				(2 * borders[2]) .. 'px ' ..
				(2 * borders[3]) .. 'px ' ..
				(2 * borders[4]) .. 'px'
			)
	end

	return cell
end

-- Insert all path cells for a given position in the bracket
local function insertPath(tbl, j, i)
	if skipPath[j][i] then
		return
	end

	local colspan, rowspan = 2, 1
	local bg = ''
	local cross = { '', '' }

	-- Detect vertical merging (rowspan) for repeated paths
	if i < r then
		local function repeatedPath(a)
			if a > r - 1 or skipPath[j][a] then
				return false
			end
			for k = 1, 3 do
				for n = 1, 4 do
					if pathCell[j][i][k][1][n] ~= pathCell[j][a][k][1][n] then
						return false
					end
				end
			end
			return true
		end

		if repeatedPath(i) then
			local row = i
			repeat
				if row ~= i and repeatedPath(row) then
					skipPath[j][row] = true
				end
				rowspan = rowspan + 1
				row = row + 1
			until row > r or not repeatedPath(row)
			rowspan = rowspan - 1
		end
	end

	-- Skip if the previous row has cross path connections
	if i > 1 and (crossCell[j][i - 1].left[1] == 1 or crossCell[j][i - 1].right[1] == 1) then
		return
	end

	-- Handle cross paths
	if hascross[j] then
		colspan = 3

		if crossCell[j][i].left[1] == 1 or crossCell[j][i].right[1] == 1 then
			rowspan = 2

			if crossCell[j][i].left[1] == 1 then
				cross[1] = 'linear-gradient(to top right, transparent calc(50% - 1px),'
					.. crossCell[j][i].left[2] .. ' calc(50% - 1px),'
					.. crossCell[j][i].left[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
			end

			if crossCell[j][i].right[1] == 1 then
				cross[2] = 'linear-gradient(to bottom right, transparent calc(50% - 1px),'
					.. crossCell[j][i].right[2] .. ' calc(50% - 1px),'
					.. crossCell[j][i].right[2] .. ' calc(50% + 1px), transparent calc(50% + 1px))'
			end
		end

		-- Combine left/right gradient layers if both exist
		if notempty(cross[1]) and notempty(cross[2]) then
			cross[1] = cross[1] .. ','
		end
		bg = cross[1] .. cross[2]
	end

	-- Generate three cells (left, middle, right) for this row
	for k = 1, 3 do
		generatePathCell(tbl, j, i, k, bg, rowspan)
	end
end

local function parsePaths(j)
	local result={}
	local str = fargs['col'..j..'-col'..(j+1)..'-paths'] or ''
		for val in str:gsub("%s+","")
					:gsub(",",", ")
					:gsub("%S+","\0%0\0")
					:gsub("%b()", function(s) return s:gsub("%z","") end)
					:gmatch("%z(.-)%z") do
			local array = split(val:gsub("%s+",""):gsub("%)",""):gsub("%(",""),{"-"})
			for k,_ in pairs(array) do
				array[k] = split(array[k],{","})
			end
			if notempty(array[2]) then
				for m=1,#array[2] do
					array[3] = {}
					array[2][m] = split(array[2][m],{":"})
					array[3][m] = array[2][m][2]
					array[2][m] = array[2][m][1]
				end
				for n=1,#array[1] do
					for m=1,#array[2] do
						table.insert(result,{tonumber(array[1][n]),tonumber(array[2][m]),['color']=array[3][m]})
					end
				end
			end
		end
	return result
end

local function isPathHidden(j,i,start,stop)
	local result=false
	if notempty(entries[j][start-1]) and (byes[j][entries[j][start-1]['headerindex']] and isBlankEntry(j,start-1) and isBlankEntry(j,start+1) or hide[j][entries[j][start-1]['headerindex']]) then
		if bargs('show-bye-paths')~='y' and bargs('show-bye-paths')~='yes' then
			result=true
		end
	end
	if notempty(entries[j+1][stop-1]) and (byes[j+1][entries[j+1][stop-1]['headerindex']] and isBlankEntry(j+1,stop-1) and isBlankEntry(j+1,stop+1) or hide[j+1][entries[j+1][stop-1]['headerindex']])then
		if bargs('show-bye-paths')~='y' and bargs('show-bye-paths')~='yes' then
			result=true
		end
	end
	if bargs('RD'..j..'-RD'..(j+1)..'-path')=='n' or bargs('RD'..j..'-RD'..(j+1)..'-path')=='no' or bargs('RD'..j..'-RD'..(j+1)..'-path')=='0' then
		if notempty(entries[j][start-1]) and entries[j][start-1]['headerindex']==1 then
			result=true
		end
	end
	return result
end

local function getPaths()
	
	-- ===== Small atomic helpers =====
	-- Helper: write a segment to a path cell
	local function setPathCell(j, row, colIndex, borderIndex, value, color)
		pathCell[j][row][colIndex][1][borderIndex] = value
		pathCell[j][row][colIndex].color = color
	end
	
	local function setMultipleCells(j, row, colIndexes, borderIndex, value, color)
		for _, colIndex in ipairs(colIndexes) do
			pathCell[j][row][colIndex][1][borderIndex] = value
			pathCell[j][row][colIndex].color = color
		end
	end
	
	-- ===== Path drawing =====
	-- Handle straight path (start == stop)
	local function handleStraightPath(j, start, color, straightpaths)
		table.insert(straightpaths, { start, color })
	end
	-- Handle downward paths (start < stop)
	local function handleDownwardPath(j, start, stop, cross, color)
		if stop > r then return end
		setPathCell(j, start + 1, 1, 1, 1, color)
	
		if cross == 0 then
			if hascross[j] then
				setPathCell(j, start + 1, 2, 1, 1, color)
				for i = start + 1, stop do
					setPathCell(j, i, 2, 2, 1, color)
				end
			else
				for i = start + 1, stop do
					setPathCell(j, i, 1, 2, 1, color)
				end
			end
		else
			crossCell[j][cross].left = { 1, color }
			for i = start + 1, cross - 1 do
				setPathCell(j, i, 1, 2, 1, color)
			end
			for i = cross + 2, stop do
				setPathCell(j, i, 2, 2, 1, color)
			end
		end
	
		setPathCell(j, stop, 3, 3, 1, color)
	end
	-- Handle upward paths (start > stop)
	local function handleUpwardPath(j, start, stop, cross, color)
		if start > r then return end
		setPathCell(j, stop + 1, 3, 1, 1, color)
	
		if cross == 0 then
			if hascross[j] then
				for i = stop + 1, start do
					setPathCell(j, i, 2, 2, 1, color)
				end
				setPathCell(j, start, 2, 3, 1, color)
			else
				for i = stop + 1, start do
					setPathCell(j, i, 1, 2, 1, color)
				end
			end
		else
			crossCell[j][cross].right = { 1, color }
			for i = stop + 1, cross - 1 do
				setPathCell(j, i, 2, 2, 1, color)
			end
			for i = cross + 2, start do
				setPathCell(j, i, 1, 2, 1, color)
			end
		end
	
		setPathCell(j, start, 1, 3, 1, color)
	end
	
	-- ===== Thickness adjustments =====
	-- Thicken start==stop paths
	local function thickenStraightPaths(j, straightpaths)
		for _, sp in ipairs(straightpaths) do
			local i, color = sp[1], sp[2]
			if i > r then break end
	
			if pathCell[j][i][1][1][3] == 0 then
				-- Set all three columns' bottom border to 1
				setMultipleCells(j, i, {1, 2, 3}, 3, 1, color)
	
				-- Set next row's top border to 0.5 if empty
				if pathCell[j][i+1][1][1][1] == 0 then
					setMultipleCells(j, i+1, {1, 2, 3}, 1, 0.5, color)
				end
			elseif pathCell[j][i+1][1][1][1] == 0 then
				-- Set next row's top border to 1
				setMultipleCells(j, i+1, {1, 3}, 1, 1, color)
				if hascross[j] then
					setMultipleCells(j, i+1, {2}, 1, 1, color)
				end
			end
		end
	end
	-- Adjust path thickness for outpaths (thicken/thin transitions)
	local function adjustOutpaths(j, outpaths)
		for _, op in ipairs(outpaths) do
			local i = op[1]
			-- skip if this is the last row (i+1 would be out of range)
			if i < r then
				local top1, top2 = pathCell[j][i+1][1], pathCell[j][i+1][2]
				local bottom1, bottom2 = pathCell[j][i][1], pathCell[j][i][2]
	
				-- Thinning: strong bottom to empty top
				if bottom1[1][3] == 1 and top1[1][1] == 0 then
					top1[1][1] = 0.5 * bottom1[1][3]
					top2[1][1] = 0.5 * bottom2[1][3]
					top1.color = bottom1.color
					top2.color = bottom2.color
	
				-- Thickening: empty bottom to strong top
				elseif bottom1[1][3] == 0 and top1[1][1] == 1 then
					bottom1[1][3] = top1[1][1]
					bottom2[1][3] = top2[1][1]
					top1[1][1] = 0.5 * bottom1[1][3]
					top2[1][1] = 0.5 * bottom2[1][3]
					bottom1.color = top1.color
					bottom2.color = top2.color
				end
			end
		end
	end
	-- Thin double-in paths
	local function thinDoubleInPaths(j, inpaths)
		for _, ip in ipairs(inpaths) do
			local i = ip[1]
			-- skip if this is the last row (i+1 would be out of range)
			if i < r then
				local curr3 = pathCell[j][i][3]
				local next3 = pathCell[j][i+1][3]
	
				if curr3[1][3] == 1 and next3[1][1] == 1 and curr3.color == next3.color then
					next3[1][1] = 0.5 * curr3[1][3]
				end
			end
		end
	end
	
	-- ===== Cross-column connections =====
	-- Connect straight paths between adjacent columns
	local function connectStraightPaths()
		for j = minc, c - 1 do
			for i = 1, r - 1 do
				local straightpath = false
	
				-- Check if the top entry in next column is blank or a bye
				local topEntry = entries[j+1][i-1]
				local isTopBlankOrBye =
					(topEntry == nil or (byes[j+1][topEntry.headerindex] and isBlankEntry(j+1, i-1)))
	
				if isTopBlankOrBye then
					local currPC, nextPC     = pathCell[j],     pathCell[j+1]
					local currEntry, nextEnt = entries[j],      entries[j+1]
	
					local cond1 = (currPC[i][3][1][3] ~= 0 and nextPC[i][1][1][3] ~= 0)
					local cond2 = (currPC[i+1][3][1][1] ~= 0 and nextPC[i+1][1][1][1] ~= 0)
	
					if cond1 or cond2 then
						if nextPC[i][1][1][3] == nextPC[i][3][1][3]
						and nextPC[i+1][1][1][1] == nextPC[i+1][3][1][1] then
							straightpath = true
						end
	
						-- Copy left path data from current col to next col
						nextPC[i][1][1][3]   = currPC[i][3][1][3]
						nextPC[i+1][1][1][1] = currPC[i+1][3][1][1]
						nextPC[i][2][1][3]   = currPC[i][3][1][3]
						nextPC[i+1][2][1][1] = currPC[i+1][3][1][1]
	
						-- Update entries to represent connecting lines
						nextEnt[i-1] = { ctype = 'line' }
						nextEnt[i]   = { ctype = 'blank' }
						if notempty(nextEnt[i+1]) then
							nextEnt[i+1].ctype = 'line2'
						else
							nextEnt[i+1] = { ctype = 'line2' }
						end
						nextEnt[i+2] = { ctype = 'blank' }
	
						-- If perfectly straight path, mirror left values to right
						if straightpath then
							nextPC[i][3][1][3]   = nextPC[i][1][1][3]
							nextPC[i+1][3][1][1] = nextPC[i+1][1][1][1]
						end
					end
				end
			end
		end
	end
	
	-- ===== Main logic =====
	local paths = {}

	-- Step 1: Determine which columns have cross paths
	for j = minc, c - 1 do
		hascross[j] = notempty(fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'])
	end

	-- Step 2: Process each column
	for j = minc, c - 1 do
		paths[j] = parsePaths(j)

		-- Initialize per-column path data
		pathCell[j], crossCell[j], skipPath[j] = {}, {}, {}
		for i = 1, r do
			pathCell[j][i] = {}
			for k = 1, 3 do
				pathCell[j][i][k] = { {0, 0, 0, 0}, color = COLORS.path_line_color }
			end
			crossCell[j][i] = {
				left  = {0, COLORS.path_line_color},
				right = {0, COLORS.path_line_color}
			}
			skipPath[j][i] = false
		end

		-- Prepare cross location list
		local crossloc = split((fargs['col' .. j .. '-col' .. (j + 1) .. '-cross'] or '')
			:gsub("%s+", ""), {","}, true)
		local hasCrossLoc = notempty(crossloc[1])
		if shift[j] ~= 0 and hasCrossLoc then
			for n = 1, #crossloc do
				crossloc[n] = crossloc[n] + shift[j]
			end
		end

		-- Temporary holders for later thickening/thinning
		local straightpaths, outpaths, inpaths = {}, {}, {}

		-- Step 3: Process each path in the current column
		for _, path in ipairs(paths[j]) do
			local startRow = 2 * (path[1] + shift[j])     + (teams_per_match[j]     - 2)
			local stopRow  = 2 * (path[2] + shift[j + 1]) + (teams_per_match[j + 1] - 2)

			-- Build cross rows
			local crossRows = {0}
			if hasCrossLoc then
				for n = 1, #crossloc do
					crossRows[n] = 2 * crossloc[n] + (teams_per_match[j] - 2)
				end
			end

			-- Find applicable cross row
			local cross = 0
			for _, cr in ipairs(crossRows) do
				if (startRow < stopRow and cr < stopRow and cr > startRow)
				or (startRow > stopRow and cr > stopRow and cr < startRow) then
					cross = cr
				end
			end

			local color = path.color or COLORS.path_line_color
			table.insert(outpaths, { startRow, color })
			table.insert(inpaths, { stopRow,  color })

			if not isPathHidden(j, i, startRow, stopRow) then
				if startRow == stopRow then
					handleStraightPath(j, startRow, color, straightpaths)
				elseif startRow < stopRow then
					handleDownwardPath(j, startRow, stopRow, cross, color)
				else
					handleUpwardPath(j, startRow, stopRow, cross, color)
				end
			end
		end

		-- Step 4: Apply thickness adjustments
		thickenStraightPaths(j, straightpaths)
		adjustOutpaths(j, outpaths)
		thinDoubleInPaths(j, inpaths)
	end

	-- Step 5: Connect straight paths across columns
	connectStraightPaths()
end

local function getGroups()
	-- Helper: true if cell (or the next cell) is empty or blank text
	local function isBlankOrBlankText(j, i)
		if entries[j][i] == nil then
			if entries[j][i + 1] == nil then
				return true
			elseif entries[j][i + 1].ctype == 'text' and isBlankEntry(j, i + 1) then
				return true
			end
		elseif entries[j][i].ctype == 'text' and isBlankEntry(j, i) then
			return true
		end
		return false
	end

	for j = minc, c - 1 do
		-- Only handle standard 2-team matches (same as original)
		if teams_per_match[j] == 2 then
			local groupIndex = 0

			for i = 1, r - 1 do
				if pathCell[j][i][3][1][3] == 1 or pathCell[j][i + 1][3][1][1] == 1 then
					groupIndex = groupIndex + 1

					if isBlankOrBlankText(j, i) then
						local k = minc - 1
						repeat
							-- guard entries[j-k] existence before indexing
							if entries[j - k] and entries[j - k][i + 1]
							   and entries[j - k][i + 1].ctype == 'text'
							   and isBlankEntry(j - k, i + 1) then
								entries[j - k][i + 2] = nil
							end

							-- create blank placeholders as in original logic
							if entries[j - k] == nil then entries[j - k] = {} end
							entries[j - k][i]   = { ctype = 'blank' }
							entries[j - k][i+1] = { ctype = 'blank' }

							if k > 0 and noPaths(j - k, i) then
								skipPath[j - k][i]   = true
								skipPath[j - k][i+1] = true
							end

							k = k + 1
						until k > j - 1 or not isBlankOrBlankText(j - k, i) or not noPaths(j - k, i)

						k = k - 1

						if entries[j - k] == nil then entries[j - k] = {} end
						entries[j - k][i] = {
							ctype   = 'group',
							index   = groupIndex,
							colspan = k + 1
						}
						entries[j - k][i + 1] = { ctype = 'blank' }
						entries[j - k][i].group = bargs('RD' .. j .. '-group' .. groupIndex)
					end
				end
			end
		end
	end
end

local function getCells()
	local DEFAULT_TPM = 2
	local maxrow = 1
	local colentry = {}
	local hasNoHeaders = true

	-- Phase 1: Determine header presence and teams_per_match
	for j = minc, c do
		if notempty(fargs["col" .. j .. "-headers"]) then
			hasNoHeaders = false
		end
		teams_per_match[j] =
			tonumber(fargs["RD" .. j .. "-teams-per-match"]) or
			tonumber(fargs["col" .. j .. "-teams-per-match"]) or
			tonumber(fargs["teams-per-match"]) or
			DEFAULT_TPM
		maxtpm = math.max(maxtpm, teams_per_match[j])
	end

	-- Phase 2: Build colentry for each column
	for j = minc, c do
		entries[j] = {}
		shift[j] = tonumber(bargs("RD" .. j .. "-shift")) or tonumber(bargs("shift")) or 0

		colentry[j] = {
			split((fargs["col" .. j .. "-headers"] or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-matches"] or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-lines"]   or ""):gsub("%s+", ""), {","}, true),
			split((fargs["col" .. j .. "-text"]    or ""):gsub("%s+", ""), {","}, true),
		}

		if hasNoHeaders and fargs["noheaders"] ~= "y" and fargs["noheaders"] ~= "yes" then
			table.insert(colentry[j][1], 1)
		end
	end

	-- Ctype mapping for colentry positions
	local CTYPE_MAP = { "header", "team", "line", "text", "group" }

	-- Helper functions for each ctype
	local function populateTeam(j, rowIndex, n)
		if entries[j][rowIndex - 1] == nil and entries[j][rowIndex - 2] == nil then
			entries[j][rowIndex - 2] = { ctype = "text", index = n }
			entries[j][rowIndex - 1] = { ctype = "blank" }
		end
		entries[j][rowIndex] = { ctype = "team", index = teams_per_match[j] * n - (teams_per_match[j] - 1), position = "top" }
		entries[j][rowIndex + 1] = { ctype = "blank" }
		for m = 2, teams_per_match[j] do
			local idx = teams_per_match[j] * n - (teams_per_match[j] - m)
			entries[j][rowIndex + 2 * (m - 1)]     = { ctype = "team", index = idx }
			entries[j][rowIndex + 2 * (m - 1) + 1] = { ctype = "blank" }
		end
	end

	local function populateText(j, rowIndex, index)
		entries[j][rowIndex]     = { ctype = "text", index = index }
		entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	local function populateLine(j, rowIndex)
		entries[j][rowIndex]     = { ctype = "line" }
		entries[j][rowIndex + 1] = { ctype = "blank" }
		entries[j][rowIndex + 2] = { ctype = "line2" }
		entries[j][rowIndex + 3] = { ctype = "blank" }
	end

	local function populateGroup(j, rowIndex, n)
		entries[j][rowIndex]     = { ctype = "group", index = n }
		entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	local function populateDefault(j, rowIndex, n)
		entries[j][rowIndex]     = { ctype = "header", index = n, position = "top" }
		entries[j][rowIndex + 1] = { ctype = "blank" }
	end

	-- Phase 3: Populate entries for each column
	for j = minc, c do
		local textindex = 0
		for k, positions in ipairs(colentry[j]) do
			table.sort(positions)
			local ctype = CTYPE_MAP[k]

			for n = 1, #positions do
				if shift[j] ~= 0 and positions[n] > 1 then
					positions[n] = positions[n] + shift[j]
				end
				local rowIndex = 2 * positions[n] - 1
				maxrow = math.max(rowIndex + 2 * teams_per_match[j] - 1, maxrow)

				if ctype == "team" then
					populateTeam(j, rowIndex, n)
					textindex = n
				elseif ctype == "text" then
					populateText(j, rowIndex, textindex + n)
				elseif ctype == "line" then
					populateLine(j, rowIndex)
				elseif ctype == "group" then
					populateGroup(j, rowIndex, n)
				else
					populateDefault(j, rowIndex, n)
				end
			end
		end
	end

	if isempty(r) then
		r = maxrow
	end
end

--=== Small helpers for yes/no parsing ===--
local function yes(val) return val == 'y' or val == 'yes' end
local function no(val)  return val == 'n' or val == 'no' end

--=== Update max legs per column ===--
local function updateMaxLegs()
    for j = minc, c do
        maxlegs[j] = rlegs[j]
        for i = 1, r do
            if notempty(entries[j][i]) then
                if notempty(entries[j][i].legs) then
                    maxlegs[j] = math.max(rlegs[j], entries[j][i].legs)
                end
                if autolegs then
                    local l = 1
                    repeat l = l + 1
                    until isempty(entries[j][i].score) or isempty(entries[j][i].score[l])
                    maxlegs[j] = math.max(maxlegs[j], l - 1)
                end
            end
        end
    end
end

--=== Build HTML output ===--
local function buildTable()
    local div = mw.html.create('div'):css('overflow', 'auto')
    if height ~= 0 then div:css('height', height) end

    local tbl = mw.html.create('table')
        :attr('cellpadding', '0')
        :attr('cellspacing', '0')
        :css('font-size', '90%')
        :css('border-collapse', 'separate')
        :css('margin', '1em 2em 0em 1em')
        :css('color', COLORS.text_color)

    if nowrap then tbl:css('white-space', 'nowrap') end

    -- Invisible header row for column widths
    tbl:tag('tr'):css('visibility', 'collapse')
    tbl:tag('td'):css('width', '1px')

    for j = minc, c do
        if seeds then tbl:tag('td'):css('width', getWidth('seed', '25px')) end
        tbl:tag('td'):css('width', getWidth('team', '150px'))

        local scoreWidth = getWidth('score', '25px')
        if maxlegs[j] == 0 then
            tbl:tag('td'):css('width', scoreWidth)
        else
            for l = 1, maxlegs[j] do
                tbl:tag('td'):css('width', scoreWidth)
            end
        end

        if aggregate and maxlegs[j] > 1 then
            tbl:tag('td'):css('width', getWidth('agg', scoreWidth))
        end

        if j ~= c then
            if hascross[j] then
                tbl:tag('td'):css('width', colspacing - 3 .. 'px'):css('padding-left', '4px')
                tbl:tag('td'):css('padding-left', '5px'):css('width', '5px')
                tbl:tag('td'):css('width', colspacing - 3 .. 'px'):css('padding-right', '2px')
            else
                tbl:tag('td'):css('width', colspacing - 3 .. 'px'):css('padding-left', '4px')
                tbl:tag('td'):css('width', colspacing - 3 .. 'px'):css('padding-right', '2px')
            end
        end
    end

    -- Table rows
    for i = 1, r do
        local row = tbl:tag('tr')
        row:tag('td'):css('height', '11px')
        for j = minc, c do
            insertEntry(row, j, i)
            if j ~= c then insertPath(row, j, i) end
        end
    end

    div:wikitext(tostring(tbl))
    return tostring(div)
end

--=== Main function ===--
function p.main(frame)
    fargs, pargs = frame.args, frame:getParent().args

    -- Parse basic args
    r     = tonumber(fargs.rows) or ''
    c     = tonumber(fargs.rounds) or 1
    maxc  = tonumber(pargs.maxrounds) or tonumber(pargs.maxround) or ''
    minc  = tonumber(pargs.minround) or 1
    if notempty(maxc) then c = maxc end
    autocol    = yes(fargs.autocol)
    colspacing = tonumber(fargs['col-spacing']) or 5
    height     = bargs('height') or 0

    -- Defaults
    maxtpm, seeds, forceseeds, aggregate, autolegs, nowrap = 1, true, false, false, false, true
    boldwinner = bargs('boldwinner') or ''
    forceseeds = yes(bargs('seeds'))
    seeds      = not no(bargs('seeds'))
    aggregate  = yes(bargs('aggregate'))
    autolegs   = yes(bargs('autolegs'))
    paramstyle = (bargs('paramstyle') == 'numbered') and 'numbered' or 'indexed'
    nowrap     = not no(pargs.nowrap)

    -- Data processing
	headerindex = {}  -- re-init before use
	getCells()
	getAltIndices()
	assignParams()
	matchGroups()
	if yes(boldwinner) or boldwinner == 'high' then boldWinner() end
	getPaths()
	if minc == 1 then getGroups() end
	updateMaxLegs()

    -- Output
    return buildTable()
end

return p