#include "configuration.h" #if HAS_SCREEN #include "graphics/EmoteRenderer.h" #include #include namespace graphics { namespace EmoteRenderer { static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len) { #if defined(OLED_UA) || defined(OLED_RU) return display->getStringWidth(text, len, true); #else // OLEDDisplay::getStringWidth (utf8=false) indexes the font jump table by (c - firstChar) with a // signed char and no bounds check, so any byte outside printable ASCII (high bytes, but also // control bytes like a stray 0x0A) reads outside the font array. Measure a sanitized copy in which // unrepresentable bytes count as a '?' placeholder; printable ASCII is passed through unchanged. char safe[64]; if (len > sizeof(safe) - 1) len = sizeof(safe) - 1; for (size_t i = 0; i < len; i++) { const uint8_t c = static_cast(text[i]); safe[i] = (c >= 0x20 && c <= 0x7E) ? static_cast(c) : '?'; } safe[len] = '\0'; return display->getStringWidth(safe, len, false); #endif } size_t utf8CharLen(uint8_t c) { if ((c & 0xE0) == 0xC0) return 2; if ((c & 0xF0) == 0xE0) return 3; if ((c & 0xF8) == 0xF0) return 4; return 1; } // Bytes the UTF-8 char at s[pos] occupies, clamped to what actually remains. A truncated multi-byte // lead (e.g. a lone 0xF0) near the end otherwise makes a walker read past the buffer - the payload of // a TEXT message is opaque bytes, so such truncated sequences are attacker-reachable. static inline size_t utf8CharLenClamped(const char *s, size_t pos, size_t len) { return std::min(utf8CharLen(static_cast(s[pos])), len - pos); } static inline bool isPossibleEmoteLead(uint8_t c) { // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges. return c == 0xE2 || c == 0xF0; } static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len) { char chunk[5] = {0, 0, 0, 0, 0}; if (len > 4) len = 4; memcpy(chunk, text, len); return getStringWidth(display, chunk, len); } static inline bool isFE0FAt(const char *s, size_t pos, size_t len) { return pos + 2 < len && static_cast(s[pos]) == 0xEF && static_cast(s[pos + 1]) == 0xB8 && static_cast(s[pos + 2]) == 0x8F; } static inline bool isSkinToneAt(const char *s, size_t pos, size_t len) { return pos + 3 < len && static_cast(s[pos]) == 0xF0 && static_cast(s[pos + 1]) == 0x9F && static_cast(s[pos + 2]) == 0x8F && (static_cast(s[pos + 3]) >= 0xBB && static_cast(s[pos + 3]) <= 0xBF); } static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len) { // Skip modifiers that do not change which bitmap we render. if (isFE0FAt(s, pos, len)) return 3; if (isSkinToneAt(s, pos, len)) return 4; return 0; } const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount) { if (!label || !*label) return nullptr; for (int i = 0; i < emoteCount; ++i) { if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0) return &emoteSet[i]; } return nullptr; } static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed, size_t &matchScore) { // Treat FE0F and skin-tone modifiers as optional while matching. textConsumed = 0; matchScore = 0; if (!label || !*label || pos >= textLen) return false; const size_t labelLen = strlen(label); size_t ti = pos; size_t li = 0; while (true) { while (ti < textLen) { const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); if (!skipLen) break; ti += skipLen; } while (li < labelLen) { const size_t skipLen = ignorableModifierLenAt(label, li, labelLen); if (!skipLen) break; li += skipLen; } if (li >= labelLen) { while (ti < textLen) { const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); if (!skipLen) break; ti += skipLen; } textConsumed = ti - pos; return textConsumed > 0; } if (ti >= textLen) return false; const uint8_t tc = static_cast(text[ti]); const uint8_t lc = static_cast(label[li]); const size_t tlen = utf8CharLen(tc); const size_t llen = utf8CharLen(lc); if (tlen != llen || ti + tlen > textLen || li + llen > labelLen) return false; if (memcmp(text + ti, label + li, tlen) != 0) return false; ti += tlen; li += llen; matchScore += llen; } } const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount) { // Prefer the longest matching label at this byte offset. const Emote *matched = nullptr; matchLen = 0; size_t bestScore = 0; if (!text || pos >= textLen) return nullptr; if (!isPossibleEmoteLead(static_cast(text[pos]))) return nullptr; for (int i = 0; i < emoteCount; ++i) { const char *label = emoteSet[i].label; if (!label || !*label) continue; if (static_cast(label[0]) != static_cast(text[pos])) continue; const size_t labelLen = strlen(label); if (labelLen == 0) continue; size_t candidateLen = 0; size_t candidateScore = 0; if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) { candidateLen = labelLen; candidateScore = labelLen; } else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) { // Matched with FE0F/skin tone modifiers treated as optional. } else { continue; } if (candidateScore > bestScore) { matched = &emoteSet[i]; matchLen = candidateLen; bestScore = candidateScore; } } return matched; } static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight, const Emote *emoteSet, int emoteCount, int emoteSpacing) { // Scan once to collect width and tallest emote for this line. LineMetrics metrics{0, fallbackHeight, false}; if (!line) return metrics; for (size_t i = 0; i < lineLen;) { size_t matchLen = 0; const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); if (matched) { metrics.hasEmote = true; metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height); if (display) metrics.width += matched->width + emoteSpacing; i += matchLen; continue; } const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); if (skipLen) { i += skipLen; continue; } const size_t charLen = utf8CharLenClamped(line, i, lineLen); if (display) metrics.width += getUtf8ChunkWidth(display, line + i, charLen); i += charLen; } return metrics; } LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount, int emoteSpacing) { return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing); } int maxEmoteHeight(const Emote *emoteSet, int emoteCount) { int tallest = 0; for (int i = 0; i < emoteCount; ++i) { if (emoteSet[i].label && *emoteSet[i].label) tallest = std::max(tallest, emoteSet[i].height); } return tallest; } int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing) { if (!display) return 0; if (!line || !*line) return 0; return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width; } static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw, bool fauxBold) { // Draw plain-text runs in chunks so UTF-8 stays intact. if (!text || len == 0) return cursorX; char chunk[33]; size_t pos = 0; while (pos < len) { size_t chunkLen = 0; while (pos + chunkLen < len) { const size_t charLen = utf8CharLenClamped(text, pos + chunkLen, len); if (chunkLen + charLen >= sizeof(chunk)) break; chunkLen += charLen; } if (chunkLen == 0) { chunkLen = std::min(len - pos, sizeof(chunk) - 1); } memcpy(chunk, text + pos, chunkLen); chunk[chunkLen] = '\0'; if (draw) { if (fauxBold) display->drawString(cursorX + 1, fontY, chunk); display->drawString(cursorX, fontY, chunk); } cursorX += getStringWidth(display, chunk, chunkLen); pos += chunkLen; } return cursorX; } size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis, const Emote *emoteSet, int emoteCount, int emoteSpacing) { if (!out || outSize == 0) return 0; out[0] = '\0'; if (!display || !line || maxWidth <= 0) return 0; const size_t lineLen = strlen(line); const int suffixWidth = (ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0; const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : ""; const size_t suffixLen = strlen(suffix); const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0); if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) { strncpy(out, line, outSize - 1); out[outSize - 1] = '\0'; return strlen(out); } int used = 0; size_t cut = 0; for (size_t i = 0; i < lineLen;) { // Keep whole emotes together when deciding where to cut. int tokenWidth = 0; size_t advance = 0; if (isPossibleEmoteLead(static_cast(line[i]))) { size_t matchLen = 0; const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); if (matched) { tokenWidth = matched->width + emoteSpacing; advance = matchLen; } } if (advance == 0) { const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); if (skipLen) { i += skipLen; cut = i; continue; } const size_t charLen = utf8CharLenClamped(line, i, lineLen); tokenWidth = getUtf8ChunkWidth(display, line + i, charLen); advance = charLen; } if (used + tokenWidth > availableWidth) break; used += tokenWidth; i += advance; cut = i; } if (cut == 0) { strncpy(out, suffix, outSize - 1); out[outSize - 1] = '\0'; return strlen(out); } size_t copyLen = cut; if (copyLen > outSize - 1) copyLen = outSize - 1; if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) { copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0; } memcpy(out, line, copyLen); size_t totalLen = copyLen; if (suffixLen > 0 && totalLen < outSize - 1) { memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen)); totalLen += std::min(suffixLen, outSize - 1 - totalLen); } out[totalLen] = '\0'; return totalLen; } void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet, int emoteCount, int emoteSpacing, bool fauxBold) { if (!line) return; const size_t lineLen = strlen(line); // Center text vertically when any emote is taller than the font. const int maxIconHeight = analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight; const int lineHeight = std::max(fontHeight, maxIconHeight); const int fontY = y + (lineHeight - fontHeight) / 2; int cursorX = x; bool inBold = false; for (size_t i = 0; i < lineLen;) { // Toggle faux bold. if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') { inBold = !inBold; i += 2; continue; } const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); if (skipLen) { i += skipLen; continue; } size_t matchLen = 0; const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); if (matched) { const int iconY = y + (lineHeight - matched->height) / 2; display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap); cursorX += matched->width + emoteSpacing; i += matchLen; continue; } size_t next = i; while (next < lineLen) { // Stop the text run before the next emote or bold marker. if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*') break; if (ignorableModifierLenAt(line, next, lineLen)) break; size_t nextMatchLen = 0; if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr) break; next += utf8CharLenClamped(line, next, lineLen); } if (next == i) next += utf8CharLenClamped(line, i, lineLen); cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold); i = next; } } } // namespace EmoteRenderer } // namespace graphics #endif // HAS_SCREEN