From 08961e0e4960e35692eedd4bcdfb3bb38c1f54fa Mon Sep 17 00:00:00 2001 From: ywang Date: Sun, 8 Aug 2021 15:06:54 +0200 Subject: Add simple text rendering library --- advtrains/init.lua | 1 + advtrains/spec/textrender_spec.lua | 17 ++ advtrains/textrender.lua | 303 +++++++++++++++++++++++++++++++ advtrains/textures/advtrains_unifont.png | Bin 0 -> 866068 bytes 4 files changed, 321 insertions(+) create mode 100644 advtrains/spec/textrender_spec.lua create mode 100644 advtrains/textrender.lua create mode 100644 advtrains/textures/advtrains_unifont.png (limited to 'advtrains') diff --git a/advtrains/init.lua b/advtrains/init.lua index 083281e..18e1e37 100644 --- a/advtrains/init.lua +++ b/advtrains/init.lua @@ -200,6 +200,7 @@ advtrains.fpath=minetest.get_worldpath().."/advtrains" dofile(advtrains.modpath.."/path.lua") dofile(advtrains.modpath.."/trainlogic.lua") +advtrains.textrender = dofile(advtrains.modpath.."/textrender.lua") dofile(advtrains.modpath.."/trainhud.lua") dofile(advtrains.modpath.."/trackplacer.lua") dofile(advtrains.modpath.."/copytool.lua") diff --git a/advtrains/spec/textrender_spec.lua b/advtrains/spec/textrender_spec.lua new file mode 100644 index 0000000..063d9ee --- /dev/null +++ b/advtrains/spec/textrender_spec.lua @@ -0,0 +1,17 @@ +package.path = "../?.lua" .. package.path + +local tr = require("textrender") + +describe("string to unicode codepoint sequence converter", function() + local mbstoucs = tr.mbstoucs + it("should convert strings properly", function() + assert.same({0x61,0xe9,0x01d8}, mbstoucs("aéǘ")) + end) + it("should not convert invalid multibyte sequences", function() + local function assert_invalid(...) + assert.same({}, mbstoucs(string.char(...))) + end + assert_invalid(65, math.random(128,191), math.random(128,191)) + assert_invalid(65, math.random(192,223)) + end) +end) \ No newline at end of file diff --git a/advtrains/textrender.lua b/advtrains/textrender.lua new file mode 100644 index 0000000..f23cd3f --- /dev/null +++ b/advtrains/textrender.lua @@ -0,0 +1,303 @@ +--[[ 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) + if not characters[char] then + characters[char] = {} + end + end + local charwidth_lut = {} + local charwidth_ranges = { + [8] = { + 0x20,0x7e, -- Latin + 0xa0,0x052f, -- LGC + }, + } + 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 +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 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 +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 + 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 -- for 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 lwidth + seg.width <= width then + local line = lines[#lines] + line.width = (line.width or 0) + seg.width + 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 + 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 + l.width = st[j-1]-tw + 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 + 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) + return cps -- TODO: at least implement support for combining diacritics +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.max(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.png\\^[sheet\\:258x260\\:%d,%d)", + char%256+2, + math.floor(char/256+4) + ) + 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 + +if minetest then + minetest.register_node("advtrains:textrender_demo",{ + description = "Text rendering demo", + tiles = {"advtrains_hud_bg.png^[resize:128x128^[colorize:#0000ff^(advtrains_hud_bg.png^[resize:128x64)^"..render("Hello\n\n World", 128, 128, {color="#00ff00"})}, + groups = {cracky=3, not_in_creative_inventory=1} + }) + minetest.register_on_mods_loaded(function() + 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) +end + +return { + mbstoucs = mbstoucs, + texture_escape = texture_escape, +} \ No newline at end of file diff --git a/advtrains/textures/advtrains_unifont.png b/advtrains/textures/advtrains_unifont.png new file mode 100644 index 0000000..6950dab Binary files /dev/null and b/advtrains/textures/advtrains_unifont.png differ -- cgit v1.2.3