aboutsummaryrefslogtreecommitdiff
path: root/advtrains/textrender.lua
blob: de9deac2db4a36e7a37206990a78db54d79e0680 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
--[[ 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 -- 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)
	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
	local tilefmt = "advtrains_hud_bg.png^[resize:128x128^[colorize:#0000ff^(advtrains_hud_bg.png^[resize:128x64)^%s"
	-- expected: Hello world<one empty line>áéḯṍǘ
	local sampletext = "Hello world\n\náe"..string.char(0xcc,0x81,0xc3,0xaf,0xcc,0x81,0x6f,0xcc,0x83,0xcc,0x81,0xc7,0x98)
	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)
end

return {
	mbstoucs = mbstoucs,
	render = render,
	texture_escape = texture_escape,
}