--[[ Simple text rendering (= texture generating) library Definitions: * A string ("str") refers to a regular Lua string * A codepoint ("cp") refers to a Unicode codepoint * A character ("char") refers to the smallest unit of rendering * A segment ("seg") refers to a unit of text that should not be broken across two or more lines unless the renderer is explicitly given the option to do so * A sequence ("seq") is a Lua table t where: * Every element with a numeric index are of the same type * Numeric indices are continuous positive integers starting from 1 * String indices can exist to indicate certain properties of the sequence (Note that a "numeric index" refers to an index of a table that is a number. This includes floating-point numbers, inf, -inf, and NaN, if present) For simplicity, the above definitions may differ from that of Unicode Codepoints and characters differ in that characters can be composed out of multiple codepoints (e.g. the character "รก" can be represented with U+00E1 or with a sequence of U+0061 followed by U+0301) Exposed API: * mbstoucs(str) a utility function that converts a string to a sequence of Unicode codepoints * texture_escape(str) a utility function that escapes the texture string ]] -- Lookup tables local characters = {} do -- prevent LUT creation code from polluting the local environment local function ensure_entry_exists(char) local e = characters[char] if not e then e = {} characters[char] = e end return e end local charwidth_lut = {} local charwidth_ranges = { [8] = { 0x20,0x7e, -- Latin 0xa0,0x052f, -- LGC 0x1e00,0x1fff, -- Latin, Greek }, } for k, v in pairs(charwidth_lut) do ensure_entry_exists(k) characters[k].width = v end for k, v in pairs(charwidth_ranges) do for i = 1, #v, 2 do for j = v[i], v[i+1] do ensure_entry_exists(j) if not characters[j].width then characters[j].width = k end end end end local charprops = { can_break_lines = {0x20,}, force_linebreak = {0x0a,}, } for k, v in pairs(charprops) do for i = 1, #v do local char = v[i] ensure_entry_exists(char) characters[char][k] = true end end -- import "interesting" data from UnicodeData local f = io.open(advtrains.modpath.."/UCD/UnicodeData.txt","r") or error("Failed to open UnicodeData") for line in f:lines() do local cp, decomp = string.match(line, "^(%x+);[^;]*;[^;]*;[^;]*;[^;]*;([^;]*);.+$") if cp and decomp then cp = tonumber(cp, 16) decomp = string.split(decomp, " ") end if cp then if decomp[1] then local compatfmt = string.match(decomp[1], "^<([^>]+)>$") if compatfmt then table.remove(decomp, 1) end if not compatfmt then if #decomp == 2 then local base, modifier = tonumber(decomp[1], 16), tonumber(decomp[2], 16) if base and modifier then local bt = ensure_entry_exists(base) if not bt.modifiers then bt.modifiers = {} end bt.modifiers[modifier] = cp end end end end end end f:close() end local function texture_escape(str) return string.gsub(str, "[%[%()^:]", "\\%1") end -- Functions local function mbstoucs(str) if type(str) ~= "string" then return {} end local i = 1 local strlen = #str local cps = {} while i <= strlen do local bytes = {string.byte(str, i, math.max(i+3, strlen))} local nbytes local codepoint local msb = bytes[1] if msb < 128 then nbytes = 1 elseif msb >= 192 and bytes[2] and bytes[2] >= 128 and bytes[2] < 192 then if msb < 224 then nbytes = 2 elseif bytes[3] and bytes[3] >= 128 and bytes[3] < 192 then if msb < 240 then nbytes = 3 elseif msb < 148 and bytes[4] and bytes[4] >= 128 and bytes[4] < 192 then nbytes = 4 end end end if not nbytes then return {} -- invalid MB character encountered elseif nbytes == 1 then codepoint = msb else codepoint = msb%(2^(7-nbytes)) for i = 2, nbytes do codepoint = codepoint * 64 + bytes[i] - 128 end end cps[#cps+1] = codepoint i = i + nbytes end return cps end local function ucstombs(ucs) local s = "" for i = 1, #ucs do local cp = math.floor(ucs[i]) local len if cp < 0 or cp > 0x10ffff then return "" end if cp < 0x80 then len = 1 elseif cp < 0x0800 then len = 2 elseif cp < 0x010000 then len = 3 else len = 4 end if len == 1 then s = s .. string.char(cp) else local t = {} for i = len, 2, -1 do local rem = cp % 64 cp = math.floor(cp/64) t[i] = 128+rem end t[1] = cp + 256 - 2^(8-len) s = s .. string.char(unpack(t)) end end return s end local function basechar(char) if type(char) == "table" then return char[1] else return char end end local function charwidth(char) return (characters[basechar(char)] or {}).width or 0 end -- Splits the text into segments local function split_into_segments(cs) local sl = {} for i = 1, #cs do local char = cs[i] local props = characters[basechar(char)] or {} local cwidth = charwidth(char) if props.can_break_lines then sl[#sl+1] = {bchar = char, bwidth = cwidth, width = cwidth, seq = {}, size = {}} elseif props.force_linebreak then sl[#sl+1] = {width = 0, seq = {}, size = {}} else if not sl[1] then sl[1] = {width = cwidth, seq = {char}, size = {cwidth}} else local seg = sl[#sl] local seq, size = seg.seq, seg.size seq[#seq+1] = char size[#size+1] = (size[#size] or 0) + cwidth seg.width = seg.width + cwidth end end end return sl end local function split_lines(cs, width, height, options) local lines = {} local nlines = options.lines local lwidth = width+1 -- force newline for the first line local wrap_overflow = options.wrap_overflow local align = options.align local sl = split_into_segments(cs) for i = 1, #sl do seg = sl[i] local seq = seg.seq if not seg.bchar then lwidth = width+1 --force newline end if lwidth + seg.width <= width then local line = lines[#lines] lwidth = (line.width or 0) + seg.width line.width = lwidth if seg.bchar then line[#line+1] = seg.bchar end local seq = seg.seq for i = 1, #seq do line[#line+1] = seq[i] end elseif lwidth > 0 then -- create a new line local line = seq line.width = seg.size[#line] lines[#lines+1] = line lwidth = 0 else -- now we need to deal with segments longer than the given width if wrap_overflow then -- forcibly break the sequence local st = seg.size local ci = 1 local tw = 0 while ci <= #seq do local l = {} local j = ci while j <= #seq and st[j]-tw <= width do l[j-ci+1] = seq[j] j = j+1 end if l[1] then lwidth = st[j-1]-tw l.width = lwidth lines[#lines+1] = l end ci = j end else -- only add the sequence that will get rendered local minx = align*(seq.width-width) local maxx = minx+width local l = {width = 0} local st = seg.size local i = 1 while st[i] and st[i] < minx do i = i + 1 end while st[i] and st[i] <= maxx do l[#l+1] = seq[i] l.width = l.width + charwidth(seq[i]) end lines[#lines+1] = l lwidth = l.width end end if #lines > nlines then for i = nlines+1, #lines do lines[i] = nil end break end end return lines end local function cpstocs(cps) local cs = {} local i = 1 local lastchar while i <= #cps do local cp = cps[i] local addchar = true if lastchar then local lcp = characters[lastchar] if lcp and lcp.modifiers and lcp.modifiers[cp] then lastchar = lcp.modifiers[cp] cs[#cs] = lastchar addchar = false end end if addchar then cs[#cs+1] = cp lastchar = cp end i = i + 1 end return cs end local function check_options(width, height, options) local w, h = tonumber(width), tonumber(height) if not (w and h) then return nil end if w < 8 or h < 18 then return nil end local opt = options if type(options) ~= "table" then opt = {} end local ret = {} -- check text alignment option ret.align = math.min(math.max(tonumber(opt.align) or 0, 0),1) -- check "wrap overflow text segment" option ret.wrap_overflow = opt.wrap_overflow and true or false -- convert to boolean -- check line count option local maxlines = math.floor(h/18) ret.lines = math.min(maxlines, tonumber(opt.lines) or maxlines) -- check text color option ret.color = string.match(tostring(options.color) or "", "^#%x%x%x%x%x%x$") or "#000000" return w, h, ret end local function get_char_texture(char) if type(char) == "number" then return string.format("(advtrains_unifont_%04x.png\\^[sheet\\:256x1\\:%d,0)", math.floor(char/256), char%256 ) end end local function render(str, ...) local width, height, opts = check_options(...) if not (width and height and opts) then return nil end local lines = split_lines(cpstocs(mbstoucs(str)), width, height, opts) local tst = {string.format("(([combine:%dx%d", width, height)} local vpad = (height-18*#lines)/2 local align = opts.align for i = 1, #lines do local y = vpad+18*(i-1) local line = lines[i] if line.width then local x = (width-line.width)*align for i = 1, #line do local char = line[i] local cw = charwidth(char) if cw > 0 then tst[#tst+1] = string.format("%d,%d=%s", x, y, get_char_texture(char)) x = x + cw end end end end return table.concat(tst,":")..string.format(")^[makealpha:0,0,0^[multiply:%s)", opts.color) end local tilefmt = "advtrains_hud_bg.png^[resize:128x128^[colorize:#0000ff^(advtrains_hud_bg.png^[resize:128x64)^%s" local sampletext = "Hello world\n\n".. ucstombs{0xe1, 0x65, 0x0301, 0xef, 0x0301, 0x6f, 0x0303, 0x0301, 0x01d8, 0x20, -- Latin alphabet test 0x03b1, 0x0301, 0x0345, 0x03b2, 0x03b3, 0x03b4, 0x03ad, 0x20, -- Greek alphabet test 0x0430, 0x0308, 0x0431, 0x0432, 0x0433, 0x0301, 0x0434} minetest.register_node("advtrains:textrender_demo",{ description = "Text rendering demo", tiles = {string.format(tilefmt, render(sampletext, 128, 128, {color="#00ff00"}))}, groups = {cracky=3, not_in_creative_inventory=1} }) minetest.register_on_mods_loaded(function() -- wait until the trainhud module is loaded minetest.register_craft{ output = "advtrains:textrender_demo", recipe = { {"default:paper","default:paper","default:paper"}, {"default:paper","advtrains:hud_demo","default:paper"}, {"default:paper","default:paper","default:paper"}, } } end) return { mbstoucs = mbstoucs, ucstombs = ucstombs, render = render, texture_escape = texture_escape, }