From f45b7809008226bc32ffee8f750a8e2e4d4d18fc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 12:39:37 +0200 Subject: [PATCH 1/7] Support png and jpg in Image Viewer --- src/activities/boot_sleep/SleepActivity.cpp | 31 ++- src/activities/home/FileBrowserActivity.cpp | 3 +- src/activities/reader/ReaderActivity.cpp | 6 +- src/activities/reader/ReaderActivity.h | 2 +- src/activities/util/BmpViewerActivity.cpp | 224 +++++++++++++------- src/activities/util/BmpViewerActivity.h | 3 + src/components/UITheme.cpp | 3 +- 7 files changed, 179 insertions(+), 93 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index e87c54bd..d5b4a440 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -187,6 +187,21 @@ void SleepActivity::renderCustomSleepScreen() const { const bool shouldLoadOverlayInfo = SETTINGS.sleepCoverOverlay != 0 && APP_STATE.lastSleepFromReader && !APP_STATE.openEpubPath.empty(); + // An explicitly selected custom sleep image should override random images from /.sleep or /sleep. + FsFile explicitSleepFile; + if (Storage.openFileForRead("SLP", "/sleep.bmp", explicitSleepFile)) { + Bitmap bitmap(explicitSleepFile, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + LOG_DBG("SLP", "Loading explicit custom sleep image: /sleep.bmp"); + const BookOverlayInfo resolvedOverlayInfo = + shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo; + renderBitmapSleepScreen(bitmap, resolvedOverlayInfo); + explicitSleepFile.close(); + return; + } + explicitSleepFile.close(); + } + // Check if we have a /.sleep (preferred) or /sleep directory const char* sleepDir = nullptr; auto dir = Storage.open("/.sleep"); @@ -260,22 +275,6 @@ void SleepActivity::renderCustomSleepScreen() const { } if (dir) dir.close(); - // Look for sleep.bmp on the root of the sd card to determine if we should - // render a custom sleep screen instead of the default. - FsFile file; - if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file, true); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - LOG_DBG("SLP", "Loading: /sleep.bmp"); - const BookOverlayInfo resolvedOverlayInfo = - shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo; - renderBitmapSleepScreen(bitmap, resolvedOverlayInfo); - file.close(); - return; - } - file.close(); - } - renderDefaultSleepScreen(); } diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 46a1b294..557ef8cb 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -96,7 +96,8 @@ void FileBrowserActivity::loadFiles() { std::string_view filename{name}; if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || - FsHelpers::hasBmpExtension(filename)) { + FsHelpers::hasBmpExtension(filename) || FsHelpers::hasJpgExtension(filename) || + FsHelpers::hasPngExtension(filename)) { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index a1ccf789..3a19ea39 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -31,7 +31,9 @@ bool ReaderActivity::isTxtFile(const std::string& path) { FsHelpers::hasMarkdownExtension(path); // Treat .md as txt files (until we have a markdown reader) } -bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } +bool ReaderActivity::isImageFile(const std::string& path) { + return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); +} std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!Storage.exists(path.c_str())) { @@ -120,7 +122,7 @@ void ReaderActivity::onEnter() { } currentBookPath = initialBookPath; - if (isBmpFile(initialBookPath)) { + if (isImageFile(initialBookPath)) { onGoToBmpViewer(initialBookPath); } else if (isXtcFile(initialBookPath)) { renderer.clearScreen(); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 6a3756db..efdfd201 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,7 +16,7 @@ class ReaderActivity final : public Activity { static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); - static bool isBmpFile(const std::string& path); + static bool isImageFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void goToLibrary(const std::string& fromBookPath = ""); diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index dae4fe62..c7946e06 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -1,17 +1,55 @@ #include "BmpViewerActivity.h" #include +#include +#include #include #include #include +#include + #include "../reader/ReaderUtils.h" #include "CrossPointSettings.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/ScreenshotUtil.h" namespace { constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp"; + +bool isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } + +bool isSupportedImageFile(const std::string& path) { + return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); +} + +void computeCenteredImagePlacement(const int imageWidth, const int imageHeight, const int pageWidth, + const int pageHeight, int& x, int& y, int& renderWidth, int& renderHeight) { + renderWidth = imageWidth; + renderHeight = imageHeight; + + if (imageWidth > pageWidth || imageHeight > pageHeight) { + const float ratio = static_cast(imageWidth) / static_cast(imageHeight); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + + if (ratio > screenRatio) { + renderWidth = pageWidth; + renderHeight = std::round(static_cast(pageWidth) / ratio); + x = 0; + y = std::round((static_cast(pageHeight) - renderHeight) / 2.0f); + } else { + renderHeight = pageHeight; + renderWidth = std::round(static_cast(pageHeight) * ratio); + x = std::round((static_cast(pageWidth) - renderWidth) / 2.0f); + y = 0; + } + return; + } + + x = (pageWidth - imageWidth) / 2; + y = (pageHeight - imageHeight) / 2; +} } // namespace BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) @@ -19,73 +57,14 @@ BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& void BmpViewerActivity::onEnter() { Activity::onEnter(); - // Removed the redundant initial renderer.clearScreen() + if (!isSupportedImageFile(filePath)) { + renderError("Unsupported image format"); + return; + } - FsFile file; - - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); - GUI.fillPopupProgress(renderer, popupRect, 20); // Initial 20% progress - // 1. Open the file - if (Storage.openFileForRead("BMP", filePath, file)) { - Bitmap bitmap(file, true); - - // 2. Parse headers to get dimensions - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - int x, y; - - 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) { - // Wider than screen - x = 0; - y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); - } else { - // Taller than screen - x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); - y = 0; - } - } else { - // Center small images - x = (pageWidth - bitmap.getWidth()) / 2; - y = (pageHeight - bitmap.getHeight()) / 2; - } - - // 4. Prepare Rendering - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); - GUI.fillPopupProgress(renderer, popupRect, 50); - - renderer.clearScreen(); - // Assuming drawBitmap defaults to 0,0 crop if omitted, or pass explicitly: drawBitmap(bitmap, x, y, pageWidth, - // pageHeight, 0, 0) - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); - - // Draw UI hints on the base layer - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Single pass for non-grayscale images - - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - - } else { - // Handle file parsing error - renderer.clearScreen(); - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Invalid BMP File"); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - } - - file.close(); - } else { - // Handle file open error - renderer.clearScreen(); - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Could not open file"); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + const bool rendered = isBmpFile(filePath) ? renderBmpImage() : renderDecodedImage(); + if (!rendered) { + renderError("Could not render image"); } } @@ -95,16 +74,117 @@ void BmpViewerActivity::onExit() { renderer.displayBuffer(HalDisplay::FULL_REFRESH); } +bool BmpViewerActivity::renderBmpImage(const bool showControls) { + FsFile file; + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + GUI.fillPopupProgress(renderer, popupRect, 20); + + if (!Storage.openFileForRead("BMP", filePath, file)) { + return false; + } + + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + file.close(); + return false; + } + + int x, y, renderWidth, renderHeight; + computeCenteredImagePlacement(bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight, x, y, renderWidth, + renderHeight); + + GUI.fillPopupProgress(renderer, popupRect, 50); + + renderer.clearScreen(); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); + if (showControls) { + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + + file.close(); + return true; +} + +bool BmpViewerActivity::renderDecodedImage(const bool showControls) { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + GUI.fillPopupProgress(renderer, popupRect, 20); + + ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(filePath); + if (!decoder) { + return false; + } + + ImageDimensions dims{}; + if (!decoder->getDimensions(filePath, dims) || dims.width <= 0 || dims.height <= 0) { + return false; + } + + int x, y, renderWidth, renderHeight; + computeCenteredImagePlacement(dims.width, dims.height, pageWidth, pageHeight, x, y, renderWidth, renderHeight); + + GUI.fillPopupProgress(renderer, popupRect, 50); + renderer.clearScreen(); + + RenderConfig config{}; + config.x = x; + config.y = y; + config.maxWidth = renderWidth; + config.maxHeight = renderHeight; + config.useExactDimensions = true; + config.useGrayscale = true; + config.useDithering = true; + + if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { + return false; + } + + if (showControls) { + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + return true; +} + +void BmpViewerActivity::renderError(const char* message) { + const auto pageHeight = renderer.getScreenHeight(); + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, message); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); +} + void BmpViewerActivity::setAsSleepScreen() { - // Only copy if the source isn't already /sleep.bmp - if (filePath != SLEEP_BMP_PATH) { - if (!Storage.copyFile("BMP", filePath, SLEEP_BMP_PATH)) { - LOG_ERR("BMP", "Failed to copy %s to %s", filePath.c_str(), SLEEP_BMP_PATH); - return; + bool success = false; + + if (FsHelpers::hasBmpExtension(filePath)) { + success = (filePath == SLEEP_BMP_PATH) ? true : Storage.copyFile("BMP", filePath, SLEEP_BMP_PATH); + } else { + const bool renderedForCapture = isBmpFile(filePath) ? renderBmpImage(false) : renderDecodedImage(false); + if (renderedForCapture) { + success = ScreenshotUtil::saveFramebufferAsBmp(SLEEP_BMP_PATH, renderer.getFrameBuffer(), + display.getDisplayWidth(), display.getDisplayHeight()); + } + + if (!success && Storage.exists(SLEEP_BMP_PATH)) { + Storage.remove(SLEEP_BMP_PATH); } } - // Switch sleep screen mode to CUSTOM so the copied image is used + if (!success) { + LOG_ERR("BMP", "Failed to set %s as sleep screen", filePath.c_str()); + GUI.drawPopup(renderer, "Failed to set sleep screen"); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + return; + } + SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM; SETTINGS.saveToFile(); LOG_INF("BMP", "Set %s as sleep screen", filePath.c_str()); diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index 2f19d236..65607799 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -16,5 +16,8 @@ class BmpViewerActivity final : public Activity { private: std::string filePath; + bool renderBmpImage(bool showControls = true); + bool renderDecodedImage(bool showControls = true); + void renderError(const char* message); void setAsSleepScreen(); }; \ No newline at end of file diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 1b637757..4a61f00d 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -115,7 +115,8 @@ UIIcon UITheme::getFileIcon(const std::string& filename) { if (FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename)) { return Text; } - if (FsHelpers::hasBmpExtension(filename)) { + if (FsHelpers::hasBmpExtension(filename) || FsHelpers::hasJpgExtension(filename) || + FsHelpers::hasPngExtension(filename)) { return Image; } return File; From eb95cacad4c9d8591c6a66a104fb7c0d2f6963dc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 13:00:27 +0200 Subject: [PATCH 2/7] Add dithering algorithms --- lib/Epub/Epub/blocks/ImageBlock.cpp | 11 +- lib/Epub/Epub/converters/DitherUtils.h | 68 +++++++++- .../converters/ImageToFramebufferDecoder.h | 33 +++++ .../converters/JpegToFramebufferConverter.cpp | 122 ++++++++++++------ .../converters/PngToFramebufferConverter.cpp | 88 +++++++++++-- lib/I18n/translations/english.yaml | 4 + src/CrossPointSettings.h | 8 ++ src/SettingsList.h | 4 + src/activities/util/BmpViewerActivity.cpp | 47 ++++++- src/activities/util/BmpViewerActivity.h | 9 +- 10 files changed, 335 insertions(+), 59 deletions(-) diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 9a958d11..26669c34 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -4,6 +4,7 @@ #include #include +#include "../../../../src/CrossPointSettings.h" #include "../converters/DirectPixelWriter.h" #include "../converters/ImageDecoderFactory.h" @@ -19,13 +20,13 @@ bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); namespace { -std::string getCachePath(const std::string& imagePath) { +std::string getCachePath(const std::string& imagePath, ImageDitherMode ditherMode) { // Replace extension with .pxc (pixel cache) size_t dotPos = imagePath.rfind('.'); if (dotPos != std::string::npos) { - return imagePath.substr(0, dotPos) + ".pxc"; + return imagePath.substr(0, dotPos) + getImageDitherCacheSuffix(ditherMode) + ".pxc"; } - return imagePath + ".pxc"; + return imagePath + getImageDitherCacheSuffix(ditherMode) + ".pxc"; } bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth, @@ -110,7 +111,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { } // Try to render from cache first - std::string cachePath = getCachePath(imagePath); + 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 } @@ -139,6 +141,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) { config.maxHeight = height; config.useGrayscale = true; 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 diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index ec63a768..8107f2eb 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -2,6 +2,8 @@ #include +#include + // 4x4 Bayer matrix for ordered dithering inline const uint8_t bayer4x4[4][4] = { {0, 8, 2, 10}, @@ -10,6 +12,13 @@ inline const uint8_t bayer4x4[4][4] = { {15, 7, 13, 5}, }; +inline uint8_t quantizeGray4Level(uint8_t gray) { + if (gray < 64) return 0; + if (gray < 128) return 1; + if (gray < 192) return 2; + return 3; +} + // Apply Bayer dithering and quantize to 4 levels (0-3) // Stateless - works correctly with any pixel processing order inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { @@ -20,8 +29,59 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - if (adjusted < 64) return 0; - if (adjusted < 128) return 1; - if (adjusted < 192) return 2; - return 3; + return quantizeGray4Level((uint8_t)adjusted); } + +class DiffusedBayerDitherer { + public: + explicit DiffusedBayerDitherer(int width) : width(width) { + errorCurRow = new int16_t[width + 2](); + errorNextRow = new int16_t[width + 2](); + } + + ~DiffusedBayerDitherer() { + delete[] errorCurRow; + delete[] errorNextRow; + } + + DiffusedBayerDitherer(const DiffusedBayerDitherer&) = delete; + DiffusedBayerDitherer& operator=(const DiffusedBayerDitherer&) = delete; + + uint8_t processPixel(int gray, int x, int screenX, int screenY) { + int adjusted = gray + errorCurRow[x + 1]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + int thresholdAdjusted = adjusted + (bayer4x4[screenY & 3][screenX & 3] - 8) * 5; + if (thresholdAdjusted < 0) thresholdAdjusted = 0; + if (thresholdAdjusted > 255) thresholdAdjusted = 255; + + uint8_t quantized = quantizeGray4Level((uint8_t)thresholdAdjusted); + int quantizedValue = quantized * 85; + int error = adjusted - quantizedValue; + + errorCurRow[x + 2] += (error * 7) / 16; + errorNextRow[x] += (error * 3) / 16; + errorNextRow[x + 1] += (error * 5) / 16; + errorNextRow[x + 2] += error / 16; + + return quantized; + } + + void nextRow() { + int16_t* tmp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = tmp; + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } + + void reset() { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorCurRow; + int16_t* errorNextRow; +}; diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h index afb5c3bd..0bdc4a86 100644 --- a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h @@ -11,11 +11,44 @@ struct ImageDimensions { int16_t height; }; +enum class ImageDitherMode : uint8_t { + Bayer = 0, + Atkinson = 1, + DiffusedBayer = 2, + COUNT, +}; + +inline ImageDitherMode imageDitherModeFromSetting(uint8_t value) { + switch (static_cast(value)) { + case ImageDitherMode::Bayer: + case ImageDitherMode::Atkinson: + case ImageDitherMode::DiffusedBayer: + return static_cast(value); + case ImageDitherMode::COUNT: + default: + return ImageDitherMode::Bayer; + } +} + +inline const char* getImageDitherCacheSuffix(ImageDitherMode mode) { + switch (mode) { + case ImageDitherMode::Atkinson: + return ".atkinson"; + case ImageDitherMode::DiffusedBayer: + return ".diffused-bayer"; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + return ".bayer"; + } +} + struct RenderConfig { int x, y; int maxWidth, maxHeight; bool useGrayscale = true; bool useDithering = true; + ImageDitherMode ditherMode = ImageDitherMode::Bayer; bool performanceMode = false; bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation) std::string cachePath; // If non-empty, decoder will write pixel cache to this path diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 83e6b547..f76613a6 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -1,5 +1,6 @@ #include "JpegToFramebufferConverter.h" +#include #include #include #include @@ -39,6 +40,10 @@ struct JpegContext { PixelCache cache; bool caching; + int currentDitherRow; + AtkinsonDitherer* atkinsonDitherer; + DiffusedBayerDitherer* diffusedBayerDitherer; + JpegContext() : renderer(nullptr), config(nullptr), @@ -50,9 +55,59 @@ struct JpegContext { dstHeight(0), fineScaleFP(1 << 16), invScaleFP(1 << 16), - caching(false) {} + caching(false), + currentDitherRow(-1), + atkinsonDitherer(nullptr), + diffusedBayerDitherer(nullptr) {} + + ~JpegContext() { + delete atkinsonDitherer; + delete diffusedBayerDitherer; + } }; +void prepareDitherRow(JpegContext& ctx, int dstY) { + if (!ctx.config || !ctx.config->useDithering) return; + + if (ctx.currentDitherRow == -1 || dstY < ctx.currentDitherRow) { + if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->reset(); + if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->reset(); + ctx.currentDitherRow = dstY; + return; + } + + while (ctx.currentDitherRow < dstY) { + if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->nextRow(); + if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->nextRow(); + ctx.currentDitherRow++; + } +} + +uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) { + if (!ctx.config || !ctx.config->useDithering) { + return quantizeGray4Level(gray); + } + + switch (ctx.config->ditherMode) { + case ImageDitherMode::Atkinson: + if (ctx.atkinsonDitherer) { + return ctx.atkinsonDitherer->processPixel(gray, localX); + } + break; + case ImageDitherMode::DiffusedBayer: + if (ctx.diffusedBayerDitherer) { + return ctx.diffusedBayerDitherer->processPixel(gray, localX, outX, outY); + } + break; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + break; + } + + return applyBayerDither4Level(gray, outX, outY); +} + // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. void* jpegOpen(const char* filename, int32_t* size) { @@ -136,7 +191,6 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (stride <= 0 || blockH <= 0 || validW <= 0) return 1; - const bool useDithering = ctx->config->useDithering; const bool caching = ctx->caching; const int32_t fineScaleFP = ctx->fineScaleFP; const int32_t invScaleFP = ctx->invScaleFP; @@ -181,19 +235,14 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (fineScaleFP == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareDitherRow(*ctx, dstY); pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const uint8_t* row = &pixels[(dstY - blockY) * stride]; for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { const int outX = cfgX + dstX; uint8_t gray = row[dstX - blockX]; - uint8_t dithered; - if (useDithering) { - dithered = applyBayerDither4Level(gray, outX, outY); - } else { - dithered = gray / 85; - if (dithered > 3) dithered = 3; - } + uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, dithered); if (caching) cw.writePixel(outX, dithered); } @@ -215,6 +264,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareDitherRow(*ctx, dstY); pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; @@ -246,13 +296,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT; uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); - uint8_t dithered; - if (useDithering) { - dithered = applyBayerDither4Level(gray, outX, outY); - } else { - dithered = gray / 85; - if (dithered > 3) dithered = 3; - } + uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, dithered); if (caching) cw.writePixel(outX, dithered); } @@ -269,13 +313,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT; uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); - uint8_t dithered; - if (useDithering) { - dithered = applyBayerDither4Level(gray, outX, outY); - } else { - dithered = gray / 85; - if (dithered > 3) dithered = 3; - } + uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, dithered); if (caching) cw.writePixel(outX, dithered); } @@ -295,13 +333,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT; uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT); - uint8_t dithered; - if (useDithering) { - dithered = applyBayerDither4Level(gray, outX, outY); - } else { - dithered = gray / 85; - if (dithered > 3) dithered = 3; - } + uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, dithered); if (caching) cw.writePixel(outX, dithered); } @@ -312,6 +344,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Nearest-neighbor (downscale: fineScale < 1.0) === for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareDitherRow(*ctx, dstY); pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; @@ -328,13 +361,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (lx >= validW) lx = validW - 1; uint8_t gray = row[lx]; - uint8_t dithered; - if (useDithering) { - dithered = applyBayerDither4Level(gray, outX, outY); - } else { - dithered = gray / 85; - if (dithered > 3) dithered = 3; - } + uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, dithered); if (caching) cw.writePixel(outX, dithered); } @@ -479,6 +506,27 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat } } + if (config.useDithering) { + switch (config.ditherMode) { + case ImageDitherMode::Atkinson: + ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(destWidth); + if (!ctx.atkinsonDitherer) { + LOG_ERR("JPG", "Failed to allocate Atkinson ditherer, falling back to Bayer"); + } + break; + case ImageDitherMode::DiffusedBayer: + ctx.diffusedBayerDitherer = new (std::nothrow) DiffusedBayerDitherer(destWidth); + if (!ctx.diffusedBayerDitherer) { + LOG_ERR("JPG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer"); + } + break; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + break; + } + } + unsigned long decodeStart = millis(); rc = jpeg->decode(0, 0, jpegScaleOption); unsigned long decodeTime = millis() - decodeStart; diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index c80cb23a..7ef7826a 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -1,5 +1,6 @@ #include "PngToFramebufferConverter.h" +#include #include #include #include @@ -36,6 +37,9 @@ struct PngContext { bool caching; uint8_t* grayLineBuffer; + int currentDitherRow; + AtkinsonDitherer* atkinsonDitherer; + DiffusedBayerDitherer* diffusedBayerDitherer; PngContext() : renderer(nullptr), @@ -49,9 +53,59 @@ struct PngContext { dstHeight(0), lastDstY(-1), caching(false), - grayLineBuffer(nullptr) {} + grayLineBuffer(nullptr), + currentDitherRow(-1), + atkinsonDitherer(nullptr), + diffusedBayerDitherer(nullptr) {} + + ~PngContext() { + delete atkinsonDitherer; + delete diffusedBayerDitherer; + } }; +void prepareDitherRow(PngContext& ctx, int dstY) { + if (!ctx.config || !ctx.config->useDithering) return; + + if (ctx.currentDitherRow == -1 || dstY < ctx.currentDitherRow) { + if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->reset(); + if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->reset(); + ctx.currentDitherRow = dstY; + return; + } + + while (ctx.currentDitherRow < dstY) { + if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->nextRow(); + if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->nextRow(); + ctx.currentDitherRow++; + } +} + +uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) { + if (!ctx.config || !ctx.config->useDithering) { + return quantizeGray4Level(gray); + } + + switch (ctx.config->ditherMode) { + case ImageDitherMode::Atkinson: + if (ctx.atkinsonDitherer) { + return ctx.atkinsonDitherer->processPixel(gray, localX); + } + break; + case ImageDitherMode::DiffusedBayer: + if (ctx.diffusedBayerDitherer) { + return ctx.diffusedBayerDitherer->processPixel(gray, localX, outX, outY); + } + break; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + break; + } + + return applyBayerDither4Level(gray, outX, outY); +} + // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. void* pngOpenWithHandle(const char* filename, int32_t* size) { @@ -205,7 +259,6 @@ int pngDrawCallback(PNGDRAW* pDraw) { int dstWidth = ctx->dstWidth; int outXBase = ctx->config->x; int screenWidth = ctx->screenWidth; - bool useDithering = ctx->config->useDithering; bool caching = ctx->caching; // Pre-compute orientation and render-mode state once per row @@ -219,6 +272,8 @@ int pngDrawCallback(PNGDRAW* pDraw) { cw.beginRow(outY, ctx->config->y); } + prepareDitherRow(*ctx, dstY); + int srcX = 0; int error = 0; @@ -227,13 +282,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { if (outX < screenWidth) { uint8_t gray = ctx->grayLineBuffer[srcX]; - uint8_t ditheredGray; - if (useDithering) { - ditheredGray = applyBayerDither4Level(gray, outX, outY); - } else { - ditheredGray = gray / 85; - if (ditheredGray > 3) ditheredGray = 3; - } + uint8_t ditheredGray = ditherGray(*ctx, gray, dstX, outX, outY); pw.writePixel(outX, ditheredGray); if (caching) cw.writePixel(outX, ditheredGray); } @@ -385,6 +434,27 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath } } + if (config.useDithering) { + switch (config.ditherMode) { + case ImageDitherMode::Atkinson: + ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(ctx.dstWidth); + if (!ctx.atkinsonDitherer) { + LOG_ERR("PNG", "Failed to allocate Atkinson ditherer, falling back to Bayer"); + } + break; + case ImageDitherMode::DiffusedBayer: + ctx.diffusedBayerDitherer = new (std::nothrow) DiffusedBayerDitherer(ctx.dstWidth); + if (!ctx.diffusedBayerDitherer) { + LOG_ERR("PNG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer"); + } + break; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + break; + } + } + unsigned long decodeStart = millis(); rc = png->decode(&ctx, 0); unsigned long decodeTime = millis() - decodeStart; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 12b6ebf4..e4736df3 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -70,6 +70,10 @@ STR_IMAGES: "Images" STR_IMAGES_DISPLAY: "Display" STR_IMAGES_PLACEHOLDER: "Placeholder" STR_IMAGES_SUPPRESS: "Suppress" +STR_IMAGE_DITHERING: "Image Dithering" +STR_IMAGE_DITHER_BAYER: "Bayer" +STR_IMAGE_DITHER_ATKINSON: "Atkinson" +STR_IMAGE_DITHER_DIFFUSED_BAYER: "Diffused Bayer" STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC" STR_SHORT_PWR_BTN: "Short Power Button Click" STR_ORIENTATION: "Reading Orientation" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index dc698c34..7d1a3484 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -137,6 +137,12 @@ class CrossPointSettings { // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; + enum IMAGE_DITHERING { + IMAGE_DITHER_BAYER = 0, + IMAGE_DITHER_ATKINSON = 1, + IMAGE_DITHER_DIFFUSED_BAYER = 2, + IMAGE_DITHERING_COUNT + }; // Timezone options (POSIX TZ rules for DST support) enum TIMEZONE { @@ -223,6 +229,8 @@ class CrossPointSettings { uint8_t showHiddenFiles = 0; // Image rendering mode in EPUB reader uint8_t imageRendering = IMAGES_DISPLAY; + // Dithering mode for decoded images (EPUB/JPG/PNG) + uint8_t imageDithering = IMAGE_DITHER_BAYER; // Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled) uint8_t syntheticTocFallback = 1; // Show clock in the reader status bar diff --git a/src/SettingsList.h b/src/SettingsList.h index 121b3322..a891b45e 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -69,6 +69,10 @@ inline const std::vector& getSettingsList() { 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::Enum( + StrId::STR_IMAGE_DITHERING, &CrossPointSettings::imageDithering, + {StrId::STR_IMAGE_DITHER_BAYER, StrId::STR_IMAGE_DITHER_ATKINSON, StrId::STR_IMAGE_DITHER_DIFFUSED_BAYER}, + "imageDithering", StrId::STR_CAT_READER), SettingInfo::Toggle(StrId::STR_CREATE_FALLBACK_FOR_INVALID_TOC, &CrossPointSettings::syntheticTocFallback, "syntheticTocFallback", StrId::STR_CAT_READER), // --- Controls --- diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index c7946e06..17627a72 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -18,6 +18,8 @@ namespace { constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp"; +uint8_t normalizeImageDitherModeValue(uint8_t mode) { return static_cast(imageDitherModeFromSetting(mode)); } + bool isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } bool isSupportedImageFile(const std::string& path) { @@ -53,7 +55,13 @@ void computeCenteredImagePlacement(const int imageWidth, const int imageHeight, } // namespace BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) - : Activity("BmpViewer", renderer, mappedInput), filePath(std::move(path)) {} + : Activity("BmpViewer", renderer, mappedInput), + filePath(std::move(path)), + imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)) {} + +bool BmpViewerActivity::renderCurrentImage(const bool showControls) { + return isBmpFile(filePath) ? renderBmpImage(showControls) : renderDecodedImage(showControls); +} void BmpViewerActivity::onEnter() { Activity::onEnter(); @@ -62,7 +70,7 @@ void BmpViewerActivity::onEnter() { return; } - const bool rendered = isBmpFile(filePath) ? renderBmpImage() : renderDecodedImage(); + const bool rendered = renderCurrentImage(); if (!rendered) { renderError("Could not render image"); } @@ -100,7 +108,8 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); if (showControls) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); @@ -139,19 +148,44 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) { config.useExactDimensions = true; config.useGrayscale = true; config.useDithering = true; + config.ditherMode = imageDitherModeFromSetting(imageDitherMode); if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { return false; } if (showControls) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); return true; } +StrId BmpViewerActivity::getCurrentDitherModeLabel() const { + switch (imageDitherModeFromSetting(imageDitherMode)) { + case ImageDitherMode::Atkinson: + return StrId::STR_IMAGE_DITHER_ATKINSON; + case ImageDitherMode::DiffusedBayer: + return StrId::STR_IMAGE_DITHER_DIFFUSED_BAYER; + case ImageDitherMode::Bayer: + case ImageDitherMode::COUNT: + default: + return StrId::STR_IMAGE_DITHER_BAYER; + } +} + +void BmpViewerActivity::cycleDitherMode() { + imageDitherMode = (imageDitherMode + 1) % CrossPointSettings::IMAGE_DITHERING_COUNT; + SETTINGS.imageDithering = imageDitherMode; + SETTINGS.saveToFile(); + + if (!renderCurrentImage()) { + renderError("Could not render image"); + } +} + void BmpViewerActivity::renderError(const char* message) { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); @@ -210,6 +244,11 @@ void BmpViewerActivity::loop() { return; } + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + cycleDitherMode(); + return; + } + // Next/Right button: set this image as the sleep screen if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { setAsSleepScreen(); diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index 65607799..5fc1f692 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include #include @@ -16,8 +19,12 @@ class BmpViewerActivity final : public Activity { private: std::string filePath; + uint8_t imageDitherMode; + bool renderCurrentImage(bool showControls = true); bool renderBmpImage(bool showControls = true); bool renderDecodedImage(bool showControls = true); + void cycleDitherMode(); + StrId getCurrentDitherModeLabel() const; void renderError(const char* message); void setAsSleepScreen(); -}; \ No newline at end of file +}; From 0ba4626157f177b50826d0a3dccfe9b64a05d814 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 13:47:41 +0200 Subject: [PATCH 3/7] Review changes --- src/activities/util/BmpViewerActivity.cpp | 58 ++++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 17627a72..f8bd8a6c 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -17,6 +17,8 @@ namespace { constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp"; +constexpr const char* SLEEP_BMP_TMP_PATH = "/sleep.bmp.tmp"; +constexpr const char* SLEEP_BMP_BACKUP_PATH = "/sleep.bmp.bak"; uint8_t normalizeImageDitherModeValue(uint8_t mode) { return static_cast(imageDitherModeFromSetting(mode)); } @@ -37,20 +39,49 @@ void computeCenteredImagePlacement(const int imageWidth, const int imageHeight, if (ratio > screenRatio) { renderWidth = pageWidth; - renderHeight = std::round(static_cast(pageWidth) / ratio); - x = 0; - y = std::round((static_cast(pageHeight) - renderHeight) / 2.0f); + renderHeight = std::max(1, static_cast(std::round(static_cast(pageWidth) / ratio))); } else { renderHeight = pageHeight; - renderWidth = std::round(static_cast(pageHeight) * ratio); - x = std::round((static_cast(pageWidth) - renderWidth) / 2.0f); - y = 0; + renderWidth = std::max(1, static_cast(std::round(static_cast(pageHeight) * ratio))); } + + x = std::max(0, (pageWidth - renderWidth) / 2); + y = std::max(0, (pageHeight - renderHeight) / 2); return; } - x = (pageWidth - imageWidth) / 2; - y = (pageHeight - imageHeight) / 2; + renderWidth = std::max(1, renderWidth); + renderHeight = std::max(1, renderHeight); + x = std::max(0, (pageWidth - renderWidth) / 2); + y = std::max(0, (pageHeight - renderHeight) / 2); +} + +bool replaceSleepBmpFromTemp() { + const bool hadExistingTarget = Storage.exists(SLEEP_BMP_PATH); + bool movedExistingToBackup = false; + + if (Storage.exists(SLEEP_BMP_BACKUP_PATH)) { + Storage.remove(SLEEP_BMP_BACKUP_PATH); + } + + if (hadExistingTarget) { + movedExistingToBackup = Storage.rename(SLEEP_BMP_PATH, SLEEP_BMP_BACKUP_PATH); + if (!movedExistingToBackup) { + return false; + } + } + + if (Storage.rename(SLEEP_BMP_TMP_PATH, SLEEP_BMP_PATH)) { + if (movedExistingToBackup) { + Storage.remove(SLEEP_BMP_BACKUP_PATH); + } + return true; + } + + if (movedExistingToBackup) { + Storage.rename(SLEEP_BMP_BACKUP_PATH, SLEEP_BMP_PATH); + } + return false; } } // namespace @@ -203,12 +234,15 @@ void BmpViewerActivity::setAsSleepScreen() { } else { const bool renderedForCapture = isBmpFile(filePath) ? renderBmpImage(false) : renderDecodedImage(false); if (renderedForCapture) { - success = ScreenshotUtil::saveFramebufferAsBmp(SLEEP_BMP_PATH, renderer.getFrameBuffer(), - display.getDisplayWidth(), display.getDisplayHeight()); + Storage.remove(SLEEP_BMP_TMP_PATH); + if (ScreenshotUtil::saveFramebufferAsBmp(SLEEP_BMP_TMP_PATH, renderer.getFrameBuffer(), display.getDisplayWidth(), + display.getDisplayHeight())) { + success = replaceSleepBmpFromTemp(); + } } - if (!success && Storage.exists(SLEEP_BMP_PATH)) { - Storage.remove(SLEEP_BMP_PATH); + if (!success && Storage.exists(SLEEP_BMP_TMP_PATH)) { + Storage.remove(SLEEP_BMP_TMP_PATH); } } From 4f031b00b16f43a81ff1b7feb159e225e55e6ed2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 17:23:29 +0200 Subject: [PATCH 4/7] (Conditionally) add further dithering algorithms --- lib/Epub/Epub/converters/DitherUtils.h | 2 ++ .../converters/ImageToFramebufferDecoder.h | 9 ++++++ .../converters/JpegToFramebufferConverter.cpp | 31 +++++++++++++++++-- .../converters/PngToFramebufferConverter.cpp | 27 ++++++++++++++-- src/CrossPointSettings.h | 4 +++ src/SettingsList.h | 2 ++ src/activities/util/BmpViewerActivity.cpp | 29 +++++++++++++++-- src/activities/util/BmpViewerActivity.h | 4 +++ 8 files changed, 102 insertions(+), 6 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index 8107f2eb..19de605c 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -32,6 +32,7 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { return quantizeGray4Level((uint8_t)adjusted); } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION class DiffusedBayerDitherer { public: explicit DiffusedBayerDitherer(int width) : width(width) { @@ -85,3 +86,4 @@ class DiffusedBayerDitherer { int16_t* errorCurRow; int16_t* errorNextRow; }; +#endif diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h index 0bdc4a86..c938785d 100644 --- a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h @@ -13,12 +13,15 @@ struct ImageDimensions { enum class ImageDitherMode : uint8_t { Bayer = 0, +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION Atkinson = 1, DiffusedBayer = 2, +#endif COUNT, }; inline ImageDitherMode imageDitherModeFromSetting(uint8_t value) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION switch (static_cast(value)) { case ImageDitherMode::Bayer: case ImageDitherMode::Atkinson: @@ -28,14 +31,20 @@ inline ImageDitherMode imageDitherModeFromSetting(uint8_t value) { default: return ImageDitherMode::Bayer; } +#else + (void)value; + return ImageDitherMode::Bayer; +#endif } inline const char* getImageDitherCacheSuffix(ImageDitherMode mode) { switch (mode) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION case ImageDitherMode::Atkinson: return ".atkinson"; case ImageDitherMode::DiffusedBayer: return ".diffused-bayer"; +#endif case ImageDitherMode::Bayer: case ImageDitherMode::COUNT: default: diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index f76613a6..e8394f84 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -1,6 +1,8 @@ #include "JpegToFramebufferConverter.h" +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION #include +#endif #include #include #include @@ -40,9 +42,11 @@ struct JpegContext { PixelCache cache; bool caching; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION int currentDitherRow; AtkinsonDitherer* atkinsonDitherer; DiffusedBayerDitherer* diffusedBayerDitherer; +#endif JpegContext() : renderer(nullptr), @@ -55,17 +59,25 @@ struct JpegContext { dstHeight(0), fineScaleFP(1 << 16), invScaleFP(1 << 16), - caching(false), + caching(false) +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION + , currentDitherRow(-1), atkinsonDitherer(nullptr), - diffusedBayerDitherer(nullptr) {} + diffusedBayerDitherer(nullptr) +#endif + { + } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION ~JpegContext() { delete atkinsonDitherer; delete diffusedBayerDitherer; } +#endif }; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION void prepareDitherRow(JpegContext& ctx, int dstY) { if (!ctx.config || !ctx.config->useDithering) return; @@ -107,6 +119,13 @@ uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int out return applyBayerDither4Level(gray, outX, outY); } +#else +uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) { + (void)ctx; + (void)localX; + return applyBayerDither4Level(gray, outX, outY); +} +#endif // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. @@ -235,7 +254,9 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (fineScaleFP == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); +#endif pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const uint8_t* row = &pixels[(dstY - blockY) * stride]; @@ -264,7 +285,9 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); +#endif pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; @@ -344,7 +367,9 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Nearest-neighbor (downscale: fineScale < 1.0) === for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); +#endif pw.beginRow(outY); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; @@ -507,6 +532,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat } if (config.useDithering) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION switch (config.ditherMode) { case ImageDitherMode::Atkinson: ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(destWidth); @@ -525,6 +551,7 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat default: break; } +#endif } unsigned long decodeStart = millis(); diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 7ef7826a..e9aa0aad 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -1,6 +1,8 @@ #include "PngToFramebufferConverter.h" +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION #include +#endif #include #include #include @@ -37,9 +39,11 @@ struct PngContext { bool caching; uint8_t* grayLineBuffer; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION int currentDitherRow; AtkinsonDitherer* atkinsonDitherer; DiffusedBayerDitherer* diffusedBayerDitherer; +#endif PngContext() : renderer(nullptr), @@ -53,17 +57,25 @@ struct PngContext { dstHeight(0), lastDstY(-1), caching(false), - grayLineBuffer(nullptr), + grayLineBuffer(nullptr) +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION + , currentDitherRow(-1), atkinsonDitherer(nullptr), - diffusedBayerDitherer(nullptr) {} + diffusedBayerDitherer(nullptr) +#endif + { + } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION ~PngContext() { delete atkinsonDitherer; delete diffusedBayerDitherer; } +#endif }; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION void prepareDitherRow(PngContext& ctx, int dstY) { if (!ctx.config || !ctx.config->useDithering) return; @@ -105,6 +117,13 @@ uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY return applyBayerDither4Level(gray, outX, outY); } +#else +uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) { + (void)ctx; + (void)localX; + return applyBayerDither4Level(gray, outX, outY); +} +#endif // File I/O callbacks use pFile->fHandle to access the FsFile*, // avoiding the need for global file state. @@ -272,7 +291,9 @@ int pngDrawCallback(PNGDRAW* pDraw) { cw.beginRow(outY, ctx->config->y); } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); +#endif int srcX = 0; int error = 0; @@ -435,6 +456,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath } if (config.useDithering) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION switch (config.ditherMode) { case ImageDitherMode::Atkinson: ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(ctx.dstWidth); @@ -453,6 +475,7 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath default: break; } +#endif } unsigned long decodeStart = millis(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 7d1a3484..7c6ea750 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -137,12 +137,16 @@ class CrossPointSettings { // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION enum IMAGE_DITHERING { IMAGE_DITHER_BAYER = 0, IMAGE_DITHER_ATKINSON = 1, IMAGE_DITHER_DIFFUSED_BAYER = 2, IMAGE_DITHERING_COUNT }; +#else + enum IMAGE_DITHERING { IMAGE_DITHER_BAYER = 0, IMAGE_DITHERING_COUNT }; +#endif // Timezone options (POSIX TZ rules for DST support) enum TIMEZONE { diff --git a/src/SettingsList.h b/src/SettingsList.h index a891b45e..8bfa98e3 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -69,10 +69,12 @@ inline const std::vector& getSettingsList() { SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER), +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION SettingInfo::Enum( StrId::STR_IMAGE_DITHERING, &CrossPointSettings::imageDithering, {StrId::STR_IMAGE_DITHER_BAYER, StrId::STR_IMAGE_DITHER_ATKINSON, StrId::STR_IMAGE_DITHER_DIFFUSED_BAYER}, "imageDithering", StrId::STR_CAT_READER), +#endif SettingInfo::Toggle(StrId::STR_CREATE_FALLBACK_FOR_INVALID_TOC, &CrossPointSettings::syntheticTocFallback, "syntheticTocFallback", StrId::STR_CAT_READER), // --- Controls --- diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index f8bd8a6c..b0126c24 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -20,7 +20,9 @@ constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp"; constexpr const char* SLEEP_BMP_TMP_PATH = "/sleep.bmp.tmp"; constexpr const char* SLEEP_BMP_BACKUP_PATH = "/sleep.bmp.bak"; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION uint8_t normalizeImageDitherModeValue(uint8_t mode) { return static_cast(imageDitherModeFromSetting(mode)); } +#endif bool isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } @@ -87,8 +89,15 @@ bool replaceSleepBmpFromTemp() { BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) : Activity("BmpViewer", renderer, mappedInput), - filePath(std::move(path)), - imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)) {} + filePath(std::move(path)) +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION + , + imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)) { +} +#else +{ +} +#endif bool BmpViewerActivity::renderCurrentImage(const bool showControls) { return isBmpFile(filePath) ? renderBmpImage(showControls) : renderDecodedImage(showControls); @@ -139,8 +148,12 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); if (showControls) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); +#else + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); +#endif GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); @@ -179,21 +192,30 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) { config.useExactDimensions = true; config.useGrayscale = true; config.useDithering = true; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION config.ditherMode = imageDitherModeFromSetting(imageDitherMode); +#else + config.ditherMode = ImageDitherMode::Bayer; +#endif if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { return false; } if (showControls) { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); +#else + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); +#endif GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); return true; } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION StrId BmpViewerActivity::getCurrentDitherModeLabel() const { switch (imageDitherModeFromSetting(imageDitherMode)) { case ImageDitherMode::Atkinson: @@ -216,6 +238,7 @@ void BmpViewerActivity::cycleDitherMode() { renderError("Could not render image"); } } +#endif void BmpViewerActivity::renderError(const char* message) { const auto pageHeight = renderer.getScreenHeight(); @@ -278,10 +301,12 @@ void BmpViewerActivity::loop() { return; } +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { cycleDitherMode(); return; } +#endif // Next/Right button: set this image as the sleep screen if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index 5fc1f692..4e88166a 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -19,12 +19,16 @@ class BmpViewerActivity final : public Activity { private: std::string filePath; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION uint8_t imageDitherMode; +#endif bool renderCurrentImage(bool showControls = true); bool renderBmpImage(bool showControls = true); bool renderDecodedImage(bool showControls = true); +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION void cycleDitherMode(); StrId getCurrentDitherModeLabel() const; +#endif void renderError(const char* message); void setAsSleepScreen(); }; From 7e37f3252c4130e23b294c2210a427d98f0754ca Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 18:03:50 +0200 Subject: [PATCH 5/7] Review comments --- lib/Epub/Epub/converters/DitherUtils.h | 7 +----- src/activities/util/BmpViewerActivity.cpp | 27 ++++++++++++++++------- src/activities/util/BmpViewerActivity.h | 3 +++ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index 19de605c..ef3889e9 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -12,12 +12,7 @@ inline const uint8_t bayer4x4[4][4] = { {15, 7, 13, 5}, }; -inline uint8_t quantizeGray4Level(uint8_t gray) { - if (gray < 64) return 0; - if (gray < 128) return 1; - if (gray < 192) return 2; - return 3; -} +inline uint8_t quantizeGray4Level(uint8_t gray) { return gray >= 213 ? 3 : static_cast((gray + 42) / 85); } // Apply Bayer dithering and quantize to 4 levels (0-3) // Stateless - works correctly with any pixel processing order diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index b0126c24..416922a1 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -92,7 +92,9 @@ BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& filePath(std::move(path)) #ifdef ENABLE_IMAGE_DITHERING_EXTENSION , - imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)) { + imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)), + initialImageDitherMode(imageDitherMode), + imageDitherSettingsDirty(false) { } #else { @@ -117,6 +119,9 @@ void BmpViewerActivity::onEnter() { } void BmpViewerActivity::onExit() { +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION + saveDitherSettingsIfNeeded(); +#endif Activity::onExit(); renderer.clearScreen(); renderer.displayBuffer(HalDisplay::FULL_REFRESH); @@ -148,12 +153,7 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); if (showControls) { -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION - const auto labels = - mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); -#else const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); -#endif GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(HalDisplay::HALF_REFRESH); @@ -232,12 +232,23 @@ StrId BmpViewerActivity::getCurrentDitherModeLabel() const { void BmpViewerActivity::cycleDitherMode() { imageDitherMode = (imageDitherMode + 1) % CrossPointSettings::IMAGE_DITHERING_COUNT; SETTINGS.imageDithering = imageDitherMode; - SETTINGS.saveToFile(); + imageDitherSettingsDirty = (imageDitherMode != initialImageDitherMode); if (!renderCurrentImage()) { renderError("Could not render image"); } } + +void BmpViewerActivity::saveDitherSettingsIfNeeded() { + if (!imageDitherSettingsDirty) { + return; + } + + SETTINGS.imageDithering = imageDitherMode; + SETTINGS.saveToFile(); + initialImageDitherMode = imageDitherMode; + imageDitherSettingsDirty = false; +} #endif void BmpViewerActivity::renderError(const char* message) { @@ -302,7 +313,7 @@ void BmpViewerActivity::loop() { } #ifdef ENABLE_IMAGE_DITHERING_EXTENSION - if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (!isBmpFile(filePath) && mappedInput.wasReleased(MappedInputManager::Button::Left)) { cycleDitherMode(); return; } diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index 4e88166a..076835a6 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -21,6 +21,8 @@ class BmpViewerActivity final : public Activity { std::string filePath; #ifdef ENABLE_IMAGE_DITHERING_EXTENSION uint8_t imageDitherMode; + uint8_t initialImageDitherMode; + bool imageDitherSettingsDirty; #endif bool renderCurrentImage(bool showControls = true); bool renderBmpImage(bool showControls = true); @@ -28,6 +30,7 @@ class BmpViewerActivity final : public Activity { #ifdef ENABLE_IMAGE_DITHERING_EXTENSION void cycleDitherMode(); StrId getCurrentDitherModeLabel() const; + void saveDitherSettingsIfNeeded(); #endif void renderError(const char* message); void setAsSleepScreen(); From 38420033eca6911446d3f939a5336889bebe73de Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 18:16:51 +0200 Subject: [PATCH 6/7] Enable dithering modes by default --- platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/platformio.ini b/platformio.ini index cfba6b82..7c2de89a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,6 +25,7 @@ build_flags = -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 -DDISABLE_FS_H_WARNING=1 -DDESTRUCTOR_CLOSES_FILE=1 + -DENABLE_IMAGE_DITHERING_EXTENSION # https://libexpat.github.io/doc/api/latest/#XML_GE -DXML_GE=0 -DXML_CONTEXT_BYTES=1024 From 7d818c4915f7ca376b8c7fdbf831691b0cbb6fc4 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 6 Apr 2026 19:01:23 +0200 Subject: [PATCH 7/7] Update languages --- lib/I18n/translations/english.yaml | 3 +++ lib/I18n/translations/german.yaml | 9 +++++++++ src/activities/util/BmpViewerActivity.cpp | 10 +++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e4736df3..77fc741d 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -446,6 +446,9 @@ STR_UPTIME: "Uptime" STR_CHARGING: "Charging" STR_GATHERING_DATA: "Gathering data..." STR_READING: "Reading..." +STR_UNSUPPORTED_IMAGE_FORMAT: "Unsupported image format" +STR_COULD_NOT_RENDER_IMAGE: "Could not render image" +STR_FAILED_TO_SET_SLEEP_SCREEN: "Failed to set sleep screen" STR_SET_SLEEP_SCREEN: "Set Sleep" STR_SLEEP_SCREEN_SET: "Sleep screen updated!" STR_WEATHER_MOON_INFO: "Moon" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 1491606c..f70f56f6 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -66,6 +66,10 @@ STR_SLEEP_COVER_MODE: "Standby-Bildmodus" STR_HIDE_BATTERY: "Batterie % ausblenden" STR_EXTRA_SPACING: "Absatzabstand" STR_TEXT_AA: "Schriftglättung" +STR_IMAGE_DITHERING: "Bild-Dithering" +STR_IMAGE_DITHER_BAYER: "Bayer" +STR_IMAGE_DITHER_ATKINSON: "Atkinson" +STR_IMAGE_DITHER_DIFFUSED_BAYER: "Diffundiertes Bayer" STR_SHORT_PWR_BTN: "An-Taste kurz drücken" STR_ORIENTATION: "Leseausrichtung" STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)" @@ -320,6 +324,11 @@ STR_UPTIME: "Laufzeit" STR_CHARGING: "Lädt" STR_GATHERING_DATA: "Daten werden gesammelt..." STR_READING: "Lese..." +STR_UNSUPPORTED_IMAGE_FORMAT: "Nicht unterstütztes Bildformat" +STR_COULD_NOT_RENDER_IMAGE: "Bild konnte nicht dargestellt werden" +STR_FAILED_TO_SET_SLEEP_SCREEN: "Standby-Bild konnte nicht gesetzt werden" +STR_SET_SLEEP_SCREEN: "Standby-Bild setzen" +STR_SLEEP_SCREEN_SET: "Standby-Bild aktualisiert!" STR_WEATHER: "Wetter" STR_WEATHER_LOCATION: "Ort" diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 416922a1..31eaa758 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -108,13 +108,13 @@ bool BmpViewerActivity::renderCurrentImage(const bool showControls) { void BmpViewerActivity::onEnter() { Activity::onEnter(); if (!isSupportedImageFile(filePath)) { - renderError("Unsupported image format"); + renderError(tr(STR_UNSUPPORTED_IMAGE_FORMAT)); return; } const bool rendered = renderCurrentImage(); if (!rendered) { - renderError("Could not render image"); + renderError(tr(STR_COULD_NOT_RENDER_IMAGE)); } } @@ -235,7 +235,7 @@ void BmpViewerActivity::cycleDitherMode() { imageDitherSettingsDirty = (imageDitherMode != initialImageDitherMode); if (!renderCurrentImage()) { - renderError("Could not render image"); + renderError(tr(STR_COULD_NOT_RENDER_IMAGE)); } } @@ -266,7 +266,7 @@ void BmpViewerActivity::setAsSleepScreen() { if (FsHelpers::hasBmpExtension(filePath)) { success = (filePath == SLEEP_BMP_PATH) ? true : Storage.copyFile("BMP", filePath, SLEEP_BMP_PATH); } else { - const bool renderedForCapture = isBmpFile(filePath) ? renderBmpImage(false) : renderDecodedImage(false); + const bool renderedForCapture = renderDecodedImage(false); if (renderedForCapture) { Storage.remove(SLEEP_BMP_TMP_PATH); if (ScreenshotUtil::saveFramebufferAsBmp(SLEEP_BMP_TMP_PATH, renderer.getFrameBuffer(), display.getDisplayWidth(), @@ -282,7 +282,7 @@ void BmpViewerActivity::setAsSleepScreen() { if (!success) { LOG_ERR("BMP", "Failed to set %s as sleep screen", filePath.c_str()); - GUI.drawPopup(renderer, "Failed to set sleep screen"); + GUI.drawPopup(renderer, tr(STR_FAILED_TO_SET_SLEEP_SCREEN)); renderer.displayBuffer(HalDisplay::HALF_REFRESH); return; }