#include "DictionaryDefinitionActivity.h" #include #include #include #include #include #include #include "CrossPointSettings.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/HtmlToPlainText.h" namespace { // Longest measurable/drawable span. Wrapped lines stay under the screen width // (far below this); only pathological unbreakable tokens are split at this cap. constexpr size_t MAX_LINE_BYTES = 191; // Body text left/right inset, matching the reader's default feel. constexpr int SIDE_PADDING = 20; } // namespace void DictionaryDefinitionActivity::onEnter() { Activity::onEnter(); // Normalize StarDict multi-type separators so the wrap loop and the // C-string font APIs below both see the whole definition. std::replace(definition.begin(), definition.end(), '\0', '\n'); definition = htmlToPlainText(definition); wrapText(); requestUpdate(); } int DictionaryDefinitionActivity::measureSpan(const int fontId, const char* text, size_t len) const { char buf[MAX_LINE_BYTES + 1]; len = std::min(len, MAX_LINE_BYTES); memcpy(buf, text, len); buf[len] = '\0'; return renderer.getTextAdvanceX(fontId, buf, EpdFontFamily::REGULAR); } // Greedy word-wrap of `definition` into byte spans. '\n' breaks lines (blank // lines survive as paragraph spacing; NULs from multi-type StarDict entries // were normalized to newlines in onEnter); '\r' is dropped by treating it as // a space at a token edge. void DictionaryDefinitionActivity::wrapText() { lines.clear(); lines.reserve(definition.size() / 32 + 8); const int fontId = SETTINGS.getReaderFontId(); // SD-card fonts: merge every definition codepoint into the persistent // advance table up front. Otherwise each unseen codepoint measured below // falls back to an on-demand glyph load from SD (8-slot overflow ring). renderer.ensureSdCardFontReady(fontId, definition.c_str(), 0x01 /* REGULAR */); const auto& metrics = UITheme::getInstance().getMetrics(); const auto orientation = renderer.getOrientation(); const bool isLandscape = orientation == GfxRenderer::Orientation::LandscapeClockwise || orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted; const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0; const int maxWidth = renderer.getScreenWidth() - hintGutterWidth - 2 * SIDE_PADDING; const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR); const int lineHeight = renderer.getLineHeight(fontId); const int topArea = (isInverted ? metrics.buttonHintsHeight : 0) + metrics.topPadding + metrics.headerHeight; const int bottomArea = metrics.buttonHintsHeight + metrics.verticalSpacing; linesPerPage = std::max(1, (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight); const char* text = definition.c_str(); const uint32_t n = static_cast(definition.size()); uint32_t lineStart = 0; uint32_t lineEnd = 0; // one past the last token byte on the current line int lineWidth = 0; const auto flushLine = [&](uint32_t nextStart) { lines.push_back({lineStart, static_cast(lineEnd - lineStart)}); lineStart = nextStart; lineEnd = nextStart; lineWidth = 0; }; uint32_t i = 0; while (i < n) { const char c = text[i]; if (c == '\n' || c == '\0') { flushLine(i + 1); i++; continue; } if (c == ' ' || c == '\t' || c == '\r') { i++; continue; } // Token: run of non-whitespace bytes, capped at the measure buffer. const uint32_t tokenStart = i; while (i < n && text[i] != ' ' && text[i] != '\t' && text[i] != '\r' && text[i] != '\n' && text[i] != '\0' && i - tokenStart < MAX_LINE_BYTES) { i++; } // If the byte cap cut the token mid-UTF-8-sequence, back off to the last // complete codepoint so measure/draw never see a partial sequence. A // natural stop lands on whitespace or the terminating NUL, never on a // continuation byte, so this is a no-op there. while (i - tokenStart > 1 && (text[i] & 0xC0) == 0x80) i--; const uint32_t tokenLen = i - tokenStart; const int tokenWidth = measureSpan(fontId, text + tokenStart, tokenLen); if (lineEnd == lineStart) { lineStart = tokenStart; lineEnd = tokenStart + tokenLen; lineWidth = tokenWidth; } else if (lineWidth + spaceWidth + tokenWidth <= maxWidth && tokenStart + tokenLen - lineStart <= UINT16_MAX) { // span len must fit Line::len lineEnd = tokenStart + tokenLen; lineWidth += spaceWidth + tokenWidth; } else { flushLine(tokenStart); lineEnd = tokenStart + tokenLen; lineWidth = tokenWidth; } // An unbreakable token wider than the screen is now alone on the line // (any previous content was flushed above): split it at the widest // fitting UTF-8 boundary and carry the remainder forward. while (lineWidth > maxWidth && lineEnd - lineStart > 1) { const uint32_t len = lineEnd - lineStart; uint32_t lastFit = 0; for (uint32_t f = 1; f <= len; f++) { if (f == len || (text[lineStart + f] & 0xC0) != 0x80) { // codepoint boundary if (measureSpan(fontId, text + lineStart, f) > maxWidth) break; lastFit = f; } } if (lastFit == 0) { // Even a single over-wide glyph must make progress; consume its whole // UTF-8 sequence rather than splitting it into invalid fragments. lastFit = 1; while (lastFit < len && (text[lineStart + lastFit] & 0xC0) == 0x80) lastFit++; } const uint32_t rest = lineStart + lastFit; lineEnd = rest; flushLine(rest); lineEnd = rest + (len - lastFit); lineWidth = measureSpan(fontId, text + lineStart, lineEnd - lineStart); } } if (lineEnd > lineStart) flushLine(n); // Trim trailing blank lines so the last page is not empty padding. while (!lines.empty() && lines.back().len == 0) lines.pop_back(); totalPages = std::max(1, (static_cast(lines.size()) + linesPerPage - 1) / linesPerPage); currentPage = 0; } void DictionaryDefinitionActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { finish(); return; } // Same tap zones as the reader page turns: left third = previous page, // the rest = next. Back is the usual left-edge swipe. int tx = 0; int ty = 0; if (mappedInput.wasScreenTapped(tx, ty)) { if (tx < renderer.getScreenWidth() / 3) { if (currentPage > 0) { currentPage--; requestUpdate(); } } else if (currentPage + 1 < totalPages) { currentPage++; requestUpdate(); } return; } buttonNavigator.onNext([this] { if (currentPage + 1 < totalPages) { currentPage++; requestUpdate(); } }); buttonNavigator.onPrevious([this] { if (currentPage > 0) { currentPage--; requestUpdate(); } }); } // Draws the current page's line spans (copied into a stack buffer for NUL // termination). Called twice per render: once in font-cache scan mode, once // for the real paint. void DictionaryDefinitionActivity::drawBody(const int fontId, const int x, const int startY) const { const int lineHeight = renderer.getLineHeight(fontId); char buf[MAX_LINE_BYTES + 1]; const int firstLine = currentPage * linesPerPage; const int lastLine = std::min(firstLine + linesPerPage, static_cast(lines.size())); for (int i = firstLine; i < lastLine; i++) { if (lines[i].len == 0) continue; const size_t len = std::min(static_cast(lines[i].len), MAX_LINE_BYTES); memcpy(buf, definition.c_str() + lines[i].start, len); buf[len] = '\0'; renderer.drawText(fontId, x, startY + (i - firstLine) * lineHeight, buf); } } void DictionaryDefinitionActivity::render(RenderLock&&) { renderer.clearScreen(); const auto& metrics = UITheme::getInstance().getMetrics(); const auto orientation = renderer.getOrientation(); const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted; const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0; const int contentX = isLandscapeCw ? hintGutterWidth : 0; const int contentWidth = renderer.getScreenWidth() - hintGutterWidth; const int contentY = isInverted ? metrics.buttonHintsHeight : 0; // Header: matched headword left, page counter right. const int headerY = contentY + metrics.topPadding + 10; renderer.drawText(UI_12_FONT_ID, contentX + SIDE_PADDING, headerY, headword.c_str(), true, EpdFontFamily::BOLD); if (totalPages > 1) { char counter[16]; snprintf(counter, sizeof(counter), "%d/%d", currentPage + 1, totalPages); const int counterWidth = renderer.getTextWidth(UI_10_FONT_ID, counter); renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - SIDE_PADDING - counterWidth, headerY, counter); } // Body: two-pass draw inside a prewarm scope (same pattern as the reader's // renderContents) so SD-card font glyphs load from SD in one batch instead // of one on-demand overflow read per character on every page turn. const int fontId = SETTINGS.getReaderFontId(); const int bodyStartY = contentY + metrics.topPadding + metrics.headerHeight; auto* fcm = renderer.getFontCacheManager(); auto scope = fcm->createPrewarmScope(); drawBody(fontId, contentX + SIDE_PADDING, bodyStartY); // scan pass: records codepoints only scope.endScanAndPrewarm(); drawBody(fontId, contentX + SIDE_PADDING, bodyStartY); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", (currentPage > 0 ? "<" : ""), (currentPage + 1 < totalPages ? ">" : "")); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); }