3 Commits

Author SHA1 Message Date
cottongin
1383d75c84 feat: Add per-family font and per-language hyphenation build flags
Add OMIT_BOOKERLY, OMIT_NOTOSANS, OMIT_OPENDYSLEXIC flags to
selectively exclude font families, and OMIT_HYPH_DE/EN/ES/FR/IT/RU
flags to exclude individual hyphenation language tries.

The mod build environment excludes OpenDyslexic (~1.03 MB) and all
hyphenation tries (~282 KB), reducing flash usage by ~1.3 MB.

Font Family setting switched from Enum to DynamicEnum with
index-to-value mapping to handle arbitrary font exclusion without
breaking the settings UI or persisted values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 00:48:23 -05:00
cottongin
632b76c9ed feat: Add placeholder cover generator for books without covers
Generate styled placeholder covers (title, author, book icon) when a
book has no embedded cover image, instead of showing a blank rectangle.

- Add PlaceholderCoverGenerator lib with 1-bit BMP rendering, scaled
  fonts, word-wrap, and a book icon bitmap
- Integrate as fallback in Epub/Xtc/Txt reader activities and
  SleepActivity after format-specific cover generation fails
- Add fallback in HomeActivity::loadRecentCovers() so the home screen
  also shows placeholder thumbnails when cache is cleared
- Add Txt::getThumbBmpPath() for TXT thumbnail support
- Add helper scripts for icon and layout preview generation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 23:38:47 -05:00
cottongin
5dc9d21bdb feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).

- Add morphological stemming (getStemVariants), Levenshtein edit
  distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
  exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
  with cascading lookup (exact → stems → suggestions → not found),
  en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
  inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
30 changed files with 1860 additions and 161 deletions

View File

@@ -1,5 +1,6 @@
#pragma once
#ifndef OMIT_BOOKERLY
#include <builtinFonts/bookerly_12_bold.h>
#include <builtinFonts/bookerly_12_bolditalic.h>
#include <builtinFonts/bookerly_12_italic.h>
@@ -16,7 +17,10 @@
#include <builtinFonts/bookerly_18_bolditalic.h>
#include <builtinFonts/bookerly_18_italic.h>
#include <builtinFonts/bookerly_18_regular.h>
#endif // OMIT_BOOKERLY
#include <builtinFonts/notosans_8_regular.h>
#ifndef OMIT_NOTOSANS
#include <builtinFonts/notosans_12_bold.h>
#include <builtinFonts/notosans_12_bolditalic.h>
#include <builtinFonts/notosans_12_italic.h>
@@ -33,6 +37,9 @@
#include <builtinFonts/notosans_18_bolditalic.h>
#include <builtinFonts/notosans_18_italic.h>
#include <builtinFonts/notosans_18_regular.h>
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
#include <builtinFonts/opendyslexic_10_bold.h>
#include <builtinFonts/opendyslexic_10_bolditalic.h>
#include <builtinFonts/opendyslexic_10_italic.h>
@@ -49,6 +56,8 @@
#include <builtinFonts/opendyslexic_8_bolditalic.h>
#include <builtinFonts/opendyslexic_8_italic.h>
#include <builtinFonts/opendyslexic_8_regular.h>
#endif // OMIT_OPENDYSLEXIC
#include <builtinFonts/ubuntu_10_bold.h>
#include <builtinFonts/ubuntu_10_regular.h>
#include <builtinFonts/ubuntu_12_bold.h>

View File

@@ -1,48 +1,84 @@
#include "LanguageRegistry.h"
#include <algorithm>
#include <array>
#include <vector>
#include "HyphenationCommon.h"
#ifndef OMIT_HYPH_DE
#include "generated/hyph-de.trie.h"
#endif
#ifndef OMIT_HYPH_EN
#include "generated/hyph-en.trie.h"
#endif
#ifndef OMIT_HYPH_ES
#include "generated/hyph-es.trie.h"
#endif
#ifndef OMIT_HYPH_FR
#include "generated/hyph-fr.trie.h"
#endif
#ifndef OMIT_HYPH_IT
#include "generated/hyph-it.trie.h"
#endif
#ifndef OMIT_HYPH_RU
#include "generated/hyph-ru.trie.h"
#endif
namespace {
#ifndef OMIT_HYPH_EN
// English hyphenation patterns (3/3 minimum prefix/suffix length)
LanguageHyphenator englishHyphenator(en_us_patterns, isLatinLetter, toLowerLatin, 3, 3);
#endif
#ifndef OMIT_HYPH_FR
LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_DE
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_RU
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
#endif
#ifndef OMIT_HYPH_ES
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
#endif
#ifndef OMIT_HYPH_IT
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
#endif
using EntryArray = std::array<LanguageEntry, 6>;
const EntryArray& entries() {
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
{"french", "fr", &frenchHyphenator},
{"german", "de", &germanHyphenator},
{"russian", "ru", &russianHyphenator},
{"spanish", "es", &spanishHyphenator},
{"italian", "it", &italianHyphenator}}};
return kEntries;
const LanguageEntryView entries() {
static const std::vector<LanguageEntry> kEntries = {
#ifndef OMIT_HYPH_EN
{"english", "en", &englishHyphenator},
#endif
#ifndef OMIT_HYPH_FR
{"french", "fr", &frenchHyphenator},
#endif
#ifndef OMIT_HYPH_DE
{"german", "de", &germanHyphenator},
#endif
#ifndef OMIT_HYPH_RU
{"russian", "ru", &russianHyphenator},
#endif
#ifndef OMIT_HYPH_ES
{"spanish", "es", &spanishHyphenator},
#endif
#ifndef OMIT_HYPH_IT
{"italian", "it", &italianHyphenator},
#endif
};
static const LanguageEntryView view{kEntries.data(), kEntries.size()};
return view;
}
} // namespace
const LanguageHyphenator* getLanguageHyphenatorForPrimaryTag(const std::string& primaryTag) {
const auto& allEntries = entries();
const auto allEntries = entries();
const auto it = std::find_if(allEntries.begin(), allEntries.end(),
[&primaryTag](const LanguageEntry& entry) { return primaryTag == entry.primaryTag; });
return (it != allEntries.end()) ? it->hyphenator : nullptr;
}
LanguageEntryView getLanguageEntries() {
const auto& allEntries = entries();
return LanguageEntryView{allEntries.data(), allEntries.size()};
return entries();
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
// Book icon: 48x48, 1-bit packed (MSB first)
// 0 = black, 1 = white (same format as Logo120.h)
static constexpr int BOOK_ICON_WIDTH = 48;
static constexpr int BOOK_ICON_HEIGHT = 48;
static const uint8_t BookIcon[] = {
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00,
0x00, 0x1f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x00, 0x01, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x01,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1c, 0x00, 0x00, 0x1f, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f,
0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00,
0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
};

View File

@@ -0,0 +1,480 @@
#include "PlaceholderCoverGenerator.h"
#include <EpdFont.h>
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
#include <algorithm>
#include <cstring>
#include <vector>
// Include the UI fonts directly for self-contained placeholder rendering.
// These are 1-bit bitmap fonts compiled from Ubuntu TTF.
#include "builtinFonts/ubuntu_10_regular.h"
#include "builtinFonts/ubuntu_12_bold.h"
// Book icon bitmap (48x48 1-bit, generated by scripts/generate_book_icon.py)
#include "BookIcon.h"
namespace {
// BMP writing helpers (same format as JpegToBmpConverter)
inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
}
inline void write32(Print& out, const uint32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
inline void write32Signed(Print& out, const int32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
const int bytesPerRow = (width + 31) / 32 * 4;
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 62 + imageSize;
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0); // Reserved
write32(bmpOut, 62); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative = top-down
write16(bmpOut, 1); // Color planes
write16(bmpOut, 1); // Bits per pixel
write32(bmpOut, 0); // BI_RGB
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter
write32(bmpOut, 2835); // yPixelsPerMeter
write32(bmpOut, 2); // colorsUsed
write32(bmpOut, 2); // colorsImportant
// Palette: index 0 = black, index 1 = white
const uint8_t palette[8] = {
0x00, 0x00, 0x00, 0x00, // Black
0xFF, 0xFF, 0xFF, 0x00 // White
};
for (const uint8_t b : palette) {
bmpOut.write(b);
}
}
/// 1-bit pixel buffer that can render text, icons, and shapes, then write as BMP.
class PixelBuffer {
public:
PixelBuffer(int width, int height) : width(width), height(height) {
bytesPerRow = (width + 31) / 32 * 4;
bufferSize = bytesPerRow * height;
buffer = static_cast<uint8_t*>(malloc(bufferSize));
if (buffer) {
memset(buffer, 0xFF, bufferSize); // White background
}
}
~PixelBuffer() {
if (buffer) {
free(buffer);
}
}
bool isValid() const { return buffer != nullptr; }
/// Set a pixel to black.
void setBlack(int x, int y) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const int byteIndex = y * bytesPerRow + x / 8;
const uint8_t bitMask = 0x80 >> (x % 8);
buffer[byteIndex] &= ~bitMask;
}
/// Set a scaled "pixel" (scale x scale block) to black.
void setBlackScaled(int x, int y, int scale) {
for (int dy = 0; dy < scale; dy++) {
for (int dx = 0; dx < scale; dx++) {
setBlack(x + dx, y + dy);
}
}
}
/// Draw a filled rectangle in black.
void fillRect(int x, int y, int w, int h) {
for (int row = y; row < y + h && row < height; row++) {
for (int col = x; col < x + w && col < width; col++) {
setBlack(col, row);
}
}
}
/// Draw a rectangular border in black.
void drawBorder(int x, int y, int w, int h, int thickness) {
fillRect(x, y, w, thickness); // Top
fillRect(x, y + h - thickness, w, thickness); // Bottom
fillRect(x, y, thickness, h); // Left
fillRect(x + w - thickness, y, thickness, h); // Right
}
/// Draw a horizontal line in black with configurable thickness.
void drawHLine(int x, int y, int length, int thickness = 1) {
fillRect(x, y, length, thickness);
}
/// Render a single glyph at (cursorX, baselineY) with integer scaling. Returns advance in X (scaled).
int renderGlyph(const EpdFontData* font, uint32_t codepoint, int cursorX, int baselineY, int scale = 1) {
const EpdFont fontObj(font);
const EpdGlyph* glyph = fontObj.getGlyph(codepoint);
if (!glyph) {
glyph = fontObj.getGlyph(REPLACEMENT_GLYPH);
}
if (!glyph) {
return 0;
}
const uint8_t* bitmap = &font->bitmap[glyph->dataOffset];
const int glyphW = glyph->width;
const int glyphH = glyph->height;
for (int gy = 0; gy < glyphH; gy++) {
const int screenY = baselineY - glyph->top * scale + gy * scale;
for (int gx = 0; gx < glyphW; gx++) {
const int pixelPos = gy * glyphW + gx;
const int screenX = cursorX + glyph->left * scale + gx * scale;
bool isSet = false;
if (font->is2Bit) {
const uint8_t byte = bitmap[pixelPos / 4];
const uint8_t bitIndex = (3 - pixelPos % 4) * 2;
const uint8_t val = 3 - ((byte >> bitIndex) & 0x3);
isSet = (val < 3);
} else {
const uint8_t byte = bitmap[pixelPos / 8];
const uint8_t bitIndex = 7 - (pixelPos % 8);
isSet = ((byte >> bitIndex) & 1);
}
if (isSet) {
setBlackScaled(screenX, screenY, scale);
}
}
}
return glyph->advanceX * scale;
}
/// Render a UTF-8 string at (x, y) where y is the top of the text line, with integer scaling.
void drawText(const EpdFontData* font, int x, int y, const char* text, int scale = 1) {
const int baselineY = y + font->ascender * scale;
int cursorX = x;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
cursorX += renderGlyph(font, cp, cursorX, baselineY, scale);
}
}
/// Draw a 1-bit icon bitmap (MSB first, 0=black, 1=white) with integer scaling.
void drawIcon(const uint8_t* icon, int iconW, int iconH, int x, int y, int scale = 1) {
const int bytesPerIconRow = iconW / 8;
for (int iy = 0; iy < iconH; iy++) {
for (int ix = 0; ix < iconW; ix++) {
const int byteIdx = iy * bytesPerIconRow + ix / 8;
const uint8_t bitMask = 0x80 >> (ix % 8);
// In the icon data: 0 = black (drawn), 1 = white (skip)
if (!(icon[byteIdx] & bitMask)) {
const int sx = x + ix * scale;
const int sy = y + iy * scale;
setBlackScaled(sx, sy, scale);
}
}
}
}
/// Write the pixel buffer to a file as a 1-bit BMP.
bool writeBmp(Print& out) const {
if (!buffer) return false;
writeBmpHeader1bit(out, width, height);
out.write(buffer, bufferSize);
return true;
}
int getWidth() const { return width; }
int getHeight() const { return height; }
private:
int width;
int height;
int bytesPerRow;
size_t bufferSize;
uint8_t* buffer;
};
/// Measure the width of a UTF-8 string in pixels (at 1x scale).
int measureTextWidth(const EpdFontData* font, const char* text) {
const EpdFont fontObj(font);
int w = 0, h = 0;
fontObj.getTextDimensions(text, &w, &h);
return w;
}
/// Get the advance width of a single character.
int getCharAdvance(const EpdFontData* font, uint32_t cp) {
const EpdFont fontObj(font);
const EpdGlyph* glyph = fontObj.getGlyph(cp);
if (!glyph) return 0;
return glyph->advanceX;
}
/// Split a string into words (splitting on spaces).
std::vector<std::string> splitWords(const std::string& text) {
std::vector<std::string> words;
std::string current;
for (size_t i = 0; i < text.size(); i++) {
if (text[i] == ' ') {
if (!current.empty()) {
words.push_back(current);
current.clear();
}
} else {
current += text[i];
}
}
if (!current.empty()) {
words.push_back(current);
}
return words;
}
/// Word-wrap text into lines that fit within maxWidth pixels at the given scale.
std::vector<std::string> wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
std::vector<std::string> lines;
const auto words = splitWords(text);
if (words.empty()) return lines;
const int spaceWidth = getCharAdvance(font, ' ') * scale;
std::string currentLine;
int currentWidth = 0;
for (const auto& word : words) {
const int wordWidth = measureTextWidth(font, word.c_str()) * scale;
if (currentLine.empty()) {
currentLine = word;
currentWidth = wordWidth;
} else if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
currentLine += " " + word;
currentWidth += spaceWidth + wordWidth;
} else {
lines.push_back(currentLine);
currentLine = word;
currentWidth = wordWidth;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
/// Truncate a string with "..." if it exceeds maxWidth pixels at the given scale.
std::string truncateText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
if (measureTextWidth(font, text.c_str()) * scale <= maxWidth) {
return text;
}
std::string truncated = text;
const char* ellipsis = "...";
const int ellipsisWidth = measureTextWidth(font, ellipsis) * scale;
while (!truncated.empty()) {
utf8RemoveLastChar(truncated);
if (measureTextWidth(font, truncated.c_str()) * scale + ellipsisWidth <= maxWidth) {
return truncated + ellipsis;
}
}
return ellipsis;
}
} // namespace
bool PlaceholderCoverGenerator::generate(const std::string& outputPath, const std::string& title,
const std::string& author, int width, int height) {
LOG_DBG("PHC", "Generating placeholder cover %dx%d: \"%s\" by \"%s\"", width, height, title.c_str(), author.c_str());
const EpdFontData* titleFont = &ubuntu_12_bold;
const EpdFontData* authorFont = &ubuntu_10_regular;
PixelBuffer buf(width, height);
if (!buf.isValid()) {
LOG_ERR("PHC", "Failed to allocate %dx%d pixel buffer (%d bytes)", width, height,
(width + 31) / 32 * 4 * height);
return false;
}
// Proportional layout constants based on cover dimensions.
// The device bezel covers ~2-3px on each edge, so we pad inward from the edge.
const int edgePadding = std::max(3, width / 48); // ~10px at 480w, ~3px at 136w
const int borderWidth = std::max(2, width / 96); // ~5px at 480w, ~2px at 136w
const int innerPadding = std::max(4, width / 32); // ~15px at 480w, ~4px at 136w
// Text scaling: 2x for full-size covers, 1x for thumbnails
const int titleScale = (height >= 600) ? 2 : 1;
const int authorScale = (height >= 600) ? 2 : 1; // Author also larger on full covers
// Icon: 2x for full cover, 1x for medium thumb, skip for small
const int iconScale = (height >= 600) ? 2 : (height >= 350 ? 1 : 0);
// Draw border inset from edge
buf.drawBorder(edgePadding, edgePadding, width - 2 * edgePadding, height - 2 * edgePadding, borderWidth);
// Content area (inside border + inner padding)
const int contentX = edgePadding + borderWidth + innerPadding;
const int contentY = edgePadding + borderWidth + innerPadding;
const int contentW = width - 2 * contentX;
const int contentH = height - 2 * contentY;
if (contentW <= 0 || contentH <= 0) {
LOG_ERR("PHC", "Cover too small for content (%dx%d)", width, height);
FsFile file;
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
return false;
}
buf.writeBmp(file);
file.close();
return true;
}
// --- Layout zones ---
// Title zone: top 2/3 of content area (icon + title)
// Author zone: bottom 1/3 of content area
const int titleZoneH = contentH * 2 / 3;
const int authorZoneH = contentH - titleZoneH;
const int authorZoneY = contentY + titleZoneH;
// --- Separator line at the zone boundary ---
const int separatorWidth = contentW / 3;
const int separatorX = contentX + (contentW - separatorWidth) / 2;
buf.drawHLine(separatorX, authorZoneY, separatorWidth);
// --- Icon dimensions (needed for title text wrapping) ---
const int iconW = (iconScale > 0) ? BOOK_ICON_WIDTH * iconScale : 0;
const int iconGap = (iconScale > 0) ? std::max(8, width / 40) : 0; // Gap between icon and title text
const int titleTextW = contentW - iconW - iconGap; // Title wraps in narrower area beside icon
// --- Prepare title text (wraps within the area to the right of the icon) ---
const std::string displayTitle = title.empty() ? "Untitled" : title;
auto titleLines = wrapText(titleFont, displayTitle, titleTextW, titleScale);
constexpr int MAX_TITLE_LINES = 5;
if (static_cast<int>(titleLines.size()) > MAX_TITLE_LINES) {
titleLines.resize(MAX_TITLE_LINES);
titleLines.back() = truncateText(titleFont, titleLines.back(), titleTextW, titleScale);
}
// --- Prepare author text (multi-line, max 3 lines) ---
std::vector<std::string> authorLines;
if (!author.empty()) {
authorLines = wrapText(authorFont, author, contentW, authorScale);
constexpr int MAX_AUTHOR_LINES = 3;
if (static_cast<int>(authorLines.size()) > MAX_AUTHOR_LINES) {
authorLines.resize(MAX_AUTHOR_LINES);
authorLines.back() = truncateText(authorFont, authorLines.back(), contentW, authorScale);
}
}
// --- Calculate title zone layout (icon LEFT of title) ---
// Tighter line spacing so 2-3 title lines fit within the icon height
const int titleLineH = titleFont->advanceY * titleScale * 3 / 4;
const int iconH = (iconScale > 0) ? BOOK_ICON_HEIGHT * iconScale : 0;
const int numTitleLines = static_cast<int>(titleLines.size());
// Visual height: distance from top of first line to bottom of last line's glyphs.
// Use ascender (not full advanceY) for the last line since trailing line-gap isn't visible.
const int titleVisualH = (numTitleLines > 0)
? (numTitleLines - 1) * titleLineH + titleFont->ascender * titleScale
: 0;
const int titleBlockH = std::max(iconH, titleVisualH); // Taller of icon or text
int titleStartY = contentY + (titleZoneH - titleBlockH) / 2;
if (titleStartY < contentY) {
titleStartY = contentY;
}
// If title fits within icon height, center it vertically against the icon.
// Otherwise top-align so extra lines overflow below.
const int iconY = titleStartY;
const int titleTextY = (iconH > 0 && titleVisualH <= iconH)
? titleStartY + (iconH - titleVisualH) / 2
: titleStartY;
// --- Horizontal centering: measure the widest title line, then center icon+gap+text block ---
int maxTitleLineW = 0;
for (const auto& line : titleLines) {
const int w = measureTextWidth(titleFont, line.c_str()) * titleScale;
if (w > maxTitleLineW) maxTitleLineW = w;
}
const int titleBlockW = iconW + iconGap + maxTitleLineW;
const int titleBlockX = contentX + (contentW - titleBlockW) / 2;
// --- Draw icon ---
if (iconScale > 0) {
buf.drawIcon(BookIcon, BOOK_ICON_WIDTH, BOOK_ICON_HEIGHT, titleBlockX, iconY, iconScale);
}
// --- Draw title lines (to the right of the icon) ---
const int titleTextX = titleBlockX + iconW + iconGap;
int currentY = titleTextY;
for (const auto& line : titleLines) {
buf.drawText(titleFont, titleTextX, currentY, line.c_str(), titleScale);
currentY += titleLineH;
}
// --- Draw author lines (centered vertically in bottom 1/3, centered horizontally) ---
if (!authorLines.empty()) {
const int authorLineH = authorFont->advanceY * authorScale;
const int authorBlockH = static_cast<int>(authorLines.size()) * authorLineH;
int authorStartY = authorZoneY + (authorZoneH - authorBlockH) / 2;
if (authorStartY < authorZoneY + 4) {
authorStartY = authorZoneY + 4; // Small gap below separator
}
for (const auto& line : authorLines) {
const int lineWidth = measureTextWidth(authorFont, line.c_str()) * authorScale;
const int lineX = contentX + (contentW - lineWidth) / 2;
buf.drawText(authorFont, lineX, authorStartY, line.c_str(), authorScale);
authorStartY += authorLineH;
}
}
// --- Write to file ---
FsFile file;
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
LOG_ERR("PHC", "Failed to open output file: %s", outputPath.c_str());
return false;
}
const bool success = buf.writeBmp(file);
file.close();
if (success) {
LOG_DBG("PHC", "Placeholder cover written to %s", outputPath.c_str());
} else {
LOG_ERR("PHC", "Failed to write placeholder BMP");
Storage.remove(outputPath.c_str());
}
return success;
}

View File

@@ -0,0 +1,14 @@
#pragma once
#include <string>
/// Generates simple 1-bit BMP placeholder covers with title/author text
/// for books that have no embedded cover image.
class PlaceholderCoverGenerator {
public:
/// Generate a placeholder cover BMP with title and author text.
/// The BMP is written to outputPath as a 1-bit black-and-white image.
/// Returns true if the file was written successfully.
static bool generate(const std::string& outputPath, const std::string& title, const std::string& author, int width,
int height);
};

View File

@@ -97,6 +97,9 @@ std::string Txt::findCoverImage() const {
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Txt::generateCoverBmp() const {
// Already generated, return true
if (Storage.exists(getCoverBmpPath().c_str())) {

View File

@@ -28,6 +28,10 @@ class Txt {
[[nodiscard]] bool generateCoverBmp() const;
[[nodiscard]] std::string findCoverImage() const;
// Thumbnail paths (matching Epub/Xtc pattern for home screen covers)
[[nodiscard]] std::string getThumbBmpPath() const;
[[nodiscard]] std::string getThumbBmpPath(int height) const;
// Read content from file
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
};

View File

@@ -65,6 +65,12 @@ extra_scripts =
pre:scripts/inject_mod_version.py
build_flags =
${base.build_flags}
-DOMIT_OPENDYSLEXIC
-DOMIT_HYPH_DE
-DOMIT_HYPH_ES
-DOMIT_HYPH_FR
-DOMIT_HYPH_IT
-DOMIT_HYPH_RU
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for mod builds

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Generate a 1-bit book icon bitmap as a C header for PlaceholderCoverGenerator.
The icon is a simplified closed book with a spine on the left and 3 text lines.
Output format matches Logo120.h: MSB-first packed 1-bit, 0=black, 1=white.
"""
from PIL import Image, ImageDraw
import sys
def generate_book_icon(size=48):
"""Create a book icon at the given size."""
img = Image.new("1", (size, size), 1) # White background
draw = ImageDraw.Draw(img)
# Scale helper
s = size / 48.0
# Book body (main rectangle, leaving room for spine and pages)
body_left = int(6 * s)
body_top = int(2 * s)
body_right = int(42 * s)
body_bottom = int(40 * s)
# Draw book body outline (2px thick)
for i in range(int(2 * s)):
draw.rectangle(
[body_left + i, body_top + i, body_right - i, body_bottom - i], outline=0
)
# Spine (thicker left edge)
spine_width = int(4 * s)
draw.rectangle([body_left, body_top, body_left + spine_width, body_bottom], fill=0)
# Pages at the bottom (slight offset from body)
pages_top = body_bottom
pages_bottom = int(44 * s)
draw.rectangle(
[body_left + int(2 * s), pages_top, body_right - int(1 * s), pages_bottom],
outline=0,
)
# Page edges (a few lines)
for i in range(3):
y = pages_top + int((i + 1) * 1 * s)
if y < pages_bottom:
draw.line(
[body_left + int(3 * s), y, body_right - int(2 * s), y], fill=0
)
# Text lines on the book cover
text_left = body_left + spine_width + int(4 * s)
text_right = body_right - int(4 * s)
line_thickness = max(1, int(1.5 * s))
text_lines_y = [int(12 * s), int(18 * s), int(24 * s)]
text_widths = [1.0, 0.7, 0.85] # Relative widths for visual interest
for y, w_ratio in zip(text_lines_y, text_widths):
line_right = text_left + int((text_right - text_left) * w_ratio)
for t in range(line_thickness):
draw.line([text_left, y + t, line_right, y + t], fill=0)
return img
def image_to_c_array(img, name="BookIcon"):
"""Convert a 1-bit PIL image to a C header array."""
width, height = img.size
pixels = img.load()
bytes_per_row = width // 8
data = []
for y in range(height):
for bx in range(bytes_per_row):
byte = 0
for bit in range(8):
x = bx * 8 + bit
if x < width:
# 1 = white, 0 = black (matching Logo120.h convention)
if pixels[x, y]:
byte |= 1 << (7 - bit)
data.append(byte)
# Format as C header
lines = []
lines.append("#pragma once")
lines.append("#include <cstdint>")
lines.append("")
lines.append(f"// Book icon: {width}x{height}, 1-bit packed (MSB first)")
lines.append(f"// 0 = black, 1 = white (same format as Logo120.h)")
lines.append(f"static constexpr int BOOK_ICON_WIDTH = {width};")
lines.append(f"static constexpr int BOOK_ICON_HEIGHT = {height};")
lines.append(f"static const uint8_t {name}[] = {{")
# Format data in rows of 16 bytes
for i in range(0, len(data), 16):
chunk = data[i : i + 16]
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f" {hex_str},")
lines.append("};")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
size = int(sys.argv[1]) if len(sys.argv) > 1 else 48
img = generate_book_icon(size)
# Save preview PNG
preview_path = f"mod/book_icon_{size}x{size}.png"
img.resize((size * 4, size * 4), Image.NEAREST).save(preview_path)
print(f"Preview saved to {preview_path}", file=sys.stderr)
# Generate C header
header = image_to_c_array(img, "BookIcon")
output_path = "lib/PlaceholderCover/BookIcon.h"
with open(output_path, "w") as f:
f.write(header)
print(f"C header saved to {output_path}", file=sys.stderr)

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate a preview of the placeholder cover layout at full cover size (480x800).
This mirrors the C++ PlaceholderCoverGenerator layout logic for visual verification.
"""
from PIL import Image, ImageDraw, ImageFont
import sys
import os
# Reuse the book icon generator
sys.path.insert(0, os.path.dirname(__file__))
from generate_book_icon import generate_book_icon
def create_preview(width=480, height=800, title="The Great Gatsby", author="F. Scott Fitzgerald"):
img = Image.new("1", (width, height), 1) # White
draw = ImageDraw.Draw(img)
# Proportional layout constants
edge_padding = max(3, width // 48) # ~10px at 480w
border_width = max(2, width // 96) # ~5px at 480w
inner_padding = max(4, width // 32) # ~15px at 480w
title_scale = 2 if height >= 600 else 1
author_scale = 2 if height >= 600 else 1 # Author also larger on full covers
icon_scale = 2 if height >= 600 else (1 if height >= 350 else 0)
# Draw border inset from edge
bx = edge_padding
by = edge_padding
bw = width - 2 * edge_padding
bh = height - 2 * edge_padding
for i in range(border_width):
draw.rectangle([bx + i, by + i, bx + bw - 1 - i, by + bh - 1 - i], outline=0)
# Content area
content_x = edge_padding + border_width + inner_padding
content_y = edge_padding + border_width + inner_padding
content_w = width - 2 * content_x
content_h = height - 2 * content_y
# Zones
title_zone_h = content_h * 2 // 3
author_zone_h = content_h - title_zone_h
author_zone_y = content_y + title_zone_h
# Separator
sep_w = content_w // 3
sep_x = content_x + (content_w - sep_w) // 2
draw.line([sep_x, author_zone_y, sep_x + sep_w, author_zone_y], fill=0)
# Use a basic font for the preview (won't match exact Ubuntu metrics, but shows layout)
try:
title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12 * title_scale)
author_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10 * author_scale)
except (OSError, IOError):
title_font = ImageFont.load_default()
author_font = ImageFont.load_default()
# Icon dimensions (needed for title text wrapping)
icon_w_px = 48 * icon_scale if icon_scale > 0 else 0
icon_h_px = 48 * icon_scale if icon_scale > 0 else 0
icon_gap = max(8, width // 40) if icon_scale > 0 else 0
title_text_w = content_w - icon_w_px - icon_gap # Title wraps in narrower area beside icon
# Wrap title (within the narrower area to the right of the icon)
title_lines = []
words = title.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=title_font)
if bbox[2] - bbox[0] <= title_text_w:
current_line = test
else:
if current_line:
title_lines.append(current_line)
current_line = word
if current_line:
title_lines.append(current_line)
title_lines = title_lines[:5]
# Line spacing: 75% of advanceY (tighter so 2-3 lines fit within icon height)
title_line_h = 29 * title_scale * 3 // 4 # Based on C++ ubuntu_12_bold advanceY
# Measure actual single-line height from the PIL font for accurate centering
sample_bbox = draw.textbbox((0, 0), "Ag", font=title_font) # Tall + descender chars
single_line_visual_h = sample_bbox[3] - sample_bbox[1]
# Visual height: line spacing between lines + actual height of last line's glyphs
num_title_lines = len(title_lines)
title_visual_h = (num_title_lines - 1) * title_line_h + single_line_visual_h if num_title_lines > 0 else 0
title_block_h = max(icon_h_px, title_visual_h)
title_start_y = content_y + (title_zone_h - title_block_h) // 2
if title_start_y < content_y:
title_start_y = content_y
# If title fits within icon height, center it vertically against the icon.
# Otherwise top-align so extra lines overflow below.
icon_y = title_start_y
if icon_h_px > 0 and title_visual_h <= icon_h_px:
title_text_y = title_start_y + (icon_h_px - title_visual_h) // 2
else:
title_text_y = title_start_y
# Horizontal centering: measure widest title line, center icon+gap+text block
max_title_line_w = 0
for line in title_lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
w = bbox[2] - bbox[0]
if w > max_title_line_w:
max_title_line_w = w
title_block_w = icon_w_px + icon_gap + max_title_line_w
title_block_x = content_x + (content_w - title_block_w) // 2
# Draw icon
if icon_scale > 0:
icon_img = generate_book_icon(48)
scaled_icon = icon_img.resize((icon_w_px, icon_h_px), Image.NEAREST)
for iy in range(scaled_icon.height):
for ix in range(scaled_icon.width):
if not scaled_icon.getpixel((ix, iy)):
img.putpixel((title_block_x + ix, icon_y + iy), 0)
# Draw title (to the right of the icon)
title_text_x = title_block_x + icon_w_px + icon_gap
current_y = title_text_y
for line in title_lines:
draw.text((title_text_x, current_y), line, fill=0, font=title_font)
current_y += title_line_h
# Wrap author
author_lines = []
words = author.split()
current_line = ""
for word in words:
test = f"{current_line} {word}".strip()
bbox = draw.textbbox((0, 0), test, font=author_font)
if bbox[2] - bbox[0] <= content_w:
current_line = test
else:
if current_line:
author_lines.append(current_line)
current_line = word
if current_line:
author_lines.append(current_line)
author_lines = author_lines[:3]
# Draw author centered in bottom 1/3
author_line_h = 24 * author_scale # Ubuntu 10 regular advanceY ~24
author_block_h = len(author_lines) * author_line_h
author_start_y = author_zone_y + (author_zone_h - author_block_h) // 2
for line in author_lines:
bbox = draw.textbbox((0, 0), line, font=author_font)
line_w = bbox[2] - bbox[0]
line_x = content_x + (content_w - line_w) // 2
draw.text((line_x, author_start_y), line, fill=0, font=author_font)
author_start_y += author_line_h
return img
if __name__ == "__main__":
# Full cover
img = create_preview(480, 800, "A Really Long Book Title That Should Wrap", "Jane Doe")
img.save("mod/preview_cover_480x800.png")
print("Saved mod/preview_cover_480x800.png", file=sys.stderr)
# Medium thumbnail
img2 = create_preview(240, 400, "A Really Long Book Title That Should Wrap", "Jane Doe")
img2.save("mod/preview_thumb_240x400.png")
print("Saved mod/preview_thumb_240x400.png", file=sys.stderr)
# Small thumbnail
img3 = create_preview(136, 226, "A Really Long Book Title", "Jane Doe")
img3.save("mod/preview_thumb_136x226.png")
print("Saved mod/preview_thumb_136x226.png", file=sys.stderr)

View File

@@ -244,8 +244,8 @@ bool CrossPointSettings::loadFromFile() {
float CrossPointSettings::getReaderLineCompression() const {
switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY:
default:
switch (lineSpacing) {
case TIGHT:
return 0.95f;
@@ -255,6 +255,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
return 1.1f;
}
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS:
switch (lineSpacing) {
case TIGHT:
@@ -265,6 +267,8 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
return 1.0f;
}
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC:
switch (lineSpacing) {
case TIGHT:
@@ -275,6 +279,30 @@ float CrossPointSettings::getReaderLineCompression() const {
case WIDE:
return 1.0f;
}
#endif // OMIT_OPENDYSLEXIC
default:
// Fallback: use Bookerly-style compression, or Noto Sans if Bookerly is omitted
#if !defined(OMIT_BOOKERLY)
switch (lineSpacing) {
case TIGHT:
return 0.95f;
case NORMAL:
default:
return 1.0f;
case WIDE:
return 1.1f;
}
#else
switch (lineSpacing) {
case TIGHT:
return 0.90f;
case NORMAL:
default:
return 0.95f;
case WIDE:
return 1.0f;
}
#endif
}
}
@@ -312,8 +340,8 @@ int CrossPointSettings::getRefreshFrequency() const {
int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) {
#ifndef OMIT_BOOKERLY
case BOOKERLY:
default:
switch (fontSize) {
case SMALL:
return BOOKERLY_12_FONT_ID;
@@ -325,6 +353,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
return BOOKERLY_18_FONT_ID;
}
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
case NOTOSANS:
switch (fontSize) {
case SMALL:
@@ -337,6 +367,8 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
return NOTOSANS_18_FONT_ID;
}
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
case OPENDYSLEXIC:
switch (fontSize) {
case SMALL:
@@ -349,5 +381,17 @@ int CrossPointSettings::getReaderFontId() const {
case EXTRA_LARGE:
return OPENDYSLEXIC_14_FONT_ID;
}
#endif // OMIT_OPENDYSLEXIC
default:
// Fallback to first available font family at medium size
#if !defined(OMIT_BOOKERLY)
return BOOKERLY_14_FONT_ID;
#elif !defined(OMIT_NOTOSANS)
return NOTOSANS_14_FONT_ID;
#elif !defined(OMIT_OPENDYSLEXIC)
return OPENDYSLEXIC_10_FONT_ID;
#else
#error "At least one font family must be available"
#endif
}
}

View File

@@ -6,10 +6,36 @@
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Compile-time table of available font families and their enum values.
// Used by the DynamicEnum getter/setter to map between list indices and stored FONT_FAMILY values.
struct FontFamilyMapping {
const char* name;
uint8_t value;
};
inline constexpr FontFamilyMapping kFontFamilyMappings[] = {
#ifndef OMIT_BOOKERLY
{"Bookerly", CrossPointSettings::BOOKERLY},
#endif
#ifndef OMIT_NOTOSANS
{"Noto Sans", CrossPointSettings::NOTOSANS},
#endif
#ifndef OMIT_OPENDYSLEXIC
{"Open Dyslexic", CrossPointSettings::OPENDYSLEXIC},
#endif
};
inline constexpr size_t kFontFamilyMappingCount = sizeof(kFontFamilyMappings) / sizeof(kFontFamilyMappings[0]);
static_assert(kFontFamilyMappingCount > 0, "At least one font family must be available");
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline std::vector<SettingInfo> getSettingsList() {
// Build font family options from the compile-time mapping table
std::vector<std::string> fontFamilyOptions;
for (size_t i = 0; i < kFontFamilyMappingCount; i++) {
fontFamilyOptions.push_back(kFontFamilyMappings[i].name);
}
return {
// --- Display ---
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
@@ -32,8 +58,20 @@ inline std::vector<SettingInfo> getSettingsList() {
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
// --- Reader ---
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"},
"fontFamily", "Reader"),
SettingInfo::DynamicEnum(
"Font Family", std::move(fontFamilyOptions),
[]() -> uint8_t {
for (uint8_t i = 0; i < kFontFamilyMappingCount; i++) {
if (kFontFamilyMappings[i].value == SETTINGS.fontFamily) return i;
}
return 0; // fallback to first available family
},
[](uint8_t idx) {
if (idx < kFontFamilyMappingCount) {
SETTINGS.fontFamily = kFontFamilyMappings[idx].value;
}
},
"fontFamily", "Reader"),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
"Reader"),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",

View File

@@ -4,6 +4,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include <Serialization.h>
#include <Txt.h>
#include <Xtc.h>
@@ -599,6 +600,11 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastXtc.generateCoverBmp()) {
LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
}
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
return (this->*renderNoCoverSleepScreen)();
}
@@ -614,6 +620,11 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastTxt.generateCoverBmp()) {
LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
}
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "No cover image found for TXT file");
return (this->*renderNoCoverSleepScreen)();
}
@@ -630,6 +641,12 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastEpub.generateCoverBmp(cropped)) {
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
lastEpub.getAuthor(), 480, 800);
}
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)();
}

View File

@@ -5,6 +5,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include <Xtc.h>
#include <cstring>
@@ -65,45 +66,35 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Storage.exists(coverPath.c_str())) {
// If epub, try to load the metadata for title/author and cover
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = false;
// Try format-specific thumbnail generation first
if (StringUtils::checkFileExtension(book.path, ".epub")) {
Epub epub(book.path, "/.crosspoint");
// Skip loading css since we only need metadata here
epub.load(false, true);
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = epub.generateThumbBmp(coverHeight);
if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = "";
}
coverRendered = false;
updateRequired = true;
success = epub.generateThumbBmp(coverHeight);
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
StringUtils::checkFileExtension(book.path, ".xtc")) {
// Handle XTC file
Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size()));
bool success = xtc.generateThumbBmp(coverHeight);
if (!success) {
RECENT_BOOKS.updateBook(book.path, book.title, book.author, "");
book.coverBmpPath = "";
}
coverRendered = false;
updateRequired = true;
success = xtc.generateThumbBmp(coverHeight);
}
}
// Fallback: generate a placeholder thumbnail with title/author
if (!success && !Storage.exists(coverPath.c_str())) {
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
coverRendered = false;
updateRequired = true;
}
}
progress++;

View File

@@ -450,8 +450,16 @@ void DictionaryDefinitionActivity::loop() {
updateRequired = true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (onDone) {
onDone();
} else {
onBack();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
@@ -491,8 +499,8 @@ void DictionaryDefinitionActivity::renderScreen() {
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");
// Button hints (bottom face buttons)
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", onDone ? "Done" : "", "\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)

View File

@@ -14,13 +14,15 @@ class DictionaryDefinitionActivity final : public Activity {
public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, const std::function<void()>& onBack)
uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone = nullptr)
: Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword),
definition(definition),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack) {}
onBack(onBack),
onDone(onDone) {}
void onEnter() override;
void onExit() override;
@@ -53,6 +55,7 @@ class DictionaryDefinitionActivity final : public Activity {
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void()> onDone;
std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0;

View File

@@ -0,0 +1,141 @@
#include "DictionarySuggestionsActivity.h"
#include <GfxRenderer.h>
#include "DictionaryDefinitionActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
void DictionarySuggestionsActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionarySuggestionsActivity*>(param);
self->displayTaskLoop();
}
void DictionarySuggestionsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionarySuggestionsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
xTaskCreate(&DictionarySuggestionsActivity::taskTrampoline, "DictSugTask", 4096, this, 1, &displayTaskHandle);
}
void DictionarySuggestionsActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void DictionarySuggestionsActivity::loop() {
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return;
}
if (suggestions.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
}
return;
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(suggestions.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const std::string& selected = suggestions[selectedIndex];
std::string definition = Dictionary::lookup(selected);
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, selected, definition, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
void DictionarySuggestionsActivity::renderScreen() {
renderer.clearScreen();
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int leftPadding = contentX + metrics.contentSidePadding;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Header
GUI.drawHeader(
renderer,
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Did you mean?");
// Subtitle: the original word (manual, below header)
const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5;
std::string subtitle = "\"" + originalWord + "\" not found";
renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str());
// Suggestion list
const int listTop = subtitleY + 25;
const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
GUI.drawList(
renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex,
[this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr);
// Button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class DictionarySuggestionsActivity final : public ActivityWithSubactivity {
public:
explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& originalWord, const std::vector<std::string>& suggestions,
int readerFontId, uint8_t orientation, const std::string& cachePath,
const std::function<void()>& onBack, const std::function<void()>& onDone)
: ActivityWithSubactivity("DictionarySuggestions", renderer, mappedInput),
originalWord(originalWord),
suggestions(suggestions),
readerFontId(readerFontId),
orientation(orientation),
cachePath(cachePath),
onBack(onBack),
onDone(onDone) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string originalWord;
std::vector<std::string> suggestions;
int readerFontId;
uint8_t orientation;
std::string cachePath;
const std::function<void()> onBack;
const std::function<void()> onDone;
int selectedIndex = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
};

View File

@@ -6,6 +6,8 @@
#include <climits>
#include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -19,7 +21,7 @@ void DictionaryWordSelectActivity::taskTrampoline(void* param) {
void DictionaryWordSelectActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
@@ -30,7 +32,7 @@ void DictionaryWordSelectActivity::displayTaskLoop() {
}
void DictionaryWordSelectActivity::onEnter() {
Activity::onEnter();
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
extractWords();
mergeHyphenatedWords();
@@ -43,7 +45,7 @@ void DictionaryWordSelectActivity::onEnter() {
}
void DictionaryWordSelectActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@@ -82,9 +84,55 @@ void DictionaryWordSelectActivity::extractWords() {
while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop;
int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str());
const std::string& wordText = *wordIt;
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
std::vector<size_t> splitStarts;
size_t partStart = 0;
for (size_t i = 0; i < wordText.size();) {
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(wordText[i + 2]) == 0x94)) {
if (i > partStart) splitStarts.push_back(partStart);
i += 3;
partStart = i;
} else {
i++;
}
}
if (partStart < wordText.size()) splitStarts.push_back(partStart);
if (splitStarts.size() <= 1 && partStart == 0) {
// No dashes found -- add as a single word
int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str());
words.push_back({wordText, screenX, screenY, wordWidth, 0});
} else {
// Add each part as a separate selectable word
for (size_t si = 0; si < splitStarts.size(); si++) {
size_t start = splitStarts[si];
size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size();
// Find actual end by trimming any trailing dash bytes
size_t textEnd = end;
while (textEnd > start && textEnd <= wordText.size()) {
if (textEnd >= 3 && static_cast<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
static_cast<uint8_t>(wordText[textEnd - 1]) == 0x94)) {
textEnd -= 3;
} else {
break;
}
}
std::string part = wordText.substr(start, textEnd - start);
if (part.empty()) continue;
std::string prefix = wordText.substr(0, start);
int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str());
int16_t partWidth = renderer.getTextWidth(fontId, part.c_str());
words.push_back({part, static_cast<int16_t>(screenX + offsetX), screenY, partWidth, 0});
}
}
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
++wordIt;
++xIt;
}
@@ -146,11 +194,53 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() {
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
}
// Cross-page hyphenation: last word on page + first word of next page
if (!nextPageFirstWord.empty() && !rows.empty()) {
int lastWordIdx = rows.back().wordIndices.back();
const std::string& lastWord = words[lastWordIdx].text;
if (!lastWord.empty()) {
bool endsWithHyphen = false;
if (lastWord.back() == '-') {
endsWithHyphen = true;
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
endsWithHyphen = true;
}
if (endsWithHyphen) {
std::string firstPart = lastWord;
if (firstPart.back() == '-') {
firstPart.pop_back();
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
firstPart.erase(firstPart.size() - 2);
}
std::string merged = firstPart + nextPageFirstWord;
words[lastWordIdx].lookupText = merged;
}
}
}
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
}
void DictionaryWordSelectActivity::loop() {
// Delegate to subactivity (definition/suggestions screen) if active
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onBack();
}
return;
}
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
@@ -297,16 +387,40 @@ void DictionaryWordSelectActivity::loop() {
return;
}
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
LookupHistory::addWord(cachePath, cleaned);
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, cleaned, definition, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
LookupHistory::addWord(cachePath, cleaned);
onLookup(cleaned, definition);
// Try stem variants (e.g., "jumped" -> "jump")
auto stems = Dictionary::getStemVariants(cleaned);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, stem, stemDef, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
}
// Find similar words for suggestions
auto similar = Dictionary::findSimilar(cleaned, 6);
if (!similar.empty()) {
enterNewActivity(new DictionarySuggestionsActivity(
renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}

View File

@@ -9,16 +9,16 @@
#include <string>
#include <vector>
#include "../Activity.h"
#include "../ActivityWithSubactivity.h"
class DictionaryWordSelectActivity final : public Activity {
class DictionaryWordSelectActivity final : public ActivityWithSubactivity {
public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation,
const std::function<void()>& onBack,
const std::function<void(const std::string&, const std::string&)>& onLookup)
: Activity("DictionaryWordSelect", renderer, mappedInput),
const std::string& nextPageFirstWord = "")
: ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)),
fontId(fontId),
marginLeft(marginLeft),
@@ -26,7 +26,7 @@ class DictionaryWordSelectActivity final : public Activity {
cachePath(cachePath),
orientation(orientation),
onBack(onBack),
onLookup(onLookup) {}
nextPageFirstWord(nextPageFirstWord) {}
void onEnter() override;
void onExit() override;
@@ -58,13 +58,15 @@ class DictionaryWordSelectActivity final : public Activity {
std::string cachePath;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void(const std::string&, const std::string&)> onLookup;
std::string nextPageFirstWord;
std::vector<WordInfo> words;
std::vector<Row> rows;
int currentRow = 0;
int currentWordInRow = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;

View File

@@ -6,6 +6,8 @@
#include <HalStorage.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderBookmarkSelectionActivity.h"
@@ -19,7 +21,6 @@
#include "fontIds.h"
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -127,15 +128,31 @@ void EpubReaderActivity::onEnter() {
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
epub->generateCoverBmp(false);
// Fallback: generate placeholder if real cover extraction failed
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(false), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress();
}
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
epub->generateCoverBmp(true);
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
PlaceholderCoverGenerator::generate(epub->getCoverBmpPath(true), epub->getTitle(), epub->getAuthor(), 480,
800);
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(epub->getThumbBmpPath(thumbHeight), epub->getTitle(),
epub->getAuthor(), thumbWidth, thumbHeight);
}
updateProgress();
}
}
@@ -665,24 +682,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
const std::string bookCachePath = epub->getCachePath();
const uint8_t currentOrientation = SETTINGS.orientation;
// Get first word of next page for cross-page hyphenation
std::string nextPageFirstWord;
if (section && section->currentPage < section->pageCount - 1) {
int savedPage = section->currentPage;
section->currentPage = savedPage + 1;
auto nextPage = section->loadPageFromSectionFile();
section->currentPage = savedPage;
if (nextPage && !nextPage->elements.empty()) {
const auto* firstLine = static_cast<const PageLine*>(nextPage->elements[0].get());
if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) {
nextPageFirstWord = firstLine->getBlock()->getWords().front();
}
}
}
exitActivity();
if (pageForLookup) {
enterNewActivity(new DictionaryWordSelectActivity(
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
bookCachePath, currentOrientation,
[this]() {
// On back from word select
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword,
const std::string& definition) {
// On successful lookup - show definition
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition,
readerFontId, currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
}
xSemaphoreGive(renderingMutex);
@@ -690,36 +710,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
const std::string bookCachePath = epub->getCachePath();
const int readerFontId = SETTINGS.getReaderFontId();
const uint8_t currentOrientation = SETTINGS.orientation;
exitActivity();
enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, bookCachePath,
[this]() {
// On back from looked up words
pendingSubactivityExit = true;
},
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) {
// Look up the word and show definition with progress bar
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
std::string definition = Dictionary::lookup(
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
if (definition.empty()) {
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
return;
}
exitActivity();
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId,
currentOrientation,
[this]() { pendingSubactivityExit = true; }));
}));
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
xSemaphoreGive(renderingMutex);
break;
}

View File

@@ -5,7 +5,6 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "DictionaryDefinitionActivity.h"
#include "DictionaryWordSelectActivity.h"
#include "EpubReaderMenuActivity.h"
#include "LookedUpWordsActivity.h"

View File

@@ -4,9 +4,12 @@
#include <algorithm>
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h"
void LookedUpWordsActivity::taskTrampoline(void* param) {
@@ -30,6 +33,7 @@ void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
updateRequired = true;
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
}
@@ -48,6 +52,16 @@ void LookedUpWordsActivity::onExit() {
void LookedUpWordsActivity::loop() {
if (subActivity) {
subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return;
}
@@ -94,18 +108,68 @@ void LookedUpWordsActivity::loop() {
return;
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size()));
const int totalItems = static_cast<int>(words.size());
const int pageItems = getPageItems();
buttonNavigator.onNextRelease([this, totalItems] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size()));
buttonNavigator.onPreviousRelease([this, totalItems] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onSelectWord(words[selectedIndex]);
const std::string& headword = words[selectedIndex];
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
std::string definition = Dictionary::lookup(
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, headword, definition, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
// Try stem variants
auto stems = Dictionary::getStemVariants(headword);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, stem, stemDef, readerFontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
}
// Show similar word suggestions
auto similar = Dictionary::findSimilar(headword, 6);
if (!similar.empty()) {
enterNewActivity(new DictionarySuggestionsActivity(
renderer, mappedInput, headword, similar, readerFontId, orientation, cachePath,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS);
updateRequired = true;
return;
}
@@ -115,39 +179,46 @@ void LookedUpWordsActivity::loop() {
}
}
int LookedUpWordsActivity::getPageItems() const {
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
return std::max(1, contentHeight / metrics.listRowHeight);
}
void LookedUpWordsActivity::renderScreen() {
renderer.clearScreen();
constexpr int sidePadding = 20;
constexpr int titleY = 15;
constexpr int startY = 60;
constexpr int lineHeight = 30;
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Title
const int titleX =
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD);
// Header
GUI.drawHeader(
renderer,
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Lookup History");
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (words.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet");
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
} else {
const int screenHeight = renderer.getScreenHeight();
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight);
const int pageStart = selectedIndex / pageItems * pageItems;
for (int i = 0; i < pageItems; i++) {
int idx = pageStart + i;
if (idx >= static_cast<int>(words.size())) break;
const int displayY = startY + i * lineHeight;
const bool isSelected = (idx == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight);
}
renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected);
}
GUI.drawList(
renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
[this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
@@ -161,12 +232,12 @@ void LookedUpWordsActivity::renderScreen() {
std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15;
constexpr int popupY = 200;
const int popupY = 200 + hintGutterHeight;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
@@ -183,12 +254,14 @@ void LookedUpWordsActivity::renderScreen() {
if (!words.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
renderer.drawText(SMALL_FONT_ID, hintX,
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
deleteHint);
}
// Normal button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v");
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}

View File

@@ -13,12 +13,14 @@
class LookedUpWordsActivity final : public ActivityWithSubactivity {
public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
const std::function<void()>& onBack,
const std::function<void(const std::string&)>& onSelectWord)
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack),
onSelectWord(onSelectWord) {}
onDone(onDone) {}
void onEnter() override;
void onExit() override;
@@ -26,12 +28,16 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
private:
std::string cachePath;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack;
const std::function<void(const std::string&)> onSelectWord;
const std::function<void()> onDone;
std::vector<std::string> words;
int selectedIndex = 0;
bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator;
// Delete confirmation state
@@ -42,6 +48,7 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int getPageItems() const;
void renderScreen();
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();

View File

@@ -5,6 +5,8 @@
#include <Serialization.h>
#include <Utf8.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
@@ -57,13 +59,43 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir();
// Prerender cover on first open so the Sleep screen is instant.
// generateCoverBmp() is a no-op if the file already exists, so this only does work once.
// TXT has no thumbnail support, so only the sleep screen cover is generated.
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
txt->generateCoverBmp();
GUI.fillPopupProgress(renderer, popupRect, 100);
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
// Each generate* call is a no-op if the file already exists, so this only does work once.
{
int totalSteps = 0;
if (!Storage.exists(txt->getCoverBmpPath().c_str())) totalSteps++;
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
}
if (totalSteps > 0) {
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
int completedSteps = 0;
auto updateProgress = [&]() {
completedSteps++;
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
};
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
const bool coverGenerated = txt->generateCoverBmp();
// Fallback: generate placeholder if no cover image was found
if (!coverGenerated) {
PlaceholderCoverGenerator::generate(txt->getCoverBmpPath(), txt->getTitle(), "", 480, 800);
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(txt->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
// TXT has no native thumbnail generation, always use placeholder
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(txt->getThumbBmpPath(thumbHeight), txt->getTitle(), "", thumbWidth,
thumbHeight);
updateProgress();
}
}
}
}
// Save current txt as last opened file and add to recent books
@@ -71,7 +103,7 @@ void TxtReaderActivity::onEnter() {
auto fileName = filePath.substr(filePath.rfind('/') + 1);
APP_STATE.openEpubPath = filePath;
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(filePath, fileName, "", "");
RECENT_BOOKS.addBook(filePath, fileName, "", txt->getThumbBmpPath());
// Trigger first update
updateRequired = true;

View File

@@ -11,6 +11,8 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <PlaceholderCoverGenerator.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
@@ -63,11 +65,22 @@ void XtcReaderActivity::onEnter() {
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
xtc->generateCoverBmp();
// Fallback: generate placeholder if first-page cover extraction failed
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
PlaceholderCoverGenerator::generate(xtc->getCoverBmpPath(), xtc->getTitle(), xtc->getAuthor(), 480, 800);
}
updateProgress();
}
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
// Fallback: generate placeholder thumbnail
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
const int thumbHeight = PRERENDER_THUMB_HEIGHTS[i];
const int thumbWidth = static_cast<int>(thumbHeight * 0.6);
PlaceholderCoverGenerator::generate(xtc->getThumbBmpPath(thumbHeight), xtc->getTitle(), xtc->getAuthor(),
thumbWidth, thumbHeight);
}
updateProgress();
}
}

View File

@@ -173,6 +173,9 @@ void SettingsActivity::toggleCurrentSetting() {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
const uint8_t currentValue = setting.valueGetter();
setting.valueSetter((currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()));
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
@@ -274,6 +277,11 @@ void SettingsActivity::render() const {
} else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settings[i].valuePtr);
valueText = settings[i].enumValues[value];
} else if (settings[i].type == SettingType::ENUM && settings[i].valueGetter) {
const uint8_t value = settings[i].valueGetter();
if (value < settings[i].enumValues.size()) {
valueText = settings[i].enumValues[value];
}
} else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
}

View File

@@ -39,13 +39,16 @@ GfxRenderer renderer(display);
Activity* currentActivity;
// Fonts
#ifndef OMIT_BOOKERLY
EpdFont bookerly14RegularFont(&bookerly_14_regular);
EpdFont bookerly14BoldFont(&bookerly_14_bold);
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
&bookerly14BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
EpdFont bookerly12RegularFont(&bookerly_12_regular);
EpdFont bookerly12BoldFont(&bookerly_12_bold);
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
@@ -64,7 +67,9 @@ EpdFont bookerly18ItalicFont(&bookerly_18_italic);
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
&bookerly18BoldItalicFont);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
EpdFont notosans12RegularFont(&notosans_12_regular);
EpdFont notosans12BoldFont(&notosans_12_bold);
EpdFont notosans12ItalicFont(&notosans_12_italic);
@@ -89,7 +94,9 @@ EpdFont notosans18ItalicFont(&notosans_18_italic);
EpdFont notosans18BoldItalicFont(&notosans_18_bolditalic);
EpdFontFamily notosans18FontFamily(&notosans18RegularFont, &notosans18BoldFont, &notosans18ItalicFont,
&notosans18BoldItalicFont);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
@@ -114,6 +121,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
&opendyslexic14BoldItalicFont);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS
EpdFont smallFont(&notosans_8_regular);
@@ -259,20 +267,28 @@ void setupDisplayAndFonts() {
display.begin();
renderer.begin();
LOG_DBG("MAIN", "Display initialized");
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#endif
#ifndef OMIT_FONTS
#ifndef OMIT_BOOKERLY
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
#endif // OMIT_BOOKERLY
#ifndef OMIT_NOTOSANS
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily);
#endif // OMIT_NOTOSANS
#ifndef OMIT_OPENDYSLEXIC
renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily);
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
#endif // OMIT_OPENDYSLEXIC
#endif // OMIT_FONTS
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);

View File

@@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function<void
if (onProgress) onProgress(100);
return result;
}
std::vector<std::string> Dictionary::getStemVariants(const std::string& word) {
std::vector<std::string> variants;
size_t len = word.size();
if (len < 3) return variants;
auto endsWith = [&word, len](const char* suffix) {
size_t slen = strlen(suffix);
return len >= slen && word.compare(len - slen, slen, suffix) == 0;
};
auto add = [&variants](const std::string& s) {
if (s.size() >= 2) variants.push_back(s);
};
// Plurals (longer suffixes first to avoid partial matches)
if (endsWith("sses")) add(word.substr(0, len - 2));
if (endsWith("ses")) add(word.substr(0, len - 2) + "is"); // analyses -> analysis
if (endsWith("ies")) {
add(word.substr(0, len - 3) + "y");
add(word.substr(0, len - 2)); // dies -> die, ties -> tie
}
if (endsWith("ves")) {
add(word.substr(0, len - 3) + "f"); // wolves -> wolf
add(word.substr(0, len - 3) + "fe"); // knives -> knife
add(word.substr(0, len - 1)); // misgives -> misgive
}
if (endsWith("men")) add(word.substr(0, len - 3) + "man"); // firemen -> fireman
if (endsWith("es") && !endsWith("sses") && !endsWith("ies") && !endsWith("ves")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
}
if (endsWith("s") && !endsWith("ss") && !endsWith("us") && !endsWith("es")) {
add(word.substr(0, len - 1));
}
// Past tense
if (endsWith("ied")) {
add(word.substr(0, len - 3) + "y");
add(word.substr(0, len - 1));
}
if (endsWith("ed") && !endsWith("ied")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
if (len > 4 && word[len - 3] == word[len - 4]) {
add(word.substr(0, len - 3));
}
}
// Progressive
if (endsWith("ying")) {
add(word.substr(0, len - 4) + "ie");
}
if (endsWith("ing") && !endsWith("ying")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 3) + "e");
if (len > 5 && word[len - 4] == word[len - 5]) {
add(word.substr(0, len - 4));
}
}
// Adverb
if (endsWith("ically")) {
add(word.substr(0, len - 6) + "ic"); // historically -> historic
add(word.substr(0, len - 4)); // basically -> basic
}
if (endsWith("ally") && !endsWith("ically")) {
add(word.substr(0, len - 4) + "al"); // accidentally -> accidental
add(word.substr(0, len - 2)); // naturally -> natur... (fallback to -ly strip)
}
if (endsWith("ily") && !endsWith("ally")) {
add(word.substr(0, len - 3) + "y");
}
if (endsWith("ly") && !endsWith("ily") && !endsWith("ally")) {
add(word.substr(0, len - 2));
}
// Comparative / superlative
if (endsWith("ier")) {
add(word.substr(0, len - 3) + "y");
}
if (endsWith("er") && !endsWith("ier")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 1));
if (len > 4 && word[len - 3] == word[len - 4]) {
add(word.substr(0, len - 3));
}
}
if (endsWith("iest")) {
add(word.substr(0, len - 4) + "y");
}
if (endsWith("est") && !endsWith("iest")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 2));
if (len > 5 && word[len - 4] == word[len - 5]) {
add(word.substr(0, len - 4));
}
}
// Derivational suffixes
if (endsWith("ness")) add(word.substr(0, len - 4));
if (endsWith("ment")) add(word.substr(0, len - 4));
if (endsWith("ful")) add(word.substr(0, len - 3));
if (endsWith("less")) add(word.substr(0, len - 4));
if (endsWith("able")) {
add(word.substr(0, len - 4));
add(word.substr(0, len - 4) + "e");
}
if (endsWith("ible")) {
add(word.substr(0, len - 4));
add(word.substr(0, len - 4) + "e");
}
if (endsWith("ation")) {
add(word.substr(0, len - 5)); // information -> inform
add(word.substr(0, len - 5) + "e"); // exploration -> explore
add(word.substr(0, len - 5) + "ate"); // donation -> donate
}
if (endsWith("tion") && !endsWith("ation")) {
add(word.substr(0, len - 4) + "te"); // completion -> complete
add(word.substr(0, len - 3)); // action -> act
add(word.substr(0, len - 3) + "e"); // reduction -> reduce
}
if (endsWith("ion") && !endsWith("tion")) {
add(word.substr(0, len - 3)); // revision -> revis (-> revise via +e)
add(word.substr(0, len - 3) + "e"); // revision -> revise
}
if (endsWith("al") && !endsWith("ial")) {
add(word.substr(0, len - 2));
add(word.substr(0, len - 2) + "e");
}
if (endsWith("ial")) {
add(word.substr(0, len - 3));
add(word.substr(0, len - 3) + "e");
}
if (endsWith("ous")) {
add(word.substr(0, len - 3)); // dangerous -> danger
add(word.substr(0, len - 3) + "e"); // famous -> fame
}
if (endsWith("ive")) {
add(word.substr(0, len - 3)); // active -> act
add(word.substr(0, len - 3) + "e"); // creative -> create
}
if (endsWith("ize")) {
add(word.substr(0, len - 3)); // modernize -> modern
add(word.substr(0, len - 3) + "e");
}
if (endsWith("ise")) {
add(word.substr(0, len - 3)); // advertise -> advert
add(word.substr(0, len - 3) + "e");
}
if (endsWith("en")) {
add(word.substr(0, len - 2)); // darken -> dark
add(word.substr(0, len - 2) + "e"); // widen -> wide
}
// Prefix removal
if (len > 5 && word.compare(0, 2, "un") == 0) add(word.substr(2));
if (len > 6 && word.compare(0, 3, "dis") == 0) add(word.substr(3));
if (len > 6 && word.compare(0, 3, "mis") == 0) add(word.substr(3));
if (len > 6 && word.compare(0, 3, "pre") == 0) add(word.substr(3));
if (len > 7 && word.compare(0, 4, "over") == 0) add(word.substr(4));
if (len > 5 && word.compare(0, 2, "re") == 0) add(word.substr(2));
// Deduplicate while preserving insertion order (inflectional stems first, prefixes last)
std::vector<std::string> deduped;
for (const auto& v : variants) {
if (std::find(deduped.begin(), deduped.end(), v) != deduped.end()) continue;
// cppcheck-suppress useStlAlgorithm
deduped.push_back(v);
}
return deduped;
}
int Dictionary::editDistance(const std::string& a, const std::string& b, int maxDist) {
int m = static_cast<int>(a.size());
int n = static_cast<int>(b.size());
if (std::abs(m - n) > maxDist) return maxDist + 1;
std::vector<int> dp(n + 1);
for (int j = 0; j <= n; j++) dp[j] = j;
for (int i = 1; i <= m; i++) {
int prev = dp[0];
dp[0] = i;
int rowMin = dp[0];
for (int j = 1; j <= n; j++) {
int temp = dp[j];
if (a[i - 1] == b[j - 1]) {
dp[j] = prev;
} else {
dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]});
}
prev = temp;
if (dp[j] < rowMin) rowMin = dp[j];
}
if (rowMin > maxDist) return maxDist + 1;
}
return dp[n];
}
std::vector<std::string> Dictionary::findSimilar(const std::string& word, int maxResults) {
if (!indexLoaded || sparseOffsets.empty()) return {};
FsFile idx;
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return {};
// Binary search to find the segment containing or nearest to the word
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
while (lo < hi) {
int mid = lo + (hi - lo + 1) / 2;
idx.seekSet(sparseOffsets[mid]);
std::string key = readWord(idx);
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
lo = mid;
} else {
hi = mid - 1;
}
}
// Scan entries from the segment before through the segment after the target
int startSeg = std::max(0, lo - 1);
int endSeg = std::min(static_cast<int>(sparseOffsets.size()) - 1, lo + 1);
idx.seekSet(sparseOffsets[startSeg]);
int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL;
int remaining = static_cast<int>(totalWords) - startSeg * SPARSE_INTERVAL;
if (totalToScan > remaining) totalToScan = remaining;
int maxDist = std::max(2, static_cast<int>(word.size()) / 3 + 1);
struct Candidate {
std::string text;
int distance;
};
std::vector<Candidate> candidates;
for (int i = 0; i < totalToScan; i++) {
std::string key = readWord(idx);
if (key.empty()) break;
uint8_t skip[8];
if (idx.read(skip, 8) != 8) break;
if (key == word) continue;
int dist = editDistance(key, word, maxDist);
if (dist <= maxDist) {
candidates.push_back({key, dist});
}
}
idx.close();
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b) { return a.distance < b.distance; });
std::vector<std::string> results;
for (size_t i = 0; i < candidates.size() && static_cast<int>(results.size()) < maxResults; i++) {
results.push_back(candidates[i].text);
}
return results;
}

View File

@@ -14,6 +14,8 @@ class Dictionary {
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
const std::function<bool()>& shouldCancel = nullptr);
static std::string cleanWord(const std::string& word);
static std::vector<std::string> getStemVariants(const std::string& word);
static std::vector<std::string> findSimilar(const std::string& word, int maxResults = 6);
private:
static constexpr int SPARSE_INTERVAL = 512;
@@ -28,4 +30,5 @@ class Dictionary {
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
static std::string readWord(FsFile& file);
static std::string readDefinition(uint32_t offset, uint32_t size);
static int editDistance(const std::string& a, const std::string& b, int maxDist);
};