Use UTF-8 codepoint parsing and disable word wrap

characters get hyphenated and line broken instead
This commit is contained in:
Mikita Wiśniewski 2025-01-25 12:36:45 +07:00
parent 122b8b6ea0
commit 30c05c8e69
7 changed files with 276 additions and 199 deletions

View file

@ -3,28 +3,40 @@
## Functions
* `mcl_signs.register_sign(name, color, [definition])`
* `name` is the part of the namestring that will follow `"mcl_signs:"`
* `color` is the HEX color value to color the greyscale sign texture with.
**Hint:** use `""` or `"#ffffff"` if you're overriding the texture fields
in sign definition
* `definition` is optional, see section below for reference
## Sign definition
```lua
{
-- This can contain any node definition fields which will ultimately make up the sign nodes.
-- Usually you will want to at least supply "description" and "_doc_items_longdesc".
-- This can contain any node definition fields which will ultimately make
-- up the sign nodes.
-- Usually you'll want to at least supply `description`.
description = S("Significant Sign"),
-- If you don't want to use texture coloring, you'll have to supply the
-- textures yourself:
}
```
## `characters.txt`
## Character map (`characters.tsv`)
It's a UTF-8 encoded text file that contains metadata for all supported
characters. It contains a sequence of info blocks, one for each character. Each
info block is made out of 3 lines:
characters. Despite its file extension and the theoretical possibility of
opening it in a spreadsheet editor, it's still plaintext values separated with
`\t` (tab idents). The separated values are _columns_, and the lines they are
located at are _cells_. 1 cell and 3 columns per character:
* **Line 1:** The literal UTF-8 encoded character
* **Line 2:** Name of the texture file for this character minus the ".png"
* **Column 1:** The literal UTF-8 encoded character
* **Column 2:** Name of the texture file for this character minus the ".png"
suffix (found in the "textures/" sub-directory in root)
* **Line 3:** Currently ignored. Previously this was for the character width
in pixels
* **Column 3:** Currently ignored. This is reserved for character width in
pixels in case the font will be made proportional
After line 3, another info block may follow. This repeats until the end of the file.
All character files must be 5 or 6 pixels wide (5 pixels are preferred).
All character textures must be 12 pixels high and 5 or 6 pixels wide (5
is preferred).

View file

@ -11,6 +11,8 @@
Code and font: MIT (see `LICENSE` file for details)
`utf8.lua` is taken from `modlib`, by Lars Mueller alias LMD or appguru(eu): [source](https://github.com/appgurueu/modlib/blob/master/utf8.lua)
License of models: GPLv3 (https://www.gnu.org/licenses/gpl-3.0.html)\
Models author: 22i.\
Source: <https://github.com/22i/amc>

View file

@ -1,4 +1,4 @@
local S, charmap = ...
local S, charmap, utf8 = ...
local function table_merge(t, ...)
local t2 = table.copy(t)
@ -13,6 +13,12 @@ local NUMBER_OF_LINES = 4
local LINE_HEIGHT = 14
local CHAR_WIDTH = 5
local SIGN_GLOW_INTENSITY = 14
local LF_CODEPOINT = utf8.codepoint("\n")
local SP_CODEPOINT = utf8.codepoint(" ")
local DS_CODEPOINT = utf8.codepoint("-") -- used as the wrapping character
local DEFAULT_COLOR = "#000000"
local DYE_TO_COLOR = {
["white"] = "#d0d6d7",
@ -33,10 +39,6 @@ local DYE_TO_COLOR = {
["pink"] = "#d56791",
}
local wordwrap_enabled = core.settings:get_bool("vl_signs_word_wrap", true)
local SIGN_GLOW_INTENSITY = 14
local F = core.formspec_escape
-- Template definition
@ -72,10 +74,15 @@ local function get_signdata(pos)
local node = core.get_node(pos)
local def = core.registered_nodes[node.name]
if not def or core.get_item_group(node.name, "sign") < 1 then return end
local meta = core.get_meta(pos)
local text = meta:get_string("text")
local color = meta:get_string("color")
if color == "" then
color = DEFAULT_COLOR
end
local glow = core.is_yes(meta:get_string("glow"))
local yaw, spos
local typ = "standing"
if def.paramtype2 == "wallmounted" then
@ -84,11 +91,11 @@ local function get_signdata(pos)
spos = vector.add(vector.offset(pos, 0, -0.25, 0), dir * 0.41)
yaw = core.dir_to_yaw(dir)
else
yaw = math.rad(((node.param2 * 1.5 ) + 1 ) % 360)
yaw = math.rad(((node.param2 * 1.5) + 1) % 360)
local dir = core.yaw_to_dir(yaw)
spos = vector.add(vector.offset(pos, 0, 0.08, 0), dir * -0.05)
end
if color == "" then color = DEFAULT_COLOR end
return {
text = text,
color = color,
@ -107,95 +114,48 @@ local function set_signmeta(pos, def)
if def.glow then meta:set_string("glow", def.glow) end
end
local function word_wrap(str)
local output = {}
for line in str:gmatch("[^\r\n]*") do
local nline = ""
for word in line:gmatch("%S+") do
if #nline + #word + 1 > LINE_LENGTH then
if nline ~= "" then table.insert(output, nline) end
nline = word
else
if nline ~= "" then nline = nline .. " " end
nline = nline .. word
end
end
table.insert(output, nline)
end
return table.concat(output, "\n")
end
local function string_to_line_array(str)
local linechar_table = {}
local current = 1
local linechar = 1
local cr_last = false
linechar_table[current] = ""
local lines = {}
local line = {}
-- compile characters
for char in str:gmatch(".") do
local add
local is_cr, is_lf = char == "\r", char == "\n"
str = string.gsub(str, "\r\n?", "\n")
for _, code in utf8.codes(str) do
if #lines > NUMBER_OF_LINES then break end
if is_cr and not cr_last then
cr_last = true
add = false
elseif is_lf or cr_last or linechar > LINE_LENGTH then
cr_last = is_cr
add = not (is_cr or is_lf)
current = current + 1
linechar_table[current] = ""
linechar = 1
if code == LF_CODEPOINT
or code == SP_CODEPOINT and #line >= (LINE_LENGTH - 1) then
table.insert(lines, line)
line = {}
elseif #line >= LINE_LENGTH then
table.insert(line, DS_CODEPOINT)
table.insert(lines, line)
line = {code}
else
add = true
end
if add then
linechar_table[current] = linechar_table[current] .. char
linechar = linechar + 1
table.insert(line, code)
end
end
if #line > 0 then table.insert(lines, line) end
return linechar_table
return lines
end
mcl_signs.create_lines = string_to_line_array
function mcl_signs.create_lines(text)
local text_table = {}
for idx, line in ipairs(string_to_line_array(text)) do
if idx > NUMBER_OF_LINES then
break
end
table.insert(text_table, line)
end
return text_table
end
function mcl_signs.generate_line(s, ypos)
local i = 1
local function generate_line(s, ypos)
local parsed = {}
local width = 0
local chars = 0
local printed_char_width = CHAR_WIDTH + 1
while chars < LINE_LENGTH and i <= #s do
local file
-- Get and render character
if charmap[s:sub(i, i)] then
file = charmap[s:sub(i, i)]
i = i + 1
elseif i < #s and charmap[s:sub(i, i + 1)] then
file = charmap[s:sub(i, i + 1)]
i = i + 2
else
-- Use replacement character:
file = "_rc"
i = i + 1
for _, code in ipairs(s) do
local file = "_rc"
if charmap[utf8.char(code)] then
file = charmap[utf8.char(code)]
end
if file then
width = width + printed_char_width
table.insert(parsed, file)
chars = chars + 1
end
end
width = width - 1
local texture = ""
local xpos = math.floor((SIGN_WIDTH - width) / 2)
@ -206,29 +166,106 @@ function mcl_signs.generate_line(s, ypos)
end
return texture
end
mcl_signs.generate_line = generate_line
function mcl_signs.generate_texture(data)
data.text = data.text or ""
--local lines = mcl_signs.create_lines(data.wordwrap and word_wrap(data.text) or data.text)
local lines = mcl_signs.create_lines(wordwrap_enabled and word_wrap(data.text) or data.text)
local function generate_texture(data)
local lines = string_to_line_array(data.text or "")
local texture = "[combine:" .. SIGN_WIDTH .. "x" .. SIGN_WIDTH
local ypos = 0
local letter_color = data.color or DEFAULT_COLOR
for _, line in ipairs(lines) do
texture = texture .. mcl_signs.generate_line(line, ypos)
texture = texture .. generate_line(line, ypos)
ypos = ypos + LINE_HEIGHT
end
texture = "(" .. texture .. "^[multiply:" .. letter_color .. ")"
return texture
end
mcl_signs.generate_texture = generate_texture
function sign_tpl.on_place(itemstack, placer, pointed_thing)
if pointed_thing.type ~= "node" then
return itemstack
-- Text entity handling
local function get_text_entity(pos, force_remove)
local objects = core.get_objects_inside_radius(pos, 0.5)
local text_entity
local i = 0
for _, v in pairs(objects) do
local ent = v:get_luaentity()
if ent and ent.name == "mcl_signs:text" then
i = i + 1
if i > 1 or force_remove == true then
v:remove()
else
text_entity = v
end
end
end
return text_entity
end
mcl_signs.get_text_entity = get_text_entity
local function update_sign(pos)
local data = get_signdata(pos)
local text_entity = get_text_entity(pos)
if text_entity and not data then
text_entity:remove()
return false
elseif not data then
return false
elseif not text_entity then
text_entity = core.add_entity(data.text_pos, "mcl_signs:text")
if not text_entity or not text_entity:get_pos() then return end
end
local glow = 0
if data.glow then
glow = SIGN_GLOW_INTENSITY
end
text_entity:set_properties({
textures = {generate_texture(data)},
glow = glow,
})
text_entity:set_yaw(data.yaw)
text_entity:set_armor_groups({immortal = 1})
return true
end
mcl_signs.update_sign = update_sign
-- Formspec
local function show_formspec(player, pos)
if not pos then return end
local meta = core.get_meta(pos)
local old_text = meta:get_string("text")
core.show_formspec(player:get_player_name(), "mcl_signs:set_text_"..pos.x.."_"..pos.y.."_"..pos.z, table.concat({
"size[6,3]textarea[0.25,0.25;6,1.5;text;",
F(S("Enter sign text:")), ";", F(old_text), "]",
"label[0,1.5;",
F(S("Maximum line length: @1", LINE_LENGTH)), "\n",
F(S("Maximum lines: @1", NUMBER_OF_LINES)),
"]",
"button_exit[0,2.5;6,1;submit;", F(S("Done")), "]"
}))
end
mcl_signs.show_formspec = show_formspec
core.register_on_player_receive_fields(function(player, formname, fields)
if formname:find("mcl_signs:set_text_") == 1 then
local x, y, z = formname:match("mcl_signs:set_text_(.-)_(.-)_(.*)")
local pos = vector.new(tonumber(x), tonumber(y), tonumber(z))
if not fields or not fields.text then return end
if not mcl_util.check_position_protection(pos, player) then
set_signmeta(pos, {
-- limit saved text to 256 characters
-- (4 lines x 15 chars = 60 so this should be more than is ever needed)
text = tostring(fields.text):sub(1, 256)
})
update_sign(pos)
end
end
end)
function sign_tpl.on_place(itemstack, placer, pointed_thing)
local under = pointed_thing.under
local node = core.get_node(under)
local def = core.registered_nodes[node.name]
@ -243,24 +280,25 @@ function sign_tpl.on_place(itemstack, placer, pointed_thing)
local wdir = core.dir_to_wallmounted(dir)
local itemstring = itemstack:get_name()
local placestack = ItemStack(itemstack)
local def = itemstack:get_definition()
local pos
-- place on wall
if wdir ~= 0 and wdir ~= 1 then
placestack:set_name("mcl_signs:wall_sign_"..def._mcl_sign_wood)
itemstack, pos = core.item_place_node(placestack, placer, pointed_thing, wdir)
elseif wdir == 1 then -- standing, not ceiling
local placestack = ItemStack(itemstack)
if wdir < 1 then
-- no placement on ceilings allowed yet
return itemstack
elseif wdir == 1 then
placestack:set_name("mcl_signs:standing_sign_"..def._mcl_sign_wood)
-- param2 value is degrees / 1.5
local rot = normalize_rotation(placer:get_look_horizontal() * 180 / math.pi / 1.5)
itemstack, pos = core.item_place_node(placestack, placer, pointed_thing, rot)
else
return itemstack
placestack:set_name("mcl_signs:wall_sign_"..def._mcl_sign_wood)
itemstack, pos = core.item_place_node(placestack, placer, pointed_thing, wdir)
end
mcl_signs.show_formspec(placer, pos)
show_formspec(placer, pos)
-- restore canonical name as core.item_place_node might have changed it
itemstack:set_name(itemstring)
return itemstack
end
@ -274,7 +312,7 @@ function sign_tpl.on_rightclick(pos, _, clicker, itemstack)
data.color = "#7e7e7e" -- black doesn't glow in the dark
end
set_signmeta(pos, {glow = "true", color = data.color})
mcl_signs.update_sign(pos)
update_sign(pos)
if not core.is_creative_enabled(clicker:get_player_name()) then
itemstack:take_item()
end
@ -284,22 +322,22 @@ function sign_tpl.on_rightclick(pos, _, clicker, itemstack)
glow = "false",
color = DEFAULT_COLOR,
})
mcl_signs.update_sign(pos)
update_sign(pos)
elseif iname:sub(1, 8) == "mcl_dye:" then
local color = iname:sub(9)
set_signmeta(pos, {color = DYE_TO_COLOR[color]})
mcl_signs.update_sign(pos)
update_sign(pos)
if not core.is_creative_enabled(clicker:get_player_name()) then
itemstack:take_item()
end
elseif not mcl_util.check_position_protection(pos, clicker) then
mcl_signs.show_formspec(clicker, pos)
show_formspec(clicker, pos)
end
return itemstack
end
function sign_tpl.on_destruct(pos)
mcl_signs.get_text_entity(pos, true)
get_text_entity(pos, true)
end
-- TODO: reactivate when a good dyes API is finished
@ -321,91 +359,13 @@ local sign_wall = table_merge(sign_tpl, {
_mcl_sign_type = "wall",
})
-- Formspec
function mcl_signs.show_formspec(player, pos)
if not pos then return end
local meta = core.get_meta(pos)
local old_text = meta:get_string("text")
core.show_formspec(player:get_player_name(), "mcl_signs:set_text_"..pos.x.."_"..pos.y.."_"..pos.z, table.concat({
"size[6,3]textarea[0.25,0.25;6,1.5;text;",
F(S("Enter sign text:")), ";", F(old_text), "]",
"label[0,1.5;",
F(S("Maximum line length: @1", LINE_LENGTH)), "\n",
F(S("Maximum lines: @1", NUMBER_OF_LINES)),
"]",
"button_exit[0,2.5;6,1;submit;", F(S("Done")), "]"
}))
end
core.register_on_player_receive_fields(function(player, formname, fields)
if formname:find("mcl_signs:set_text_") == 1 then
local x, y, z = formname:match("mcl_signs:set_text_(.-)_(.-)_(.*)")
local pos = vector.new(tonumber(x), tonumber(y), tonumber(z))
if not fields or not fields.text then return end
if not mcl_util.check_position_protection(pos, player) then
set_signmeta(pos, {
-- limit saved text to 256 characters
-- (4 lines x 15 chars = 60 so this should be more than is ever needed)
text = tostring(fields.text):sub(1, 256)
})
mcl_signs.update_sign(pos)
end
end
end)
-- Text entity handling
function mcl_signs.get_text_entity(pos, force_remove)
local objects = core.get_objects_inside_radius(pos, 0.5)
local text_entity
local i = 0
for _, v in pairs(objects) do
local ent = v:get_luaentity()
if ent and ent.name == "mcl_signs:text" then
i = i + 1
if i > 1 or force_remove == true then
v:remove()
else
text_entity = v
end
end
end
return text_entity
end
function mcl_signs.update_sign(pos)
local data = get_signdata(pos)
local text_entity = mcl_signs.get_text_entity(pos)
if text_entity and not data then
text_entity:remove()
return false
elseif not data then
return false
elseif not text_entity then
text_entity = core.add_entity(data.text_pos, "mcl_signs:text")
if not text_entity or not text_entity:get_pos() then return end
end
local glow = 0
if data.glow then
glow = SIGN_GLOW_INTENSITY
end
text_entity:set_properties({
textures = {mcl_signs.generate_texture(data)},
glow = glow,
})
text_entity:set_yaw(data.yaw)
text_entity:set_armor_groups({immortal = 1})
return true
end
core.register_lbm({
nodenames = {"group:sign"},
name = "mcl_signs:restore_entities",
label = "Restore sign text",
run_at_every_load = true,
action = function(pos)
mcl_signs.update_sign(pos)
update_sign(pos)
end
})
@ -418,7 +378,7 @@ core.register_entity("mcl_signs:text", {
},
on_activate = function(self)
local pos = self.object:get_pos()
mcl_signs.update_sign(pos)
update_sign(pos)
local props = self.object:get_properties()
local t = props and props.textures
if type(t) ~= "table" or #t == 0 then self.object:remove() end

View file

@ -76,7 +76,7 @@ end
function mcl_signs.upgrade_sign_rot(pos, node)
local numsign = false
for _,v in pairs(rotkeys) do
for _,v in ipairs(rotkeys) do
if mcl2rotsigns[node.name] then
node.name = mcl2rotsigns[node.name]
node.param2 = nidp2_degrotate[v][node.param2 + 1]
@ -103,7 +103,8 @@ function mcl_signs.upgrade_sign_rot(pos, node)
end
end
end
core.swap_node(pos,node)
core.swap_node(pos, node)
mcl_signs.upgrade_sign_meta(pos)
mcl_signs.update_sign(pos)
end

View file

@ -5,12 +5,15 @@ local modname = core.get_current_modname()
local S = core.get_translator(modname)
local modpath = core.get_modpath(modname)
-- UTF-8 library from Modlib
local utf8 = dofile(modpath .. DIR_DELIM .. "utf8.lua")
-- Character map
local charmap = {}
for line in io.lines(modpath .. "/characters.tsv") do
local split = line:split("\t")
if #split > 0 then
local char, img, _ = split[1], split[2], split[3]
local char, img, _ = split[1], split[2], split[3] -- 3rd is ignored, reserved for width
charmap[char] = img
end
end
@ -22,6 +25,6 @@ local files = {
"compat"
}
for _, file in ipairs(files) do
loadfile(modpath .. DIR_DELIM .. file .. ".lua")(S, charmap)
for _, file in pairs(files) do
loadfile(modpath .. DIR_DELIM .. file .. ".lua")(S, charmap, utf8)
end

View file

@ -0,0 +1,102 @@
local assert, error, select, string_char, table_concat
= assert, error, select, string.char, table.concat
local utf8 = {}
-- Overly permissive pattern that greedily matches a single UTF-8 codepoint
utf8.charpattern = "[%z-\127\194-\253][\128-\191]*"
function utf8.is_valid_codepoint(codepoint)
-- Must be in bounds & must not be a surrogate
return codepoint <= 0x10FFFF and (codepoint < 0xD800 or codepoint > 0xDFFF)
end
local function utf8_bytes(codepoint)
if codepoint <= 0x007F then
return codepoint
end if codepoint <= 0x7FF then
local payload_2 = codepoint % 0x40
codepoint = (codepoint - payload_2) / 0x40
return 0xC0 + codepoint, 0x80 + payload_2
end if codepoint <= 0xFFFF then
local payload_3 = codepoint % 0x40
codepoint = (codepoint - payload_3) / 0x40
local payload_2 = codepoint % 0x40
codepoint = (codepoint - payload_2) / 0x40
return 0xE0 + codepoint, 0x80 + payload_2, 0x80 + payload_3
end if codepoint <= 0x10FFFF then
local payload_4 = codepoint % 0x40
codepoint = (codepoint - payload_4) / 0x40
local payload_3 = codepoint % 0x40
codepoint = (codepoint - payload_3) / 0x40
local payload_2 = codepoint % 0x40
codepoint = (codepoint - payload_2) / 0x40
return 0xF0 + codepoint, 0x80 + payload_2, 0x80 + payload_3, 0x80 + payload_4
end error"codepoint out of range"
end
function utf8.char(...)
local n_args = select("#", ...)
if n_args == 0 then
return
end if n_args == 1 then
return string_char(utf8_bytes(...))
end
local chars = {}
for i = 1, n_args do
chars[i] = string_char(utf8_bytes(select(i, ...)))
end
return table_concat(chars)
end
local function utf8_next_codepoint(str, i)
local first_byte = str:byte(i)
if first_byte < 0x80 then
return i + 1, first_byte
end
local len, head_bits
if first_byte >= 0xC0 and first_byte <= 0xDF then -- 110_00000 to 110_11111
len, head_bits = 2, first_byte % 0x20 -- last 5 bits
elseif first_byte >= 0xE0 and first_byte <= 0xEF then -- 1110_0000 to 1110_1111
len, head_bits = 3, first_byte % 0x10 -- last 4 bits
elseif first_byte >= 0xF0 and first_byte <= 0xF7 then -- 11110_000 to 11110_111
len, head_bits = 4, first_byte % 0x8 -- last 3 bits
else error"invalid UTF-8" end
local codepoint = 0
local pow = 1
for j = i + len - 1, i + 1, -1 do
local byte = assert(str:byte(j), "invalid UTF-8")
local val_bits = byte % 0x40 -- extract last 6 bits xxxxxx from 10xxxxxx
assert(byte - val_bits == 0x80) -- assert that first two bits are 10
codepoint = codepoint + val_bits * pow
pow = pow * 0x40
end
return i + len, codepoint + head_bits * pow
end
function utf8.codepoint(str, i, j)
i, j = i or 1, j or #str
if i > j then return end
local codepoint
i, codepoint = utf8_next_codepoint(str, i)
assert(i - j <= 1, "invalid UTF-8")
return codepoint, utf8.codepoint(str, i)
end
-- Iterator to loop over the UTF-8 characters as `index, codepoint`
function utf8.codes(text, i)
i = i or 1
return function()
if i > #text then
return
end
local prev_index = i
local codepoint
i, codepoint = utf8_next_codepoint(text, i)
return prev_index, codepoint
end
end
return utf8

View file

@ -337,9 +337,6 @@ mcl_enable_hamburger (Enable Hamburger) bool true
# Starting Inventory contents (given directly to the new player)
give_starting_inv (Player Starter Pack) bool false
# Use word wrapping for signs to break lines between words rather than within them
vl_signs_word_wrap (Word wrap sign text) bool true
[Debugging]
# If enabled, this will show the itemstring of an item in the description.
mcl_item_id_debug (Item ID Debug) bool false