diff --git a/tasmota/tasmota_support/support.ino b/tasmota/tasmota_support/support.ino
index 331d91533b08..f7111b5b6b40 100644
--- a/tasmota/tasmota_support/support.ino
+++ b/tasmota/tasmota_support/support.ino
@@ -2953,6 +2953,44 @@ String SettingsTextEscaped(uint32_t index) {
return HtmlEscape(SettingsText(index));
}
+// Truncate src to max UTF-8 codepoints with optional max visual width.
+// max_codepoints limits codepoints (0 = no limit)
+// max_width limits *approximated* visual width (0 = no limit):
+// narrow chars (1-2 byte: ASCII, Latin, Cyrillic) approximated to cost 1 width unit
+// wide chars (3-4 byte: CJK, emoji) approximated to cost 2 width units
+// Multi-byte characters are never split mid-sequence
+// ZWJ sequences and skin tone modifiers are not recognized as single glyphs and may be split
+String Utf8Truncate(const char *src, uint32_t max_codepoints, uint32_t max_width = 0) {
+ if (!max_codepoints && !max_width) {
+ return String(src);
+ }
+ size_t slen = strlen(src);
+ size_t bytes = 0;
+ size_t width = 0;
+ size_t chars = 0;
+ while (bytes < slen) {
+ if (max_codepoints && chars >= max_codepoints) { break; }
+ uint8_t lead = (uint8_t)src[bytes];
+ size_t clen = 1;
+ if (lead >= 0xF0) { clen = 4; }
+ else if (lead >= 0xE0) { clen = 3; }
+ else if (lead >= 0xC0) { clen = 2; }
+ size_t cwidth = (clen >= 3) ? 2 : 1;
+ if (max_width && (width + cwidth > max_width)) { break; }
+ if (bytes + clen > slen) { break; }
+ width += cwidth;
+ bytes += clen;
+ chars++;
+ }
+ if (!bytes) {
+ return String();
+ }
+ String result;
+ result.reserve(bytes);
+ result.concat(src, bytes);
+ return result;
+}
+
String UrlEscape(const char *unescaped) {
static const char *hex = "0123456789ABCDEF";
String result;
diff --git a/tasmota/tasmota_xdrv_driver/xdrv_01_9_webserver.ino b/tasmota/tasmota_xdrv_driver/xdrv_01_9_webserver.ino
index 6d20ec68bd9a..3b26c1c17240 100644
--- a/tasmota/tasmota_xdrv_driver/xdrv_01_9_webserver.ino
+++ b/tasmota/tasmota_xdrv_driver/xdrv_01_9_webserver.ino
@@ -1491,11 +1491,12 @@ void HandleRoot(void) {
uint32_t button_ptr = 0;
for (uint32_t button_idx = 1; button_idx <= TasmotaGlobal.devices_present; button_idx++) {
if (bitRead(Web.light_shutter_button_mask, button_idx -1)) { continue; } // Skip non-sequential light and/or shutter button
- bool set_button = ((button_idx <= MAX_BUTTON_TEXT) && strlen(GetWebButton(button_idx -1)));
+ const char* web_button = (button_idx <= MAX_BUTTON_TEXT) ? GetWebButton(button_idx -1) : "";
+ bool has_web_button = (web_button[0] != '\0');
snprintf_P(stemp, sizeof(stemp), PSTR(" %d"), button_idx);
WSContentSend_P(HTTP_DEVICE_CONTROL, 100 / cols, button_idx, button_idx,
- (set_button) ? HtmlEscape(GetWebButton(button_idx -1)).c_str() : (cols < 5) ? PSTR(D_BUTTON_TOGGLE) : "",
- (set_button) ? "" : (TasmotaGlobal.devices_present > 1) ? stemp : "");
+ (has_web_button) ? HtmlEscape(web_button).c_str() : (cols < 5) ? PSTR(D_BUTTON_TOGGLE) : "",
+ (has_web_button) ? "" : (TasmotaGlobal.devices_present > 1) ? stemp : "");
button_ptr++;
if (0 == button_ptr % cols) { WSContentSend_P(PSTR("
")); }
}
@@ -1526,12 +1527,13 @@ void HandleRoot(void) {
if (1 == j) { break; } // Both buttons shown
shutter_button_idx--; // Right button is previous button (up)
- bool set_button = ((shutter_button_idx <= MAX_BUTTON_TEXT) && strlen(GetWebButton(shutter_button_idx -1)));
+ const char* web_button = (shutter_button_idx <= MAX_BUTTON_TEXT) ? GetWebButton(shutter_button_idx -1) : "";
+ bool has_web_button = (web_button[0] != '\0');
snprintf_P(stemp, sizeof(stemp), PSTR("Shutter %d"), shutter_idx +1);
uint32_t shutter_real_to_percent_position = ShutterRealToPercentPosition(-9999, shutter_idx);
Web.shutter_slider[shutter_idx] = (shutter_options & 1) ? (100 - shutter_real_to_percent_position) : shutter_real_to_percent_position;
WSContentSend_P(HTTP_MSG_SLIDER_SHUTTER,
- (set_button) ? HtmlEscape(GetWebButton(shutter_button_idx -1)).c_str() : stemp,
+ (has_web_button) ? HtmlEscape(web_button).c_str() : stemp,
shutter_idx +1,
Web.shutter_slider[shutter_idx],
shutter_idx +1);
@@ -1593,18 +1595,18 @@ void HandleRoot(void) {
Web.slider[2],
'n', 0); // n0 - Value id
WSContentSend_P(PSTR("
"));
- }
+ }
- bool set_button = ((button_idx <= MAX_BUTTON_TEXT) && strlen(GetWebButton(button_idx -1)));
- char first[2];
- snprintf_P(first, sizeof(first), PSTR("%s"), PSTR(D_BUTTON_TOGGLE));
- char butt_txt[4];
- snprintf_P(butt_txt, sizeof(butt_txt), PSTR("%s"), (set_button) ? HtmlEscape(GetWebButton(button_idx -1)).c_str() : first);
+ const char* web_button = (button_idx <= MAX_BUTTON_TEXT) ? GetWebButton(button_idx -1) : "";
+ bool has_web_button = (web_button[0] != '\0');
+ // web_button non-empty: truncate to max 4 chars or "visual width" of 4 (e.g. "Ligh", "💡💡", "台灯"). Latin char = 1 width, CJK/emoji char = 2 width
+ // web_button empty: take first char of D_BUTTON_TOGGLE + button index (e.g. "T1", "П1", "开1")
+ String butt_text = (has_web_button) ? HtmlEscape(Utf8Truncate(web_button, 4, 4)) : Utf8Truncate(D_BUTTON_TOGGLE, 1);
char number[8];
WSContentSend_P(PSTR(""));
WSContentSend_P(HTTP_DEVICE_CONTROL, 15, button_idx, button_idx,
- butt_txt,
- (set_button) ? "" : itoa(button_idx, number, 10));
+ butt_text.c_str(),
+ (has_web_button) ? "" : itoa(button_idx, number, 10));
button_idx++;
Web.slider[3] = Settings->light_dimmer;
@@ -1627,15 +1629,15 @@ void HandleRoot(void) {
WSContentSend_P(PSTR("
"));
if (button_idx < (light_device + light_devices)) {
- bool set_button = ((button_idx <= MAX_BUTTON_TEXT) && strlen(GetWebButton(button_idx -1)));
- char first[2];
- snprintf_P(first, sizeof(first), PSTR("%s"), PSTR(D_BUTTON_TOGGLE));
- char butt_txt[4];
- snprintf_P(butt_txt, sizeof(butt_txt), PSTR("%s"), (set_button) ? HtmlEscape(GetWebButton(button_idx -1)).c_str() : first);
+ const char* web_button = (button_idx <= MAX_BUTTON_TEXT) ? GetWebButton(button_idx -1) : "";
+ bool has_web_button = (web_button[0] != '\0');
+ // web_button non-empty: truncate to max 4 chars or max "visual width" of 4 (e.g. "Ligh", "💡💡", "台灯"). Latin char = 1 width, CJK/emoji char = 2 width
+ // web_button empty: take first char of D_BUTTON_TOGGLE + button index (e.g. "T1", "П1", "开1")
+ String butt_text = (has_web_button) ? HtmlEscape(Utf8Truncate(web_button, 4, 4)) : Utf8Truncate(D_BUTTON_TOGGLE, 1);
char number[8];
WSContentSend_P(HTTP_DEVICE_CONTROL, 15, button_idx, button_idx,
- butt_txt,
- (set_button) ? "" : itoa(button_idx, number, 10));
+ butt_text.c_str(),
+ (has_web_button) ? "" : itoa(button_idx, number, 10));
button_idx++;
width = 85;
}
@@ -1654,17 +1656,16 @@ void HandleRoot(void) {
} else { // Settings->flag3.pwm_multi_channels - SetOption68 1 - Enable multi-channels PWM instead of Color PWM
stemp[0] = 'e'; stemp[1] = '0'; stemp[2] = '\0'; // e0
for (uint32_t i = 0; i < light_devices; i++) {
- bool set_button = ((button_idx <= MAX_BUTTON_TEXT) && strlen(GetWebButton(button_idx -1)));
- char first[2];
- snprintf_P(first, sizeof(first), PSTR("%s"), PSTR(D_BUTTON_TOGGLE));
- char butt_txt[4];
- snprintf_P(butt_txt, sizeof(butt_txt), PSTR("%s"),
- (set_button) ? HtmlEscape(GetWebButton(button_idx -1)).c_str() : first);
+ const char* web_button = (button_idx <= MAX_BUTTON_TEXT) ? GetWebButton(button_idx -1) : "";
+ bool has_web_button = (web_button[0] != '\0');
+ // web_button non-empty: truncate to max 4 chars or "visual width" of 4 (e.g. "Ligh", "💡💡", "台灯"). Latin char = 1 width, CJK/emoji char = 2 width
+ // web_button empty: take first char of D_BUTTON_TOGGLE + button index (e.g. "T1", "П1", "开1")
+ String butt_text = (has_web_button) ? HtmlEscape(Utf8Truncate(web_button, 4, 4)) : Utf8Truncate(D_BUTTON_TOGGLE, 1);
char number[8];
WSContentSend_P(PSTR("
"));
WSContentSend_P(HTTP_DEVICE_CONTROL, 15, button_idx, button_idx,
- butt_txt,
- (set_button) ? "" : itoa(button_idx, number, 10));
+ butt_text.c_str(),
+ (has_web_button) ? "" : itoa(button_idx, number, 10));
button_idx++;
stemp[1]++; // e1 to e5 - Make unique ids