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