From b531077b8c74fa977c3f0efddf236b3db2f9fa86 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 2 Mar 2026 10:52:22 +0100 Subject: [PATCH] Support transparent PNG --- lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 1 + src/SettingsList.h | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 287 ++++++++++++++++++- src/activities/boot_sleep/SleepActivity.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 62 ++++ src/activities/reader/EpubReaderActivity.h | 5 + src/activities/reader/TxtReaderActivity.cpp | 185 ++++++++++++ src/activities/reader/TxtReaderActivity.h | 5 + src/activities/reader/XtcReaderActivity.cpp | 75 +++++ src/activities/reader/XtcReaderActivity.h | 5 + 11 files changed, 627 insertions(+), 2 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index f5b80b8e..16239f0f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -273,6 +273,7 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" STR_COVER_CUSTOM: "Cover + Custom" +STR_PAGE_OVERLAY: "Page overlay" STR_RECENTS: "Recents" STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5ba7bde2..16c53110 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -24,6 +24,7 @@ class CrossPointSettings { COVER = 3, BLANK = 4, COVER_CUSTOM = 5, + OVERLAY = 6, SLEEP_SCREEN_MODE_COUNT }; enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index c10d58c6..10374a1e 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -16,7 +16,7 @@ inline const std::vector& getSettingsList() { // --- Display --- SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen, {StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT, - StrId::STR_COVER_CUSTOM}, + StrId::STR_COVER_CUSTOM, StrId::STR_PAGE_OVERLAY}, "sleepScreen", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode, {StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY), diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index cd6de5fc..9821500b 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -4,19 +4,142 @@ #include #include #include +#include #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" +#include "reader/EpubReaderActivity.h" +#include "reader/TxtReaderActivity.h" +#include "reader/XtcReaderActivity.h" #include "util/StringUtils.h" +namespace { + +// Context passed through PNGdec's decode() user-pointer to the per-scanline draw callback. +struct PngOverlayCtx { + const GfxRenderer* renderer; + int screenW; + int screenH; + int srcWidth; + int dstWidth; + int dstX; + int dstY; + float yScale; + int lastDstY; +}; + +// PNGdec file I/O callbacks — mirror the pattern in PngToFramebufferConverter.cpp. +void* pngSleepOpen(const char* filename, int32_t* size) { + FsFile* f = new FsFile(); + if (!Storage.openFileForRead("SLP", std::string(filename), *f)) { + delete f; + return nullptr; + } + *size = f->size(); + return f; +} +void pngSleepClose(void* handle) { + FsFile* f = reinterpret_cast(handle); + if (f) { + f->close(); + delete f; + } +} +int32_t pngSleepRead(PNGFILE* pFile, uint8_t* pBuf, int32_t len) { + FsFile* f = reinterpret_cast(pFile->fHandle); + return f ? f->read(pBuf, len) : 0; +} +int32_t pngSleepSeek(PNGFILE* pFile, int32_t pos) { + FsFile* f = reinterpret_cast(pFile->fHandle); + if (!f) return -1; + return f->seek(pos); +} + +// Per-scanline draw callback for PNG overlay compositing. +// Transparent pixels (alpha < 128) are skipped so the reader page shows through. +// Opaque pixels are drawn in their grayscale brightness (dark → black, light → white). +int pngOverlayDraw(PNGDRAW* pDraw) { + PngOverlayCtx* ctx = reinterpret_cast(pDraw->pUser); + + const int destY = ctx->dstY + (int)(pDraw->y * ctx->yScale); + if (destY == ctx->lastDstY) return 1; // skip duplicate rows from Y scaling + ctx->lastDstY = destY; + if (destY < 0 || destY >= ctx->screenH) return 1; + + const int srcWidth = ctx->srcWidth; + const int dstWidth = ctx->dstWidth; + const uint8_t* pixels = pDraw->pPixels; + const int pixelType = pDraw->iPixelType; + const int hasAlpha = pDraw->iHasAlpha; + + int srcX = 0, error = 0; + for (int dstX = 0; dstX < dstWidth; dstX++) { + const int outX = ctx->dstX + dstX; + if (outX >= 0 && outX < ctx->screenW) { + uint8_t alpha = 255, gray = 0; + switch (pixelType) { + case PNG_PIXEL_TRUECOLOR_ALPHA: { + const uint8_t* p = &pixels[srcX * 4]; + alpha = p[3]; + gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + break; + } + case PNG_PIXEL_GRAY_ALPHA: + gray = pixels[srcX * 2]; + alpha = pixels[srcX * 2 + 1]; + break; + case PNG_PIXEL_TRUECOLOR: { + const uint8_t* p = &pixels[srcX * 3]; + gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + break; + } + case PNG_PIXEL_GRAYSCALE: + gray = pixels[srcX]; + break; + case PNG_PIXEL_INDEXED: + if (pDraw->pPalette) { + const uint8_t idx = pixels[srcX]; + const uint8_t* p = &pDraw->pPalette[idx * 3]; + gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8); + if (hasAlpha) alpha = pDraw->pPalette[768 + idx]; + } + break; + default: + gray = pixels[srcX]; + break; + } + + if (alpha >= 128) { + ctx->renderer->drawPixel(outX, destY, gray < 128); // true = black, false = white + } + // alpha < 128: transparent — leave the reader page pixel intact + } + + // Bresenham-style X stepping (handles downscaling; 1:1 when srcWidth == dstWidth) + error += srcWidth; + while (error >= dstWidth) { + error -= dstWidth; + srcX++; + } + } + return 1; +} + +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + // For OVERLAY mode the popup is suppressed so the frame buffer (reader page) stays intact + if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY) { + GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); + } switch (SETTINGS.sleepScreen) { case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK): @@ -26,6 +149,8 @@ void SleepActivity::onEnter() { case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER): case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM): return renderCoverSleepScreen(); + case (CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY): + return renderOverlaySleepScreen(); default: return renderDefaultSleepScreen(); } @@ -284,3 +409,163 @@ void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); } + +void SleepActivity::renderOverlaySleepScreen() const { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Step 1: Ensure the frame buffer contains the reader page. + // When coming from a reader activity the frame buffer already holds the page. + // When coming from a non-reader activity we re-render it from the saved progress. + if (!APP_STATE.lastSleepFromReader && !APP_STATE.openEpubPath.empty()) { + const auto& path = APP_STATE.openEpubPath; + bool rendered = false; + + if (StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch")) { + rendered = XtcReaderActivity::drawCurrentPageToBuffer(path, renderer); + } else if (StringUtils::checkFileExtension(path, ".txt")) { + rendered = TxtReaderActivity::drawCurrentPageToBuffer(path, renderer); + } else if (StringUtils::checkFileExtension(path, ".epub")) { + rendered = EpubReaderActivity::drawCurrentPageToBuffer(path, renderer); + } + + if (!rendered) { + LOG_DBG("SLP", "Page re-render failed, using white background"); + renderer.clearScreen(); + } + } + + // Step 2: Load the overlay image using the same selection logic as renderCustomSleepScreen. + // BMP: white pixels are skipped (transparent via drawBitmap), black pixels composited on top. + // PNG: pixels with alpha < 128 are skipped; opaque pixels are drawn with their grayscale value. + auto tryDrawOverlay = [&](const std::string& filename) -> bool { + FsFile file; + if (!Storage.openFileForRead("SLP", filename, file)) return false; + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + file.close(); + return false; + } + + int x, y; + float cropX = 0, cropY = 0; + if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + if (ratio > screenRatio) { + x = 0; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + } else { + x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); + y = 0; + } + } else { + x = (pageWidth - bitmap.getWidth()) / 2; + y = (pageHeight - bitmap.getHeight()) / 2; + } + + // Draw without clearScreen so the reader page remains in the frame buffer beneath + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); + file.close(); + return true; + }; + + auto tryDrawPngOverlay = [&](const std::string& filename) -> bool { + constexpr size_t MIN_FREE_HEAP = 60 * 1024; // PNG decoder ~42 KB + overhead + if (ESP.getFreeHeap() < MIN_FREE_HEAP) { + LOG_ERR("SLP", "Not enough heap for PNG overlay decoder"); + return false; + } + PNG* png = new (std::nothrow) PNG(); + if (!png) return false; + + int rc = png->open(filename.c_str(), pngSleepOpen, pngSleepClose, pngSleepRead, pngSleepSeek, pngOverlayDraw); + if (rc != PNG_SUCCESS) { + LOG_DBG("SLP", "PNG open failed: %s (%d)", filename.c_str(), rc); + delete png; + return false; + } + + const int srcW = png->getWidth(), srcH = png->getHeight(); + float yScale = 1.0f; + int dstW = srcW, dstH = srcH; + if (srcW > pageWidth || srcH > pageHeight) { + const float scaleX = (float)pageWidth / srcW, scaleY = (float)pageHeight / srcH; + const float scale = (scaleX < scaleY) ? scaleX : scaleY; + dstW = (int)(srcW * scale); + dstH = (int)(srcH * scale); + yScale = (float)dstH / srcH; + } + + PngOverlayCtx ctx; + ctx.renderer = &renderer; + ctx.screenW = pageWidth; + ctx.screenH = pageHeight; + ctx.srcWidth = srcW; + ctx.dstWidth = dstW; + ctx.dstX = (pageWidth - dstW) / 2; + ctx.dstY = (pageHeight - dstH) / 2; + ctx.yScale = yScale; + ctx.lastDstY = -1; + + rc = png->decode(&ctx, 0); + png->close(); + delete png; + return rc == PNG_SUCCESS; + }; + + // Try /sleep/ directory first (random selection, same as renderCustomSleepScreen). + // Accepts both .bmp and .png files; .bmp headers are validated during the scan. + bool overlayDrawn = false; + auto dir = Storage.open("/sleep"); + if (dir && dir.isDirectory()) { + std::vector files; + char name[500]; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (file.isDirectory()) { file.close(); continue; } + file.getName(name, sizeof(name)); + auto filename = std::string(name); + if (filename[0] == '.') { file.close(); continue; } + const auto flen = filename.length(); + const bool isBmp = flen >= 4 && filename.substr(flen - 4) == ".bmp"; + const bool isPng = flen >= 4 && filename.substr(flen - 4) == ".png"; + if (!isBmp && !isPng) { file.close(); continue; } + if (isBmp) { + Bitmap bmp(file); + if (bmp.parseHeaders() != BmpReaderError::Ok) { file.close(); continue; } + } + files.emplace_back(filename); + file.close(); + } + const auto numFiles = files.size(); + if (numFiles > 0) { + auto randomFileIndex = random(numFiles); + while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) { + randomFileIndex = random(numFiles); + } + APP_STATE.lastSleepImage = randomFileIndex; + APP_STATE.saveToFile(); + const std::string selected = "/sleep/" + files[randomFileIndex]; + const auto slen = selected.length(); + if (slen >= 4 && selected.substr(slen - 4) == ".png") { + overlayDrawn = tryDrawPngOverlay(selected); + } else { + overlayDrawn = tryDrawOverlay(selected); + } + } + } + if (dir) dir.close(); + + if (!overlayDrawn) { + overlayDrawn = tryDrawOverlay("/sleep.bmp"); + } + if (!overlayDrawn) { + overlayDrawn = tryDrawPngOverlay("/sleep.png"); + } + + if (!overlayDrawn) { + LOG_DBG("SLP", "No overlay image found, displaying page without overlay"); + } + + renderer.displayBuffer(HalDisplay::HALF_REFRESH); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 87df8ba1..43c4952f 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -15,4 +15,5 @@ class SleepActivity final : public Activity { void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; void renderBlankSleepScreen() const; + void renderOverlaySleepScreen() const; }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b99cd07e..fff77935 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -829,3 +829,65 @@ void EpubReaderActivity::restoreSavedPosition() { } requestUpdate(); } + +bool EpubReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) { + auto epub = std::make_shared(filePath, "/.crosspoint"); + // skip CSS (second arg) since we only need layout/section data + if (!epub->load(true, false)) { + LOG_DBG("SLP", "EPUB: failed to load %s", filePath.c_str()); + return false; + } + + epub->setupCacheDir(); + + // Load saved spine index and page number + int spineIndex = 0, pageNumber = 0; + FsFile f; + if (Storage.openFileForRead("SLP", epub->getCachePath() + "/progress.bin", f)) { + uint8_t data[6]; + if (f.read(data, 6) == 6) { + spineIndex = data[0] | (data[1] << 8); + pageNumber = data[2] | (data[3] << 8); + } + f.close(); + } + if (spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) spineIndex = 0; + + // Apply the reader orientation so margins match what the reader would produce + applyReaderOrientation(renderer, SETTINGS.orientation); + + // Compute margins exactly as render() does + int marginTop, marginRight, marginBottom, marginLeft; + renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft); + marginTop += SETTINGS.screenMargin; + marginLeft += SETTINGS.screenMargin; + marginRight += SETTINGS.screenMargin; + const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + marginBottom += std::max(SETTINGS.screenMargin, statusBarHeight); + + const uint16_t viewportWidth = renderer.getScreenWidth() - marginLeft - marginRight; + const uint16_t viewportHeight = renderer.getScreenHeight() - marginTop - marginBottom; + + // Load the cached section file (won't rebuild if cache missing — too slow for sleep) + auto section = std::unique_ptr
(new Section(epub, spineIndex, renderer)); + if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { + LOG_DBG("SLP", "EPUB: section cache not found for spine %d", spineIndex); + return false; + } + + if (pageNumber < 0 || pageNumber >= section->pageCount) pageNumber = 0; + section->currentPage = pageNumber; + + auto page = section->loadPageFromSectionFile(); + if (!page) { + LOG_DBG("SLP", "EPUB: failed to load page %d", pageNumber); + return false; + } + + renderer.clearScreen(); + page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop); + // No displayBuffer call — caller (SleepActivity) handles that after compositing the overlay + return true; +} diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 91c6f049..9acf3a60 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -58,4 +58,9 @@ class EpubReaderActivity final : public Activity { void loop() override; void render(RenderLock&& lock) override; bool isReaderActivity() const override { return true; } + + // Renders the last saved page to the frame buffer without flushing to display. + // Used by SleepActivity to prepare the background for the overlay sleep mode. + // Returns false if the page cannot be loaded (missing cache / file error). + static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer); }; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 994492ee..157d3cbe 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -600,3 +600,188 @@ void TxtReaderActivity::savePageIndexCache() const { f.close(); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages); } + +bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) { + Txt txt(filePath, "/.crosspoint"); + if (!txt.load()) { + LOG_DBG("SLP", "TXT: failed to load %s", filePath.c_str()); + return false; + } + + // Compute layout values that match what initializeReader() produces + const int fontId = SETTINGS.getReaderFontId(); + const uint8_t screenMargin = SETTINGS.screenMargin; + const uint8_t paragraphAlignment = SETTINGS.paragraphAlignment; + + int marginTop, marginRight, marginBottom, marginLeft; + renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft); + marginTop += screenMargin; + marginLeft += screenMargin; + marginRight += screenMargin; + marginBottom += std::max(screenMargin, static_cast(UITheme::getInstance().getStatusBarHeight())); + + const int vw = renderer.getScreenWidth() - marginLeft - marginRight; + const int vh = renderer.getScreenHeight() - marginTop - marginBottom; + const int lineHeight = renderer.getLineHeight(fontId); + const int linesPerPage = std::max(1, vh / lineHeight); + + // Load the page offset index from cache (must already exist from normal reading) + std::string cachePath = txt.getCachePath() + "/index.bin"; + FsFile cacheFile; + if (!Storage.openFileForRead("SLP", cachePath, cacheFile)) { + LOG_DBG("SLP", "TXT: no page index cache"); + return false; + } + + uint32_t magic; + serialization::readPod(cacheFile, magic); + if (magic != CACHE_MAGIC) { + cacheFile.close(); + return false; + } + + uint8_t version; + serialization::readPod(cacheFile, version); + if (version != CACHE_VERSION) { + cacheFile.close(); + return false; + } + + uint32_t cachedFileSize; + serialization::readPod(cacheFile, cachedFileSize); + if (cachedFileSize != txt.getFileSize()) { + cacheFile.close(); + return false; + } + + int32_t cachedVw, cachedLpp, cachedFontId, cachedMargin; + serialization::readPod(cacheFile, cachedVw); + serialization::readPod(cacheFile, cachedLpp); + serialization::readPod(cacheFile, cachedFontId); + serialization::readPod(cacheFile, cachedMargin); + if (cachedVw != vw || cachedLpp != linesPerPage || cachedFontId != fontId || cachedMargin != screenMargin) { + LOG_DBG("SLP", "TXT: cache invalid (settings changed)"); + cacheFile.close(); + return false; + } + + uint8_t cachedAlignment; + serialization::readPod(cacheFile, cachedAlignment); + if (cachedAlignment != paragraphAlignment) { + cacheFile.close(); + return false; + } + + uint32_t numPages; + serialization::readPod(cacheFile, numPages); + if (numPages == 0) { + cacheFile.close(); + return false; + } + + std::vector pageOffsets; + pageOffsets.reserve(numPages); + for (uint32_t i = 0; i < numPages; i++) { + uint32_t offset; + serialization::readPod(cacheFile, offset); + pageOffsets.push_back(offset); + } + cacheFile.close(); + + // Load saved page number from progress file + int savedPage = 0; + FsFile progFile; + if (Storage.openFileForRead("SLP", txt.getCachePath() + "/progress.bin", progFile)) { + uint8_t data[4]; + if (progFile.read(data, 4) == 4) { + savedPage = data[0] + (data[1] << 8); + } + progFile.close(); + } + if (savedPage < 0 || savedPage >= static_cast(numPages)) savedPage = 0; + + // Load the page lines from file + std::vector pageLines; + const size_t fileSize = txt.getFileSize(); + size_t offset = pageOffsets[savedPage]; + if (offset >= fileSize) { + LOG_DBG("SLP", "TXT: page offset out of bounds"); + return false; + } + + // Replicate loadPageAtOffset() logic with local layout variables + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) return false; + + if (!txt.readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + size_t pos = 0; + while (pos < chunkSize && static_cast(pageLines.size()) < linesPerPage) { + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') lineEnd++; + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + if (!lineComplete && !pageLines.empty()) break; + + size_t lineContentLen = lineEnd - pos; + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + std::string line(reinterpret_cast(buffer + pos), displayLen); + size_t lineBytePos = 0; + + while (!line.empty() && static_cast(pageLines.size()) < linesPerPage) { + if (renderer.getTextWidth(fontId, line.c_str()) <= vw) { + pageLines.push_back(line); + lineBytePos = displayLen; + line.clear(); + break; + } + size_t breakPos = line.length(); + while (breakPos > 0 && renderer.getTextWidth(fontId, line.substr(0, breakPos).c_str()) > vw) { + size_t spacePos = line.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + breakPos--; + while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) breakPos--; + } + } + if (breakPos == 0) breakPos = 1; + pageLines.push_back(line.substr(0, breakPos)); + size_t skipChars = breakPos; + if (breakPos < line.length() && line[breakPos] == ' ') skipChars++; + lineBytePos += skipChars; + line = line.substr(skipChars); + } + pos = line.empty() ? lineEnd + 1 : pos + lineBytePos; + } + free(buffer); + + if (pageLines.empty()) return false; + + // Render lines to frame buffer (no displayBuffer call) + renderer.clearScreen(); + int y = marginTop; + for (const auto& line : pageLines) { + if (!line.empty()) { + int x = marginLeft; + switch (paragraphAlignment) { + case CrossPointSettings::CENTER_ALIGN: + x = marginLeft + (vw - renderer.getTextWidth(fontId, line.c_str())) / 2; + break; + case CrossPointSettings::RIGHT_ALIGN: + x = marginLeft + vw - renderer.getTextWidth(fontId, line.c_str()); + break; + default: + break; + } + renderer.drawText(fontId, x, y, line.c_str()); + } + y += lineHeight; + } + return true; +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 45877a8e..21e20c48 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -49,4 +49,9 @@ class TxtReaderActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool isReaderActivity() const override { return true; } + + // Renders the last saved page to the frame buffer without flushing to display. + // Used by SleepActivity to prepare the background for the overlay sleep mode. + // Returns false if the page cannot be loaded (missing cache / file error). + static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer); }; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index a10d1025..052e2f8f 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -351,3 +351,78 @@ void XtcReaderActivity::loadProgress() { f.close(); } } + +bool XtcReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer) { + Xtc xtc(filePath, "/.crosspoint"); + if (!xtc.load()) { + LOG_DBG("SLP", "XTC: failed to load %s", filePath.c_str()); + return false; + } + + // Load saved page number + uint32_t savedPage = 0; + FsFile f; + if (Storage.openFileForRead("SLP", xtc.getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + savedPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); + } + f.close(); + } + if (savedPage >= xtc.getPageCount()) savedPage = 0; + + const uint16_t pageWidth = xtc.getPageWidth(); + const uint16_t pageHeight = xtc.getPageHeight(); + const uint8_t bitDepth = xtc.getBitDepth(); + + // Only use the 1-bit BW path; grayscale is not needed as a background under the overlay + const size_t pageBufferSize = (bitDepth == 2) ? ((static_cast(pageWidth) * pageHeight + 7) / 8) * 2 + : ((pageWidth + 7) / 8) * pageHeight; + + uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); + if (!pageBuffer) { + LOG_ERR("SLP", "XTC: failed to allocate page buffer"); + return false; + } + + if (xtc.loadPage(savedPage, pageBuffer, pageBufferSize) == 0) { + LOG_ERR("SLP", "XTC: failed to load page %lu", savedPage); + free(pageBuffer); + return false; + } + + renderer.clearScreen(); + + if (bitDepth == 2) { + // 2-bit XTH: draw all non-white pixels as black (BW pass only) + const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; + const uint8_t* plane1 = pageBuffer; + const uint8_t* plane2 = pageBuffer + planeSize; + const size_t colBytes = (pageHeight + 7) / 8; + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + const size_t colIndex = pageWidth - 1 - x; + const size_t byteInCol = y / 8; + const size_t bitInByte = 7 - (y % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + if ((bit1 << 1) | bit2) { + renderer.drawPixel(x, y, true); + } + } + } + } else { + // 1-bit XTG: draw black pixels + const size_t srcRowBytes = (pageWidth + 7) / 8; + for (uint16_t srcY = 0; srcY < pageHeight; srcY++) { + for (uint16_t srcX = 0; srcX < pageWidth; srcX++) { + const bool isBlack = !((pageBuffer[srcY * srcRowBytes + srcX / 8] >> (7 - srcX % 8)) & 1); + if (isBlack) renderer.drawPixel(srcX, srcY, true); + } + } + } + + free(pageBuffer); + return true; +} diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 18effaad..85bd973d 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -29,4 +29,9 @@ class XtcReaderActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool isReaderActivity() const override { return true; } + + // Renders the last saved page to the frame buffer without flushing to display. + // Used by SleepActivity to prepare the background for the overlay sleep mode. + // Returns false if the page cannot be loaded (missing cache / file error). + static bool drawCurrentPageToBuffer(const std::string& filePath, GfxRenderer& renderer); };