#include "DictionaryDefinitionActivity.h" #include #include #include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" void DictionaryDefinitionActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void DictionaryDefinitionActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); renderScreen(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void DictionaryDefinitionActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); wrapText(); updateRequired = true; xTaskCreate(&DictionaryDefinitionActivity::taskTrampoline, "DictDefTask", 4096, this, 1, &displayTaskHandle); } void DictionaryDefinitionActivity::onExit() { Activity::onExit(); xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; } // --------------------------------------------------------------------------- // Check if a Unicode codepoint is likely renderable by the e-ink bitmap font. // Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols. // Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts. // --------------------------------------------------------------------------- bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) { if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows return false; } // --------------------------------------------------------------------------- // HTML entity decoder // --------------------------------------------------------------------------- std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) { // Named entities if (entity == "amp") return "&"; if (entity == "lt") return "<"; if (entity == "gt") return ">"; if (entity == "quot") return "\""; if (entity == "apos") return "'"; if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " "; if (entity == "ndash") return "\xE2\x80\x93"; // U+2013 if (entity == "mdash") return "\xE2\x80\x94"; // U+2014 if (entity == "lsquo") return "\xE2\x80\x98"; if (entity == "rsquo") return "\xE2\x80\x99"; if (entity == "ldquo") return "\xE2\x80\x9C"; if (entity == "rdquo") return "\xE2\x80\x9D"; if (entity == "hellip") return "\xE2\x80\xA6"; if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return ""; // Numeric entities: { or  if (!entity.empty() && entity[0] == '#') { unsigned long cp = 0; if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) { cp = std::strtoul(entity.c_str() + 2, nullptr, 16); } else { cp = std::strtoul(entity.c_str() + 1, nullptr, 10); } if (cp > 0 && cp < 0x80) { return std::string(1, static_cast(cp)); } if (cp >= 0x80 && cp < 0x800) { char buf[3] = {static_cast(0xC0 | (cp >> 6)), static_cast(0x80 | (cp & 0x3F)), '\0'}; return std::string(buf, 2); } if (cp >= 0x800 && cp < 0x10000) { char buf[4] = {static_cast(0xE0 | (cp >> 12)), static_cast(0x80 | ((cp >> 6) & 0x3F)), static_cast(0x80 | (cp & 0x3F)), '\0'}; return std::string(buf, 3); } if (cp >= 0x10000 && cp < 0x110000) { char buf[5] = {static_cast(0xF0 | (cp >> 18)), static_cast(0x80 | ((cp >> 12) & 0x3F)), static_cast(0x80 | ((cp >> 6) & 0x3F)), static_cast(0x80 | (cp & 0x3F)), '\0'}; return std::string(buf, 4); } } return ""; // unknown entity — drop it } // --------------------------------------------------------------------------- // HTML → TextAtom list // --------------------------------------------------------------------------- std::vector DictionaryDefinitionActivity::parseHtml(const std::string& html) { std::vector atoms; bool isBold = false; bool isItalic = false; bool inSvg = false; int svgDepth = 0; std::vector listStack; std::string currentWord; auto currentStyle = [&]() -> EpdFontFamily::Style { if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC; if (isBold) return EpdFontFamily::BOLD; if (isItalic) return EpdFontFamily::ITALIC; return EpdFontFamily::REGULAR; }; auto flushWord = [&]() { if (!currentWord.empty() && !inSvg) { atoms.push_back({currentWord, currentStyle(), false, 0}); currentWord.clear(); } }; auto indentPx = [&]() -> int { // 15 pixels per nesting level (the first level has no extra indent) int depth = static_cast(listStack.size()); return (depth > 1) ? (depth - 1) * 15 : 0; }; // Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/") // that appears before the first tag in sametypesequence=h entries. size_t i = 0; { size_t firstTag = html.find('<'); if (firstTag != std::string::npos) i = firstTag; } while (i < html.size()) { // ------- HTML tag ------- if (html[i] == '<') { flushWord(); size_t tagEnd = html.find('>', i); if (tagEnd == std::string::npos) break; std::string tagContent = html.substr(i + 1, tagEnd - i - 1); // Extract tag name: first token, lowercased, trailing '/' stripped. size_t space = tagContent.find(' '); std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent; for (auto& c : tagName) c = static_cast(std::tolower(static_cast(c))); if (!tagName.empty() && tagName.back() == '/') tagName.pop_back(); // --- SVG handling (skip all content inside ) --- if (tagName == "svg") { inSvg = true; svgDepth = 1; } else if (inSvg) { if (tagName == "svg") { svgDepth++; } else if (tagName == "/svg") { svgDepth--; if (svgDepth <= 0) inSvg = false; } } if (!inSvg) { // --- Inline style tags --- if (tagName == "b" || tagName == "strong") { isBold = true; } else if (tagName == "/b" || tagName == "/strong") { isBold = false; } else if (tagName == "i" || tagName == "em") { isItalic = true; } else if (tagName == "/i" || tagName == "/em") { isItalic = false; // --- Block-level tags → newlines --- } else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") { atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()}); // Headings get bold style applied to following text if (tagName != "p") isBold = true; } else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") { atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()}); isBold = false; } else if (tagName == "br") { atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()}); // --- Separator between definition entries --- } else if (tagName == "/html") { atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line isBold = false; isItalic = false; // Skip any raw text between and the next tag — this is where // pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary. size_t nextTag = html.find('<', tagEnd + 1); i = (nextTag != std::string::npos) ? nextTag : html.size(); continue; // --- Lists --- } else if (tagName == "ol") { bool alpha = tagContent.find("lower-alpha") != std::string::npos; listStack.push_back({0, alpha}); } else if (tagName == "ul") { listStack.push_back({0, false}); } else if (tagName == "/ol" || tagName == "/ul") { if (!listStack.empty()) listStack.pop_back(); } else if (tagName == "li") { atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()}); if (!listStack.empty()) { auto& ls = listStack.back(); ls.counter++; std::string marker; if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) { marker = std::string(1, static_cast('a' + ls.counter - 1)) + ". "; } else if (ls.isAlpha) { marker = std::to_string(ls.counter) + ". "; } else { marker = std::to_string(ls.counter) + ". "; } atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0}); } else { // Unordered list or bare
  • atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0}); } } // All other tags (span, div, code, sup, sub, table, etc.) are silently ignored; // their text content will still be emitted. } i = tagEnd + 1; continue; } // Skip content inside SVG if (inSvg) { i++; continue; } // ------- HTML entity ------- if (html[i] == '&') { size_t semicolon = html.find(';', i); if (semicolon != std::string::npos && semicolon - i < 16) { std::string entity = html.substr(i + 1, semicolon - i - 1); std::string decoded = decodeEntity(entity); if (!decoded.empty()) { // Treat decoded chars like normal text (could be space etc.) for (char dc : decoded) { if (dc == ' ') { flushWord(); } else { currentWord += dc; } } } i = semicolon + 1; continue; } // Not a valid entity — emit '&' literally currentWord += '&'; i++; continue; } // ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) ------- if (html[i] == '/' || html[i] == '[') { char closeDelim = (html[i] == '/') ? '/' : ']'; size_t end = html.find(closeDelim, i + 1); if (end != std::string::npos && end - i < 80) { bool hasNonAscii = false; for (size_t j = i + 1; j < end; j++) { if (static_cast(html[j]) > 127) { hasNonAscii = true; break; } } if (hasNonAscii) { flushWord(); i = end + 1; // skip entire IPA section including delimiters continue; } } // Not IPA — fall through to treat as regular character } // ------- Whitespace ------- if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') { flushWord(); i++; continue; } // ------- Regular character (with non-renderable character filter) ------- { unsigned char byte = static_cast(html[i]); if (byte < 0x80) { // ASCII — always renderable currentWord += html[i]; i++; } else { // Multi-byte UTF-8: decode codepoint and check if renderable int seqLen = 1; uint32_t cp = 0; if ((byte & 0xE0) == 0xC0) { seqLen = 2; cp = byte & 0x1F; } else if ((byte & 0xF0) == 0xE0) { seqLen = 3; cp = byte & 0x0F; } else if ((byte & 0xF8) == 0xF0) { seqLen = 4; cp = byte & 0x07; } else { i++; continue; } // invalid start byte if (i + static_cast(seqLen) > html.size()) { i++; continue; } bool valid = true; for (int j = 1; j < seqLen; j++) { unsigned char cb = static_cast(html[i + j]); if ((cb & 0xC0) != 0x80) { valid = false; break; } cp = (cp << 6) | (cb & 0x3F); } if (valid && isRenderableCodepoint(cp)) { for (int j = 0; j < seqLen; j++) { currentWord += html[i + j]; } } // else: silently skip non-renderable character i += valid ? seqLen : 1; } } } flushWord(); return atoms; } // --------------------------------------------------------------------------- // Word-wrap the parsed HTML atoms into positioned line segments // --------------------------------------------------------------------------- void DictionaryDefinitionActivity::wrapText() { wrappedLines.clear(); const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW || orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW; const int screenWidth = renderer.getScreenWidth(); const int lineHeight = renderer.getLineHeight(readerFontId); const int sidePadding = landscape ? 50 : 20; constexpr int topArea = 50; constexpr int bottomArea = 50; const int maxWidth = screenWidth - 2 * sidePadding; const int spaceWidth = renderer.getSpaceWidth(readerFontId); linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight; if (linesPerPage < 1) linesPerPage = 1; auto atoms = parseHtml(definition); std::vector currentLine; int currentX = 0; int baseIndent = 0; // indent for continuation lines within the same block for (const auto& atom : atoms) { // ---- Newline directive ---- if (atom.isNewline) { // Collapse multiple consecutive blank lines if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) { // Already have a blank line; update indent but don't push another baseIndent = atom.indent; currentX = baseIndent; continue; } wrappedLines.push_back(std::move(currentLine)); currentLine.clear(); baseIndent = atom.indent; currentX = baseIndent; continue; } // ---- Text word ---- int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style); int gap = (currentX > baseIndent) ? spaceWidth : 0; // Wrap if this word won't fit if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) { wrappedLines.push_back(std::move(currentLine)); currentLine.clear(); currentX = baseIndent; gap = 0; } int16_t x = static_cast(currentX + gap); currentLine.push_back({atom.text, x, atom.style}); currentX = x + wordWidth; } // Flush last line if (!currentLine.empty()) { wrappedLines.push_back(std::move(currentLine)); } totalPages = (static_cast(wrappedLines.size()) + linesPerPage - 1) / linesPerPage; if (totalPages < 1) totalPages = 1; } void DictionaryDefinitionActivity::loop() { const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (prevPage && currentPage > 0) { currentPage--; updateRequired = true; } if (nextPage && currentPage < totalPages - 1) { currentPage++; updateRequired = true; } if (mappedInput.wasReleased(MappedInputManager::Button::Back) || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { onBack(); return; } } void DictionaryDefinitionActivity::renderScreen() { renderer.clearScreen(); const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW || orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW; const int sidePadding = landscape ? 50 : 20; constexpr int titleY = 10; const int lineHeight = renderer.getLineHeight(readerFontId); constexpr int bodyStartY = 50; // Title: the word in bold (UI font) renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD); // Separator line renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40); // Body: styled definition lines int startLine = currentPage * linesPerPage; for (int i = 0; i < linesPerPage && (startLine + i) < static_cast(wrappedLines.size()); i++) { int y = bodyStartY + i * lineHeight; const auto& line = wrappedLines[startLine + i]; for (const auto& seg : line) { renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style); } } // Pagination indicator (bottom right) if (totalPages > 1) { std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages); int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str()); renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth, renderer.getScreenHeight() - 50, pageInfo.c_str()); } // Button hints (bottom face buttons — hide Confirm stub like Home Screen) const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Side button hints (drawn in portrait coordinates for correct placement) { const auto origOrientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::Portrait); const int portW = renderer.getScreenWidth(); constexpr int sideButtonWidth = 30; constexpr int sideButtonHeight = 78; constexpr int sideButtonGap = 5; constexpr int sideTopY = 345; constexpr int cornerRadius = 6; const int sideX = portW - sideButtonWidth; const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap}; const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"}; const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW); for (int i = 0; i < 2; i++) { renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false); renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false, true, false, true); const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight); const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str()); if (useCCW) { renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str()); } else { renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str()); } } renderer.setOrientation(origOrientation); } // Use half refresh when entering the screen for cleaner transition; fast refresh for page turns. renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH); firstRender = false; }