Compare commits

...

17 Commits

Author SHA1 Message Date
martin brook
0be09f46f5
Merge a2d32640f2 into 4dd73a211a 2026-02-01 09:24:23 +00:00
Martin Brook
a2d32640f2 style: fix clang-format issues 2026-02-01 09:24:04 +00:00
Martin Brook
ff28388b04 fix: isFormatSupported now correctly extracts extension
Address review comment #6:
- isFormatSupported() was passing the full path to supportsFormat()
  which only matches extensions like ".jpg", so it always returned false
- Delegate to getDecoder() which correctly extracts the extension first
2026-02-01 09:24:04 +00:00
Martin Brook
b11ed2ea26 refactor: use shared drawPixelWithRenderMode in renderFromCache
Address review comment #4:
- Replace hand-rolled pixel rendering logic in renderFromCache() with
  shared drawPixelWithRenderMode() from DitherUtils.h
- This also hoists getRenderMode() out of the per-pixel call since
  drawPixelWithRenderMode handles it internally
2026-02-01 09:24:04 +00:00
Martin Brook
12b1e8e2cb refactor: use shared DitherUtils and PixelCache in JPEG decoder
Address review comments #2 and #3:
- Remove duplicated bayer4x4, applyBayerDither4Level(),
  drawPixelWithRenderMode(), and PixelCache from JPEG decoder
- Include shared DitherUtils.h and PixelCache.h instead
2026-02-01 09:24:04 +00:00
Martin Brook
8a31d05c38 refactor: replace PNG global state with PngContext struct
Address review comments #1 and #7:
- Replace all file-scope global variables (gRenderer, gConfig, gScale,
  gCacheBuffer, etc.) with a PngContext struct passed through pDraw->pUser
- Unify file I/O callbacks to use pFile->fHandle instead of global FsFile*
- Remove the unused FsFile opened at the start of decodeToFramebuffer()
  that was never used for actual decoding (duplicate open)
- Use shared PixelCache from PixelCache.h instead of hand-rolled globals
2026-02-01 09:24:04 +00:00
Martin Brook
b8e61130f2 refactor: extract shared DitherUtils.h and PixelCache.h
Address review comments #2, #3, and #10:
- Extract duplicated bayer4x4 matrix, applyBayerDither4Level(), and
  drawPixelWithRenderMode() into shared DitherUtils.h
- Extract duplicated PixelCache struct into shared PixelCache.h so both
  JPEG and PNG decoders use the same implementation
- Add MAX_CACHE_BYTES (256KB) size limit to PixelCache::allocate() to
  proactively guard against oversized allocations on embedded targets
2026-02-01 09:24:04 +00:00
Martin Brook
f6847f4f83 fix: allow images to share first chapter page with title
Only force a page break before an image when it won't fit in the
remaining page space, instead of unconditionally breaking whenever
the page has any content.
2026-02-01 09:24:04 +00:00
Martin Brook
4e80c0cc90 feat: add EPUB test generator for image rendering verification
- Generate test EPUBs with annotated JPEG and PNG images
- Test cases cover: grayscale (4 levels), centering, scaling, cache performance
2026-02-01 09:24:04 +00:00
Martin Brook
f807953ee3 feat: epub add png jpeg support 2026-02-01 09:24:00 +00:00
Gaspar Fabrega Ragni
4dd73a211a
fix: custom sleep not showing image at index 0 (#639)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Fixing custom sleep behaviour where the first image in the /sleep
directory is not shown
* image at index 0 is not being rendered when more than 1 image is
stored in /sleep directory, because `APP_STATE.lastSleepImage` is always
0.

## Additional Context

* `APP_STATE.lastSleepImage` is reset to 0 when a epub is open, this
value is only used to compare it to the randomly selected one in
`renderCustomSleepScreen()` that should always be a valid index, since
the list of valid bmp images is colected from scratch. -> no need to
reset it and block image @ index 0 from being rendered

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_

Co-authored-by: Oyster <Oyster@home>
2026-02-01 19:22:52 +11:00
Thomas Foskolos
634f6279cb
docs: Update USER_GUIDE.md (#625)
Add Greek as not supported language atm on the use guide
2026-02-01 19:21:36 +11:00
nscheung
11b2a59233
fix: Hide button hints in landscape CW mode (#637)
## Summary

* This change hides the button hints from overlapping chapter titles
when in landscape CW mode.

Before

![Before](https://github.com/user-attachments/assets/3015a9b3-3fa5-443b-a641-3e65841a6fbc)
After

![After](https://github.com/user-attachments/assets/011de15d-5ae6-429c-8f91-d8f37abe52d9)

## Additional Context

* I initially considered implementing an offset fix, but with potential
UI changes on the horizon, hiding the button hints appears to be the
simplest solution for now.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? < Partially >
2026-02-01 19:21:28 +11:00
Luke Stein
12c20bb09e
fix: WiFi error screen text clarifications (#612)
## Summary

* Clarify strings on Wifi connection error screens
* I have confirmed on device that these are short enough not to overflow
screen margins

## Additional Context

* Several screens give duplicative text (e.g., header "Connection
Failed" with contents text "Connection failed") or slightly confusing
text (header "Forget Network?" with text "Remove saved password?")

---

### AI Usage

Did you use AI tools to help write this code? **No**
2026-02-01 19:19:23 +11:00
Arthur Tazhitdinov
6b7065b986
fix: don't wake up after USB connect (#576)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 18:51:31 +11:00
Arthur Tazhitdinov
f4df513bf3
feat(ui): change popup logic (#442)
## Summary

* refactors Indexing popups into ScreenComponents (they had different
implementations in different files)
* removes Indexing popup for small chapters
* only show Indexing popup (without progress bar) for large chapters
(using same minimum file size condition as for progress bar before)

## Additional Context

* Having to show even single popup message and redraw the screen slows
down the flow significantly
* Testing results:
    * Opening large chapter with progress bar - 11 seconds
* Same chapter without progress bar, only single Indexing popup - 5
seconds

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY>**_
2026-02-01 18:41:24 +11:00
Jonas Diemer
f935b59a41
feat: Add reading menu and delete cache function (#433)
## Summary

* Adds a menu in the Epub reader
* The Chapter selection is moved there to pos 1 (so it can be reached by
double tapping the confirm button)
* A Go Home is there, too
* Most significantly, a function "Delete Book Cache" is added. This
returns to main (to avoid directly rebuilding cached items, eg. if this
is used to debug/develop other areas - and it's also easier ;))

Probably, the Sync function could now be moved from the Chapter
selection to this menu, too.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-02-01 18:34:30 +11:00
42 changed files with 2292 additions and 204 deletions

View File

@ -201,7 +201,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi.
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
---

View File

@ -25,6 +25,29 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
}
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
// Images don't use fontId or text rendering
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
}
bool PageImage::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
// serialize ImageBlock
return imageBlock->serialize(file);
}
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
int16_t xPos;
int16_t yPos;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
auto ib = ImageBlock::deserialize(file);
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
}
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@ -36,8 +59,9 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count);
for (const auto& el : elements) {
// Only PageLine exists currently
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
// Use getTag() method to determine type
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
if (!el->serialize(file)) {
return false;
}
@ -59,6 +83,9 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl));
} else if (tag == TAG_PageImage) {
auto pi = PageImage::deserialize(file);
page->elements.push_back(std::move(pi));
} else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
return nullptr;

View File

@ -4,10 +4,12 @@
#include <utility>
#include <vector>
#include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h"
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageImage = 2, // New tag
};
// represents something that has been added to a page
@ -19,6 +21,7 @@ class PageElement {
virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0;
virtual PageElementTag getTag() const = 0; // Add type identification
};
// a line from a block element
@ -30,9 +33,23 @@ class PageLine final : public PageElement {
: PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageLine; }
static std::unique_ptr<PageLine> deserialize(FsFile& file);
};
// New PageImage class
class PageImage final : public PageElement {
std::shared_ptr<ImageBlock> imageBlock;
public:
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
PageElementTag getTag() const override { return TAG_PageImage; }
static std::unique_ptr<PageImage> deserialize(FsFile& file);
};
class Page {
public:
// the list of block index and line numbers on this page

View File

@ -123,9 +123,7 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const std::function<void()>& popupFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@ -171,11 +169,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
// Only show progress bar for larger chapters where rendering overhead is worth it
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
progressSetupFn();
}
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
return false;
}
@ -184,10 +177,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn);
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();

View File

@ -33,7 +33,6 @@ class Section {
bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
const std::function<void()>& popupFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
};

View File

@ -0,0 +1,175 @@
#include "ImageBlock.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include "../converters/DitherUtils.h"
#include "../converters/ImageDecoderFactory.h"
// Cache file format:
// - uint16_t width
// - uint16_t height
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
: imagePath(imagePath), width(width), height(height) {}
bool ImageBlock::imageExists() const {
FsFile file;
return SdMan.openFileForRead("IMG", imagePath, file);
}
void ImageBlock::layout(GfxRenderer& renderer) {}
static std::string getCachePath(const std::string& imagePath) {
// Replace extension with .pxc (pixel cache)
size_t dotPos = imagePath.rfind('.');
if (dotPos != std::string::npos) {
return imagePath.substr(0, dotPos) + ".pxc";
}
return imagePath + ".pxc";
}
static bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
FsFile cacheFile;
if (!SdMan.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
}
uint16_t cachedWidth, cachedHeight;
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
cacheFile.close();
return false;
}
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
int widthDiff = abs(cachedWidth - expectedWidth);
int heightDiff = abs(cachedHeight - expectedHeight);
if (widthDiff > 1 || heightDiff > 1) {
Serial.printf("[%lu] [IMG] Cache dimension mismatch: %dx%d vs %dx%d\n", millis(), cachedWidth, cachedHeight,
expectedWidth, expectedHeight);
cacheFile.close();
return false;
}
// Use cached dimensions for rendering (they're the actual decoded size)
expectedWidth = cachedWidth;
expectedHeight = cachedHeight;
Serial.printf("[%lu] [IMG] Loading from cache: %s (%dx%d)\n", millis(), cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
Serial.printf("[%lu] [IMG] Failed to allocate row buffer\n", millis());
cacheFile.close();
return false;
}
for (int row = 0; row < cachedHeight; row++) {
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
Serial.printf("[%lu] [IMG] Cache read error at row %d\n", millis(), row);
free(rowBuffer);
cacheFile.close();
return false;
}
int destY = y + row;
for (int col = 0; col < cachedWidth; col++) {
int byteIdx = col / 4;
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
}
}
free(rowBuffer);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache render complete\n", millis());
return true;
}
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
Serial.printf("[%lu] [IMG] Rendering image at %d,%d: %s (%dx%d)\n", millis(), x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Bounds check render position using logical screen dimensions
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
Serial.printf("[%lu] [IMG] Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)\n", millis(), x, y, width,
height, screenWidth, screenHeight);
return;
}
// Try to render from cache first
std::string cachePath = getCachePath(imagePath);
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
return; // Successfully rendered from cache
}
// No cache - need to decode the image
// Check if image file exists
FsFile file;
if (!SdMan.openFileForRead("IMG", imagePath, file)) {
Serial.printf("[%lu] [IMG] Image file not found: %s\n", millis(), imagePath.c_str());
return;
}
size_t fileSize = file.size();
file.close();
if (fileSize == 0) {
Serial.printf("[%lu] [IMG] Image file is empty: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decoding and caching: %s\n", millis(), imagePath.c_str());
RenderConfig config;
config.x = x;
config.y = y;
config.maxWidth = width;
config.maxHeight = height;
config.useGrayscale = true;
config.useDithering = true;
config.performanceMode = false;
config.cachePath = cachePath; // Enable caching during decode
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
if (!decoder) {
Serial.printf("[%lu] [IMG] No decoder found for image: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Using %s decoder\n", millis(), decoder->getFormatName());
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
if (!success) {
Serial.printf("[%lu] [IMG] Failed to decode image: %s\n", millis(), imagePath.c_str());
return;
}
Serial.printf("[%lu] [IMG] Decode successful\n", millis());
}
bool ImageBlock::serialize(FsFile& file) {
serialization::writeString(file, imagePath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
}
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
std::string path;
serialization::readString(file, path);
int16_t w, h;
serialization::readPod(file, w);
serialization::readPod(file, h);
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
}

View File

@ -0,0 +1,32 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
#include "Block.h"
class ImageBlock final : public Block {
public:
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
~ImageBlock() override = default;
const std::string& getImagePath() const { return imagePath; }
int16_t getWidth() const { return width; }
int16_t getHeight() const { return height; }
bool imageExists() const;
void layout(GfxRenderer& renderer) override;
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
void render(GfxRenderer& renderer, const int x, const int y);
bool serialize(FsFile& file);
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
private:
std::string imagePath;
int16_t width;
int16_t height;
};

View File

@ -0,0 +1,40 @@
#pragma once
#include <GfxRenderer.h>
#include <stdint.h>
// 4x4 Bayer matrix for ordered dithering
inline const uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
{12, 4, 14, 6},
{3, 11, 1, 9},
{15, 7, 13, 5},
};
// 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) {
int bayer = bayer4x4[y & 3][x & 3];
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
int adjusted = gray + dither;
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;
}
// Draw a pixel respecting the current render mode for grayscale support
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
renderer.drawPixel(x, y, true);
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
renderer.drawPixel(x, y, false);
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
renderer.drawPixel(x, y, false);
}
}

View File

@ -0,0 +1,31 @@
#include "FramebufferWriter.h"
void FramebufferWriter::setPixel(int x, int y, bool isBlack) {
if (x < 0 || x >= DISPLAY_WIDTH || y < 0 || y >= DISPLAY_HEIGHT) {
return;
}
const uint16_t byteIndex = y * DISPLAY_WIDTH_BYTES + (x / 8);
const uint8_t bitPosition = 7 - (x % 8);
if (isBlack) {
frameBuffer[byteIndex] &= ~(1 << bitPosition);
} else {
frameBuffer[byteIndex] |= (1 << bitPosition);
}
}
void FramebufferWriter::setPixel2Bit(int x, int y, uint8_t value) {
if (x < 0 || x >= DISPLAY_WIDTH || y < 0 || y >= DISPLAY_HEIGHT || value > 3) {
return;
}
const uint16_t byteIndex = y * DISPLAY_WIDTH_BYTES + (x / 8);
const uint8_t bitPosition = 7 - (x % 8);
if (value < 2) {
frameBuffer[byteIndex] &= ~(1 << bitPosition);
} else {
frameBuffer[byteIndex] |= (1 << bitPosition);
}
}

View File

@ -0,0 +1,19 @@
#pragma once
#include <stdint.h>
class FramebufferWriter {
private:
uint8_t* frameBuffer;
static constexpr int DISPLAY_WIDTH = 800;
static constexpr int DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8; // 100
static constexpr int DISPLAY_HEIGHT = 480;
public:
explicit FramebufferWriter(uint8_t* framebuffer) : frameBuffer(framebuffer) {}
// Simple pixel setting for 1-bit rendering
void setPixel(int x, int y, bool isBlack);
// 2-bit grayscale pixel setting (for dual-pass rendering)
void setPixel2Bit(int x, int y, uint8_t value); // value: 0-3
};

View File

@ -0,0 +1,60 @@
#include "ImageDecoderFactory.h"
#include <HardwareSerial.h>
#include <memory>
#include <string>
#include <vector>
#include "JpegToFramebufferConverter.h"
#include "PngToFramebufferConverter.h"
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
bool ImageDecoderFactory::initialized = false;
void ImageDecoderFactory::initialize() {
if (initialized) return;
jpegDecoder = std::unique_ptr<JpegToFramebufferConverter>(new JpegToFramebufferConverter());
pngDecoder = std::unique_ptr<PngToFramebufferConverter>(new PngToFramebufferConverter());
initialized = true;
Serial.printf("[%lu] [DEC] Image decoder factory initialized\n", millis());
}
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
if (!initialized) {
initialize();
}
std::string ext = imagePath;
size_t dotPos = ext.rfind('.');
if (dotPos != std::string::npos) {
ext = ext.substr(dotPos);
for (auto& c : ext) {
c = tolower(c);
}
} else {
ext = "";
}
if (jpegDecoder && jpegDecoder->supportsFormat(ext)) {
return jpegDecoder.get();
} else if (pngDecoder && pngDecoder->supportsFormat(ext)) {
return pngDecoder.get();
}
Serial.printf("[%lu] [DEC] No decoder found for image: %s\n", millis(), imagePath.c_str());
return nullptr;
}
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
std::vector<std::string> ImageDecoderFactory::getSupportedFormats() {
std::vector<std::string> formats;
formats.push_back(".jpg");
formats.push_back(".jpeg");
formats.push_back(".png");
return formats;
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter;
class PngToFramebufferConverter;
class ImageDecoderFactory {
public:
static void initialize();
// Returns non-owning pointer - factory owns the decoder lifetime
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
static bool isFormatSupported(const std::string& imagePath);
static std::vector<std::string> getSupportedFormats();
private:
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
static bool initialized;
};

View File

@ -0,0 +1,18 @@
#include "ImageToFramebufferDecoder.h"
#include <Arduino.h>
#include <HardwareSerial.h>
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
if (width > MAX_SOURCE_WIDTH || height > MAX_SOURCE_HEIGHT) {
Serial.printf("[%lu] [IMG] Image too large (%dx%d %s), max supported: %dx%d\n", millis(), width, height,
format.c_str(), MAX_SOURCE_WIDTH, MAX_SOURCE_HEIGHT);
return false;
}
return true;
}
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
Serial.printf("[%lu] [IMG] Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.\n",
millis(), feature.c_str(), imagePath.c_str());
}

View File

@ -0,0 +1,41 @@
#pragma once
#include <SdFat.h>
#include <memory>
#include <string>
class GfxRenderer;
struct ImageDimensions {
int16_t width;
int16_t height;
};
struct RenderConfig {
int x, y;
int maxWidth, maxHeight;
bool useGrayscale = true;
bool useDithering = true;
bool performanceMode = false;
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
};
class ImageToFramebufferDecoder {
public:
virtual ~ImageToFramebufferDecoder() = default;
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
virtual bool supportsFormat(const std::string& extension) const = 0;
virtual const char* getFormatName() const = 0;
protected:
// Size validation helpers
static constexpr int MAX_SOURCE_WIDTH = 2048;
static constexpr int MAX_SOURCE_HEIGHT = 1536;
bool validateImageDimensions(int width, int height, const std::string& format);
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
};

View File

@ -0,0 +1,286 @@
#include "JpegToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include "DitherUtils.h"
#include "PixelCache.h"
struct JpegContext {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
};
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
FsFile file;
if (!SdMan.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file for dimensions: %s\n", millis(), imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
file.close();
if (status != 0) {
Serial.printf("[%lu] [JPG] Failed to init JPEG for dimensions: %d\n", millis(), status);
return false;
}
out.width = imageInfo.m_width;
out.height = imageInfo.m_height;
Serial.printf("[%lu] [JPG] Image dimensions: %dx%d\n", millis(), out.width, out.height);
return true;
}
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [JPG] Decoding JPEG: %s\n", millis(), imagePath.c_str());
FsFile file;
if (!SdMan.openFileForRead("JPG", imagePath, file)) {
Serial.printf("[%lu] [JPG] Failed to open file: %s\n", millis(), imagePath.c_str());
return false;
}
JpegContext context(file);
pjpeg_image_info_t imageInfo;
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
Serial.printf("[%lu] [JPG] picojpeg init failed: %d\n", millis(), status);
file.close();
return false;
}
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
file.close();
return false;
}
// Calculate scale factor to fit within maxWidth/maxHeight
float scaleX =
(config.maxWidth > 0 && imageInfo.m_width > config.maxWidth) ? (float)config.maxWidth / imageInfo.m_width : 1.0f;
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
? (float)config.maxHeight / imageInfo.m_height
: 1.0f;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
int destWidth = (int)(imageInfo.m_width * scale);
int destHeight = (int)(imageInfo.m_height * scale);
Serial.printf("[%lu] [JPG] JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d\n", millis(),
imageInfo.m_width, imageInfo.m_height, destWidth, destHeight, scale, imageInfo.m_scanType,
imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
Serial.printf("[%lu] [JPG] Null buffer pointers in imageInfo\n", millis());
file.close();
return false;
}
const int screenWidth = renderer.getScreenWidth();
const int screenHeight = renderer.getScreenHeight();
// Allocate pixel cache if cachePath is provided
PixelCache cache;
bool caching = !config.cachePath.empty();
if (caching) {
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
Serial.printf("[%lu] [JPG] Failed to allocate cache buffer, continuing without caching\n", millis());
caching = false;
}
}
int mcuX = 0;
int mcuY = 0;
while (mcuY < imageInfo.m_MCUSPerCol) {
status = pjpeg_decode_mcu();
if (status == PJPG_NO_MORE_BLOCKS) {
break;
}
if (status != 0) {
Serial.printf("[%lu] [JPG] MCU decode failed: %d\n", millis(), status);
file.close();
return false;
}
// Source position in image coordinates
int srcStartX = mcuX * imageInfo.m_MCUWidth;
int srcStartY = mcuY * imageInfo.m_MCUHeight;
switch (imageInfo.m_scanType) {
case PJPG_GRAYSCALE:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V1:
for (int row = 0; row < 8; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (col < 8) ? 0 : 1;
int pixelIndex = row * 8 + (col % 8);
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH1V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 8; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockIndex = (row < 8) ? 0 : 1;
int pixelIndex = (row % 8) * 8 + col;
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
case PJPG_YH2V2:
for (int row = 0; row < 16; row++) {
int srcY = srcStartY + row;
int destY = config.y + (int)(srcY * scale);
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
for (int col = 0; col < 16; col++) {
int srcX = srcStartX + col;
int destX = config.x + (int)(srcX * scale);
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
int blockX = (col < 8) ? 0 : 1;
int blockY = (row < 8) ? 0 : 1;
int blockIndex = blockY * 2 + blockX;
int pixelIndex = (row % 8) * 8 + (col % 8);
int blockOffset = blockIndex * 64;
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
if (dithered > 3) dithered = 3;
drawPixelWithRenderMode(renderer, destX, destY, dithered);
if (caching) cache.setPixel(destX, destY, dithered);
}
}
break;
}
mcuX++;
if (mcuX >= imageInfo.m_MCUSPerRow) {
mcuX = 0;
mcuY++;
}
}
Serial.printf("[%lu] [JPG] Decoding complete\n", millis());
file.close();
// Write cache file if caching was enabled
if (caching) {
cache.writeToFile(config.cachePath);
}
return true;
}
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
if (context->bufferPos >= context->bufferFilled) {
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
if (readCount <= 0) {
*pBytes_actually_read = 0;
return 0;
}
context->bufferFilled = readCount;
context->bufferPos = 0;
}
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
context->bufferPos += bytesToCopy;
*pBytes_actually_read = bytesToCopy;
return 0;
}
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) const {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".jpg" || ext == ".jpeg");
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <stdint.h>
#include <string>
#include "ImageToFramebufferDecoder.h"
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
bool supportsFormat(const std::string& extension) const override;
const char* getFormatName() const override { return "JPEG"; }
private:
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
};

View File

@ -0,0 +1,83 @@
#pragma once
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <stdint.h>
#include <cstring>
#include <string>
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
// Packs 4 pixels per byte, MSB first.
struct PixelCache {
uint8_t* buffer;
int width;
int height;
int bytesPerRow;
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
bool allocate(int w, int h, int ox, int oy) {
width = w;
height = h;
originX = ox;
originY = oy;
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
size_t bufferSize = (size_t)bytesPerRow * h;
if (bufferSize > MAX_CACHE_BYTES) {
Serial.printf("[%lu] [IMG] Cache buffer too large: %d bytes for %dx%d (limit %d)\n", millis(), bufferSize, w, h,
MAX_CACHE_BYTES);
return false;
}
buffer = (uint8_t*)malloc(bufferSize);
if (buffer) {
memset(buffer, 0, bufferSize);
Serial.printf("[%lu] [IMG] Allocated cache buffer: %d bytes for %dx%d\n", millis(), bufferSize, w, h);
}
return buffer != nullptr;
}
void setPixel(int screenX, int screenY, uint8_t value) {
if (!buffer) return;
int localX = screenX - originX;
int localY = screenY - originY;
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
int byteIdx = localY * bytesPerRow + localX / 4;
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
bool writeToFile(const std::string& cachePath) {
if (!buffer) return false;
FsFile cacheFile;
if (!SdMan.openFileForWrite("IMG", cachePath, cacheFile)) {
Serial.printf("[%lu] [IMG] Failed to open cache file for writing: %s\n", millis(), cachePath.c_str());
return false;
}
uint16_t w = width;
uint16_t h = height;
cacheFile.write(&w, 2);
cacheFile.write(&h, 2);
cacheFile.write(buffer, bytesPerRow * height);
cacheFile.close();
Serial.printf("[%lu] [IMG] Cache written: %s (%dx%d, %d bytes)\n", millis(), cachePath.c_str(), width, height,
4 + bytesPerRow * height);
return true;
}
~PixelCache() {
if (buffer) {
free(buffer);
buffer = nullptr;
}
}
};

View File

@ -0,0 +1,255 @@
#include "PngToFramebufferConverter.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <PNGdec.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include "DitherUtils.h"
#include "PixelCache.h"
// Context struct passed through PNGdec callbacks to avoid global mutable state.
// The draw callback receives this via pDraw->pUser (set by png.decode()).
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
struct PngContext {
GfxRenderer* renderer;
const RenderConfig* config;
int screenWidth;
int screenHeight;
// Scaling state
float scale;
int srcWidth;
int srcHeight;
int dstWidth;
int dstHeight;
int lastDstY; // Track last rendered destination Y to avoid duplicates
PixelCache cache;
bool caching;
PngContext()
: renderer(nullptr),
config(nullptr),
screenWidth(0),
screenHeight(0),
scale(1.0f),
srcWidth(0),
srcHeight(0),
dstWidth(0),
dstHeight(0),
lastDstY(-1),
caching(false) {}
};
// File I/O callbacks use pFile->fHandle to access the FsFile*,
// avoiding the need for global file state.
static void* pngOpenWithHandle(const char* filename, int32_t* size) {
FsFile* f = new FsFile();
if (!SdMan.openFileForRead("PNG", std::string(filename), *f)) {
delete f;
return nullptr;
}
*size = f->size();
return f;
}
static void pngCloseWithHandle(void* handle) {
FsFile* f = reinterpret_cast<FsFile*>(handle);
if (f) {
f->close();
delete f;
}
}
static int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
return f->read(pBuf, len);
}
static int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return -1;
return f->seek(pos);
}
// Single static PNG object shared between getDimensions and decode
// (these operations never happen simultaneously)
static PNG png;
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
int rc =
png.open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle, nullptr);
if (rc != 0) {
Serial.printf("[%lu] [PNG] Failed to open PNG for dimensions: %d\n", millis(), rc);
return false;
}
out.width = png.getWidth();
out.height = png.getHeight();
png.close();
return true;
}
// Helper to get grayscale from PNG pixel data
static uint8_t getGrayFromPixel(uint8_t* pPixels, int x, int pixelType, uint8_t* palette) {
switch (pixelType) {
case PNG_PIXEL_GRAYSCALE:
return pPixels[x];
case PNG_PIXEL_TRUECOLOR: {
uint8_t* p = &pPixels[x * 3];
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
case PNG_PIXEL_INDEXED: {
uint8_t paletteIndex = pPixels[x];
if (palette) {
uint8_t* p = &palette[paletteIndex * 3];
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
return paletteIndex;
}
case PNG_PIXEL_GRAY_ALPHA:
return pPixels[x * 2];
case PNG_PIXEL_TRUECOLOR_ALPHA: {
uint8_t* p = &pPixels[x * 4];
return (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
}
default:
return 128;
}
}
int pngDrawCallback(PNGDRAW* pDraw) {
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
if (!ctx || !ctx->config || !ctx->renderer) return 0;
int srcY = pDraw->y;
uint8_t* pPixels = pDraw->pPixels;
int pixelType = pDraw->iPixelType;
// Calculate destination Y with scaling
int dstY = (int)(srcY * ctx->scale);
// Skip if we already rendered this destination row (multiple source rows map to same dest)
if (dstY == ctx->lastDstY) return 1;
ctx->lastDstY = dstY;
// Check bounds
if (dstY >= ctx->dstHeight) return 1;
int outY = ctx->config->y + dstY;
if (outY >= ctx->screenHeight) return 1;
// Render scaled row using nearest-neighbor sampling
for (int dstX = 0; dstX < ctx->dstWidth; dstX++) {
int outX = ctx->config->x + dstX;
if (outX >= ctx->screenWidth) continue;
// Map destination X back to source X
int srcX = (int)(dstX / ctx->scale);
if (srcX >= ctx->srcWidth) srcX = ctx->srcWidth - 1;
uint8_t gray = getGrayFromPixel(pPixels, srcX, pixelType, pDraw->pPalette);
uint8_t ditheredGray;
if (ctx->config->useDithering) {
ditheredGray = applyBayerDither4Level(gray, outX, outY);
} else {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (ctx->caching) ctx->cache.setPixel(outX, outY, ditheredGray);
}
return 1;
}
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
const RenderConfig& config) {
Serial.printf("[%lu] [PNG] Decoding PNG: %s\n", millis(), imagePath.c_str());
PngContext ctx;
ctx.renderer = &renderer;
ctx.config = &config;
ctx.screenWidth = renderer.getScreenWidth();
ctx.screenHeight = renderer.getScreenHeight();
int rc = png.open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
pngDrawCallback);
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Failed to open PNG: %d\n", millis(), rc);
return false;
}
if (!validateImageDimensions(png.getWidth(), png.getHeight(), "PNG")) {
png.close();
return false;
}
// Calculate scale factor to fit within maxWidth x maxHeight
ctx.srcWidth = png.getWidth();
ctx.srcHeight = png.getHeight();
float scaleX = (float)config.maxWidth / ctx.srcWidth;
float scaleY = (float)config.maxHeight / ctx.srcHeight;
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
ctx.lastDstY = -1; // Reset row tracking
Serial.printf("[%lu] [PNG] PNG %dx%d -> %dx%d (scale %.2f), bpp: %d\n", millis(), ctx.srcWidth, ctx.srcHeight,
ctx.dstWidth, ctx.dstHeight, ctx.scale, png.getBpp());
if (png.getBpp() != 8) {
warnUnsupportedFeature("bit depth (" + std::to_string(png.getBpp()) + "bpp)", imagePath);
}
if (png.hasAlpha()) {
warnUnsupportedFeature("alpha channel", imagePath);
}
// Allocate cache buffer using SCALED dimensions
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
Serial.printf("[%lu] [PNG] Failed to allocate cache buffer, continuing without caching\n", millis());
ctx.caching = false;
}
}
rc = png.decode(&ctx, 0);
if (rc != PNG_SUCCESS) {
Serial.printf("[%lu] [PNG] Decode failed: %d\n", millis(), rc);
png.close();
return false;
}
png.close();
Serial.printf("[%lu] [PNG] PNG decoding complete\n", millis());
// Write cache file if caching was enabled and buffer was allocated
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
}
return true;
}
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) const {
std::string ext = extension;
for (auto& c : ext) {
c = tolower(c);
}
return (ext == ".png");
}

View File

@ -0,0 +1,19 @@
#pragma once
#include <PNGdec.h>
#include "ImageToFramebufferDecoder.h"
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
public:
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
return getDimensionsStatic(imagePath, dims);
}
bool supportsFormat(const std::string& extension) const override;
const char* getFormatName() const override { return "PNG"; }
};

View File

@ -5,13 +5,16 @@
#include <SDCardManager.h>
#include <expat.h>
#include "../../Epub.h"
#include "../Page.h"
#include "../converters/ImageDecoderFactory.h"
#include "../converters/ImageToFramebufferDecoder.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@ -96,30 +99,144 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// TODO: Start processing image tags
std::string alt = "[Image]";
std::string src;
std::string alt;
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "alt") == 0) {
if (strlen(atts[i + 1]) > 0) {
alt = "[Image: " + std::string(atts[i + 1]) + "]";
}
break;
if (strcmp(atts[i], "src") == 0) {
src = atts[i + 1];
} else if (strcmp(atts[i], "alt") == 0) {
alt = atts[i + 1];
}
}
if (!src.empty()) {
Serial.printf("[%lu] [EHP] Found image: src=%s\n", millis(), src.c_str());
// Get the spine item's href to resolve the relative path
size_t lastUnderscore = self->filepath.rfind('_');
if (lastUnderscore != std::string::npos && lastUnderscore > 0) {
std::string indexStr = self->filepath.substr(lastUnderscore + 1);
indexStr.resize(indexStr.find('.'));
int spineIndex = atoi(indexStr.c_str());
const auto& spineItem = self->epub->getSpineItem(spineIndex);
std::string htmlHref = spineItem.href;
size_t lastSlash = htmlHref.find_last_of('/');
std::string htmlDir = (lastSlash != std::string::npos) ? htmlHref.substr(0, lastSlash + 1) : "";
// Resolve the image path relative to the HTML file
std::string imageHref = src;
while (imageHref.find("../") == 0) {
imageHref = imageHref.substr(3);
if (!htmlDir.empty()) {
size_t dirSlash = htmlDir.find_last_of('/', htmlDir.length() - 2);
htmlDir = (dirSlash != std::string::npos) ? htmlDir.substr(0, dirSlash + 1) : "";
}
}
std::string resolvedPath = htmlDir + imageHref;
// Create a unique filename for the cached image
std::string ext;
size_t extPos = resolvedPath.rfind('.');
if (extPos != std::string::npos) {
ext = resolvedPath.substr(extPos);
}
std::string cachedImagePath = self->epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_" +
std::to_string(self->imageCounter++) + ext;
// Extract image to cache file
FsFile cachedImageFile;
bool extractSuccess = false;
if (SdMan.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
cachedImageFile.flush();
cachedImageFile.close();
delay(50); // Give SD card time to sync
}
if (extractSuccess) {
// Get image dimensions
ImageDimensions dims = {0, 0};
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
if (decoder && decoder->getDimensions(cachedImagePath, dims)) {
Serial.printf("[%lu] [EHP] Image dimensions: %dx%d\n", millis(), dims.width, dims.height);
// Scale to fit viewport while maintaining aspect ratio
int maxWidth = self->viewportWidth;
int maxHeight = self->viewportHeight;
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
if (scale > 1.0f) scale = 1.0f;
int displayWidth = (int)(dims.width * scale);
int displayHeight = (int)(dims.height * scale);
Serial.printf("[%lu] [EHP] Display size: %dx%d (scale %.2f)\n", millis(), displayWidth, displayHeight,
scale);
// Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
self->completePageFn(std::move(self->currentPage));
self->currentPage.reset(new Page());
if (!self->currentPage) {
Serial.printf("[%lu] [EHP] Failed to create new page\n", millis());
return;
}
self->currentPageNextY = 0;
} else if (!self->currentPage) {
self->currentPage.reset(new Page());
if (!self->currentPage) {
Serial.printf("[%lu] [EHP] Failed to create initial page\n", millis());
return;
}
self->currentPageNextY = 0;
}
// Create ImageBlock and add to page
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
if (!imageBlock) {
Serial.printf("[%lu] [EHP] Failed to create ImageBlock\n", millis());
return;
}
int xPos = (self->viewportWidth - displayWidth) / 2;
auto pageImage = std::make_shared<PageImage>(imageBlock, xPos, self->currentPageNextY);
if (!pageImage) {
Serial.printf("[%lu] [EHP] Failed to create PageImage\n", millis());
return;
}
self->currentPage->elements.push_back(pageImage);
self->currentPageNextY += displayHeight;
self->depth += 1;
return;
} else {
Serial.printf("[%lu] [EHP] Failed to get image dimensions\n", millis());
SdMan.remove(cachedImagePath.c_str());
}
} else {
Serial.printf("[%lu] [EHP] Failed to extract image\n", millis());
}
}
}
// Fallback to alt text if image processing fails
if (!alt.empty()) {
alt = "[Image: " + alt + "]";
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
return;
}
// No alt text, skip
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
// Advance depth before processing character data (like you would for a element with text)
self->depth += 1;
self->characterData(userData, alt.c_str(), alt.length());
// Skip table contents (skip until parent as we pre-advanced depth above)
self->skipUntilDepth = self->depth - 1;
return;
}
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
@ -289,10 +406,10 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
// Get file size for progress calculation
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
// Get file size to decide whether to show indexing popup.
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
popupFn();
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
@ -322,17 +439,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {

View File

@ -7,18 +7,21 @@
#include <memory>
#include "../ParsedText.h"
#include "../blocks/ImageBlock.h"
#include "../blocks/TextBlock.h"
class Page;
class GfxRenderer;
class Epub;
#define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser {
std::shared_ptr<Epub> epub;
const std::string& filepath;
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100)
std::function<void()> popupFn; // Popup callback
int depth = 0;
int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX;
@ -37,6 +40,7 @@ class ChapterHtmlSlimParser {
uint16_t viewportWidth;
uint16_t viewportHeight;
bool hyphenationEnabled;
int imageCounter = 0;
void startNewTextBlock(TextBlock::Style style);
void flushPartWordBuffer();
@ -47,13 +51,14 @@ class ChapterHtmlSlimParser {
static void XMLCALL endElement(void* userData, const XML_Char* name);
public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const bool extraParagraphSpacing,
explicit ChapterHtmlSlimParser(std::shared_ptr<Epub> epub, const std::string& filepath, GfxRenderer& renderer,
const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath),
const std::function<void()>& popupFn = nullptr)
: epub(epub),
filepath(filepath),
renderer(renderer),
fontId(fontId),
lineCompression(lineCompression),
@ -63,7 +68,7 @@ class ChapterHtmlSlimParser {
viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled),
completePageFn(completePageFn),
progressFn(progressFn) {}
popupFn(popupFn) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);

View File

@ -56,7 +56,7 @@ class GfxRenderer {
int getScreenHeight() const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const;
// void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const;
@ -96,6 +96,7 @@ class GfxRenderer {
public:
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
RenderMode getRenderMode() const { return renderMode; }
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;

View File

@ -24,12 +24,13 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() {
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
@ -44,12 +45,20 @@ bool HalGPIO::isUsbConnected() const {
return digitalRead(UART0_RXD) == HIGH;
}
bool HalGPIO::isWakeupByPowerButton() const {
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason();
if (isUsbConnected()) {
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
} else {
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return WakeupReason::PowerButton;
}
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
return WakeupReason::AfterFlash;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
return WakeupReason::AfterUSBPower;
}
return WakeupReason::Other;
}

View File

@ -47,8 +47,9 @@ class HalGPIO {
// Check if USB is connected
bool isUsbConnected() const;
// Check if wakeup was caused by power button press
bool isWakeupByPowerButton() const;
enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
WakeupReason getWakeupReason() const;
// Button indices
static constexpr uint8_t BTN_BACK = 0;

View File

@ -30,6 +30,9 @@ build_flags =
-std=c++2a
# Enable UTF-8 long file names in SdFat
-DUSE_UTF8_LONG_NAMES=1
# Increase PNG scanline buffer to support up to 800px wide images
# Default is (320*4+1)*2=2562, we need more for larger images
-DPNG_MAX_BUFFERED_PIXELS=6402
; Board configuration
board_build.flash_mode = dio
@ -47,6 +50,7 @@ lib_deps =
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1
bitbank2/PNGdec @ ^1.0.0
links2004/WebSockets @ 2.7.3
[env:default]

View File

@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""
Generate test EPUBs for image rendering verification.
Creates EPUBs with annotated JPEG and PNG images to verify:
- Grayscale rendering (4 levels)
- Image scaling
- Image centering
- Cache performance
- Page serialization
"""
import os
import zipfile
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("Please install Pillow: pip install Pillow")
exit(1)
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
SCREEN_WIDTH = 480
SCREEN_HEIGHT = 800
def get_font(size=20):
"""Get a font, falling back to default if needed."""
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
except:
try:
return ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans.ttf", size)
except:
return ImageFont.load_default()
def draw_text_centered(draw, y, text, font, fill=0):
"""Draw centered text at given y position."""
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
x = (draw.im.size[0] - text_width) // 2
draw.text((x, y), text, font=font, fill=fill)
def draw_text_wrapped(draw, x, y, text, font, max_width, fill=0):
"""Draw text with word wrapping."""
words = text.split()
lines = []
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
if bbox[2] - bbox[0] <= max_width:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
line_height = font.size + 4 if hasattr(font, 'size') else 20
for i, line in enumerate(lines):
draw.text((x, y + i * line_height), line, font=font, fill=fill)
return len(lines) * line_height
def create_grayscale_test_image(filename, is_png=True):
"""
Create image with 4 grayscale squares to verify 4-level rendering.
"""
width, height = 400, 600
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(16)
font_small = get_font(14)
# Title
draw_text_centered(draw, 10, "GRAYSCALE TEST", font, fill=0)
draw_text_centered(draw, 35, "Verify 4 distinct gray levels", font_small, fill=64)
# Draw 4 grayscale squares
square_size = 70
start_y = 65
gap = 10
# Gray levels chosen to avoid Bayer dithering threshold boundaries (±40 dither offset)
# Thresholds at 64, 128, 192 - use values in the middle of each band for solid output
# Safe zones: 0-23 (black), 88-103 (dark gray), 152-167 (light gray), 232-255 (white)
levels = [
(0, "Level 0: BLACK"),
(96, "Level 1: DARK GRAY"),
(160, "Level 2: LIGHT GRAY"),
(255, "Level 3: WHITE"),
]
for i, (gray_value, label) in enumerate(levels):
y = start_y + i * (square_size + gap + 22)
x = (width - square_size) // 2
# Draw square with border
draw.rectangle([x-2, y-2, x + square_size + 2, y + square_size + 2], fill=0)
draw.rectangle([x, y, x + square_size, y + square_size], fill=gray_value)
# Label below square
bbox = draw.textbbox((0, 0), label, font=font_small)
label_width = bbox[2] - bbox[0]
draw.text(((width - label_width) // 2, y + square_size + 5), label, font=font_small, fill=0)
# Instructions at bottom (well below the last square)
y = height - 70
draw_text_centered(draw, y, "PASS: 4 distinct shades visible", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Only black/white or", font_small, fill=64)
draw_text_centered(draw, y + 38, "muddy/indistinct grays", font_small, fill=64)
# Save
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_centering_test_image(filename, is_png=True):
"""
Create image with border markers to verify centering.
"""
width, height = 350, 400
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(16)
font_small = get_font(14)
# Draw border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
# Corner markers
marker_size = 20
for x, y in [(0, 0), (width-marker_size, 0), (0, height-marker_size), (width-marker_size, height-marker_size)]:
draw.rectangle([x, y, x+marker_size, y+marker_size], fill=0)
# Center cross
cx, cy = width // 2, height // 2
draw.line([cx - 30, cy, cx + 30, cy], fill=0, width=2)
draw.line([cx, cy - 30, cx, cy + 30], fill=0, width=2)
# Title
draw_text_centered(draw, 40, "CENTERING TEST", font, fill=0)
# Instructions
y = 80
draw_text_centered(draw, y, "Image should be centered", font_small, fill=0)
draw_text_centered(draw, y + 20, "horizontally on screen", font_small, fill=0)
y = 150
draw_text_centered(draw, y, "Check:", font_small, fill=0)
draw_text_centered(draw, y + 25, "- Equal margins left & right", font_small, fill=64)
draw_text_centered(draw, y + 45, "- All 4 corners visible", font_small, fill=64)
draw_text_centered(draw, y + 65, "- Border is complete rectangle", font_small, fill=64)
# Pass/fail
y = height - 80
draw_text_centered(draw, y, "PASS: Centered, all corners visible", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Off-center or cropped", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_scaling_test_image(filename, is_png=True):
"""
Create large image to verify scaling works.
"""
# Make image larger than screen but within decoder limits (max 2048x1536)
width, height = 1200, 1500
img = Image.new('L', (width, height), 240)
draw = ImageDraw.Draw(img)
font = get_font(48)
font_medium = get_font(32)
font_small = get_font(24)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=8)
draw.rectangle([20, 20, width-21, height-21], outline=128, width=4)
# Title
draw_text_centered(draw, 60, "SCALING TEST", font, fill=0)
draw_text_centered(draw, 130, f"Original: {width}x{height} (larger than screen)", font_medium, fill=64)
# Grid pattern to verify scaling quality
grid_start_y = 220
grid_size = 400
cell_size = 50
draw_text_centered(draw, grid_start_y - 40, "Grid pattern (check for artifacts):", font_small, fill=0)
grid_x = (width - grid_size) // 2
for row in range(grid_size // cell_size):
for col in range(grid_size // cell_size):
x = grid_x + col * cell_size
y = grid_start_y + row * cell_size
if (row + col) % 2 == 0:
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=0)
else:
draw.rectangle([x, y, x + cell_size, y + cell_size], fill=200)
# Size indicator bars
y = grid_start_y + grid_size + 60
draw_text_centered(draw, y, "Width markers (should fit on screen):", font_small, fill=0)
bar_y = y + 40
# Full width bar
draw.rectangle([50, bar_y, width - 50, bar_y + 30], fill=0)
draw.text((60, bar_y + 5), "FULL WIDTH", font=font_small, fill=255)
# Half width bar
bar_y += 60
half_start = width // 4
draw.rectangle([half_start, bar_y, width - half_start, bar_y + 30], fill=85)
draw.text((half_start + 10, bar_y + 5), "HALF WIDTH", font=font_small, fill=255)
# Instructions
y = height - 350
draw_text_centered(draw, y, "VERIFICATION:", font_medium, fill=0)
y += 50
instructions = [
"1. Image fits within screen bounds",
"2. All borders visible (not cropped)",
"3. Grid pattern clear (no moire)",
"4. Text readable after scaling",
"5. Aspect ratio preserved (not stretched)",
]
for i, text in enumerate(instructions):
draw_text_centered(draw, y + i * 35, text, font_small, fill=64)
y = height - 100
draw_text_centered(draw, y, "PASS: Scaled down, readable, complete", font_small, fill=0)
draw_text_centered(draw, y + 30, "FAIL: Cropped, distorted, or unreadable", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_cache_test_image(filename, page_num, is_png=True):
"""
Create image for cache performance testing.
"""
width, height = 400, 300
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(18)
font_small = get_font(14)
font_large = get_font(36)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=2)
# Page number prominent
draw_text_centered(draw, 30, f"CACHE TEST PAGE {page_num}", font, fill=0)
draw_text_centered(draw, 80, f"#{page_num}", font_large, fill=0)
# Instructions
y = 140
draw_text_centered(draw, y, "Navigate away then return", font_small, fill=64)
draw_text_centered(draw, y + 25, "Second load should be faster", font_small, fill=64)
y = 220
draw_text_centered(draw, y, "PASS: Faster reload from cache", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Same slow decode each time", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_gradient_test_image(filename, is_png=True):
"""
Create horizontal gradient to test grayscale banding.
"""
width, height = 400, 500
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(16)
font_small = get_font(14)
draw_text_centered(draw, 10, "GRADIENT TEST", font, fill=0)
draw_text_centered(draw, 35, "Smooth gradient → 4 bands expected", font_small, fill=64)
# Horizontal gradient
gradient_y = 70
gradient_height = 100
for x in range(width):
gray = int(255 * x / width)
draw.line([(x, gradient_y), (x, gradient_y + gradient_height)], fill=gray)
# Border around gradient
draw.rectangle([0, gradient_y-1, width-1, gradient_y + gradient_height + 1], outline=0, width=1)
# Labels
y = gradient_y + gradient_height + 10
draw.text((5, y), "BLACK", font=font_small, fill=0)
draw.text((width - 50, y), "WHITE", font=font_small, fill=0)
# 4-step gradient (what it should look like)
y = 220
draw_text_centered(draw, y, "Expected result (4 distinct bands):", font_small, fill=0)
band_y = y + 25
band_height = 60
band_width = width // 4
for i, gray in enumerate([0, 85, 170, 255]):
x = i * band_width
draw.rectangle([x, band_y, x + band_width, band_y + band_height], fill=gray)
draw.rectangle([0, band_y-1, width-1, band_y + band_height + 1], outline=0, width=1)
# Vertical gradient
y = 340
draw_text_centered(draw, y, "Vertical gradient:", font_small, fill=0)
vgrad_y = y + 25
vgrad_height = 80
for row in range(vgrad_height):
gray = int(255 * row / vgrad_height)
draw.line([(50, vgrad_y + row), (width - 50, vgrad_y + row)], fill=gray)
draw.rectangle([49, vgrad_y-1, width-49, vgrad_y + vgrad_height + 1], outline=0, width=1)
# Pass/fail
y = height - 50
draw_text_centered(draw, y, "PASS: Clear 4-band quantization", font_small, fill=0)
draw_text_centered(draw, y + 20, "FAIL: Binary/noisy dithering", font_small, fill=64)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_format_test_image(filename, format_name, is_png=True):
"""
Create simple image to verify format support.
"""
width, height = 350, 250
img = Image.new('L', (width, height), 255)
draw = ImageDraw.Draw(img)
font = get_font(20)
font_large = get_font(36)
font_small = get_font(14)
# Border
draw.rectangle([0, 0, width-1, height-1], outline=0, width=3)
# Format name
draw_text_centered(draw, 30, f"{format_name} FORMAT TEST", font, fill=0)
draw_text_centered(draw, 80, format_name, font_large, fill=0)
# Checkmark area
y = 140
draw_text_centered(draw, y, "If you can read this,", font_small, fill=64)
draw_text_centered(draw, y + 20, f"{format_name} decoding works!", font_small, fill=64)
y = height - 40
draw_text_centered(draw, y, f"PASS: {format_name} image visible", font_small, fill=0)
if is_png:
img.save(filename, 'PNG')
else:
img.save(filename, 'JPEG', quality=95)
def create_epub(epub_path, title, chapters):
"""
Create an EPUB file with the given chapters.
chapters: list of (chapter_title, html_content, images)
images: list of (image_filename, image_data)
"""
with zipfile.ZipFile(epub_path, 'w', zipfile.ZIP_DEFLATED) as epub:
# mimetype (must be first, uncompressed)
epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
# Container
container_xml = '''<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>'''
epub.writestr('META-INF/container.xml', container_xml)
# Collect all images and chapters
manifest_items = []
spine_items = []
# Add chapters and images
for i, (chapter_title, html_content, images) in enumerate(chapters):
chapter_id = f'chapter{i+1}'
chapter_file = f'chapter{i+1}.xhtml'
# Add images for this chapter
for img_filename, img_data in images:
media_type = 'image/png' if img_filename.endswith('.png') else 'image/jpeg'
manifest_items.append(f' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>')
epub.writestr(f'OEBPS/images/{img_filename}', img_data)
# Add chapter
manifest_items.append(f' <item id="{chapter_id}" href="{chapter_file}" media-type="application/xhtml+xml"/>')
spine_items.append(f' <itemref idref="{chapter_id}"/>')
epub.writestr(f'OEBPS/{chapter_file}', html_content)
# content.opf
content_opf = f'''<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">test-epub-{title.lower().replace(" ", "-")}</dc:identifier>
<dc:title>{title}</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
{chr(10).join(manifest_items)}
</manifest>
<spine>
{chr(10).join(spine_items)}
</spine>
</package>'''
epub.writestr('OEBPS/content.opf', content_opf)
# Navigation document
nav_items = '\n'.join([f' <li><a href="chapter{i+1}.xhtml">{chapters[i][0]}</a></li>'
for i in range(len(chapters))])
nav_xhtml = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc">
<h1>Contents</h1>
<ol>
{nav_items}
</ol>
</nav>
</body>
</html>'''
epub.writestr('OEBPS/nav.xhtml', nav_xhtml)
def make_chapter(title, body_content):
"""Create XHTML chapter content."""
return f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>{title}</title></head>
<body>
<h1>{title}</h1>
{body_content}
</body>
</html>'''
def main():
OUTPUT_DIR.mkdir(exist_ok=True)
# Temp directory for images
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
print("Generating test images...")
# Generate all test images
images = {}
# JPEG tests
create_grayscale_test_image(tmpdir / 'grayscale_test.jpg', is_png=False)
create_centering_test_image(tmpdir / 'centering_test.jpg', is_png=False)
create_scaling_test_image(tmpdir / 'scaling_test.jpg', is_png=False)
create_gradient_test_image(tmpdir / 'gradient_test.jpg', is_png=False)
create_format_test_image(tmpdir / 'jpeg_format.jpg', 'JPEG', is_png=False)
create_cache_test_image(tmpdir / 'cache_test_1.jpg', 1, is_png=False)
create_cache_test_image(tmpdir / 'cache_test_2.jpg', 2, is_png=False)
# PNG tests
create_grayscale_test_image(tmpdir / 'grayscale_test.png', is_png=True)
create_centering_test_image(tmpdir / 'centering_test.png', is_png=True)
create_scaling_test_image(tmpdir / 'scaling_test.png', is_png=True)
create_gradient_test_image(tmpdir / 'gradient_test.png', is_png=True)
create_format_test_image(tmpdir / 'png_format.png', 'PNG', is_png=True)
create_cache_test_image(tmpdir / 'cache_test_1.png', 1, is_png=True)
create_cache_test_image(tmpdir / 'cache_test_2.png', 2, is_png=True)
# Read all images
for img_file in tmpdir.glob('*.*'):
images[img_file.name] = img_file.read_bytes()
print("Creating JPEG test EPUB...")
jpeg_chapters = [
("Introduction", make_chapter("JPEG Image Tests", """
<p>This EPUB tests JPEG image rendering.</p>
<p>Navigate through chapters to verify each test case.</p>
<p><strong>Test Plan:</strong></p>
<ul>
<li>Grayscale rendering (4 levels)</li>
<li>Image centering</li>
<li>Large image scaling</li>
<li>Cache performance</li>
</ul>
"""), []),
("1. JPEG Format", make_chapter("JPEG Format Test", """
<p>Basic JPEG decoding test.</p>
<img src="images/jpeg_format.jpg" alt="JPEG format test"/>
<p>If the image above is visible, JPEG decoding works.</p>
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
("2. Grayscale", make_chapter("Grayscale Test", """
<p>Verify 4 distinct gray levels are visible.</p>
<img src="images/grayscale_test.jpg" alt="Grayscale test"/>
"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]),
("3. Gradient", make_chapter("Gradient Test", """
<p>Verify gradient quantizes to 4 bands.</p>
<img src="images/gradient_test.jpg" alt="Gradient test"/>
"""), [('gradient_test.jpg', images['gradient_test.jpg'])]),
("4. Centering", make_chapter("Centering Test", """
<p>Verify image is centered horizontally.</p>
<img src="images/centering_test.jpg" alt="Centering test"/>
"""), [('centering_test.jpg', images['centering_test.jpg'])]),
("5. Scaling", make_chapter("Scaling Test", """
<p>This image is 1200x1500 pixels - larger than the screen.</p>
<p>It should be scaled down to fit.</p>
<img src="images/scaling_test.jpg" alt="Scaling test"/>
"""), [('scaling_test.jpg', images['scaling_test.jpg'])]),
("6. Cache Test A", make_chapter("Cache Test - Page A", """
<p>First cache test page. Note the load time.</p>
<img src="images/cache_test_1.jpg" alt="Cache test 1"/>
<p>Navigate to next page, then come back.</p>
"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]),
("7. Cache Test B", make_chapter("Cache Test - Page B", """
<p>Second cache test page.</p>
<img src="images/cache_test_2.jpg" alt="Cache test 2"/>
<p>Navigate back to Page A - it should load faster from cache.</p>
"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]),
]
create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters)
print("Creating PNG test EPUB...")
png_chapters = [
("Introduction", make_chapter("PNG Image Tests", """
<p>This EPUB tests PNG image rendering.</p>
<p>Navigate through chapters to verify each test case.</p>
<p><strong>Test Plan:</strong></p>
<ul>
<li>PNG decoding (no crash)</li>
<li>Grayscale rendering (4 levels)</li>
<li>Image centering</li>
<li>Large image scaling</li>
</ul>
"""), []),
("1. PNG Format", make_chapter("PNG Format Test", """
<p>Basic PNG decoding test.</p>
<img src="images/png_format.png" alt="PNG format test"/>
<p>If the image above is visible and no crash occurred, PNG decoding works.</p>
"""), [('png_format.png', images['png_format.png'])]),
("2. Grayscale", make_chapter("Grayscale Test", """
<p>Verify 4 distinct gray levels are visible.</p>
<img src="images/grayscale_test.png" alt="Grayscale test"/>
"""), [('grayscale_test.png', images['grayscale_test.png'])]),
("3. Gradient", make_chapter("Gradient Test", """
<p>Verify gradient quantizes to 4 bands.</p>
<img src="images/gradient_test.png" alt="Gradient test"/>
"""), [('gradient_test.png', images['gradient_test.png'])]),
("4. Centering", make_chapter("Centering Test", """
<p>Verify image is centered horizontally.</p>
<img src="images/centering_test.png" alt="Centering test"/>
"""), [('centering_test.png', images['centering_test.png'])]),
("5. Scaling", make_chapter("Scaling Test", """
<p>This image is 1200x1500 pixels - larger than the screen.</p>
<p>It should be scaled down to fit.</p>
<img src="images/scaling_test.png" alt="Scaling test"/>
"""), [('scaling_test.png', images['scaling_test.png'])]),
("6. Cache Test A", make_chapter("Cache Test - Page A", """
<p>First cache test page. Note the load time.</p>
<img src="images/cache_test_1.png" alt="Cache test 1"/>
<p>Navigate to next page, then come back.</p>
"""), [('cache_test_1.png', images['cache_test_1.png'])]),
("7. Cache Test B", make_chapter("Cache Test - Page B", """
<p>Second cache test page.</p>
<img src="images/cache_test_2.png" alt="Cache test 2"/>
<p>Navigate back to Page A - it should load faster from cache.</p>
"""), [('cache_test_2.png', images['cache_test_2.png'])]),
]
create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters)
print("Creating mixed format test EPUB...")
mixed_chapters = [
("Introduction", make_chapter("Mixed Image Format Tests", """
<p>This EPUB contains both JPEG and PNG images.</p>
<p>Tests format detection and mixed rendering.</p>
"""), []),
("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """
<p>This is a JPEG image:</p>
<img src="images/jpeg_format.jpg" alt="JPEG"/>
"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]),
("2. PNG Image", make_chapter("PNG in Mixed EPUB", """
<p>This is a PNG image:</p>
<img src="images/png_format.png" alt="PNG"/>
"""), [('png_format.png', images['png_format.png'])]),
("3. Both Formats", make_chapter("Both Formats on One Page", """
<p>JPEG image:</p>
<img src="images/grayscale_test.jpg" alt="JPEG grayscale"/>
<p>PNG image:</p>
<img src="images/grayscale_test.png" alt="PNG grayscale"/>
<p>Both should render with proper grayscale.</p>
"""), [('grayscale_test.jpg', images['grayscale_test.jpg']),
('grayscale_test.png', images['grayscale_test.png'])]),
]
create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters)
print(f"\nTest EPUBs created in: {OUTPUT_DIR}")
print("Files:")
for f in OUTPUT_DIR.glob('*.epub'):
print(f" - {f.name}")
if __name__ == '__main__':
main()

View File

@ -42,6 +42,38 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
}
ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2
renderer.fillRect(x, y, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return {x, y, w, h};
}
void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) {
constexpr int barHeight = 4;
const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - 10;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,

View File

@ -15,9 +15,20 @@ class ScreenComponents {
public:
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
struct PopupLayout {
int x;
int y;
int width;
int height;
};
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message);
static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress);
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);

View File

@ -8,13 +8,15 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "ScreenComponents.h"
#include "fontIds.h"
#include "images/CrossLarge.h"
#include "util/StringUtils.h"
void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep...");
ScreenComponents::drawPopup(renderer, "Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen();
@ -31,20 +33,6 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SdMan.open("/sleep");

View File

@ -10,7 +10,6 @@ class SleepActivity final : public Activity {
void onEnter() override;
private:
void renderPopup(const char* message) const;
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;

View File

@ -266,9 +266,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
}
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
connectionError = "Connection failed";
connectionError = "Error: General failure";
if (status == WL_NO_SSID_AVAIL) {
connectionError = "Network not found";
connectionError = "Error: Network not found";
}
state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true;
@ -278,7 +278,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
// Check for timeout
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
WiFi.disconnect();
connectionError = "Connection timeout";
connectionError = "Error: Connection timeout";
state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true;
return;
@ -689,7 +689,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
@ -697,7 +697,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
}
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?");
// Draw Cancel/Forget network buttons
const int buttonY = top + 80;

View File

@ -130,31 +130,9 @@ void EpubReaderActivity::loop() {
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
// Handle sync position
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex);
}
@ -242,6 +220,89 @@ void EpubReaderActivity::loop() {
}
}
void EpubReaderActivity::onReaderMenuBack() {
exitActivity();
updateRequired = true;
}
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
const int totalP = section ? section->pageCount : 0;
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// 1. Close the menu
exitActivity();
// 2. Open the Chapter Selector
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// 2. Trigger the reader's "Go Home" callback
if (onGoHome) {
onGoHome();
}
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (epub) {
// 2. BACKUP: Read current progress
// We use the current variables that track our position
uint16_t backupSpine = currentSpineIndex;
uint16_t backupPage = section->currentPage;
uint16_t backupPageCount = section->pageCount;
section.reset();
// 3. WIPE: Clear the cache directory
epub->clearCache();
// 4. RESTORE: Re-setup the directory and rewrite the progress file
epub->setupCacheDir();
saveProgress(backupSpine, backupPage, backupPageCount);
}
exitActivity();
updateRequired = true;
xSemaphoreGive(renderingMutex);
if (onGoHome) onGoHome();
break;
}
}
}
void EpubReaderActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
@ -308,49 +369,11 @@ void EpubReaderActivity::renderScreen() {
viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
renderer.displayBuffer();
pagesUntilFullRefresh = 0;
}
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
};
const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) {
viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset();
return;
@ -407,21 +430,26 @@ void EpubReaderActivity::renderScreen() {
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
}
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
}
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
data[4] = section->pageCount & 0xFF;
data[5] = (section->pageCount >> 8) & 0xFF;
data[2] = currentPage & 0xFF;
data[3] = (currentPage >> 8) & 0xFF;
data[4] = pageCount & 0xFF;
data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6);
f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
} else {
Serial.printf("[ERS] Could not save progress!\n");
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {

View File

@ -5,6 +5,7 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "EpubReaderMenuActivity.h"
#include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public ActivityWithSubactivity {
@ -27,6 +28,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
void saveProgress(int spineIndex, int currentPage, int pageCount);
void onReaderMenuBack();
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,

View File

@ -181,9 +181,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const std::string title =
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
@ -208,8 +206,11 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
}
}
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Skip button hints in landscape CW mode (they overlap content)
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@ -0,0 +1,103 @@
#include "EpubReaderMenuActivity.h"
#include <GfxRenderer.h>
#include "fontIds.h"
void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle);
}
void EpubReaderMenuActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderMenuActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderMenuActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderMenuActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderMenuActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// 1. Capture the callback and action locally
auto actionCallback = onAction;
auto selectedAction = menuItems[selectedIndex].action;
// 2. Execute the callback
actionCallback(selectedAction);
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return; // Also return here just in case
}
}
void EpubReaderMenuActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
// Title
const std::string truncTitle =
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD);
// Menu Items
constexpr int startY = 60;
constexpr int lineHeight = 30;
for (size_t i = 0; i < menuItems.size(); ++i) {
const int displayY = startY + (i * lineHeight);
const bool isSelected = (static_cast<int>(i) == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true);
}
renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected);
}
// Footer / Hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,51 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "MappedInputManager.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE };
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
title(title),
onBack(onBack),
onAction(onAction) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
struct MenuItem {
MenuAction action;
std::string label;
};
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}};
int selectedIndex = 0;
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string title = "Reader Menu";
const std::function<void()> onBack;
const std::function<void(MenuAction)> onAction;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
};

View File

@ -207,28 +207,10 @@ void TxtReaderActivity::buildPageIndex() {
size_t offset = 0;
const size_t fileSize = txt->getFileSize();
int lastProgressPercent = -1;
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
// Progress bar dimensions (matching EpubReaderActivity style)
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
constexpr int boxY = 50;
const int barX = boxX + (boxWidth - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Draw initial progress box
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
ScreenComponents::drawPopup(renderer, "Indexing...");
while (offset < fileSize) {
std::vector<std::string> tempLines;
@ -248,17 +230,6 @@ void TxtReaderActivity::buildPageIndex() {
pageOffsets.push_back(offset);
}
// Update progress bar every 10% (matching EpubReaderActivity logic)
int progressPercent = (offset * 100) / fileSize;
if (lastProgressPercent / 10 != progressPercent / 10) {
lastProgressPercent = progressPercent;
// Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
// Yield to other tasks periodically
if (pageOffsets.size() % 20 == 0) {
vTaskDelay(1);
@ -402,9 +373,6 @@ void TxtReaderActivity::renderScreen() {
// Initialize reader if not done
if (!initialized) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
initializeReader();
}

View File

@ -149,8 +149,11 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
}
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Skip button hints in landscape CW mode (they overlap content)
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@ -294,10 +294,22 @@ void setup() {
SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile();
if (gpio.isWakeupByPowerButton()) {
// For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration();
switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton:
// For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration();
break;
case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis());
gpio.startDeepSleep();
break;
case HalGPIO::WakeupReason::AfterFlash:
// After flashing, just proceed to boot
case HalGPIO::WakeupReason::Other:
default:
break;
}
// First serial output only here to avoid timing inconsistencies for power button press duration verification
@ -317,7 +329,6 @@ void setup() {
// Clear app state to avoid getting into a boot loop if the epub doesn't load
const auto path = APP_STATE.openEpubPath;
APP_STATE.openEpubPath = "";
APP_STATE.lastSleepImage = 0;
APP_STATE.saveToFile();
onGoToReader(path, MyLibraryActivity::Tab::Recent);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.