Compare commits
3 Commits
c1dfe92ea3
...
1383d75c84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1383d75c84
|
||
|
|
632b76c9ed
|
||
|
|
5dc9d21bdb
|
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
27
lib/PlaceholderCover/BookIcon.h
Normal file
27
lib/PlaceholderCover/BookIcon.h
Normal 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,
|
||||
};
|
||||
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal 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;
|
||||
}
|
||||
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal 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);
|
||||
};
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
123
scripts/generate_book_icon.py
Normal file
123
scripts/generate_book_icon.py
Normal 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)
|
||||
179
scripts/preview_placeholder_cover.py
Normal file
179
scripts/preview_placeholder_cover.py
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)();
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal 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);
|
||||
}
|
||||
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal 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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
16
src/main.cpp
16
src/main.cpp
@@ -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(¬osans_12_regular);
|
||||
EpdFont notosans12BoldFont(¬osans_12_bold);
|
||||
EpdFont notosans12ItalicFont(¬osans_12_italic);
|
||||
@@ -89,7 +94,9 @@ EpdFont notosans18ItalicFont(¬osans_18_italic);
|
||||
EpdFont notosans18BoldItalicFont(¬osans_18_bolditalic);
|
||||
EpdFontFamily notosans18FontFamily(¬osans18RegularFont, ¬osans18BoldFont, ¬osans18ItalicFont,
|
||||
¬osans18BoldItalicFont);
|
||||
#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(¬osans_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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user