aboutsummaryrefslogtreecommitdiff
path: root/advtrains/textrender.lua
blob: 2f8f18b62f3042d9d7b385a790452d6d2a1432dd (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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
--[[ 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,
}