12 Commits

Author SHA1 Message Date
cottongin
4dadea1a03 perf: Port upstream PR #1027 — word-width cache and hyphenation early exit
Reduces ParsedText::layoutAndExtractLines CPU time 5–9% via two
independent optimizations from jpirnay's PR #1027:

- 128-entry direct-mapped word-width cache (4 KB BSS, FNV-1a hash)
  absorbs redundant getTextAdvanceX calls across paragraphs
- Early exit in hyphenateWordAtIndex when prefix exceeds available
  width (ascending byte-offset order guarantees monotonic widths)
- Reusable prefix string buffer eliminates per-candidate substr allocs
- Reserve hint for lineBreakIndices in computeLineBreaks

List-specific upstream changes (splice, iterator style) not applicable
as mod already uses std::vector (PR #1038). Benchmark infrastructure
excluded (removed by author in final commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 01:48:58 -05:00
cottongin
0d9a1f4f89 perf: Port upstream PR #1055 — byte-level framebuffer writes
Replace per-pixel drawPixel calls with byte-level framebuffer writes
for fillRect, axis-aligned drawLine, and fillRectDither. Adds
fillPhysicalHSpanByte/fillPhysicalHSpan helpers that write directly
to physical rows with memset and partial-byte masking.

Also applies coderabbit nitpick: fillPolygon scanline fill now uses
fillPhysicalHSpan for Landscape orientations.

Upstream: https://github.com/crosspoint-reader/crosspoint-reader/pull/1055
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 01:14:30 -05:00
cottongin
1b350656a5 fix: Restore normal CPU frequency before drawing sleep screen
When auto-sleep triggers after inactivity, the CPU remains at 10 MHz
(low power mode) during sleep screen rendering, causing it to draw
much slower than expected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 20:28:57 -05:00
cottongin
51dc498768 feat: Expandable selected row for long filenames in File Browser
When the selected row's filename overflows the available text width
(with extension), the row expands to 2 lines with smart text wrapping.
The file extension moves to the second row (right-aligned). Non-selected
rows retain single-line truncation.

Key behaviors:
- 3-tier text wrapping: preferred delimiters (" - ", " -- ", en/em-dash),
  word boundaries, then character-level fallback
- Row-height line spacing for natural visual rhythm
- Icons aligned with line 1 (LyraTheme)
- Pagination uses effectivePageItems with anti-leak clamping to prevent
  page boundary shifts while ensuring all items remain accessible
- Boundary item duplication: items bumped from a page due to expansion
  appear at the top of the next page, guarded against cascading

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:42:56 -05:00
cottongin
406c3aeace fix: Port upstream PRs #1038, #1037, #1045, #1019
- #1038 (partial): Add .erase() for consumed words in layoutAndExtractLines
  to fix redundant early flush bug; fix wordContinues flag in hyphenateWordAtIndex
- #1037: Add combining mark handling for hyphenation (NFC-like precomposition)
  and rendering (base glyph tracking in EpdFont, GfxRenderer including CCW)
- #1045: Shorten STR_FORGET_BUTTON labels across all 9 translation files
- #1019: Display file extensions in File Browser via getFileExtension helper
- Pull romanian.yaml from upstream/master (merged PR #987)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 16:27:59 -05:00
cottongin
55a1fef01a fix: Port upstream 1.1.0-rc PRs #1014, #1018, #990 and align #1002
Port three new upstream commits and align the existing #1002 port:

- PR #1014: Strip unused CSS rules by filtering unsupported selector
  types (+, >, [, :, #, ~, *, descendants) in processRuleBlockWithStyle.
  Fix normalized() trailing whitespace to also strip newlines.
- PR #1018: Add deleteCache() to CssParser, move CSS_CACHE_VERSION to
  static class member, remove stale cache on version mismatch, invalidate
  section caches (Storage.removeDir) when CSS is rebuilt. Refactor
  parseCssFiles() to early-return when cache exists.
- PR #990: Adapt classic theme continue-reading card width to cover
  aspect ratio (clamped to 90% screen width), increase homeTopPadding
  20->40, fix centering with rect.x offset for boxX/continueBoxX.
- #1002 alignment: Add tryInterpretLength() to skip non-numeric CSS
  values (auto, inherit), add "both width and height set" image sizing
  branch in ChapterHtmlSlimParser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 15:52:30 -05:00
cottongin
18be265a4a fix: Re-apply upstream PRs #1005, #1010, #1003
Re-applies changes that were accidentally discarded during a prior
dry-run cherry-pick reset (git checkout -- .).

- PR #1005: Use HalPowerManager for battery percentage (uint16_t return
  type, remove Battery.h, update theme files)
- PR #1010: Fix dangling pointer in onGoToReader()
- PR #1003: Render image placeholders while waiting for decode (adds
  isCached, renderPlaceholder, renderTextOnly, countUncachedImages,
  renderImagePlaceholders)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 22:31:07 -05:00
cottongin
3a0641889f perf: Port upstream font drawing performance optimization (PR #978)
Cherry-pick upstream commit 07d715e which refactors renderChar and
drawTextRotated90CW into a template-based renderCharImpl, hoisting
the is2Bit branch outside inner pixel loops for 15-23% speedup.

Additionally extends the template with Rotated90CCW to fix two bugs
in the mod's drawTextRotated90CCW: operator precedence in bmpVal
calculation and missing compressed font support via getGlyphBitmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 22:20:44 -05:00
cottongin
ad282cadfe fix: Align double FAST_REFRESH image rendering with upstream PR #957
Reorder refresh branches so image+AA pages always use the double
FAST_REFRESH technique instead of occasionally falling through to
HALF_REFRESH when the refresh counter expires. Image pages no longer
count toward the full refresh cadence. Remove experimental Method B
toggle (USE_IMAGE_DOUBLE_FAST_REFRESH / displayWindow).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 14:30:10 -05:00
cottongin
c8ba4fe973 fix: Port upstream CSS-aware image sizing (PR #1002)
Parse CSS height/width into CssStyle for images and use aspect-ratio-
preserving logic when CSS dimensions are set. Falls back to viewport-fit
scaling when no CSS dimensions are present. Includes divide-by-zero
guards and viewport clamping with aspect ratio rescaling.

- Add imageHeight field to CssStyle/CssPropertyFlags
- Parse CSS height declarations into imageHeight
- Add imageHeight + width to cache serialization (bump cache v2->v3)
- Replace viewport-fit-only image scaling with CSS-aware sizing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 14:21:31 -05:00
cottongin
c1b8e53138 fix: Port upstream 1.1.0-rc fixes (glyph null-safety, PNGdec wide image buffer)
Cherry-pick two bug fixes from upstream PR #992:

- fix(GfxRenderer): Null-safety in getSpaceWidth/getTextAdvanceX to
  prevent Load access fault when bold/italic font variants lack certain
  glyphs (upstream 3e2c518)
- fix(PNGdec): Increase PNG_MAX_BUFFERED_PIXELS to 16416 for 2048px
  wide images and add pre-decode buffer overflow guard (upstream b8e743e)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 13:20:30 -05:00
cottongin
0fda9031fd fix: Use double FAST_REFRESH for dithered letterbox sleep covers
Replace HALF_REFRESH with double FAST_REFRESH technique for the BW
pass when dithered letterbox fill is active. This avoids the e-ink
crosstalk and image corruption that occurred when HALF_REFRESH drove
large areas of dithered gray pixels simultaneously.

Revert the hash-based block dithering workaround (bayerCrossesBwBoundary,
hashBlockDither) back to standard Bayer dithering for all gray ranges,
since the root cause was HALF_REFRESH rather than the dithering pattern
itself.

Letterbox fill is now included in all three render passes (BW, LSB, MSB)
so the greyscale LUT treats letterbox pixels identically to cover pixels,
maintaining color-matched edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 11:33:45 -05:00
39 changed files with 1986 additions and 568 deletions

View File

@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
int cursorX = startX; int cursorX = startX;
const int cursorY = startY; 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; uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) { while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const EpdGlyph* glyph = getGlyph(cp); const EpdGlyph* glyph = getGlyph(cp);
@@ -30,12 +35,31 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue; continue;
} }
*minX = std::min(*minX, cursorX + glyph->left); const bool isCombining = utf8IsCombiningMark(cp);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width); int raiseBy = 0;
*minY = std::min(*minY, cursorY + glyph->top - glyph->height); if (isCombining && hasBaseGlyph) {
*maxY = std::max(*maxY, cursorY + glyph->top); 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; cursorX += glyph->advanceX;
} }
}
} }
void EpdFont::getTextDimensions(const char* string, int* w, int* h) const { void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {

View File

@@ -213,23 +213,23 @@ bool Epub::parseTocNavFile() const {
} }
void Epub::parseCssFiles() const { void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed) constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024;
// Larger files risk memory exhaustion on ESP32 constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024;
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
if (cssFiles.empty()) { if (cssFiles.empty()) {
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles"); 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 LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
if (!cssParser->hasCache()) {
// No cache yet - parse CSS files if (cssParser->hasCache()) {
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
return;
}
for (const auto& cssPath : cssFiles) { for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str()); LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Check heap before parsing - CSS parsing allocates heavily
const uint32_t freeHeap = ESP.getFreeHeap(); const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) { if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap, 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; continue;
} }
// Check CSS file size before decompressing - skip files that are too large
size_t cssFileSize = 0; size_t cssFileSize = 0;
if (getItemSize(cssPath, &cssFileSize)) { if (getItemSize(cssPath, &cssFileSize)) {
if (cssFileSize > MAX_CSS_FILE_SIZE) { 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"; const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile; FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
@@ -262,7 +260,6 @@ void Epub::parseCssFiles() const {
} }
tempCssFile.close(); tempCssFile.close();
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading"); LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
@@ -273,14 +270,12 @@ void Epub::parseCssFiles() const {
Storage.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
} }
// Save to cache for next time
if (!cssParser->saveToCache()) { if (!cssParser->saveToCache()) {
LOG_ERR("EBP", "Failed to save CSS rules to cache"); LOG_ERR("EBP", "Failed to save CSS rules to cache");
} }
cssParser->clear(); cssParser->clear();
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size()); LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
}
} }
// load in the meta data for the epub file // load in the meta data for the epub file
@@ -294,14 +289,17 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try to load existing cache first // Try to load existing cache first
if (bookMetadataCache->load()) { if (bookMetadataCache->load()) {
if (!skipLoadingCss && !cssParser->hasCache()) { if (!skipLoadingCss) {
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files"); if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
// to get CSS file list LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
cssParser->deleteCache();
if (!parseContentOpf(bookMetadataCache->coreMetadata)) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files"); 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(); parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
}
} }
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true; return true;
@@ -400,8 +398,8 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
} }
if (!skipLoadingCss) { if (!skipLoadingCss) {
// Parse CSS files after cache reload
parseCssFiles(); parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
} }
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());

View File

@@ -61,6 +61,49 @@ std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos)); 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 // PageTableRow
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -80,6 +80,8 @@ class PageImage final : public PageElement {
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override; bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; } 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); static std::unique_ptr<PageImage> deserialize(FsFile& file);
// Helper to get image block dimensions (needed for bounding box calculation) // 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. // Returns true if page has images and fills out the bounding box coordinates.
// If no images, returns false. // If no images, returns false.
bool getImageBoundingBox(int& outX, int& outY, int& outWidth, int& outHeight) const; 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;
}; };

View File

@@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstring>
#include <functional> #include <functional>
#include <limits> #include <limits>
#include <vector> #include <vector>
@@ -51,6 +52,80 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style); return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
} }
// ---------------------------------------------------------------------------
// Direct-mapped word-width cache
//
// Avoids redundant getTextAdvanceX calls when the same (word, style, fontId)
// triple appears across paragraphs. A fixed-size static array is used so
// that heap allocation and fragmentation are both zero.
//
// Eviction policy: hash-direct mapping — a word always occupies the single
// slot determined by its hash; a collision simply overwrites that slot.
// This gives O(1) lookup (one hash + one memcmp) regardless of how full the
// cache is, avoiding the O(n) linear-scan overhead that causes a regression
// on corpora with many unique words (e.g. German compound-heavy text).
//
// Words longer than 23 bytes bypass the cache entirely — they are uncommon,
// unlikely to repeat verbatim, and exceed the fixed-width key buffer.
// ---------------------------------------------------------------------------
struct WordWidthCacheEntry {
char word[24]; // NUL-terminated; 23 usable bytes + terminator
int fontId;
uint16_t width;
uint8_t style; // EpdFontFamily::Style narrowed to one byte
bool valid; // false = slot empty (BSS-initialised to 0)
};
// Power-of-two size → slot selection via fast bitmask AND.
// 128 entries × 32 bytes = 4 KB in BSS; covers typical paragraph vocabulary
// with a low collision rate even for German compound-heavy prose.
static constexpr uint32_t WORD_WIDTH_CACHE_SIZE = 128;
static constexpr uint32_t WORD_WIDTH_CACHE_MASK = WORD_WIDTH_CACHE_SIZE - 1;
static WordWidthCacheEntry s_wordWidthCache[WORD_WIDTH_CACHE_SIZE];
// FNV-1a over the word bytes, then XOR-folded with fontId and style.
static uint32_t wordWidthCacheHash(const char* str, const size_t len, const int fontId, const uint8_t style) {
uint32_t h = 2166136261u; // FNV offset basis
for (size_t i = 0; i < len; ++i) {
h ^= static_cast<uint8_t>(str[i]);
h *= 16777619u; // FNV prime
}
h ^= static_cast<uint32_t>(fontId);
h *= 16777619u;
h ^= style;
return h;
}
// Returns the cached width for (word, style, fontId), measuring and caching
// on a miss. Appending a hyphen is not supported — those measurements are
// word-fragment lookups that will not repeat and must not pollute the cache.
static uint16_t cachedMeasureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
const EpdFontFamily::Style style) {
const size_t len = word.size();
if (len >= 24) {
return measureWordWidth(renderer, fontId, word, style);
}
const uint8_t styleByte = static_cast<uint8_t>(style);
const char* const wordCStr = word.c_str();
const uint32_t slot = wordWidthCacheHash(wordCStr, len, fontId, styleByte) & WORD_WIDTH_CACHE_MASK;
auto& e = s_wordWidthCache[slot];
if (e.valid && e.fontId == fontId && e.style == styleByte && memcmp(e.word, wordCStr, len + 1) == 0) {
return e.width; // O(1) cache hit
}
const uint16_t w = measureWordWidth(renderer, fontId, word, style);
memcpy(e.word, wordCStr, len + 1);
e.fontId = fontId;
e.width = w;
e.style = styleByte;
e.valid = true;
return w;
}
} // namespace } // namespace
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
@@ -100,6 +175,15 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
for (size_t i = 0; i < lineCount; ++i) { for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine); 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) { std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
@@ -107,7 +191,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
wordWidths.reserve(words.size()); wordWidths.reserve(words.size());
for (size_t i = 0; i < words.size(); ++i) { for (size_t i = 0; i < words.size(); ++i) {
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i])); wordWidths.push_back(cachedMeasureWordWidth(renderer, fontId, words[i], wordStyles[i]));
} }
return wordWidths; return wordWidths;
@@ -219,6 +303,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// Stores the index of the word that starts the next line (last_word_index + 1) // Stores the index of the word that starts the next line (last_word_index + 1)
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
lineBreakIndices.reserve(totalWordCount / 8 + 1);
size_t currentWordIndex = 0; size_t currentWordIndex = 0;
while (currentWordIndex < totalWordCount) { while (currentWordIndex < totalWordCount) {
@@ -359,6 +444,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
bool chosenNeedsHyphen = true; bool chosenNeedsHyphen = true;
// Iterate over each legal breakpoint and retain the widest prefix that still fits. // Iterate over each legal breakpoint and retain the widest prefix that still fits.
// Re-use a single string buffer to avoid one heap allocation per candidate breakpoint.
std::string prefix;
prefix.reserve(word.size());
for (const auto& info : breakInfos) { for (const auto& info : breakInfos) {
const size_t offset = info.byteOffset; const size_t offset = info.byteOffset;
if (offset == 0 || offset >= word.size()) { if (offset == 0 || offset >= word.size()) {
@@ -366,9 +454,15 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
} }
const bool needsHyphen = info.requiresInsertedHyphen; const bool needsHyphen = info.requiresInsertedHyphen;
const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen); prefix.assign(word, 0, offset);
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) { const int prefixWidth = measureWordWidth(renderer, fontId, prefix, style, needsHyphen);
continue; // Skip if too wide or not an improvement if (prefixWidth > availableWidth) {
// breakOffsets returns candidates in ascending byte-offset order, and prefix width is
// non-decreasing with offset, so every subsequent candidate will also be too wide.
break;
}
if (prefixWidth <= chosenWidth) {
continue;
} }
chosenWidth = prefixWidth; chosenWidth = prefixWidth;
@@ -392,11 +486,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(words.begin() + wordIndex + 1, remainder); words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style); wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The remainder inherits whatever continuation status the original word had with the word after it. // Preserve the prefix's attach-to-previous flag; allow a break between prefix and remainder.
const bool originalContinuedToNext = wordContinues[wordIndex]; wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Forced break belongs to the original whole word; transfer it to the remainder (last part). // Forced break belongs to the original whole word; transfer it to the remainder (last part).
if (!forceBreakAfter.empty()) { if (!forceBreakAfter.empty()) {

View File

@@ -93,6 +93,11 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
} // namespace } // 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) { 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); LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);

View File

@@ -16,6 +16,7 @@ class ImageBlock final : public Block {
int16_t getHeight() const { return height; } int16_t getHeight() const { return height; }
bool imageExists() const; bool imageExists() const;
bool isCached() const;
BlockType getType() override { return IMAGE_BLOCK; } BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; } bool isEmpty() override { return false; }

View File

@@ -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 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 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. // 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. // 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. // 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, LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
ctx.scale, png->getBpp()); 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) { if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath); warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
} }

View File

@@ -74,7 +74,7 @@ std::string CssParser::normalized(const std::string& s) {
} }
// Remove trailing space // Remove trailing space
if (!result.empty() && result.back() == ' ') { while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
result.pop_back(); result.pop_back();
} }
return result; return result;
@@ -189,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
} }
CssLength CssParser::interpretLength(const std::string& val) { CssLength CssParser::interpretLength(const std::string& val) {
const std::string v = normalized(val); CssLength result;
if (v.empty()) return CssLength{}; 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(); size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) { for (size_t i = 0; i < v.size(); ++i) {
const char c = v[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 numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart); const std::string unitPart = v.substr(unitStart);
// Parse numeric value
char* endPtr = nullptr; char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr); 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; auto unit = CssUnit::Pixels;
if (unitPart == "em") { if (unitPart == "em") {
unit = CssUnit::Em; unit = CssUnit::Em;
@@ -221,9 +230,9 @@ CssLength CssParser::interpretLength(const std::string& val) {
} else if (unitPart == "%") { } else if (unitPart == "%") {
unit = CssUnit::Percent; unit = CssUnit::Percent;
} }
// px and unitless default to Pixels
return CssLength{numericValue, unit}; out = CssLength{numericValue, unit};
return true;
} }
// Declaration parsing // Declaration parsing
@@ -295,10 +304,19 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft = style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
1; 1;
} }
} else if (propNameBuf == "height") {
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.imageHeight = len;
style.defined.imageHeight = 1;
}
} else if (propNameBuf == "width") { } else if (propNameBuf == "width") {
style.width = interpretLength(propValueBuf); CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.width = len;
style.defined.width = 1; style.defined.width = 1;
} }
}
} }
CssStyle CssParser::parseDeclarations(const std::string& declBlock) { CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
@@ -346,6 +364,17 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons
std::string key = normalized(sel); std::string key = normalized(sel);
if (key.empty()) continue; 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 // Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) { if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing"); 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); result.applyOver(tagIt->second);
} }
// TODO: Support combinations of classes (e.g. style on .class1.class2)
// 2. Apply class styles (medium priority) // 2. Apply class styles (medium priority)
if (!classAttr.empty()) { if (!classAttr.empty()) {
const auto classes = splitWhitespace(classAttr); 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) // 3. Apply element.class styles (higher priority)
for (const auto& cls : classes) { for (const auto& cls : classes) {
std::string combinedKey = tag + "." + normalized(cls); std::string combinedKey = tag + "." + normalized(cls);
@@ -564,12 +595,15 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache serialization // Cache serialization
// Cache format version - increment when format changes // Cache file name (version is CssParser::CSS_CACHE_VERSION)
constexpr uint8_t CSS_CACHE_VERSION = 2;
constexpr char rulesCache[] = "/css_rules.cache"; constexpr char rulesCache[] = "/css_rules.cache";
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); } 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 { bool CssParser::saveToCache() const {
if (cachePath.empty()) { if (cachePath.empty()) {
return false; return false;
@@ -581,7 +615,7 @@ bool CssParser::saveToCache() const {
} }
// Write version // Write version
file.write(CSS_CACHE_VERSION); file.write(CssParser::CSS_CACHE_VERSION);
// Write rule count // Write rule count
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size()); const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
@@ -616,6 +650,8 @@ bool CssParser::saveToCache() const {
writeLength(style.paddingBottom); writeLength(style.paddingBottom);
writeLength(style.paddingLeft); writeLength(style.paddingLeft);
writeLength(style.paddingRight); writeLength(style.paddingRight);
writeLength(style.imageHeight);
writeLength(style.width);
// Write defined flags as uint16_t // Write defined flags as uint16_t
uint16_t definedBits = 0; uint16_t definedBits = 0;
@@ -632,6 +668,8 @@ bool CssParser::saveToCache() const {
if (style.defined.paddingBottom) definedBits |= 1 << 10; if (style.defined.paddingBottom) definedBits |= 1 << 10;
if (style.defined.paddingLeft) definedBits |= 1 << 11; if (style.defined.paddingLeft) definedBits |= 1 << 11;
if (style.defined.paddingRight) definedBits |= 1 << 12; 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)); file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
} }
@@ -655,9 +693,11 @@ bool CssParser::loadFromCache() {
// Read and verify version // Read and verify version
uint8_t version = 0; uint8_t version = 0;
if (file.read(&version, 1) != 1 || 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)", version, 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(); file.close();
Storage.remove((cachePath + rulesCache).c_str());
return false; return false;
} }
@@ -733,7 +773,8 @@ bool CssParser::loadFromCache() {
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) || if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) || !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(); rulesBySelector_.clear();
file.close(); file.close();
return false; return false;
@@ -759,6 +800,8 @@ bool CssParser::loadFromCache() {
style.defined.paddingBottom = (definedBits & 1 << 10) != 0; style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
style.defined.paddingLeft = (definedBits & 1 << 11) != 0; style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
style.defined.paddingRight = (definedBits & 1 << 12) != 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; rulesBySelector_[selector] = style;
} }

View File

@@ -82,6 +82,11 @@ class CssParser {
*/ */
bool hasCache() const; bool hasCache() const;
/**
* Delete CSS rules cache file if it exists
*/
void deleteCache() const;
/** /**
* Save parsed CSS rules to a cache file. * Save parsed CSS rules to a cache file.
* @return true if cache was written successfully * @return true if cache was written successfully
@@ -91,10 +96,14 @@ class CssParser {
/** /**
* Load CSS rules from a cache file. * Load CSS rules from a cache file.
* Clears any existing rules before loading. * Clears any existing rules before loading.
* Removes stale cache file on version mismatch.
* @return true if cache was loaded successfully * @return true if cache was loaded successfully
*/ */
bool loadFromCache(); 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: private:
// Storage: maps normalized selector -> style properties // Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_; std::unordered_map<std::string, CssStyle> rulesBySelector_;
@@ -113,6 +122,7 @@ class CssParser {
static CssFontWeight interpretFontWeight(const std::string& val); static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val); static CssTextDecoration interpretDecoration(const std::string& val);
static CssLength interpretLength(const std::string& val); static CssLength interpretLength(const std::string& val);
static bool tryInterpretLength(const std::string& val, CssLength& out);
// String utilities // String utilities
static std::string normalized(const std::string& s); static std::string normalized(const std::string& s);

View File

@@ -70,6 +70,7 @@ struct CssPropertyFlags {
uint16_t paddingLeft : 1; uint16_t paddingLeft : 1;
uint16_t paddingRight : 1; uint16_t paddingRight : 1;
uint16_t width : 1; uint16_t width : 1;
uint16_t imageHeight : 1;
CssPropertyFlags() CssPropertyFlags()
: textAlign(0), : textAlign(0),
@@ -85,18 +86,20 @@ struct CssPropertyFlags {
paddingBottom(0), paddingBottom(0),
paddingLeft(0), paddingLeft(0),
paddingRight(0), paddingRight(0),
width(0) {} width(0),
imageHeight(0) {}
[[nodiscard]] bool anySet() const { [[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || 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() { void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0; marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0; paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
width = 0; width = imageHeight = 0;
} }
}; };
@@ -118,7 +121,8 @@ struct CssStyle {
CssLength paddingBottom; // Padding after CssLength paddingBottom; // Padding after
CssLength paddingLeft; // Padding left CssLength paddingLeft; // Padding left
CssLength paddingRight; // Padding right 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 CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -181,6 +185,10 @@ struct CssStyle {
width = base.width; width = base.width;
defined.width = 1; defined.width = 1;
} }
if (base.hasImageHeight()) {
imageHeight = base.imageHeight;
defined.imageHeight = 1;
}
} }
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; } [[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -197,6 +205,7 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; } [[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; } [[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasWidth() const { return defined.width; } [[nodiscard]] bool hasWidth() const { return defined.width; }
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
void reset() { void reset() {
textAlign = CssTextAlign::Left; textAlign = CssTextAlign::Left;
@@ -207,6 +216,7 @@ struct CssStyle {
marginTop = marginBottom = marginLeft = marginRight = CssLength{}; marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
width = CssLength{}; width = CssLength{};
imageHeight = CssLength{};
defined.clearAll(); defined.clearAll();
} }
}; };

View File

@@ -174,6 +174,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
while (*ptr != 0) { while (*ptr != 0) {
const unsigned char* current = ptr; const unsigned char* current = ptr;
const uint32_t cp = utf8NextCodepoint(&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)}); cps.push_back({cp, static_cast<size_t>(current - base)});
} }

View File

@@ -418,7 +418,74 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (decoder->getDimensions(cachedImagePath, dims)) { if (decoder->getDimensions(cachedImagePath, dims)) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height); 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 maxWidth = self->viewportWidth;
int maxHeight = self->viewportHeight; int maxHeight = self->viewportHeight;
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; 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; float scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f; if (scale > 1.0f) scale = 1.0f;
int displayWidth = (int)(dims.width * scale); displayWidth = (int)(dims.width * scale);
int displayHeight = (int)(dims.height * scale); displayHeight = (int)(dims.height * scale);
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, 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 // Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() && if (self->currentPage && !self->currentPage->elements.empty() &&

View File

@@ -3,6 +3,8 @@
#include <Logging.h> #include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
#include <cstring>
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) { if (fontData->groups != nullptr) {
if (!fontDecompressor) { if (!fontDecompressor) {
@@ -59,6 +61,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 // IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and
// efficient as possible. // efficient as possible.
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
@@ -115,8 +243,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, void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const { const EpdFontFamily::Style style) const {
const int yPos = y + getFontAscenderSize(fontId); int yPos = y + getFontAscenderSize(fontId);
int xpos = x; int xpos = x;
int lastBaseX = x;
int lastBaseY = yPos;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
// cannot draw a NULL / empty string // cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') { if (text == nullptr || *text == '\0') {
@@ -129,9 +262,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return; return;
} }
const auto& font = fontIt->second; const auto& font = fontIt->second;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp; uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 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); renderChar(font, cp, &xpos, &yPos, black, style);
} }
} }
@@ -141,15 +308,34 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
if (y2 < y1) { if (y2 < y1) {
std::swap(y1, y2); std::swap(y1, y2);
} }
for (int y = y1; y <= y2; y++) { // In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span.
drawPixel(x1, y, state); switch (orientation) {
case Portrait:
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state);
return;
case PortraitInverted:
fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state);
return;
default:
for (int y = y1; y <= y2; y++) drawPixel(x1, y, state);
return;
} }
} else if (y1 == y2) { } else if (y1 == y2) {
if (x2 < x1) { if (x2 < x1) {
std::swap(x1, x2); std::swap(x1, x2);
} }
for (int x = x1; x <= x2; x++) { // In Landscape a logical horizontal line maps to a physical horizontal span.
drawPixel(x, y1, state); switch (orientation) {
case LandscapeCounterClockwise:
fillPhysicalHSpan(y1, x1, x2, state);
return;
case LandscapeClockwise:
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2,
HalDisplay::DISPLAY_WIDTH - 1 - x1, state);
return;
default:
for (int x = x1; x <= x2; x++) drawPixel(x, y1, state);
return;
} }
} else { } else {
// Bresenham's line algorithm — integer arithmetic only // Bresenham's line algorithm — integer arithmetic only
@@ -278,9 +464,80 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
} }
} }
// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations.
// Handles partial left/right bytes and fills the aligned middle with memset.
// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel.
void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end,
const uint8_t patternByte) const {
const int cX0 = std::max(phyX_start, 0);
const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1);
if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int startByte = cX0 >> 3;
const int endByte = cX1 >> 3;
const int leftBits = cX0 & 7;
const int rightBits = cX1 & 7;
if (startByte == endByte) {
const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1));
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
return;
}
// Left partial byte
if (leftBits != 0) {
const uint8_t fillMask = 0xFF >> leftBits;
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
}
// Full bytes in the middle
const int fullStart = (leftBits == 0) ? startByte : startByte + 1;
const int fullEnd = (rightBits == 7) ? endByte : endByte - 1;
if (fullStart <= fullEnd) {
memset(row + fullStart, patternByte, fullEnd - fullStart + 1);
}
// Right partial byte
if (rightBits != 7) {
const uint8_t fillMask = ~(0xFF >> (rightBits + 1));
row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask);
}
}
// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white).
void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const {
fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF);
}
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
for (int fillY = y; fillY < y + height; fillY++) { if (width <= 0 || height <= 0) return;
drawLine(x, fillY, x + width - 1, fillY, state);
// For each orientation, one logical dimension maps to a constant physical row, allowing the
// perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead.
switch (orientation) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state);
}
return;
case PortraitInverted:
for (int lx = x; lx < x + width; lx++) {
fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y,
state);
}
return;
case LandscapeCounterClockwise:
for (int ly = y; ly < y + height; ly++) {
fillPhysicalHSpan(ly, x, x + width - 1, state);
}
return;
case LandscapeClockwise:
for (int ly = y; ly < y + height; ly++) {
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, state);
}
return;
} }
} }
@@ -317,17 +574,77 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons
fillRect(x, y, width, height, true); fillRect(x, y, width, height, true);
} else if (color == Color::White) { } else if (color == Color::White) {
fillRect(x, y, width, height, false); fillRect(x, y, width, height, false);
} else if (color == Color::LightGray) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::LightGray>(fillX, fillY);
}
}
} else if (color == Color::DarkGray) { } else if (color == Color::DarkGray) {
for (int fillY = y; fillY < y + height; fillY++) { // Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard).
for (int fillX = x; fillX < x + width; fillX++) { // Byte patterns (phyY even / phyY odd):
drawPixelDither<Color::DarkGray>(fillX, fillY); // Portrait / PortraitInverted: 0xAA / 0x55
// LandscapeCW / LandscapeCCW: 0x55 / 0xAA
switch (orientation) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
fillPhysicalHSpanByte(phyY, y, y + height - 1, pb);
} }
return;
case PortraitInverted:
for (int lx = x; lx < x + width; lx++) {
const int phyY = lx;
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
HalDisplay::DISPLAY_WIDTH - 1 - y, pb);
}
return;
case LandscapeCounterClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = ly;
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
fillPhysicalHSpanByte(phyY, x, x + width - 1, pb);
}
return;
case LandscapeClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, pb);
}
return;
}
} else if (color == Color::LightGray) {
// Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark).
// Rows that would be all-white are skipped entirely.
switch (orientation) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
if (phyY % 2 == 0) continue;
fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55);
}
return;
case PortraitInverted:
for (int lx = x; lx < x + width; lx++) {
const int phyY = lx;
if (phyY % 2 != 0) continue;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA);
}
return;
case LandscapeCounterClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = ly;
if (phyY % 2 != 0) continue;
fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55);
}
return;
case LandscapeClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
if (phyY % 2 == 0) continue;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA);
}
return;
} }
} }
} }
@@ -725,12 +1042,19 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
if (startX < 0) startX = 0; if (startX < 0) startX = 0;
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
// Draw horizontal line // In Landscape orientations, horizontal scanlines map to physical horizontal spans.
if (orientation == LandscapeCounterClockwise) {
fillPhysicalHSpan(scanY, startX, endX, state);
} else if (orientation == LandscapeClockwise) {
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - scanY, HalDisplay::DISPLAY_WIDTH - 1 - endX,
HalDisplay::DISPLAY_WIDTH - 1 - startX, state);
} else {
for (int x = startX; x <= endX; x++) { for (int x = startX; x <= endX; x++) {
drawPixel(x, scanY, state); drawPixel(x, scanY, state);
} }
} }
} }
}
free(nodeX); free(nodeX);
} }
@@ -824,7 +1148,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
return 0; 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 { int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFontFamily::Style style) const {
@@ -838,7 +1163,12 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo
int width = 0; int width = 0;
const auto& font = fontIt->second; const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 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; return width;
} }
@@ -887,68 +1217,51 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
const auto& font = fontIt->second; const auto& font = fontIt->second;
// For 90° clockwise rotation: int xPos = x;
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX) int yPos = y;
// Text reads from bottom to top int lastBaseX = x;
int lastBaseY = y;
int yPos = y; // Current Y position (decreases as we draw characters) int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp; uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 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); const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) { if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style); 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); renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, 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;
} }
} }
@@ -959,77 +1272,59 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
return; return;
} }
if (fontMap.count(fontId) == 0) { const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId);
// For 90° counter-clockwise rotation: const auto& font = fontIt->second;
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
// Text reads from top to bottom
const int advanceY = font.getData(style)->advanceY; int xPos = x;
const int ascender = font.getData(style)->ascender; int yPos = y;
int lastBaseX = x;
int yPos = y; // Current Y position (increases as we draw characters) int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp; uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) { 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); const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) { if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style); 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; renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
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;
} }
} }
@@ -1094,7 +1389,7 @@ bool GfxRenderer::storeBwBuffer() {
* Uses chunked restoration to match chunked storage. * Uses chunked restoration to match chunked storage.
*/ */
void GfxRenderer::restoreBwBuffer() { void GfxRenderer::restoreBwBuffer() {
// Check if any all chunks are allocated // Check if all chunks are allocated
bool missingChunks = false; bool missingChunks = false;
for (const auto& bwBufferChunk : bwBufferChunks) { for (const auto& bwBufferChunk : bwBufferChunks) {
if (!bwBufferChunk) { if (!bwBufferChunk) {
@@ -1109,13 +1404,6 @@ void GfxRenderer::restoreBwBuffer() {
} }
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { 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; const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
} }
@@ -1136,66 +1424,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
} }
} }
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
const bool pixelState, const EpdFontFamily::Style style) const { EpdFontFamily::Style style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, 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::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const { void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {

View File

@@ -38,14 +38,21 @@ class GfxRenderer {
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap; std::map<int, EpdFontFamily> fontMap;
FontDecompressor* fontDecompressor = nullptr; 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; EpdFontFamily::Style style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
template <Color color> template <Color color>
void drawPixelDither(int x, int y) const; void drawPixelDither(int x, int y) const;
template <Color color> template <Color color>
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
// Write a patterned horizontal span directly to the physical framebuffer using byte-level operations.
// phyY: physical row; phyX_start/phyX_end: inclusive physical column range.
// patternByte is repeated across the span; partial edge bytes are blended with existing content.
// Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel.
void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const;
// Write a solid horizontal span directly to the physical framebuffer using byte-level operations.
// Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white).
void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const;
public: public:
explicit GfxRenderer(HalDisplay& halDisplay) explicit GfxRenderer(HalDisplay& halDisplay)
@@ -136,6 +143,9 @@ class GfxRenderer {
void restoreBwBuffer(); // Restore and free the stored buffer void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const; void cleanupGrayscaleWithFrameBuffer() const;
// Font helpers
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
// Low level functions // Low level functions
uint8_t* getFrameBuffer() const; uint8_t* getFrameBuffer() const;
static size_t getBufferSize(); static size_t getBufferSize();

View File

@@ -13,6 +13,7 @@ extern const char* const STRINGS_CZ[];
extern const char* const STRINGS_PO[]; extern const char* const STRINGS_PO[];
extern const char* const STRINGS_RU[]; extern const char* const STRINGS_RU[];
extern const char* const STRINGS_SV[]; extern const char* const STRINGS_SV[];
extern const char* const STRINGS_RO[];
} // namespace i18n_strings } // namespace i18n_strings
// Language enum // Language enum
@@ -25,6 +26,7 @@ enum class Language : uint8_t {
PORTUGUESE = 5, PORTUGUESE = 5,
RUSSIAN = 6, RUSSIAN = 6,
SWEDISH = 7, SWEDISH = 7,
ROMANIAN = 8,
_COUNT _COUNT
}; };
@@ -419,6 +421,8 @@ inline const char* const* getStringArray(Language lang) {
return i18n_strings::STRINGS_RU; return i18n_strings::STRINGS_RU;
case Language::SWEDISH: case Language::SWEDISH:
return i18n_strings::STRINGS_SV; return i18n_strings::STRINGS_SV;
case Language::ROMANIAN:
return i18n_strings::STRINGS_RO;
default: default:
return i18n_strings::STRINGS_EN; return i18n_strings::STRINGS_EN;
} }

View File

@@ -15,5 +15,6 @@ extern const char* const STRINGS_CZ[];
extern const char* const STRINGS_PO[]; extern const char* const STRINGS_PO[];
extern const char* const STRINGS_RU[]; extern const char* const STRINGS_RU[];
extern const char* const STRINGS_SV[]; extern const char* const STRINGS_SV[];
extern const char* const STRINGS_RO[];
} // namespace i18n_strings } // namespace i18n_strings

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy"
STR_NO_RECENT_BOOKS: "Žádné 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_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_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_STARTING: "Spuštění Calibre..."
STR_CALIBRE_SETUP: "Nastavení" STR_CALIBRE_SETUP: "Nastavení"
STR_CALIBRE_STATUS: "Stav" STR_CALIBRE_STATUS: "Stav"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books"
STR_NO_RECENT_BOOKS: "No recent books" STR_NO_RECENT_BOOKS: "No recent books"
STR_CALIBRE_DESC: "Use Calibre wireless device transfers" STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
STR_FORGET_AND_REMOVE: "Forget network and remove saved password?" 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_STARTING: "Starting Calibre..."
STR_CALIBRE_SETUP: "Setup" STR_CALIBRE_SETUP: "Setup"
STR_CALIBRE_STATUS: "Status" STR_CALIBRE_STATUS: "Status"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents"
STR_NO_RECENT_BOOKS: "Aucun livre récent" STR_NO_RECENT_BOOKS: "Aucun livre récent"
STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre" 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_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_STARTING: "Démarrage de Calibre..."
STR_CALIBRE_SETUP: "Configuration" STR_CALIBRE_SETUP: "Configuration"
STR_CALIBRE_STATUS: "Statut" STR_CALIBRE_STATUS: "Statut"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen"
STR_NO_RECENT_BOOKS: "Keine Bücher" STR_NO_RECENT_BOOKS: "Keine Bücher"
STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)" STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)"
STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?" 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_STARTING: "Calibre starten…"
STR_CALIBRE_SETUP: "Installation" STR_CALIBRE_SETUP: "Installation"
STR_CALIBRE_STATUS: "Status" STR_CALIBRE_STATUS: "Status"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livros recentes"
STR_NO_RECENT_BOOKS: "Sem livros recentes" STR_NO_RECENT_BOOKS: "Sem livros recentes"
STR_CALIBRE_DESC: "Usar transferências sem fio Calibre" STR_CALIBRE_DESC: "Usar transferências sem fio Calibre"
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?" 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_STARTING: "Iniciando Calibre..."
STR_CALIBRE_SETUP: "Configuração" STR_CALIBRE_SETUP: "Configuração"
STR_CALIBRE_STATUS: "Status" STR_CALIBRE_STATUS: "Status"

View 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"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Недавние книги"
STR_NO_RECENT_BOOKS: "Нет недавних книг" STR_NO_RECENT_BOOKS: "Нет недавних книг"
STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre" STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre"
STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?" STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?"
STR_FORGET_BUTTON: "Забыть сеть" STR_FORGET_BUTTON: "Забыть"
STR_CALIBRE_STARTING: "Запуск Calibre..." STR_CALIBRE_STARTING: "Запуск Calibre..."
STR_CALIBRE_SETUP: "Настройка" STR_CALIBRE_SETUP: "Настройка"
STR_CALIBRE_STATUS: "Статус" STR_CALIBRE_STATUS: "Статус"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes"
STR_NO_RECENT_BOOKS: "No hay libros recientes" STR_NO_RECENT_BOOKS: "No hay libros recientes"
STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre" 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_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_STARTING: "Iniciando calibre..."
STR_CALIBRE_SETUP: "Configuración" STR_CALIBRE_SETUP: "Configuración"
STR_CALIBRE_STATUS: "Estado" STR_CALIBRE_STATUS: "Estado"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna"
STR_NO_RECENT_BOOKS: "Inga senaste böcker" STR_NO_RECENT_BOOKS: "Inga senaste böcker"
STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring" 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_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_STARTING: "Starar Calibre…"
STR_CALIBRE_SETUP: "Inställning" STR_CALIBRE_SETUP: "Inställning"
STR_CALIBRE_STATUS: "Status" STR_CALIBRE_STATUS: "Status"

View File

@@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string);
size_t utf8RemoveLastChar(std::string& str); size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end. // Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars); 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
}

View File

@@ -78,7 +78,7 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
esp_deep_sleep_start(); esp_deep_sleep_start();
} }
int HalPowerManager::getBatteryPercentage() const { uint16_t HalPowerManager::getBatteryPercentage() const {
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage(); return battery.readPercentage();
} }

View File

@@ -28,7 +28,7 @@ class HalPowerManager {
void startDeepSleep(HalGPIO& gpio) const; void startDeepSleep(HalGPIO& gpio) const;
// Get battery percentage (range 0-100) // 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) // RAII lock to prevent low-power mode during critical work (e.g. rendering)
class Lock { class Lock {

View File

@@ -2,7 +2,7 @@
default_envs = default default_envs = default
[crosspoint] [crosspoint]
version = 1.0.0 version = 1.1.1-rc
[base] [base]
platform = espressif32 @ 6.12.0 platform = espressif32 @ 6.12.0
@@ -31,9 +31,9 @@ build_flags =
-std=gnu++2a -std=gnu++2a
# Enable UTF-8 long file names in SdFat # Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1 -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 # 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 = build_unflags =
-std=gnu++11 -std=gnu++11

View File

@@ -1,6 +0,0 @@
#pragma once
#include <BatteryMonitor.h>
#define BAT_GPIO0 0 // Battery voltage
static BatteryMonitor battery(BAT_GPIO0);

View File

@@ -21,6 +21,11 @@
#include "util/BookSettings.h" #include "util/BookSettings.h"
#include "util/StringUtils.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 { namespace {
// Number of source pixels along the image edge to average for the dominant color // 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 --- // --- Edge average cache ---
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep. // Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
constexpr uint8_t EDGE_CACHE_VERSION = 2; 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); 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 levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 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) { if (data.letterboxA > 0) {
for (int y = 0; y < data.letterboxA; y++) for (int y = 0; y < data.letterboxA; y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) { for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv; const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -312,13 +267,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenHeight() - data.letterboxB; const int start = renderer.getScreenHeight() - data.letterboxB;
for (int y = start; y < renderer.getScreenHeight(); y++) for (int y = start; y < renderer.getScreenHeight(); y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) { for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv; const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -326,13 +275,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
if (data.letterboxA > 0) { if (data.letterboxA > 0) {
for (int x = 0; x < data.letterboxA; x++) for (int x = 0; x < data.letterboxA; x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) { for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv; const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -340,13 +283,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenWidth() - data.letterboxB; const int start = renderer.getScreenWidth() - data.letterboxB;
for (int x = start; x < renderer.getScreenWidth(); x++) for (int x = start; x < renderer.getScreenWidth(); x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) { for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv; const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv); renderer.drawPixelGray(x, y, lv);
} }
} }
@@ -543,35 +480,47 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const bool hasGreyscale = bitmap.hasGreyscale() && const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass) const bool isInverted =
if (fillData.valid) { SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
drawLetterboxFill(renderer, fillData, fillMode);
}
#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); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { renderer.displayBuffer(HalDisplay::FAST_REFRESH);
renderer.invertScreen(); } 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); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
if (hasGreyscale) { if (hasGreyscale) {
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (fillData.valid) { if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers(); renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData(); bitmap.rewindToData();
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (fillData.valid) { if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
drawLetterboxFill(renderer, fillData, fillMode);
}
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers(); renderer.copyGrayscaleMsbBuffers();

View File

@@ -13,7 +13,6 @@
#include <cstring> #include <cstring>
#include <vector> #include <vector>
#include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"

View File

@@ -196,6 +196,15 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos); 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&&) { void MyLibraryActivity::render(Activity::RenderLock&&) {
renderer.clearScreen(); renderer.clearScreen();
@@ -214,7 +223,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
GUI.drawList( GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return getFileName(files[index]); }, nullptr, [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 // Help text

View File

@@ -22,11 +22,6 @@
#include "util/BookmarkStore.h" #include "util/BookmarkStore.h"
#include "util/Dictionary.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 { namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700; 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, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) { const int orientedMarginLeft) {
// Determine if this page needs special image handling // Force special handling for pages with images when anti-aliasing is on
bool pageHasImages = page->hasImages(); bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
bool useAntiAliasing = SETTINGS.textAntiAliasing;
// Force half refresh for pages with images when anti-aliasing is on, if (page->countUncachedImages() > 0) {
// as grayscale tones require half refresh to display correctly page->renderTextOnly(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
bool forceFullRefresh = pageHasImages && useAntiAliasing; page->renderImagePlaceholders(renderer, orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
renderer.clearScreen();
}
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); 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); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (imagePageWithAA) {
// Check if half-refresh is needed (either entering Reader or pages counter reached) // Double FAST_REFRESH with selective image blanking (pablohc's technique):
if (pagesUntilFullRefresh <= 1) { // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
renderer.displayBuffer(HalDisplay::HALF_REFRESH); // Instead, blank only the image area and do two fast refreshes.
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).
int imgX, imgY, imgW, imgH; int imgX, imgY, imgW, imgH;
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
int screenX = imgX + orientedMarginLeft; renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);
int screenY = imgY + orientedMarginTop; renderer.displayBuffer(HalDisplay::FAST_REFRESH);
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);
#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); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(HalDisplay::FAST_REFRESH); 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 { } else {
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
renderer.displayBuffer(HalDisplay::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 { } else {
// Normal page without images, or images without anti-aliasing
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
} }

View File

@@ -1,6 +1,7 @@
#include "BaseTheme.h" #include "BaseTheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h> #include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
@@ -9,7 +10,6 @@
#include <ctime> #include <ctime>
#include <string> #include <string>
#include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "I18n.h" #include "I18n.h"
#include "RecentBooksStore.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); 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 } // namespace
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode) // 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 y = rect.y + 6;
if (showPercentage) { 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 { void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers) // Right aligned: percentage on left, icon on right (UI headers)
// rect.x is already positioned for the icon (drawHeader calculated it) // 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; const int y = rect.y + 6;
if (showPercentage) { if (showPercentage) {
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
int rowHeight = int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; (rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight; 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) { if (totalPages > 1) {
constexpr int indicatorWidth = 20; constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6; 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 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; 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) { for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2; const int lineWidth = 1 + i * 2;
const int startX = centerX - i; const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + 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) { for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2; const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i); const int startX = centerX - (arrowSize - 1 - i);
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
} }
} }
// Draw selection // Compute page start: use effective page items but prevent backward leak
int contentWidth = rect.width - 5; int pageStartIndex;
if (selectedIndex >= 0) { if (selectedExpands) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight); 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 // Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems; int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight; const bool isExpanded = (selectedExpands && i == selectedIndex);
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
// Draw name
auto itemName = rowTitle(i); 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) { if (isExpanded) {
// Draw subtitle int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
std::string subtitleText = rowSubtitle(i); auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
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(), for (size_t l = 0; l < lines.size(); ++l) {
i != selectedIndex); renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
} }
if (rowValue != nullptr) { if (rowValue != nullptr) {
// Draw value
std::string valueText = rowValue(i); std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 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, renderer.drawText(UI_10_FONT_ID,
itemY, valueText.c_str(), i != selectedIndex); 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, void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { 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 hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0; 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) // Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8; const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5; const int bookmarkHeight = bookHeight / 5;
@@ -394,29 +594,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("THEME", "Rendering bmp"); LOG_DBG("THEME", "Rendering bmp");
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, 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);
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); renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art // 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 boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + 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; const int boxY = titleYStart - boxPadding;
// Draw box (inverted when selected: black box instead of white) // 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; constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2; const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; 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; const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);

View File

@@ -82,7 +82,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.tabBarHeight = 50, .tabBarHeight = 50,
.scrollBarWidth = 4, .scrollBarWidth = 4,
.scrollBarRightOffset = 5, .scrollBarRightOffset = 5,
.homeTopPadding = 20, .homeTopPadding = 40,
.homeCoverHeight = 400, .homeCoverHeight = 400,
.homeCoverTileHeight = 400, .homeCoverTileHeight = 400,
.homeRecentBooksCount = 1, .homeRecentBooksCount = 1,

View File

@@ -1,6 +1,7 @@
#include "LyraTheme.h" #include "LyraTheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <I18n.h> #include <I18n.h>
#include <Utf8.h> #include <Utf8.h>
@@ -10,7 +11,6 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -84,11 +84,100 @@ const uint8_t* iconForName(UIIcon icon, int size) {
} }
return nullptr; 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 } // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode) // 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 y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth; 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 { void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers) // 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 y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth; 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; (rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight; 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) { if (totalPages > 1) {
const int scrollAreaHeight = rect.height; const int scrollAreaHeight = rect.height;
// Draw scroll bar const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount; const int currentPage = selectedIndex / effectivePageItems;
const int currentPage = selectedIndex / pageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1); const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset; const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true); 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); scrollBarHeight, true);
} }
// Draw selection
int contentWidth = int contentWidth =
rect.width - rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1); (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) { if (selectedIndex >= 0) {
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, 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); Color::LightGray);
} }
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection; int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2; int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize; int iconSize = listIconSize;
if (rowIcon != nullptr) { if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize; iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection; textX += iconSize + hPaddingInSelection;
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
} }
// Draw all items // Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
int iconY = (rowSubtitle != nullptr) ? 16 : 10; int iconY = (rowSubtitle != nullptr) ? 16 : 10;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { int yPos = rect.y;
const int itemY = rect.y + (i % pageItems) * rowHeight; for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
int rowTextWidth = textWidth; const bool isExpanded = (selectedExpands && i == selectedIndex);
// Draw name
int valueWidth = 0; int valueWidth = 0;
std::string valueText = ""; std::string valueText;
if (rowValue != nullptr) { if (rowValue != nullptr) {
valueText = rowValue(i); valueText = rowValue(i);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth); 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; valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
rowTextWidth -= valueWidth; }
} }
auto itemName = rowTitle(i); 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) { if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i); UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize); const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) { if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection, 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) { if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i); std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth); 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 (!valueText.empty()) {
if (i == selectedIndex && highlightValue) { if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect( renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY, contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black); valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
} }
renderer.drawText(UI_10_FONT_ID,
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue)); valueText.c_str(), !(i == selectedIndex && highlightValue));
}
yPos += rowHeight;
} }
} }
} }

View File

@@ -15,7 +15,6 @@
#include <cstring> #include <cstring>
#include <ctime> #include <ctime>
#include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
@@ -211,6 +210,7 @@ void waitForPowerRelease() {
// Enter deep sleep mode // Enter deep sleep mode
void enterDeepSleep() { void enterDeepSleep() {
powerManager.setPowerSaving(false);
APP_STATE.lastSleepFromReader = currentActivity && currentActivity->isReaderActivity(); APP_STATE.lastSleepFromReader = currentActivity && currentActivity->isReaderActivity();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
exitActivity(); exitActivity();
@@ -227,9 +227,9 @@ void onGoHome();
void onGoToMyLibraryWithPath(const std::string& path); void onGoToMyLibraryWithPath(const std::string& path);
void onGoToRecentBooks(); void onGoToRecentBooks();
void onGoToReader(const std::string& initialEpubPath) { void onGoToReader(const std::string& initialEpubPath) {
const std::string bookPath = initialEpubPath; // Copy before exitActivity() invalidates the reference
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome, onGoToMyLibraryWithPath));
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath));
} }
void onGoToFileTransfer() { void onGoToFileTransfer() {