local module = {} setmetatable(module, {__index=_G}) setfenv(1, module) local m = require 'marigold-svg.marigold' function capitalize(str) return string.gsub(str, "^.", string.upper) end --===== misc =====-- local function stack(...) local array = {...} local l l = function(acc_f, acc_h) if #array == 0 then return acc_f, acc_h end local f, h = array[1][1], array[1][2] table.remove(array, 1) local acc_f = function(y) local tbl = acc_f(y) table.insert(tbl, f(y+acc_h)) return tbl end local acc_h = acc_h + h return l(acc_f, acc_h) end local f, h = l(function(y) return {} end, 0) return function(y) return m.g(f(y)) end, h end local function pad(top, bottom, f, h) return function(y) return f(y+top) end, top + h + bottom end local function _divider() local size = 3 return function(y) return m.rect{ x=0, y=y, width=500, height=size, style="fill:darkred;", } end, size end local divider = function() return pad(0, 10, _divider()) end local function empty() return function(y) return m.g{} end, 0 end local function wrap_text(str, max_len, tbl) tbl = tbl or {} local len = 0 local count = 0 local in_tag = false for i = 1,#str do count = count+1 local c = string.sub(str, i, i) if c == '>' then in_tag = false elseif c == '<' then in_tag = true end if not in_tag then len = len+1 end if len >= max_len then break end end local line = string.sub(str, 1,count) if len < max_len then table.insert(tbl, line) return tbl end -- remove word-end from line local start = string.find(line, '[^ ]+$') local word = string.sub(line, start or #line) local line = string.sub(line, 1, (start and start-1) or nil) local rest = word .. string.sub(str, count+1) table.insert(tbl, line) return wrap_text(rest, max_len, tbl) end local function wrapped_text(str, max_len, x, size, line_spacing, style) local lines = wrap_text(str, max_len) local transform = function(index, line) return function(y) return m.text(line, { x=x, y=y+size, style="font-size:"..size.."px;"..(style or ''), textLength=(index ~= #lines and 480) or nil, }) end, size+line_spacing end local tbl = {} for index, line in ipairs(lines) do table.insert(tbl, {transform(index, line)}) end return stack(unpack(tbl)) end local function format(str) local str = string.gsub(str, "%*%*(.-)%*%*", '%1') str = string.gsub(str, "%*(.-)%*", '%1') return str end local function text(str, size, style) return function(y) return m.text( str, { style="font-size:"..size.."px;" .. (style or ''), x=10, y=y+size } ) end, size end --===== basic stats =====-- local function header(stats) local f, h = stack( --{pad(10, 10, name(stats))}, {pad(10, 10, text( stats.name, 40, 'font-variant-caps:small-caps;stroke:black;fill:darkred;font-family:serif' ))}, {pad(0, 10, text( string.format("%s %s, %s", capitalize(stats.size), stats.type, stats.alignment), 14, 'font-style:italic' ))} ) return f, h end local function _statline(name, value) local size = 14 return function(y) return m.text( ''..name..' '..value, { x=10, y=y+size, style="font-size:"..size.."px;fill:darkred;" } ) end, 14 end --local statline = function(name, value) return pad(0, 2, _statline(name, value)) end local function statline(name, value) return pad(0, 2, wrapped_text( string.format('%s %s', name, value), 80, 10, 14, 2, 'fill:darkred' )) end local function armor_hp(stats) local f, h = pad(0, 10, stack( {statline('Armor Class', stats.ac)}, {statline('Hit Points', stats.hp)}, {statline('Speed', stats.speed)} )) return f, h end local function bonus(ability_score) return math.floor( (ability_score-10)/2 ) end local function scorebox(name, ability_score, x, y) local score = tonumber(ability_score) local value = string.format("%d (%s%d)", score, (score<0 and '-') or '+', bonus(score)) return m.g{ m.text(name, { x=x+10, y=y+15, style="font-weight:bold;font-size:15px;fill:darkred" }), m.text(value, {x=x, y=y+30, style="font-size:15px;fill:darkred;" }), } end local function ability_scores(stats) return function(y) return m.g{ scorebox('STR', stats.str, 10, y), scorebox('DEX', stats.dex, 90, y), scorebox('CON', stats.con, 170, y), scorebox('INT', stats.int, 250, y), scorebox('WIS', stats.wis, 330, y), scorebox('CHA', stats.cha, 410, y), } end, 40 end local function optional_attribute(stats, name, key) local value = stats[key] if value then local f, h = statline(name, value) return f, h else local f, h = empty() return f, h end end local function misc_attributes(stats) local f, h = stack( {optional_attribute(stats, 'Saving Throws', 'saving_throws')}, {optional_attribute(stats, 'Skills', 'skills')}, {optional_attribute(stats, 'Damage Vulnerabilities', 'vulnerabilities')}, {optional_attribute(stats, 'Damage Resistances', 'resistances')}, {optional_attribute(stats, 'Damage Immunities', 'immunities')}, {optional_attribute(stats, 'Condition Immunities', 'condition_immunities')}, {optional_attribute(stats, 'Senses', 'senses')}, {optional_attribute(stats, 'Languages', 'languages')}, {optional_attribute(stats, 'Challenge', 'cr')} ) if h > 0 then return stack({pad(0, 10, f, h)}, {divider()}) else return empty(), 0 end end local function base(stats) local f, h = stack( {header(stats)}, {divider()}, {armor_hp(stats)}, {divider()}, {ability_scores(stats)}, {divider()}, {misc_attributes(stats)} ) return f, h end --===== traits =====-- local function trait(t) return pad(0, 10, wrapped_text( string.format('%s. %s', t.name, format(t.value)), 80, 10, 14, 5 )) end local function traits(stats) if not stats.traits then return empty() end local tbl = {} for _, t in ipairs(stats.traits) do table.insert(tbl, {trait(t)}) end return stack(unpack(tbl)) end --===== actions =====-- local function subheader(title) return pad(0, 10, stack( {pad(0, 5, text(title, 25, 'font-variant-caps:small-caps;stroke:black;fill:darkred'))}, {divider()} )) end local function actions(stats) if not stats.actions then return empty() end local tbl = {} for _, action in ipairs(stats.actions) do table.insert(tbl, {trait(action)}) end return stack( {subheader('Actions')}, unpack(tbl) ) end local function reactions(stats) if not stats.reactions then return empty() end local tbl = {} for _, reaction in ipairs(stats.reactions) do table.insert(tbl, {trait(reaction)}) end return stack( {subheader('Reactions')}, unpack(tbl) ) end local function legendary(stats) if not stats.legendary then return empty() end local tbl = {} for _, action in ipairs(stats.legendary) do table.insert(tbl, {trait(action)}) end return stack( {subheader('Legendary Actions')}, {pad(0, 10, wrapped_text(stats.legendary.description, 80, 10, 14, 2, ''))}, unpack(tbl) ) end --===== draw =====-- function draw(stats) local f, h = stack( {base(stats)}, {traits(stats)}, {actions(stats)}, {reactions(stats)}, {legendary(stats)} ) return m.render(m.svg{ viewBox = string.format("0 0 500 %d", h), width = 500, height = height, style = "font-family:sans-serif;", f(0), }) end return module