Compare commits
9 Commits
mod/sync-u
...
51dc498768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dc498768
|
||
|
|
406c3aeace
|
||
|
|
55a1fef01a
|
||
|
|
18be265a4a
|
||
|
|
3a0641889f
|
||
|
|
ad282cadfe
|
||
|
|
c8ba4fe973
|
||
|
|
c1b8e53138
|
||
|
|
0fda9031fd
|
@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
||||
|
||||
int cursorX = startX;
|
||||
const int cursorY = startY;
|
||||
int lastBaseX = startX;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
||||
const EpdGlyph* glyph = getGlyph(cp);
|
||||
@@ -30,13 +35,32 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
||||
continue;
|
||||
}
|
||||
|
||||
*minX = std::min(*minX, cursorX + glyph->left);
|
||||
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
|
||||
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
|
||||
*maxY = std::max(*maxY, cursorY + glyph->top);
|
||||
const bool isCombining = utf8IsCombiningMark(cp);
|
||||
int raiseBy = 0;
|
||||
if (isCombining && hasBaseGlyph) {
|
||||
const int currentGap = glyph->top - glyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
|
||||
const int glyphBaseY = cursorY - raiseBy;
|
||||
|
||||
*minX = std::min(*minX, glyphBaseX + glyph->left);
|
||||
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
|
||||
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
|
||||
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
|
||||
|
||||
if (!isCombining) {
|
||||
lastBaseX = cursorX;
|
||||
lastBaseAdvance = glyph->advanceX;
|
||||
lastBaseTop = glyph->top;
|
||||
hasBaseGlyph = true;
|
||||
cursorX += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
|
||||
int minX = 0, minY = 0, maxX = 0, maxY = 0;
|
||||
|
||||
@@ -213,23 +213,23 @@ bool Epub::parseTocNavFile() const {
|
||||
}
|
||||
|
||||
void Epub::parseCssFiles() const {
|
||||
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
||||
// Larger files risk memory exhaustion on ESP32
|
||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
||||
// Minimum heap required before attempting CSS parsing
|
||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024;
|
||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024;
|
||||
|
||||
if (cssFiles.empty()) {
|
||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||
}
|
||||
|
||||
// See if we have a cached version of the CSS rules
|
||||
if (!cssParser->hasCache()) {
|
||||
// No cache yet - parse CSS files
|
||||
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
||||
|
||||
if (cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
||||
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
||||
@@ -237,7 +237,6 @@ void Epub::parseCssFiles() const {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check CSS file size before decompressing - skip files that are too large
|
||||
size_t cssFileSize = 0;
|
||||
if (getItemSize(cssPath, &cssFileSize)) {
|
||||
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
||||
@@ -247,7 +246,6 @@ void Epub::parseCssFiles() const {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract CSS file to temp location
|
||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||
FsFile tempCssFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||
@@ -262,7 +260,6 @@ void Epub::parseCssFiles() const {
|
||||
}
|
||||
tempCssFile.close();
|
||||
|
||||
// Parse the CSS file
|
||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
@@ -273,7 +270,6 @@ void Epub::parseCssFiles() const {
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
}
|
||||
|
||||
// Save to cache for next time
|
||||
if (!cssParser->saveToCache()) {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
@@ -281,7 +277,6 @@ void Epub::parseCssFiles() const {
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
}
|
||||
}
|
||||
|
||||
// load in the meta data for the epub file
|
||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
@@ -294,14 +289,17 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
|
||||
// Try to load existing cache first
|
||||
if (bookMetadataCache->load()) {
|
||||
if (!skipLoadingCss && !cssParser->hasCache()) {
|
||||
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
|
||||
// to get CSS file list
|
||||
if (!skipLoadingCss) {
|
||||
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
||||
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
||||
cssParser->deleteCache();
|
||||
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||
}
|
||||
parseCssFiles();
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
}
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
@@ -400,8 +398,8 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
}
|
||||
|
||||
if (!skipLoadingCss) {
|
||||
// Parse CSS files after cache reload
|
||||
parseCssFiles();
|
||||
Storage.removeDir((cachePath + "/sections").c_str());
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
|
||||
@@ -61,6 +61,49 @@ std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||
}
|
||||
|
||||
bool PageImage::isCached() const { return imageBlock->isCached(); }
|
||||
|
||||
void PageImage::renderPlaceholder(GfxRenderer& renderer, const int xOffset, const int yOffset) const {
|
||||
int x = xPos + xOffset;
|
||||
int y = yPos + yOffset;
|
||||
int w = imageBlock->getWidth();
|
||||
int h = imageBlock->getHeight();
|
||||
renderer.fillRect(x, y, w, h, true);
|
||||
if (w > 2 && h > 2) {
|
||||
renderer.fillRect(x + 1, y + 1, w - 2, h - 2, false);
|
||||
}
|
||||
}
|
||||
|
||||
void Page::renderTextOnly(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageLine) {
|
||||
element->render(renderer, fontId, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int Page::countUncachedImages() const {
|
||||
int count = 0;
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
auto* img = static_cast<PageImage*>(element.get());
|
||||
if (!img->isCached()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void Page::renderImagePlaceholders(GfxRenderer& renderer, const int xOffset, const int yOffset) const {
|
||||
for (auto& element : elements) {
|
||||
if (element->getTag() == TAG_PageImage) {
|
||||
auto* img = static_cast<PageImage*>(element.get());
|
||||
img->renderPlaceholder(renderer, xOffset, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PageTableRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -80,6 +80,8 @@ class PageImage final : public PageElement {
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||
bool isCached() const;
|
||||
void renderPlaceholder(GfxRenderer& renderer, int xOffset, int yOffset) const;
|
||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||
|
||||
// Helper to get image block dimensions (needed for bounding box calculation)
|
||||
@@ -104,4 +106,8 @@ class Page {
|
||||
// Returns true if page has images and fills out the bounding box coordinates.
|
||||
// If no images, returns false.
|
||||
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const;
|
||||
|
||||
void renderTextOnly(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||
int countUncachedImages() const;
|
||||
void renderImagePlaceholders(GfxRenderer& renderer, int xOffset, int yOffset) const;
|
||||
};
|
||||
|
||||
@@ -100,6 +100,15 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
|
||||
}
|
||||
|
||||
// Remove consumed words so size() reflects only remaining words
|
||||
if (lineCount > 0) {
|
||||
const size_t consumed = lineBreakIndices[lineCount - 1];
|
||||
words.erase(words.begin(), words.begin() + consumed);
|
||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
|
||||
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
|
||||
forceBreakAfter.erase(forceBreakAfter.begin(), forceBreakAfter.begin() + consumed);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
@@ -392,11 +401,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
|
||||
|
||||
// The remainder inherits whatever continuation status the original word had with the word after it.
|
||||
const bool originalContinuedToNext = wordContinues[wordIndex];
|
||||
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||
wordContinues[wordIndex] = false;
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||
// Preserve the prefix's attach-to-previous flag; allow a break between prefix and remainder.
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
|
||||
|
||||
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
|
||||
if (!forceBreakAfter.empty()) {
|
||||
|
||||
@@ -93,6 +93,11 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ImageBlock::isCached() const {
|
||||
std::string cachePath = getCachePath(imagePath);
|
||||
return Storage.exists(cachePath.c_str());
|
||||
}
|
||||
|
||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ImageBlock final : public Block {
|
||||
int16_t getHeight() const { return height; }
|
||||
|
||||
bool imageExists() const;
|
||||
bool isCached() const;
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
@@ -90,6 +90,32 @@ int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
||||
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
||||
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
||||
|
||||
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
|
||||
// and each scanline includes a leading filter byte.
|
||||
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
|
||||
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
|
||||
// PNGdec can overrun its internal buffer before our draw callback executes.
|
||||
int bytesPerPixelFromType(int pixelType) {
|
||||
switch (pixelType) {
|
||||
case PNG_PIXEL_TRUECOLOR:
|
||||
return 3;
|
||||
case PNG_PIXEL_GRAY_ALPHA:
|
||||
return 2;
|
||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
||||
return 4;
|
||||
case PNG_PIXEL_GRAYSCALE:
|
||||
case PNG_PIXEL_INDEXED:
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
|
||||
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
|
||||
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
|
||||
return ((pitch + 1) * 2) + 32;
|
||||
}
|
||||
|
||||
// Convert entire source line to grayscale with alpha blending to white background.
|
||||
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
||||
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
||||
@@ -304,6 +330,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
|
||||
ctx.scale, png->getBpp());
|
||||
|
||||
const int pixelType = png->getPixelType();
|
||||
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
|
||||
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
|
||||
LOG_ERR("PNG",
|
||||
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
|
||||
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
|
||||
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
|
||||
png->close();
|
||||
delete png;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (png->getBpp() != 8) {
|
||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ std::string CssParser::normalized(const std::string& s) {
|
||||
}
|
||||
|
||||
// Remove trailing space
|
||||
if (!result.empty() && result.back() == ' ') {
|
||||
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
@@ -189,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) return CssLength{};
|
||||
CssLength result;
|
||||
tryInterpretLength(val, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) {
|
||||
out = CssLength{};
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find where the number ends
|
||||
size_t unitStart = v.size();
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
@@ -205,12 +213,13 @@ CssLength CssParser::interpretLength(const std::string& val) {
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
// Parse numeric value
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
|
||||
if (endPtr == numPart.c_str()) {
|
||||
out = CssLength{};
|
||||
return false; // No number parsed (e.g. auto, inherit, initial)
|
||||
}
|
||||
|
||||
// Determine unit type (preserve for deferred resolution)
|
||||
auto unit = CssUnit::Pixels;
|
||||
if (unitPart == "em") {
|
||||
unit = CssUnit::Em;
|
||||
@@ -221,9 +230,9 @@ CssLength CssParser::interpretLength(const std::string& val) {
|
||||
} else if (unitPart == "%") {
|
||||
unit = CssUnit::Percent;
|
||||
}
|
||||
// px and unitless default to Pixels
|
||||
|
||||
return CssLength{numericValue, unit};
|
||||
out = CssLength{numericValue, unit};
|
||||
return true;
|
||||
}
|
||||
// Declaration parsing
|
||||
|
||||
@@ -295,11 +304,20 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
|
||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
|
||||
1;
|
||||
}
|
||||
} else if (propNameBuf == "height") {
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
style.imageHeight = len;
|
||||
style.defined.imageHeight = 1;
|
||||
}
|
||||
} else if (propNameBuf == "width") {
|
||||
style.width = interpretLength(propValueBuf);
|
||||
CssLength len;
|
||||
if (tryInterpretLength(propValueBuf, len)) {
|
||||
style.width = len;
|
||||
style.defined.width = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
CssStyle style;
|
||||
@@ -346,6 +364,17 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons
|
||||
std::string key = normalized(sel);
|
||||
if (key.empty()) continue;
|
||||
|
||||
// Skip unsupported selector types to reduce memory usage.
|
||||
// We only match: tag, tag.class, .class
|
||||
if (key.find('+') != std::string::npos) continue; // adjacent sibling
|
||||
if (key.find('>') != std::string::npos) continue; // child combinator
|
||||
if (key.find('[') != std::string::npos) continue; // attribute selector
|
||||
if (key.find(':') != std::string::npos) continue; // pseudo selector
|
||||
if (key.find('#') != std::string::npos) continue; // ID selector
|
||||
if (key.find('~') != std::string::npos) continue; // general sibling
|
||||
if (key.find('*') != std::string::npos) continue; // wildcard
|
||||
if (key.find(' ') != std::string::npos) continue; // descendant combinator
|
||||
|
||||
// Skip if this would exceed the rule limit
|
||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
||||
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
|
||||
@@ -531,6 +560,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
||||
result.applyOver(tagIt->second);
|
||||
}
|
||||
|
||||
// TODO: Support combinations of classes (e.g. style on .class1.class2)
|
||||
// 2. Apply class styles (medium priority)
|
||||
if (!classAttr.empty()) {
|
||||
const auto classes = splitWhitespace(classAttr);
|
||||
@@ -544,6 +574,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
|
||||
// 3. Apply element.class styles (higher priority)
|
||||
for (const auto& cls : classes) {
|
||||
std::string combinedKey = tag + "." + normalized(cls);
|
||||
@@ -564,12 +595,15 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
|
||||
|
||||
// Cache serialization
|
||||
|
||||
// Cache format version - increment when format changes
|
||||
constexpr uint8_t CSS_CACHE_VERSION = 2;
|
||||
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
|
||||
constexpr char rulesCache[] = "/css_rules.cache";
|
||||
|
||||
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
|
||||
|
||||
void CssParser::deleteCache() const {
|
||||
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
|
||||
}
|
||||
|
||||
bool CssParser::saveToCache() const {
|
||||
if (cachePath.empty()) {
|
||||
return false;
|
||||
@@ -581,7 +615,7 @@ bool CssParser::saveToCache() const {
|
||||
}
|
||||
|
||||
// Write version
|
||||
file.write(CSS_CACHE_VERSION);
|
||||
file.write(CssParser::CSS_CACHE_VERSION);
|
||||
|
||||
// Write rule count
|
||||
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
||||
@@ -616,6 +650,8 @@ bool CssParser::saveToCache() const {
|
||||
writeLength(style.paddingBottom);
|
||||
writeLength(style.paddingLeft);
|
||||
writeLength(style.paddingRight);
|
||||
writeLength(style.imageHeight);
|
||||
writeLength(style.width);
|
||||
|
||||
// Write defined flags as uint16_t
|
||||
uint16_t definedBits = 0;
|
||||
@@ -632,6 +668,8 @@ bool CssParser::saveToCache() const {
|
||||
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
||||
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
||||
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
||||
if (style.defined.width) definedBits |= 1 << 13;
|
||||
if (style.defined.imageHeight) definedBits |= 1 << 14;
|
||||
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
||||
}
|
||||
|
||||
@@ -655,9 +693,11 @@ bool CssParser::loadFromCache() {
|
||||
|
||||
// Read and verify version
|
||||
uint8_t version = 0;
|
||||
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
|
||||
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
|
||||
CssParser::CSS_CACHE_VERSION);
|
||||
file.close();
|
||||
Storage.remove((cachePath + rulesCache).c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -733,7 +773,8 @@ bool CssParser::loadFromCache() {
|
||||
|
||||
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
|
||||
!readLength(style.imageHeight) || !readLength(style.width)) {
|
||||
rulesBySelector_.clear();
|
||||
file.close();
|
||||
return false;
|
||||
@@ -759,6 +800,8 @@ bool CssParser::loadFromCache() {
|
||||
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
||||
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
||||
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
||||
style.defined.width = (definedBits & 1 << 13) != 0;
|
||||
style.defined.imageHeight = (definedBits & 1 << 14) != 0;
|
||||
|
||||
rulesBySelector_[selector] = style;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,11 @@ class CssParser {
|
||||
*/
|
||||
bool hasCache() const;
|
||||
|
||||
/**
|
||||
* Delete CSS rules cache file if it exists
|
||||
*/
|
||||
void deleteCache() const;
|
||||
|
||||
/**
|
||||
* Save parsed CSS rules to a cache file.
|
||||
* @return true if cache was written successfully
|
||||
@@ -91,10 +96,14 @@ class CssParser {
|
||||
/**
|
||||
* Load CSS rules from a cache file.
|
||||
* Clears any existing rules before loading.
|
||||
* Removes stale cache file on version mismatch.
|
||||
* @return true if cache was loaded successfully
|
||||
*/
|
||||
bool loadFromCache();
|
||||
|
||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
||||
static constexpr uint8_t CSS_CACHE_VERSION = 3;
|
||||
|
||||
private:
|
||||
// Storage: maps normalized selector -> style properties
|
||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||
@@ -113,6 +122,7 @@ class CssParser {
|
||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||
static CssLength interpretLength(const std::string& val);
|
||||
static bool tryInterpretLength(const std::string& val, CssLength& out);
|
||||
|
||||
// String utilities
|
||||
static std::string normalized(const std::string& s);
|
||||
|
||||
@@ -70,6 +70,7 @@ struct CssPropertyFlags {
|
||||
uint16_t paddingLeft : 1;
|
||||
uint16_t paddingRight : 1;
|
||||
uint16_t width : 1;
|
||||
uint16_t imageHeight : 1;
|
||||
|
||||
CssPropertyFlags()
|
||||
: textAlign(0),
|
||||
@@ -85,18 +86,20 @@ struct CssPropertyFlags {
|
||||
paddingBottom(0),
|
||||
paddingLeft(0),
|
||||
paddingRight(0),
|
||||
width(0) {}
|
||||
width(0),
|
||||
imageHeight(0) {}
|
||||
|
||||
[[nodiscard]] bool anySet() const {
|
||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width;
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width ||
|
||||
imageHeight;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||
width = 0;
|
||||
width = imageHeight = 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +121,8 @@ struct CssStyle {
|
||||
CssLength paddingBottom; // Padding after
|
||||
CssLength paddingLeft; // Padding left
|
||||
CssLength paddingRight; // Padding right
|
||||
CssLength width; // Element width (used for table columns/cells)
|
||||
CssLength width; // Element width (used for table columns/cells and image sizing)
|
||||
CssLength imageHeight; // Height for img (e.g. 2em) -- width derived from aspect ratio when only height set
|
||||
|
||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||
|
||||
@@ -181,6 +185,10 @@ struct CssStyle {
|
||||
width = base.width;
|
||||
defined.width = 1;
|
||||
}
|
||||
if (base.hasImageHeight()) {
|
||||
imageHeight = base.imageHeight;
|
||||
defined.imageHeight = 1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
||||
@@ -197,6 +205,7 @@ struct CssStyle {
|
||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||
[[nodiscard]] bool hasWidth() const { return defined.width; }
|
||||
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
|
||||
|
||||
void reset() {
|
||||
textAlign = CssTextAlign::Left;
|
||||
@@ -207,6 +216,7 @@ struct CssStyle {
|
||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||
width = CssLength{};
|
||||
imageHeight = CssLength{};
|
||||
defined.clearAll();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,6 +174,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* current = ptr;
|
||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
|
||||
// a previous base character that can be composed into a single
|
||||
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
|
||||
// composition here. This provides lightweight NFC-like behavior for
|
||||
// common Western European diacritics (acute, grave, circumflex, tilde,
|
||||
// diaeresis, cedilla) without pulling in a full Unicode normalization
|
||||
// library.
|
||||
if (!cps.empty()) {
|
||||
uint32_t prev = cps.back().value;
|
||||
uint32_t composed = 0;
|
||||
switch (cp) {
|
||||
case 0x0300: // grave
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C0;
|
||||
break; // A -> À
|
||||
case 0x0061:
|
||||
composed = 0x00E0;
|
||||
break; // a -> à
|
||||
case 0x0045:
|
||||
composed = 0x00C8;
|
||||
break; // E -> È
|
||||
case 0x0065:
|
||||
composed = 0x00E8;
|
||||
break; // e -> è
|
||||
case 0x0049:
|
||||
composed = 0x00CC;
|
||||
break; // I -> Ì
|
||||
case 0x0069:
|
||||
composed = 0x00EC;
|
||||
break; // i -> ì
|
||||
case 0x004F:
|
||||
composed = 0x00D2;
|
||||
break; // O -> Ò
|
||||
case 0x006F:
|
||||
composed = 0x00F2;
|
||||
break; // o -> ò
|
||||
case 0x0055:
|
||||
composed = 0x00D9;
|
||||
break; // U -> Ù
|
||||
case 0x0075:
|
||||
composed = 0x00F9;
|
||||
break; // u -> ù
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0301: // acute
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C1;
|
||||
break; // A -> Á
|
||||
case 0x0061:
|
||||
composed = 0x00E1;
|
||||
break; // a -> á
|
||||
case 0x0045:
|
||||
composed = 0x00C9;
|
||||
break; // E -> É
|
||||
case 0x0065:
|
||||
composed = 0x00E9;
|
||||
break; // e -> é
|
||||
case 0x0049:
|
||||
composed = 0x00CD;
|
||||
break; // I -> Í
|
||||
case 0x0069:
|
||||
composed = 0x00ED;
|
||||
break; // i -> í
|
||||
case 0x004F:
|
||||
composed = 0x00D3;
|
||||
break; // O -> Ó
|
||||
case 0x006F:
|
||||
composed = 0x00F3;
|
||||
break; // o -> ó
|
||||
case 0x0055:
|
||||
composed = 0x00DA;
|
||||
break; // U -> Ú
|
||||
case 0x0075:
|
||||
composed = 0x00FA;
|
||||
break; // u -> ú
|
||||
case 0x0059:
|
||||
composed = 0x00DD;
|
||||
break; // Y -> Ý
|
||||
case 0x0079:
|
||||
composed = 0x00FD;
|
||||
break; // y -> ý
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0302: // circumflex
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C2;
|
||||
break; // A -> Â
|
||||
case 0x0061:
|
||||
composed = 0x00E2;
|
||||
break; // a -> â
|
||||
case 0x0045:
|
||||
composed = 0x00CA;
|
||||
break; // E -> Ê
|
||||
case 0x0065:
|
||||
composed = 0x00EA;
|
||||
break; // e -> ê
|
||||
case 0x0049:
|
||||
composed = 0x00CE;
|
||||
break; // I -> Î
|
||||
case 0x0069:
|
||||
composed = 0x00EE;
|
||||
break; // i -> î
|
||||
case 0x004F:
|
||||
composed = 0x00D4;
|
||||
break; // O -> Ô
|
||||
case 0x006F:
|
||||
composed = 0x00F4;
|
||||
break; // o -> ô
|
||||
case 0x0055:
|
||||
composed = 0x00DB;
|
||||
break; // U -> Û
|
||||
case 0x0075:
|
||||
composed = 0x00FB;
|
||||
break; // u -> û
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0303: // tilde
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C3;
|
||||
break; // A -> Ã
|
||||
case 0x0061:
|
||||
composed = 0x00E3;
|
||||
break; // a -> ã
|
||||
case 0x004E:
|
||||
composed = 0x00D1;
|
||||
break; // N -> Ñ
|
||||
case 0x006E:
|
||||
composed = 0x00F1;
|
||||
break; // n -> ñ
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0308: // diaeresis/umlaut
|
||||
switch (prev) {
|
||||
case 0x0041:
|
||||
composed = 0x00C4;
|
||||
break; // A -> Ä
|
||||
case 0x0061:
|
||||
composed = 0x00E4;
|
||||
break; // a -> ä
|
||||
case 0x0045:
|
||||
composed = 0x00CB;
|
||||
break; // E -> Ë
|
||||
case 0x0065:
|
||||
composed = 0x00EB;
|
||||
break; // e -> ë
|
||||
case 0x0049:
|
||||
composed = 0x00CF;
|
||||
break; // I -> Ï
|
||||
case 0x0069:
|
||||
composed = 0x00EF;
|
||||
break; // i -> ï
|
||||
case 0x004F:
|
||||
composed = 0x00D6;
|
||||
break; // O -> Ö
|
||||
case 0x006F:
|
||||
composed = 0x00F6;
|
||||
break; // o -> ö
|
||||
case 0x0055:
|
||||
composed = 0x00DC;
|
||||
break; // U -> Ü
|
||||
case 0x0075:
|
||||
composed = 0x00FC;
|
||||
break; // u -> ü
|
||||
case 0x0059:
|
||||
composed = 0x0178;
|
||||
break; // Y -> Ÿ
|
||||
case 0x0079:
|
||||
composed = 0x00FF;
|
||||
break; // y -> ÿ
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 0x0327: // cedilla
|
||||
switch (prev) {
|
||||
case 0x0043:
|
||||
composed = 0x00C7;
|
||||
break; // C -> Ç
|
||||
case 0x0063:
|
||||
composed = 0x00E7;
|
||||
break; // c -> ç
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (composed != 0) {
|
||||
cps.back().value = composed;
|
||||
continue; // skip pushing the combining mark itself
|
||||
}
|
||||
}
|
||||
|
||||
cps.push_back({cp, static_cast<size_t>(current - base)});
|
||||
}
|
||||
|
||||
|
||||
@@ -418,7 +418,74 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
if (decoder->getDimensions(cachedImagePath, dims)) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
// Scale to fit viewport while maintaining aspect ratio
|
||||
int displayWidth = 0;
|
||||
int displayHeight = 0;
|
||||
const float emSize =
|
||||
static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
|
||||
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
|
||||
if (!styleAttr.empty()) {
|
||||
imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
|
||||
}
|
||||
const bool hasCssHeight = imgStyle.hasImageHeight();
|
||||
const bool hasCssWidth = imgStyle.hasWidth();
|
||||
|
||||
if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) {
|
||||
displayHeight = static_cast<int>(
|
||||
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
|
||||
displayWidth = static_cast<int>(
|
||||
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) {
|
||||
float scaleX = (displayWidth > self->viewportWidth)
|
||||
? static_cast<float>(self->viewportWidth) / displayWidth
|
||||
: 1.0f;
|
||||
float scaleY = (displayHeight > self->viewportHeight)
|
||||
? static_cast<float>(self->viewportHeight) / displayHeight
|
||||
: 1.0f;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
displayWidth = static_cast<int>(displayWidth * scale + 0.5f);
|
||||
displayHeight = static_cast<int>(displayHeight * scale + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
}
|
||||
LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight);
|
||||
} else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) {
|
||||
displayHeight = static_cast<int>(
|
||||
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayHeight > self->viewportHeight) {
|
||||
displayHeight = self->viewportHeight;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
}
|
||||
if (displayWidth > self->viewportWidth) {
|
||||
displayWidth = self->viewportWidth;
|
||||
displayHeight =
|
||||
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
}
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight);
|
||||
} else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) {
|
||||
displayWidth = static_cast<int>(
|
||||
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
|
||||
if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth;
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
displayHeight =
|
||||
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
|
||||
if (displayHeight > self->viewportHeight) {
|
||||
displayHeight = self->viewportHeight;
|
||||
displayWidth =
|
||||
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
|
||||
if (displayWidth < 1) displayWidth = 1;
|
||||
}
|
||||
if (displayHeight < 1) displayHeight = 1;
|
||||
LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight);
|
||||
} else {
|
||||
int maxWidth = self->viewportWidth;
|
||||
int maxHeight = self->viewportHeight;
|
||||
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
|
||||
@@ -426,10 +493,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
if (scale > 1.0f) scale = 1.0f;
|
||||
|
||||
int displayWidth = (int)(dims.width * scale);
|
||||
int displayHeight = (int)(dims.height * scale);
|
||||
|
||||
displayWidth = (int)(dims.width * scale);
|
||||
displayHeight = (int)(dims.height * scale);
|
||||
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
|
||||
}
|
||||
|
||||
// Create page for image - only break if image won't fit remaining space
|
||||
if (self->currentPage && !self->currentPage->elements.empty() &&
|
||||
|
||||
@@ -59,6 +59,132 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation,
|
||||
}
|
||||
}
|
||||
|
||||
enum class TextRotation { None, Rotated90CW, Rotated90CCW };
|
||||
|
||||
// Shared glyph rendering logic for normal and rotated text.
|
||||
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
|
||||
template <TextRotation rotation>
|
||||
static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode renderMode,
|
||||
const EpdFontFamily& fontFamily, const uint32_t cp, int* cursorX, int* cursorY,
|
||||
const bool pixelState, const EpdFontFamily::Style style) {
|
||||
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
if (!glyph) {
|
||||
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
|
||||
return;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = fontFamily.getData(style);
|
||||
const bool is2Bit = fontData->is2Bit;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
const int top = glyph->top;
|
||||
|
||||
const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
int outerBase, innerBase;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
outerBase = *cursorX + fontData->ascender - top; // screenX = outerBase + glyphY
|
||||
innerBase = *cursorY - left; // screenY = innerBase - glyphX
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerBase = *cursorX + fontData->advanceY - 1 - fontData->ascender + top; // screenX = outerBase - glyphY
|
||||
innerBase = *cursorY + left; // screenY = innerBase + glyphX
|
||||
} else {
|
||||
outerBase = *cursorY - top; // screenY = outerBase + glyphY
|
||||
innerBase = *cursorX + left; // screenX = innerBase + glyphX
|
||||
}
|
||||
|
||||
if (is2Bit) {
|
||||
int pixelPosition = 0;
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
int outerCoord;
|
||||
if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerCoord = outerBase - glyphY;
|
||||
} else {
|
||||
outerCoord = outerBase + glyphY;
|
||||
}
|
||||
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
|
||||
int screenX, screenY;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase - glyphX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase + glyphX;
|
||||
} else {
|
||||
screenX = innerBase + glyphX;
|
||||
screenY = outerCoord;
|
||||
}
|
||||
|
||||
const uint8_t byte = bitmap[pixelPosition >> 2];
|
||||
const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2;
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
|
||||
// we swap this to better match the way images and screen think about colors:
|
||||
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
|
||||
const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3);
|
||||
|
||||
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
renderer.drawPixel(screenX, screenY, pixelState);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
|
||||
renderer.drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) {
|
||||
// Dark gray
|
||||
renderer.drawPixel(screenX, screenY, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int pixelPosition = 0;
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
int outerCoord;
|
||||
if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
outerCoord = outerBase - glyphY;
|
||||
} else {
|
||||
outerCoord = outerBase + glyphY;
|
||||
}
|
||||
for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) {
|
||||
int screenX, screenY;
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase - glyphX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
screenX = outerCoord;
|
||||
screenY = innerBase + glyphX;
|
||||
} else {
|
||||
screenX = innerBase + glyphX;
|
||||
screenY = outerCoord;
|
||||
}
|
||||
|
||||
const uint8_t byte = bitmap[pixelPosition >> 3];
|
||||
const uint8_t bit_index = 7 - (pixelPosition & 7);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
renderer.drawPixel(screenX, screenY, pixelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
if constexpr (rotation == TextRotation::Rotated90CW) {
|
||||
*cursorY -= glyph->advanceX;
|
||||
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
|
||||
*cursorY += glyph->advanceX;
|
||||
} else {
|
||||
*cursorX += glyph->advanceX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
|
||||
// efficient as possible.
|
||||
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||
@@ -115,8 +241,13 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te
|
||||
|
||||
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const int yPos = y + getFontAscenderSize(fontId);
|
||||
int yPos = y + getFontAscenderSize(fontId);
|
||||
int xpos = x;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = yPos;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
|
||||
// cannot draw a NULL / empty string
|
||||
if (text == nullptr || *text == '\0') {
|
||||
@@ -129,9 +260,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
|
||||
return;
|
||||
}
|
||||
const auto& font = fontIt->second;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX + lastBaseAdvance / 2;
|
||||
int combiningY = lastBaseY - raiseBy;
|
||||
renderChar(font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xpos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
renderChar(font, cp, &xpos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
@@ -824,7 +989,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
|
||||
return 0;
|
||||
}
|
||||
|
||||
return fontIt->second.getGlyph(' ', style)->advanceX;
|
||||
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
|
||||
return spaceGlyph ? spaceGlyph->advanceX : 0;
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
||||
@@ -838,7 +1004,12 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo
|
||||
int width = 0;
|
||||
const auto& font = fontIt->second;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
width += font.getGlyph(cp, style)->advanceX;
|
||||
if (utf8IsCombiningMark(cp)) {
|
||||
continue;
|
||||
}
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
if (glyph) width += glyph->advanceX;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
@@ -887,68 +1058,51 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
|
||||
const auto& font = fontIt->second;
|
||||
|
||||
// For 90° clockwise rotation:
|
||||
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
|
||||
// Text reads from bottom to top
|
||||
|
||||
int yPos = y; // Current Y position (decreases as we draw characters)
|
||||
int xPos = x;
|
||||
int yPos = y;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = y;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX - raiseBy;
|
||||
int combiningY = lastBaseY - lastBaseAdvance / 2;
|
||||
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xPos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = font.getData(style);
|
||||
const int is2Bit = fontData->is2Bit;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
const int top = glyph->top;
|
||||
|
||||
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
|
||||
// 90° clockwise rotation transformation:
|
||||
// screenX = x + (ascender - top + glyphY)
|
||||
// screenY = yPos - (left + glyphX)
|
||||
const int screenX = x + (fontData->ascender - top + glyphY);
|
||||
const int screenY = yPos - left - glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next character position (going up, so decrease Y)
|
||||
yPos -= glyph->advanceX;
|
||||
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,77 +1113,59 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
|
||||
return;
|
||||
}
|
||||
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
const auto fontIt = fontMap.find(fontId);
|
||||
if (fontIt == fontMap.end()) {
|
||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||
return;
|
||||
}
|
||||
const auto font = fontMap.at(fontId);
|
||||
|
||||
// For 90° counter-clockwise rotation:
|
||||
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
|
||||
// Text reads from top to bottom
|
||||
const auto& font = fontIt->second;
|
||||
|
||||
const int advanceY = font.getData(style)->advanceY;
|
||||
const int ascender = font.getData(style)->ascender;
|
||||
|
||||
int yPos = y; // Current Y position (increases as we draw characters)
|
||||
int xPos = x;
|
||||
int yPos = y;
|
||||
int lastBaseX = x;
|
||||
int lastBaseY = y;
|
||||
int lastBaseAdvance = 0;
|
||||
int lastBaseTop = 0;
|
||||
bool hasBaseGlyph = false;
|
||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
|
||||
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
|
||||
if (!combiningGlyph) {
|
||||
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
int raiseBy = 0;
|
||||
if (combiningGlyph) {
|
||||
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
|
||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||
}
|
||||
}
|
||||
|
||||
int combiningX = lastBaseX + raiseBy;
|
||||
int combiningY = lastBaseY + lastBaseAdvance / 2;
|
||||
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
|
||||
continue;
|
||||
}
|
||||
|
||||
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
if (!glyph) {
|
||||
continue;
|
||||
|
||||
if (!utf8IsCombiningMark(cp)) {
|
||||
lastBaseX = xPos;
|
||||
lastBaseY = yPos;
|
||||
lastBaseAdvance = glyph ? glyph->advanceX : 0;
|
||||
lastBaseTop = glyph ? glyph->top : 0;
|
||||
hasBaseGlyph = true;
|
||||
}
|
||||
|
||||
const int is2Bit = font.getData(style)->is2Bit;
|
||||
const uint32_t offset = glyph->dataOffset;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
const int top = glyph->top;
|
||||
|
||||
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
|
||||
// 90° counter-clockwise rotation transformation:
|
||||
// screenX = mirrored CW X (right-to-left within advanceY span)
|
||||
// screenY = yPos + (left + glyphX) (downward)
|
||||
const int screenX = x + advanceY - 1 - (ascender - top + glyphY);
|
||||
const int screenY = yPos + left + glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next character position (going down, so increase Y)
|
||||
yPos += glyph->advanceX;
|
||||
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,7 +1230,7 @@ bool GfxRenderer::storeBwBuffer() {
|
||||
* Uses chunked restoration to match chunked storage.
|
||||
*/
|
||||
void GfxRenderer::restoreBwBuffer() {
|
||||
// Check if any all chunks are allocated
|
||||
// Check if all chunks are allocated
|
||||
bool missingChunks = false;
|
||||
for (const auto& bwBufferChunk : bwBufferChunks) {
|
||||
if (!bwBufferChunk) {
|
||||
@@ -1109,13 +1245,6 @@ void GfxRenderer::restoreBwBuffer() {
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
|
||||
// Check if chunk is missing
|
||||
if (!bwBufferChunks[i]) {
|
||||
LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug");
|
||||
freeBwBufferChunks();
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
|
||||
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
||||
}
|
||||
@@ -1136,66 +1265,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
|
||||
const bool pixelState, const EpdFontFamily::Style style) const {
|
||||
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
}
|
||||
|
||||
// no glyph?
|
||||
if (!glyph) {
|
||||
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
|
||||
return;
|
||||
}
|
||||
|
||||
const EpdFontData* fontData = fontFamily.getData(style);
|
||||
const int is2Bit = fontData->is2Bit;
|
||||
const uint8_t width = glyph->width;
|
||||
const uint8_t height = glyph->height;
|
||||
const int left = glyph->left;
|
||||
|
||||
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
|
||||
|
||||
if (bitmap != nullptr) {
|
||||
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||
const int screenY = *y - glyph->top + glyphY;
|
||||
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||
const int pixelPosition = glyphY * width + glyphX;
|
||||
const int screenX = *x + left + glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
|
||||
// we swap this to better match the way images and screen think about colors:
|
||||
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
drawPixel(screenX, screenY, pixelState);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
// Dark gray
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||
|
||||
if ((byte >> bit_index) & 1) {
|
||||
drawPixel(screenX, screenY, pixelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*x += glyph->advanceX;
|
||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const {
|
||||
renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, style);
|
||||
}
|
||||
|
||||
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
|
||||
|
||||
@@ -38,10 +38,9 @@ class GfxRenderer {
|
||||
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
FontDecompressor* fontDecompressor = nullptr;
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
template <Color color>
|
||||
void drawPixelDither(int x, int y) const;
|
||||
template <Color color>
|
||||
@@ -136,6 +135,9 @@ class GfxRenderer {
|
||||
void restoreBwBuffer(); // Restore and free the stored buffer
|
||||
void cleanupGrayscaleWithFrameBuffer() const;
|
||||
|
||||
// Font helpers
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
|
||||
// Low level functions
|
||||
uint8_t* getFrameBuffer() const;
|
||||
static size_t getBufferSize();
|
||||
|
||||
@@ -13,6 +13,7 @@ extern const char* const STRINGS_CZ[];
|
||||
extern const char* const STRINGS_PO[];
|
||||
extern const char* const STRINGS_RU[];
|
||||
extern const char* const STRINGS_SV[];
|
||||
extern const char* const STRINGS_RO[];
|
||||
} // namespace i18n_strings
|
||||
|
||||
// Language enum
|
||||
@@ -25,6 +26,7 @@ enum class Language : uint8_t {
|
||||
PORTUGUESE = 5,
|
||||
RUSSIAN = 6,
|
||||
SWEDISH = 7,
|
||||
ROMANIAN = 8,
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -419,6 +421,8 @@ inline const char* const* getStringArray(Language lang) {
|
||||
return i18n_strings::STRINGS_RU;
|
||||
case Language::SWEDISH:
|
||||
return i18n_strings::STRINGS_SV;
|
||||
case Language::ROMANIAN:
|
||||
return i18n_strings::STRINGS_RO;
|
||||
default:
|
||||
return i18n_strings::STRINGS_EN;
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ extern const char* const STRINGS_CZ[];
|
||||
extern const char* const STRINGS_PO[];
|
||||
extern const char* const STRINGS_RU[];
|
||||
extern const char* const STRINGS_SV[];
|
||||
extern const char* const STRINGS_RO[];
|
||||
|
||||
} // namespace i18n_strings
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy"
|
||||
STR_NO_RECENT_BOOKS: "Žádné nedávné knihy"
|
||||
STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Zapomenout síť a odstranit uložené heslo?"
|
||||
STR_FORGET_BUTTON: "Zapomenout na síť"
|
||||
STR_FORGET_BUTTON: "Zapomenout"
|
||||
STR_CALIBRE_STARTING: "Spuštění Calibre..."
|
||||
STR_CALIBRE_SETUP: "Nastavení"
|
||||
STR_CALIBRE_STATUS: "Stav"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books"
|
||||
STR_NO_RECENT_BOOKS: "No recent books"
|
||||
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
|
||||
STR_FORGET_AND_REMOVE: "Forget network and remove saved password?"
|
||||
STR_FORGET_BUTTON: "Forget network"
|
||||
STR_FORGET_BUTTON: "Forget"
|
||||
STR_CALIBRE_STARTING: "Starting Calibre..."
|
||||
STR_CALIBRE_SETUP: "Setup"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents"
|
||||
STR_NO_RECENT_BOOKS: "Aucun livre récent"
|
||||
STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Oublier le réseau et supprimer le mot de passe enregistré ?"
|
||||
STR_FORGET_BUTTON: "Oublier le réseau"
|
||||
STR_FORGET_BUTTON: "Oublier"
|
||||
STR_CALIBRE_STARTING: "Démarrage de Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuration"
|
||||
STR_CALIBRE_STATUS: "Statut"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen"
|
||||
STR_NO_RECENT_BOOKS: "Keine Bücher"
|
||||
STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)"
|
||||
STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?"
|
||||
STR_FORGET_BUTTON: "WLAN entfernen"
|
||||
STR_FORGET_BUTTON: "Entfernen"
|
||||
STR_CALIBRE_STARTING: "Calibre starten…"
|
||||
STR_CALIBRE_SETUP: "Installation"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livros recentes"
|
||||
STR_NO_RECENT_BOOKS: "Sem livros recentes"
|
||||
STR_CALIBRE_DESC: "Usar transferências sem fio Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?"
|
||||
STR_FORGET_BUTTON: "Esquecer rede"
|
||||
STR_FORGET_BUTTON: "Esquecer"
|
||||
STR_CALIBRE_STARTING: "Iniciando Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuração"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
|
||||
318
lib/I18n/translations/romanian.yaml
Normal file
318
lib/I18n/translations/romanian.yaml
Normal file
@@ -0,0 +1,318 @@
|
||||
_language_name: "Română"
|
||||
_language_code: "ROMANIAN"
|
||||
_order: "8"
|
||||
|
||||
STR_CROSSPOINT: "CrossPoint"
|
||||
STR_BOOTING: "PORNEŞTE"
|
||||
STR_SLEEPING: "REPAUS"
|
||||
STR_ENTERING_SLEEP: "Intră în repaus..."
|
||||
STR_BROWSE_FILES: "Răsfoieşte fişierele"
|
||||
STR_FILE_TRANSFER: "Transfer de fişiere"
|
||||
STR_SETTINGS_TITLE: "Setări"
|
||||
STR_CALIBRE_LIBRARY: "Biblioteca Calibre"
|
||||
STR_CONTINUE_READING: "Continuă lectura"
|
||||
STR_NO_OPEN_BOOK: "Nicio carte deschisă"
|
||||
STR_START_READING: "Începeţi lectura"
|
||||
STR_BOOKS: "Cărţi"
|
||||
STR_NO_BOOKS_FOUND: "Nicio carte găsită"
|
||||
STR_SELECT_CHAPTER: "Selectaţi capitolul"
|
||||
STR_NO_CHAPTERS: "Niciun capitol"
|
||||
STR_END_OF_BOOK: "Sfârşitul cărţii"
|
||||
STR_EMPTY_CHAPTER: "Capitol gol"
|
||||
STR_INDEXING: "Indexează..."
|
||||
STR_MEMORY_ERROR: "Eroare de memorie"
|
||||
STR_PAGE_LOAD_ERROR: "Eroare la încărcarea paginii"
|
||||
STR_EMPTY_FILE: "Fişier gol"
|
||||
STR_OUT_OF_BOUNDS: "Eroare: În afara limitelor"
|
||||
STR_LOADING: "Se încarcă..."
|
||||
STR_LOADING_POPUP: "Se încarcă..."
|
||||
STR_LOAD_XTC_FAILED: "Eroare la încărcarea XTC"
|
||||
STR_LOAD_TXT_FAILED: "Eroare la încărcarea TXT"
|
||||
STR_LOAD_EPUB_FAILED: "Eroare la încărcarea EPUB"
|
||||
STR_SD_CARD_ERROR: "Eroare la cardul SD"
|
||||
STR_WIFI_NETWORKS: "Reţele WiFi"
|
||||
STR_NO_NETWORKS: "Nu s-au găsit reţele"
|
||||
STR_NETWORKS_FOUND: "%zu reţele găsite"
|
||||
STR_SCANNING: "Scanează..."
|
||||
STR_CONNECTING: "Se conectează..."
|
||||
STR_CONNECTED: "Conectat!"
|
||||
STR_CONNECTION_FAILED: "Conexiune eşuată"
|
||||
STR_CONNECTION_TIMEOUT: "Timp de conectare depăşit"
|
||||
STR_FORGET_NETWORK: "Uitaţi reţeaua?"
|
||||
STR_SAVE_PASSWORD: "Salvaţi parola?"
|
||||
STR_REMOVE_PASSWORD: "Ştergeţi parola salvată?"
|
||||
STR_PRESS_OK_SCAN: "Apăsaţi OK pentru a scana din nou"
|
||||
STR_PRESS_ANY_CONTINUE: "Apăsaţi orice buton pentru a continua"
|
||||
STR_SELECT_HINT: "STÂNGA/DREAPTA: Selectaţi | OK: Confirmaţi"
|
||||
STR_HOW_CONNECT: "Cum doriţi să vă conectaţi?"
|
||||
STR_JOIN_NETWORK: "Conectaţi-vă la o reţea"
|
||||
STR_CREATE_HOTSPOT: "Creaţi un hotspot"
|
||||
STR_JOIN_DESC: "Conectaţi-vă la o reţea WiFi existentă"
|
||||
STR_HOTSPOT_DESC: "Creaţi un hotspot WiFi"
|
||||
STR_STARTING_HOTSPOT: "Hotspot porneşte..."
|
||||
STR_HOTSPOT_MODE: "Mod Hotspot"
|
||||
STR_CONNECT_WIFI_HINT: "Conectaţi-vă dispozitivul la această reţea WiFi"
|
||||
STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs."
|
||||
STR_OR_HTTP_PREFIX: "sau http://"
|
||||
STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:"
|
||||
STR_CALIBRE_WIRELESS: "Calibre Wireless"
|
||||
STR_CALIBRE_WEB_URL: "Calibre URL"
|
||||
STR_CONNECT_WIRELESS: "Conectaţi-vă ca dispozitiv wireless"
|
||||
STR_NETWORK_LEGEND: "* = Criptat | + = Salvat"
|
||||
STR_MAC_ADDRESS: "Adresă MAC:"
|
||||
STR_CHECKING_WIFI: "Verificare WiFi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Introduceţi parola WiFi"
|
||||
STR_ENTER_TEXT: "Introduceţi textul"
|
||||
STR_TO_PREFIX: "la "
|
||||
STR_CALIBRE_DISCOVERING: "Descoperă Calibre..."
|
||||
STR_CALIBRE_CONNECTING_TO: "Se conectează la "
|
||||
STR_CALIBRE_CONNECTED_TO: "Conectat la "
|
||||
STR_CALIBRE_WAITING_COMMANDS: "Se aşteaptă comenzi..."
|
||||
STR_CONNECTION_FAILED_RETRYING: "(Conexiune eşuată, se reîncearcă)"
|
||||
STR_CALIBRE_DISCONNECTED: "Calibre deconectat"
|
||||
STR_CALIBRE_WAITING_TRANSFER: "Se aşteaptă transfer..."
|
||||
STR_CALIBRE_TRANSFER_HINT: "Dacă transferul eşuează, activaţi\\n'Ignoraţi spaţiul liber' în setările\\nplugin-ului SmartDevice din Calibre."
|
||||
STR_CALIBRE_RECEIVING: "Se primeşte: "
|
||||
STR_CALIBRE_RECEIVED: "Primite: "
|
||||
STR_CALIBRE_WAITING_MORE: "Se aşteaptă mai multe..."
|
||||
STR_CALIBRE_FAILED_CREATE_FILE: "Creare fişier eşuată"
|
||||
STR_CALIBRE_PASSWORD_REQUIRED: "Necesită parolă"
|
||||
STR_CALIBRE_TRANSFER_INTERRUPTED: "Transfer întrerupt"
|
||||
STR_CALIBRE_INSTRUCTION_1: "1) Instalaţi plugin-ul CrossPoint Reader"
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Fiţi în aceeaşi reţea WiFi"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) În Calibre: \"Trimiteţi la dispozitiv\""
|
||||
STR_CALIBRE_INSTRUCTION_4: "\"Păstraţi acest ecran deschis în timpul trimiterii\""
|
||||
STR_CAT_DISPLAY: "Ecran"
|
||||
STR_CAT_READER: "Lectură"
|
||||
STR_CAT_CONTROLS: "Controale"
|
||||
STR_CAT_SYSTEM: "Sistem"
|
||||
STR_SLEEP_SCREEN: "Ecran de repaus"
|
||||
STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
|
||||
STR_STATUS_BAR: "Bara de stare"
|
||||
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
|
||||
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
|
||||
STR_TEXT_AA: "Anti-Aliasing text"
|
||||
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
|
||||
STR_ORIENTATION: "Orientare lectură"
|
||||
STR_FRONT_BTN_LAYOUT: "Aspect butoane frontale"
|
||||
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
|
||||
STR_LONG_PRESS_SKIP: "Sărire capitol la apăsare lungă"
|
||||
STR_FONT_FAMILY: "Familie font lectură"
|
||||
STR_EXT_READER_FONT: "Font lectură extern"
|
||||
STR_EXT_CHINESE_FONT: "Font lectură"
|
||||
STR_EXT_UI_FONT: "Font meniu"
|
||||
STR_FONT_SIZE: "Dimensiune font"
|
||||
STR_LINE_SPACING: "Spaţiere între rânduri"
|
||||
STR_ASCII_LETTER_SPACING: "Spaţiere litere ASCII "
|
||||
STR_ASCII_DIGIT_SPACING: "Spaţiere cifre ASCII"
|
||||
STR_CJK_SPACING: "Spaţiere CJK"
|
||||
STR_COLOR_MODE: "Mod culoare"
|
||||
STR_SCREEN_MARGIN: "Margine ecran lectură"
|
||||
STR_PARA_ALIGNMENT: "Aliniere paragrafe reader"
|
||||
STR_HYPHENATION: "Silabisire"
|
||||
STR_TIME_TO_SLEEP: "Timp până la repaus"
|
||||
STR_REFRESH_FREQ: "Frecvenţă reîmprospătare"
|
||||
STR_CALIBRE_SETTINGS: "Setări Calibre"
|
||||
STR_KOREADER_SYNC: "Sincronizare KOReader"
|
||||
STR_CHECK_UPDATES: "Căutaţi actualizări"
|
||||
STR_LANGUAGE: "Limbă"
|
||||
STR_SELECT_WALLPAPER: "Selectaţi imaginea de fundal"
|
||||
STR_CLEAR_READING_CACHE: "Goliţi cache-ul de lectură"
|
||||
STR_CALIBRE: "Calibre"
|
||||
STR_USERNAME: "Utilizator"
|
||||
STR_PASSWORD: "Parolă"
|
||||
STR_SYNC_SERVER_URL: "URL server sincronizare"
|
||||
STR_DOCUMENT_MATCHING: "Corespondenţă document"
|
||||
STR_AUTHENTICATE: "Autentificare"
|
||||
STR_KOREADER_USERNAME: "Nume utilizator KOReader"
|
||||
STR_KOREADER_PASSWORD: "Parolă KOReader"
|
||||
STR_FILENAME: "Nume fişier"
|
||||
STR_BINARY: "Fişier binar"
|
||||
STR_SET_CREDENTIALS_FIRST: "Vă rugăm să setaţi mai întâi acreditările"
|
||||
STR_WIFI_CONN_FAILED: "Conexiune WiFi eşuată"
|
||||
STR_AUTHENTICATING: "Se autentifică..."
|
||||
STR_AUTH_SUCCESS: "Autentificare reuşită!"
|
||||
STR_KOREADER_AUTH: "Autentificare KOReader"
|
||||
STR_SYNC_READY: "Sincronizare KOReader gata de utilizare"
|
||||
STR_AUTH_FAILED: "Autentificare eşuată"
|
||||
STR_DONE: "Gata"
|
||||
STR_CLEAR_CACHE_WARNING_1: "Aceasta va şterge tot cache-ul de lectură."
|
||||
STR_CLEAR_CACHE_WARNING_2: "Tot progresul de lectură va fi pierdut!"
|
||||
STR_CLEAR_CACHE_WARNING_3: "Cărţile vor trebui reindexate"
|
||||
STR_CLEAR_CACHE_WARNING_4: "când vor fi deschise din nou."
|
||||
STR_CLEARING_CACHE: "Se şterge cache-ul..."
|
||||
STR_CACHE_CLEARED: "Cache şters"
|
||||
STR_ITEMS_REMOVED: "elemente eliminate"
|
||||
STR_FAILED_LOWER: "eşuat"
|
||||
STR_CLEAR_CACHE_FAILED: "ştergerea cache-ului a eşuat"
|
||||
STR_CHECK_SERIAL_OUTPUT: "Verificaţi ieşirea serială pentru detalii"
|
||||
STR_DARK: "Întunecat"
|
||||
STR_LIGHT: "Luminos"
|
||||
STR_CUSTOM: "Personalizat"
|
||||
STR_COVER: "Copertă"
|
||||
STR_NONE_OPT: "Niciunul"
|
||||
STR_FIT: "Potrivit"
|
||||
STR_CROP: "Decupat"
|
||||
STR_NO_PROGRESS: "Fără progres"
|
||||
STR_FULL_OPT: "Complet"
|
||||
STR_NEVER: "Niciodată"
|
||||
STR_IN_READER: "În lectură"
|
||||
STR_ALWAYS: "Întotdeauna"
|
||||
STR_IGNORE: "Ignoră"
|
||||
STR_SLEEP: "Repaus"
|
||||
STR_PAGE_TURN: "Răsfoire pagină"
|
||||
STR_PORTRAIT: "Vertical"
|
||||
STR_LANDSCAPE_CW: "Orizontal dreapta"
|
||||
STR_INVERTED: "Invers"
|
||||
STR_LANDSCAPE_CCW: "Orizontal stânga"
|
||||
STR_FRONT_LAYOUT_BCLR: "Înapoi, Cnfrm, St, Dr"
|
||||
STR_FRONT_LAYOUT_LRBC: "St, Dr, Înapoi, Cnfrm"
|
||||
STR_FRONT_LAYOUT_LBCR: "St, Înapoi, Cnfrm, Dr"
|
||||
STR_PREV_NEXT: "Înainte/Înapoi"
|
||||
STR_NEXT_PREV: "Înapoi/Înainte"
|
||||
STR_BOOKERLY: "Bookerly"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
STR_OPEN_DYSLEXIC: "Open Dyslexic"
|
||||
STR_SMALL: "Mic"
|
||||
STR_MEDIUM: "Mediu"
|
||||
STR_LARGE: "Mare"
|
||||
STR_X_LARGE: "Foarte mare"
|
||||
STR_TIGHT: "Strâns"
|
||||
STR_NORMAL: "Normal"
|
||||
STR_WIDE: "Larg"
|
||||
STR_JUSTIFY: "Aliniere"
|
||||
STR_ALIGN_LEFT: "Stânga"
|
||||
STR_CENTER: "Centru"
|
||||
STR_ALIGN_RIGHT: "Dreapta"
|
||||
STR_MIN_1: "1 min"
|
||||
STR_MIN_5: "5 min"
|
||||
STR_MIN_10: "10 min"
|
||||
STR_MIN_15: "15 min"
|
||||
STR_MIN_30: "30 min"
|
||||
STR_PAGES_1: "1 pagină"
|
||||
STR_PAGES_5: "5 pagini"
|
||||
STR_PAGES_10: "10 pagini"
|
||||
STR_PAGES_15: "15 pagini"
|
||||
STR_PAGES_30: "30 pagini"
|
||||
STR_UPDATE: "Actualizare"
|
||||
STR_CHECKING_UPDATE: "Se verifică actualizările..."
|
||||
STR_NEW_UPDATE: "Nouă actualizare disponibilă!"
|
||||
STR_CURRENT_VERSION: "Versiune curentă: "
|
||||
STR_NEW_VERSION: "Noua versiune: "
|
||||
STR_UPDATING: "Se actualizează..."
|
||||
STR_NO_UPDATE: "Nicio actualizare disponibilă"
|
||||
STR_UPDATE_FAILED: "Actualizare eşuată"
|
||||
STR_UPDATE_COMPLETE: "Actualizare completă"
|
||||
STR_POWER_ON_HINT: "Apăsaţi şi menţineţi apăsat întrerupătorul pentru a porni din nou"
|
||||
STR_EXTERNAL_FONT: "Font extern"
|
||||
STR_BUILTIN_DISABLED: "Încorporat (Dezactivat)"
|
||||
STR_NO_ENTRIES: "Niciun rezultat găsit"
|
||||
STR_DOWNLOADING: "Se descarcă..."
|
||||
STR_DOWNLOAD_FAILED: "Descărcare eşuată"
|
||||
STR_ERROR_MSG: "Eroare:"
|
||||
STR_UNNAMED: "Fără nume"
|
||||
STR_NO_SERVER_URL: "Niciun URL de server configurat"
|
||||
STR_FETCH_FEED_FAILED: "Eşec la preluarea feed-ului"
|
||||
STR_PARSE_FEED_FAILED: "Eşec la analizarea feed-ului"
|
||||
STR_NETWORK_PREFIX: "Reţea: "
|
||||
STR_IP_ADDRESS_PREFIX: "Adresă IP: "
|
||||
STR_SCAN_QR_WIFI_HINT: "sau scanaţi codul QR cu telefonul pentru a vă conecta la Wifi."
|
||||
STR_ERROR_GENERAL_FAILURE: "Eroare: Eşec general"
|
||||
STR_ERROR_NETWORK_NOT_FOUND: "Eroare: Reţea negăsită"
|
||||
STR_ERROR_CONNECTION_TIMEOUT: "Eroare: Timp de conectare depăşit"
|
||||
STR_SD_CARD: "Card SD"
|
||||
STR_BACK: "« Înapoi"
|
||||
STR_EXIT: "« Ieşire"
|
||||
STR_HOME: "« Acasă"
|
||||
STR_SAVE: "« Salvare"
|
||||
STR_SELECT: "Selectează"
|
||||
STR_TOGGLE: "Schimbă"
|
||||
STR_CONFIRM: "Confirmă"
|
||||
STR_CANCEL: "Anulare"
|
||||
STR_CONNECT: "Conectare"
|
||||
STR_OPEN: "Deschidere"
|
||||
STR_DOWNLOAD: "Descarcă"
|
||||
STR_RETRY: "Reîncercare"
|
||||
STR_YES: "Da"
|
||||
STR_NO: "Nu"
|
||||
STR_STATE_ON: "Pornit"
|
||||
STR_STATE_OFF: "Oprit"
|
||||
STR_SET: "Setare"
|
||||
STR_NOT_SET: "Neconfigurat"
|
||||
STR_DIR_LEFT: "Stânga"
|
||||
STR_DIR_RIGHT: "Dreapta"
|
||||
STR_DIR_UP: "Sus"
|
||||
STR_DIR_DOWN: "Jos"
|
||||
STR_CAPS_ON: "CAPS"
|
||||
STR_CAPS_OFF: "caps"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_ON_MARKER: "[ON]"
|
||||
STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus"
|
||||
STR_FILTER_CONTRAST: "Contrast"
|
||||
STR_STATUS_BAR_FULL_PERCENT: "Complet cu procentaj"
|
||||
STR_STATUS_BAR_FULL_BOOK: "Complet cu bara de carte"
|
||||
STR_STATUS_BAR_BOOK_ONLY: "Doar bara de carte"
|
||||
STR_STATUS_BAR_FULL_CHAPTER: "Complet cu bara de capitol"
|
||||
STR_UI_THEME: "Tema UI"
|
||||
STR_THEME_CLASSIC: "Clasic"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
STR_SUNLIGHT_FADING_FIX: "Corecţie estompare lumină"
|
||||
STR_REMAP_FRONT_BUTTONS: "Remapare butoane frontale"
|
||||
STR_OPDS_BROWSER: "Browser OPDS"
|
||||
STR_COVER_CUSTOM: "Copertă + Personalizat"
|
||||
STR_RECENTS: "Recente"
|
||||
STR_MENU_RECENT_BOOKS: "Cărţi recente"
|
||||
STR_NO_RECENT_BOOKS: "Nicio carte recentă"
|
||||
STR_CALIBRE_DESC: "Utilizaţi transferurile wireless ale dispozitivului Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Uitaţi reţeaua şi eliminaţi parola salvată?"
|
||||
STR_FORGET_BUTTON: "Uitaţi"
|
||||
STR_CALIBRE_STARTING: "Pornirea Calibre..."
|
||||
STR_CALIBRE_SETUP: "Configurare"
|
||||
STR_CALIBRE_STATUS: "Stare"
|
||||
STR_CLEAR_BUTTON: "ştergere"
|
||||
STR_DEFAULT_VALUE: "Implicit"
|
||||
STR_REMAP_PROMPT: "Apăsaţi un buton frontal pentru fiecare rol"
|
||||
STR_UNASSIGNED: "Neatribuit"
|
||||
STR_ALREADY_ASSIGNED: "Deja atribuit"
|
||||
STR_REMAP_RESET_HINT: "Buton lateral Sus: Resetaţi la aspectul implicit"
|
||||
STR_REMAP_CANCEL_HINT: "Buton lateral Jos: Anulaţi remaparea"
|
||||
STR_HW_BACK_LABEL: "Înapoi (butonul 1)"
|
||||
STR_HW_CONFIRM_LABEL: "Confirmare (butonul 2)"
|
||||
STR_HW_LEFT_LABEL: "Stânga (butonul 3)"
|
||||
STR_HW_RIGHT_LABEL: "Dreapta (butonul 4)"
|
||||
STR_GO_TO_PERCENT: "Săriţi la %"
|
||||
STR_GO_HOME_BUTTON: "Acasă"
|
||||
STR_SYNC_PROGRESS: "Progres sincronizare"
|
||||
STR_DELETE_CACHE: "Ştergere cache cărţi"
|
||||
STR_CHAPTER_PREFIX: "Capitol: "
|
||||
STR_PAGES_SEPARATOR: " pagini | "
|
||||
STR_BOOK_PREFIX: "Carte: "
|
||||
STR_KBD_SHIFT: "shift"
|
||||
STR_KBD_SHIFT_CAPS: "SHIFT"
|
||||
STR_KBD_LOCK: "LOCK"
|
||||
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
|
||||
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
|
||||
STR_SYNCING_TIME: "Timp de sincronizare..."
|
||||
STR_CALC_HASH: "Calcularea hash-ului documentului..."
|
||||
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
|
||||
STR_FETCH_PROGRESS: "Preluarea progresului de la distanţă..."
|
||||
STR_UPLOAD_PROGRESS: "Încărcarea progresului..."
|
||||
STR_NO_CREDENTIALS_MSG: "Nicio acreditare configurată"
|
||||
STR_KOREADER_SETUP_HINT: "Configuraţi contul KOReader în setări"
|
||||
STR_PROGRESS_FOUND: "Progres găsit!"
|
||||
STR_REMOTE_LABEL: "Remote:"
|
||||
STR_LOCAL_LABEL: "Local:"
|
||||
STR_PAGE_OVERALL_FORMAT: "Pagina %d, %.2f%% din total"
|
||||
STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% din total"
|
||||
STR_DEVICE_FROM_FORMAT: " De la: %s"
|
||||
STR_APPLY_REMOTE: "Aplică progresul remote"
|
||||
STR_UPLOAD_LOCAL: "Încărcaţi progresul local"
|
||||
STR_NO_REMOTE_MSG: "Niciun progres remote găsit"
|
||||
STR_UPLOAD_PROMPT: "Încărcaţi poziţia curentă?"
|
||||
STR_UPLOAD_SUCCESS: "Progres încărcat!"
|
||||
STR_SYNC_FAILED_MSG: "Sincronizare eşuată"
|
||||
STR_SECTION_PREFIX: "Secţiune "
|
||||
STR_UPLOAD: "Încărcare"
|
||||
STR_BOOK_S_STYLE: "Stilul cărţii"
|
||||
STR_EMBEDDED_STYLE: "Stil încorporat"
|
||||
STR_OPDS_SERVER_URL: "URL server OPDS"
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Недавние книги"
|
||||
STR_NO_RECENT_BOOKS: "Нет недавних книг"
|
||||
STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre"
|
||||
STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?"
|
||||
STR_FORGET_BUTTON: "Забыть сеть"
|
||||
STR_FORGET_BUTTON: "Забыть"
|
||||
STR_CALIBRE_STARTING: "Запуск Calibre..."
|
||||
STR_CALIBRE_SETUP: "Настройка"
|
||||
STR_CALIBRE_STATUS: "Статус"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes"
|
||||
STR_NO_RECENT_BOOKS: "No hay libros recientes"
|
||||
STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre"
|
||||
STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?"
|
||||
STR_FORGET_BUTTON: "Olvidar la red"
|
||||
STR_FORGET_BUTTON: "Olvidar"
|
||||
STR_CALIBRE_STARTING: "Iniciando calibre..."
|
||||
STR_CALIBRE_SETUP: "Configuración"
|
||||
STR_CALIBRE_STATUS: "Estado"
|
||||
|
||||
@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna"
|
||||
STR_NO_RECENT_BOOKS: "Inga senaste böcker"
|
||||
STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring"
|
||||
STR_FORGET_AND_REMOVE: "Glöm nätverk och ta bort sparat lösenord?"
|
||||
STR_FORGET_BUTTON: "Glöm nätverk"
|
||||
STR_FORGET_BUTTON: "Glöm"
|
||||
STR_CALIBRE_STARTING: "Starar Calibre…"
|
||||
STR_CALIBRE_SETUP: "Inställning"
|
||||
STR_CALIBRE_STATUS: "Status"
|
||||
|
||||
@@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string);
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
|
||||
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
|
||||
inline bool utf8IsCombiningMark(const uint32_t cp) {
|
||||
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|
||||
|| (cp >= 0x1DC0 && cp <= 0x1DFF) // Combining Diacritical Marks Supplement
|
||||
|| (cp >= 0x20D0 && cp <= 0x20FF) // Combining Diacritical Marks for Symbols
|
||||
|| (cp >= 0xFE20 && cp <= 0xFE2F); // Combining Half Marks
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
int HalPowerManager::getBatteryPercentage() const {
|
||||
uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
return battery.readPercentage();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class HalPowerManager {
|
||||
void startDeepSleep(HalGPIO& gpio) const;
|
||||
|
||||
// Get battery percentage (range 0-100)
|
||||
int getBatteryPercentage() const;
|
||||
uint16_t getBatteryPercentage() const;
|
||||
|
||||
// RAII lock to prevent low-power mode during critical work (e.g. rendering)
|
||||
class Lock {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
default_envs = default
|
||||
|
||||
[crosspoint]
|
||||
version = 1.0.0
|
||||
version = 1.1.1-rc
|
||||
|
||||
[base]
|
||||
platform = espressif32 @ 6.12.0
|
||||
@@ -31,9 +31,9 @@ build_flags =
|
||||
-std=gnu++2a
|
||||
# Enable UTF-8 long file names in SdFat
|
||||
-DUSE_UTF8_LONG_NAMES=1
|
||||
# Increase PNG scanline buffer to support up to 800px wide images
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=6402
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
|
||||
build_unflags =
|
||||
-std=gnu++11
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#pragma once
|
||||
#include <BatteryMonitor.h>
|
||||
|
||||
#define BAT_GPIO0 0 // Battery voltage
|
||||
|
||||
static BatteryMonitor battery(BAT_GPIO0);
|
||||
@@ -21,6 +21,11 @@
|
||||
#include "util/BookSettings.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
// Sleep cover refresh strategy when dithered letterbox fill is active:
|
||||
// 1 = Double FAST_REFRESH (clear to white, then render content -- avoids HALF_REFRESH crosstalk)
|
||||
// 0 = Standard HALF_REFRESH (original behavior)
|
||||
#define USE_SLEEP_DOUBLE_FAST_REFRESH 1
|
||||
|
||||
namespace {
|
||||
|
||||
// Number of source pixels along the image edge to average for the dominant color
|
||||
@@ -74,37 +79,6 @@ uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a gray value would produce a dithered mix that crosses the
|
||||
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||||
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||||
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||||
// and washes out adjacent content during HALF_REFRESH.
|
||||
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||||
bool bayerCrossesBwBoundary(uint8_t gray) { return gray > 170 && gray < 255; }
|
||||
|
||||
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||||
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||||
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||||
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||||
// (noise-like), making it much less visually obvious at the same block size.
|
||||
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||||
// identical levels across BW, LSB, and MSB render passes.
|
||||
static constexpr int BW_DITHER_BLOCK = 2;
|
||||
|
||||
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||||
const int bx = x / BW_DITHER_BLOCK;
|
||||
const int by = y / BW_DITHER_BLOCK;
|
||||
// Fast mixing hash (splitmix32-inspired)
|
||||
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||||
h ^= h >> 16;
|
||||
h *= 0x45d9f3bu;
|
||||
h ^= h >> 16;
|
||||
// Proportion of level-3 blocks needed to approximate the target gray
|
||||
const float ratio = (avg - 170.0f) / 85.0f;
|
||||
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||||
return (h < threshold) ? 3 : 2;
|
||||
}
|
||||
|
||||
// --- Edge average cache ---
|
||||
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||||
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||||
@@ -278,19 +252,6 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
|
||||
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||||
|
||||
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||
//
|
||||
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||||
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||||
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||||
// Bayer grid at the same block size.
|
||||
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||||
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||||
|
||||
// For solid mode: snap to nearest e-ink level
|
||||
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||||
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||
|
||||
@@ -298,13 +259,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
if (data.letterboxA > 0) {
|
||||
for (int y = 0; y < data.letterboxA; y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -312,13 +267,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||||
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -326,13 +275,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
if (data.letterboxA > 0) {
|
||||
for (int x = 0; x < data.letterboxA; x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelA;
|
||||
else if (hashA)
|
||||
lv = hashBlockDither(data.avgA, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgA, x, y);
|
||||
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -340,13 +283,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
|
||||
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||||
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid)
|
||||
lv = levelB;
|
||||
else if (hashB)
|
||||
lv = hashBlockDither(data.avgB, x, y);
|
||||
else
|
||||
lv = quantizeBayerDither(data.avgB, x, y);
|
||||
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
@@ -543,35 +480,47 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
|
||||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||||
|
||||
// Draw letterbox fill (BW pass)
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
const bool isInverted =
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
|
||||
|
||||
#if USE_SLEEP_DOUBLE_FAST_REFRESH
|
||||
const bool useDoubleFast =
|
||||
fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED;
|
||||
#else
|
||||
const bool useDoubleFast = false;
|
||||
#endif
|
||||
|
||||
if (useDoubleFast) {
|
||||
// Double FAST_REFRESH technique: avoids HALF_REFRESH crosstalk with dithered letterbox.
|
||||
// Pass 1: clear to white baseline
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
// Pass 2: render actual content and display
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
|
||||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
if (isInverted) renderer.invertScreen();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
} else {
|
||||
// Standard path: single HALF_REFRESH
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
if (isInverted) renderer.invertScreen();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
|
||||
@@ -196,6 +196,15 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
const auto pos = filename.rfind('.');
|
||||
if (pos == std::string::npos) return "";
|
||||
return filename.substr(pos);
|
||||
}
|
||||
|
||||
void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
@@ -214,7 +223,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
||||
[this](int index) { return getFileName(files[index]); }, nullptr,
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); });
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||
[this](int index) { return getFileExtension(files[index]); }, false);
|
||||
}
|
||||
|
||||
// Help text
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
// Image refresh optimization strategy:
|
||||
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
|
||||
// 1 = Use displayWindow() for partial refresh (experimental)
|
||||
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
|
||||
|
||||
namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
constexpr unsigned long skipChapterMs = 700;
|
||||
@@ -1022,13 +1017,16 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
// Determine if this page needs special image handling
|
||||
bool pageHasImages = page->hasImages();
|
||||
bool useAntiAliasing = SETTINGS.textAntiAliasing;
|
||||
// Force special handling for pages with images when anti-aliasing is on
|
||||
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
|
||||
|
||||
// Force half refresh for pages with images when anti-aliasing is on,
|
||||
// as grayscale tones require half refresh to display correctly
|
||||
bool forceFullRefresh = pageHasImages && useAntiAliasing;
|
||||
if (page->countUncachedImages() > 0) {
|
||||
page->renderTextOnly(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
page->renderImagePlaceholders(renderer, orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayBuffer();
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
|
||||
@@ -1048,42 +1046,26 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
|
||||
// Check if half-refresh is needed (either entering Reader or pages counter reached)
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else if (forceFullRefresh) {
|
||||
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
|
||||
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
|
||||
if (imagePageWithAA) {
|
||||
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
|
||||
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
|
||||
// Instead, blank only the image area and do two fast refreshes.
|
||||
int imgX, imgY, imgW, imgH;
|
||||
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
|
||||
int screenX = imgX + orientedMarginLeft;
|
||||
int screenY = imgY + orientedMarginTop;
|
||||
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)", imgX, imgY, imgW,
|
||||
imgH, screenX, screenY, imgW, imgH);
|
||||
renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
|
||||
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
|
||||
// Method A: Fill blank area + two FAST_REFRESH operations
|
||||
renderer.fillRect(screenX, screenY, imgW, imgH, false);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
#else
|
||||
// Method B (experimental): Use displayWindow() for partial refresh
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
|
||||
#endif
|
||||
} else {
|
||||
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
pagesUntilFullRefresh--;
|
||||
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
|
||||
} else if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||
} else {
|
||||
// Normal page without images, or images without anti-aliasing
|
||||
renderer.displayBuffer();
|
||||
pagesUntilFullRefresh--;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "BaseTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
@@ -9,7 +10,6 @@
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "I18n.h"
|
||||
#include "RecentBooksStore.h"
|
||||
@@ -45,11 +45,110 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
|
||||
|
||||
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
|
||||
}
|
||||
// Truncate a string with "..." to fit within maxWidth.
|
||||
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||
std::string truncated = text;
|
||||
std::string withEllipsis = truncated + "...";
|
||||
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
withEllipsis = truncated + "...";
|
||||
}
|
||||
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||
}
|
||||
|
||||
// Text wrapping with 3-tier break logic:
|
||||
// 1) Preferred delimiters: " -- ", " - ", en-dash, em-dash (title-author separator)
|
||||
// 2) Word boundaries: last space or hyphen that fits
|
||||
// 3) Character-level fallback for long unbroken tokens
|
||||
// The last allowed line is truncated with "..." if it overflows.
|
||||
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||
|
||||
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||
lines.push_back(text);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (maxLines == 1) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Tier 1: Try preferred delimiters (last occurrence to maximize line 1 content).
|
||||
// \xe2\x80\x93 = en-dash, \xe2\x80\x94 = em-dash
|
||||
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||
for (const char* delim : preferredDelimiters) {
|
||||
size_t delimLen = strlen(delim);
|
||||
auto pos = text.rfind(delim);
|
||||
if (pos != std::string::npos && pos > 0) {
|
||||
std::string firstPart = text.substr(0, pos);
|
||||
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||
lines.push_back(firstPart);
|
||||
std::string remainder = text.substr(pos + delimLen);
|
||||
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||
} else {
|
||||
lines.push_back(remainder);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 & 3: Word-boundary wrapping with character-level fallback.
|
||||
std::string currentLine;
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
std::string lineAtBreak;
|
||||
const unsigned char* ptrAtBreak = nullptr;
|
||||
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* charStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||
std::string candidate = currentLine + nextChar;
|
||||
|
||||
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||
currentLine = candidate;
|
||||
if (cp == ' ' || cp == '-') {
|
||||
lineAtBreak = currentLine;
|
||||
ptrAtBreak = ptr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overflow
|
||||
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||
if (ptrAtBreak != nullptr) {
|
||||
std::string line = lineAtBreak;
|
||||
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||
lines.push_back(line);
|
||||
ptr = ptrAtBreak;
|
||||
while (*ptr == ' ') ++ptr;
|
||||
currentLine.clear();
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = nextChar;
|
||||
}
|
||||
lineAtBreak.clear();
|
||||
ptrAtBreak = nullptr;
|
||||
} else {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
|
||||
if (showPercentage) {
|
||||
@@ -64,7 +163,7 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
|
||||
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Right aligned: percentage on left, icon on right (UI headers)
|
||||
// rect.x is already positioned for the icon (drawHeader calculated it)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
|
||||
if (showPercentage) {
|
||||
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
int contentWidth = rect.width - 5;
|
||||
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
||||
|
||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
// Detect if selected row's title overflows and needs 2-line expansion
|
||||
bool selectedExpands = false;
|
||||
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||
int titleTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto selTitle = rowTitle(selectedIndex);
|
||||
if (renderer.getTextWidth(font, selTitle.c_str()) > titleTextWidth) {
|
||||
selectedExpands = true;
|
||||
}
|
||||
}
|
||||
|
||||
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||
if (totalPages > 1) {
|
||||
constexpr int indicatorWidth = 20;
|
||||
constexpr int arrowSize = 6;
|
||||
constexpr int margin = 15; // Offset from right edge
|
||||
constexpr int margin = 15;
|
||||
|
||||
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
|
||||
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
|
||||
const int indicatorTop = rect.y;
|
||||
const int indicatorBottom = rect.y + rect.height - arrowSize;
|
||||
|
||||
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + i * 2;
|
||||
const int startX = centerX - i;
|
||||
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
|
||||
}
|
||||
|
||||
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
|
||||
const int startX = centerX - (arrowSize - 1 - i);
|
||||
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection
|
||||
int contentWidth = rect.width - 5;
|
||||
if (selectedIndex >= 0) {
|
||||
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
|
||||
// Compute page start: use effective page items but prevent backward leak
|
||||
int pageStartIndex;
|
||||
if (selectedExpands) {
|
||||
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||
int originalStart = selectedIndex / pageItems * pageItems;
|
||||
pageStartIndex = std::max(rawStart, originalStart);
|
||||
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||
}
|
||||
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
// Include previous page's boundary item if it would need expansion when selected,
|
||||
// so it doesn't vanish when navigating from it to the current page.
|
||||
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection highlight
|
||||
if (selectedIndex >= 0) {
|
||||
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||
int selY = rect.y + selRowsBeforeOnPage * rowHeight - 2;
|
||||
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||
renderer.fillRect(0, selY, rect.width, selHeight);
|
||||
}
|
||||
|
||||
// Draw all items
|
||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
||||
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
||||
int yPos = rect.y;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||
|
||||
// Draw name
|
||||
auto itemName = rowTitle(i);
|
||||
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
|
||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
// Draw subtitle
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
|
||||
i != selectedIndex);
|
||||
if (isExpanded) {
|
||||
int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
|
||||
auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
|
||||
|
||||
for (size_t l = 0; l < lines.size(); ++l) {
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
|
||||
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
|
||||
}
|
||||
|
||||
if (rowValue != nullptr) {
|
||||
// Draw value
|
||||
std::string valueText = rowValue(i);
|
||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
||||
itemY, valueText.c_str(), i != selectedIndex);
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
|
||||
yPos + rowHeight, valueText.c_str(), false);
|
||||
}
|
||||
yPos += 2 * rowHeight;
|
||||
} else {
|
||||
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
|
||||
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
|
||||
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, yPos, item.c_str(),
|
||||
i != selectedIndex);
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, yPos + 30,
|
||||
subtitle.c_str(), i != selectedIndex);
|
||||
}
|
||||
|
||||
if (rowValue != nullptr) {
|
||||
std::string valueText = rowValue(i);
|
||||
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, yPos,
|
||||
valueText.c_str(), i != selectedIndex);
|
||||
}
|
||||
yPos += rowHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,14 +527,52 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
|
||||
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
|
||||
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
|
||||
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
|
||||
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
||||
const int bookWidth = rect.width / 2;
|
||||
const int bookHeight = rect.height;
|
||||
const int bookX = (rect.width - bookWidth) / 2;
|
||||
const int bookY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
const bool bookSelected = hasContinueReading && selectorIndex == 0;
|
||||
|
||||
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
||||
// Adapt width to cover image aspect ratio; fall back to half screen when no cover
|
||||
const int baseHeight = rect.height;
|
||||
|
||||
int bookWidth;
|
||||
bool hasCoverImage = false;
|
||||
|
||||
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
|
||||
const std::string coverBmpPath =
|
||||
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
|
||||
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
hasCoverImage = true;
|
||||
const int imgWidth = bitmap.getWidth();
|
||||
const int imgHeight = bitmap.getHeight();
|
||||
|
||||
if (imgWidth > 0 && imgHeight > 0) {
|
||||
const float aspectRatio = static_cast<float>(imgWidth) / static_cast<float>(imgHeight);
|
||||
bookWidth = static_cast<int>(baseHeight * aspectRatio);
|
||||
|
||||
const int maxWidth = static_cast<int>(rect.width * 0.9f);
|
||||
if (bookWidth > maxWidth) {
|
||||
bookWidth = maxWidth;
|
||||
}
|
||||
} else {
|
||||
bookWidth = rect.width / 2;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCoverImage) {
|
||||
bookWidth = rect.width / 2;
|
||||
}
|
||||
|
||||
const int bookX = rect.x + (rect.width - bookWidth) / 2;
|
||||
const int bookY = rect.y;
|
||||
const int bookHeight = baseHeight;
|
||||
|
||||
// Bookmark dimensions (used in multiple places)
|
||||
const int bookmarkWidth = bookWidth / 8;
|
||||
const int bookmarkHeight = bookHeight / 5;
|
||||
@@ -394,29 +594,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
LOG_DBG("THEME", "Rendering bmp");
|
||||
// Calculate position to center image within the book card
|
||||
int coverX, coverY;
|
||||
|
||||
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
|
||||
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
|
||||
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
|
||||
|
||||
if (imgRatio > boxRatio) {
|
||||
coverX = bookX;
|
||||
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
|
||||
coverY = bookY;
|
||||
}
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
|
||||
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
|
||||
}
|
||||
|
||||
// Draw the cover image centered within the book card
|
||||
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
|
||||
|
||||
// Draw border around the card
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
|
||||
// No bookmark ribbon when cover is shown - it would just cover the art
|
||||
@@ -597,7 +777,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
|
||||
const int boxWidth = maxTextWidth + boxPadding * 2;
|
||||
const int boxHeight = totalTextHeight + boxPadding * 2;
|
||||
const int boxX = (rect.width - boxWidth) / 2;
|
||||
const int boxX = rect.x + (rect.width - boxWidth) / 2;
|
||||
const int boxY = titleYStart - boxPadding;
|
||||
|
||||
// Draw box (inverted when selected: black box instead of white)
|
||||
@@ -640,7 +820,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
constexpr int continuePadding = 6;
|
||||
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
|
||||
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
|
||||
const int continueBoxX = (rect.width - continueBoxWidth) / 2;
|
||||
const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2;
|
||||
const int continueBoxY = continueY - continuePadding / 2;
|
||||
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
|
||||
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
|
||||
|
||||
@@ -82,7 +82,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
|
||||
.tabBarHeight = 50,
|
||||
.scrollBarWidth = 4,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 20,
|
||||
.homeTopPadding = 40,
|
||||
.homeCoverHeight = 400,
|
||||
.homeCoverTileHeight = 400,
|
||||
.homeRecentBooksCount = 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "LyraTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
@@ -10,7 +11,6 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -84,11 +84,100 @@ const uint8_t* iconForName(UIIcon icon, int size) {
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
|
||||
std::string truncated = text;
|
||||
std::string withEllipsis = truncated + "...";
|
||||
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
withEllipsis = truncated + "...";
|
||||
}
|
||||
return truncated.empty() ? std::string("...") : withEllipsis;
|
||||
}
|
||||
|
||||
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
|
||||
|
||||
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
|
||||
lines.push_back(text);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (maxLines == 1) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
|
||||
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
|
||||
for (const char* delim : preferredDelimiters) {
|
||||
size_t delimLen = strlen(delim);
|
||||
auto pos = text.rfind(delim);
|
||||
if (pos != std::string::npos && pos > 0) {
|
||||
std::string firstPart = text.substr(0, pos);
|
||||
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
|
||||
lines.push_back(firstPart);
|
||||
std::string remainder = text.substr(pos + delimLen);
|
||||
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
|
||||
} else {
|
||||
lines.push_back(remainder);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string currentLine;
|
||||
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
|
||||
std::string lineAtBreak;
|
||||
const unsigned char* ptrAtBreak = nullptr;
|
||||
|
||||
while (*ptr != 0) {
|
||||
const unsigned char* charStart = ptr;
|
||||
uint32_t cp = utf8NextCodepoint(&ptr);
|
||||
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
|
||||
std::string candidate = currentLine + nextChar;
|
||||
|
||||
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
|
||||
currentLine = candidate;
|
||||
if (cp == ' ' || cp == '-') {
|
||||
lineAtBreak = currentLine;
|
||||
ptrAtBreak = ptr;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
|
||||
if (ptrAtBreak != nullptr) {
|
||||
std::string line = lineAtBreak;
|
||||
while (!line.empty() && line.back() == ' ') line.pop_back();
|
||||
lines.push_back(line);
|
||||
ptr = ptrAtBreak;
|
||||
while (*ptr == ' ') ++ptr;
|
||||
currentLine.clear();
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = nextChar;
|
||||
}
|
||||
lineAtBreak.clear();
|
||||
ptrAtBreak = nullptr;
|
||||
} else {
|
||||
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Left aligned: icon on left, percentage on right (reader mode)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
const int battWidth = LyraMetrics::values.batteryWidth;
|
||||
|
||||
@@ -125,7 +214,7 @@ void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
|
||||
|
||||
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
||||
// Right aligned: percentage on left, icon on right (UI headers)
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
const int y = rect.y + 6;
|
||||
const int battWidth = LyraMetrics::values.batteryWidth;
|
||||
|
||||
@@ -278,13 +367,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
|
||||
int pageItems = rect.height / rowHeight;
|
||||
|
||||
const int totalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
// Detect if selected row's title overflows and needs 2-line expansion
|
||||
bool selectedExpands = false;
|
||||
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
|
||||
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
|
||||
int prelContentWidth =
|
||||
rect.width -
|
||||
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
|
||||
|
||||
auto selTitle = rowTitle(selectedIndex);
|
||||
auto selValue = rowValue(selectedIndex);
|
||||
int selValueWidth = 0;
|
||||
if (!selValue.empty()) {
|
||||
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
|
||||
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
|
||||
selectedExpands = true;
|
||||
}
|
||||
}
|
||||
|
||||
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
|
||||
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
|
||||
if (totalPages > 1) {
|
||||
const int scrollAreaHeight = rect.height;
|
||||
|
||||
// Draw scroll bar
|
||||
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
|
||||
const int currentPage = selectedIndex / pageItems;
|
||||
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
|
||||
const int currentPage = selectedIndex / effectivePageItems;
|
||||
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
|
||||
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
|
||||
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
|
||||
@@ -292,19 +403,71 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
scrollBarHeight, true);
|
||||
}
|
||||
|
||||
// Draw selection
|
||||
int contentWidth =
|
||||
rect.width -
|
||||
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
||||
|
||||
// Compute page start: use effective page items but prevent backward leak
|
||||
int pageStartIndex;
|
||||
if (selectedExpands) {
|
||||
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
|
||||
int originalStart = selectedIndex / pageItems * pageItems;
|
||||
pageStartIndex = std::max(rawStart, originalStart);
|
||||
if (selectedIndex >= pageStartIndex + effectivePageItems) {
|
||||
pageStartIndex = selectedIndex - effectivePageItems + 1;
|
||||
}
|
||||
if (pageStartIndex > 0 && pageStartIndex == originalStart
|
||||
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
|
||||
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
int prevValueWidth = 0;
|
||||
if (rowValue != nullptr) {
|
||||
auto prevValue = rowValue(pageStartIndex - 1);
|
||||
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||
if (!prevValue.empty()) {
|
||||
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
// Include previous page's boundary item if it would need expansion when selected,
|
||||
// so it doesn't vanish when navigating from it to the current page.
|
||||
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
|
||||
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
|
||||
auto prevTitle = rowTitle(pageStartIndex - 1);
|
||||
int prevValueWidth = 0;
|
||||
if (rowValue != nullptr) {
|
||||
auto prevValue = rowValue(pageStartIndex - 1);
|
||||
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
|
||||
if (!prevValue.empty()) {
|
||||
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
|
||||
}
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
|
||||
pageStartIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw selection highlight
|
||||
if (selectedIndex >= 0) {
|
||||
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
|
||||
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
|
||||
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
|
||||
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
|
||||
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
|
||||
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
|
||||
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
|
||||
Color::LightGray);
|
||||
}
|
||||
|
||||
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
|
||||
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
|
||||
int iconSize;
|
||||
int iconSize = listIconSize;
|
||||
if (rowIcon != nullptr) {
|
||||
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
|
||||
textX += iconSize + hPaddingInSelection;
|
||||
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
}
|
||||
|
||||
// Draw all items
|
||||
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
|
||||
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
||||
const int itemY = rect.y + (i % pageItems) * rowHeight;
|
||||
int rowTextWidth = textWidth;
|
||||
int yPos = rect.y;
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
|
||||
const bool isExpanded = (selectedExpands && i == selectedIndex);
|
||||
|
||||
// Draw name
|
||||
int valueWidth = 0;
|
||||
std::string valueText = "";
|
||||
std::string valueText;
|
||||
if (rowValue != nullptr) {
|
||||
valueText = rowValue(i);
|
||||
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
|
||||
if (!valueText.empty()) {
|
||||
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
|
||||
rowTextWidth -= valueWidth;
|
||||
}
|
||||
}
|
||||
|
||||
auto itemName = rowTitle(i);
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
|
||||
|
||||
if (isExpanded) {
|
||||
int wrapWidth = textWidth;
|
||||
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
|
||||
|
||||
for (size_t l = 0; l < lines.size(); ++l) {
|
||||
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
|
||||
}
|
||||
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
itemY + iconY, iconSize, iconSize);
|
||||
yPos + iconY, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (!valueText.empty()) {
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
||||
yPos + rowHeight + 7, valueText.c_str(), true);
|
||||
}
|
||||
yPos += 2 * rowHeight;
|
||||
} else {
|
||||
int rowTextWidth = textWidth - valueWidth;
|
||||
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
|
||||
|
||||
if (rowIcon != nullptr) {
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
yPos + iconY, iconSize, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowSubtitle != nullptr) {
|
||||
// Draw subtitle
|
||||
std::string subtitleText = rowSubtitle(i);
|
||||
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
|
||||
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
|
||||
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
|
||||
}
|
||||
|
||||
// Draw value
|
||||
if (!valueText.empty()) {
|
||||
if (i == selectedIndex && highlightValue) {
|
||||
renderer.fillRoundedRect(
|
||||
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
|
||||
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
|
||||
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
|
||||
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
|
||||
renderer.drawText(UI_10_FONT_ID,
|
||||
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
|
||||
valueText.c_str(), !(i == selectedIndex && highlightValue));
|
||||
}
|
||||
yPos += rowHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
@@ -227,9 +226,9 @@ void onGoHome();
|
||||
void onGoToMyLibraryWithPath(const std::string& path);
|
||||
void onGoToRecentBooks();
|
||||
void onGoToReader(const std::string& initialEpubPath) {
|
||||
const std::string bookPath = initialEpubPath; // Copy before exitActivity() invalidates the reference
|
||||
exitActivity();
|
||||
enterNewActivity(
|
||||
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath));
|
||||
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome, onGoToMyLibraryWithPath));
|
||||
}
|
||||
|
||||
void onGoToFileTransfer() {
|
||||
|
||||
Reference in New Issue
Block a user