From a322a011774949d5a5971234386b8f7ee791c81d Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 26 Feb 2024 23:57:18 +0100 Subject: [PATCH] Add text support --- display-simulator/esphome-display.js | 84 +++++++++++- display-simulator/esphome-font.js | 187 +++++++++++++++++++++++++++ display-simulator/index.html | 133 +++++++++++++++++-- 3 files changed, 394 insertions(+), 10 deletions(-) create mode 100644 display-simulator/esphome-font.js diff --git a/display-simulator/esphome-display.js b/display-simulator/esphome-display.js index b684873..0f6b197 100644 --- a/display-simulator/esphome-display.js +++ b/display-simulator/esphome-display.js @@ -255,14 +255,55 @@ class Display { } else if (y1 == y2) { // Check for special case of a top-flat triangle this.filled_flat_side_triangle_(x3, y3, x1, y1, x2, y2, color); } else { // General case: split the no-flat-side triangle in a top-flat triangle and bottom-flat triangle - let x_temp = Math.floor(x1 + ((y2 - y1) / (y3 - y1)) * (x3 - x1)), + let x_temp = Math.floor(x1 + Math.floor((y2 - y1) / (y3 - y1)) * (x3 - x1)), y_temp = y2; this.filled_flat_side_triangle_(x1, y1, x2, y2, x_temp, y_temp, color); this.filled_flat_side_triangle_(x3, y3, x2, y2, x_temp, y_temp, color); } } - image(x, y, image, align = Image.TOP_LEFT, color_on = this.COLOR_ON, color_off = this.COLOR_OFF) { + print(x, y, font, arg4, arg5 = null, arg6 = null) { + let color = this.COLOR_ON; + let align = Font.TOP_LEFT; + let text; + if (arg5 === null && arg6 === null) { + text = arg4; + } else if (arg6 === null) { + if (typeof(arg4) == "number") { + align = arg4; + } else { + color = arg4; + } + text = arg5; + } else { + color = arg4; + align = arg5; + text = arg6; + } + x = Math.floor(x); + y = Math.floor(y); + let [x_start, y_start, width, height] = this.get_text_bounds(x, y, text, font, align); + font.print(x_start, y_start, this, color, text); + } + + image(x, y, image, arg4 = null, arg5 = null, arg6 = null) { + let align = Image.TOP_LEFT; + let color_on = this.COLOR_ON; + let color_off = this.COLOR_OFF + if (typeof(arg4) == "number") { + align = arg4; + if (arg5 !== null) { + color_on = arg5; + if (arg6 !== null) { + color_off = arg6; + } + } + } else if (arg4 !== null) { + color_on = arg4; + if (arg5 !== null) { + color_off = arg5; + } + } x = Math.floor(x); y = Math.floor(y); let x_align = Math.floor(align) & Image.HORIZONTAL_ALIGNMENT; @@ -294,4 +335,43 @@ class Display { image.draw(x, y, this, color_on, color_off); } + + get_text_bounds(x, y, text, font, align) { + let [width, x_offset, baseline, height] = font.measure(text); + + let x_align = Math.floor(align) & 0x18; + let y_align = Math.floor(align) & 0x07; + let x1, y1; + + switch (x_align) { + case Font.RIGHT: + x1 = x - width; + break; + case Font.CENTER_HORIZONTAL: + x1 = x - Math.floor(width / 2); + break; + case Font.LEFT: + default: + // LEFT + x1 = x; + break; + } + + switch (y_align) { + case Font.BOTTOM: + y1 = y - height; + break; + case Font.BASELINE: + y1 = y - baseline; + break; + case Font.CENTER_VERTICAL: + y1 = y - Math.floor(height / 2); + break; + case Font.TOP: + default: + y1 = y; + break; + } + return [x1, y1, width, height]; + } } diff --git a/display-simulator/esphome-font.js b/display-simulator/esphome-font.js new file mode 100644 index 0000000..c64196b --- /dev/null +++ b/display-simulator/esphome-font.js @@ -0,0 +1,187 @@ +class Glyph { + // Code in this class was taken from esphome/components/font/font.cpp and translated to JavaScript + + constructor(data) { + this.glyph_data_ = data; + } + + draw(x, y, display, color) { + let x_at = Math.floor(x) + let y_start = Math.floor(y); + let scan_x1 = this.glyph_data_.offset_x; + let scan_y1 = this.glyph_data_.offset_y; + let scan_width = this.glyph_data_.width; + let scan_height = this.glyph_data_.height; + + let data = 0; + let max_x = x_at + scan_x1 + scan_width; + let max_y = y_start + scan_y1 + scan_height; + + for (let glyph_y = y_start + scan_y1; glyph_y < max_y; glyph_y++) { + for (let glyph_x = x_at + scan_x1; glyph_x < max_x; data++, glyph_x += 8) { + let pixel_data = this.glyph_data_.data[data]; + let pixel_max_x = Math.min(max_x, glyph_x + 8); + + for (let pixel_x = glyph_x; pixel_x < pixel_max_x && pixel_data; pixel_x++, pixel_data <<= 1) { + if (pixel_data & 0x80) { + display.draw_pixel_at(pixel_x, glyph_y, color); + } + } + } + } + } + + get_char() { + return this.glyph_data_.a_char; + } + + compare_to(str) { + // 1 -> this.char_ + // 2 -> str + for (let i = 0;; i++) { + if (this.glyph_data_.a_char.charCodeAt(i) == 0) + return true; + if (str.charCodeAt(i) == 0) + return false; + if (this.glyph_data_.a_char.charCodeAt(i) > str.charCodeAt(i)) + return false; + if (this.glyph_data_.a_char.charCodeAt(i) < str.charCodeAt(i)) + return true; + } + // this should not happen + return false; + } + + match_length(str) { + for (let i = 0;; i++) { + if (this.glyph_data_.a_char.charCodeAt(i) == 0) + return i; + if (str.charCodeAt(i) != this.glyph_data_.a_char.charCodeAt(i)) + return 0; + } + // this should not happen + return 0; + } +} + +class Font { + // Code in this class was taken from esphome/components/font/font.cpp and translated to JavaScript + static TOP = 0x00; + static CENTER_VERTICAL = 0x01; + static BASELINE = 0x02; + static BOTTOM = 0x04; + static LEFT = 0x00; + static CENTER_HORIZONTAL = 0x08; + static RIGHT = 0x10; + static TOP_LEFT = Font.TOP | Font.LEFT; + static TOP_CENTER = Font.TOP | Font.CENTER_HORIZONTAL; + static TOP_RIGHT = Font.TOP | Font.RIGHT; + static CENTER_LEFT = Font.CENTER_VERTICAL | Font.LEFT; + static CENTER = Font.CENTER_VERTICAL | Font.CENTER_HORIZONTAL; + static CENTER_RIGHT = Font.CENTER_VERTICAL | Font.RIGHT; + static BASELINE_LEFT = Font.BASELINE | Font.LEFT; + static BASELINE_CENTER = Font.BASELINE | Font.CENTER_HORIZONTAL; + static BASELINE_RIGHT = Font.BASELINE | Font.RIGHT; + static BOTTOM_LEFT = Font.BOTTOM | Font.LEFT; + static BOTTOM_CENTER = Font.BOTTOM | Font.CENTER_HORIZONTAL; + static BOTTOM_RIGHT = Font.BOTTOM | Font.RIGHT; + + constructor(data, data_nr, baseline, height) { + this.glyphs_ = []; + for (let i = 0; i < data.length; i++) { + this.glyphs_.push(new Glyph(data[i])); + } + + this.baseline_ = Math.floor(baseline); + this.height_ = Math.floor(height); + } + + match_next_glyph(str) { + let lo = 0; + let hi = this.glyphs_.length - 1; + while (lo != hi) { + let mid = Math.floor((lo + hi + 1) / 2); + if (this.glyphs_[mid].compare_to(str)) { + lo = mid; + } else { + hi = mid - 1; + } + } + let match_length = this.glyphs_[lo].match_length(str); + if (match_length <= 0) + return [-1, match_length]; + return [lo, match_length]; + } + + print(x_start, y_start, display, color, text) { + let i = 0; + let x_at = x_start; + if (text[text.length - 1] != "\u0000") { + text += "\u0000" + } + while (text[i] != "\u0000") { + let [glyph_n, match_length] = this.match_next_glyph(text.substring(i)); + if (glyph_n < 0) { + // Unknown char, skip + console.log("Encountered character without representation in font: '" + text[i] + "'"); + if (this.get_glyphs().length > 0) { + let glyph_width = this.get_glyphs()[0].glyph_data_.width; + display.filled_rectangle(x_at, y_start, glyph_width, this.height_, color); + x_at += glyph_width; + } + + i++; + continue; + } + + let glyph = this.get_glyphs()[glyph_n]; + glyph.draw(x_at, y_start, display, color); + x_at += glyph.glyph_data_.width + glyph.glyph_data_.offset_x; + i += match_length; + } + } + + measure(str) { + let i = 0; + let min_x = 0; + let has_char = false; + let x = 0; + if (str[str.length - 1] != "\u0000") { + str += "\u0000" + } + while (str[i] != "\u0000") { + let [glyph_n, match_length] = this.match_next_glyph(str.substring(i)); + if (glyph_n < 0) { + // Unknown char, skip + if (this.get_glyphs().length > 0) + x += this.get_glyphs()[0].glyph_data_.width; + i++; + continue; + } + + let glyph = this.glyphs_[glyph_n]; + if (!has_char) { + min_x = glyph.glyph_data_.offset_x; + } else { + min_x = Math.min(min_x, x + glyph.glyph_data_.offset_x); + } + x += glyph.glyph_data_.width + glyph.glyph_data_.offset_x; + + i += match_length; + has_char = true; + } + return [(x - min_x), min_x, this.baseline_, this.height_]; + } + + get_baseline() { + return this.baseline_; + } + + get_height() { + return this.height_; + } + + get_glyphs() { + return this.glyphs_; + } +} diff --git a/display-simulator/index.html b/display-simulator/index.html index 5cfadea..adbba26 100644 --- a/display-simulator/index.html +++ b/display-simulator/index.html @@ -5,6 +5,7 @@ Display Simulator for ESPHome +
@@ -40,6 +41,12 @@ // Draw a circle in the middle of the display it.circle(it.get_width() / 2, it.get_height() / 2, 16); +

+ Display Dimensions: + + x + +

To add resoucres like fonts or images first add them to an ESPHome project and compile it. Then load the generated file located at ".esphome/build/your-project/src/main.cpp".

@@ -95,6 +102,10 @@ this.hex_regex = /0x[0-9A-Fa-f]{2}/gm; this.image_init_regex = /(?[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*new[ \t]*image::Image\((?[A-Za-z_][A-Za-z0-9_]*)[ \t]*,[ \t]*(?[0-9]+)[ \t]*,[ \t]*(?[0-9]+)[ \t]*,[ \t]*image::(?[A-Z0-9_]*)[ \t]*\);/gm; this.image_transparency_regex = /(?[A-Za-z_][A-Za-z0-9_]*)[ \t]*->set_transparency\([[ \t]*(?(true|false))[[ \t]*\);/gm; + this.glyphdata_array_regex = /(((static)|(const))[ \t]+)+(font::GlyphData)[ \t]+(?[A-Za-z_][A-Za-z0-9_]*)\[[0-9]*\][ \t]*(PROGMEM[ \t]*)?=[ \t\n]*{[ \t\n]*(?(.|\n)*?)[ \t\n]*};/gm; + this.glyphdata_regex = /font::GlyphData[ \t]*{[ \t\n]*\.a_char[ \t]*=[ \t]*"(?.*?)"[ \t]*,[ \t\n]*\.data[ \t]*=[ \t]*(?[A-Za-z_][A-Za-z0-9_]*)[ \t\n]*\+[ \t\n]*(?[0-9]*)[ \t]*,[ \t\n]*\.offset_x[ \t]*=[ \t]*(?[0-9\-]*)[ \t]*,[ \t\n]*\.offset_y[ \t]*=[ \t]*(?[0-9\-]*)[ \t]*,[ \t\n]*\.width[ \t]*=[ \t]*(?[0-9]*)[ \t]*,[ \t\n]*\.height[ \t]*=[ \t]*(?[0-9]*)[ \t]*,?[ \t\n]*}/gm; + this.font_init_regex = /(?[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*new[ \t]*font::Font\((?[A-Za-z_][A-Za-z0-9_]*)[ \t]*,[ \t]*(?[0-9]+)[ \t]*,[ \t]*(?[0-9]+)[ \t]*,[ \t]*(?[A-Z0-9_]*)[ \t]*\);/gm; + this.octal_regex = /\\(?[0-7]{1,3})/gm; this.resources = {}; this.file_input.onchange = e => { let file = e.target.files[0]; @@ -103,7 +114,10 @@ reader.onload = readerEvent => { let content = readerEvent.target.result; this.file_input.value = ""; - var count = this.load_images(content); + let data_arrays = this.load_data_arrays(content); + let glyphdata_arrays = this.load_glyphdata_arrays + let count = this.load_images(content, data_arrays); + count += this.load_fonts(content, data_arrays); if (count == 0) { alert("The selected file contains no supported resources."); } @@ -112,7 +126,7 @@ } } - load_images(content) { + load_data_arrays(content) { let data_arrays = {}; let data_matches = [...content.matchAll(this.data_array_regex)]; for (let i = 0; i < data_matches.length; i++) { @@ -125,12 +139,17 @@ data_arrays[data_matches[i].groups["data_id"]] = data_array; } + return data_arrays; + } + + load_images(content, data_arrays) { let images = {}; let count = 0; let image_matches = [...content.matchAll(this.image_init_regex)]; for (let i = 0; i < image_matches.length; i++) { let groups = image_matches[i].groups; if (!(groups["data_id"] in data_arrays)) { + console.log("Data array not found, skipping image \"" + groups["image_id"] + "\""); continue; } let image = {} @@ -162,9 +181,90 @@ return count; } + load_glyphdata_arrays(content, data_arrays) { + let glyphdata_arrays = {}; + let glyphdata_array_matches = [...content.matchAll(this.glyphdata_array_regex)]; + let data_array_id = null; + for (let i = 0; i < glyphdata_array_matches.length; i++) { + let glyphdata_matches = [...glyphdata_array_matches[i].groups["data"].matchAll(this.glyphdata_regex)]; + let glyphdata_array = []; + let debug_sum = 0; + let debug_predicted_index = 0; + + for (let j = 0; j < glyphdata_matches.length; j++) { + let groups = glyphdata_matches[j].groups; + if (!(groups["data_id"] in data_arrays)) { + console.log("Data array not found, skipping glyph \"" + glyphdata["a_char"] + "\""); + continue; + } + let glyphdata = {}; + let a_char = groups["a_char"] + if (a_char.startsWith("\\") && (a_char.length > 1)) { + let octal_matches = [...a_char.matchAll(this.octal_regex)]; + let char_array = new Uint8Array(octal_matches.length); + let utf8decoder = new TextDecoder() + for (let k = 0; k < octal_matches.length; k++) { + char_array[k] = parseInt(octal_matches[k].groups["octal"], 8); + } + a_char = utf8decoder.decode(char_array); + } + glyphdata["a_char"] = a_char + "\u0000"; + glyphdata["offset_x"] = parseInt(groups["offset_x"]); + glyphdata["offset_y"] = parseInt(groups["offset_y"]); + glyphdata["width"] = parseInt(groups["width"]); + glyphdata["height"] = parseInt(groups["height"]); + let data_length = Math.ceil(glyphdata["width"] / 8) * glyphdata["height"]; + let data = new Uint8Array(data_length); + for (let k = 0; k < data_length; k++) { + data[k] = data_arrays[groups["data_id"]][parseInt(groups["data_offset"]) + k]; + } + glyphdata["data"] = data; + glyphdata_array[j] = glyphdata; + } + glyphdata_arrays[glyphdata_array_matches[i].groups["data_id"]] = glyphdata_array; + } + return glyphdata_arrays; + } + + load_fonts(content, data_arrays) { + let glyphdata_arrays = this.load_glyphdata_arrays(content, data_arrays); + let fonts = {}; + let count = 0; + let font_matches = [...content.matchAll(this.font_init_regex)]; + for (let i = 0; i < font_matches.length; i++) { + let groups = font_matches[i].groups; + if (!(groups["glyphdata_id"] in glyphdata_arrays)) { + console.log("Glyphdata array not found, skipping font \"" + groups["font_id"] + "\""); + continue; + } + let font = {} + font["class"] = "font"; + font["id"] = groups["font_id"]; + font["data"] = glyphdata_arrays[groups["glyphdata_id"]]; + font["data_nr"] = font["data"].length; + if (font["data_nr"] < groups["data_nr"]) { + console.error("Some glyphs not loaded for font \"" + font["id"] + "\" (" + font["data_nr"] + "/" + groups["data_nr"] + ")"); + } + font["baseline"] = parseInt(groups["baseline"]); + font["height"] = parseInt(groups["height"]); + fonts[font["id"]] = font; + count++; + } + for (const [key, value] of Object.entries(fonts)) { + if (key in this.resources) { + alert("Replacing existing resource \"" + key + "\" with font (" + value["data_nr"] + " glyphs, size " + (value["baseline"] + 1) + ")"); + } else { + alert("Loading new font resource \"" + key + "\" (" + value["data_nr"] + " glyphs, size " + (value["baseline"] + 1) + ")"); + } + this.resources[key] = value; + } + return count; + } + get_resource(id) { if (id in this.resources) { let r = this.resources[id]; + let obj; switch (r["class"]) { case "image": let type = Image.IMAGE_TYPE_BINARY; @@ -177,9 +277,12 @@ } else if (r["type"] == "IMAGE_TYPE_RGBA") { type = Image.IMAGE_TYPE_RGBA; } - let obj = new Image(r["data"], r["width"], r["height"], type); + obj = new Image(r["data"], r["width"], r["height"], type); obj.set_transparency(r["transparent"]); return obj; + case "font": + obj = new Font(r["data"], r["data_nr"], r["baseline"], r["height"]); + return obj; } } throw new Error("Resource \"" + id + "\" not found. Load it with the button below."); @@ -190,14 +293,28 @@ function run_code() { "use strict"; + let width = Math.floor(parseInt(document.getElementById("display_width").value)); + let height = Math.floor(parseInt(document.getElementById("display_height").value)); + + if (isNaN(width) || width < 1) { + width = 1; + } + if (isNaN(height) || height < 1) { + height = 1; + } + let code = document.getElementById("code").value let id_regex = /id\([ \t]*(?[A-Za-z_][A-Za-z0-9_]*)[ \t]*\)/gm; let id_replacement = "id(\"$\")"; + let unicode_regex = /\\U(?[0-9A-Fa-f]{1,8})/gm; + let unicode_replacement = "\\u{$}"; code = code.replace(id_regex, id_replacement); - + code = code.replace(unicode_regex, unicode_replacement); + code = code.replaceAll("ImageAlign::", "Image."); + code = code.replaceAll("TextAlign::", "Font."); code = "\"use strict\";\ - let it = new CanvasDisplay(document.getElementById(\"display\"), 128, 64, 5, 0.05);\ + let it = new CanvasDisplay(document.getElementById(\"display\"), " + width + ", " + height + ", 5, 0.05);\ let COLOR_OFF = it.COLOR_OFF;\ let COLOR_ON = it.COLOR_ON;\ function id(id) {return resource_manager.get_resource(id);}\ @@ -286,12 +403,12 @@ }); // Cursor position -document.getElementById("display").addEventListener("mousemove", function(evt) { +document.getElementById("display").addEventListener("mousemove", function(e) { let display = document.getElementById("display"); let status_message = document.getElementById("status_message"); let rect = display.getBoundingClientRect(); - let x = Math.floor((evt.clientX - rect.left) / display.dataset.scale); - let y = Math.floor((evt.clientY - rect.top) / display.dataset.scale); + let x = Math.floor((e.clientX - rect.left) / display.dataset.scale); + let y = Math.floor((e.clientY - rect.top) / display.dataset.scale); status_message.textContent = x + ", " + y; status_message.style.color = "#fff"; status_message.style.margin = (2 * display.dataset.scale) + "px";