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(theme) local size = 3 return function(y) return m.rect{ x=0, y=y, width=500, height=size, style="fill:"..theme.flair, } end, size end local divider = function(theme) return pad(0, 10, _divider(theme)) 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, theme) local f, h = stack( --{pad(10, 10, name(stats))}, {pad(10, 10, text( stats.name, 40, 'font-variant-caps:small-caps;stroke:'..theme.text..';fill:'..theme.flair..';font-family:serif' ))}, {pad(0, 10, text( string.format("%s %s, %s", capitalize(stats.size), stats.type, stats.alignment), 14, 'font-style:italic;fill:'..theme.text ))} ) return f, h end local function statline(name, value, theme) return pad(0, 2, wrapped_text( string.format('%s %s', name, value), 80, 10, 14, 2, 'fill:'..theme.flair )) end local function armor_hp(stats, theme) local f, h = pad(0, 10, stack( {statline('Armor Class', stats.ac, theme)}, {statline('Hit Points', stats.hp, theme)}, {statline('Speed', stats.speed, theme)} )) 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, theme) 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:"..theme.flair }), m.text(value, {x=x, y=y+30, style="font-size:15px;fill:"..theme.flair }), } end local function ability_scores(stats, theme) return function(y) return m.g{ scorebox('STR', stats.str, 10, y, theme), scorebox('DEX', stats.dex, 90, y, theme), scorebox('CON', stats.con, 170, y, theme), scorebox('INT', stats.int, 250, y, theme), scorebox('WIS', stats.wis, 330, y, theme), scorebox('CHA', stats.cha, 410, y, theme), } end, 40 end local function optional_attribute(stats, name, key, theme) local value = stats[key] if value then local f, h = statline(name, value, theme) return f, h else local f, h = empty() return f, h end end local function misc_attributes(stats, theme) local f, h = stack( {optional_attribute(stats, 'Saving Throws', 'saving_throws', theme)}, {optional_attribute(stats, 'Skills', 'skills', theme)}, {optional_attribute(stats, 'Damage Vulnerabilities', 'vulnerabilities', theme)}, {optional_attribute(stats, 'Damage Resistances', 'resistances', theme)}, {optional_attribute(stats, 'Damage Immunities', 'immunities', theme)}, {optional_attribute(stats, 'Condition Immunities', 'condition_immunities', theme)}, {optional_attribute(stats, 'Senses', 'senses', theme)}, {optional_attribute(stats, 'Languages', 'languages', theme)}, {optional_attribute(stats, 'Challenge', 'cr', theme)} ) if h > 0 then return stack({pad(0, 10, f, h)}, {divider(theme)}) else return empty(), 0 end end local function base(stats, theme) local f, h = stack( {header(stats, theme)}, {divider(theme)}, {armor_hp(stats, theme)}, {divider(theme)}, {ability_scores(stats, theme)}, {divider(theme)}, {misc_attributes(stats, theme)} ) return f, h end --===== traits =====-- local function trait(t, theme) return pad(0, 10, wrapped_text( string.format('%s. %s', t.name, format(t.value)), 80, 10, 14, 5, 'fill:'..theme.text )) end local function traits(stats, theme) if not stats.traits then return empty() end local tbl = {} for _, t in ipairs(stats.traits) do table.insert(tbl, {trait(t, theme)}) end return stack(unpack(tbl)) end --===== actions =====-- local function subheader(title, theme) return pad(0, 10, stack( {pad(0, 5, text(title, 25, 'font-variant-caps:small-caps;stroke:'..theme.text..';fill:'..theme.flair))}, {divider(theme)} )) end local function actions(stats, theme) if not stats.actions then return empty() end local tbl = {} for _, action in ipairs(stats.actions) do table.insert(tbl, {trait(action, theme)}) end return stack( {subheader('Actions', theme)}, unpack(tbl) ) end local function reactions(stats, theme) if not stats.reactions then return empty() end local tbl = {} for _, reaction in ipairs(stats.reactions) do table.insert(tbl, {trait(reaction, theme)}) end return stack( {subheader('Reactions', theme)}, unpack(tbl) ) end local function legendary(stats, theme) if not stats.legendary then return empty() end local tbl = {} for _, action in ipairs(stats.legendary.actions) do table.insert(tbl, {trait(action, theme)}) end return stack( {subheader('Legendary Actions', theme)}, {pad(0, 10, wrapped_text(stats.legendary.description, 80, 10, 14, 2, 'fill:'..theme.text))}, unpack(tbl) ) end --===== draw =====-- function draw(stats, theme) local theme = theme or { bg = '#020202', text = 'beige', flair = 'tomato', } local f, h = stack( {base(stats, theme)}, {traits(stats, theme)}, {actions(stats, theme)}, {reactions(stats, theme)}, {legendary(stats, theme)} ) return m.render(m.svg{ viewBox = string.format("0 0 500 %d", h), width = 500, height = height, style = "font-family:sans-serif;", m.rect{x=0, y=0, width=500, height=h, style='fill:'..theme.bg}, f(0), }) end return module