Merge pull request #39 from jpirnay/fix-darkpng
fix: properly support pngs for sleep images
This commit is contained in:
@@ -60,7 +60,11 @@ struct RenderConfig {
|
||||
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
|
||||
// If true, the decoder uses a 1-bit Atkinson dither and emits only the values 0/3 — suitable for
|
||||
// pure black-and-white display (no grayscale planes). The 4-level dither path produces values 1/2
|
||||
// which the BW DirectPixelWriter collapses to black, making mid-grays render very dark.
|
||||
bool monochromeOutput = false;
|
||||
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||
};
|
||||
|
||||
class ImageToFramebufferDecoder {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#include "JpegToFramebufferConverter.h"
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
#include <BitmapHelpers.h>
|
||||
#endif
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
@@ -42,6 +40,11 @@ struct JpegContext {
|
||||
PixelCache cache;
|
||||
bool caching;
|
||||
|
||||
// See PngContext for the rationale: monochromeOutput requests a 1-bit Atkinson dither
|
||||
// emitting only 0/3 so the BW DirectPixelWriter (`pixelValue < 3` rule) maps cleanly.
|
||||
int oneBitDitherRow;
|
||||
Atkinson1BitDitherer* atkinson1BitDitherer;
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
int currentDitherRow;
|
||||
AtkinsonDitherer* atkinsonDitherer;
|
||||
@@ -59,7 +62,9 @@ struct JpegContext {
|
||||
dstHeight(0),
|
||||
fineScaleFP(1 << 16),
|
||||
invScaleFP(1 << 16),
|
||||
caching(false)
|
||||
caching(false),
|
||||
oneBitDitherRow(-1),
|
||||
atkinson1BitDitherer(nullptr)
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
,
|
||||
currentDitherRow(-1),
|
||||
@@ -69,14 +74,32 @@ struct JpegContext {
|
||||
{
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
~JpegContext() {
|
||||
delete atkinson1BitDitherer;
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
delete atkinsonDitherer;
|
||||
delete diffusedBayerDitherer;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
// Advance the 1-bit Atkinson ditherer to the requested destination row.
|
||||
// Handles non-monotonic row walks (block-based JPEG decode) by reset+replay.
|
||||
void prepareOneBitDitherRow(JpegContext& ctx, int dstY) {
|
||||
if (!ctx.atkinson1BitDitherer) return;
|
||||
|
||||
if (ctx.oneBitDitherRow == -1 || dstY < ctx.oneBitDitherRow) {
|
||||
ctx.atkinson1BitDitherer->reset();
|
||||
ctx.oneBitDitherRow = dstY;
|
||||
return;
|
||||
}
|
||||
|
||||
while (ctx.oneBitDitherRow < dstY) {
|
||||
ctx.atkinson1BitDitherer->nextRow();
|
||||
ctx.oneBitDitherRow++;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
void prepareDitherRow(JpegContext& ctx, int dstY) {
|
||||
if (!ctx.config || !ctx.config->useDithering) return;
|
||||
@@ -96,6 +119,10 @@ void prepareDitherRow(JpegContext& ctx, int dstY) {
|
||||
}
|
||||
|
||||
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||
if (ctx.atkinson1BitDitherer) {
|
||||
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
||||
}
|
||||
|
||||
if (!ctx.config || !ctx.config->useDithering) {
|
||||
return quantizeGray4Level(gray);
|
||||
}
|
||||
@@ -121,7 +148,9 @@ uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int out
|
||||
}
|
||||
#else
|
||||
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||
(void)ctx;
|
||||
if (ctx.atkinson1BitDitherer) {
|
||||
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
||||
}
|
||||
(void)localX;
|
||||
return applyBayerDither4Level(gray, outX, outY);
|
||||
}
|
||||
@@ -254,6 +283,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
if (fineScaleFP == FP_ONE) {
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
prepareOneBitDitherRow(*ctx, dstY);
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
prepareDitherRow(*ctx, dstY);
|
||||
#endif
|
||||
@@ -285,6 +315,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
prepareOneBitDitherRow(*ctx, dstY);
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
prepareDitherRow(*ctx, dstY);
|
||||
#endif
|
||||
@@ -367,6 +398,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
||||
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
|
||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||
const int outY = cfgY + dstY;
|
||||
prepareOneBitDitherRow(*ctx, dstY);
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
prepareDitherRow(*ctx, dstY);
|
||||
#endif
|
||||
@@ -531,7 +563,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
||||
}
|
||||
}
|
||||
|
||||
if (config.useDithering) {
|
||||
// See PngToFramebufferConverter for rationale: BW-only display needs a 1-bit
|
||||
// dither so mid-grays don't collapse to black under DirectPixelWriter's `< 3` rule.
|
||||
if (config.monochromeOutput) {
|
||||
ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(destWidth);
|
||||
if (!ctx.atkinson1BitDitherer) {
|
||||
LOG_ERR("JPG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither");
|
||||
}
|
||||
}
|
||||
|
||||
if (config.useDithering && !ctx.atkinson1BitDitherer) {
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
switch (config.ditherMode) {
|
||||
case ImageDitherMode::Atkinson:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#include "PngToFramebufferConverter.h"
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
#include <BitmapHelpers.h>
|
||||
#endif
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
@@ -39,6 +37,15 @@ struct PngContext {
|
||||
bool caching;
|
||||
|
||||
uint8_t* grayLineBuffer;
|
||||
|
||||
// When the caller requests monochrome output (RenderConfig::monochromeOutput),
|
||||
// we run a proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path)
|
||||
// and emit only values 0 or 3, which round-trip cleanly through the BW writer's
|
||||
// `pixelValue < 3` rule. The 4-level dither path collapses mid-grays to solid
|
||||
// black under that rule.
|
||||
int oneBitDitherRow;
|
||||
Atkinson1BitDitherer* atkinson1BitDitherer;
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
int currentDitherRow;
|
||||
AtkinsonDitherer* atkinsonDitherer;
|
||||
@@ -57,7 +64,9 @@ struct PngContext {
|
||||
dstHeight(0),
|
||||
lastDstY(-1),
|
||||
caching(false),
|
||||
grayLineBuffer(nullptr)
|
||||
grayLineBuffer(nullptr),
|
||||
oneBitDitherRow(-1),
|
||||
atkinson1BitDitherer(nullptr)
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
,
|
||||
currentDitherRow(-1),
|
||||
@@ -67,14 +76,33 @@ struct PngContext {
|
||||
{
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
~PngContext() {
|
||||
delete atkinson1BitDitherer;
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
delete atkinsonDitherer;
|
||||
delete diffusedBayerDitherer;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
// Advance the 1-bit Atkinson ditherer to the requested destination row.
|
||||
// Like the 4-level path below, this handles re-decode passes that walk source
|
||||
// rows non-monotonically by resetting and replaying when needed.
|
||||
void prepareOneBitDitherRow(PngContext& ctx, int dstY) {
|
||||
if (!ctx.atkinson1BitDitherer) return;
|
||||
|
||||
if (ctx.oneBitDitherRow == -1 || dstY < ctx.oneBitDitherRow) {
|
||||
ctx.atkinson1BitDitherer->reset();
|
||||
ctx.oneBitDitherRow = dstY;
|
||||
return;
|
||||
}
|
||||
|
||||
while (ctx.oneBitDitherRow < dstY) {
|
||||
ctx.atkinson1BitDitherer->nextRow();
|
||||
ctx.oneBitDitherRow++;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
void prepareDitherRow(PngContext& ctx, int dstY) {
|
||||
if (!ctx.config || !ctx.config->useDithering) return;
|
||||
@@ -94,6 +122,12 @@ void prepareDitherRow(PngContext& ctx, int dstY) {
|
||||
}
|
||||
|
||||
uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||
// BW mode: route through 1-bit Atkinson and emit only 0/3 so the
|
||||
// DirectPixelWriter's `pixelValue < 3` rule maps cleanly to black/white.
|
||||
if (ctx.atkinson1BitDitherer) {
|
||||
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
||||
}
|
||||
|
||||
if (!ctx.config || !ctx.config->useDithering) {
|
||||
return quantizeGray4Level(gray);
|
||||
}
|
||||
@@ -119,7 +153,9 @@ uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY
|
||||
}
|
||||
#else
|
||||
uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||
(void)ctx;
|
||||
if (ctx.atkinson1BitDitherer) {
|
||||
return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0;
|
||||
}
|
||||
(void)localX;
|
||||
return applyBayerDither4Level(gray, outX, outY);
|
||||
}
|
||||
@@ -291,6 +327,7 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
cw.beginRow(outY, ctx->config->y);
|
||||
}
|
||||
|
||||
prepareOneBitDitherRow(*ctx, dstY);
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
prepareDitherRow(*ctx, dstY);
|
||||
#endif
|
||||
@@ -455,7 +492,20 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
||||
}
|
||||
}
|
||||
|
||||
if (config.useDithering) {
|
||||
// When the caller explicitly requests monochrome output, use a 1-bit Atkinson
|
||||
// ditherer instead of the 4-level paths below. The 4-level dither produces
|
||||
// values 1-2 for mid grays, which DirectPixelWriter then collapses to black
|
||||
// under its `< 3` BW rule, making images render very dark in BW-only mode.
|
||||
// The 1-bit ditherer emits only 0 or 3 so the BW writer maps cleanly to
|
||||
// black/white.
|
||||
if (config.monochromeOutput) {
|
||||
ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(ctx.dstWidth);
|
||||
if (!ctx.atkinson1BitDitherer) {
|
||||
LOG_ERR("PNG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither");
|
||||
}
|
||||
}
|
||||
|
||||
if (config.useDithering && !ctx.atkinson1BitDitherer) {
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
switch (config.ditherMode) {
|
||||
case ImageDitherMode::Atkinson:
|
||||
|
||||
@@ -458,5 +458,7 @@ 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_IMAGE_DISPLAY_BW: ">> B&W"
|
||||
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray"
|
||||
STR_WEATHER_MOON_INFO: "Moon"
|
||||
STR_WEATHER_SUN_INFO: "Sun"
|
||||
@@ -336,6 +336,8 @@ 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_IMAGE_DISPLAY_BW: ">> S/W"
|
||||
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Grau"
|
||||
|
||||
STR_WEATHER: "Wetter"
|
||||
STR_WEATHER_LOCATION: "Ort"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "SleepActivity.h"
|
||||
|
||||
#include <Epub.h>
|
||||
#include <Epub/converters/PngToFramebufferConverter.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
@@ -9,6 +10,7 @@
|
||||
#include <Serialization.h>
|
||||
#include <Txt.h>
|
||||
#include <Xtc.h>
|
||||
#include <esp_system.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <new>
|
||||
@@ -70,6 +72,127 @@ int32_t pngSleepSeek(PNGFILE* pFile, int32_t pos) {
|
||||
return f->seek(pos);
|
||||
}
|
||||
|
||||
bool renderPngSleepScreen(const std::string& filename, GfxRenderer& renderer, const BookOverlayInfo& overlayInfo) {
|
||||
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 sleep image: %s", filename.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
RenderConfig config;
|
||||
config.x = 0;
|
||||
config.y = 0;
|
||||
config.maxWidth = pageWidth;
|
||||
config.maxHeight = pageHeight;
|
||||
config.useGrayscale = true;
|
||||
config.useDithering = true;
|
||||
config.ditherMode = ImageDitherMode::Bayer;
|
||||
config.performanceMode = false;
|
||||
config.useExactDimensions = false;
|
||||
|
||||
// Overlay drawing is shared across all three rendering passes (BW + LSB + MSB) so the
|
||||
// text appears on every plane. Captured by reference so the lambda sees the renderer.
|
||||
const auto drawOverlay = [&]() {
|
||||
if (overlayInfo.progressText.empty()) {
|
||||
return;
|
||||
}
|
||||
const int lineHeight12 = renderer.getLineHeight(BOOKERLY_12_FONT_ID);
|
||||
const int lineHeight10 = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
constexpr int lineSpacing = 3;
|
||||
constexpr int sectionSpacing = 10;
|
||||
const int maxTextWidth = pageWidth - 20;
|
||||
|
||||
int textBlockHeight = 0;
|
||||
if (!overlayInfo.title.empty()) {
|
||||
textBlockHeight += lineHeight12;
|
||||
if (!overlayInfo.author.empty()) {
|
||||
textBlockHeight += lineSpacing;
|
||||
} else if (!overlayInfo.progressText.empty()) {
|
||||
textBlockHeight += sectionSpacing;
|
||||
}
|
||||
}
|
||||
if (!overlayInfo.author.empty()) {
|
||||
textBlockHeight += lineHeight10;
|
||||
if (!overlayInfo.progressText.empty()) {
|
||||
textBlockHeight += sectionSpacing;
|
||||
}
|
||||
}
|
||||
if (!overlayInfo.progressText.empty()) {
|
||||
textBlockHeight += lineHeight10;
|
||||
}
|
||||
|
||||
const int overlayY = pageHeight - textBlockHeight - (lineHeight12 / 3) - (lineHeight10 * 2 / 3);
|
||||
int y = overlayY + (lineHeight12 / 3);
|
||||
if (!overlayInfo.title.empty()) {
|
||||
const std::string title = renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), maxTextWidth);
|
||||
renderer.drawText(BOOKERLY_12_FONT_ID, 10, y, title.c_str(), true);
|
||||
y += lineHeight12;
|
||||
if (!overlayInfo.author.empty()) {
|
||||
y += lineSpacing;
|
||||
} else if (!overlayInfo.progressText.empty()) {
|
||||
y += sectionSpacing;
|
||||
}
|
||||
}
|
||||
if (!overlayInfo.author.empty()) {
|
||||
const std::string author = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.author.c_str(), maxTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, 10, y, author.c_str(), true);
|
||||
y += lineHeight10;
|
||||
if (!overlayInfo.progressText.empty()) {
|
||||
y += sectionSpacing;
|
||||
}
|
||||
}
|
||||
if (!overlayInfo.progressText.empty()) {
|
||||
const std::string progress =
|
||||
renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), maxTextWidth);
|
||||
renderer.drawText(UI_10_FONT_ID, 10, y, progress.c_str(), true);
|
||||
}
|
||||
};
|
||||
|
||||
PngToFramebufferConverter decoder;
|
||||
|
||||
// Pass 1: BW plane — mirrors SleepActivity::renderBitmapSleepScreen so the BW carrier
|
||||
// matches the 4-level quantization layered on top via the LSB/MSB planes.
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.clearScreen();
|
||||
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
||||
LOG_DBG("SLP", "PNG sleep image decode failed: %s", filename.c_str());
|
||||
return false;
|
||||
}
|
||||
drawOverlay();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
// Pass 2: GRAYSCALE_LSB plane.
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
||||
LOG_DBG("SLP", "PNG sleep image LSB decode failed: %s", filename.c_str());
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return false;
|
||||
}
|
||||
drawOverlay();
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
// Pass 3: GRAYSCALE_MSB plane.
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (!decoder.decodeToFramebuffer(filename, renderer, config)) {
|
||||
LOG_DBG("SLP", "PNG sleep image MSB decode failed: %s", filename.c_str());
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return false;
|
||||
}
|
||||
drawOverlay();
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -216,9 +339,9 @@ size_t pickSleepImageIndex(size_t numFiles) {
|
||||
if (last == SIZE_MAX || last >= numFiles) return 0;
|
||||
return (last + 1) % numFiles;
|
||||
}
|
||||
size_t idx = random(numFiles);
|
||||
size_t idx = static_cast<size_t>(esp_random() % numFiles);
|
||||
while (numFiles > 1 && APP_STATE.lastSleepImage != SIZE_MAX && idx == APP_STATE.lastSleepImage) {
|
||||
idx = random(numFiles);
|
||||
idx = static_cast<size_t>(esp_random() % numFiles);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
@@ -266,28 +389,43 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
}
|
||||
explicitSleepFile.close();
|
||||
}
|
||||
if (Storage.openFileForRead("SLP", "/sleep.png", explicitSleepFile)) {
|
||||
explicitSleepFile.close();
|
||||
const BookOverlayInfo resolvedOverlayInfo =
|
||||
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
||||
LOG_DBG("SLP", "Loading explicit custom sleep image: /sleep.png");
|
||||
if (renderPngSleepScreen("/sleep.png", renderer, resolvedOverlayInfo)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect valid BMP files from both /.sleep and /sleep directories (no preference between them)
|
||||
const auto files = collectSleepImages(/*allowPng=*/false);
|
||||
// Collect valid BMP and PNG files from both /.sleep and /sleep directories (no preference between them)
|
||||
const auto files = collectSleepImages(/*allowPng=*/true);
|
||||
const auto numFiles = files.size();
|
||||
if (numFiles > 0) {
|
||||
const auto pickedIndex = pickSleepImageIndex(numFiles);
|
||||
APP_STATE.lastSleepImage = pickedIndex;
|
||||
APP_STATE.saveToFile();
|
||||
const auto& filename = files[pickedIndex];
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", filename, file)) {
|
||||
LOG_DBG("SLP", "Loading sleep image: %s", filename.c_str());
|
||||
delay(100);
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
const BookOverlayInfo resolvedOverlayInfo =
|
||||
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
||||
renderBitmapSleepScreen(bitmap, resolvedOverlayInfo);
|
||||
file.close();
|
||||
LOG_DBG("SLP", "Loading sleep image: %s", filename.c_str());
|
||||
const BookOverlayInfo resolvedOverlayInfo =
|
||||
shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo;
|
||||
if (FsHelpers::hasPngExtension(filename)) {
|
||||
if (renderPngSleepScreen(filename, renderer, resolvedOverlayInfo)) {
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
} else {
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("SLP", filename, file)) {
|
||||
delay(100);
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
renderBitmapSleepScreen(bitmap, resolvedOverlayInfo);
|
||||
file.close();
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +625,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
|
||||
const bool hasTitle = !overlayInfo.title.empty();
|
||||
const bool hasProgress = !overlayInfo.progressText.empty();
|
||||
const bool hasAuthor = !overlayInfo.author.empty();
|
||||
if (!hasTitle && !hasAuthor && !hasProgress) {
|
||||
// If there is no overlay progress text, do not draw the overlay background block.
|
||||
if (!hasProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -497,23 +636,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
|
||||
constexpr int sectionSpacing = 10;
|
||||
const int availableWidth = pageWidth - 20;
|
||||
|
||||
int textBlockHeight = 0;
|
||||
int textBlockHeight = lineHeight10; // progress line (always present here)
|
||||
if (hasTitle) {
|
||||
textBlockHeight += lineHeight12;
|
||||
if (hasAuthor) {
|
||||
textBlockHeight += lineSpacing;
|
||||
} else if (hasProgress) {
|
||||
textBlockHeight += sectionSpacing;
|
||||
}
|
||||
textBlockHeight += hasAuthor ? lineSpacing : sectionSpacing;
|
||||
}
|
||||
if (hasAuthor) {
|
||||
textBlockHeight += lineHeight10;
|
||||
if (hasProgress) {
|
||||
textBlockHeight += sectionSpacing;
|
||||
}
|
||||
}
|
||||
if (hasProgress) {
|
||||
textBlockHeight += lineHeight10;
|
||||
textBlockHeight += lineHeight10 + sectionSpacing;
|
||||
}
|
||||
|
||||
const bool textBlack = (overlayMode != 3);
|
||||
@@ -534,8 +663,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
|
||||
const std::string titleStr =
|
||||
renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), availableWidth, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(BOOKERLY_12_FONT_ID, currentY, titleStr.c_str(), textBlack, EpdFontFamily::BOLD);
|
||||
const int spacingAfterTitle = hasAuthor ? lineSpacing : (hasProgress ? sectionSpacing : lineSpacing);
|
||||
currentY += lineHeight12 + spacingAfterTitle;
|
||||
currentY += lineHeight12 + (hasAuthor ? lineSpacing : sectionSpacing);
|
||||
}
|
||||
|
||||
if (hasAuthor) {
|
||||
@@ -544,23 +672,20 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver
|
||||
currentY += lineHeight10 + sectionSpacing;
|
||||
}
|
||||
|
||||
if (hasProgress) {
|
||||
std::string progressStr;
|
||||
if (!overlayInfo.chapterName.empty()) {
|
||||
const std::string prefix = "";
|
||||
const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str());
|
||||
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str());
|
||||
const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth;
|
||||
const std::string truncatedChapter =
|
||||
maxChapterWidth > 0
|
||||
? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth)
|
||||
: "";
|
||||
progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix;
|
||||
} else {
|
||||
progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack);
|
||||
std::string progressStr;
|
||||
if (!overlayInfo.chapterName.empty()) {
|
||||
const std::string prefix = "";
|
||||
const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str());
|
||||
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str());
|
||||
const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth;
|
||||
const std::string truncatedChapter =
|
||||
maxChapterWidth > 0 ? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth)
|
||||
: "";
|
||||
progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix;
|
||||
} else {
|
||||
progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack);
|
||||
};
|
||||
|
||||
drawOverlay();
|
||||
|
||||
@@ -150,14 +150,44 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) {
|
||||
|
||||
GUI.fillPopupProgress(renderer, popupRect, 50);
|
||||
|
||||
bmpHasGreyscale = bitmap.hasGreyscale();
|
||||
// Only render in grayscale when the bitmap actually carries greyscale data AND the user has it enabled.
|
||||
const bool renderGrayscale = bmpHasGreyscale && grayscaleDisplay;
|
||||
|
||||
// Draw control hints. btn2 only shows the BW/Gray toggle when the bitmap supports greyscale —
|
||||
// pure 1-bit BMPs have nothing to toggle. The label shows the *target* mode (what pressing it switches to).
|
||||
const auto drawHints = [&]() {
|
||||
if (!showControls) return;
|
||||
const char* modeLabel =
|
||||
bmpHasGreyscale ? (grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE)) : "";
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
};
|
||||
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
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);
|
||||
}
|
||||
drawHints();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
if (renderGrayscale) {
|
||||
// Multi-pass 4-level grayscale render — mirrors SleepActivity::renderBitmapSleepScreen.
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
}
|
||||
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
@@ -182,7 +212,6 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) {
|
||||
computeCenteredImagePlacement(dims.width, dims.height, pageWidth, pageHeight, x, y, renderWidth, renderHeight);
|
||||
|
||||
GUI.fillPopupProgress(renderer, popupRect, 50);
|
||||
renderer.clearScreen();
|
||||
|
||||
RenderConfig config{};
|
||||
config.x = x;
|
||||
@@ -198,23 +227,78 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) {
|
||||
config.ditherMode = ImageDitherMode::Bayer;
|
||||
#endif
|
||||
|
||||
// Helper to draw the on-screen control hints. The btn3 label shows the *other* mode
|
||||
// (i.e. what pressing it would switch to).
|
||||
const auto drawHints = [&]() {
|
||||
if (!showControls) return;
|
||||
const char* modeLabel = grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE);
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
const char* btn3Label = grayscaleDisplay ? I18N.get(getCurrentDitherModeLabel()) : modeLabel;
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, btn3Label, tr(STR_SET_SLEEP_SCREEN));
|
||||
#else
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN));
|
||||
#endif
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
};
|
||||
|
||||
if (!grayscaleDisplay) {
|
||||
// Pure black-and-white path: single decode with 1-bit Atkinson dither.
|
||||
config.monochromeOutput = true;
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.clearScreen();
|
||||
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
|
||||
return false;
|
||||
}
|
||||
drawHints();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Grayscale path: three decode passes (BW + LSB + MSB), 4-level dither in all of them.
|
||||
// Mirrors SleepActivity::renderCustomSleepScreen so the BW plane carries the same
|
||||
// 4-level quantization as the gray planes layered on top.
|
||||
config.monochromeOutput = false;
|
||||
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
renderer.clearScreen();
|
||||
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);
|
||||
}
|
||||
drawHints();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return false;
|
||||
}
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return false;
|
||||
}
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
renderer.displayGrayBuffer();
|
||||
renderer.setRenderMode(GfxRenderer::BW);
|
||||
return true;
|
||||
}
|
||||
|
||||
void BmpViewerActivity::toggleDisplayMode() {
|
||||
grayscaleDisplay = !grayscaleDisplay;
|
||||
// Switching between 1-bit BW and 4-level grayscale requires a full refresh to clear
|
||||
// ghosting from the previous mode — a half refresh leaves visible residue.
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
if (!renderCurrentImage()) {
|
||||
renderError(tr(STR_COULD_NOT_RENDER_IMAGE));
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
StrId BmpViewerActivity::getCurrentDitherModeLabel() const {
|
||||
switch (imageDitherModeFromSetting(imageDitherMode)) {
|
||||
@@ -312,8 +396,17 @@ void BmpViewerActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm: toggle between 1-bit B&W and 4-level grayscale display.
|
||||
// For decoded images this always applies; for BMPs it only makes sense when the bitmap
|
||||
// actually carries greyscale data (1-bit BMPs have nothing to toggle).
|
||||
const bool toggleSupported = isBmpFile(filePath) ? bmpHasGreyscale : true;
|
||||
if (toggleSupported && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
toggleDisplayMode();
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
if (!isBmpFile(filePath) && mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
if (!isBmpFile(filePath) && grayscaleDisplay && mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
cycleDitherMode();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,16 @@ class BmpViewerActivity final : public Activity {
|
||||
uint8_t initialImageDitherMode;
|
||||
bool imageDitherSettingsDirty;
|
||||
#endif
|
||||
// Per-session toggle: monochrome (1-bit Atkinson, single decode) vs grayscale (4-level dither, multipass).
|
||||
// Not persisted — defaults to grayscale every time the viewer opens.
|
||||
bool grayscaleDisplay = true;
|
||||
// True after a successful BMP render iff the bitmap actually carries greyscale data.
|
||||
// Used to gate the BW/Gray toggle in loop() — pure 1-bit BMPs cannot be toggled.
|
||||
bool bmpHasGreyscale = false;
|
||||
bool renderCurrentImage(bool showControls = true);
|
||||
bool renderBmpImage(bool showControls = true);
|
||||
bool renderDecodedImage(bool showControls = true);
|
||||
void toggleDisplayMode();
|
||||
#ifdef ENABLE_IMAGE_DITHERING_EXTENSION
|
||||
void cycleDitherMode();
|
||||
StrId getCurrentDitherModeLabel() const;
|
||||
|
||||
Reference in New Issue
Block a user