From d922b5a67a9008530ea3c9f949ed8d36a03eb422 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 19 May 2026 09:28:36 +0200 Subject: [PATCH] Render larger images on demand not by default --- lib/Epub/Epub/Page.cpp | 25 ++++++- lib/Epub/Epub/Page.h | 4 +- lib/Epub/Epub/blocks/ImageBlock.cpp | 71 +++++++++++++++++--- lib/Epub/Epub/blocks/ImageBlock.h | 19 +++++- lib/I18n/translations/english.yaml | 1 + src/CrossPointSettings.h | 3 + src/SettingsList.h | 2 + src/activities/reader/EpubReaderActivity.cpp | 24 +++++-- src/activities/reader/EpubReaderActivity.h | 5 ++ 9 files changed, 134 insertions(+), 20 deletions(-) diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index c156b584..3bfb0251 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -27,10 +27,13 @@ std::unique_ptr PageLine::deserialize(FsFile& file) { } void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { - // Images don't use fontId or text rendering imageBlock->render(renderer, xPos + xOffset, yPos + yOffset); } +void PageImage::renderWithForceLoad(GfxRenderer& renderer, const int xOffset, const int yOffset, const bool forceLoad) { + imageBlock->render(renderer, xPos + xOffset, yPos + yOffset, forceLoad); +} + bool PageImage::serialize(FsFile& file) { serialization::writePod(file, xPos); serialization::writePod(file, yPos); @@ -201,12 +204,28 @@ std::unique_ptr PageTableFragment::deserialize(FsFile& file) new PageTableFragment(columnCount, totalWidth, totalHeight, colWidths, std::move(rows), xPos, yPos)); } -void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const { +void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, + const bool forceLoadLargeImages) const { for (auto& element : elements) { - element->render(renderer, fontId, xOffset, yOffset); + if (element->getTag() == TAG_PageImage) { + static_cast(*element).renderWithForceLoad(renderer, xOffset, yOffset, forceLoadLargeImages); + } else { + element->render(renderer, fontId, xOffset, yOffset); + } } } +bool Page::hasPlaceholderImages(const bool forceLoadLargeImages) const { + for (const auto& el : elements) { + if (el->getTag() == TAG_PageImage) { + if (static_cast(*el).getImageBlock().wouldShowPlaceholder(forceLoadLargeImages)) { + return true; + } + } + } + return 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) { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 8d3fdff6..067f2d5a 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -58,6 +58,7 @@ class PageImage final : public PageElement { PageImage(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), imageBlock(std::move(block)) {} void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; + void renderWithForceLoad(GfxRenderer& renderer, int xOffset, int yOffset, bool forceLoad); bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageImage; } static std::unique_ptr deserialize(FsFile& file); @@ -128,8 +129,9 @@ class Page { footnotes.push_back(entry); } - void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; + void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool forceLoadLargeImages = true) const; void renderTextOnly(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; + bool hasPlaceholderImages(bool forceLoadLargeImages) const; bool serialize(FsFile& file) const; static std::unique_ptr deserialize(FsFile& file); diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 3e331572..5cc6728b 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -1,12 +1,16 @@ #include "ImageBlock.h" +#include #include #include #include #include "../../../../src/CrossPointSettings.h" +#include "../../../../src/fontIds.h" #include "../converters/DirectPixelWriter.h" #include "../converters/ImageDecoderFactory.h" +#include "../converters/JpegToFramebufferConverter.h" +#include "../converters/PngToFramebufferConverter.h" // Cache file format: // - uint16_t width @@ -97,7 +101,51 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } // namespace -void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { +bool ImageBlock::isLargeImage() const { + if (largeImageCached != 0) return largeImageCached == 1; + ImageDimensions dims{0, 0}; + const bool ok = FsHelpers::hasJpgExtension(imagePath) + ? JpegToFramebufferConverter::getDimensionsStatic(imagePath, dims) + : PngToFramebufferConverter::getDimensionsStatic(imagePath, dims); + if (ok && dims.width > 0 && dims.height > 0) { + largeImageCached = (int32_t(dims.width) * dims.height > LARGE_IMAGE_PIXEL_THRESHOLD) ? 1 : -1; + } else { + largeImageCached = -1; // unreadable header → assume not large, render normally + } + return largeImageCached == 1; +} + +bool ImageBlock::wouldShowPlaceholder(bool forceLoad) const { + if (forceLoad) return false; + if (!isLargeImage()) return false; + // If the pixel cache already exists the render is instant — no placeholder needed + const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering); + const std::string pxcPath = [&] { + size_t dot = imagePath.rfind('.'); + return (dot != std::string::npos ? imagePath.substr(0, dot) : imagePath) + getImageDitherCacheSuffix(ditherMode) + + ".pxc"; + }(); + return !Storage.exists(pxcPath.c_str()); +} + +void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const { + constexpr int BORDER = 1; + constexpr int PADDING = 6; + + renderer.drawRect(x, y, width, height, BORDER, true); + + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int totalTextH = lineH * 2; + + if (lineH > 0 && width > PADDING * 2 && height > totalTextH + PADDING * 2) { + const int textX = x + PADDING; + const int textY = y + (height - totalTextH) / 2; + renderer.drawText(UI_10_FONT_ID, textX, textY, "Image"); + renderer.drawText(UI_10_FONT_ID, textX, textY + lineH, "Press OK to load"); + } +} + +void ImageBlock::render(GfxRenderer& renderer, const int x, const int y, const bool forceLoad) { LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height); const int screenWidth = renderer.getScreenWidth(); @@ -110,15 +158,21 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { return; } - // Try to render from cache first + // Try to render from pixel cache first (always, regardless of forceLoad) const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering); std::string cachePath = getCachePath(imagePath, ditherMode); if (renderFromCache(renderer, cachePath, x, y, width, height)) { - return; // Successfully rendered from cache + return; } - // No cache - need to decode the image - // Check if image file exists + // No pixel cache — check if this is a large image that should show a placeholder + if (wouldShowPlaceholder(forceLoad)) { + LOG_DBG("IMG", "Large image placeholder at %d,%d (%dx%d): %s", x, y, width, height, imagePath.c_str()); + renderPlaceholder(renderer, x, y); + return; + } + + // Proceed with full decode FsFile file; if (!Storage.openFileForRead("IMG", imagePath, file)) { LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str()); @@ -143,8 +197,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { config.useDithering = true; config.ditherMode = ditherMode; config.performanceMode = false; - config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches - config.cachePath = cachePath; // Enable caching during decode + config.useExactDimensions = true; + config.cachePath = cachePath; ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath); if (!decoder) { @@ -157,10 +211,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { bool success = decoder->decodeToFramebuffer(imagePath, renderer, config); if (!success) { LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str()); - return; } - - LOG_DBG("IMG", "Decode successful"); } bool ImageBlock::serialize(FsFile& file) { diff --git a/lib/Epub/Epub/blocks/ImageBlock.h b/lib/Epub/Epub/blocks/ImageBlock.h index f3b01e6c..03149bcb 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.h +++ b/lib/Epub/Epub/blocks/ImageBlock.h @@ -6,6 +6,11 @@ #include "Block.h" +// Source pixel area above which an image is considered "large" and rendered +// as a placeholder until the user explicitly requests it. +// 800x600 covers most full-page illustrations that take >1s to dither on ESP32. +static constexpr int32_t LARGE_IMAGE_PIXEL_THRESHOLD = 800 * 600; + class ImageBlock final : public Block { public: ImageBlock(const std::string& imagePath, int16_t width, int16_t height); @@ -17,10 +22,18 @@ class ImageBlock final : public Block { bool imageExists() const; + // Returns true if the source image dimensions exceed LARGE_IMAGE_PIXEL_THRESHOLD. + // Result is cached after the first call to avoid repeated header reads. + bool isLargeImage() const; + + // Returns true if this image would be shown as a placeholder given forceLoad. + // False when: forceLoad is true, image is not large, or pixel cache already exists. + bool wouldShowPlaceholder(bool forceLoad) const; + BlockType getType() override { return IMAGE_BLOCK; } bool isEmpty() override { return false; } - void render(GfxRenderer& renderer, const int x, const int y); + void render(GfxRenderer& renderer, int x, int y, bool forceLoad = true); bool serialize(FsFile& file); static std::unique_ptr deserialize(FsFile& file); @@ -28,4 +41,8 @@ class ImageBlock final : public Block { std::string imagePath; int16_t width; int16_t height; + + mutable int8_t largeImageCached = 0; // 0=unchecked, 1=large, -1=not large + + void renderPlaceholder(GfxRenderer& renderer, int x, int y) const; }; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ebd6deca..146d3ea5 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -102,6 +102,7 @@ STR_IMAGES: "Images" STR_IMAGES_DISPLAY: "Display" STR_IMAGES_PLACEHOLDER: "Placeholder" STR_IMAGES_SUPPRESS: "Suppress" +STR_LARGE_IMAGE_PLACEHOLDER: "Placeholder for large images" STR_IMAGE_DITHERING: "Image Dithering" STR_IMAGE_DITHER_BAYER: "Bayer" STR_IMAGE_DITHER_ATKINSON: "Atkinson" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index c9b74dae..c8df545d 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -267,6 +267,9 @@ class CrossPointSettings { uint8_t showFileExtensions = 0; // Image rendering mode in EPUB reader uint8_t imageRendering = IMAGES_DISPLAY; + // Show a placeholder for large images (>800×600 source pixels) instead of decoding immediately. + // The user can press OK on the placeholder page to decode the image on demand. + uint8_t largeImagePlaceholder = 1; // Dithering mode for decoded images (EPUB/JPG/PNG) uint8_t imageDithering = IMAGE_DITHER_BAYER; // Tilt-based page turning (X3 only — requires QMI8658 IMU) diff --git a/src/SettingsList.h b/src/SettingsList.h index 576b3853..5cd1dc10 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -139,6 +139,8 @@ inline const std::vector list = { SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER), + SettingInfo::Toggle(StrId::STR_LARGE_IMAGE_PLACEHOLDER, &CrossPointSettings::largeImagePlaceholder, + "largeImagePlaceholder", StrId::STR_CAT_READER), SettingInfo::Value(StrId::STR_SCREEN_MARGIN, &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", StrId::STR_CAT_READER) .withSubmenu(StrId::STR_MENU_READER_SPACING), diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3f790fdb..155cb792 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -407,6 +407,12 @@ void EpubReaderActivity::loop() { return; } if (ev.type == ButtonEventManager::PressType::Short) { + if (pageHasPlaceholders) { + forceLoadLargeImages = true; + pageHasPlaceholders = false; + requestUpdate(); + return; + } openReaderMenu(); return; } @@ -1512,6 +1518,8 @@ bool EpubReaderActivity::stepPageState(const bool isForwardTurn) { } lastPageTurnTime = millis(); + forceLoadLargeImages = false; + pageHasPlaceholders = false; return true; } @@ -1868,15 +1876,18 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } lastRenderStats.textAntiAliasing = aaEnabledForThisRender; - // Force special handling for pages with images when anti-aliasing is on - bool imagePageWithAA = page->hasImages() && aaEnabledForThisRender; + // Force special handling for pages with real (non-placeholder) images when anti-aliasing is on + bool imagePageWithAA = page->hasImages() && !pageHasPlaceholders && aaEnabledForThisRender; bool forceHalfRefreshThisPage = pendingHalfRefreshAfterImagePage && SETTINGS.halfRefreshAfterImagePage; pendingHalfRefreshAfterImagePage = false; lastRenderStats.imagePageWithAA = imagePageWithAA; lastRenderStats.forcedHalfRefresh = forceHalfRefreshThisPage; + const bool effectiveForceLoad = forceLoadLargeImages || !SETTINGS.largeImagePlaceholder; + pageHasPlaceholders = page->hasPlaceholderImages(effectiveForceLoad); + logReaderMemSnapshot("before_bw_render"); - page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad); renderStatusBar(); if (showTruncatedSectionHintThisRender) { const int hintX = orientedMarginLeft + 4; @@ -1912,7 +1923,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // Re-render page content to restore images into the blanked area // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %) - page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + page->render(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, effectiveForceLoad); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } else { renderer.displayBuffer(HalDisplay::HALF_REFRESH); @@ -1970,7 +1981,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or LOG_INF("ERS", "Skipping grayscale/BW-restore for this page (insufficient heap for BW snapshot)"); } - if (page->hasImages() && getEffectiveImageRendering() != CrossPointSettings::IMAGES_SUPPRESS) { + // Only schedule the half-refresh if real images were decoded on this page. + // Placeholder-only pages don't deposit grayscale data that needs settling. + if (page->hasImages() && !pageHasPlaceholders && + getEffectiveImageRendering() != CrossPointSettings::IMAGES_SUPPRESS) { pendingHalfRefreshAfterImagePage = true; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index d242046a..eebf4b72 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -109,6 +109,11 @@ class EpubReaderActivity final : public Activity { unsigned long lastPageTurnTime = 0UL; unsigned long pageTurnDuration = 0UL; bool pendingHalfRefreshAfterImagePage = false; + // When true, large images on the current page are decoded instead of shown as placeholders. + // Reset to false on every page turn so the next image page starts with a placeholder again. + bool forceLoadLargeImages = false; + // Set after each render: true if the current page contains at least one placeholder image. + bool pageHasPlaceholders = false; // Temporary AA suspension when BW snapshot allocation fails under memory pressure. // Automatically lifted once heap recovers above hysteresis thresholds. bool antiAliasingSuspendedLowMemory = false;