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;
const int cursorY = startY;
int lastBaseX = startX;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const EpdGlyph* glyph = getGlyph(cp);
@@ -30,12 +35,31 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
*minX = std::min(*minX, cursorX + glyph->left);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
*maxY = std::max(*maxY, cursorY + glyph->top);
const bool isCombining = utf8IsCombiningMark(cp);
int raiseBy = 0;
if (isCombining && hasBaseGlyph) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseTop = glyph->top;
hasBaseGlyph = true;
cursorX += glyph->advanceX;
}
}
}
void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {

View File

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

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));
}
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
// ---------------------------------------------------------------------------

View File

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

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <functional>
#include <limits>
#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);
}
// ---------------------------------------------------------------------------
// 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
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) {
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) {
@@ -107,7 +191,7 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
wordWidths.reserve(words.size());
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;
@@ -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)
std::vector<size_t> lineBreakIndices;
lineBreakIndices.reserve(totalWordCount / 8 + 1);
size_t currentWordIndex = 0;
while (currentWordIndex < totalWordCount) {
@@ -359,6 +444,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
bool chosenNeedsHyphen = true;
// 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) {
const size_t offset = info.byteOffset;
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 int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen);
if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) {
continue; // Skip if too wide or not an improvement
prefix.assign(word, 0, offset);
const int prefixWidth = measureWordWidth(renderer, fontId, prefix, style, needsHyphen);
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;
@@ -392,11 +486,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The remainder inherits whatever continuation status the original word had with the word after it.
const bool originalContinuedToNext = wordContinues[wordIndex];
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Preserve the prefix's attach-to-previous flag; allow a break between prefix and remainder.
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
if (!forceBreakAfter.empty()) {

View File

@@ -93,6 +93,11 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
} // namespace
bool ImageBlock::isCached() const {
std::string cachePath = getCachePath(imagePath);
return Storage.exists(cachePath.c_str());
}
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);

View File

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

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 MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
// and each scanline includes a leading filter byte.
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
// PNGdec can overrun its internal buffer before our draw callback executes.
int bytesPerPixelFromType(int pixelType) {
switch (pixelType) {
case PNG_PIXEL_TRUECOLOR:
return 3;
case PNG_PIXEL_GRAY_ALPHA:
return 2;
case PNG_PIXEL_TRUECOLOR_ALPHA:
return 4;
case PNG_PIXEL_GRAYSCALE:
case PNG_PIXEL_INDEXED:
default:
return 1;
}
}
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
return ((pitch + 1) * 2) + 32;
}
// Convert entire source line to grayscale with alpha blending to white background.
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
@@ -304,6 +330,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
ctx.scale, png->getBpp());
const int pixelType = png->getPixelType();
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
LOG_ERR("PNG",
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
png->close();
delete png;
return false;
}
if (png->getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
}

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ struct CssPropertyFlags {
uint16_t paddingLeft : 1;
uint16_t paddingRight : 1;
uint16_t width : 1;
uint16_t imageHeight : 1;
CssPropertyFlags()
: textAlign(0),
@@ -85,18 +86,20 @@ struct CssPropertyFlags {
paddingBottom(0),
paddingLeft(0),
paddingRight(0),
width(0) {}
width(0),
imageHeight(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width;
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || width ||
imageHeight;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
width = 0;
width = imageHeight = 0;
}
};
@@ -118,7 +121,8 @@ struct CssStyle {
CssLength paddingBottom; // Padding after
CssLength paddingLeft; // Padding left
CssLength paddingRight; // Padding right
CssLength width; // Element width (used for table columns/cells)
CssLength width; // Element width (used for table columns/cells and image sizing)
CssLength imageHeight; // Height for img (e.g. 2em) -- width derived from aspect ratio when only height set
CssPropertyFlags defined; // Tracks which properties were explicitly set
@@ -181,6 +185,10 @@ struct CssStyle {
width = base.width;
defined.width = 1;
}
if (base.hasImageHeight()) {
imageHeight = base.imageHeight;
defined.imageHeight = 1;
}
}
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
@@ -197,6 +205,7 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasWidth() const { return defined.width; }
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
void reset() {
textAlign = CssTextAlign::Left;
@@ -207,6 +216,7 @@ struct CssStyle {
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
width = CssLength{};
imageHeight = CssLength{};
defined.clearAll();
}
};

View File

@@ -174,6 +174,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
while (*ptr != 0) {
const unsigned char* current = ptr;
const uint32_t cp = utf8NextCodepoint(&ptr);
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
// a previous base character that can be composed into a single
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
// composition here. This provides lightweight NFC-like behavior for
// common Western European diacritics (acute, grave, circumflex, tilde,
// diaeresis, cedilla) without pulling in a full Unicode normalization
// library.
if (!cps.empty()) {
uint32_t prev = cps.back().value;
uint32_t composed = 0;
switch (cp) {
case 0x0300: // grave
switch (prev) {
case 0x0041:
composed = 0x00C0;
break; // A -> À
case 0x0061:
composed = 0x00E0;
break; // a -> à
case 0x0045:
composed = 0x00C8;
break; // E -> È
case 0x0065:
composed = 0x00E8;
break; // e -> è
case 0x0049:
composed = 0x00CC;
break; // I -> Ì
case 0x0069:
composed = 0x00EC;
break; // i -> ì
case 0x004F:
composed = 0x00D2;
break; // O -> Ò
case 0x006F:
composed = 0x00F2;
break; // o -> ò
case 0x0055:
composed = 0x00D9;
break; // U -> Ù
case 0x0075:
composed = 0x00F9;
break; // u -> ù
default:
break;
}
break;
case 0x0301: // acute
switch (prev) {
case 0x0041:
composed = 0x00C1;
break; // A -> Á
case 0x0061:
composed = 0x00E1;
break; // a -> á
case 0x0045:
composed = 0x00C9;
break; // E -> É
case 0x0065:
composed = 0x00E9;
break; // e -> é
case 0x0049:
composed = 0x00CD;
break; // I -> Í
case 0x0069:
composed = 0x00ED;
break; // i -> í
case 0x004F:
composed = 0x00D3;
break; // O -> Ó
case 0x006F:
composed = 0x00F3;
break; // o -> ó
case 0x0055:
composed = 0x00DA;
break; // U -> Ú
case 0x0075:
composed = 0x00FA;
break; // u -> ú
case 0x0059:
composed = 0x00DD;
break; // Y -> Ý
case 0x0079:
composed = 0x00FD;
break; // y -> ý
default:
break;
}
break;
case 0x0302: // circumflex
switch (prev) {
case 0x0041:
composed = 0x00C2;
break; // A -> Â
case 0x0061:
composed = 0x00E2;
break; // a -> â
case 0x0045:
composed = 0x00CA;
break; // E -> Ê
case 0x0065:
composed = 0x00EA;
break; // e -> ê
case 0x0049:
composed = 0x00CE;
break; // I -> Î
case 0x0069:
composed = 0x00EE;
break; // i -> î
case 0x004F:
composed = 0x00D4;
break; // O -> Ô
case 0x006F:
composed = 0x00F4;
break; // o -> ô
case 0x0055:
composed = 0x00DB;
break; // U -> Û
case 0x0075:
composed = 0x00FB;
break; // u -> û
default:
break;
}
break;
case 0x0303: // tilde
switch (prev) {
case 0x0041:
composed = 0x00C3;
break; // A -> Ã
case 0x0061:
composed = 0x00E3;
break; // a -> ã
case 0x004E:
composed = 0x00D1;
break; // N -> Ñ
case 0x006E:
composed = 0x00F1;
break; // n -> ñ
default:
break;
}
break;
case 0x0308: // diaeresis/umlaut
switch (prev) {
case 0x0041:
composed = 0x00C4;
break; // A -> Ä
case 0x0061:
composed = 0x00E4;
break; // a -> ä
case 0x0045:
composed = 0x00CB;
break; // E -> Ë
case 0x0065:
composed = 0x00EB;
break; // e -> ë
case 0x0049:
composed = 0x00CF;
break; // I -> Ï
case 0x0069:
composed = 0x00EF;
break; // i -> ï
case 0x004F:
composed = 0x00D6;
break; // O -> Ö
case 0x006F:
composed = 0x00F6;
break; // o -> ö
case 0x0055:
composed = 0x00DC;
break; // U -> Ü
case 0x0075:
composed = 0x00FC;
break; // u -> ü
case 0x0059:
composed = 0x0178;
break; // Y -> Ÿ
case 0x0079:
composed = 0x00FF;
break; // y -> ÿ
default:
break;
}
break;
case 0x0327: // cedilla
switch (prev) {
case 0x0043:
composed = 0x00C7;
break; // C -> Ç
case 0x0063:
composed = 0x00E7;
break; // c -> ç
default:
break;
}
break;
default:
break;
}
if (composed != 0) {
cps.back().value = composed;
continue; // skip pushing the combining mark itself
}
}
cps.push_back({cp, static_cast<size_t>(current - base)});
}

View File

@@ -418,7 +418,74 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (decoder->getDimensions(cachedImagePath, dims)) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
// Scale to fit viewport while maintaining aspect ratio
int displayWidth = 0;
int displayHeight = 0;
const float emSize =
static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
if (!styleAttr.empty()) {
imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
}
const bool hasCssHeight = imgStyle.hasImageHeight();
const bool hasCssWidth = imgStyle.hasWidth();
if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) {
displayHeight = static_cast<int>(
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
displayWidth = static_cast<int>(
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
if (displayHeight < 1) displayHeight = 1;
if (displayWidth < 1) displayWidth = 1;
if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) {
float scaleX = (displayWidth > self->viewportWidth)
? static_cast<float>(self->viewportWidth) / displayWidth
: 1.0f;
float scaleY = (displayHeight > self->viewportHeight)
? static_cast<float>(self->viewportHeight) / displayHeight
: 1.0f;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
displayWidth = static_cast<int>(displayWidth * scale + 0.5f);
displayHeight = static_cast<int>(displayHeight * scale + 0.5f);
if (displayWidth < 1) displayWidth = 1;
if (displayHeight < 1) displayHeight = 1;
}
LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight);
} else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) {
displayHeight = static_cast<int>(
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
if (displayHeight < 1) displayHeight = 1;
displayWidth =
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
if (displayHeight > self->viewportHeight) {
displayHeight = self->viewportHeight;
displayWidth =
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
if (displayWidth < 1) displayWidth = 1;
}
if (displayWidth > self->viewportWidth) {
displayWidth = self->viewportWidth;
displayHeight =
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
if (displayHeight < 1) displayHeight = 1;
}
if (displayWidth < 1) displayWidth = 1;
LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight);
} else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) {
displayWidth = static_cast<int>(
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth;
if (displayWidth < 1) displayWidth = 1;
displayHeight =
static_cast<int>(displayWidth * (static_cast<float>(dims.height) / dims.width) + 0.5f);
if (displayHeight > self->viewportHeight) {
displayHeight = self->viewportHeight;
displayWidth =
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
if (displayWidth < 1) displayWidth = 1;
}
if (displayHeight < 1) displayHeight = 1;
LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight);
} else {
int maxWidth = self->viewportWidth;
int maxHeight = self->viewportHeight;
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
@@ -426,10 +493,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
float scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
int displayWidth = (int)(dims.width * scale);
int displayHeight = (int)(dims.height * scale);
displayWidth = (int)(dims.width * scale);
displayHeight = (int)(dims.height * scale);
LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale);
}
// Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() &&

View File

@@ -3,6 +3,8 @@
#include <Logging.h>
#include <Utf8.h>
#include <cstring>
const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const {
if (fontData->groups != nullptr) {
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
// efficient as possible.
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,
const EpdFontFamily::Style style) const {
const int yPos = y + getFontAscenderSize(fontId);
int yPos = y + getFontAscenderSize(fontId);
int xpos = x;
int lastBaseX = x;
int lastBaseY = yPos;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
// cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
@@ -129,9 +262,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
const auto& font = fontIt->second;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX + lastBaseAdvance / 2;
int combiningY = lastBaseY - raiseBy;
renderChar(font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xpos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
renderChar(font, cp, &xpos, &yPos, black, style);
}
}
@@ -141,15 +308,34 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
if (y2 < y1) {
std::swap(y1, y2);
}
for (int y = y1; y <= y2; y++) {
drawPixel(x1, y, state);
// In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span.
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) {
if (x2 < x1) {
std::swap(x1, x2);
}
for (int x = x1; x <= x2; x++) {
drawPixel(x, y1, state);
// In Landscape a logical horizontal line maps to a physical horizontal span.
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 {
// 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 {
for (int fillY = y; fillY < y + height; fillY++) {
drawLine(x, fillY, x + width - 1, fillY, state);
if (width <= 0 || height <= 0) return;
// 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);
} else if (color == Color::White) {
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) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::DarkGray>(fillX, fillY);
// Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard).
// Byte patterns (phyY even / phyY odd):
// 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 (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++) {
drawPixel(x, scanY, state);
}
}
}
}
free(nodeX);
}
@@ -824,7 +1148,8 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
return 0;
}
return fontIt->second.getGlyph(' ', style)->advanceX;
const EpdGlyph* spaceGlyph = fontIt->second.getGlyph(' ', style);
return spaceGlyph ? spaceGlyph->advanceX : 0;
}
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFontFamily::Style style) const {
@@ -838,7 +1163,12 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo
int width = 0;
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
width += font.getGlyph(cp, style)->advanceX;
if (utf8IsCombiningMark(cp)) {
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
if (glyph) width += glyph->advanceX;
}
return width;
}
@@ -887,68 +1217,51 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
const auto& font = fontIt->second;
// For 90° clockwise rotation:
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
// Text reads from bottom to top
int yPos = y; // Current Y position (decreases as we draw characters)
int xPos = x;
int yPos = y;
int lastBaseX = x;
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX - raiseBy;
int combiningY = lastBaseY - lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!glyph) {
continue;
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
const EpdFontData* fontData = font.getData(style);
const int is2Bit = fontData->is2Bit;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° clockwise rotation transformation:
// screenX = x + (ascender - top + glyphY)
// screenY = yPos - (left + glyphX)
const int screenX = x + (fontData->ascender - top + glyphY);
const int screenY = yPos - left - glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going up, so decrease Y)
yPos -= glyph->advanceX;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
}
}
@@ -959,77 +1272,59 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
return;
}
if (fontMap.count(fontId) == 0) {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
}
const auto font = fontMap.at(fontId);
// For 90° counter-clockwise rotation:
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
// Text reads from top to bottom
const auto& font = fontIt->second;
const int advanceY = font.getData(style)->advanceY;
const int ascender = font.getData(style)->ascender;
int yPos = y; // Current Y position (increases as we draw characters)
int xPos = x;
int yPos = y;
int lastBaseX = x;
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX + raiseBy;
int combiningY = lastBaseY + lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!glyph) {
continue;
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° counter-clockwise rotation transformation:
// screenX = mirrored CW X (right-to-left within advanceY span)
// screenY = yPos + (left + glyphX) (downward)
const int screenX = x + advanceY - 1 - (ascender - top + glyphY);
const int screenY = yPos + left + glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going down, so increase Y)
yPos += glyph->advanceX;
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
}
}
@@ -1094,7 +1389,7 @@ bool GfxRenderer::storeBwBuffer() {
* Uses chunked restoration to match chunked storage.
*/
void GfxRenderer::restoreBwBuffer() {
// Check if any all chunks are allocated
// Check if all chunks are allocated
bool missingChunks = false;
for (const auto& bwBufferChunk : bwBufferChunks) {
if (!bwBufferChunk) {
@@ -1109,13 +1404,6 @@ void GfxRenderer::restoreBwBuffer() {
}
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing
if (!bwBufferChunks[i]) {
LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug");
freeBwBufferChunks();
return;
}
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
}
@@ -1136,66 +1424,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
}
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
const bool pixelState, const EpdFontFamily::Style style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) {
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
}
// no glyph?
if (!glyph) {
LOG_ERR("GFX", "No glyph for codepoint %d", cp);
return;
}
const EpdFontData* fontData = fontFamily.getData(style);
const int is2Bit = fontData->is2Bit;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
const int screenY = *y - glyph->top + glyphY;
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
const int screenX = *x + left + glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
// we swap this to better match the way images and screen think about colors:
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
// Black (also paints over the grays in BW mode)
drawPixel(screenX, screenY, pixelState);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
// Light gray (also mark the MSB if it's going to be a dark gray too)
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
// Dark gray
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, pixelState);
}
}
}
}
}
*x += glyph->advanceX;
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const {
renderCharImpl<TextRotation::None>(*this, renderMode, fontFamily, cp, x, y, pixelState, style);
}
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {

View File

@@ -38,14 +38,21 @@ class GfxRenderer {
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap;
FontDecompressor* fontDecompressor = nullptr;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const;
void freeBwBufferChunks();
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
template <Color color>
void drawPixelDither(int x, int y) const;
template <Color color>
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:
explicit GfxRenderer(HalDisplay& halDisplay)
@@ -136,6 +143,9 @@ class GfxRenderer {
void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const;
// Font helpers
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
// Low level functions
uint8_t* getFrameBuffer() const;
static size_t getBufferSize();

View File

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

View File

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

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy"
STR_NO_RECENT_BOOKS: "Žádné nedávné knihy"
STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre"
STR_FORGET_AND_REMOVE: "Zapomenout síť a odstranit uložené heslo?"
STR_FORGET_BUTTON: "Zapomenout na síť"
STR_FORGET_BUTTON: "Zapomenout"
STR_CALIBRE_STARTING: "Spuštění Calibre..."
STR_CALIBRE_SETUP: "Nastavení"
STR_CALIBRE_STATUS: "Stav"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books"
STR_NO_RECENT_BOOKS: "No recent books"
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
STR_FORGET_AND_REMOVE: "Forget network and remove saved password?"
STR_FORGET_BUTTON: "Forget network"
STR_FORGET_BUTTON: "Forget"
STR_CALIBRE_STARTING: "Starting Calibre..."
STR_CALIBRE_SETUP: "Setup"
STR_CALIBRE_STATUS: "Status"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents"
STR_NO_RECENT_BOOKS: "Aucun livre récent"
STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre"
STR_FORGET_AND_REMOVE: "Oublier le réseau et supprimer le mot de passe enregistré ?"
STR_FORGET_BUTTON: "Oublier le réseau"
STR_FORGET_BUTTON: "Oublier"
STR_CALIBRE_STARTING: "Démarrage de Calibre..."
STR_CALIBRE_SETUP: "Configuration"
STR_CALIBRE_STATUS: "Statut"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen"
STR_NO_RECENT_BOOKS: "Keine Bücher"
STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)"
STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?"
STR_FORGET_BUTTON: "WLAN entfernen"
STR_FORGET_BUTTON: "Entfernen"
STR_CALIBRE_STARTING: "Calibre starten…"
STR_CALIBRE_SETUP: "Installation"
STR_CALIBRE_STATUS: "Status"

View File

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

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_CALIBRE_DESC: "Использовать беспроводную передачу Calibre"
STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?"
STR_FORGET_BUTTON: "Забыть сеть"
STR_FORGET_BUTTON: "Забыть"
STR_CALIBRE_STARTING: "Запуск Calibre..."
STR_CALIBRE_SETUP: "Настройка"
STR_CALIBRE_STATUS: "Статус"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes"
STR_NO_RECENT_BOOKS: "No hay libros recientes"
STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre"
STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?"
STR_FORGET_BUTTON: "Olvidar la red"
STR_FORGET_BUTTON: "Olvidar"
STR_CALIBRE_STARTING: "Iniciando calibre..."
STR_CALIBRE_SETUP: "Configuración"
STR_CALIBRE_STATUS: "Estado"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna"
STR_NO_RECENT_BOOKS: "Inga senaste böcker"
STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring"
STR_FORGET_AND_REMOVE: "Glöm nätverk och ta bort sparat lösenord?"
STR_FORGET_BUTTON: "Glöm nätverk"
STR_FORGET_BUTTON: "Glöm"
STR_CALIBRE_STARTING: "Starar Calibre…"
STR_CALIBRE_SETUP: "Inställning"
STR_CALIBRE_STATUS: "Status"

View File

@@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string);
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
inline bool utf8IsCombiningMark(const uint32_t cp) {
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|| (cp >= 0x1DC0 && cp <= 0x1DFF) // Combining Diacritical Marks Supplement
|| (cp >= 0x20D0 && cp <= 0x20FF) // Combining Diacritical Marks for Symbols
|| (cp >= 0xFE20 && cp <= 0xFE2F); // Combining Half Marks
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
default_envs = default
[crosspoint]
version = 1.0.0
version = 1.1.1-rc
[base]
platform = espressif32 @ 6.12.0
@@ -31,9 +31,9 @@ build_flags =
-std=gnu++2a
# Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1
# Increase PNG scanline buffer to support up to 800px wide images
# Increase PNG scanline buffer to support up to 2048px wide images
# Default is (320*4+1)*2=2562, we need more for larger images
-DPNG_MAX_BUFFERED_PIXELS=6402
-DPNG_MAX_BUFFERED_PIXELS=16416
build_unflags =
-std=gnu++11

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/StringUtils.h"
// Sleep cover refresh strategy when dithered letterbox fill is active:
// 1 = Double FAST_REFRESH (clear to white, then render content -- avoids HALF_REFRESH crosstalk)
// 0 = Standard HALF_REFRESH (original behavior)
#define USE_SLEEP_DOUBLE_FAST_REFRESH 1
namespace {
// Number of source pixels along the image edge to average for the dominant color
@@ -74,37 +79,6 @@ uint8_t quantizeBayerDither(int gray, int x, int y) {
}
}
// Check whether a gray value would produce a dithered mix that crosses the
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
// creating a high-frequency checkerboard that causes e-ink display crosstalk
// and washes out adjacent content during HALF_REFRESH.
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
bool bayerCrossesBwBoundary(uint8_t gray) { return gray > 170 && gray < 255; }
// Hash-based block dithering for BW-boundary gray values (171-254).
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
// determined by a deterministic spatial hash. The proportion of level-3 blocks
// approximates the target gray. Unlike Bayer, the pattern is irregular
// (noise-like), making it much less visually obvious at the same block size.
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
// identical levels across BW, LSB, and MSB render passes.
static constexpr int BW_DITHER_BLOCK = 2;
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
const int bx = x / BW_DITHER_BLOCK;
const int by = y / BW_DITHER_BLOCK;
// Fast mixing hash (splitmix32-inspired)
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
h ^= h >> 16;
h *= 0x45d9f3bu;
h ^= h >> 16;
// Proportion of level-3 blocks needed to approximate the target gray
const float ratio = (avg - 170.0f) / 85.0f;
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
return (h < threshold) ? 3 : 2;
}
// --- Edge average cache ---
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
constexpr uint8_t EDGE_CACHE_VERSION = 2;
@@ -278,19 +252,6 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
//
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
// crosstalk, and the irregular hash pattern is much less visible than a regular
// Bayer grid at the same block size.
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
// For solid mode: snap to nearest e-ink level
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
@@ -298,13 +259,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
if (data.letterboxA > 0) {
for (int y = 0; y < data.letterboxA; y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv;
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
@@ -312,13 +267,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenHeight() - data.letterboxB;
for (int y = start; y < renderer.getScreenHeight(); y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
uint8_t lv;
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
@@ -326,13 +275,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
if (data.letterboxA > 0) {
for (int x = 0; x < data.letterboxA; x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv;
if (isSolid)
lv = levelA;
else if (hashA)
lv = hashBlockDither(data.avgA, x, y);
else
lv = quantizeBayerDither(data.avgA, x, y);
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
@@ -340,13 +283,7 @@ void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uin
const int start = renderer.getScreenWidth() - data.letterboxB;
for (int x = start; x < renderer.getScreenWidth(); x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
uint8_t lv;
if (isSolid)
lv = levelB;
else if (hashB)
lv = hashBlockDither(data.avgB, x, y);
else
lv = quantizeBayerDither(data.avgB, x, y);
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
@@ -543,35 +480,47 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
// Draw letterbox fill (BW pass)
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
const bool isInverted =
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
#if USE_SLEEP_DOUBLE_FAST_REFRESH
const bool useDoubleFast =
fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED;
#else
const bool useDoubleFast = false;
#endif
if (useDoubleFast) {
// Double FAST_REFRESH technique: avoids HALF_REFRESH crosstalk with dithered letterbox.
// Pass 1: clear to white baseline
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Pass 2: render actual content and display
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
renderer.invertScreen();
}
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
// Standard path: single HALF_REFRESH
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
if (hasGreyscale) {
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (fillData.valid) {
drawLetterboxFill(renderer, fillData, fillMode);
}
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();

View File

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

View File

@@ -196,6 +196,15 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos);
}
std::string getFileExtension(std::string filename) {
if (filename.back() == '/') {
return "";
}
const auto pos = filename.rfind('.');
if (pos == std::string::npos) return "";
return filename.substr(pos);
}
void MyLibraryActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
@@ -214,7 +223,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return getFileName(files[index]); }, nullptr,
[this](int index) { return UITheme::getFileIcon(files[index]); });
[this](int index) { return UITheme::getFileIcon(files[index]); },
[this](int index) { return getFileExtension(files[index]); }, false);
}
// Help text

View File

@@ -22,11 +22,6 @@
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
// Image refresh optimization strategy:
// 0 = Use double FAST_REFRESH technique (default, feels snappier)
// 1 = Use displayWindow() for partial refresh (experimental)
#define USE_IMAGE_DOUBLE_FAST_REFRESH 0
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
@@ -1022,13 +1017,16 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
// Determine if this page needs special image handling
bool pageHasImages = page->hasImages();
bool useAntiAliasing = SETTINGS.textAntiAliasing;
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
// Force half refresh for pages with images when anti-aliasing is on,
// as grayscale tones require half refresh to display correctly
bool forceFullRefresh = pageHasImages && useAntiAliasing;
if (page->countUncachedImages() > 0) {
page->renderTextOnly(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
page->renderImagePlaceholders(renderer, orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
renderer.clearScreen();
}
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
@@ -1048,42 +1046,26 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
// Check if half-refresh is needed (either entering Reader or pages counter reached)
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else if (forceFullRefresh) {
// OPTIMIZATION: For image pages with anti-aliasing, use fast double-refresh technique
// to reduce perceived lag. Only when pagesUntilFullRefresh > 1 (screen already clean).
if (imagePageWithAA) {
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
// Instead, blank only the image area and do two fast refreshes.
int imgX, imgY, imgW, imgH;
if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {
int screenX = imgX + orientedMarginLeft;
int screenY = imgY + orientedMarginTop;
LOG_DBG("ERS", "Image page: fast double-refresh (page bbox: %d,%d %dx%d, screen: %d,%d %dx%d)", imgX, imgY, imgW,
imgH, screenX, screenY, imgW, imgH);
renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
#if USE_IMAGE_DOUBLE_FAST_REFRESH == 0
// Method A: Fill blank area + two FAST_REFRESH operations
renderer.fillRect(screenX, screenY, imgW, imgH, false);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
#else
// Method B (experimental): Use displayWindow() for partial refresh
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayWindow(screenX, screenY, imgW, imgH, HalDisplay::FAST_REFRESH);
#endif
} else {
LOG_DBG("ERS", "Image page but no bbox, using standard half refresh");
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
pagesUntilFullRefresh--;
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
} else if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
// Normal page without images, or images without anti-aliasing
renderer.displayBuffer();
pagesUntilFullRefresh--;
}

View File

@@ -1,6 +1,7 @@
#include "BaseTheme.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h>
@@ -9,7 +10,6 @@
#include <ctime>
#include <string>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "I18n.h"
#include "RecentBooksStore.h"
@@ -45,11 +45,110 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
}
// Truncate a string with "..." to fit within maxWidth.
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
std::string truncated = text;
std::string withEllipsis = truncated + "...";
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
utf8RemoveLastChar(truncated);
withEllipsis = truncated + "...";
}
return truncated.empty() ? std::string("...") : withEllipsis;
}
// Text wrapping with 3-tier break logic:
// 1) Preferred delimiters: " -- ", " - ", en-dash, em-dash (title-author separator)
// 2) Word boundaries: last space or hyphen that fits
// 3) Character-level fallback for long unbroken tokens
// The last allowed line is truncated with "..." if it overflows.
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
lines.push_back(text);
return lines;
}
if (maxLines == 1) {
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
return lines;
}
// Tier 1: Try preferred delimiters (last occurrence to maximize line 1 content).
// \xe2\x80\x93 = en-dash, \xe2\x80\x94 = em-dash
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
for (const char* delim : preferredDelimiters) {
size_t delimLen = strlen(delim);
auto pos = text.rfind(delim);
if (pos != std::string::npos && pos > 0) {
std::string firstPart = text.substr(0, pos);
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
lines.push_back(firstPart);
std::string remainder = text.substr(pos + delimLen);
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
} else {
lines.push_back(remainder);
}
return lines;
}
}
}
// Tier 2 & 3: Word-boundary wrapping with character-level fallback.
std::string currentLine;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
std::string lineAtBreak;
const unsigned char* ptrAtBreak = nullptr;
while (*ptr != 0) {
const unsigned char* charStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
std::string candidate = currentLine + nextChar;
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
currentLine = candidate;
if (cp == ' ' || cp == '-') {
lineAtBreak = currentLine;
ptrAtBreak = ptr;
}
continue;
}
// Overflow
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
if (ptrAtBreak != nullptr) {
std::string line = lineAtBreak;
while (!line.empty() && line.back() == ' ') line.pop_back();
lines.push_back(line);
ptr = ptrAtBreak;
while (*ptr == ' ') ++ptr;
currentLine.clear();
} else {
lines.push_back(currentLine);
currentLine = nextChar;
}
lineAtBreak.clear();
ptrAtBreak = nullptr;
} else {
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
return lines;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
} // namespace
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = battery.readPercentage();
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
if (showPercentage) {
@@ -64,7 +163,7 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
// rect.x is already positioned for the icon (drawHeader calculated it)
const uint16_t percentage = battery.readPercentage();
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
if (showPercentage) {
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
int contentWidth = rect.width - 5;
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
// Detect if selected row's title overflows and needs 2-line expansion
bool selectedExpands = false;
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
int titleTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto selTitle = rowTitle(selectedIndex);
if (renderer.getTextWidth(font, selTitle.c_str()) > titleTextWidth) {
selectedExpands = true;
}
}
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
if (totalPages > 1) {
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
constexpr int margin = 15;
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
const int indicatorTop = rect.y;
const int indicatorBottom = rect.y + rect.height - arrowSize;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
}
// Draw selection
int contentWidth = rect.width - 5;
if (selectedIndex >= 0) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
// Compute page start: use effective page items but prevent backward leak
int pageStartIndex;
if (selectedExpands) {
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
int originalStart = selectedIndex / pageItems * pageItems;
pageStartIndex = std::max(rawStart, originalStart);
if (selectedIndex >= pageStartIndex + effectivePageItems) {
pageStartIndex = selectedIndex - effectivePageItems + 1;
}
if (pageStartIndex > 0 && pageStartIndex == originalStart
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto prevTitle = rowTitle(pageStartIndex - 1);
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
pageStartIndex--;
}
}
} else {
pageStartIndex = selectedIndex / pageItems * pageItems;
// Include previous page's boundary item if it would need expansion when selected,
// so it doesn't vanish when navigating from it to the current page.
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto prevTitle = rowTitle(pageStartIndex - 1);
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
pageStartIndex--;
}
}
}
// Draw selection highlight
if (selectedIndex >= 0) {
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
int selY = rect.y + selRowsBeforeOnPage * rowHeight - 2;
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
renderer.fillRect(0, selY, rect.width, selHeight);
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const bool isExpanded = (selectedExpands && i == selectedIndex);
// Draw name
auto itemName = rowTitle(i);
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
i != selectedIndex);
if (isExpanded) {
int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
for (size_t l = 0; l < lines.size(); ++l) {
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
}
if (rowValue != nullptr) {
// Draw value
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
itemY, valueText.c_str(), i != selectedIndex);
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
yPos + rowHeight, valueText.c_str(), false);
}
yPos += 2 * rowHeight;
} else {
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, yPos, item.c_str(),
i != selectedIndex);
if (rowSubtitle != nullptr) {
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, yPos + 30,
subtitle.c_str(), i != selectedIndex);
}
if (rowValue != nullptr) {
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, yPos,
valueText.c_str(), i != selectedIndex);
}
yPos += rowHeight;
}
}
}
@@ -365,14 +527,52 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = rect.width / 2;
const int bookHeight = rect.height;
const int bookX = (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// --- Top "book" card for the current title (selectorIndex == 0) ---
// Adapt width to cover image aspect ratio; fall back to half screen when no cover
const int baseHeight = rect.height;
int bookWidth;
bool hasCoverImage = false;
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
FsFile file;
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
hasCoverImage = true;
const int imgWidth = bitmap.getWidth();
const int imgHeight = bitmap.getHeight();
if (imgWidth > 0 && imgHeight > 0) {
const float aspectRatio = static_cast<float>(imgWidth) / static_cast<float>(imgHeight);
bookWidth = static_cast<int>(baseHeight * aspectRatio);
const int maxWidth = static_cast<int>(rect.width * 0.9f);
if (bookWidth > maxWidth) {
bookWidth = maxWidth;
}
} else {
bookWidth = rect.width / 2;
}
}
file.close();
}
}
if (!hasCoverImage) {
bookWidth = rect.width / 2;
}
const int bookX = rect.x + (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const int bookHeight = baseHeight;
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
@@ -394,29 +594,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("THEME", "Rendering bmp");
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art
@@ -597,7 +777,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
const int boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + boxPadding * 2;
const int boxX = (rect.width - boxWidth) / 2;
const int boxX = rect.x + (rect.width - boxWidth) / 2;
const int boxY = titleYStart - boxPadding;
// Draw box (inverted when selected: black box instead of white)
@@ -640,7 +820,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
const int continueBoxX = (rect.width - continueBoxWidth) / 2;
const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2;
const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);

View File

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

View File

@@ -1,6 +1,7 @@
#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Utf8.h>
@@ -10,7 +11,6 @@
#include <string>
#include <vector>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
@@ -84,11 +84,100 @@ const uint8_t* iconForName(UIIcon icon, int size) {
}
return nullptr;
}
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
std::string truncated = text;
std::string withEllipsis = truncated + "...";
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
utf8RemoveLastChar(truncated);
withEllipsis = truncated + "...";
}
return truncated.empty() ? std::string("...") : withEllipsis;
}
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
lines.push_back(text);
return lines;
}
if (maxLines == 1) {
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
return lines;
}
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
for (const char* delim : preferredDelimiters) {
size_t delimLen = strlen(delim);
auto pos = text.rfind(delim);
if (pos != std::string::npos && pos > 0) {
std::string firstPart = text.substr(0, pos);
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
lines.push_back(firstPart);
std::string remainder = text.substr(pos + delimLen);
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
} else {
lines.push_back(remainder);
}
return lines;
}
}
}
std::string currentLine;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
std::string lineAtBreak;
const unsigned char* ptrAtBreak = nullptr;
while (*ptr != 0) {
const unsigned char* charStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
std::string candidate = currentLine + nextChar;
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
currentLine = candidate;
if (cp == ' ' || cp == '-') {
lineAtBreak = currentLine;
ptrAtBreak = ptr;
}
continue;
}
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
if (ptrAtBreak != nullptr) {
std::string line = lineAtBreak;
while (!line.empty() && line.back() == ' ') line.pop_back();
lines.push_back(line);
ptr = ptrAtBreak;
while (*ptr == ' ') ++ptr;
currentLine.clear();
} else {
lines.push_back(currentLine);
currentLine = nextChar;
}
lineAtBreak.clear();
ptrAtBreak = nullptr;
} else {
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
return lines;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
} // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = battery.readPercentage();
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
@@ -125,7 +214,7 @@ void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
const uint16_t percentage = battery.readPercentage();
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
@@ -278,13 +367,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
// Detect if selected row's title overflows and needs 2-line expansion
bool selectedExpands = false;
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
int prelContentWidth =
rect.width -
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
auto selTitle = rowTitle(selectedIndex);
auto selValue = rowValue(selectedIndex);
int selValueWidth = 0;
if (!selValue.empty()) {
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
}
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
selectedExpands = true;
}
}
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
if (totalPages > 1) {
const int scrollAreaHeight = rect.height;
// Draw scroll bar
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
const int currentPage = selectedIndex / pageItems;
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
const int currentPage = selectedIndex / effectivePageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
@@ -292,19 +403,71 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
scrollBarHeight, true);
}
// Draw selection
int contentWidth =
rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
// Compute page start: use effective page items but prevent backward leak
int pageStartIndex;
if (selectedExpands) {
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
int originalStart = selectedIndex / pageItems * pageItems;
pageStartIndex = std::max(rawStart, originalStart);
if (selectedIndex >= pageStartIndex + effectivePageItems) {
pageStartIndex = selectedIndex - effectivePageItems + 1;
}
if (pageStartIndex > 0 && pageStartIndex == originalStart
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
} else {
pageStartIndex = selectedIndex / pageItems * pageItems;
// Include previous page's boundary item if it would need expansion when selected,
// so it doesn't vanish when navigating from it to the current page.
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
}
// Draw selection highlight
if (selectedIndex >= 0) {
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
Color::LightGray);
}
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize;
int iconSize = listIconSize;
if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection;
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int rowTextWidth = textWidth;
int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const bool isExpanded = (selectedExpands && i == selectedIndex);
// Draw name
int valueWidth = 0;
std::string valueText = "";
std::string valueText;
if (rowValue != nullptr) {
valueText = rowValue(i);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
if (!valueText.empty()) {
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
rowTextWidth -= valueWidth;
}
}
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
if (isExpanded) {
int wrapWidth = textWidth;
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
for (size_t l = 0; l < lines.size(); ++l) {
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
}
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
itemY + iconY, iconSize, iconSize);
yPos + iconY, iconSize, iconSize);
}
}
if (!valueText.empty()) {
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
yPos + rowHeight + 7, valueText.c_str(), true);
}
yPos += 2 * rowHeight;
} else {
int rowTextWidth = textWidth - valueWidth;
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
yPos + iconY, iconSize, iconSize);
}
}
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
}
// Draw value
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
valueText.c_str(), !(i == selectedIndex && highlightValue));
}
yPos += rowHeight;
}
}
}

View File

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