# Module:Box-header

local getArgs = require('Module:Arguments').getArgs  

local p = {}  
---------- Config data ----------  
local namedColours = mw.loadData( 'Module:Box-header/colours' )  
local modes = {  
lightest = { sat=0.10, val=1.00 },  
light = { sat=0.15, val=0.95 },  
normal = { sat=0.40, val=0.85 },  
dark = { sat=0.90, val=0.70 },  
darkest = { sat=1.00, val=0.45 },  
content = { sat=0.04, val=1.00 },  
grey = { sat=0.00 }  
}  
local min_contrast_ratio_normal_text = 7 -- i.e 7:1  
local min_contrast_ratio_large_text = 4.5 -- i.e. 4.5:1  

-- Template parameter aliases  
-- Specify each as either a single value, or a table of values  
-- Aliases are checked -to-, i.e. `['one'] = { 'two', 'three' }` is equivalent to using `}` in a template  
local parameterAliases = {  
['1'] = 1,  
['2'] = 2,  
['colour'] = 'color'  
}  

---------- Dependecies ----------  
local colourContrastModule = require('Module:Color contrast')  
local hex = require( 'luabit.hex' )  

---------- Utility functions ----------  
local function getParam(args, parameter)  
if args[parameter] then  
return args[parameter]  
end  
local aliases = parameterAliases[parameter]  
if not aliases then  
return nil  
end  
if type(aliases) ~= 'table' then  
return args[aliases]  
end  
for _, alias in ipairs(aliases) do  
if args[alias] then  
return args[alias]  
end  
end  
return nil  
end  

local function setCleanArgs(argsTable)  
local cleanArgs = {}  
for key, val in pairs(argsTable) do  
if type(val) == 'string' then  
val = val:match('^%s*(.-)%s*$')  
if val ~= '' then  
cleanArgs[key] = val  
end  
else  
cleanArgs[key] = val  
end  
end  
return cleanArgs  
end  

-- Merge two tables into a new table. If the are any duplicate keys, the values from the second overwrite the values from the first.  
local function mergeTables(first, second)  
local merged = {}  
for key, val in pairs(first) do  
merged[key] = val  
end  
for key, val in pairs(second) do  
merged[key] = val  
end  
return merged  
end  

local function toOpenTagString(selfClosedHtmlObject)  
local closedTagString = tostring(selfClosedHtmlObject)  
local openTagString = mw.ustring.gsub(closedTagString, ' />$', '>')  
return openTagString  
end  

local function normaliseHexTriplet(hexString)  
if not hexString then return nil end  
local hexComponent = mw.ustring.match(hexString, '^#(%x%x%x)$') or mw.ustring.match(hexString, '^#(%x%x%x%x%x%x)$')  
if hexComponent and #hexComponent == 6 then  
return mw.ustring.upper(hexString)  
end  
if hexComponent and #hexComponent == 3 then  
local r = mw.ustring.rep(mw.ustring.sub(hexComponent, 1, 1), 2)  
local g = mw.ustring.rep(mw.ustring.sub(hexComponent, 2, 2), 2)  
local b = mw.ustring.rep(mw.ustring.sub(hexComponent, 3, 3), 2)  
return '#' .. mw.ustring.upper(r .. g .. b)  
end  
return nil  
end  

---------- Conversions ----------  
local function decimalToPaddedHex(number)  
local prefixedHex = hex.to_hex(tonumber(number)) -- prefixed with '0x'  
local padding = #prefixedHex == 3 and '0' or ''  
return mw.ustring.gsub(prefixedHex, '0x', padding)  
end  
local function hexToDecimal(hexNumber)  
return tonumber(hexNumber, 16)  
end  
local function RGBtoHexTriplet(R, G, B)  
return '#' .. decimalToPaddedHex(R) .. decimalToPaddedHex(G) .. decimalToPaddedHex(B)  
end  
local function hexTripletToRGB(hexTriplet)  
local R_hex, G_hex, B_hex = string.match(hexTriplet, '(%x%x)(%x%x)(%x%x)')  
return hexToDecimal(R_hex), hexToDecimal(G_hex), hexToDecimal(B_hex)  
end  
local function HSVtoRGB(H, S, V) -- per HSL and HSV#Converting_to_RGB  
local C = V * S  
local H_prime = H / 60  
local X = C * ( 1 - math.abs(math.fmod(H_prime, 2) - 1) )  
local R1, G1, B1  
if H_prime <= 1 then  
R1 = C  
G1 = X  
B1 = 0  
elseif H_prime <= 2 then  
R1 = X  
G1 = C  
B1 = 0  
elseif H_prime <= 3 then  
R1 = 0  
G1 = C  
B1 = X  
elseif H_prime <= 4 then  
R1 = 0  
G1 = X  
B1 = C  
elseif H_prime <= 5 then  
R1 = X  
G1 = 0  
B1 = C  
elseif H_prime <= 6 then  
R1 = C  
G1 = 0  
B1 = X  
end  
local m = V - C  
local R = R1 + m  
local G = G1 + m  
local B = B1 + m  

local R_255 = math.floor(R*255)  
local G_255 = math.floor(G*255)  
local B_255 = math.floor(B*255)  
return R_255, G_255, B_255  
end  
local function RGBtoHue(R_255, G_255, B_255) -- per HSL and HSV#Hue and chroma  
local R = R_255/255  
local G = G_255/255  
local B = B_255/255  

local M = math.max(R, G, B)  
local m = math.min(R, G, B)  
local C = M - m  
local H_prime  
if C == 0 then  
return null  
elseif M == R then  
H_prime = math.fmod(((G - B)/C + 6), 6) -- adding six before taking mod ensures positive value  
elseif M == G then  
H_prime = (B - R)/C + 2  
elseif M == B then  
H_prime = (R - G)/C + 4  
end  
local H = 60 * H_prime  
return H  
end  
local function nameToHexTriplet(name)  
if not name then return nil end  
local codename = mw.ustring.gsub(mw.ustring.lower(name), ' ', '')  
return namedColours[codename]  
end  

---------- Choose colours ----------  
local function calculateColours(H, S, V, minContrast)  
local bgColour = RGBtoHexTriplet(HSVtoRGB(H, S, V))  
local textColour = colourContrastModule._greatercontrast({bgColour})  
local contrast = colourContrastModule._ratio({ bgColour, textColour })  
if contrast >= minContrast then  
return bgColour, textColour  
elseif textColour == '#FFFFFF' then  
-- make the background darker and slightly increase the saturation  
return calculateColours(H, math.min(1, S+0.005), math.max(0, V-0.03), minContrast)  
else  
-- make the background lighter and slightly decrease the saturation  
return calculateColours(H, math.max(0, S-0.005), math.min(1, V+0.03), minContrast)  
end  
end  

local function makeColours(hue, modeName)  
local mode = modes[modeName]  
local isGrey = not(hue)  
if isGrey then hue = 0 end  

local borderSat = isGrey and modes.grey.sat or 0.15  
local = RGBtoHexTriplet(HSVtoRGB(hue, borderSat, 0.75))  

local titleSat = isGrey and modes.grey.sat or mode.sat  
local titleBackground, titleForeground = calculateColours(hue, titleSat, mode.val, min_contrast_ratio_large_text)  

local contentSat = isGrey and modes.grey.sat or modes.content.sat  
local contentBackground, contentForeground = calculateColours(hue, contentSat, modes.content.val, min_contrast_ratio_normal_text)  

return , titleForeground, titleBackground, contentForeground, contentBackground  
end  

local function findHue(colour)  
local colourAsNumber = tonumber(colour)  
if colourAsNumber and ( -1 < colourAsNumber ) and ( colourAsNumber < 360) then  
return colourAsNumber  
end  

local colourAsHexTriplet = normaliseHexTriplet(colour) or nameToHexTriplet(colour)  
if colourAsHexTriplet then  
return RGBtoHue(hexTripletToRGB(colourAsHexTriplet))  
end  

return null  
end  

local function normaliseMode(mode)  
if not mode or not modes[mw.ustring.lower(mode)] or mw.ustring.lower(mode) == 'grey' then  
return 'normal'  
end  
return mw.ustring.lower(mode)  
end  
---------- Build output ----------  
local function boxHeaderOuter(args)  
local baseStyle = {  
clear = 'both',  
['box-sizing'] = '-box',  
= ( getParam(args, '-type') or 'solid' ) .. ' ' .. ( getParam(args, 'titleborder') or getParam(args, '') or '#ababab' ),  
background = getParam(args, 'titlebackground') or '#bcbcbc',  
color = getParam(args, 'titleforeground') or '#000',  
padding = getParam(args, 'padding') or '.1em',  
['text-align'] = getParam(args, 'title-align') or '',  
['font-family'] = getParam(args, 'font-family') or 'sans-serif',  
['font-size'] = getParam(args, 'titlefont-size') or '100%',  
['margin-bottom'] = '',  
}  

local tag = mw.html.create('div', {selfClosing = true})  
:addClass('box-header-title-container')  
:addClass('flex-columns-noflex')  
:css(baseStyle)  
:css('-width', ( getParam(args, '-top') or getParam(args, '-width') or '1' ) .. 'px ' .. ( getParam(args, '-width') or '1' ) .. 'px 0')  
:css('padding-top', getParam(args, 'padding-top') or '.1em')  
:css('padding-', getParam(args, 'padding-') or '.1em')  
:css('padding-', getParam(args, 'padding-') or '.1em')  
:css('padding-bottom', getParam(args, 'padding-bottom') or '.1em')  
:css('moz--radius', getParam(args, 'title--radius') or '0')  
:css('webkit--radius', getParam(args, 'title--radius') or '0')  
:css('-radius', getParam(args, 'title--radius') or '0')  
return toOpenTagString(tag)  
end  

local function boxHeaderTopLinks(args)  
local style = {  
float = '',  
['margin-bottom'] = '.1em',  
['font-size'] = getParam(args, 'font-size') or '80%',  
color = getParam(args, 'titleforeground') or '#000'  
}  
local tag = mw.html.create('div', {selfClosing = true})  
:addClass('plainlinks noprint' )  
:css(style)  
return toOpenTagString(tag)  
end  

local function boxHeaderEditLink(args)  
local page = getParam(args, 'editpage')  
if not page or page == '}'  
then  
return ''  
end  
local style = {  
color = getParam(args, 'titleforeground') or '#000'  
}  
local tag = mw.html.create('span')  
:css(style)  
:wikitext('தொகு')  
local linktext = tostring(tag)  
local linktarget = tostring(mw.uri.fullUrl(page, {action='edit', section=getParam(args, 'section')}))  
return '[' .. linktarget .. ' ' .. linktext .. ']&nbsp;'  
end  

local function boxHeaderViewLink(args)  
local style = {  
color = getParam(args, 'titleforeground') or '#000'  
}  
local tag = mw.html.create('span')  
:css(style)  
:wikitext('பார்')  
local linktext = tostring(tag)  
local linktarget = ':' .. getParam(args, 'viewpage')  
return "<b>·</b>&nbsp;' .. linktext .. '&nbsp;'  
end  

local function boxHeaderTitle(args)  
local baseStyle = {  
['font-family'] = getParam(args, 'title-font-family') or 'sans-serif',  
['font-size'] = getParam(args, 'title-font-size') or '100%',  
['font-weight'] = getParam(args, 'title-font-weight') or 'bold',  
= '',  
margin = '0',  
padding = '0',  
color = getParam(args, 'titleforeground') or '#000';  
}  
local tagName = getParam(args, 'SPAN') and 'span' or 'h2'  
local tag = mw.html.create(tagName)  
:css(baseStyle)  
:css('padding-bottom', '.1em')  
:wikitext(getParam(args, 'title'))  
if getParam(args, 'extra') then  
local rules = mw.text.split(getParam(args, 'extra'), ';', true)  
for _, rule in pairs(rules) do  
local parts = mw.text.split(rule, ':', true)  
local prop = parts[1]  
local val = parts[2]  
if prop and val then  
tag:css(prop, val)  
end  
end  
end  
return tostring(tag)  
end  

local function boxBody(args)  
local baseStyle = {  
['box-sizing'] = '-box',  
= ( getParam(args, '-width') or '1' ) .. 'px solid ' .. ( getParam(args, '') or '#ababab'),  
['vertical-align'] = 'top';  
background = getParam(args, 'background') or '#fefeef',  
opacity = getParam(args, 'background-opacity') or '1',  
color = getParam(args, 'foreground') or '#000',  
['text-align'] = getParam(args, 'text-align') or '',  
margin = '0 0 ',  
padding = getParam(args, 'padding') or '1em',  
}  
local tag = mw.html.create('div', {selfClosing = true})  
:css(baseStyle)  
:css('-top-width', ( getParam(args, '-top') or '1' ) .. 'px')  
:css('padding-top', getParam(args, 'padding-top') or '.3em')  
:css('-radius', getParam(args, '-radius') or '0')  
return toOpenTagString(tag)  
end  

local function contrastCategories(args)  
local cats = ''  

local titleText = nameToHexTriplet(getParam(args, 'titleforeground')) or normaliseHexTriplet(getParam(args, 'titleforeground')) or '#000000'  
local titleBackground = nameToHexTriplet(getParam(args, 'titlebackground')) or normaliseHexTriplet(getParam(args, 'titlebackground')) or '#bcbcbc'  
local titleContrast = colourContrastModule._ratio({titleBackground, titleText})  
local insufficientTitleContrast = type(titleContrast) == 'number' and ( titleContrast < min_contrast_ratio_large_text )  

local bodyText = nameToHexTriplet(getParam(args, 'foreground')) or normaliseHexTriplet(getParam(args, 'foreground')) or '#000000'  
local bodyBackground = nameToHexTriplet(getParam(args, 'background')) or normaliseHexTriplet(getParam(args, 'background')) or '#fefeef'  
local bodyContrast = colourContrastModule._ratio({bodyBackground, bodyText})  
local insufficientBodyContrast = type(bodyContrast) == 'number' and ( bodyContrast < min_contrast_ratio_normal_text )  

if insufficientTitleContrast and insufficientBodyContrast then  
return ''  
elseif insufficientTitleContrast then  
return ''  
elseif insufficientBodyContrast then  
return ''  
else  
return ''  
end  
end  

---------- Main functions / entry points ----------  

-- Entry point for templates (manually-specified colours)  
function p.boxHeader  
local args = getArgs  
local page = args.editpage  
if not args.editpage or args.editpage == '' then  
page = mw.title.getCurrentTitle.prefixedText  
end  
local output = p._boxHeader(args, page)  
if mw.ustring.find(output, '{') then  
return :preprocess(output)  
end  
return output  
end  

-- Entry point for modules (manually-specified colours)  
function p._boxHeader(_args, page)  
local args = setCleanArgs(_args)  
if page and not args.editpage then  
args.editpage = page  
end  
if not args.title then  
args.title = '}'  
end  
local output = {}  
table.insert(output, boxHeaderOuter(args))  
if not getParam(args, 'EDITLINK') then  
table.insert(output, boxHeaderTopLinks(args))  
if not getParam(args, 'noedit') then  
table.insert(output, boxHeaderEditLink(args))  
end  
if getParam(args, 'viewpage') then  
table.insert(output, boxHeaderViewLink(args))  
end  
if getParam(args, 'top') then  
table.insert(output, getParam(args, 'top') .. '&nbsp;')  
end  
table.insert(output, '</div>')  
end  
table.insert(output, boxHeaderTitle(args))  
table.insert(output, '</div>')  
table.insert(output, boxBody(args))  
if not getParam(args, 'TOC') then  
table.insert(output, '__NOTOC__')  
end  
if not getParam(args, 'EDIT') then  
table.insert(output, '__NOEDITSECTION__')  
end  
table.insert(output, contrastCategories(args))  

return table.concat(output)  
end  

-- Entry point for templates (automatically calculated colours)  
function p.autoColour  
local args = getArgs  
local colourParam = getParam(args, 'colour')  
local generatedColour = nil  
if not colourParam or colourParam == '' then  
-- convert the root page name into a number and use that  
local root = mw.title.getCurrentTitle.rootPageTitle.prefixedText  
local rootStart = mw.ustring.sub(root, 1, 12)  
local digitsFromRootStart = mw.ustring.gsub(rootStart, ".", function(s) return math.fmod(string.byte(s, 2) or string.byte(s, 1), 10) end)  
local numberFromRoot = tonumber(digitsFromRootStart, 10)  
generatedColour = math.fmod(numberFromRoot, 360)  
end  
local output = p._autoColour(args, generatedColour)  
if mw.ustring.find(output, '{') then  
return :preprocess(output)  
end  
return output  
end  

-- Entry point for modules (automatically calculated colours)  
function p._autoColour(_args, generatedColour)  
local args = setCleanArgs(_args)  
local hue = generatedColour or findHue(getParam(args, 'colour'))  
local mode = normaliseMode(getParam(args, 'mode'))  
local , titleForeground, titleBackground, contentForeground, contentBackground = makeColours(hue, mode)  
local boxTemplateArgs = mergeTables(args, {  
title = getParam(args, '1') or '}',  
editpage = getParam(args, '2') or '',  
noedit = getParam(args, '2') and '' or 'yes',  
= ,  
titleforeground = titleForeground,  
titlebackground = titleBackground,  
foreground = contentForeground,  
background = contentBackground  
})  
return p._boxHeader(boxTemplateArgs)  
end  

return p